diff --git a/packages/sdk/browser/__tests__/BrowserClient.test.ts b/packages/sdk/browser/__tests__/BrowserClient.test.ts index 391f8829c4..c7e1bdfcef 100644 --- a/packages/sdk/browser/__tests__/BrowserClient.test.ts +++ b/packages/sdk/browser/__tests__/BrowserClient.test.ts @@ -246,37 +246,6 @@ describe('given a mock platform for a BrowserClient', () => { expect(client.getContext()).toEqual({ kind: 'user', key: 'bob' }); }); - it('parses bootstrap data only once when using start()', async () => { - const bootstrapModule = await import('@launchdarkly/js-client-sdk-common'); - const readFlagsFromBootstrapSpy = jest.spyOn(bootstrapModule, 'readFlagsFromBootstrap'); - - const client = makeClient( - 'client-side-id', - { kind: 'user', key: 'bob' }, - AutoEnvAttributes.Disabled, - { - streaming: false, - logger, - diagnosticOptOut: true, - }, - platform, - ); - - await client.start({ - identifyOptions: { - bootstrap: goodBootstrapDataWithReasons, - }, - }); - - expect(readFlagsFromBootstrapSpy).toHaveBeenCalledTimes(1); - expect(readFlagsFromBootstrapSpy).toHaveBeenCalledWith( - expect.anything(), - goodBootstrapDataWithReasons, - ); - - readFlagsFromBootstrapSpy.mockRestore(); - }); - it('uses the latest bootstrap data when identify is called with new bootstrap data', async () => { const initialBootstrapData = { 'string-flag': 'is bob', @@ -866,7 +835,7 @@ describe('given a mock platform for a BrowserClient', () => { // Verify that the logger was called with the error message expect(logger.error).toHaveBeenCalledWith( - 'Client must be started before it can identify a context, did you forget to call start()?', + 'The client must be started before a context can be identified. Call start() prior to identifying a context.', ); // Verify that no fetch calls were made diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index 6d6660aad4..a26add5989 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -21,10 +21,8 @@ import { LDIdentifyResult, LDPluginEnvironmentMetadata, LDWaitForInitializationOptions, - LDWaitForInitializationResult, MODE_TABLE, Platform, - readFlagsFromBootstrap, resolveForegroundMode, safeRegisterDebugOverridePlugins, } from '@launchdarkly/js-client-sdk-common'; @@ -45,13 +43,9 @@ class BrowserClientImpl extends LDClientImpl { private readonly _goalManager?: GoalManager; private readonly _plugins?: LDPlugin[]; - private _initialContext?: LDContext; - - // NOTE: This also keeps track of when we tried to initialize the client. - private _startPromise?: Promise; - constructor( clientSideId: string, + initialContext: LDContext, autoEnvAttributes: AutoEnvAttributes, options: BrowserOptions = {}, overridePlatform?: Platform, @@ -156,6 +150,8 @@ class BrowserClientImpl extends LDClientImpl { getImplementationHooks: (environmentMetadata: LDPluginEnvironmentMetadata) => internal.safeGetHooks(logger, environmentMetadata, validatedBrowserOptions.plugins), credentialType: 'clientSideId', + requiresStart: true, + initialContext, }); this.setEventSendingEnabled(true, false); @@ -234,10 +230,6 @@ class BrowserClientImpl extends LDClientImpl { } } - setInitialContext(context: LDContext): void { - this._initialContext = context; - } - override async identify(context: LDContext, identifyOptions?: LDIdentifyOptions): Promise { return super.identify(context, identifyOptions); } @@ -246,79 +238,18 @@ class BrowserClientImpl extends LDClientImpl { context: LDContext, identifyOptions?: LDIdentifyOptions, ): Promise { - if (!this._startPromise) { - this.logger.error( - 'Client must be started before it can identify a context, did you forget to call start()?', - ); - return { status: 'error', error: new Error('Identify called before start') }; - } - - const identifyOptionsWithUpdatedDefaults = { - ...identifyOptions, - }; - if (identifyOptions?.sheddable === undefined) { - identifyOptionsWithUpdatedDefaults.sheddable = true; + const options = + identifyOptions?.sheddable === undefined + ? { ...identifyOptions, sheddable: true } + : identifyOptions; + const res = await super.identifyResult(context, options); + // Ensure that we do not start the goal manager if start() is not called. + if (this.startPromise) { + this._goalManager?.startTracking(); } - - const res = await super.identifyResult(context, identifyOptionsWithUpdatedDefaults); - - this._goalManager?.startTracking(); return res; } - start(options?: LDStartOptions): Promise { - if (this.initializeResult) { - return Promise.resolve(this.initializeResult); - } - if (this._startPromise) { - return this._startPromise; - } - if (!this._initialContext) { - this.logger.error('Initial context not set'); - return Promise.resolve({ status: 'failed', error: new Error('Initial context not set') }); - } - - // When we get to this point, we assume this is the first time that start is being - // attempted. This line should only be called once during the lifetime of the client. - const identifyOptions = { - ...(options?.identifyOptions ?? {}), - - // Initial identify operations are not sheddable. - sheddable: false, - }; - - // If the bootstrap data is provided in the start options, and the identify options do not have bootstrap data, - // then use the bootstrap data from the start options. - if (options?.bootstrap && !identifyOptions.bootstrap) { - identifyOptions.bootstrap = options.bootstrap; - } - - if (identifyOptions?.bootstrap) { - try { - if (!identifyOptions.bootstrapParsed) { - identifyOptions.bootstrapParsed = readFlagsFromBootstrap( - this.logger, - identifyOptions.bootstrap, - ); - } - this.presetFlags(identifyOptions.bootstrapParsed!); - } catch (error) { - this.logger.error('Failed to bootstrap data', error); - } - } - - if (!this.initializedPromise) { - this.initializedPromise = new Promise((resolve) => { - this.initResolve = resolve; - }); - } - - this._startPromise = this.promiseWithTimeout(this.initializedPromise!, options?.timeout ?? 5); - - this.identifyResult(this._initialContext!, identifyOptions); - return this._startPromise; - } - setConnectionMode(mode?: FDv2ConnectionMode): void { if (!this.dataManager.setConnectionMode) { this.logger.warn( @@ -366,8 +297,13 @@ export function makeClient( options: BrowserOptions = {}, overridePlatform?: Platform, ): LDClient { - const impl = new BrowserClientImpl(clientSideId, autoEnvAttributes, options, overridePlatform); - impl.setInitialContext(initialContext); + const impl = new BrowserClientImpl( + clientSideId, + initialContext, + autoEnvAttributes, + options, + overridePlatform, + ); // Return a PIMPL style implementation. This decouples the interface from the interface of the implementation. // In the future we should consider updating the common SDK code to not use inheritance and instead compose diff --git a/packages/sdk/browser/src/LDClient.ts b/packages/sdk/browser/src/LDClient.ts index 7de1ea0d2d..5a86a4835e 100644 --- a/packages/sdk/browser/src/LDClient.ts +++ b/packages/sdk/browser/src/LDClient.ts @@ -1,5 +1,6 @@ import { LDClient as CommonClient, + LDStartOptions as CommonLDStartOptions, FDv2ConnectionMode, LDContext, LDIdentifyResult, @@ -9,18 +10,11 @@ import { import { BrowserIdentifyOptions as LDIdentifyOptions } from './BrowserIdentifyOptions'; -export interface LDStartOptions extends LDWaitForInitializationOptions { - /** - * Optional bootstrap data to use for the identify operation. If {@link LDIdentifyOptions.bootstrap} is provided, it will be ignored. - */ - bootstrap?: unknown; - - /** - * Optional identify options to use for the identify operation. See {@link LDIdentifyOptions} for more information. - * - * @remarks - * Since the first identify option should never be sheddable, we omit the sheddable option from the interface to avoid confusion. - */ +/** + * Browser-specific start options that extend the common start options + * with browser-specific identify options (see {@link LDIdentifyOptions}). + */ +export interface LDStartOptions extends CommonLDStartOptions { identifyOptions?: Omit; } diff --git a/packages/sdk/electron/__tests__/ElectronClient.test.ts b/packages/sdk/electron/__tests__/ElectronClient.test.ts index 1139dee269..b6d3dc5875 100644 --- a/packages/sdk/electron/__tests__/ElectronClient.test.ts +++ b/packages/sdk/electron/__tests__/ElectronClient.test.ts @@ -766,10 +766,7 @@ it('can use bootstrap data with identify', async () => { expect(mockedCreateEventSource).toHaveBeenCalled(); }); -it('parses bootstrap data only once when identify is called with bootstrap', async () => { - const commonModule = await import('@launchdarkly/js-client-sdk-common'); - const readFlagsFromBootstrapSpy = jest.spyOn(commonModule, 'readFlagsFromBootstrap'); - +it('parses bootstrap data when start is called with bootstrap', async () => { (ElectronPlatform as jest.Mock).mockReturnValue({ crypto: new ElectronCrypto(), info: new ElectronInfo(), @@ -796,7 +793,8 @@ it('parses bootstrap data only once when identify is called with bootstrap', asy await client.start({ bootstrap: goodBootstrapData }); - expect(readFlagsFromBootstrapSpy).toHaveBeenCalledTimes(1); - expect(readFlagsFromBootstrapSpy).toHaveBeenCalledWith(expect.anything(), goodBootstrapData); - readFlagsFromBootstrapSpy.mockRestore(); + // Verify that bootstrap data was parsed and flags are available. + expect(client.allFlags().killswitch).toBe(true); + expect(client.allFlags()['string-flag']).toBe('is bob'); + expect(client.allFlags().cat).toBe(false); }); diff --git a/packages/sdk/electron/src/ElectronClient.ts b/packages/sdk/electron/src/ElectronClient.ts index 5b3baf91e3..dde4dede23 100644 --- a/packages/sdk/electron/src/ElectronClient.ts +++ b/packages/sdk/electron/src/ElectronClient.ts @@ -23,7 +23,6 @@ import { LDWaitForInitializationOptions, LDWaitForInitializationResult, mobileFdv1Endpoints, - readFlagsFromBootstrap, } from '@launchdarkly/js-client-sdk-common'; import ElectronDataManager from './ElectronDataManager'; @@ -43,10 +42,6 @@ import ElectronPlatform from './platform/ElectronPlatform'; const VALID_LOG_LEVELS: ReadonlySet = new Set(['error', 'warn', 'info', 'debug']); export class ElectronClient extends LDClientImpl { - private readonly _initialContext: LDContext; - - private _startPromise?: Promise; - private readonly _plugins: LDPlugin[]; private _ipcNamespace?: string; @@ -93,6 +88,8 @@ export class ElectronClient extends LDClientImpl { getImplementationHooks: (_environmentMetadata: LDPluginEnvironmentMetadata) => internal.safeGetHooks(logger, _environmentMetadata, validatedElectronOptions.plugins), credentialType: useClientSideId ? 'clientSideId' : 'mobileKey', + requiresStart: true, + initialContext, }; const platform = new ElectronPlatform(logger, options); @@ -125,7 +122,6 @@ export class ElectronClient extends LDClientImpl { internalOptions, ); - this._initialContext = initialContext; this._plugins = validatedElectronOptions.plugins; this.setEventSendingEnabled(!this.isOffline(), false); @@ -142,76 +138,15 @@ export class ElectronClient extends LDClientImpl { internal.safeRegisterPlugins(this.logger, this.environmentMetadata, client, this._plugins); } - start(options?: LDStartOptions): Promise { - if (this.initializeResult !== undefined) { - return Promise.resolve(this.initializeResult); - } - if (this._startPromise) { - return this._startPromise; - } - if (!this._initialContext) { - this.logger.error('Initial context not set'); - return Promise.resolve({ status: 'failed', error: new Error('Initial context not set') }); - } - - const identifyOptions: LDIdentifyOptions = { - ...(options?.identifyOptions ?? {}), - sheddable: false, - }; - - if ( - options?.bootstrap !== undefined && - options?.bootstrap !== null && - !identifyOptions.bootstrap - ) { - identifyOptions.bootstrap = options.bootstrap; - } - - if (identifyOptions.bootstrap) { - try { - if (!identifyOptions.bootstrapParsed) { - identifyOptions.bootstrapParsed = readFlagsFromBootstrap( - this.logger, - identifyOptions.bootstrap, - ); - } - this.presetFlags(identifyOptions.bootstrapParsed!); - } catch (error) { - this.logger.error('Failed to bootstrap data', error); - } - } - - if (!this.initializedPromise) { - this.initializedPromise = new Promise((resolve) => { - this.initResolve = resolve; - }); - } - - this._startPromise = this.promiseWithTimeout(this.initializedPromise!, options?.timeout ?? 5); - - this.identifyResult(this._initialContext, identifyOptions); - return this._startPromise; - } - override async identifyResult( - pristineContext: LDContext, + context: LDContext, identifyOptions?: LDIdentifyOptions, ): Promise { - if (!this._startPromise) { - this.logger.error( - 'Client must be started before it can identify a context, did you forget to call start()?', - ); - return { status: 'error', error: new Error('Identify called before start') }; - } - - const identifyOptionsWithUpdatedDefaults = { - ...identifyOptions, - }; - if (identifyOptions?.sheddable === undefined) { - identifyOptionsWithUpdatedDefaults.sheddable = true; - } - - return super.identifyResult(pristineContext, identifyOptionsWithUpdatedDefaults); + const options = + identifyOptions?.sheddable === undefined + ? { ...identifyOptions, sheddable: true } + : identifyOptions; + return super.identifyResult(context, options); } async setConnectionMode(mode: ConnectionMode): Promise { diff --git a/packages/sdk/electron/src/LDClient.ts b/packages/sdk/electron/src/LDClient.ts index 4f1815e89f..187817f706 100644 --- a/packages/sdk/electron/src/LDClient.ts +++ b/packages/sdk/electron/src/LDClient.ts @@ -4,23 +4,11 @@ import type { LDContext, LDIdentifyOptions, LDIdentifyResult, - LDWaitForInitializationOptions, + LDStartOptions, LDWaitForInitializationResult, } from '@launchdarkly/js-client-sdk-common'; -export interface LDStartOptions extends LDWaitForInitializationOptions { - /** - * Optional bootstrap data to use for the identify operation. If - * {@link LDIdentifyOptions.bootstrap} is provided in identifyOptions, it takes precedence. - */ - bootstrap?: unknown; - - /** - * Optional identify options to use for the first identify. Since the first identify is not - * sheddable, the sheddable option is omitted from this type. - */ - identifyOptions?: Omit; -} +export type { LDStartOptions }; export interface LDClient extends Omit { /** diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.start.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.start.test.ts new file mode 100644 index 0000000000..63ce8ba085 --- /dev/null +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.start.test.ts @@ -0,0 +1,315 @@ +import { AutoEnvAttributes, clone, Hasher, LDLogger } from '@launchdarkly/js-sdk-common'; + +import { LDContext } from '../src/api/LDContext'; +import * as bootstrapModule from '../src/flag-manager/bootstrap'; +import LDClientImpl from '../src/LDClientImpl'; +import { Flags } from '../src/types'; +import { createBasicPlatform } from './createBasicPlatform'; +import * as mockResponseJson from './evaluation/mockResponse.json'; +import { goodBootstrapData, goodBootstrapDataWithReasons } from './flag-manager/testBootstrapData'; +import { MockEventSource } from './streaming/LDClientImpl.mocks'; +import { makeTestDataManagerFactory } from './TestDataManager'; + +const testSdkKey = 'test-sdk-key'; +const context: LDContext = { kind: 'user', key: 'test-user' }; + +let clients: LDClientImpl[] = []; + +function setupClient( + mockPlatform: ReturnType, + options?: { + logger?: LDLogger; + requiresStart?: boolean; + disableNetwork?: boolean; + sendEvents?: boolean; + initialContext?: LDContext; + }, +) { + const logger = options?.logger ?? { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; + + const ldc = new LDClientImpl( + testSdkKey, + AutoEnvAttributes.Disabled, + mockPlatform, + { + logger, + sendEvents: options?.sendEvents ?? false, + }, + makeTestDataManagerFactory(testSdkKey, mockPlatform, { + disableNetwork: options?.disableNetwork, + }), + { + getImplementationHooks: () => [], + credentialType: 'clientSideId', + requiresStart: options?.requiresStart ?? true, + initialContext: options?.initialContext, + }, + ); + + clients.push(ldc); + return { ldc, logger }; +} + +function setupStreamingPlatform() { + const mockPlatform = createBasicPlatform(); + const defaultPutResponse = clone(mockResponseJson); + mockPlatform.crypto.randomUUID.mockReturnValue('random1'); + const hasher = { + update: jest.fn((): Hasher => hasher), + digest: jest.fn(() => 'digested1'), + }; + mockPlatform.crypto.createHash.mockReturnValue(hasher); + mockPlatform.requests.getEventSourceCapabilities.mockImplementation(() => ({ + readTimeout: true, + headers: true, + customMethod: true, + })); + mockPlatform.requests.createEventSource.mockImplementation( + (streamUri: string = '', options: any = {}) => { + const mockEventSource = new MockEventSource(streamUri, options); + mockEventSource.simulateEvents('put', [{ data: JSON.stringify(defaultPutResponse) }]); + return mockEventSource; + }, + ); + return mockPlatform; +} + +describe('LDClientImpl.start()', () => { + afterEach(async () => { + await Promise.all(clients.map((c) => c.close())); + clients = []; + jest.resetAllMocks(); + }); + + it('returns failed status when initial context is not set', async () => { + const mockPlatform = setupStreamingPlatform(); + const { ldc } = setupClient(mockPlatform); + + const result = await ldc.start(); + expect(result).toEqual({ + status: 'failed', + error: expect.any(Error), + }); + }); + + it('returns the same promise when called multiple times', async () => { + const mockPlatform = setupStreamingPlatform(); + const { ldc } = setupClient(mockPlatform, { initialContext: context }); + + const promise1 = ldc.start(); + const promise2 = ldc.start(); + const promise3 = ldc.start(); + + expect(promise1).toBe(promise2); + expect(promise2).toBe(promise3); + + const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]); + expect(result1).toEqual(result2); + expect(result2).toEqual(result3); + expect(result1.status).toBe('complete'); + }); + + it('returns cached result after initialization completes', async () => { + const mockPlatform = setupStreamingPlatform(); + const { ldc } = setupClient(mockPlatform, { initialContext: context }); + + const result1 = await ldc.start(); + expect(result1.status).toBe('complete'); + + const result2 = await ldc.start(); + expect(result2.status).toBe('complete'); + }); + + it('resolves with complete status on successful identify', async () => { + const mockPlatform = setupStreamingPlatform(); + const { ldc } = setupClient(mockPlatform, { initialContext: context }); + + const result = await ldc.start(); + expect(result.status).toBe('complete'); + }); + + it('sets the active context after start completes', async () => { + const mockPlatform = setupStreamingPlatform(); + const { ldc } = setupClient(mockPlatform, { initialContext: context }); + + expect(ldc.getContext()).toBeUndefined(); + await ldc.start(); + expect(ldc.getContext()).toEqual(context); + }); + + describe('bootstrap data', () => { + it('presets flags from bootstrap in identifyOptions', async () => { + const mockPlatform = createBasicPlatform(); + const { ldc } = setupClient(mockPlatform, { disableNetwork: true, initialContext: context }); + + await ldc.start({ + identifyOptions: { bootstrap: goodBootstrapData }, + }); + + const flags = ldc.allFlags(); + expect(flags.killswitch).toBe(true); + expect(flags['string-flag']).toBe('is bob'); + expect(flags.cat).toBe(false); + }); + + it('presets flags from top-level bootstrap option', async () => { + const mockPlatform = createBasicPlatform(); + const { ldc } = setupClient(mockPlatform, { disableNetwork: true, initialContext: context }); + + await ldc.start({ bootstrap: goodBootstrapData }); + + const flags = ldc.allFlags(); + expect(flags.killswitch).toBe(true); + expect(flags['string-flag']).toBe('is bob'); + }); + + it('makes flags available synchronously before identify completes', async () => { + const mockPlatform = createBasicPlatform(); + const { ldc } = setupClient(mockPlatform, { disableNetwork: true, initialContext: context }); + + const startPromise = ldc.start({ + identifyOptions: { bootstrap: goodBootstrapDataWithReasons }, + }); + + const flags = ldc.allFlags(); + expect(flags['string-flag']).toBe('is bob'); + expect(flags.killswitch).toBe(true); + + await startPromise; + }); + + it('supports bootstrap data with evaluation reasons', async () => { + const mockPlatform = createBasicPlatform(); + const { ldc } = setupClient(mockPlatform, { disableNetwork: true, initialContext: context }); + + await ldc.start({ + identifyOptions: { bootstrap: goodBootstrapDataWithReasons }, + }); + + expect(ldc.jsonVariationDetail('json', undefined)).toEqual({ + reason: { kind: 'OFF' }, + value: ['a', 'b', 'c', 'd'], + variationIndex: 1, + }); + }); + + it('prefers identifyOptions.bootstrap over top-level bootstrap', async () => { + const mockPlatform = createBasicPlatform(); + const { ldc } = setupClient(mockPlatform, { disableNetwork: true, initialContext: context }); + + const differentBootstrap = { + 'other-flag': true, + $flagsState: { 'other-flag': { variation: 0, version: 1 } }, + $valid: true, + }; + + await ldc.start({ + bootstrap: goodBootstrapData, + identifyOptions: { bootstrap: differentBootstrap }, + }); + + const flags = ldc.allFlags(); + expect(flags['other-flag']).toBe(true); + expect(flags.killswitch).toBeUndefined(); + }); + + it('parses bootstrap data only once when using start()', async () => { + const readFlagsFromBootstrapSpy = jest.spyOn(bootstrapModule, 'readFlagsFromBootstrap'); + + const mockPlatform = createBasicPlatform(); + const { ldc } = setupClient(mockPlatform, { disableNetwork: true, initialContext: context }); + + await ldc.start({ + identifyOptions: { bootstrap: goodBootstrapDataWithReasons }, + }); + + expect(readFlagsFromBootstrapSpy).toHaveBeenCalledTimes(1); + expect(readFlagsFromBootstrapSpy).toHaveBeenCalledWith( + expect.anything(), + goodBootstrapDataWithReasons, + ); + + readFlagsFromBootstrapSpy.mockRestore(); + }); + }); + + describe('requiresStart guard', () => { + it('blocks identify before start when requiresStart is true', async () => { + const mockPlatform = setupStreamingPlatform(); + const { ldc, logger } = setupClient(mockPlatform, { + requiresStart: true, + initialContext: context, + }); + + const result = await ldc.identifyResult({ kind: 'user', key: 'other-user' }); + + expect(result.status).toBe('error'); + if (result.status === 'error') { + expect(result.error.message).toBe('Identify called before start'); + } + expect(logger.error).toHaveBeenCalledWith( + 'The client must be started before a context can be identified. Call start() prior to identifying a context.', + ); + }); + + it('allows identify after start when requiresStart is true', async () => { + const mockPlatform = setupStreamingPlatform(); + const { ldc } = setupClient(mockPlatform, { requiresStart: true, initialContext: context }); + + await ldc.start(); + + const result = await ldc.identifyResult({ kind: 'user', key: 'other-user' }); + expect(result.status).toBe('completed'); + }); + + it('allows identify without start when requiresStart is false', async () => { + const mockPlatform = setupStreamingPlatform(); + const { ldc } = setupClient(mockPlatform, { requiresStart: false }); + + const result = await ldc.identifyResult(context); + expect(result.status).toBe('completed'); + }); + + it('allows concurrent identifies after start', async () => { + const mockPlatform = setupStreamingPlatform(); + const { ldc } = setupClient(mockPlatform, { requiresStart: true, initialContext: context }); + + const startPromise = ldc.start(); + const promise1 = ldc.identifyResult({ kind: 'user', key: 'user-1' }); + const promise2 = ldc.identifyResult({ kind: 'user', key: 'user-2' }); + const promise3 = ldc.identifyResult({ kind: 'user', key: 'user-3' }); + + const [startResult, result1, result2, result3] = await Promise.all([ + startPromise, + promise1, + promise2, + promise3, + ]); + + expect(startResult.status).toBe('complete'); + expect(result1.status).toBe('completed'); + expect(result2.status).toBe('completed'); + expect(result3.status).toBe('completed'); + }); + }); + + describe('waitForInitialization integration', () => { + it('resolves waitForInitialization when start completes', async () => { + const mockPlatform = setupStreamingPlatform(); + const { ldc } = setupClient(mockPlatform, { initialContext: context }); + + const waitPromise = ldc.waitForInitialization({ timeout: 10 }); + const startPromise = ldc.start(); + + const [waitResult, startResult] = await Promise.all([waitPromise, startPromise]); + + expect(waitResult.status).toBe('complete'); + expect(startResult.status).toBe('complete'); + }); + }); +}); diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index 2a05f477dd..caed0f5f74 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -28,6 +28,7 @@ import { LDIdentifySuccess, LDIdentifyTimeout, type LDOptions, + LDStartOptions, LDWaitForInitializationComplete, LDWaitForInitializationFailed, LDWaitForInitializationOptions, @@ -53,6 +54,7 @@ import { } from './evaluation/evaluationDetail'; import createEventProcessor from './events/createEventProcessor'; import EventFactory from './events/EventFactory'; +import { readFlagsFromBootstrap } from './flag-manager/bootstrap'; import DefaultFlagManager, { FlagManager, LDDebugOverride } from './flag-manager/FlagManager'; import { FlagChangeType } from './flag-manager/FlagUpdater'; import { ItemDescriptor } from './flag-manager/ItemDescriptor'; @@ -98,6 +100,13 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult { protected initResolve?: (result: LDWaitForInitializationResult) => void; protected initializeResult?: LDWaitForInitializationResult; + // NOTE: this is used to ease the transition to the new initialization pattern. + // All client SDKs should set this to true with the exception of React Native which is + // still using the deprecated construct + identify pattern. + private _requiresStart: boolean = false; + protected initialContext?: LDContext; + protected startPromise?: Promise; + /** * Creates the client object synchronously. No async, no network calls. */ @@ -119,6 +128,8 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult { this._config = new ConfigurationImpl(options, internalOptions); this.logger = this._config.logger; + this._requiresStart = internalOptions?.requiresStart ?? false; + this.initialContext = internalOptions?.initialContext; this._baseHeaders = defaultHeaders( this.sdkKey, @@ -265,6 +276,71 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult { this._flagManager.presetFlags(newFlags); } + /** + * Starts the client and returns a promise that resolves to the initialization result. + * + * This method is idempotent - calling it multiple times returns the same promise. + * + * @param options Optional configuration. See {@link LDStartOptions}. + * @returns A promise that resolves to the initialization result. + */ + start(options?: LDStartOptions): Promise { + if (this.initializeResult) { + return Promise.resolve(this.initializeResult); + } + if (this.startPromise) { + return this.startPromise; + } + if (!this.initialContext) { + this.logger.error('Initial context not set'); + return Promise.resolve({ status: 'failed', error: new Error('Initial context not set') }); + } + + const identifyOptions: LDIdentifyOptions = { + ...(options?.identifyOptions ?? {}), + + // Initial identify operations are not sheddable. + sheddable: false, + }; + + // If the bootstrap data is provided in the start options, and the identify options do not + // have bootstrap data, then use the bootstrap data from the start options. + if (options?.bootstrap && !identifyOptions.bootstrap) { + identifyOptions.bootstrap = options.bootstrap; + } + + if (identifyOptions.bootstrap) { + try { + if (!identifyOptions.bootstrapParsed) { + identifyOptions.bootstrapParsed = readFlagsFromBootstrap( + this.logger, + identifyOptions.bootstrap, + ); + } + if (identifyOptions.bootstrapParsed) { + this.presetFlags(identifyOptions.bootstrapParsed); + } + } catch (error) { + this.logger.error('Failed to bootstrap data', error); + } + } + + if (!this.initializedPromise) { + this.initializedPromise = new Promise((resolve) => { + this.initResolve = resolve; + }); + } + + this.startPromise = this._promiseWithTimeout( + this.initializedPromise!, + options?.timeout ?? 5, + 'start', + ); + + this.identifyResult(this.initialContext!, identifyOptions); + return this.startPromise; + } + /** * Identifies a context to LaunchDarkly. See {@link LDClient.identify}. * @@ -307,6 +383,13 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult { pristineContext: LDContext, identifyOptions?: LDIdentifyOptions, ): Promise { + if (this._requiresStart && !this.startPromise) { + this.logger.error( + 'The client must be started before a context can be identified. Call start() prior to identifying a context.', + ); + return { status: 'error', error: new Error('Identify called before start') }; + } + const identifyTimeout = identifyOptions?.timeout ?? DEFAULT_IDENTIFY_TIMEOUT_SECONDS; const noTimeout = identifyOptions?.timeout === undefined && identifyOptions?.noTimeout === true; @@ -441,7 +524,7 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult { // If waitForInitialization was previously called, then return the promise with a timeout. // This condition should only be triggered if waitForInitialization was called multiple times. if (this.initializedPromise) { - return this.promiseWithTimeout(this.initializedPromise, timeout); + return this._promiseWithTimeout(this.initializedPromise, timeout); } // Create a new promise for tracking initialization @@ -451,26 +534,23 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult { }); } - return this.promiseWithTimeout(this.initializedPromise, timeout); + return this._promiseWithTimeout(this.initializedPromise, timeout); } /** - * Apply a timeout promise to a base promise. This is for use with waitForInitialization. + * Apply a timeout promise to a base promise. This is for use with waitForInitialization + * and start. * * @param basePromise The promise to race against a timeout. * @param timeout The timeout in seconds. * @returns A promise that resolves to the initialization result or timeout. - * - * @privateRemarks - * This method is protected because it is used by the browser SDK's `start` method. - * Eventually, the start method will be moved to this common implementation and this method will - * be made private. */ - protected promiseWithTimeout( + private _promiseWithTimeout( basePromise: Promise, timeout: number, + label: string = 'waitForInitialization', ): Promise { - const cancelableTimeout = cancelableTimedPromise(timeout, 'waitForInitialization'); + const cancelableTimeout = cancelableTimedPromise(timeout, label); return Promise.race([ basePromise.then((res: LDWaitForInitializationResult) => { cancelableTimeout.cancel(); diff --git a/packages/shared/sdk-client/src/api/LDStartOptions.ts b/packages/shared/sdk-client/src/api/LDStartOptions.ts new file mode 100644 index 0000000000..7b618d2fb4 --- /dev/null +++ b/packages/shared/sdk-client/src/api/LDStartOptions.ts @@ -0,0 +1,20 @@ +import { LDIdentifyOptions } from './LDIdentifyOptions'; +import { LDWaitForInitializationOptions } from './LDWaitForInitialization'; + +export interface LDStartOptions extends LDWaitForInitializationOptions { + /** + * Optional bootstrap data to use for the identify operation. + * If {@link LDIdentifyOptions.bootstrap} is provided in identifyOptions, it takes precedence. + */ + bootstrap?: unknown; + + /** + * Optional identify options to use for the identify operation. + * See {@link LDIdentifyOptions} for more information. + * + * @remarks + * Since the first identify option should never be sheddable, the sheddable option is omitted + * from the interface to avoid confusion. + */ + identifyOptions?: Omit; +} diff --git a/packages/shared/sdk-client/src/api/index.ts b/packages/shared/sdk-client/src/api/index.ts index 9c78ce1965..de03c367fb 100644 --- a/packages/shared/sdk-client/src/api/index.ts +++ b/packages/shared/sdk-client/src/api/index.ts @@ -10,6 +10,7 @@ export * from './LDIdentifyOptions'; export * from './LDInspection'; export * from './LDIdentifyResult'; export * from './LDPlugin'; +export * from './LDStartOptions'; export * from './LDWaitForInitialization'; export * from './LDContext'; export * from './datasource'; diff --git a/packages/shared/sdk-client/src/configuration/Configuration.ts b/packages/shared/sdk-client/src/configuration/Configuration.ts index d82b254dc3..de296ec2bd 100644 --- a/packages/shared/sdk-client/src/configuration/Configuration.ts +++ b/packages/shared/sdk-client/src/configuration/Configuration.ts @@ -10,6 +10,7 @@ import { } from '@launchdarkly/js-sdk-common'; import { Hook, type LDOptions } from '../api'; +import { LDContext } from '../api/LDContext'; import { LDInspection } from '../api/LDInspection'; import type { InternalDataSystemOptions, @@ -26,6 +27,17 @@ export interface LDClientInternalOptions extends internal.LDInternalOptions { credentialType: 'clientSideId' | 'mobileKey'; getLegacyStorageKeys?: () => string[]; dataSystemDefaults?: PlatformDataSystemDefaults; + + /** + * When true, the SDK requires `start()` to be called before `identify()`. + * Set this value to `true` to use the new initialization pattern. + */ + requiresStart?: boolean; + + /** + * The initial context to use when starting the client. + */ + initialContext?: LDContext; } export interface Configuration { diff --git a/packages/shared/sdk-client/src/index.ts b/packages/shared/sdk-client/src/index.ts index 4beba89b8d..951ddc078c 100644 --- a/packages/shared/sdk-client/src/index.ts +++ b/packages/shared/sdk-client/src/index.ts @@ -42,6 +42,7 @@ export type { LDWaitForInitializationComplete, LDWaitForInitializationFailed, LDWaitForInitializationTimeout, + LDStartOptions, LDContext, LDContextStrict, } from './api';