diff --git a/packages/sdk/browser/__tests__/BrowserClient.test.ts b/packages/sdk/browser/__tests__/BrowserClient.test.ts index 7a8dc3ff2..4cba088a1 100644 --- a/packages/sdk/browser/__tests__/BrowserClient.test.ts +++ b/packages/sdk/browser/__tests__/BrowserClient.test.ts @@ -12,6 +12,7 @@ import { import { BrowserClient } from '../src/BrowserClient'; import { MockHasher } from './MockHasher'; +import { goodBootstrapDataWithReasons } from './testBootstrapData'; function mockResponse(value: string, statusCode: number) { const response: Response = { @@ -257,4 +258,31 @@ describe('given a mock platform for a BrowserClient', () => { url: 'http://filtered.com', }); }); + + it('can use bootstrap data', async () => { + const client = new BrowserClient( + 'client-side-id', + AutoEnvAttributes.Disabled, + { + streaming: false, + logger, + diagnosticOptOut: true, + }, + platform, + ); + 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, + }); + }); }); diff --git a/packages/sdk/browser/__tests__/BrowserDataManager.test.ts b/packages/sdk/browser/__tests__/BrowserDataManager.test.ts index c863de92d..b6842038a 100644 --- a/packages/sdk/browser/__tests__/BrowserDataManager.test.ts +++ b/packages/sdk/browser/__tests__/BrowserDataManager.test.ts @@ -24,6 +24,7 @@ import BrowserEncoding from '../src/platform/BrowserEncoding'; import BrowserInfo from '../src/platform/BrowserInfo'; import LocalStorage from '../src/platform/LocalStorage'; import { MockHasher } from './MockHasher'; +import { goodBootstrapData } from './testBootstrapData'; global.TextEncoder = TextEncoder; @@ -123,6 +124,7 @@ describe('given a BrowserDataManager with mocked dependencies', () => { upsert: jest.fn(), on: jest.fn(), off: jest.fn(), + setBootstrap: jest.fn(), } as unknown as jest.Mocked; browserConfig = validateOptions({}, logger); @@ -314,6 +316,36 @@ describe('given a BrowserDataManager with mocked dependencies', () => { expect(platform.requests.createEventSource).not.toHaveBeenCalled(); }); + it('uses data from bootstrap and does not make an initial poll', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: BrowserIdentifyOptions = { + bootstrap: goodBootstrapData, + }; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + flagManager.loadCached.mockResolvedValue(true); + + await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(logger.debug).toHaveBeenCalledWith( + '[BrowserDataManager] Identify - Initialization completed from bootstrap', + ); + + expect(flagManager.loadCached).not.toHaveBeenCalledWith(context); + expect(identifyResolve).toHaveBeenCalled(); + expect(flagManager.init).not.toHaveBeenCalled(); + expect(flagManager.setBootstrap).toHaveBeenCalledWith(expect.anything(), { + cat: { version: 2, flag: { version: 2, variation: 1, value: false } }, + json: { version: 3, flag: { version: 3, variation: 1, value: ['a', 'b', 'c', 'd'] } }, + killswitch: { version: 5, flag: { version: 5, variation: 0, value: true } }, + 'my-boolean-flag': { version: 11, flag: { version: 11, variation: 1, value: false } }, + 'string-flag': { version: 3, flag: { version: 3, variation: 1, value: 'is bob' } }, + }); + expect(platform.requests.createEventSource).not.toHaveBeenCalled(); + expect(platform.requests.fetch).not.toHaveBeenCalled(); + }); + it('should identify from polling when there are no cached flags', async () => { const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); diff --git a/packages/sdk/browser/__tests__/bootstrap.test.ts b/packages/sdk/browser/__tests__/bootstrap.test.ts new file mode 100644 index 000000000..b7e9a2e43 --- /dev/null +++ b/packages/sdk/browser/__tests__/bootstrap.test.ts @@ -0,0 +1,149 @@ +import { jest } from '@jest/globals'; + +import { readFlagsFromBootstrap } from '../src/bootstrap'; +import { goodBootstrapData, goodBootstrapDataWithReasons } from './testBootstrapData'; + +it('can read valid bootstrap data', () => { + const logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + const readData = readFlagsFromBootstrap(logger, goodBootstrapData); + expect(readData).toEqual({ + cat: { version: 2, flag: { version: 2, variation: 1, value: false } }, + json: { version: 3, flag: { version: 3, variation: 1, value: ['a', 'b', 'c', 'd'] } }, + killswitch: { version: 5, flag: { version: 5, variation: 0, value: true } }, + 'my-boolean-flag': { version: 11, flag: { version: 11, variation: 1, value: false } }, + 'string-flag': { version: 3, flag: { version: 3, variation: 1, value: 'is bob' } }, + }); + expect(logger.debug).not.toHaveBeenCalled(); + expect(logger.info).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); +}); + +it('can read valid bootstrap data with reasons', () => { + const logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + const readData = readFlagsFromBootstrap(logger, goodBootstrapDataWithReasons); + expect(readData).toEqual({ + cat: { + version: 2, + flag: { + version: 2, + variation: 1, + value: false, + reason: { + kind: 'OFF', + }, + }, + }, + json: { + version: 3, + flag: { + version: 3, + variation: 1, + value: ['a', 'b', 'c', 'd'], + reason: { + kind: 'OFF', + }, + }, + }, + killswitch: { + version: 5, + flag: { + version: 5, + variation: 0, + value: true, + reason: { + kind: 'FALLTHROUGH', + }, + }, + }, + 'my-boolean-flag': { + version: 11, + flag: { + version: 11, + variation: 1, + value: false, + reason: { + kind: 'OFF', + }, + }, + }, + 'string-flag': { + version: 3, + flag: { + version: 3, + variation: 1, + value: 'is bob', + reason: { + kind: 'OFF', + }, + }, + }, + }); + expect(logger.debug).not.toHaveBeenCalled(); + expect(logger.info).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); +}); + +it('can read old bootstrap data', () => { + const logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + const oldData: any = { ...goodBootstrapData }; + delete oldData.$flagsState; + + const readData = readFlagsFromBootstrap(logger, oldData); + expect(readData).toEqual({ + cat: { version: 0, flag: { version: 0, value: false } }, + json: { version: 0, flag: { version: 0, value: ['a', 'b', 'c', 'd'] } }, + killswitch: { version: 0, flag: { version: 0, value: true } }, + 'my-boolean-flag': { version: 0, flag: { version: 0, value: false } }, + 'string-flag': { version: 0, flag: { version: 0, value: 'is bob' } }, + }); + expect(logger.debug).not.toHaveBeenCalled(); + expect(logger.info).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + 'LaunchDarkly client was initialized with bootstrap data that did not' + + ' include flag metadata. Events may not be sent correctly.', + ); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.error).not.toHaveBeenCalled(); +}); + +it('can handle invalid bootstrap data', () => { + const logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + const invalid: any = { $valid: false, $flagsState: {} }; + + const readData = readFlagsFromBootstrap(logger, invalid); + expect(readData).toEqual({}); + expect(logger.debug).not.toHaveBeenCalled(); + expect(logger.info).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + 'LaunchDarkly bootstrap data is not available because the back end' + + ' could not read the flags.', + ); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.error).not.toHaveBeenCalled(); +}); diff --git a/packages/sdk/browser/__tests__/testBootstrapData.ts b/packages/sdk/browser/__tests__/testBootstrapData.ts new file mode 100644 index 000000000..bb36d55d9 --- /dev/null +++ b/packages/sdk/browser/__tests__/testBootstrapData.ts @@ -0,0 +1,76 @@ +export const goodBootstrapData = { + cat: false, + json: ['a', 'b', 'c', 'd'], + killswitch: true, + 'my-boolean-flag': false, + 'string-flag': 'is bob', + $flagsState: { + cat: { + variation: 1, + version: 2, + }, + json: { + variation: 1, + version: 3, + }, + killswitch: { + variation: 0, + version: 5, + }, + 'my-boolean-flag': { + variation: 1, + version: 11, + }, + 'string-flag': { + variation: 1, + version: 3, + }, + }, + $valid: true, +}; + +export const goodBootstrapDataWithReasons = { + cat: false, + json: ['a', 'b', 'c', 'd'], + killswitch: true, + 'my-boolean-flag': false, + 'string-flag': 'is bob', + $flagsState: { + cat: { + variation: 1, + version: 2, + reason: { + kind: 'OFF', + }, + }, + json: { + variation: 1, + version: 3, + reason: { + kind: 'OFF', + }, + }, + killswitch: { + variation: 0, + version: 5, + reason: { + kind: 'FALLTHROUGH', + }, + }, + 'my-boolean-flag': { + variation: 1, + version: 11, + reason: { + kind: 'OFF', + }, + }, + 'string-flag': { + variation: 1, + version: 3, + reason: { + kind: 'OFF', + }, + }, + }, + $valid: true, +}; diff --git a/packages/sdk/browser/src/BrowserDataManager.ts b/packages/sdk/browser/src/BrowserDataManager.ts index 197d5b00d..a540f3bea 100644 --- a/packages/sdk/browser/src/BrowserDataManager.ts +++ b/packages/sdk/browser/src/BrowserDataManager.ts @@ -15,6 +15,7 @@ import { Requestor, } from '@launchdarkly/js-client-sdk-common'; +import { readFlagsFromBootstrap } from './bootstrap'; import { BrowserIdentifyOptions } from './BrowserIdentifyOptions'; import { ValidatedOptions } from './options'; @@ -84,14 +85,27 @@ export default class BrowserDataManager extends BaseDataManager { this.setConnectionParams(); } this.secureModeHash = browserIdentifyOptions?.hash; - if (await this.flagManager.loadCached(context)) { - this.debugLog('Identify - Flags loaded from cache. Continuing to initialize via a poll.'); + + if (browserIdentifyOptions?.bootstrap) { + this.finishIdentifyFromBootstrap(context, browserIdentifyOptions.bootstrap, identifyResolve); + } else { + if (await this.flagManager.loadCached(context)) { + this.debugLog('Identify - Flags loaded from cache. Continuing to initialize via a poll.'); + } + const plainContextString = JSON.stringify(Context.toLDContext(context)); + const requestor = this.getRequestor(plainContextString); + await this.finishIdentifyFromPoll(requestor, context, identifyResolve, identifyReject); } - const plainContextString = JSON.stringify(Context.toLDContext(context)); - const requestor = this.getRequestor(plainContextString); - // TODO: Handle wait for network results in a meaningful way. SDK-707 + this.updateStreamingState(); + } + private async finishIdentifyFromPoll( + requestor: Requestor, + context: Context, + identifyResolve: () => void, + identifyReject: (err: Error) => void, + ) { try { this.dataSourceStatusManager.requestStateUpdate(DataSourceState.Initializing); const payload = await requestor.requestPayload(); @@ -113,8 +127,16 @@ export default class BrowserDataManager extends BaseDataManager { ); identifyReject(e); } + } - this.updateStreamingState(); + private finishIdentifyFromBootstrap( + context: Context, + bootstrap: unknown, + identifyResolve: () => void, + ) { + this.flagManager.setBootstrap(context, readFlagsFromBootstrap(this.logger, bootstrap)); + this.debugLog('Identify - Initialization completed from bootstrap'); + identifyResolve(); } setForcedStreaming(streaming?: boolean) { diff --git a/packages/sdk/browser/src/BrowserIdentifyOptions.ts b/packages/sdk/browser/src/BrowserIdentifyOptions.ts index 458178fe6..231b49905 100644 --- a/packages/sdk/browser/src/BrowserIdentifyOptions.ts +++ b/packages/sdk/browser/src/BrowserIdentifyOptions.ts @@ -6,4 +6,19 @@ export interface BrowserIdentifyOptions extends Omit { + if (key !== metadataKey && key !== validKey) { + let flag: Flag; + if (metadata && metadata[key]) { + flag = { + value: data[key], + ...metadata[key], + }; + } else { + flag = { + value: data[key], + version: 0, + }; + } + ret[key] = { + version: flag.version, + flag, + }; + } + }); + return ret; +} diff --git a/packages/shared/sdk-client/src/events/EventFactory.ts b/packages/shared/sdk-client/src/events/EventFactory.ts index 25ef0e055..b0d64a53b 100644 --- a/packages/shared/sdk-client/src/events/EventFactory.ts +++ b/packages/shared/sdk-client/src/events/EventFactory.ts @@ -23,7 +23,7 @@ export default class EventFactory extends internal.EventFactoryBase { defaultVal, flagKey, reason, - trackEvents, + trackEvents: !!trackEvents, value, variation, version, diff --git a/packages/shared/sdk-client/src/flag-manager/FlagManager.ts b/packages/shared/sdk-client/src/flag-manager/FlagManager.ts index 915a81407..4dcc35a3e 100644 --- a/packages/shared/sdk-client/src/flag-manager/FlagManager.ts +++ b/packages/shared/sdk-client/src/flag-manager/FlagManager.ts @@ -40,6 +40,12 @@ export interface FlagManager { */ loadCached(context: Context): Promise; + /** + * Update in-memory storage with the specified flags, but do not persistent them to cache + * storage. + */ + setBootstrap(context: Context, newFlags: { [key: string]: ItemDescriptor }): void; + /** * Register a flag change callback. */ @@ -108,6 +114,12 @@ export default class DefaultFlagManager implements FlagManager { return this.flagStore.getAll(); } + setBootstrap(context: Context, newFlags: { [key: string]: ItemDescriptor }): void { + // Bypasses the persistence as we do not want to put these flags into any cache. + // Generally speaking persistence likely *SHOULD* be disabled when using bootstrap. + this.flagUpdater.init(context, newFlags); + } + async init(context: Context, newFlags: { [key: string]: ItemDescriptor }): Promise { return (await this.flagPersistencePromise).init(context, newFlags); } diff --git a/packages/shared/sdk-client/src/index.ts b/packages/shared/sdk-client/src/index.ts index 5d06a391d..642e55b06 100644 --- a/packages/shared/sdk-client/src/index.ts +++ b/packages/shared/sdk-client/src/index.ts @@ -27,6 +27,8 @@ export type { FlagManager } from './flag-manager/FlagManager'; export type { Configuration } from './configuration/Configuration'; export type { LDEmitter }; +export type { ItemDescriptor } from './flag-manager/ItemDescriptor'; +export type { Flag } from './types'; export { DataSourcePaths } from './streaming'; export { BaseDataManager } from './DataManager'; diff --git a/packages/shared/sdk-client/src/types/index.ts b/packages/shared/sdk-client/src/types/index.ts index 18b24736d..f79ffc01f 100644 --- a/packages/shared/sdk-client/src/types/index.ts +++ b/packages/shared/sdk-client/src/types/index.ts @@ -2,10 +2,10 @@ import { LDEvaluationReason, LDFlagValue } from '@launchdarkly/js-sdk-common'; export interface Flag { version: number; - flagVersion: number; + flagVersion?: number; value: LDFlagValue; - variation: number; - trackEvents: boolean; + variation?: number; + trackEvents?: boolean; trackReason?: boolean; reason?: LDEvaluationReason; debugEventsUntilDate?: number;