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
94 changes: 9 additions & 85 deletions packages/sdk/browser/src/BrowserClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import {
AutoEnvAttributes,
base64UrlEncode,
BasicLogger,
cancelableTimedPromise,
Configuration,
Encoding,
FlagManager,
Expand All @@ -16,6 +15,8 @@ import {
LDHeaders,
LDIdentifyResult,
LDPluginEnvironmentMetadata,
LDWaitForInitializationOptions,
LDWaitForInitializationResult,
Platform,
safeRegisterDebugOverridePlugins,
} from '@launchdarkly/js-client-sdk-common';
Expand All @@ -27,15 +28,7 @@ import { BrowserIdentifyOptions as LDIdentifyOptions } from './BrowserIdentifyOp
import { registerStateDetection } from './BrowserStateDetector';
import GoalManager from './goals/GoalManager';
import { Goal, isClick } from './goals/Goals';
import {
LDClient,
LDStartOptions,
LDWaitForInitializationComplete,
LDWaitForInitializationFailed,
LDWaitForInitializationOptions,
LDWaitForInitializationResult,
LDWaitForInitializationTimeout,
} from './LDClient';
import { LDClient, LDStartOptions } from './LDClient';
import { LDPlugin } from './LDPlugin';
import validateBrowserOptions, { BrowserOptions, filterToBaseOptionsWithDefaults } from './options';
import BrowserPlatform from './platform/BrowserPlatform';
Expand All @@ -44,13 +37,6 @@ class BrowserClientImpl extends LDClientImpl {
private readonly _goalManager?: GoalManager;
private readonly _plugins?: LDPlugin[];

// The initialized promise is used to track the initialization state of the client.
// This is separate from the start promise because the start promise could time out before
// the initialization is complete.
private _initializedPromise?: Promise<LDWaitForInitializationResult>;
private _initResolve?: (result: LDWaitForInitializationResult) => void;
private _initializeResult?: LDWaitForInitializationResult;

private _initialContext?: LDContext;

// NOTE: This also keeps track of when we tried to initialize the client.
Expand Down Expand Up @@ -252,21 +238,14 @@ class BrowserClientImpl extends LDClientImpl {
}

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;
}

start(options?: LDStartOptions): Promise<LDWaitForInitializationResult> {
if (this._initializeResult) {
return Promise.resolve(this._initializeResult);
if (this.initializeResult) {
return Promise.resolve(this.initializeResult);
}
if (this._startPromise) {
return this._startPromise;
Expand Down Expand Up @@ -300,73 +279,18 @@ class BrowserClientImpl extends LDClientImpl {
}
}

if (!this._initializedPromise) {
this._initializedPromise = new Promise((resolve) => {
this._initResolve = resolve;
if (!this.initializedPromise) {
this.initializedPromise = new Promise((resolve) => {
this.initResolve = resolve;
});
}

this._startPromise = this._promiseWithTimeout(this._initializedPromise, options?.timeout ?? 5);
this._startPromise = this.promiseWithTimeout(this.initializedPromise!, options?.timeout ?? 5);

this.identifyResult(this._initialContext!, identifyOptions);
return this._startPromise;
}

waitForInitialization(
options?: LDWaitForInitializationOptions,
): Promise<LDWaitForInitializationResult> {
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<LDWaitForInitializationResult>,
timeout: number,
): Promise<LDWaitForInitializationResult> {
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'.
Expand Down
59 changes: 2 additions & 57 deletions packages/sdk/browser/src/LDClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,67 +2,12 @@ import {
LDClient as CommonClient,
LDContext,
LDIdentifyResult,
LDWaitForInitializationOptions,
LDWaitForInitializationResult,
} from '@launchdarkly/js-client-sdk-common';

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;

export interface LDStartOptions extends LDWaitForInitializationOptions {
/**
* Optional bootstrap data to use for the identify operation. If {@link LDIdentifyOptions.bootstrap} is provided, it will be ignored.
Expand Down
5 changes: 5 additions & 0 deletions packages/sdk/browser/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ export type {
LDIdentifyTimeout,
LDIdentifyShed,
LDDebugOverride,
LDWaitForInitializationOptions,
LDWaitForInitializationResult,
LDWaitForInitializationComplete,
LDWaitForInitializationFailed,
LDWaitForInitializationTimeout,
} from '@launchdarkly/js-client-sdk-common';

/**
Expand Down
120 changes: 120 additions & 0 deletions packages/shared/sdk-client/__tests__/LDClientImpl.timeout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,123 @@ describe('sdk-client identify timeout', () => {
expect(logger.warn).not.toHaveBeenCalledWith(expect.stringMatching(/timeout greater/));
});
});

describe('sdk-client waitForInitialization', () => {
beforeAll(() => {
jest.useFakeTimers();
});

beforeEach(() => {
defaultPutResponse = clone<Flags>(mockResponseJson);

mockPlatform.requests.createEventSource.mockImplementation(
(streamUri: string = '', options: any = {}) => {
mockEventSource = new MockEventSource(streamUri, options);
mockEventSource.simulateEvents('put', simulatedEvents);
return mockEventSource;
},
);

ldc = new LDClientImpl(
testSdkKey,
AutoEnvAttributes.Enabled,
mockPlatform,
{
logger,
sendEvents: false,
},
makeTestDataManagerFactory(testSdkKey, mockPlatform),
);
});

afterEach(() => {
jest.resetAllMocks();
});

it('blocks until the client is ready when waitForInitialization is called', async () => {
simulatedEvents = [{ data: JSON.stringify(defaultPutResponse) }];

const waitPromise = ldc.waitForInitialization({ timeout: 10 });
const identifyPromise = ldc.identifyResult(carContext);

jest.advanceTimersByTimeAsync(DEFAULT_IDENTIFY_TIMEOUT).then();

await Promise.all([waitPromise, identifyPromise]);

await expect(waitPromise).resolves.toEqual({ status: 'complete' });
await expect(identifyPromise).resolves.toEqual({ status: 'completed' });
});

it('can call waitForInitialization multiple times', async () => {
simulatedEvents = [{ data: JSON.stringify(defaultPutResponse) }];

const waitPromise = ldc.waitForInitialization({ timeout: 10 });
const identifyPromise = ldc.identifyResult(carContext);

jest.advanceTimersByTimeAsync(DEFAULT_IDENTIFY_TIMEOUT).then();

await Promise.all([waitPromise, identifyPromise]);

await expect(waitPromise).resolves.toEqual({ status: 'complete' });
await expect(identifyPromise).resolves.toEqual({ status: 'completed' });

const waitPromise2 = ldc.waitForInitialization({ timeout: 10 });
await expect(waitPromise2).resolves.toEqual({ status: 'complete' });
});

it('resolves waitForInitialization with timeout status when initialization does not complete before the timeout', async () => {
// set simulated events to be empty so initialization does not complete
simulatedEvents = [];

const waitPromise = ldc.waitForInitialization({ timeout: 10 });
const identifyPromise = ldc.identifyResult(carContext);

jest.advanceTimersByTimeAsync(10 * 1000 + 1).then();

await Promise.all([waitPromise, identifyPromise]);

await expect(waitPromise).resolves.toEqual({ status: 'timeout' });
await expect(identifyPromise).resolves.toEqual({
timeout: DEFAULT_IDENTIFY_TIMEOUT,
status: 'timeout',
});
});

it('resolves waitForInitialization with failed status immediately when identify fails', async () => {
const errorPlatform = createBasicPlatform();
const identifyError = new Error('Network error');

// Mock fetch to reject with an error
errorPlatform.requests.createEventSource.mockImplementation(() => {
throw identifyError;
});

const errorLdc = new LDClientImpl(
testSdkKey,
AutoEnvAttributes.Enabled,
errorPlatform,
{
logger,
sendEvents: false,
},
makeTestDataManagerFactory(testSdkKey, errorPlatform),
);

const waitPromise = errorLdc.waitForInitialization({ timeout: 10 });
const identifyPromise = errorLdc.identifyResult(carContext);

// Advance timers to allow error handler to be set up and error to propagate
await jest.advanceTimersByTimeAsync(DEFAULT_IDENTIFY_TIMEOUT);
jest.runAllTicks();

const identifyResult = await identifyPromise;

expect(identifyResult.status).toBe('error');

// Verify that waitForInitialization returns immediately with failed status
await expect(waitPromise).resolves.toEqual({
status: 'failed',
error: identifyError,
});
});
});
Loading