diff --git a/packages/sdk/browser/__tests__/BrowserClient.plugins.test.ts b/packages/sdk/browser/__tests__/BrowserClient.plugins.test.ts index bc4c8e4e1e..60bbef86a5 100644 --- a/packages/sdk/browser/__tests__/BrowserClient.plugins.test.ts +++ b/packages/sdk/browser/__tests__/BrowserClient.plugins.test.ts @@ -41,8 +41,10 @@ it('registers plugins and executes hooks during initialization', async () => { const platform = makeBasicPlatform(); + const context: LDContext = { key: 'user-key', kind: 'user' }; const client = makeClient( 'client-side-id', + context, AutoEnvAttributes.Disabled, { streaming: false, @@ -55,10 +57,7 @@ it('registers plugins and executes hooks during initialization', async () => { // Verify the plugin was registered expect(mockPlugin.register).toHaveBeenCalled(); - - // Now test that hooks work by calling identify and variation - const context: LDContext = { key: 'user-key', kind: 'user' }; - await client.identify(context); + await client.start(); expect(mockHook.beforeIdentify).toHaveBeenCalledWith({ context, timeout: undefined }, {}); @@ -134,6 +133,7 @@ it('registers multiple plugins and executes all hooks', async () => { const client = makeClient( 'client-side-id', + { key: 'user-key', kind: 'user' }, AutoEnvAttributes.Disabled, { streaming: false, @@ -149,7 +149,7 @@ it('registers multiple plugins and executes all hooks', async () => { expect(mockPlugin2.register).toHaveBeenCalled(); // Test that both hooks work - await client.identify({ key: 'user-key', kind: 'user' }); + await client.start(); client.variation('flag-key', false); client.track('event-key', { data: true }, 42); @@ -200,6 +200,7 @@ it('passes correct environmentMetadata to plugin getHooks and register functions makeClient( 'client-side-id', + { kind: 'user', key: '', anonymous: true }, AutoEnvAttributes.Disabled, { streaming: false, @@ -281,6 +282,7 @@ it('passes correct environmentMetadata without optional fields', async () => { makeClient( 'client-side-id', + { kind: 'user', key: '', anonymous: true }, AutoEnvAttributes.Disabled, { streaming: false, diff --git a/packages/sdk/browser/__tests__/BrowserClient.test.ts b/packages/sdk/browser/__tests__/BrowserClient.test.ts index 089d19286e..af9b61c25e 100644 --- a/packages/sdk/browser/__tests__/BrowserClient.test.ts +++ b/packages/sdk/browser/__tests__/BrowserClient.test.ts @@ -29,6 +29,7 @@ describe('given a mock platform for a BrowserClient', () => { it('includes urls in custom events', async () => { const client = makeClient( 'client-side-id', + { key: 'user-key', kind: 'user' }, AutoEnvAttributes.Disabled, { streaming: false, @@ -37,7 +38,7 @@ describe('given a mock platform for a BrowserClient', () => { }, platform, ); - await client.identify({ key: 'user-key', kind: 'user' }); + await client.start(); await client.flush(); client.track('user-key', undefined, 1); await client.flush(); @@ -58,6 +59,7 @@ describe('given a mock platform for a BrowserClient', () => { it('can filter URLs in custom events', async () => { const client = makeClient( 'client-side-id', + { key: 'user-key', kind: 'user' }, AutoEnvAttributes.Disabled, { streaming: false, @@ -68,7 +70,7 @@ describe('given a mock platform for a BrowserClient', () => { }, platform, ); - await client.identify({ key: 'user-key', kind: 'user' }); + await client.start(); await client.flush(); client.track('user-key', undefined, 1); await client.flush(); @@ -92,6 +94,7 @@ describe('given a mock platform for a BrowserClient', () => { it('can filter URLs in click events', async () => { const client = makeClient( 'client-side-id', + { key: 'user-key', kind: 'user' }, AutoEnvAttributes.Disabled, { streaming: false, @@ -102,7 +105,7 @@ describe('given a mock platform for a BrowserClient', () => { }, platform, ); - await client.identify({ key: 'user-key', kind: 'user' }); + await client.start(); await client.flush(); // Simulate a click event @@ -135,6 +138,7 @@ describe('given a mock platform for a BrowserClient', () => { it('can filter URLs in pageview events', async () => { const client = makeClient( 'client-side-id', + { key: 'user-key', kind: 'user' }, AutoEnvAttributes.Disabled, { streaming: false, @@ -146,7 +150,7 @@ describe('given a mock platform for a BrowserClient', () => { platform, ); - await client.identify({ key: 'user-key', kind: 'user' }); + await client.start(); await client.flush(); const events = JSON.parse(platform.requests.fetch.mock.calls[2][1].body); @@ -165,6 +169,7 @@ describe('given a mock platform for a BrowserClient', () => { it('can use bootstrap data', async () => { const client = makeClient( 'client-side-id', + { kind: 'user', key: 'bob' }, AutoEnvAttributes.Disabled, { streaming: false, @@ -173,12 +178,12 @@ describe('given a mock platform for a BrowserClient', () => { }, platform, ); - await client.identify( - { kind: 'user', key: 'bob' }, - { + + await client.start({ + identifyOptions: { bootstrap: goodBootstrapDataWithReasons, }, - ); + }); expect(client.jsonVariationDetail('json', undefined)).toEqual({ reason: { @@ -192,6 +197,7 @@ describe('given a mock platform for a BrowserClient', () => { it('can evaluate flags with bootstrap data before identify completes', async () => { const client = makeClient( 'client-side-id', + { kind: 'user', key: 'bob' }, AutoEnvAttributes.Disabled, { streaming: false, @@ -201,12 +207,11 @@ describe('given a mock platform for a BrowserClient', () => { platform, ); - const identifyPromise = client.identify( - { kind: 'user', key: 'bob' }, - { + const identifyPromise = client.start({ + identifyOptions: { bootstrap: goodBootstrapDataWithReasons, }, - ); + }); const flagValue = client.jsonVariationDetail('json', undefined); expect(flagValue).toEqual({ @@ -229,18 +234,26 @@ describe('given a mock platform for a BrowserClient', () => { it('can shed intermediate identify calls', async () => { const client = makeClient( 'client-side-id', + { key: 'user-key-0', kind: 'user' }, AutoEnvAttributes.Disabled, { streaming: false, logger, diagnosticOptOut: true, sendEvents: false, fetchGoals: false }, platform, ); + const promise0 = client.start(); const promise1 = client.identify({ key: 'user-key-1', kind: 'user' }); const promise2 = client.identify({ key: 'user-key-2', kind: 'user' }); const promise3 = client.identify({ key: 'user-key-3', kind: 'user' }); - const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]); + const [result0, result1, result2, result3] = await Promise.all([ + promise0, + promise1, + promise2, + promise3, + ]); - expect(result1).toEqual({ status: 'completed' }); + expect(result0).toEqual({ status: 'complete' }); + expect(result1).toEqual({ status: 'shed' }); expect(result2).toEqual({ status: 'shed' }); expect(result3).toEqual({ status: 'completed' }); // With events and goals disabled the only fetch calls should be for polling requests. @@ -251,6 +264,7 @@ describe('given a mock platform for a BrowserClient', () => { const order: string[] = []; const client = makeClient( 'client-side-id', + { kind: 'user', key: 'user-key-0' }, AutoEnvAttributes.Disabled, { streaming: false, @@ -277,18 +291,21 @@ describe('given a mock platform for a BrowserClient', () => { platform, ); + await client.start(); + const promise1 = client.identify({ key: 'user-key-1', kind: 'user' }); const promise2 = client.identify({ key: 'user-key-2', kind: 'user' }); const promise3 = client.identify({ key: 'user-key-3', kind: 'user' }); await Promise.all([promise1, promise2, promise3]); - expect(order).toEqual(['user-key-1', 'user-key-2', 'user-key-3']); + expect(order).toEqual(['user-key-0', 'user-key-1', 'user-key-2', 'user-key-3']); }); it('completes identify calls in order', async () => { const order: string[] = []; const client = makeClient( 'client-side-id', + { key: 'user-key-1', kind: 'user' }, AutoEnvAttributes.Disabled, { streaming: false, @@ -318,7 +335,7 @@ describe('given a mock platform for a BrowserClient', () => { platform, ); - const promise1 = client.identify({ key: 'user-key-1', kind: 'user' }); + const promise1 = client.start(); const promise2 = client.identify({ key: 'user-key-2', kind: 'user' }); const promise3 = client.identify({ key: 'user-key-3', kind: 'user' }); @@ -331,6 +348,7 @@ describe('given a mock platform for a BrowserClient', () => { const order: string[] = []; const client = makeClient( 'client-side-id', + { key: 'user-key-0', kind: 'user' }, AutoEnvAttributes.Disabled, { streaming: false, @@ -360,6 +378,8 @@ describe('given a mock platform for a BrowserClient', () => { platform, ); + await client.start(); + const result1 = await client.identify({ key: 'user-key-1', kind: 'user' }); const result2 = await client.identify({ key: 'user-key-2', kind: 'user' }); const result3 = await client.identify({ key: 'user-key-3', kind: 'user' }); @@ -369,17 +389,20 @@ describe('given a mock platform for a BrowserClient', () => { expect(result3.status).toEqual('completed'); // user-key-2 is shed, so it is not included in the order - expect(order).toEqual(['user-key-1', 'user-key-2', 'user-key-3']); + expect(order).toEqual(['user-key-0', 'user-key-1', 'user-key-2', 'user-key-3']); }); it('can shed intermediate identify calls without waiting for results', async () => { const client = makeClient( 'client-side-id', + { key: 'user-key-0', kind: 'user' }, AutoEnvAttributes.Disabled, { streaming: false, logger, diagnosticOptOut: true, sendEvents: false, fetchGoals: false }, platform, ); + await client.start(); + const promise1 = client.identify({ key: 'user-key-1', kind: 'user' }); const promise2 = client.identify({ key: 'user-key-2', kind: 'user' }); const promise3 = client.identify({ key: 'user-key-3', kind: 'user' }); @@ -387,17 +410,20 @@ describe('given a mock platform for a BrowserClient', () => { await Promise.all([promise1, promise2, promise3]); // With events and goals disabled the only fetch calls should be for polling requests. - expect(platform.requests.fetch.mock.calls.length).toBe(2); + expect(platform.requests.fetch.mock.calls.length).toBe(3); }); it('it does not shed non-shedable identify calls', async () => { const client = makeClient( 'client-side-id', + { key: 'user-key-0', kind: 'user' }, AutoEnvAttributes.Disabled, { streaming: false, logger, diagnosticOptOut: true, sendEvents: false, fetchGoals: false }, platform, ); + await client.start(); + const promise1 = client.identify({ key: 'user-key-1', kind: 'user' }, { sheddable: false }); const promise2 = client.identify({ key: 'user-key-2', kind: 'user' }, { sheddable: false }); const promise3 = client.identify({ key: 'user-key-3', kind: 'user' }, { sheddable: false }); @@ -408,24 +434,25 @@ describe('given a mock platform for a BrowserClient', () => { expect(result2).toEqual({ status: 'completed' }); expect(result3).toEqual({ status: 'completed' }); // With events and goals disabled the only fetch calls should be for polling requests. - expect(platform.requests.fetch.mock.calls.length).toBe(3); + expect(platform.requests.fetch.mock.calls.length).toBe(4); }); it('blocks until the client is ready when waitForInitialization is called', async () => { const client = makeClient( 'client-side-id', + { key: 'user-key', kind: 'user' }, 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' }); + const startPromise = client.start(); - await Promise.all([waitPromise, identifyPromise]); + await Promise.all([waitPromise, startPromise]); await expect(waitPromise).resolves.toEqual({ status: 'complete' }); - await expect(identifyPromise).resolves.toEqual({ status: 'completed' }); + await expect(startPromise).resolves.toEqual({ status: 'complete' }); }); it('resolves waitForInitialization with timeout status when initialization does not complete before the timeout', async () => { @@ -445,13 +472,13 @@ describe('given a mock platform for a BrowserClient', () => { const client = makeClient( 'client-side-id', + { key: 'user-key', kind: 'user' }, 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' }); + client.start(); // Call waitForInitialization with a short timeout (0.1 seconds) const waitPromise = client.waitForInitialization({ timeout: 0.1 }); @@ -475,6 +502,7 @@ describe('given a mock platform for a BrowserClient', () => { const client = makeClient( 'client-side-id', + { key: 'user-key', kind: 'user' }, AutoEnvAttributes.Disabled, { streaming: false, logger, diagnosticOptOut: true, sendEvents: false, fetchGoals: false }, errorPlatform, @@ -484,13 +512,13 @@ describe('given a mock platform for a BrowserClient', () => { const waitPromise = client.waitForInitialization({ timeout: 10 }); // Start identify which will fail - const identifyPromise = client.identify({ key: 'user-key', kind: 'user' }); + const identifyPromise = client.start(); await jest.advanceTimersByTimeAsync(4000); // trigger all poll retries // Wait for identify to fail await expect(identifyPromise).resolves.toEqual({ - status: 'error', + status: 'failed', error: identifyError, }); @@ -512,19 +540,20 @@ describe('given a mock platform for a BrowserClient', () => { const client = makeClient( 'client-side-id', + { key: 'user-key', kind: 'user' }, 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' }); + const identifyPromise = client.start(); await jest.advanceTimersByTimeAsync(4000); // trigger all poll retries // Wait for identify to fail await expect(identifyPromise).resolves.toEqual({ - status: 'error', + status: 'failed', error: identifyError, }); @@ -538,4 +567,62 @@ describe('given a mock platform for a BrowserClient', () => { error: identifyError, }); }); + + it('returns the same promise when start is called multiple times', async () => { + const client = makeClient( + 'client-side-id', + { kind: 'user', key: 'user-key' }, + AutoEnvAttributes.Disabled, + { streaming: false, logger, diagnosticOptOut: true, sendEvents: false, fetchGoals: false }, + platform, + ); + + // Call start multiple times before it completes + const promise1 = client.start(); + const promise2 = client.start(); + const promise3 = client.start(); + + // Verify all promises are the same reference + // The implementation should cache the promise and return the same one + expect(promise1).toBe(promise2); + expect(promise2).toBe(promise3); + expect(promise1).toBe(promise3); + + // Verify all promises resolve to the same value + const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]); + expect(result1).toEqual(result2); + expect(result2).toEqual(result3); + expect(result1.status).toBe('complete'); + + // Verify that only one identify call was made (one for polling) + expect(platform.requests.fetch.mock.calls.length).toBe(1); + }); + + it('cannot call identify before start', async () => { + const client = makeClient( + 'client-side-id', + { kind: 'user', key: 'user-key' }, + AutoEnvAttributes.Disabled, + { streaming: false, logger, diagnosticOptOut: true, sendEvents: false, fetchGoals: false }, + platform, + ); + + // Call identify before start + const result = await client.identify({ kind: 'user', key: 'new-user-key' }); + + // Verify that identify returns an error status + expect(result.status).toBe('error'); + if (result.status === 'error') { + expect(result.error).toBeInstanceOf(Error); + expect(result.error.message).toBe('Identify called before start'); + } + + // 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()?', + ); + + // Verify that no fetch calls were made + expect(platform.requests.fetch.mock.calls.length).toBe(0); + }); }); diff --git a/packages/sdk/browser/__tests__/compat/LDClientCompatImpl.test.ts b/packages/sdk/browser/__tests__/compat/LDClientCompatImpl.test.ts index ac6368189f..c38aa20fca 100644 --- a/packages/sdk/browser/__tests__/compat/LDClientCompatImpl.test.ts +++ b/packages/sdk/browser/__tests__/compat/LDClientCompatImpl.test.ts @@ -36,6 +36,7 @@ const mockBrowserClient: jest.MockedObject = { error: jest.fn(), }, getContext: jest.fn(), + start: jest.fn(), }; jest.mock('../../src/BrowserClient', () => ({ diff --git a/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts b/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts index 27d0415068..0d50a90bb8 100644 --- a/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts +++ b/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts @@ -1,4 +1,4 @@ -import { initialize, LDClient, LDLogger, LDOptions } from '@launchdarkly/js-client-sdk'; +import { createClient, LDClient, LDLogger, LDOptions } from '@launchdarkly/js-client-sdk'; import { CommandParams, CommandType, ValueType } from './CommandParams'; import { CreateInstanceParams, SDKConfigParams } from './ConfigParams'; @@ -207,11 +207,15 @@ export async function newSdkClientEntity(options: CreateInstanceParams) { options.configuration.clientSide?.initialUser || options.configuration.clientSide?.initialContext || makeDefaultInitialContext(); - const client = initialize(options.configuration.credential || 'unknown-env-id', sdkConfig); + const client = createClient( + options.configuration.credential || 'unknown-env-id', + initialContext, + sdkConfig, + ); let failed = false; try { await Promise.race([ - client.identify(initialContext), + client.start(), new Promise((_resolve, reject) => { setTimeout(reject, timeout); }), diff --git a/packages/sdk/browser/example/src/app.ts b/packages/sdk/browser/example/src/app.ts index 8ca290132f..2e9e21c0d9 100644 --- a/packages/sdk/browser/example/src/app.ts +++ b/packages/sdk/browser/example/src/app.ts @@ -1,4 +1,4 @@ -import { initialize } from '@launchdarkly/js-client-sdk'; +import { createClient } from '@launchdarkly/js-client-sdk'; // Set clientSideID to your LaunchDarkly client-side ID const clientSideID = 'LD_CLIENT_SIDE_ID'; @@ -24,7 +24,7 @@ div.appendChild(document.createTextNode('No flag evaluations yet')); statusBox.appendChild(document.createTextNode('Initializing...')); const main = async () => { - const ldclient = initialize(clientSideID); + const ldclient = createClient(clientSideID, context); const render = () => { const flagValue = ldclient.variation(flagKey, false); const label = `The ${flagKey} feature flag evaluates to ${flagValue}.`; @@ -44,15 +44,30 @@ const main = async () => { render(); }); - const { status } = await ldclient.identify(context); + ldclient.start(); - if (status === 'completed') { - statusBox.replaceChild(document.createTextNode('Initialized'), statusBox.firstChild as Node); - } else if (status === 'error') { + const { status } = await ldclient.waitForInitialization(); + + if (status === 'complete') { + statusBox.replaceChild( + document.createTextNode(`Initialized with context: ${JSON.stringify(ldclient.getContext())}`), + statusBox.firstChild as Node, + ); + } else if (status === 'failed') { statusBox.replaceChild( document.createTextNode('Error identifying client'), statusBox.firstChild as Node, ); + } else if (status === 'timeout') { + statusBox.replaceChild( + document.createTextNode('Timeout identifying client'), + statusBox.firstChild as Node, + ); + } else { + statusBox.replaceChild( + document.createTextNode('Unknown error identifying client'), + statusBox.firstChild as Node, + ); } render(); diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index e765a71fa3..96e1904c37 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -29,6 +29,7 @@ import GoalManager from './goals/GoalManager'; import { Goal, isClick } from './goals/Goals'; import { LDClient, + LDStartOptions, LDWaitForInitializationComplete, LDWaitForInitializationFailed, LDWaitForInitializationOptions, @@ -42,13 +43,18 @@ import BrowserPlatform from './platform/BrowserPlatform'; 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; private _initResolve?: (result: LDWaitForInitializationResult) => void; private _initializeResult?: LDWaitForInitializationResult; - // NOTE: keeps track of when we tried an initial identification. We should consolidate this - // with the waitForInitialization logic in the future. - private _identifyAttempted: boolean = false; + private _initialContext?: LDContext; + + // NOTE: This also keeps track of when we tried to initialize the client. + private _startPromise?: Promise; constructor( clientSideId: string, @@ -219,6 +225,10 @@ class BrowserClientImpl extends LDClientImpl { } } + setInitialContext(context: LDContext): void { + this._initialContext = context; + } + override async identify(context: LDContext, identifyOptions?: LDIdentifyOptions): Promise { return super.identify(context, identifyOptions); } @@ -227,6 +237,13 @@ 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, }; @@ -234,21 +251,6 @@ class BrowserClientImpl extends LDClientImpl { identifyOptionsWithUpdatedDefaults.sheddable = true; } - if (!this._identifyAttempted) { - this._identifyAttempted = true; - if (identifyOptionsWithUpdatedDefaults.bootstrap) { - try { - const bootstrapData = readFlagsFromBootstrap( - this.logger, - identifyOptionsWithUpdatedDefaults.bootstrap, - ); - this.presetFlags(bootstrapData); - } catch (error) { - this.logger.error('Failed to bootstrap data', error); - } - } - } - const res = await super.identifyResult(context, identifyOptionsWithUpdatedDefaults); if (res.status === 'completed') { this._initializeResult = { status: 'complete' }; @@ -262,6 +264,54 @@ class BrowserClientImpl extends LDClientImpl { 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 { + const bootstrapData = readFlagsFromBootstrap(this.logger, identifyOptions.bootstrap); + this.presetFlags(bootstrapData); + } 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; + } + waitForInitialization( options?: LDWaitForInitializationOptions, ): Promise { @@ -344,11 +394,13 @@ class BrowserClientImpl extends LDClientImpl { export function makeClient( clientSideId: string, + initialContext: LDContext, autoEnvAttributes: AutoEnvAttributes, options: BrowserOptions = {}, overridePlatform?: Platform, ): LDClient { const impl = new BrowserClientImpl(clientSideId, autoEnvAttributes, options, overridePlatform); + impl.setInitialContext(initialContext); // 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 @@ -386,6 +438,7 @@ export function makeClient( waitForInitialization: (waitOptions?: LDWaitForInitializationOptions) => impl.waitForInitialization(waitOptions), logger: impl.logger, + start: (startOptions?: LDStartOptions) => impl.start(startOptions), }; impl.registerPlugins(client); diff --git a/packages/sdk/browser/src/LDClient.ts b/packages/sdk/browser/src/LDClient.ts index 3480a75911..4dfe8cfd53 100644 --- a/packages/sdk/browser/src/LDClient.ts +++ b/packages/sdk/browser/src/LDClient.ts @@ -30,7 +30,7 @@ export interface LDWaitForInitializationOptions { * * @default 5 seconds */ - timeout: number; + timeout?: number; } /** @@ -63,6 +63,21 @@ export type LDWaitForInitializationResult = | 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. + */ + 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. + */ + identifyOptions?: Omit; +} + /** * * The LaunchDarkly SDK client object. @@ -158,4 +173,14 @@ export type LDClient = Omit< waitForInitialization( options?: LDWaitForInitializationOptions, ): Promise; + + /** + * Starts the client and returns a promise that resolves to the initialization result. + * + * The promise will resolve to a {@link LDWaitForInitializationResult} object containing the + * status of the waitForInitialization operation. + * + * @param options Optional configuration. Please see {@link LDStartOptions}. + */ + start(options?: LDStartOptions): Promise; }; diff --git a/packages/sdk/browser/src/compat/LDClientCompat.ts b/packages/sdk/browser/src/compat/LDClientCompat.ts index 21fc1c0c81..48e792cd22 100644 --- a/packages/sdk/browser/src/compat/LDClientCompat.ts +++ b/packages/sdk/browser/src/compat/LDClientCompat.ts @@ -14,7 +14,13 @@ import { LDClient as LDCLientBrowser } from '../LDClient'; */ export interface LDClient extends Omit< LDCLientBrowser, - 'close' | 'flush' | 'identify' | 'identifyResult' | 'waitForInitialization' + | 'close' + | 'flush' + | 'identify' + | 'identifyResult' + | 'waitForInitialization' + | 'setInitialContext' + | 'start' > { /** * Identifies a context to LaunchDarkly. diff --git a/packages/sdk/browser/src/compat/LDClientCompatImpl.ts b/packages/sdk/browser/src/compat/LDClientCompatImpl.ts index 946207a8be..e10721d249 100644 --- a/packages/sdk/browser/src/compat/LDClientCompatImpl.ts +++ b/packages/sdk/browser/src/compat/LDClientCompatImpl.ts @@ -42,9 +42,13 @@ export default class LDClientCompatImpl implements LDClient { const cleanedOptions = { ...options }; delete cleanedOptions.bootstrap; delete cleanedOptions.hash; - this._client = makeClient(envKey, AutoEnvAttributes.Disabled, options); + this._client = makeClient(envKey, context, AutoEnvAttributes.Disabled, options); this._emitter = new LDEmitterCompat(this._client); this.logger = this._client.logger; + + // start the client, then immediately kick off an identify operation + // in order to preserve the behavior of the previous SDK. + this._client.start(); this._initIdentify(context, bootstrap, hash); } diff --git a/packages/sdk/browser/src/compat/index.ts b/packages/sdk/browser/src/compat/index.ts index f913d7d01f..d5dbb5a9f3 100644 --- a/packages/sdk/browser/src/compat/index.ts +++ b/packages/sdk/browser/src/compat/index.ts @@ -16,7 +16,7 @@ export type { LDClient, LDOptions }; /** * Creates an instance of the LaunchDarkly client. This version of initialization is for - * improved backwards compatibility. In general the `initialize` function from the root packge + * improved backwards compatibility. In general the `createClient` function from the root packge * should be used instead of the one in the `/compat` module. * * The client will begin attempting to connect to LaunchDarkly as soon as it is created. To @@ -49,6 +49,10 @@ export type { LDClient, LDOptions }; * @return * The new client instance. */ -export function initialize(envKey: string, context: LDContext, options?: LDOptions): LDClient { +export function initialize( + envKey: string, + context: LDContext, + options?: LDOptions, +): Omit { return new LDClientCompatImpl(envKey, context, options); } diff --git a/packages/sdk/browser/src/index.ts b/packages/sdk/browser/src/index.ts index bf91ae34a7..cc744bf0a2 100644 --- a/packages/sdk/browser/src/index.ts +++ b/packages/sdk/browser/src/index.ts @@ -3,14 +3,14 @@ * * This SDK is intended for use in browser environments. * - * In typical usage, you will call {@link initialize} once at startup time to obtain an instance of + * In typical usage, you will call {@link createClient} once at startup time to obtain an instance of * {@link LDClient}, which provides access to all of the SDK's functionality. * * For more information, see the [SDK Reference Guide](https://docs.launchdarkly.com/sdk/client-side/javascript). * * @packageDocumentation */ -import { AutoEnvAttributes } from '@launchdarkly/js-client-sdk-common'; +import { AutoEnvAttributes, LDContext } from '@launchdarkly/js-client-sdk-common'; import { makeClient } from './BrowserClient'; import { LDClient } from './LDClient'; @@ -21,22 +21,40 @@ export type { LDClient, LDOptions }; export type { LDPlugin } from './LDPlugin'; /** - * Creates an instance of the LaunchDarkly client. + * Creates an instance of the LaunchDarkly client. Note that the client will not be ready to + * use until {@link LDClient.start} is called. * * Usage: * ``` - * import { initialize } from 'launchdarkly-js-client-sdk'; - * const client = initialize(clientSideId, options); + * import { createClient } from 'launchdarkly-js-client-sdk'; + * const client = createClient(clientSideId, context, options); + * + * // Attach event listeners and add any additional logic here + * + * // Then start the client + * client.start(); * ``` + * @remarks + * The client will not automatically start until {@link LDClient.start} is called in order to + * synchronize the registering of event listeners and other initialization logic that should be + * done before the client initiates its connection to LaunchDarkly. * * @param clientSideId * The client-side ID, also known as the environment ID. + * @param pristineContext + * The initial context used to identify the user. @see {@link LDContext} * @param options - * Optional configuration settings. - * @return - * The new client instance. + * Optional configuration settings. @see {@link LDOptions} + * @returns + * The new client instance. @see {@link LDClient} */ -export function initialize(clientSideId: string, options?: LDOptions): LDClient { +export function createClient( + clientSideId: string, + pristineContext: LDContext, + options?: LDOptions, +): LDClient { // AutoEnvAttributes are not supported yet in the browser SDK. - return makeClient(clientSideId, AutoEnvAttributes.Disabled, options); + const client = makeClient(clientSideId, pristineContext, AutoEnvAttributes.Disabled, options); + + return client; }