Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 20 additions & 26 deletions packages/sdk/svelte/__tests__/lib/client/SvelteLDClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ 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 { createClient, LDClient } from '@launchdarkly/js-client-sdk';

import { LD } from '../../../src/lib/client/SvelteLDClient';

vi.mock('@launchdarkly/js-client-sdk/compat', { spy: true });
vi.mock('@launchdarkly/js-client-sdk', { spy: true });

const clientSideID = 'test-client-side-id';
const rawFlags = { 'test-flag': true, 'another-test-flag': 'flag-value' };
Expand All @@ -21,6 +21,8 @@ const mockLDClient = {
allFlags: vi.fn().mockReturnValue(rawFlags),
variation: vi.fn((_, defaultValue) => defaultValue),
identify: vi.fn(),
start: vi.fn(),
waitForInitialization: vi.fn().mockReturnValue(Promise.resolve({ status: 'complete' })),
};

describe('launchDarkly', () => {
Expand All @@ -31,7 +33,7 @@ describe('launchDarkly', () => {
expect(ld).toHaveProperty('identify');
expect(ld).toHaveProperty('flags');
expect(ld).toHaveProperty('initialize');
expect(ld).toHaveProperty('initializing');
expect(ld).toHaveProperty('initalizationState');
expect(ld).toHaveProperty('watch');
expect(ld).toHaveProperty('useFlag');
});
Expand All @@ -40,8 +42,7 @@ describe('launchDarkly', () => {
const ld = LD;

beforeEach(() => {
// mocks the initialize function to return the mockLDClient
(initialize as Mock<typeof initialize>).mockReturnValue(
(createClient as Mock<typeof createClient>).mockReturnValue(
mockLDClient as unknown as LDClient,
);
});
Expand All @@ -62,27 +63,25 @@ describe('launchDarkly', () => {
});

it('should set the loading status to false when the client is ready', async () => {
const { initializing } = ld;
ld.initialize(clientSideID, mockContext);
const { initalizationState } = ld;
const promise = ld.initialize(clientSideID, mockContext);

expect(get(initializing)).toBe(true); // should be true before the ready event is emitted
mockLDEventEmitter.emit('ready');
expect(get(initalizationState)).toBe('pending');

expect(get(initializing)).toBe(false);
await promise;
expect(get(initalizationState)).toBe('complete');
});

it('should initialize the LaunchDarkly SDK instance', () => {
ld.initialize(clientSideID, mockContext);

expect(initialize).toHaveBeenCalledWith('test-client-side-id', mockContext);
expect(createClient).toHaveBeenCalledWith('test-client-side-id', mockContext, undefined);
});

it('should register function that gets flag values when client is ready', () => {
it('should register function that gets flag values when client is ready', async () => {
const newFlags = { ...rawFlags, 'new-flag': true };
const allFlagsSpy = vi.spyOn(mockLDClient, 'allFlags').mockReturnValue(newFlags);

ld.initialize(clientSideID, mockContext);
mockLDEventEmitter.emit('ready');
await ld.initialize(clientSideID, mockContext);

expect(allFlagsSpy).toHaveBeenCalledOnce();
expect(allFlagsSpy).toHaveReturnedWith(newFlags);
Expand All @@ -104,8 +103,7 @@ describe('launchDarkly', () => {
const ld = LD;

beforeEach(() => {
// mocks the initialize function to return the mockLDClient
(initialize as Mock<typeof initialize>).mockReturnValue(
(createClient as Mock<typeof createClient>).mockReturnValue(
mockLDClient as unknown as LDClient,
);
});
Expand All @@ -124,16 +122,14 @@ describe('launchDarkly', () => {
expect(get(flagStore)).toBe(true);
});

it('should update the flag store when the flag value changes', () => {
it('should update the flag store when the flag value changes', async () => {
const booleanFlagKey = 'test-flag';
const stringFlagKey = 'another-test-flag';
ld.initialize(clientSideID, mockContext);
const initializationPromise = 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');

await initializationPromise;
// '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`
Expand Down Expand Up @@ -166,8 +162,7 @@ describe('launchDarkly', () => {
const ld = LD;

beforeEach(() => {
// mocks the initialize function to return the mockLDClient
(initialize as Mock<typeof initialize>).mockReturnValue(
(createClient as Mock<typeof createClient>).mockReturnValue(
mockLDClient as unknown as LDClient,
);
});
Expand All @@ -191,8 +186,7 @@ describe('launchDarkly', () => {
const ld = LD;

beforeEach(() => {
// mocks the initialize function to return the mockLDClient
(initialize as Mock<typeof initialize>).mockReturnValue(
(createClient as Mock<typeof createClient>).mockReturnValue(
mockLDClient as unknown as LDClient,
);
});
Expand Down
115 changes: 89 additions & 26 deletions packages/sdk/svelte/src/lib/client/SvelteLDClient.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { derived, type Readable, readonly, writable, type Writable } from 'svelte/store';

import type { LDFlagSet } from '@launchdarkly/js-client-sdk';
import {
initialize,
type LDClient,
createClient,
type LDClient as LDClientBase,
type LDContext,
type LDFlagSet,
type LDFlagValue,
} from '@launchdarkly/js-client-sdk/compat';
type LDIdentifyResult,
type LDOptions,
} from '@launchdarkly/js-client-sdk';

export type { LDContext, LDFlagValue };

Expand All @@ -16,12 +18,58 @@ export type LDClientID = string;
/** Flags for LaunchDarkly */
export type LDFlags = LDFlagSet;

/**
* The LaunchDarkly client interface for Svelte which is a restrictive proxy of the {@link LDClientBase}.
*/
export interface LDClient {
/**
* Initializes the LaunchDarkly client.
* @param {LDClientID} clientId - The LD client-side ID.
* @param {LDContext} context - The user context.
* @param {LDOptions} options - The options.
* @returns {Promise} A promise that resolves when the client is initialized.
*/
initialize(clientId: LDClientID, context: LDContext, options?: LDOptions): Promise<void>;

/**
* Identifies the user context.
* @param {LDContext} context - The user context.
* @returns {Promise} A promise that resolves when the user is identified.
*/
identify(context: LDContext): Promise<LDIdentifyResult>;

/**
* The flags store.
*/
flags: Readable<LDFlags>;

/**
* The initialization state store.
*/
initalizationState: Readable<string>;

/**
* Watches a flag for changes.
* @param {string} flagKey - The key of the flag to watch.
* @returns {Readable<LDFlagValue>} A readable store of the flag value.
*/
watch(flagKey: string): Readable<LDFlagValue>;

/**
* 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.
*/
useFlag<TFlag extends LDFlagValue>(flagKey: string, defaultValue: TFlag): TFlag;
}

/**
* 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 {
function isClientInitialized(client: LDClientBase | undefined): asserts client is LDClientBase {
if (!client) {
throw new Error('LaunchDarkly client not initialized');
}
Expand All @@ -37,7 +85,7 @@ function isClientInitialized(client: LDClient | undefined): asserts client is LD
* @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 {
function toFlagsProxy(client: LDClientBase, flags: LDFlags): LDFlags {
return new Proxy(flags, {
get(target, prop, receiver) {
const currentValue = Reflect.get(target, prop, receiver);
Expand All @@ -62,43 +110,58 @@ function toFlagsProxy(client: LDClient, flags: LDFlags): LDFlags {
* Creates a LaunchDarkly instance.
* @returns {Object} The LaunchDarkly instance object.
*/
function createLD() {
let coreLdClient: LDClient | undefined;
const loading = writable(true);
function init(): LDClient {
let coreLdClient: LDClientBase | undefined;
const flagsWritable = writable<LDFlags>({});
const initializeResult = writable<string>('pending');

// NOTE: we will returns an empty promise for now as the promise states and handling is being wrappered
// we can evaluate this decision in the future before this SDK is marked as stable.
/**
* 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', () => {
function initialize(
clientId: LDClientID,
context: LDContext,
options?: LDOptions,
): Promise<void> {
coreLdClient = createClient(clientId, context, options);

coreLdClient.on('change', () => {
const rawFlags = coreLdClient!.allFlags();
const allFlags = toFlagsProxy(coreLdClient!, rawFlags);
flagsWritable.set(allFlags);
});

return {
initializing: loading,
};
// TODO: currently all options are defaulted which means that the client initailization will timeout in 5 seconds.
// we will need to address this before this SDK is marked as stable.
coreLdClient.start();

return coreLdClient
.waitForInitialization()
.then((result) => {
const rawFlags = coreLdClient!.allFlags();
const allFlags = toFlagsProxy(coreLdClient!, rawFlags);
flagsWritable.set(allFlags);

initializeResult.set(result.status);
})
.catch(() => {
// NOTE: this should never happen as we don't throw errors from initialization.
options?.logger?.error('Failed to initialize LaunchDarkly client');
initializeResult.set('failed');
});
}

/**
* 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) {
async function identify(context: LDContext): Promise<LDIdentifyResult> {
isClientInitialized(coreLdClient);
return coreLdClient.identify(context);
}
Expand All @@ -125,12 +188,12 @@ function createLD() {
return {
identify,
flags: readonly(flagsWritable),
initialize: LDInitialize,
initializing: readonly(loading),
initialize,
initalizationState: readonly(initializeResult),
watch,
useFlag,
};
}

/** The LaunchDarkly instance */
export const LD = createLD();
export const LD = init();
6 changes: 4 additions & 2 deletions packages/sdk/svelte/src/lib/provider/LDProvider.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,17 @@

export let clientID: LDClientID;
export let context: LDContext;
const { initialize, initializing } = LD;
const { initialize, initalizationState } = LD;

onMount(() => {
initialize(clientID, context);
});
</script>

{#if $$slots.initializing && $initializing}
{#if $$slots.initializing && $initalizationState === 'pending'}
<slot name="initializing">Loading flags (default loading slot value)...</slot>
{:else if $initalizationState === 'failed' || $initalizationState === 'timeout'}
<slot name="failed">Failed to initialize LaunchDarkly client ({$initalizationState})</slot>
{:else}
<slot />
{/if}