diff --git a/package.json b/package.json index 4444b7c8fe..b7b0bdc24b 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "packages/sdk/react-universal", "packages/sdk/react-universal/example", "packages/sdk/vercel", + "packages/sdk/svelte", "packages/sdk/akamai-base", "packages/sdk/akamai-base/example", "packages/sdk/akamai-edgekv", diff --git a/packages/sdk/svelte/.gitignore b/packages/sdk/svelte/.gitignore new file mode 100644 index 0000000000..5396c65c3c --- /dev/null +++ b/packages/sdk/svelte/.gitignore @@ -0,0 +1,13 @@ +.DS_Store +node_modules +/build +/dist +/.svelte-kit +/package +.env +.env.* +!.env.example +vite.config.js.timestamp-* + +# Playwright +/test-results \ No newline at end of file diff --git a/packages/sdk/svelte/__tests__/lib/client/SvelteLDClient.test.ts b/packages/sdk/svelte/__tests__/lib/client/SvelteLDClient.test.ts new file mode 100644 index 0000000000..ea689d6eda --- /dev/null +++ b/packages/sdk/svelte/__tests__/lib/client/SvelteLDClient.test.ts @@ -0,0 +1,215 @@ +import { EventEmitter } from 'node:events'; +import { get } from 'svelte/store'; +import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; + +import { initialize, LDClient } from '@launchdarkly/js-client-sdk/compat'; + +import { LD } from '../../../src/lib/client/SvelteLDClient'; + +vi.mock('@launchdarkly/js-client-sdk/compat', { spy: true }); + +const clientSideID = 'test-client-side-id'; +const rawFlags = { 'test-flag': true, 'another-test-flag': 'flag-value' }; +const mockContext = { key: 'user1' }; + +// used to mock ready and change events on the LDClient +const mockLDEventEmitter = new EventEmitter(); + +const mockLDClient = { + on: (e: string, cb: () => void) => mockLDEventEmitter.on(e, cb), + off: vi.fn(), + allFlags: vi.fn().mockReturnValue(rawFlags), + variation: vi.fn((_, defaultValue) => defaultValue), + identify: vi.fn(), +}; + +describe('launchDarkly', () => { + describe('createLD', () => { + it('should create a LaunchDarkly instance with correct properties', () => { + const ld = LD; + expect(typeof ld).toBe('object'); + expect(ld).toHaveProperty('identify'); + expect(ld).toHaveProperty('flags'); + expect(ld).toHaveProperty('initialize'); + expect(ld).toHaveProperty('initializing'); + expect(ld).toHaveProperty('watch'); + expect(ld).toHaveProperty('useFlag'); + }); + + describe('initialize', async () => { + const ld = LD; + + beforeEach(() => { + // mocks the initialize function to return the mockLDClient + (initialize as Mock<typeof initialize>).mockReturnValue( + mockLDClient as unknown as LDClient, + ); + }); + + afterEach(() => { + vi.clearAllMocks(); + mockLDEventEmitter.removeAllListeners(); + }); + + it('should throw an error if the client is not initialized', async () => { + const flagKey = 'test-flag'; + const user = { key: 'user1' }; + + expect(() => ld.useFlag(flagKey, true)).toThrow('LaunchDarkly client not initialized'); + await expect(() => ld.identify(user)).rejects.toThrow( + 'LaunchDarkly client not initialized', + ); + }); + + it('should set the loading status to false when the client is ready', async () => { + const { initializing } = ld; + ld.initialize(clientSideID, mockContext); + + expect(get(initializing)).toBe(true); // should be true before the ready event is emitted + mockLDEventEmitter.emit('ready'); + + expect(get(initializing)).toBe(false); + }); + + it('should initialize the LaunchDarkly SDK instance', () => { + ld.initialize(clientSideID, mockContext); + + expect(initialize).toHaveBeenCalledWith('test-client-side-id', mockContext); + }); + + it('should register function that gets flag values when client is ready', () => { + const newFlags = { ...rawFlags, 'new-flag': true }; + const allFlagsSpy = vi.spyOn(mockLDClient, 'allFlags').mockReturnValue(newFlags); + + ld.initialize(clientSideID, mockContext); + mockLDEventEmitter.emit('ready'); + + expect(allFlagsSpy).toHaveBeenCalledOnce(); + expect(allFlagsSpy).toHaveReturnedWith(newFlags); + }); + + it('should register function that gets flag values when flags changed', () => { + const changedFlags = { ...rawFlags, 'changed-flag': true }; + const allFlagsSpy = vi.spyOn(mockLDClient, 'allFlags').mockReturnValue(changedFlags); + + ld.initialize(clientSideID, mockContext); + mockLDEventEmitter.emit('change'); + + expect(allFlagsSpy).toHaveBeenCalledOnce(); + expect(allFlagsSpy).toHaveReturnedWith(changedFlags); + }); + }); + + describe('watch function', () => { + const ld = LD; + + beforeEach(() => { + // mocks the initialize function to return the mockLDClient + (initialize as Mock<typeof initialize>).mockReturnValue( + mockLDClient as unknown as LDClient, + ); + }); + + afterEach(() => { + vi.clearAllMocks(); + mockLDEventEmitter.removeAllListeners(); + }); + + it('should return a derived store that reflects the value of the specified flag', () => { + const flagKey = 'test-flag'; + ld.initialize(clientSideID, mockContext); + + const flagStore = ld.watch(flagKey); + + expect(get(flagStore)).toBe(true); + }); + + it('should update the flag store when the flag value changes', () => { + const booleanFlagKey = 'test-flag'; + const stringFlagKey = 'another-test-flag'; + ld.initialize(clientSideID, mockContext); + const flagStore = ld.watch(booleanFlagKey); + const flagStore2 = ld.watch(stringFlagKey); + + // emit ready event to set initial flag values + mockLDEventEmitter.emit('ready'); + + // 'test-flag' initial value is true according to `rawFlags` + expect(get(flagStore)).toBe(true); + // 'another-test-flag' intial value is 'flag-value' according to `rawFlags` + expect(get(flagStore2)).toBe('flag-value'); + + mockLDClient.allFlags.mockReturnValue({ + ...rawFlags, + 'test-flag': false, + 'another-test-flag': 'new-flag-value', + }); + + // dispatch a change event on ldClient + mockLDEventEmitter.emit('change'); + + expect(get(flagStore)).toBe(false); + expect(get(flagStore2)).toBe('new-flag-value'); + }); + + it('should return undefined if the flag is not found', () => { + const flagKey = 'non-existent-flag'; + ld.initialize(clientSideID, mockContext); + + const flagStore = ld.watch(flagKey); + + expect(get(flagStore)).toBeUndefined(); + }); + }); + + describe('useFlag function', () => { + const ld = LD; + + beforeEach(() => { + // mocks the initialize function to return the mockLDClient + (initialize as Mock<typeof initialize>).mockReturnValue( + mockLDClient as unknown as LDClient, + ); + }); + + afterEach(() => { + vi.clearAllMocks(); + mockLDEventEmitter.removeAllListeners(); + }); + + it('should return flag value', () => { + mockLDClient.variation.mockReturnValue(true); + const flagKey = 'test-flag'; + ld.initialize(clientSideID, mockContext); + + expect(ld.useFlag(flagKey, false)).toBe(true); + expect(mockLDClient.variation).toHaveBeenCalledWith(flagKey, false); + }); + }); + + describe('identify function', () => { + const ld = LD; + + beforeEach(() => { + // mocks the initialize function to return the mockLDClient + (initialize as Mock<typeof initialize>).mockReturnValue( + mockLDClient as unknown as LDClient, + ); + }); + + afterEach(() => { + vi.clearAllMocks(); + mockLDEventEmitter.removeAllListeners(); + }); + + it('should call the identify method on the LaunchDarkly client', () => { + const user = { key: 'user1' }; + ld.initialize(clientSideID, user); + + ld.identify(user); + + expect(mockLDClient.identify).toHaveBeenCalledWith(user); + }); + }); + }); +}); diff --git a/packages/sdk/svelte/package.json b/packages/sdk/svelte/package.json new file mode 100644 index 0000000000..f00be2461a --- /dev/null +++ b/packages/sdk/svelte/package.json @@ -0,0 +1,89 @@ +{ + "name": "@launchdarkly/svelte-client-sdk", + "version": "0.1.0", + "description": "Svelte LaunchDarkly SDK", + "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/sdk/svelte", + "repository": { + "type": "git", + "url": "https://github.com/launchdarkly/js-core.git" + }, + "license": "Apache-2.0", + "packageManager": "yarn@3.4.1", + "keywords": [ + "launchdarkly", + "svelte" + ], + "type": "module", + "svelte": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "svelte": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "!dist/**/*.test.*", + "!dist/**/*.spec.*" + ], + "scripts": { + "clean": "rimraf dist", + "dev": "vite dev", + "build": "vite build && npm run package", + "preview": "vite preview", + "package": "svelte-kit sync && svelte-package && publint", + "prepublishOnly": "npm run package", + "lint": "eslint . --ext .ts,.tsx", + "prettier": "prettier --write '**/*.@(js|ts|tsx|json|css)' --ignore-path ../../../.prettierignore", + "check": "yarn prettier && yarn lint && yarn build && yarn test", + "test": "playwright test", + "test:unit": "vitest", + "test:unit-ui": "vitest --ui", + "test:unit-coverage": "vitest --coverage" + }, + "peerDependencies": { + "@launchdarkly/js-client-sdk": "workspace:^", + "svelte": "^4.0.0" + }, + "dependencies": { + "@launchdarkly/js-client-sdk": "workspace:^", + "esm-env": "^1.0.0" + }, + "devDependencies": { + "@playwright/test": "^1.28.1", + "@sveltejs/adapter-auto": "^3.0.0", + "@sveltejs/kit": "^2.0.0", + "@sveltejs/package": "^2.0.0", + "@sveltejs/vite-plugin-svelte": "^5.0.1", + "@testing-library/svelte": "^5.2.0", + "@types/jest": "^29.5.11", + "@typescript-eslint/eslint-plugin": "^6.20.0", + "@typescript-eslint/parser": "^6.20.0", + "@vitest/coverage-v8": "^2.1.8", + "@vitest/ui": "^2.1.8", + "eslint": "^8.45.0", + "eslint-config-airbnb-base": "^15.0.0", + "eslint-config-airbnb-typescript": "^17.1.0", + "eslint-config-prettier": "^8.8.0", + "eslint-plugin-import": "^2.27.5", + "eslint-plugin-jest": "^27.6.3", + "eslint-plugin-prettier": "^5.0.0", + "eslint-plugin-svelte": "^2.35.1", + "jsdom": "^24.0.0", + "launchdarkly-js-test-helpers": "^2.2.0", + "prettier": "^3.0.0", + "prettier-plugin-svelte": "^3.1.2", + "publint": "^0.1.9", + "rimraf": "^5.0.5", + "svelte": "^5.4.0", + "svelte-check": "^3.6.0", + "ts-jest": "^29.1.1", + "ts-node": "^10.9.2", + "typedoc": "0.25.0", + "typescript": "5.1.6", + "vite": "^6.0.2", + "vitest": "^2.1.8" + } +} diff --git a/packages/sdk/svelte/playwright.config.ts b/packages/sdk/svelte/playwright.config.ts new file mode 100644 index 0000000000..1c5d7a1fd3 --- /dev/null +++ b/packages/sdk/svelte/playwright.config.ts @@ -0,0 +1,12 @@ +import type { PlaywrightTestConfig } from '@playwright/test'; + +const config: PlaywrightTestConfig = { + webServer: { + command: 'npm run build && npm run preview', + port: 4173 + }, + testDir: 'tests', + testMatch: /(.+\.)?(test|spec)\.[jt]s/ +}; + +export default config; diff --git a/packages/sdk/svelte/src/app.d.ts b/packages/sdk/svelte/src/app.d.ts new file mode 100644 index 0000000000..ede601ab93 --- /dev/null +++ b/packages/sdk/svelte/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://kit.svelte.dev/docs/types#app +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/packages/sdk/svelte/src/app.html b/packages/sdk/svelte/src/app.html new file mode 100644 index 0000000000..f90b0a64e4 --- /dev/null +++ b/packages/sdk/svelte/src/app.html @@ -0,0 +1,11 @@ +<!doctype html> +<html lang="en"> +<head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + %sveltekit.head% +</head> +<body data-sveltekit-preload-data="hover"> +<div>%sveltekit.body%</div> +</body> +</html> diff --git a/packages/sdk/svelte/src/lib/LDFlag.svelte b/packages/sdk/svelte/src/lib/LDFlag.svelte new file mode 100644 index 0000000000..f0d4bfe20e --- /dev/null +++ b/packages/sdk/svelte/src/lib/LDFlag.svelte @@ -0,0 +1,14 @@ +<script lang="ts"> + import { LD, type LDFlagValue } from './client/SvelteLDClient.js'; + + export let flag: string; + export let matches: LDFlagValue = true; + + $: flagValue = LD.watch(flag); + </script> + + {#if $flagValue === matches} + <slot name="true" /> + {:else} + <slot name="false" /> + {/if} diff --git a/packages/sdk/svelte/src/lib/client/SvelteLDClient.ts b/packages/sdk/svelte/src/lib/client/SvelteLDClient.ts new file mode 100644 index 0000000000..21fc32ffb5 --- /dev/null +++ b/packages/sdk/svelte/src/lib/client/SvelteLDClient.ts @@ -0,0 +1,136 @@ +import { derived, type Readable, readonly, writable, type Writable } from 'svelte/store'; + +import type { LDFlagSet } from '@launchdarkly/js-client-sdk'; +import { + initialize, + type LDClient, + type LDContext, + type LDFlagValue, +} from '@launchdarkly/js-client-sdk/compat'; + +export type { LDContext, LDFlagValue }; + +/** Client ID for LaunchDarkly */ +export type LDClientID = string; + +/** Flags for LaunchDarkly */ +export type LDFlags = LDFlagSet; + +/** + * Checks if the LaunchDarkly client is initialized. + * @param {LDClient | undefined} client - The LaunchDarkly client. + * @throws {Error} If the client is not initialized. + */ +function isClientInitialized(client: LDClient | undefined): asserts client is LDClient { + if (!client) { + throw new Error('LaunchDarkly client not initialized'); + } +} + +/** + * Creates a proxy for the given flags object that intercepts access to flag values. + * When a flag value is accessed, it checks if the flag key exists in the target object. + * If the flag key exists, it returns the variation of the flag from the client. + * Otherwise, it returns the current value of the flag. + * + * @param client - The LaunchDarkly client instance used to get flag variations. + * @param flags - The initial flags object to be proxied. + * @returns A proxy object that intercepts access to flag values and returns the appropriate variation. + */ +function toFlagsProxy(client: LDClient, flags: LDFlags): LDFlags { + return new Proxy(flags, { + get(target, prop, receiver) { + const currentValue = Reflect.get(target, prop, receiver); + // only process flag keys and ignore symbols and native Object functions + if (typeof prop === 'symbol') { + return currentValue; + } + + // check if flag key exists + const validFlagKey = Object.hasOwn(target, prop); + + if (!validFlagKey) { + return currentValue; + } + + return client.variation(prop, currentValue); + }, + }); +} + +/** + * Creates a LaunchDarkly instance. + * @returns {Object} The LaunchDarkly instance object. + */ +function createLD() { + let coreLdClient: LDClient | undefined; + const loading = writable(true); + const flagsWritable = writable<LDFlags>({}); + + /** + * Initializes the LaunchDarkly client. + * @param {LDClientID} clientId - The client ID. + * @param {LDContext} context - The user context. + * @returns {Object} An object with the initialization status store. + */ + function LDInitialize(clientId: LDClientID, context: LDContext) { + coreLdClient = initialize(clientId, context); + coreLdClient!.on('ready', () => { + loading.set(false); + const rawFlags = coreLdClient!.allFlags(); + const allFlags = toFlagsProxy(coreLdClient!, rawFlags); + flagsWritable.set(allFlags); + }); + + coreLdClient!.on('change', () => { + const rawFlags = coreLdClient!.allFlags(); + const allFlags = toFlagsProxy(coreLdClient!, rawFlags); + flagsWritable.set(allFlags); + }); + + return { + initializing: loading, + }; + } + + /** + * Identifies the user context. + * @param {LDContext} context - The user context. + * @returns {Promise} A promise that resolves when the user is identified. + */ + async function identify(context: LDContext) { + isClientInitialized(coreLdClient); + return coreLdClient.identify(context); + } + + /** + * Watches a flag for changes. + * @param {string} flagKey - The key of the flag to watch. + * @returns {Readable<LDFlagsValue>} A readable store of the flag value. + */ + const watch = (flagKey: string): Readable<LDFlagValue> => + derived<Writable<LDFlags>, LDFlagValue>(flagsWritable, ($flags) => $flags[flagKey]); + + /** + * Gets the current value of a flag. + * @param {string} flagKey - The key of the flag to get. + * @param {TFlag} defaultValue - The default value of the flag. + * @returns {TFlag} The current value of the flag. + */ + function useFlag<TFlag extends LDFlagValue>(flagKey: string, defaultValue: TFlag): TFlag { + isClientInitialized(coreLdClient); + return coreLdClient.variation(flagKey, defaultValue); + } + + return { + identify, + flags: readonly(flagsWritable), + initialize: LDInitialize, + initializing: readonly(loading), + watch, + useFlag, + }; +} + +/** The LaunchDarkly instance */ +export const LD = createLD(); diff --git a/packages/sdk/svelte/src/lib/client/index.ts b/packages/sdk/svelte/src/lib/client/index.ts new file mode 100644 index 0000000000..64d6e6118a --- /dev/null +++ b/packages/sdk/svelte/src/lib/client/index.ts @@ -0,0 +1 @@ +export * from './SvelteLDClient'; diff --git a/packages/sdk/svelte/src/lib/index.ts b/packages/sdk/svelte/src/lib/index.ts new file mode 100644 index 0000000000..32c2c870bb --- /dev/null +++ b/packages/sdk/svelte/src/lib/index.ts @@ -0,0 +1,6 @@ +// Reexport your entry components here +export * as LDClient from './client/SvelteLDClient.js'; + +// Export Components +export { default as LDProvider } from './provider/LDProvider.svelte'; +export { default as LDFlag } from './LDFlag.svelte'; diff --git a/packages/sdk/svelte/src/lib/provider/LDProvider.svelte b/packages/sdk/svelte/src/lib/provider/LDProvider.svelte new file mode 100644 index 0000000000..d5a53be7fe --- /dev/null +++ b/packages/sdk/svelte/src/lib/provider/LDProvider.svelte @@ -0,0 +1,19 @@ +<script lang="ts"> + import { onMount } from 'svelte'; + import { LD } from '../client/SvelteLDClient.js'; + import type { LDClientID, LDContext } from '../client/SvelteLDClient.js'; + + export let clientID: LDClientID; + export let context: LDContext; + const { initialize, initializing } = LD; + + onMount(() => { + initialize(clientID, context); + }); +</script> + +{#if $$slots.initializing && $initializing} + <slot name="initializing">Loading flags (default loading slot value)...</slot> +{:else} + <slot /> +{/if} diff --git a/packages/sdk/svelte/svelte.config.js b/packages/sdk/svelte/svelte.config.js new file mode 100644 index 0000000000..734094d0b6 --- /dev/null +++ b/packages/sdk/svelte/svelte.config.js @@ -0,0 +1,18 @@ +import adapter from '@sveltejs/adapter-auto'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + // Consult https://kit.svelte.dev/docs/integrations#preprocessors + // for more information about preprocessors + preprocess: vitePreprocess(), + + kit: { + // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. + // If your environment is not supported or you settled on a specific environment, switch out the adapter. + // See https://kit.svelte.dev/docs/adapters for more information about adapters. + adapter: adapter() + } +}; + +export default config; diff --git a/packages/sdk/svelte/tsconfig.eslint.json b/packages/sdk/svelte/tsconfig.eslint.json new file mode 100644 index 0000000000..8241f86c36 --- /dev/null +++ b/packages/sdk/svelte/tsconfig.eslint.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["/**/*.ts", "/**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/packages/sdk/svelte/tsconfig.json b/packages/sdk/svelte/tsconfig.json new file mode 100644 index 0000000000..8ed3dd7f25 --- /dev/null +++ b/packages/sdk/svelte/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "module": "NodeNext", + "moduleResolution": "NodeNext" + } +} diff --git a/packages/sdk/svelte/tsconfig.ref.json b/packages/sdk/svelte/tsconfig.ref.json new file mode 100644 index 0000000000..34a1cb607a --- /dev/null +++ b/packages/sdk/svelte/tsconfig.ref.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*", "package.json"], + "compilerOptions": { + "composite": true + } +} diff --git a/packages/sdk/svelte/tsconfig.test.json b/packages/sdk/svelte/tsconfig.test.json new file mode 100644 index 0000000000..8d49b842cf --- /dev/null +++ b/packages/sdk/svelte/tsconfig.test.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "lib": ["es6", "dom"], + "module": "ES6", + "moduleResolution": "node", + "resolveJsonModule": true, + "rootDir": ".", + "strict": true, + "types": ["jest", "node"] + }, + "exclude": ["dist", "node_modules", "__tests__", "example"] +} diff --git a/packages/sdk/svelte/typedoc.json b/packages/sdk/svelte/typedoc.json new file mode 100644 index 0000000000..7ac616b544 --- /dev/null +++ b/packages/sdk/svelte/typedoc.json @@ -0,0 +1,5 @@ +{ + "extends": ["../../../typedoc.base.json"], + "entryPoints": ["src/index.ts"], + "out": "docs" +} diff --git a/packages/sdk/svelte/vite.config.ts b/packages/sdk/svelte/vite.config.ts new file mode 100644 index 0000000000..eef9c1da2c --- /dev/null +++ b/packages/sdk/svelte/vite.config.ts @@ -0,0 +1,17 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import path from 'path'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [sveltekit()], + resolve: { + alias: { + lib: path.resolve(__dirname, 'src/lib'), + }, + }, + test: { + include: ['__tests__/**/*.{test,spec}.{js,ts,svelte}'], + globals: true, + environment: 'jsdom', + }, +}); diff --git a/tsconfig.json b/tsconfig.json index 5110eb7541..2059110636 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -40,6 +40,9 @@ { "path": "./packages/sdk/akamai-base/tsconfig.ref.json" }, + { + "path": "./packages/sdk/svelte/tsconfig.ref.json" + }, { "path": "./packages/store/node-server-sdk-redis/tsconfig.ref.json" },