Skip to content

Commit bc71f02

Browse files
committed
chore: adding vue client sdk wrapper
Additional changes: - modified tsconfig.json to match the rest of the monorepo
1 parent c3eaa46 commit bc71f02

8 files changed

Lines changed: 400 additions & 16 deletions

File tree

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { createClient as createBaseClient } from '@launchdarkly/js-client-sdk';
2+
3+
import { createClient } from '../../src/client/LDVueClient';
4+
5+
jest.mock('@launchdarkly/js-client-sdk', () => ({
6+
createClient: jest.fn(),
7+
}));
8+
9+
const createBaseClientMock = createBaseClient as jest.Mock;
10+
11+
type Result = { status: string; error?: Error };
12+
13+
const makeBaseClient = (overrides: Record<string, unknown> = {}) => ({
14+
getContext: jest.fn(() => ({ kind: 'user', key: 'context-key' })),
15+
start: jest.fn(() => Promise.resolve<Result>({ status: 'complete' })),
16+
identify: jest.fn(() => Promise.resolve<Result>({ status: 'completed' })),
17+
boolVariation: jest.fn(() => true),
18+
on: jest.fn(),
19+
off: jest.fn(),
20+
close: jest.fn(),
21+
...overrides,
22+
});
23+
24+
beforeEach(() => {
25+
createBaseClientMock.mockReset();
26+
});
27+
28+
it('passes wrapper metadata to the base client', () => {
29+
createBaseClientMock.mockReturnValue(makeBaseClient());
30+
31+
createClient('env-id', { kind: 'user', key: 'k' });
32+
33+
expect(createBaseClientMock).toHaveBeenCalledWith(
34+
'env-id',
35+
{ kind: 'user', key: 'k' },
36+
expect.objectContaining({ wrapperName: 'vue-client-sdk', wrapperVersion: expect.any(String) }),
37+
);
38+
});
39+
40+
it('tracks initialization state through start()', async () => {
41+
createBaseClientMock.mockReturnValue(makeBaseClient());
42+
const client = createClient('env-id', { kind: 'user', key: 'k' });
43+
44+
expect(client.getInitializationState()).toBe('initializing');
45+
expect(client.isReady()).toBe(false);
46+
47+
await client.start();
48+
49+
expect(client.getInitializationState()).toBe('complete');
50+
expect(client.isReady()).toBe(true);
51+
expect(client.getInitializationError()).toBeUndefined();
52+
});
53+
54+
it('notifies init-status subscribers and replays the cached result to late subscribers', async () => {
55+
createBaseClientMock.mockReturnValue(makeBaseClient());
56+
const client = createClient('env-id', { kind: 'user', key: 'k' });
57+
58+
const early = jest.fn();
59+
client.onInitializationStatusChange(early);
60+
61+
await client.start();
62+
63+
expect(early).toHaveBeenCalledWith({ status: 'complete' });
64+
65+
const late = jest.fn();
66+
client.onInitializationStatusChange(late);
67+
expect(late).toHaveBeenCalledWith({ status: 'complete' });
68+
});
69+
70+
it('exposes the initialization error when start fails', async () => {
71+
const error = new Error('boom');
72+
createBaseClientMock.mockReturnValue(
73+
makeBaseClient({ start: jest.fn(() => Promise.resolve({ status: 'failed', error })) }),
74+
);
75+
const client = createClient('env-id', { kind: 'user', key: 'k' });
76+
77+
await client.start();
78+
79+
expect(client.getInitializationState()).toBe('failed');
80+
expect(client.getInitializationError()).toBe(error);
81+
});
82+
83+
it('notifies context subscribers after a successful identify', async () => {
84+
createBaseClientMock.mockReturnValue(makeBaseClient());
85+
const client = createClient('env-id', { kind: 'user', key: 'k' });
86+
87+
const onContext = jest.fn();
88+
client.onContextChange(onContext);
89+
90+
await client.identify({ kind: 'user', key: 'new-key' });
91+
92+
expect(onContext).toHaveBeenCalledWith({ kind: 'user', key: 'context-key' });
93+
});
94+
95+
it('does not notify context subscribers when identify does not complete', async () => {
96+
createBaseClientMock.mockReturnValue(
97+
makeBaseClient({ identify: jest.fn(() => Promise.resolve({ status: 'error', error: new Error('x') })) }),
98+
);
99+
const client = createClient('env-id', { kind: 'user', key: 'k' });
100+
101+
const onContext = jest.fn();
102+
client.onContextChange(onContext);
103+
104+
await client.identify({ kind: 'user', key: 'new-key' });
105+
106+
expect(onContext).not.toHaveBeenCalled();
107+
});

