diff --git a/packages/sdk/browser/__tests__/BrowserClient.test.ts b/packages/sdk/browser/__tests__/BrowserClient.test.ts index 0d06abde70..bd6a7e3cae 100644 --- a/packages/sdk/browser/__tests__/BrowserClient.test.ts +++ b/packages/sdk/browser/__tests__/BrowserClient.test.ts @@ -373,4 +373,132 @@ describe('given a mock platform for a BrowserClient', () => { // With events and goals disabled the only fetch calls should be for polling requests. expect(platform.requests.fetch.mock.calls.length).toBe(3); }); + + it('blocks until the client is ready when waitForInitialization is called', async () => { + const client = makeClient( + 'client-side-id', + AutoEnvAttributes.Disabled, + { streaming: false, logger, diagnosticOptOut: true, sendEvents: false, fetchGoals: false }, + platform, + ); + + const waitPromise = client.waitForInitialization({ timeout: 10 }); + const identifyPromise = client.identify({ key: 'user-key', kind: 'user' }); + + await Promise.all([waitPromise, identifyPromise]); + + await expect(waitPromise).resolves.toEqual({ status: 'complete' }); + await expect(identifyPromise).resolves.toEqual({ status: 'completed' }); + }); + + it('resolves waitForInitialization with timeout status when initialization does not complete before the timeout', async () => { + jest.useRealTimers(); + + // Create a platform with a delayed fetch response + const delayedPlatform = makeBasicPlatform(); + let resolveFetch: (value: any) => void; + const delayedFetchPromise = new Promise((resolve) => { + resolveFetch = resolve; + }); + + // Mock fetch to return a promise that won't resolve until we explicitly resolve it + delayedPlatform.requests.fetch = jest.fn((_url: string, _options: any) => + delayedFetchPromise.then(() => ({})), + ) as any; + + const client = makeClient( + 'client-side-id', + AutoEnvAttributes.Disabled, + { streaming: false, logger, diagnosticOptOut: true, sendEvents: false, fetchGoals: false }, + delayedPlatform, + ); + + // Start identify which will trigger a fetch that won't complete + client.identify({ key: 'user-key', kind: 'user' }); + + // Call waitForInitialization with a short timeout (0.1 seconds) + const waitPromise = client.waitForInitialization({ timeout: 0.1 }); + + // Verify that waitForInitialization rejects with a timeout error + await expect(waitPromise).resolves.toEqual({ status: 'timeout' }); + + // Clean up: resolve the fetch to avoid hanging promises and restore fake timers + resolveFetch!({}); + jest.useFakeTimers(); + }); + + it('resolves waitForInitialization with failed status immediately when identify fails', async () => { + const errorPlatform = makeBasicPlatform(); + const identifyError = new Error('Network error'); + + // Mock fetch to reject with an error + errorPlatform.requests.fetch = jest.fn((_url: string, _options: any) => + Promise.reject(identifyError), + ) as any; + + const client = makeClient( + 'client-side-id', + AutoEnvAttributes.Disabled, + { streaming: false, logger, diagnosticOptOut: true, sendEvents: false, fetchGoals: false }, + errorPlatform, + ); + + // Call waitForInitialization first - this creates the promise + const waitPromise = client.waitForInitialization({ timeout: 10 }); + + // Start identify which will fail + const identifyPromise = client.identify({ key: 'user-key', kind: 'user' }); + + await jest.advanceTimersByTimeAsync(4000); // trigger all poll retries + + // Wait for identify to fail + await expect(identifyPromise).resolves.toEqual({ + status: 'error', + error: identifyError, + }); + + // Verify that waitForInitialization returns immediately with failed status + await expect(waitPromise).resolves.toEqual({ + status: 'failed', + error: identifyError, + }); + }); + + it('resolves waitForInitialization with failed status when identify fails before waitForInitialization is called', async () => { + const errorPlatform = makeBasicPlatform(); + const identifyError = new Error('Network error'); + + // Mock fetch to reject with an error + errorPlatform.requests.fetch = jest.fn((_url: string, _options: any) => + Promise.reject(identifyError), + ) as any; + + const client = makeClient( + 'client-side-id', + AutoEnvAttributes.Disabled, + { streaming: false, logger, diagnosticOptOut: true, sendEvents: false, fetchGoals: false }, + errorPlatform, + ); + + // Start identify which will fail BEFORE waitForInitialization is called + const identifyPromise = client.identify({ key: 'user-key', kind: 'user' }); + + await jest.advanceTimersByTimeAsync(4000); // trigger all poll retries + + // Wait for identify to fail + await expect(identifyPromise).resolves.toEqual({ + status: 'error', + error: identifyError, + }); + + // Now call waitForInitialization AFTER identify has already failed + // It should return the failed status immediately, not timeout + const waitPromise = client.waitForInitialization({ timeout: 10 }); + + // Verify that waitForInitialization returns immediately with failed status + await expect(waitPromise).resolves.toEqual({ + status: 'failed', + error: identifyError, + }); + }); }); diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index abb9ceb4c2..454206aad0 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -2,6 +2,7 @@ import { AutoEnvAttributes, base64UrlEncode, BasicLogger, + cancelableTimedPromise, Configuration, Encoding, FlagManager, @@ -24,7 +25,14 @@ import { BrowserIdentifyOptions as LDIdentifyOptions } from './BrowserIdentifyOp import { registerStateDetection } from './BrowserStateDetector'; import GoalManager from './goals/GoalManager'; import { Goal, isClick } from './goals/Goals'; -import { LDClient } from './LDClient'; +import { + LDClient, + LDWaitForInitializationComplete, + LDWaitForInitializationFailed, + LDWaitForInitializationOptions, + LDWaitForInitializationResult, + LDWaitForInitializationTimeout, +} from './LDClient'; import { LDPlugin } from './LDPlugin'; import validateBrowserOptions, { BrowserOptions, filterToBaseOptionsWithDefaults } from './options'; import BrowserPlatform from './platform/BrowserPlatform'; @@ -32,6 +40,9 @@ import BrowserPlatform from './platform/BrowserPlatform'; class BrowserClientImpl extends LDClientImpl { private readonly _goalManager?: GoalManager; private readonly _plugins?: LDPlugin[]; + private _initializedPromise?: Promise; + private _initResolve?: (result: LDWaitForInitializationResult) => void; + private _initializeResult?: LDWaitForInitializationResult; constructor( clientSideId: string, @@ -212,10 +223,73 @@ class BrowserClientImpl extends LDClientImpl { identifyOptionsWithUpdatedDefaults.sheddable = true; } const res = await super.identifyResult(context, identifyOptionsWithUpdatedDefaults); + if (res.status === 'completed') { + this._initializeResult = { status: 'complete' }; + this._initResolve?.(this._initializeResult); + } else if (res.status === 'error') { + this._initializeResult = { status: 'failed', error: res.error }; + this._initResolve?.(this._initializeResult); + } + this._goalManager?.startTracking(); return res; } + waitForInitialization( + options?: LDWaitForInitializationOptions, + ): Promise { + const timeout = options?.timeout ?? 5; + + // If initialization has already completed (successfully or failed), return the result immediately. + if (this._initializeResult) { + return Promise.resolve(this._initializeResult); + } + + // It 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); + } + + if (!this._initializedPromise) { + this._initializedPromise = new Promise((resolve) => { + this._initResolve = resolve; + }); + } + + return this._promiseWithTimeout(this._initializedPromise, timeout); + } + + /** + * Apply a timeout promise to a base promise. This is for use with waitForInitialization. + * + * @param basePromise The promise to race against a timeout. + * @param timeout The timeout in seconds. + * @param logger A logger to log when the timeout expires. + * @returns + */ + private _promiseWithTimeout( + basePromise: Promise, + timeout: number, + ): Promise { + const cancelableTimeout = cancelableTimedPromise(timeout, 'waitForInitialization'); + return Promise.race([ + basePromise.then((res: LDWaitForInitializationResult) => { + cancelableTimeout.cancel(); + return res; + }), + cancelableTimeout.promise + // If the promise resolves without error, then the initialization completed successfully. + // NOTE: this should never return as the resolution would only be triggered by the basePromise + // being resolved. + .then(() => ({ status: 'complete' }) as LDWaitForInitializationComplete) + .catch(() => ({ status: 'timeout' }) as LDWaitForInitializationTimeout), + ]).catch((reason) => { + this.logger?.error(reason.message); + return { status: 'failed', error: reason as Error } as LDWaitForInitializationFailed; + }); + } + setStreaming(streaming?: boolean): void { // With FDv2 we may want to consider if we support connection mode directly. // Maybe with an extension to connection mode for 'automatic'. @@ -282,6 +356,8 @@ export function makeClient( close: () => impl.close(), allFlags: () => impl.allFlags(), addHook: (hook: Hook) => impl.addHook(hook), + waitForInitialization: (waitOptions?: LDWaitForInitializationOptions) => + impl.waitForInitialization(waitOptions), logger: impl.logger, }; diff --git a/packages/sdk/browser/src/LDClient.ts b/packages/sdk/browser/src/LDClient.ts index 6c4f515eef..3480a75911 100644 --- a/packages/sdk/browser/src/LDClient.ts +++ b/packages/sdk/browser/src/LDClient.ts @@ -6,6 +6,63 @@ import { import { BrowserIdentifyOptions as LDIdentifyOptions } from './BrowserIdentifyOptions'; +/** + * @ignore + * Currently these options and the waitForInitialization method signiture will mirror the one + * that is defined in the server common. We will be consolidating this mehod so that it will + * be common to all sdks in the future. + */ +/** + * Options for the waitForInitialization method. + */ +export interface LDWaitForInitializationOptions { + /** + * The timeout duration in seconds to wait for initialization before resolving the promise. + * If exceeded, the promise will resolve to a {@link LDWaitForInitializationTimeout} object. + * + * If no options are specified on the `waitForInitialization`, the default timeout of 5 seconds will be used. + * + * Using a high timeout, or no timeout, is not recommended because it could result in a long + * delay when conditions prevent successful initialization. + * + * A value of 0 will cause the promise to resolve without waiting. In that scenario it would be + * more effective to not call `waitForInitialization`. + * + * @default 5 seconds + */ + timeout: number; +} + +/** + * The waitForInitialization operation failed. + */ +export interface LDWaitForInitializationFailed { + status: 'failed'; + error: Error; +} + +/** + * The waitForInitialization operation timed out. + */ +export interface LDWaitForInitializationTimeout { + status: 'timeout'; +} + +/** + * The waitForInitialization operation completed successfully. + */ +export interface LDWaitForInitializationComplete { + status: 'complete'; +} + +/** + * The result of the waitForInitialization operation. + */ +export type LDWaitForInitializationResult = + | LDWaitForInitializationFailed + | LDWaitForInitializationTimeout + | LDWaitForInitializationComplete; + /** * * The LaunchDarkly SDK client object. @@ -66,4 +123,39 @@ export type LDClient = Omit< pristineContext: LDContext, identifyOptions?: LDIdentifyOptions, ): Promise; + + /** + * Returns a Promise that tracks the client's initialization state. + * + * The Promise will be resolved to a {@link LDWaitForInitializationResult} object containing the + * status of the waitForInitialization operation. + * + * @example + * This example shows use of async/await syntax for specifying handlers: + * ``` + * const result = await client.waitForInitialization({ timeout: 5 }); + * + * if (result.status === 'complete') { + * doSomethingWithSuccessfullyInitializedClient(); + * } else if (result.status === 'failed') { + * doSomethingForFailedStartup(result.error); + * } else if (result.status === 'timeout') { + * doSomethingForTimedOutStartup(); + * } + * ``` + * + * @remarks + * You can also use event listeners ({@link on}) for the same purpose: the event `"initialized"` + * indicates success, and `"error"` indicates an error. + * + * @param options + * Optional configuration. Please see {@link LDWaitForInitializationOptions}. + * + * @returns + * A Promise that will be resolved to a {@link LDWaitForInitializationResult} object containing the + * status of the waitForInitialization operation. + */ + waitForInitialization( + options?: LDWaitForInitializationOptions, + ): Promise; }; diff --git a/packages/sdk/browser/src/compat/LDClientCompat.ts b/packages/sdk/browser/src/compat/LDClientCompat.ts index ab88392725..21fc1c0c81 100644 --- a/packages/sdk/browser/src/compat/LDClientCompat.ts +++ b/packages/sdk/browser/src/compat/LDClientCompat.ts @@ -14,7 +14,7 @@ import { LDClient as LDCLientBrowser } from '../LDClient'; */ export interface LDClient extends Omit< LDCLientBrowser, - 'close' | 'flush' | 'identify' | 'identifyResult' + 'close' | 'flush' | 'identify' | 'identifyResult' | 'waitForInitialization' > { /** * Identifies a context to LaunchDarkly. diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index 5253275b2e..0e45544483 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -345,6 +345,7 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult { if (res.status === 'shed') { return { status: 'shed' } as LDIdentifyShed; } + this.emitter.emit('initialized'); return { status: 'completed' } as LDIdentifySuccess; }); diff --git a/packages/shared/sdk-client/src/LDEmitter.ts b/packages/shared/sdk-client/src/LDEmitter.ts index 76705f90c3..cbd4b4a4d7 100644 --- a/packages/shared/sdk-client/src/LDEmitter.ts +++ b/packages/shared/sdk-client/src/LDEmitter.ts @@ -6,7 +6,7 @@ type FlagChangeKey = `change:${string}`; * Type for name of emitted events. 'change' is used for all flag changes. 'change:flag-name-here' is used * for specific flag changes. */ -export type EventName = 'change' | FlagChangeKey | 'dataSourceStatus' | 'error'; +export type EventName = 'change' | FlagChangeKey | 'dataSourceStatus' | 'error' | 'initialized'; /** * Implementation Note: There should not be any default listeners for change events in a client