diff --git a/packages/sdk/browser/src/BrowserIdentifyOptions.ts b/packages/sdk/browser/src/BrowserIdentifyOptions.ts index 13dabe168f..e1cc74e93d 100644 --- a/packages/sdk/browser/src/BrowserIdentifyOptions.ts +++ b/packages/sdk/browser/src/BrowserIdentifyOptions.ts @@ -1,4 +1,4 @@ -import { ItemDescriptor, LDIdentifyOptions } from '@launchdarkly/js-client-sdk-common'; +import { LDIdentifyOptions } from '@launchdarkly/js-client-sdk-common'; /** * @property sheddable - If true, the identify operation will be sheddable. This means that if multiple identify operations are done, without @@ -13,27 +13,4 @@ export interface BrowserIdentifyOptions extends Omit { await dataManager.identify(identifyResolve, identifyReject, context, { bootstrap: goodBootstrapData, - } as ElectronIdentifyOptions); + } as LDIdentifyOptions); expect(flagManager.setBootstrap).toHaveBeenCalledWith( context, diff --git a/packages/sdk/electron/src/ElectronClient.ts b/packages/sdk/electron/src/ElectronClient.ts index 6098dbb93a..d8a0e6bfb3 100644 --- a/packages/sdk/electron/src/ElectronClient.ts +++ b/packages/sdk/electron/src/ElectronClient.ts @@ -14,6 +14,7 @@ import { LDEmitterEventName, LDFlagValue, LDHeaders, + LDIdentifyOptions, LDIdentifyResult, LDPluginEnvironmentMetadata, LDWaitForInitializationResult, @@ -21,7 +22,6 @@ import { } from '@launchdarkly/js-client-sdk-common'; import ElectronDataManager from './ElectronDataManager'; -import type { ElectronIdentifyOptions } from './ElectronIdentifyOptions'; import type { ElectronOptions, ElectronOptions as LDOptions } from './ElectronOptions'; import type { LDClient, LDStartOptions } from './LDClient'; import type { LDPlugin } from './LDPlugin'; @@ -156,7 +156,7 @@ export class ElectronClient extends LDClientImpl { return Promise.resolve({ status: 'failed', error: new Error('Initial context not set') }); } - const identifyOptions: ElectronIdentifyOptions = { + const identifyOptions: LDIdentifyOptions = { ...(options?.identifyOptions ?? {}), sheddable: false, }; @@ -197,7 +197,7 @@ export class ElectronClient extends LDClientImpl { override async identifyResult( pristineContext: LDContext, - identifyOptions?: ElectronIdentifyOptions, + identifyOptions?: LDIdentifyOptions, ): Promise { if (!this._startPromise) { this.logger.error( @@ -276,7 +276,7 @@ export function makeClient( off: (key: string, callback: (...args: unknown[]) => void) => impl.off(key as LDEmitterEventName, callback as (...args: unknown[]) => void), flush: () => impl.flush(), - identify: (ctx: LDContext, identifyOptions?: ElectronIdentifyOptions) => + identify: (ctx: LDContext, identifyOptions?: LDIdentifyOptions) => impl.identifyResult(ctx, identifyOptions), getContext: () => impl.getContext(), close: () => impl.close(), diff --git a/packages/sdk/electron/src/ElectronDataManager.ts b/packages/sdk/electron/src/ElectronDataManager.ts index e140c4b736..048e586b6b 100644 --- a/packages/sdk/electron/src/ElectronDataManager.ts +++ b/packages/sdk/electron/src/ElectronDataManager.ts @@ -14,7 +14,6 @@ import { readFlagsFromBootstrap, } from '@launchdarkly/js-client-sdk-common'; -import type { ElectronIdentifyOptions } from './ElectronIdentifyOptions'; import type { ValidatedOptions } from './options'; const logTag = '[ElectronDataManager]'; @@ -68,12 +67,11 @@ export default class ElectronDataManager extends BaseDataManager { // When bootstrap is provided, we will resolve the identify immediately. Then we will fallthrough to connect // to the configured connection mode. - const electronIdentifyOptions = identifyOptions as ElectronIdentifyOptions | undefined; - if (electronIdentifyOptions?.bootstrap) { - this._finishIdentifyFromBootstrap(context, electronIdentifyOptions, identifyResolve); + if (identifyOptions?.bootstrap) { + this._finishIdentifyFromBootstrap(context, identifyOptions, identifyResolve); } // Bootstrap path already called resolve so we use this to prevent duplicate resolve calls. - const resolvedFromBootstrap = !!electronIdentifyOptions?.bootstrap; + const resolvedFromBootstrap = !!identifyOptions?.bootstrap; const offline = this.connectionMode === 'offline'; // In offline mode we do not support waiting for results. @@ -109,12 +107,12 @@ export default class ElectronDataManager extends BaseDataManager { private _finishIdentifyFromBootstrap( context: Context, - electronIdentifyOptions: ElectronIdentifyOptions, + identifyOpts: LDIdentifyOptions, identifyResolve: () => void, ): void { - let { bootstrapParsed } = electronIdentifyOptions; + let { bootstrapParsed } = identifyOpts; if (!bootstrapParsed) { - bootstrapParsed = readFlagsFromBootstrap(this.logger, electronIdentifyOptions.bootstrap); + bootstrapParsed = readFlagsFromBootstrap(this.logger, identifyOpts.bootstrap); } this.flagManager.setBootstrap(context, bootstrapParsed); this._debugLog('Identify - Initialization completed from bootstrap'); diff --git a/packages/sdk/electron/src/ElectronIdentifyOptions.ts b/packages/sdk/electron/src/ElectronIdentifyOptions.ts deleted file mode 100644 index 75f4a9d3ed..0000000000 --- a/packages/sdk/electron/src/ElectronIdentifyOptions.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { ItemDescriptor, LDIdentifyOptions } from '@launchdarkly/js-client-sdk-common'; - -/** - * Options for the identify method when using the Electron SDK. - * Extends the base identify options with bootstrap support. - */ -export interface ElectronIdentifyOptions extends LDIdentifyOptions { - /** - * The initial set of flags to use until the remote set is retrieved. - * - * Bootstrap data can be generated by server SDKs. When bootstrap data is provided the SDK - * identification operation will complete without waiting for any values from LaunchDarkly and - * the variation calls can be used immediately. - * - * If streaming or polling is configured, a connection will subsequently be established - * to receive live updates. - * - * For more information, see the [SDK Reference Guide](https://docs.launchdarkly.com/sdk/features/bootstrapping#javascript). - */ - bootstrap?: unknown; - - /** - * Parsed bootstrap data used to ensure that the bootstrap data is only parsed once during - * the initialization process. - * - * @hidden - */ - bootstrapParsed?: { [key: string]: ItemDescriptor }; -} diff --git a/packages/sdk/electron/src/LDClient.ts b/packages/sdk/electron/src/LDClient.ts index 85303f10f7..4f1815e89f 100644 --- a/packages/sdk/electron/src/LDClient.ts +++ b/packages/sdk/electron/src/LDClient.ts @@ -2,17 +2,16 @@ import type { ConnectionMode, LDClient as LDClientBase, LDContext, + LDIdentifyOptions, LDIdentifyResult, LDWaitForInitializationOptions, LDWaitForInitializationResult, } from '@launchdarkly/js-client-sdk-common'; -import type { ElectronIdentifyOptions } from './ElectronIdentifyOptions'; - export interface LDStartOptions extends LDWaitForInitializationOptions { /** * Optional bootstrap data to use for the identify operation. If - * {@link ElectronIdentifyOptions.bootstrap} is provided in identifyOptions, it takes precedence. + * {@link LDIdentifyOptions.bootstrap} is provided in identifyOptions, it takes precedence. */ bootstrap?: unknown; @@ -20,7 +19,7 @@ export interface LDStartOptions extends LDWaitForInitializationOptions { * 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; + identifyOptions?: Omit; } export interface LDClient extends Omit { @@ -31,13 +30,10 @@ export interface LDClient extends Omit { * Must not be called before {@link LDClient.start} has been called. * * @param context The context to identify. - * @param identifyOptions Optional configuration including {@link ElectronIdentifyOptions.bootstrap}. + * @param identifyOptions Optional configuration including {@link LDIdentifyOptions.bootstrap}. * @returns A promise which resolves to an object containing the result of the identify operation. */ - identify( - context: LDContext, - identifyOptions?: ElectronIdentifyOptions, - ): Promise; + identify(context: LDContext, identifyOptions?: LDIdentifyOptions): Promise; /** * Starts the client by performing the first identify with the initial context passed to diff --git a/packages/sdk/electron/src/LDCommon.ts b/packages/sdk/electron/src/LDCommon.ts index 6e113deb9b..3a17d78719 100644 --- a/packages/sdk/electron/src/LDCommon.ts +++ b/packages/sdk/electron/src/LDCommon.ts @@ -1,10 +1,7 @@ import { BasicLogger, BasicLoggerOptions, LDLogger } from '@launchdarkly/js-client-sdk-common'; -import { ElectronIdentifyOptions as LDIdentifyOptions } from './ElectronIdentifyOptions'; - -export type { LDIdentifyOptions }; - export type { + LDIdentifyOptions, AutoEnvAttributes, BasicLogger, BasicLoggerOptions, diff --git a/packages/sdk/react-native/__tests__/ReactNativeLDClient.bootstrap.test.ts b/packages/sdk/react-native/__tests__/ReactNativeLDClient.bootstrap.test.ts new file mode 100644 index 0000000000..156b80ac82 --- /dev/null +++ b/packages/sdk/react-native/__tests__/ReactNativeLDClient.bootstrap.test.ts @@ -0,0 +1,154 @@ +import { AutoEnvAttributes, LDLogger } from '@launchdarkly/js-client-sdk-common'; + +import PlatformCrypto from '../src/platform/crypto'; +import PlatformEncoding from '../src/platform/PlatformEncoding'; +import PlatformInfo from '../src/platform/PlatformInfo'; +import PlatformStorage from '../src/platform/PlatformStorage'; +import ReactNativeLDClient from '../src/ReactNativeLDClient'; + +jest.mock('../src/platform', () => ({ + __esModule: true, + default: jest.fn((logger: LDLogger) => ({ + crypto: new PlatformCrypto(), + info: new PlatformInfo(logger, {}), + requests: { + createEventSource: jest.fn(), + fetch: jest.fn(), + getEventSourceCapabilities: jest.fn(), + }, + encoding: new PlatformEncoding(), + storage: new PlatformStorage(logger), + })), +})); + +const goodBootstrapData = { + 'string-flag': 'is bob', + 'my-boolean-flag': false, + $flagsState: { + 'string-flag': { + variation: 1, + version: 3, + }, + 'my-boolean-flag': { + variation: 1, + version: 11, + }, + }, + $valid: true, +}; + +const goodBootstrapDataWithReasons = { + 'string-flag': 'is bob', + 'my-boolean-flag': false, + json: ['a', 'b', 'c', 'd'], + $flagsState: { + 'string-flag': { + variation: 1, + version: 3, + reason: { kind: 'OFF' }, + }, + 'my-boolean-flag': { + variation: 1, + version: 11, + reason: { kind: 'OFF' }, + }, + json: { + variation: 1, + version: 3, + reason: { kind: 'OFF' }, + }, + }, + $valid: true, +}; + +describe('ReactNativeLDClient bootstrap', () => { + let logger: LDLogger; + + beforeEach(() => { + logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; + }); + + it('can use bootstrap data with identify', async () => { + const client = new ReactNativeLDClient('mobile-key', AutoEnvAttributes.Enabled, { + sendEvents: false, + initialConnectionMode: 'offline', + logger, + }); + + await client.identify( + { kind: 'user', key: 'bob' }, + { bootstrap: goodBootstrapDataWithReasons }, + ); + + expect(client.jsonVariationDetail('json', undefined)).toEqual({ + reason: { kind: 'OFF' }, + value: ['a', 'b', 'c', 'd'], + variationIndex: 1, + }); + }); + + it('can evaluate string and boolean flags from bootstrap data', async () => { + const client = new ReactNativeLDClient('mobile-key', AutoEnvAttributes.Enabled, { + sendEvents: false, + initialConnectionMode: 'offline', + logger, + }); + + await client.identify({ kind: 'user', key: 'bob' }, { bootstrap: goodBootstrapData }); + + expect(client.stringVariation('string-flag', 'default')).toBe('is bob'); + expect(client.boolVariation('my-boolean-flag', true)).toBe(false); + }); + + it('uses the latest bootstrap data when re-identifying with new bootstrap data', async () => { + const newBootstrapData = { + 'string-flag': 'is alice', + 'my-boolean-flag': true, + $flagsState: { + 'string-flag': { + variation: 1, + version: 4, + }, + 'my-boolean-flag': { + variation: 0, + version: 12, + }, + }, + $valid: true, + }; + + const client = new ReactNativeLDClient('mobile-key', AutoEnvAttributes.Enabled, { + sendEvents: false, + initialConnectionMode: 'offline', + logger, + }); + + await client.identify({ kind: 'user', key: 'bob' }, { bootstrap: goodBootstrapData }); + + expect(client.stringVariation('string-flag', 'default')).toBe('is bob'); + expect(client.boolVariation('my-boolean-flag', false)).toBe(false); + + await client.identify({ kind: 'user', key: 'alice' }, { bootstrap: newBootstrapData }); + + expect(client.stringVariation('string-flag', 'default')).toBe('is alice'); + expect(client.boolVariation('my-boolean-flag', false)).toBe(true); + }); + + it('returns defaults when no bootstrap data is provided', async () => { + const client = new ReactNativeLDClient('mobile-key', AutoEnvAttributes.Enabled, { + sendEvents: false, + initialConnectionMode: 'offline', + logger, + }); + + await client.identify({ kind: 'user', key: 'bob' }); + + expect(client.stringVariation('string-flag', 'default')).toBe('default'); + expect(client.boolVariation('my-boolean-flag', true)).toBe(true); + }); +}); diff --git a/packages/sdk/react-native/src/MobileDataManager.ts b/packages/sdk/react-native/src/MobileDataManager.ts index 04d0a5d6fb..0ba41a69ba 100644 --- a/packages/sdk/react-native/src/MobileDataManager.ts +++ b/packages/sdk/react-native/src/MobileDataManager.ts @@ -11,6 +11,7 @@ import { LDIdentifyOptions, makeRequestor, Platform, + readFlagsFromBootstrap, } from '@launchdarkly/js-client-sdk-common'; import { ValidatedOptions } from './options'; @@ -63,12 +64,20 @@ export default class MobileDataManager extends BaseDataManager { return; } this.context = context; + + // When bootstrap is provided, resolve identify immediately then fall through to connect. + if (identifyOptions?.bootstrap) { + this._finishIdentifyFromBootstrap(context, identifyOptions, identifyResolve); + } + // Bootstrap path already called resolve so we use this to prevent duplicate resolve calls. + const resolvedFromBootstrap = !!identifyOptions?.bootstrap; + const offline = this.connectionMode === 'offline'; // In offline mode we do not support waiting for results. const waitForNetworkResults = !!identifyOptions?.waitForNetworkResults && !offline; const loadedFromCache = await this.flagManager.loadCached(context); - if (loadedFromCache && !waitForNetworkResults) { + if (loadedFromCache && !waitForNetworkResults && !resolvedFromBootstrap) { this._debugLog('Identify completing with cached flags'); identifyResolve(); } @@ -85,7 +94,9 @@ export default class MobileDataManager extends BaseDataManager { this._debugLog( 'Offline identify - no cached flags, using defaults or already loaded flags.', ); - identifyResolve(); + if (!resolvedFromBootstrap) { + identifyResolve(); + } } } else { // Context has been validated in LDClientImpl.identify @@ -93,6 +104,21 @@ export default class MobileDataManager extends BaseDataManager { } } + private _finishIdentifyFromBootstrap( + context: Context, + identifyOpts: LDIdentifyOptions, + identifyResolve: () => void, + ): void { + let { bootstrapParsed } = identifyOpts; + if (!bootstrapParsed) { + bootstrapParsed = readFlagsFromBootstrap(this.logger, identifyOpts.bootstrap); + } + this.flagManager.setBootstrap(context, bootstrapParsed); + this._debugLog('Identify - Initialization completed from bootstrap'); + + identifyResolve(); + } + private _setupConnection( context: Context, identifyResolve?: () => void, diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index 9bec7272ba..beecf992fb 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -338,6 +338,7 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult { }, execute: async (beforeResult) => { const { context, checkedContext } = beforeResult!; + if (!checkedContext.valid) { const error = new Error('Context was unspecified or had no key'); this.emitter.emit('error', context, error); diff --git a/packages/shared/sdk-client/src/api/LDIdentifyOptions.ts b/packages/shared/sdk-client/src/api/LDIdentifyOptions.ts index 3faec6820c..20369b8fe7 100644 --- a/packages/shared/sdk-client/src/api/LDIdentifyOptions.ts +++ b/packages/shared/sdk-client/src/api/LDIdentifyOptions.ts @@ -1,3 +1,5 @@ +import { ItemDescriptor } from '../flag-manager/ItemDescriptor'; + export interface LDIdentifyOptions { /** * In seconds. Determines when the identify promise resolves if no flags have been @@ -44,4 +46,21 @@ export interface LDIdentifyOptions { * Defaults to false. */ sheddable?: boolean; + + /** + * The initial set of flags to use until the remote set is retrieved. + * + * Bootstrap data can be generated by server SDKs. When bootstrap data is provided the SDK the + * identification operation will complete without waiting for any values from LaunchDarkly and + * the variation calls can be used immediately. + */ + bootstrap?: unknown; + + /** + * Parsed bootstrap data used to ensure that the bootstrap data is only parsed once during + * the initialization process. + * + * @hidden + */ + bootstrapParsed?: { [key: string]: ItemDescriptor }; }