packages/sdk/vue/package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,22 +32,27 @@
3232
"clean": "rimraf dist",
3333
"build": "tsup",
3434
"lint": "eslint .",
35-
"test": "npx jest --ci --passWithNoTests",
35+
"test": "npx jest --ci",
3636
"coverage": "yarn test --coverage",
3737
"check": "yarn lint && yarn build && yarn test"
3838
},
39+
"dependencies": {
40+
"@launchdarkly/js-client-sdk": "workspace:^"
41+
},
3942
"peerDependencies": {
4043
"vue": "^3.3.0"
4144
},
4245
"devDependencies": {
4346
"@eslint/js": "^9.0.0",
4447
"@types/jest": "^29.5.12",
48+
"@vue/test-utils": "^2.4.6",
4549
"eslint": "^9.0.0",
4650
"eslint-import-resolver-typescript": "^4.0.0",
4751
"eslint-plugin-import-x": "^4.0.0",
4852
"eslint-plugin-jest": "^28.0.0",
4953
"globals": "^16.0.0",
5054
"jest": "^29.7.0",
55+
"jest-environment-jsdom": "^29.7.0",
5156
"rimraf": "^5.0.5",
5257
"ts-jest": "^29.1.1",
5358
"tsup": "^8.5.1",
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import type {
2+
LDClient,
3+
LDContextStrict,
4+
LDWaitForInitializationResult,
5+
} from '@launchdarkly/js-client-sdk';
6+
import type { Ref } from 'vue';
7+
8+
/**
9+
* Represents the current initialization state of the LaunchDarkly client.
10+
*/
11+
export type InitializationStatus = LDWaitForInitializationResult | { status: 'initializing' };
12+
13+
/**
14+
* Initialization state of the client as a string union.
15+
* Derived from {@link InitializationStatus} for consistency.
16+
*/
17+
export type InitializedState = InitializationStatus['status'];
18+
19+
/**
20+
* The LaunchDarkly client interface for Vue.
21+
*
22+
* Extends the base {@link LDClient} with initialization-status and context-change subscriptions that
23+
* the Vue provider and composables use to react to `start()` and `identify()`.
24+
*/
25+
export interface LDVueClient extends LDClient {
26+
/**
27+
* Returns the initialization state of the client. Useful to determine whether the client can be
28+
* used to evaluate flags on initial render.
29+
*/
30+
getInitializationState(): InitializedState;
31+
32+
/**
33+
* Returns the error that caused initialization to fail, if any. Only set when
34+
* {@link getInitializationState} returns `'failed'`.
35+
*/
36+
getInitializationError(): Error | undefined;
37+
38+
/**
39+
* Subscribes to context changes triggered by `identify()`. The callback is invoked after each
40+
* successful `identify()` call (and once after a successful `start()`) with the resolved context.
41+
*
42+
* @returns An unsubscribe function.
43+
*/
44+
onContextChange(callback: (context: LDContextStrict) => void): () => void;
45+
46+
/**
47+
* Subscribes to initialization status changes. The callback fires when `start()` resolves. If the
48+
* client has already resolved, the callback is invoked immediately with the cached result.
49+
*
50+
* @returns An unsubscribe function.
51+
*/
52+
onInitializationStatusChange(
53+
callback: (result: LDWaitForInitializationResult) => void,
54+
): () => void;
55+
56+
/**
57+
* Returns whether the client is ready to evaluate flags. True once initialization has completed
58+
* (successfully or not), or when bootstrap data was provided.
59+
*/
60+
isReady(): boolean;
61+
}
62+
63+
/**
64+
* The reactive value provided to Vue components via inject. Composables read from these refs.
65+
*/
66+
export interface LDVueInstance {
67+
/**
68+
* The LaunchDarkly client.
69+
*/
70+
client: LDVueClient;
71+
72+
/**
73+
* The current LaunchDarkly context. Undefined until the client has initialized.
74+
*/
75+
context: Readonly<Ref<LDContextStrict | undefined>>;
76+
77+
/**
78+
* The initialization state of the client.
79+
*/
80+
initializedState: Readonly<Ref<InitializedState>>;
81+
82+
/**
83+
* The error that caused the client to fail to initialize. Only set when `initializedState` is
84+
* `'failed'`.
85+
*/
86+
error: Readonly<Ref<Error | undefined>>;
87+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { LDOptions, LDStartOptions } from '@launchdarkly/js-client-sdk';
2+
import type { InjectionKey } from 'vue';
3+
4+
import type { LDVueInstance } from './LDClient';
5+
6+
/**
7+
* Options for the underlying LaunchDarkly client.
8+
*/
9+
export type LDVueClientOptions = LDOptions;
10+
11+
/**
12+
* Options for creating a Vue provider.
13+
*/
14+
export interface LDVueProviderOptions {
15+
/**
16+
* Options for the LaunchDarkly client.
17+
*
18+
* @see {@link LDVueClientOptions}
19+
*/
20+
ldOptions?: LDVueClientOptions;
21+
22+
/**
23+
* Options for starting the LaunchDarkly client. Useful when not deferring initialization.
24+
*
25+
* @see {@link LDStartOptions}
26+
*/
27+
startOptions?: LDStartOptions;
28+
29+
/**
30+
* If true, the client will not start automatically. Start it manually via `useLDClient().start()`.
31+
*
32+
* @defaultValue false
33+
*/
34+
deferInitialization?: boolean;
35+
36+
/**
37+
* A custom injection key, for running multiple LaunchDarkly clients in the same application. If not
38+
* provided, the default key is used. Create one with {@link createLDVueInstanceKey}.
39+
*/
40+
injectionKey?: InjectionKey<LDVueInstance>;
41+
42+
/**
43+
* Bootstrap data from the server. When provided, the client immediately uses these values before
44+
* the first network response, eliminating the flag-fetch waterfall on page load. Merged into
45+
* `startOptions.bootstrap`; this top-level value takes precedence.
46+
*/
47+
bootstrap?: unknown;
48+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import {
2+
createClient as createBaseClient,
3+
type LDContext,
4+
type LDContextStrict,
5+
type LDIdentifyOptions,
6+
type LDIdentifyResult,
7+
type LDOptions,
8+
type LDStartOptions,
9+
type LDWaitForInitializationResult,
10+
} from '@launchdarkly/js-client-sdk';
11+
12+
import type { InitializedState, LDVueClient } from './LDClient';
13+
import type { LDVueClientOptions } from './LDOptions';
14+
15+
/**
16+
* Creates a new instance of the LaunchDarkly client for Vue.
17+
*
18+
* @remarks
19+
* This factory is provided to allow the caller to own the client lifecycle. When using this
20+
* function, the caller is responsible for calling `client.start()` before or after mounting
21+
* and for subscribing to client lifecycle events.
22+
*
23+
* TODO(scaffold): add recommendation to prefer createLDProvider / LDVuePlugin once those
24+
* arrive in the next PR, and restore the createLDProviderWithClient cross-reference.
25+
*
26+
* @example
27+
* ```ts
28+
* import { createClient } from '@launchdarkly/vue-client-sdk';
29+
*
30+
* const client = createClient('your-client-side-id', { kind: 'user', key: 'user-key' });
31+
* await client.start();
32+
* ```
33+
*
34+
* @param clientSideID the LaunchDarkly client-side ID @see https://launchdarkly.com/docs/sdk/concepts/client-side-server-side#client-side-id
35+
* @param context the initial LaunchDarkly context @see https://launchdarkly.com/docs/sdk/concepts/context
36+
* @param options options for the client @see {@link LDVueClientOptions}
37+
* @returns the new client instance @see {@link LDVueClient}
38+
*/
39+
export function createClient(
40+
clientSideID: string,
41+
context: LDContext,
42+
options: LDVueClientOptions = {},
43+
): LDVueClient {
44+
const baseClientOptions: LDOptions = {
45+
...options,
46+
wrapperName: options.wrapperName ?? 'vue-client-sdk',
47+
wrapperVersion: options.wrapperVersion ?? '0.1.0', // x-release-please-version
48+
};
49+
50+
const baseClient = createBaseClient(clientSideID, context, baseClientOptions);
51+
let initializationState: InitializedState = 'initializing';
52+
let hasBootstrap = false;
53+
let startCalled = false;
54+
let startNotified = false;
55+
const subscribers = new Set<(context: LDContextStrict) => void>();
56+
const initStatusSubscribers = new Set<(result: LDWaitForInitializationResult) => void>();
57+
let lastInitResult: LDWaitForInitializationResult | undefined;
58+
59+
function notifyContextSubscribers() {
60+
const newContext = baseClient.getContext();
61+
if (newContext) {
62+
subscribers.forEach((cb) => cb(newContext));
63+
}
64+
}
65+
66+
return {
67+
...baseClient,
68+
start: (startOptions?: LDStartOptions) => {
69+
// The base client start method is idempotent, so just return its result if already called.
70+
if (startCalled) {
71+
return baseClient.start(startOptions);
72+
}
73+
startCalled = true;
74+
if (startOptions?.bootstrap) {
75+
hasBootstrap = true;
76+
}
77+
return baseClient.start(startOptions).then((result: LDWaitForInitializationResult) => {
78+
initializationState = result.status;
79+
lastInitResult = result;
80+
if (!startNotified) {
81+
startNotified = true;
82+
notifyContextSubscribers();
83+
}
84+
initStatusSubscribers.forEach((cb) => cb(result));
85+
return result;
86+
});
87+
},
88+
identify: (ldContext: LDContext, identifyOptions?: LDIdentifyOptions) =>
89+
baseClient.identify(ldContext, identifyOptions).then((result: LDIdentifyResult) => {
90+
if (result.status === 'completed') {
91+
notifyContextSubscribers();
92+
}
93+
return result;
94+
}),
95+
getInitializationState: () => initializationState,
96+
getInitializationError: () =>
97+
lastInitResult?.status === 'failed' ? lastInitResult.error : undefined,
98+
onContextChange: (callback: (ldContext: LDContextStrict) => void) => {
99+
subscribers.add(callback);
100+
return () => {
101+
subscribers.delete(callback);
102+
};
103+
},
104+
onInitializationStatusChange: (callback: (result: LDWaitForInitializationResult) => void) => {
105+
if (lastInitResult) {
106+
callback(lastInitResult);
107+
}
108+
initStatusSubscribers.add(callback);
109+
return () => {
110+
initStatusSubscribers.delete(callback);
111+
};
112+
},
113+
isReady: () => initializationState !== 'initializing' || hasBootstrap,
114+
};
115+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export type {
2+
InitializationStatus,
3+
InitializedState,
4+
LDVueClient,
5+
LDVueInstance,
6+
} from './LDClient';
7+
export type { LDVueClientOptions, LDVueProviderOptions } from './LDOptions';
8+
export { createClient } from './LDVueClient';
9+
// TODO(scaffold): provider and composables arrive in the next PR

0 commit comments

Comments
 (0)