-
Notifications
You must be signed in to change notification settings - Fork 39
chore: adding vue client sdk wrapper #1435
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
joker23
wants to merge
2
commits into
main
Choose a base branch
from
skz/sdk-2194/migrate-vue-sdk-client-wrapper
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, unknown> = {}) => ({ | ||
| getContext: jest.fn(() => ({ kind: 'user', key: 'context-key' })), | ||
| start: jest.fn(() => Promise.resolve<Result>({ status: 'complete' })), | ||
| identify: jest.fn(() => Promise.resolve<Result>({ 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(); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Ref<LDContextStrict | undefined>>; | ||
|
|
||
| /** | ||
| * The initialization state of the client. | ||
| */ | ||
| initializedState: Readonly<Ref<InitializedState>>; | ||
|
|
||
| /** | ||
| * The error that caused the client to fail to initialize. Only set when `initializedState` is | ||
| * `'failed'`. | ||
| */ | ||
| error: Readonly<Ref<Error | undefined>>; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<LDVueInstance>; | ||
|
|
||
| /** | ||
| * 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; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 || startOptions?.identifyOptions?.bootstrap) { | ||
| hasBootstrap = true; | ||
| } | ||
| return baseClient.start(startOptions).then((result: LDWaitForInitializationResult) => { | ||
| initializationState = result.status; | ||
| lastInitResult = result; | ||
| if (!startNotified && result.status === 'complete') { | ||
| 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, | ||
| }; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.