diff --git a/packages/sdk/vue/__tests__/client/LDVueClient.test.ts b/packages/sdk/vue/__tests__/client/LDVueClient.test.ts new file mode 100644 index 0000000000..170e5c3109 --- /dev/null +++ b/packages/sdk/vue/__tests__/client/LDVueClient.test.ts @@ -0,0 +1,107 @@ +import { createClient as createBaseClient } from '@launchdarkly/js-client-sdk'; + +import { createClient } from '../../src/client/LDVueClient'; + +jest.mock('@launchdarkly/js-client-sdk', () => ({ + createClient: jest.fn(), +})); + +const createBaseClientMock = createBaseClient as jest.Mock; + +type Result = { status: string; error?: Error }; + +const makeBaseClient = (overrides: Record = {}) => ({ + getContext: jest.fn(() => ({ kind: 'user', key: 'context-key' })), + start: jest.fn(() => Promise.resolve({ status: 'complete' })), + identify: jest.fn(() => Promise.resolve({ status: 'completed' })), + boolVariation: jest.fn(() => true), + on: jest.fn(), + off: jest.fn(), + close: jest.fn(), + ...overrides, +}); + +beforeEach(() => { + createBaseClientMock.mockReset(); +}); + +it('passes wrapper metadata to the base client', () => { + createBaseClientMock.mockReturnValue(makeBaseClient()); + + createClient('env-id', { kind: 'user', key: 'k' }); + + expect(createBaseClientMock).toHaveBeenCalledWith( + 'env-id', + { kind: 'user', key: 'k' }, + expect.objectContaining({ wrapperName: 'vue-client-sdk', wrapperVersion: expect.any(String) }), + ); +}); + +it('tracks initialization state through start()', async () => { + createBaseClientMock.mockReturnValue(makeBaseClient()); + const client = createClient('env-id', { kind: 'user', key: 'k' }); + + expect(client.getInitializationState()).toBe('initializing'); + expect(client.isReady()).toBe(false); + + await client.start(); + + expect(client.getInitializationState()).toBe('complete'); + expect(client.isReady()).toBe(true); + expect(client.getInitializationError()).toBeUndefined(); +}); + +it('notifies init-status subscribers and replays the cached result to late subscribers', async () => { + createBaseClientMock.mockReturnValue(makeBaseClient()); + const client = createClient('env-id', { kind: 'user', key: 'k' }); + + const early = jest.fn(); + client.onInitializationStatusChange(early); + + await client.start(); + + expect(early).toHaveBeenCalledWith({ status: 'complete' }); + + const late = jest.fn(); + client.onInitializationStatusChange(late); + expect(late).toHaveBeenCalledWith({ status: 'complete' }); +}); + +it('exposes the initialization error when start fails', async () => { + const error = new Error('boom'); + createBaseClientMock.mockReturnValue( + makeBaseClient({ start: jest.fn(() => Promise.resolve({ status: 'failed', error })) }), + ); + const client = createClient('env-id', { kind: 'user', key: 'k' }); + + await client.start(); + + expect(client.getInitializationState()).toBe('failed'); + expect(client.getInitializationError()).toBe(error); +}); + +it('notifies context subscribers after a successful identify', async () => { + createBaseClientMock.mockReturnValue(makeBaseClient()); + const client = createClient('env-id', { kind: 'user', key: 'k' }); + + const onContext = jest.fn(); + client.onContextChange(onContext); + + await client.identify({ kind: 'user', key: 'new-key' }); + + expect(onContext).toHaveBeenCalledWith({ kind: 'user', key: 'context-key' }); +}); + +it('does not notify context subscribers when identify does not complete', async () => { + createBaseClientMock.mockReturnValue( + makeBaseClient({ identify: jest.fn(() => Promise.resolve({ status: 'error', error: new Error('x') })) }), + ); + const client = createClient('env-id', { kind: 'user', key: 'k' }); + + const onContext = jest.fn(); + client.onContextChange(onContext); + + await client.identify({ kind: 'user', key: 'new-key' }); + + expect(onContext).not.toHaveBeenCalled(); +}); diff --git a/packages/sdk/vue/package.json b/packages/sdk/vue/package.json index bb4a69bc81..c2a20a789b 100644 --- a/packages/sdk/vue/package.json +++ b/packages/sdk/vue/package.json @@ -32,22 +32,27 @@ "clean": "rimraf dist", "build": "tsup", "lint": "eslint .", - "test": "npx jest --ci --passWithNoTests", + "test": "npx jest --ci", "coverage": "yarn test --coverage", "check": "yarn lint && yarn build && yarn test" }, + "dependencies": { + "@launchdarkly/js-client-sdk": "workspace:^" + }, "peerDependencies": { "vue": "^3.3.0" }, "devDependencies": { "@eslint/js": "^9.0.0", "@types/jest": "^29.5.12", + "@vue/test-utils": "^2.4.6", "eslint": "^9.0.0", "eslint-import-resolver-typescript": "^4.0.0", "eslint-plugin-import-x": "^4.0.0", "eslint-plugin-jest": "^28.0.0", "globals": "^16.0.0", "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", "rimraf": "^5.0.5", "ts-jest": "^29.1.1", "tsup": "^8.5.1", diff --git a/packages/sdk/vue/src/client/LDClient.ts b/packages/sdk/vue/src/client/LDClient.ts new file mode 100644 index 0000000000..fe632ed61d --- /dev/null +++ b/packages/sdk/vue/src/client/LDClient.ts @@ -0,0 +1,87 @@ +import type { + LDClient, + LDContextStrict, + LDWaitForInitializationResult, +} from '@launchdarkly/js-client-sdk'; +import type { Ref } from 'vue'; + +/** + * Represents the current initialization state of the LaunchDarkly client. + */ +export type InitializationStatus = LDWaitForInitializationResult | { status: 'initializing' }; + +/** + * Initialization state of the client as a string union. + * Derived from {@link InitializationStatus} for consistency. + */ +export type InitializedState = InitializationStatus['status']; + +/** + * The LaunchDarkly client interface for Vue. + * + * Extends the base {@link LDClient} with initialization-status and context-change subscriptions that + * the Vue provider and composables use to react to `start()` and `identify()`. + */ +export interface LDVueClient extends LDClient { + /** + * Returns the initialization state of the client. Useful to determine whether the client can be + * used to evaluate flags on initial render. + */ + getInitializationState(): InitializedState; + + /** + * Returns the error that caused initialization to fail, if any. Only set when + * {@link getInitializationState} returns `'failed'`. + */ + getInitializationError(): Error | undefined; + + /** + * Subscribes to context changes triggered by `identify()`. The callback is invoked after each + * successful `identify()` call (and once after a successful `start()`) with the resolved context. + * + * @returns An unsubscribe function. + */ + onContextChange(callback: (context: LDContextStrict) => void): () => void; + + /** + * Subscribes to initialization status changes. The callback fires when `start()` resolves. If the + * client has already resolved, the callback is invoked immediately with the cached result. + * + * @returns An unsubscribe function. + */ + onInitializationStatusChange( + callback: (result: LDWaitForInitializationResult) => void, + ): () => void; + + /** + * Returns whether the client is ready to evaluate flags. True once initialization has completed + * (successfully or not), or when bootstrap data was provided. + */ + isReady(): boolean; +} + +/** + * The reactive value provided to Vue components via inject. Composables read from these refs. + */ +export interface LDVueInstance { + /** + * The LaunchDarkly client. + */ + client: LDVueClient; + + /** + * The current LaunchDarkly context. Undefined until the client has initialized. + */ + context: Readonly>; + + /** + * The initialization state of the client. + */ + initializedState: Readonly>; + + /** + * The error that caused the client to fail to initialize. Only set when `initializedState` is + * `'failed'`. + */ + error: Readonly>; +} diff --git a/packages/sdk/vue/src/client/LDOptions.ts b/packages/sdk/vue/src/client/LDOptions.ts new file mode 100644 index 0000000000..b50ea97d0c --- /dev/null +++ b/packages/sdk/vue/src/client/LDOptions.ts @@ -0,0 +1,48 @@ +import type { LDOptions, LDStartOptions } from '@launchdarkly/js-client-sdk'; +import type { InjectionKey } from 'vue'; + +import type { LDVueInstance } from './LDClient'; + +/** + * Options for the underlying LaunchDarkly client. + */ +export type LDVueClientOptions = LDOptions; + +/** + * Options for creating a Vue provider. + */ +export interface LDVueProviderOptions { + /** + * Options for the LaunchDarkly client. + * + * @see {@link LDVueClientOptions} + */ + ldOptions?: LDVueClientOptions; + + /** + * Options for starting the LaunchDarkly client. Useful when not deferring initialization. + * + * @see {@link LDStartOptions} + */ + startOptions?: LDStartOptions; + + /** + * If true, the client will not start automatically. Start it manually via `useLDClient().start()`. + * + * @defaultValue false + */ + deferInitialization?: boolean; + + /** + * A custom injection key, for running multiple LaunchDarkly clients in the same application. If not + * provided, the default key is used. Create one with {@link createLDVueInstanceKey}. + */ + injectionKey?: InjectionKey; + + /** + * Bootstrap data from the server. When provided, the client immediately uses these values before + * the first network response, eliminating the flag-fetch waterfall on page load. Merged into + * `startOptions.bootstrap`; this top-level value takes precedence. + */ + bootstrap?: unknown; +} diff --git a/packages/sdk/vue/src/client/LDVueClient.ts b/packages/sdk/vue/src/client/LDVueClient.ts new file mode 100644 index 0000000000..cd13afe803 --- /dev/null +++ b/packages/sdk/vue/src/client/LDVueClient.ts @@ -0,0 +1,115 @@ +import { + createClient as createBaseClient, + type LDContext, + type LDContextStrict, + type LDIdentifyOptions, + type LDIdentifyResult, + type LDOptions, + type LDStartOptions, + type LDWaitForInitializationResult, +} from '@launchdarkly/js-client-sdk'; + +import type { InitializedState, LDVueClient } from './LDClient'; +import type { LDVueClientOptions } from './LDOptions'; + +/** + * Creates a new instance of the LaunchDarkly client for Vue. + * + * @remarks + * This factory is provided to allow the caller to own the client lifecycle. When using this + * function, the caller is responsible for calling `client.start()` before or after mounting + * and for subscribing to client lifecycle events. + * + * TODO(scaffold): add recommendation to prefer createLDProvider / LDVuePlugin once those + * arrive in the next PR, and restore the createLDProviderWithClient cross-reference. + * + * @example + * ```ts + * import { createClient } from '@launchdarkly/vue-client-sdk'; + * + * const client = createClient('your-client-side-id', { kind: 'user', key: 'user-key' }); + * await client.start(); + * ``` + * + * @param clientSideID the LaunchDarkly client-side ID @see https://launchdarkly.com/docs/sdk/concepts/client-side-server-side#client-side-id + * @param context the initial LaunchDarkly context @see https://launchdarkly.com/docs/sdk/concepts/context + * @param options options for the client @see {@link LDVueClientOptions} + * @returns the new client instance @see {@link LDVueClient} + */ +export function createClient( + clientSideID: string, + context: LDContext, + options: LDVueClientOptions = {}, +): LDVueClient { + const baseClientOptions: LDOptions = { + ...options, + wrapperName: options.wrapperName ?? 'vue-client-sdk', + wrapperVersion: options.wrapperVersion ?? '0.1.0', // x-release-please-version + }; + + const baseClient = createBaseClient(clientSideID, context, baseClientOptions); + let initializationState: InitializedState = 'initializing'; + let hasBootstrap = false; + let startCalled = false; + let startNotified = false; + const subscribers = new Set<(context: LDContextStrict) => void>(); + const initStatusSubscribers = new Set<(result: LDWaitForInitializationResult) => void>(); + let lastInitResult: LDWaitForInitializationResult | undefined; + + function notifyContextSubscribers() { + const newContext = baseClient.getContext(); + if (newContext) { + subscribers.forEach((cb) => cb(newContext)); + } + } + + return { + ...baseClient, + start: (startOptions?: LDStartOptions) => { + // The base client start method is idempotent, so just return its result if already called. + if (startCalled) { + return baseClient.start(startOptions); + } + startCalled = true; + if (startOptions?.bootstrap) { + hasBootstrap = true; + } + return baseClient.start(startOptions).then((result: LDWaitForInitializationResult) => { + initializationState = result.status; + lastInitResult = result; + if (!startNotified) { + startNotified = true; + notifyContextSubscribers(); + } + initStatusSubscribers.forEach((cb) => cb(result)); + return result; + }); + }, + identify: (ldContext: LDContext, identifyOptions?: LDIdentifyOptions) => + baseClient.identify(ldContext, identifyOptions).then((result: LDIdentifyResult) => { + if (result.status === 'completed') { + notifyContextSubscribers(); + } + return result; + }), + getInitializationState: () => initializationState, + getInitializationError: () => + lastInitResult?.status === 'failed' ? lastInitResult.error : undefined, + onContextChange: (callback: (ldContext: LDContextStrict) => void) => { + subscribers.add(callback); + return () => { + subscribers.delete(callback); + }; + }, + onInitializationStatusChange: (callback: (result: LDWaitForInitializationResult) => void) => { + if (lastInitResult) { + callback(lastInitResult); + } + initStatusSubscribers.add(callback); + return () => { + initStatusSubscribers.delete(callback); + }; + }, + isReady: () => initializationState !== 'initializing' || hasBootstrap, + }; +} diff --git a/packages/sdk/vue/src/client/index.ts b/packages/sdk/vue/src/client/index.ts new file mode 100644 index 0000000000..e6a0f29ad8 --- /dev/null +++ b/packages/sdk/vue/src/client/index.ts @@ -0,0 +1,9 @@ +export type { + InitializationStatus, + InitializedState, + LDVueClient, + LDVueInstance, +} from './LDClient'; +export type { LDVueClientOptions, LDVueProviderOptions } from './LDOptions'; +export { createClient } from './LDVueClient'; +// TODO(scaffold): provider and composables arrive in the next PR diff --git a/packages/sdk/vue/src/index.ts b/packages/sdk/vue/src/index.ts index b174ea05c5..43cb33cabf 100644 --- a/packages/sdk/vue/src/index.ts +++ b/packages/sdk/vue/src/index.ts @@ -1,2 +1,15 @@ -// TODO(scaffold): full implementation arrives in the next PR -export {}; +/** + * This is the API reference for the LaunchDarkly Client-side SDK for Vue. + * + * TODO(scaffold): restore the createLDProvider / LDVuePlugin references in the @packageDocumentation + * summary once those symbols arrive in the next PR. + * + * @packageDocumentation + */ +export * from './client'; +// TODO(scaffold): the following arrive in the next PR: +// export { LDVuePlugin, type LDVuePluginOptions } from './plugin'; +// export type { LDClient, LDContext, LDContextStrict, LDOptions, LDEvaluationDetail, +// LDEvaluationDetailTyped, LDEvaluationReason, LDFlagSet, LDFlagValue, LDInspection, +// LDLogger, Hook, LDIdentifyOptions, LDIdentifyResult, LDStartOptions, +// LDWaitForInitializationResult } from '@launchdarkly/js-client-sdk'; diff --git a/packages/sdk/vue/tsconfig.json b/packages/sdk/vue/tsconfig.json index 4b3fa864b9..ff3a1edbbe 100644 --- a/packages/sdk/vue/tsconfig.json +++ b/packages/sdk/vue/tsconfig.json @@ -1,22 +1,22 @@ { "compilerOptions": { + "rootDir": ".", + "outDir": "dist", + "target": "ES2020", + "lib": ["ES2020", "dom"], + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noImplicitOverride": true, "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "sourceMap": true, "declaration": true, "declarationMap": true, - "lib": ["es2017", "dom", "dom.iterable"], - "module": "ESNext", - "moduleResolution": "node", - "noImplicitOverride": true, - "outDir": "dist", "resolveJsonModule": true, - "rootDir": ".", - "skipLibCheck": true, - "sourceMap": true, - "strict": true, "stripInternal": true, - "target": "ES2017", - "types": ["jest", "node"] + "types": ["jest", "node"], + "skipLibCheck": true }, - "include": ["src/**/*"], - "exclude": ["**/*.test.ts", "dist", "node_modules", "__tests__", "example", "contract-tests"] + "exclude": ["**/*.test.ts", "dist", "node_modules", "__tests__"] }