diff --git a/packages/sdk/react-native/src/provider/setupListeners.ts b/packages/sdk/react-native/src/provider/setupListeners.ts index cd7a1d34af..111e30ce6d 100644 --- a/packages/sdk/react-native/src/provider/setupListeners.ts +++ b/packages/sdk/react-native/src/provider/setupListeners.ts @@ -7,10 +7,6 @@ const setupListeners = ( client: ReactNativeLDClient, setState: Dispatch>, ) => { - client.on('ready', () => { - setState({ client }); - }); - client.on('change', () => { setState({ client }); }); diff --git a/packages/shared/common/src/api/data/LDFlagChangeset.ts b/packages/shared/common/src/api/data/LDFlagChangeset.ts deleted file mode 100644 index d0523a8331..0000000000 --- a/packages/shared/common/src/api/data/LDFlagChangeset.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { LDFlagValue } from './LDFlagValue'; - -export interface LDFlagChangeset { - [key: string]: { - current?: LDFlagValue; - previous?: LDFlagValue; - }; -} diff --git a/packages/shared/common/src/api/data/index.ts b/packages/shared/common/src/api/data/index.ts index a966f5341a..e990d7f62b 100644 --- a/packages/shared/common/src/api/data/index.ts +++ b/packages/shared/common/src/api/data/index.ts @@ -2,4 +2,3 @@ export * from './LDEvaluationDetail'; export * from './LDEvaluationReason'; export * from './LDFlagSet'; export * from './LDFlagValue'; -export * from './LDFlagChangeset'; diff --git a/packages/shared/mocks/src/streamingProcessor.ts b/packages/shared/mocks/src/streamingProcessor.ts index 2b70d57bab..5312cae66d 100644 --- a/packages/shared/mocks/src/streamingProcessor.ts +++ b/packages/shared/mocks/src/streamingProcessor.ts @@ -35,7 +35,7 @@ export const setupMockStreamingProcessor = ( errorHandler(unauthorized); }); } else { - // execute put which will resolve the init promise + // execute put which will resolve the identify promise process.nextTick(() => listeners.get('put')?.processJson(putResponseJson)); if (patchResponseJson) { diff --git a/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts b/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts index fd2b38fae0..cc2718008f 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts @@ -1,10 +1,10 @@ -import { clone, type LDContext, LDFlagChangeset } from '@launchdarkly/js-sdk-common'; +import { clone, type LDContext } from '@launchdarkly/js-sdk-common'; import { basicPlatform, logger, setupMockStreamingProcessor } from '@launchdarkly/private-js-mocks'; import LDEmitter from './api/LDEmitter'; -import type { DeleteFlag, Flag, Flags, PatchFlag } from './evaluation/fetchFlags'; import * as mockResponseJson from './evaluation/mockResponse.json'; import LDClientImpl from './LDClientImpl'; +import { DeleteFlag, Flag, Flags, PatchFlag } from './types'; jest.mock('@launchdarkly/js-sdk-common', () => { const actual = jest.requireActual('@launchdarkly/js-sdk-common'); @@ -22,17 +22,18 @@ jest.mock('@launchdarkly/js-sdk-common', () => { }; }); -const defaultPutResponse = mockResponseJson as Flags; const testSdkKey = 'test-sdk-key'; const context: LDContext = { kind: 'org', key: 'Testy Pizza' }; let ldc: LDClientImpl; let emitter: LDEmitter; +let defaultPutResponse: Flags; +let defaultFlagKeys: string[]; // Promisify on.change listener so we can await it in tests. const onChangePromise = () => - new Promise((res) => { - ldc.on('change', (_context: LDContext, changeset: LDFlagChangeset) => { - res(changeset); + new Promise((res) => { + ldc.on('change', (_context: LDContext, changes: string[]) => { + res(changes); }); }); @@ -69,6 +70,9 @@ const identifyGetAllFlags = async ( describe('sdk-client storage', () => { beforeEach(() => { jest.useFakeTimers(); + defaultPutResponse = clone(mockResponseJson); + defaultFlagKeys = Object.keys(defaultPutResponse); + basicPlatform.storage.get.mockImplementation(() => JSON.stringify(defaultPutResponse)); jest .spyOn(LDClientImpl.prototype as any, 'createStreamUriPath') @@ -93,8 +97,8 @@ describe('sdk-client storage', () => { // 'change' should not have been emitted expect(emitter.emit).toHaveBeenCalledTimes(3); - expect(emitter.emit).toHaveBeenNthCalledWith(1, 'initializing', context); - expect(emitter.emit).toHaveBeenNthCalledWith(2, 'ready', context); + expect(emitter.emit).toHaveBeenNthCalledWith(1, 'identifying', context); + expect(emitter.emit).toHaveBeenNthCalledWith(2, 'change', context, defaultFlagKeys); expect(emitter.emit).toHaveBeenNthCalledWith( 3, 'error', @@ -113,6 +117,43 @@ describe('sdk-client storage', () => { }); }); + test('no storage, cold start from streamer', async () => { + // fake previously cached flags even though there's no storage for this context + // @ts-ignore + ldc.flags = defaultPutResponse; + basicPlatform.storage.get.mockImplementation(() => undefined); + setupMockStreamingProcessor(false, defaultPutResponse); + + const p = ldc.identify(context); + + // I'm not sure why but both runAllTimersAsync and runAllTicks are required + // here for the identify promise be resolved + await jest.runAllTimersAsync(); + jest.runAllTicks(); + await p; + + expect(emitter.emit).toHaveBeenCalledTimes(1); + expect(emitter.emit).toHaveBeenNthCalledWith(1, 'identifying', context); + expect(basicPlatform.storage.set).toHaveBeenNthCalledWith( + 1, + 'org:Testy Pizza', + JSON.stringify(defaultPutResponse), + ); + expect(ldc.logger.debug).toHaveBeenCalledWith('Not emitting changes from PUT'); + + // this is defaultPutResponse + expect(ldc.allFlags()).toEqual({ + 'dev-test-flag': true, + 'easter-i-tunes-special': false, + 'easter-specials': 'no specials', + fdsafdsafdsafdsa: true, + 'log-level': 'warn', + 'moonshot-demo': true, + test1: 's1', + 'this-is-a-test': true, + }); + }); + test('syncing storage when a flag is deleted', async () => { const putResponse = clone(defaultPutResponse); delete putResponse['dev-test-flag']; @@ -123,11 +164,9 @@ describe('sdk-client storage', () => { 'org:Testy Pizza', JSON.stringify(putResponse), ); - expect(emitter.emit).toHaveBeenNthCalledWith(1, 'initializing', context); - expect(emitter.emit).toHaveBeenNthCalledWith(2, 'ready', context); - expect(emitter.emit).toHaveBeenNthCalledWith(3, 'change', context, { - 'dev-test-flag': { previous: true }, - }); + expect(emitter.emit).toHaveBeenNthCalledWith(1, 'identifying', context); + expect(emitter.emit).toHaveBeenNthCalledWith(2, 'change', context, defaultFlagKeys); + expect(emitter.emit).toHaveBeenNthCalledWith(3, 'change', context, ['dev-test-flag']); }); test('syncing storage when a flag is added', async () => { @@ -147,9 +186,7 @@ describe('sdk-client storage', () => { 'org:Testy Pizza', JSON.stringify(putResponse), ); - expect(emitter.emit).toHaveBeenNthCalledWith(3, 'change', context, { - 'another-dev-test-flag': { current: newFlag.value }, - }); + expect(emitter.emit).toHaveBeenNthCalledWith(3, 'change', context, ['another-dev-test-flag']); }); test('syncing storage when a flag is updated', async () => { @@ -159,12 +196,7 @@ describe('sdk-client storage', () => { const allFlags = await identifyGetAllFlags(false, putResponse); expect(allFlags).toMatchObject({ 'dev-test-flag': false }); - expect(emitter.emit).toHaveBeenNthCalledWith(3, 'change', context, { - 'dev-test-flag': { - previous: true, - current: putResponse['dev-test-flag'].value, - }, - }); + expect(emitter.emit).toHaveBeenNthCalledWith(3, 'change', context, ['dev-test-flag']); }); test('syncing storage on multiple flag operations', async () => { @@ -179,17 +211,14 @@ describe('sdk-client storage', () => { expect(allFlags).toMatchObject({ 'dev-test-flag': false, 'another-dev-test-flag': true }); expect(allFlags).not.toHaveProperty('moonshot-demo'); - expect(emitter.emit).toHaveBeenNthCalledWith(3, 'change', context, { - 'dev-test-flag': { - previous: true, - current: putResponse['dev-test-flag'].value, - }, - 'another-dev-test-flag': { current: newFlag.value }, - 'moonshot-demo': { previous: true }, - }); + expect(emitter.emit).toHaveBeenNthCalledWith(3, 'change', context, [ + 'moonshot-demo', + 'dev-test-flag', + 'another-dev-test-flag', + ]); }); - test('syncing storage when PUT is consistent so no updates needed', async () => { + test('syncing storage when PUT is consistent so no change', async () => { const allFlags = await identifyGetAllFlags( false, defaultPutResponse, @@ -198,8 +227,13 @@ describe('sdk-client storage', () => { false, ); - expect(basicPlatform.storage.set).not.toHaveBeenCalled(); - expect(emitter.emit).not.toHaveBeenCalledWith('change'); + expect(basicPlatform.storage.set).toHaveBeenNthCalledWith( + 1, + 'org:Testy Pizza', + JSON.stringify(defaultPutResponse), + ); + expect(emitter.emit).toHaveBeenCalledTimes(2); + expect(emitter.emit).toHaveBeenNthCalledWith(2, 'change', context, defaultFlagKeys); // this is defaultPutResponse expect(allFlags).toEqual({ @@ -229,12 +263,7 @@ describe('sdk-client storage', () => { // both previous and current are true but inExperiment has changed // so a change event should be emitted - expect(emitter.emit).toHaveBeenNthCalledWith(3, 'change', context, { - 'dev-test-flag': { - previous: true, - current: true, - }, - }); + expect(emitter.emit).toHaveBeenNthCalledWith(3, 'change', context, ['dev-test-flag']); }); test('patch should emit change event', async () => { @@ -244,23 +273,16 @@ describe('sdk-client storage', () => { patchResponse.version += 1; const allFlags = await identifyGetAllFlags(false, defaultPutResponse, patchResponse); - const flagsInStorage = JSON.parse(basicPlatform.storage.set.mock.calls[0][1]) as Flags; + const flagsInStorage = JSON.parse(basicPlatform.storage.set.mock.lastCall[1]) as Flags; expect(allFlags).toMatchObject({ 'dev-test-flag': false }); - expect(basicPlatform.storage.set).toHaveBeenCalledTimes(1); - expect(basicPlatform.storage.set).toHaveBeenNthCalledWith( - 1, + expect(basicPlatform.storage.set).toHaveBeenCalledWith( 'org:Testy Pizza', expect.stringContaining(JSON.stringify(patchResponse)), ); expect(flagsInStorage['dev-test-flag'].version).toEqual(patchResponse.version); expect(emitter.emit).toHaveBeenCalledTimes(3); - expect(emitter.emit).toHaveBeenNthCalledWith(3, 'change', context, { - 'dev-test-flag': { - previous: true, - current: false, - }, - }); + expect(emitter.emit).toHaveBeenNthCalledWith(3, 'change', context, ['dev-test-flag']); }); test('patch should add new flags', async () => { @@ -268,22 +290,16 @@ describe('sdk-client storage', () => { patchResponse.key = 'another-dev-test-flag'; const allFlags = await identifyGetAllFlags(false, defaultPutResponse, patchResponse); - const flagsInStorage = JSON.parse(basicPlatform.storage.set.mock.calls[0][1]) as Flags; + const flagsInStorage = JSON.parse(basicPlatform.storage.set.mock.lastCall[1]) as Flags; expect(allFlags).toHaveProperty('another-dev-test-flag'); - expect(basicPlatform.storage.set).toHaveBeenCalledTimes(1); - expect(basicPlatform.storage.set).toHaveBeenNthCalledWith( - 1, + expect(basicPlatform.storage.set).toHaveBeenCalledWith( 'org:Testy Pizza', expect.stringContaining(JSON.stringify(patchResponse)), ); expect(flagsInStorage).toHaveProperty('another-dev-test-flag'); expect(emitter.emit).toHaveBeenCalledTimes(3); - expect(emitter.emit).toHaveBeenNthCalledWith(3, 'change', context, { - 'another-dev-test-flag': { - current: true, - }, - }); + expect(emitter.emit).toHaveBeenNthCalledWith(3, 'change', context, ['another-dev-test-flag']); }); test('patch should ignore older version', async () => { @@ -300,7 +316,7 @@ describe('sdk-client storage', () => { false, ); - expect(basicPlatform.storage.set).not.toHaveBeenCalled(); + expect(basicPlatform.storage.set).toHaveBeenCalledTimes(1); expect(emitter.emit).not.toHaveBeenCalledWith('change'); // this is defaultPutResponse @@ -328,22 +344,16 @@ describe('sdk-client storage', () => { undefined, deleteResponse, ); - const flagsInStorage = JSON.parse(basicPlatform.storage.set.mock.calls[0][1]) as Flags; + const flagsInStorage = JSON.parse(basicPlatform.storage.set.mock.lastCall[1]) as Flags; expect(allFlags).not.toHaveProperty('dev-test-flag'); - expect(basicPlatform.storage.set).toHaveBeenCalledTimes(1); - expect(basicPlatform.storage.set).toHaveBeenNthCalledWith( - 1, + expect(basicPlatform.storage.set).toHaveBeenCalledWith( 'org:Testy Pizza', expect.not.stringContaining('dev-test-flag'), ); expect(flagsInStorage['dev-test-flag']).toBeUndefined(); expect(emitter.emit).toHaveBeenCalledTimes(3); - expect(emitter.emit).toHaveBeenNthCalledWith(3, 'change', context, { - 'dev-test-flag': { - previous: true, - }, - }); + expect(emitter.emit).toHaveBeenNthCalledWith(3, 'change', context, ['dev-test-flag']); }); test('delete should not delete newer version', async () => { @@ -361,7 +371,7 @@ describe('sdk-client storage', () => { ); expect(allFlags).toHaveProperty('dev-test-flag'); - expect(basicPlatform.storage.set).not.toHaveBeenCalled(); + expect(basicPlatform.storage.set).toHaveBeenCalledTimes(1); expect(emitter.emit).not.toHaveBeenCalledWith('change'); }); @@ -373,7 +383,7 @@ describe('sdk-client storage', () => { await identifyGetAllFlags(false, defaultPutResponse, undefined, deleteResponse, false); - expect(basicPlatform.storage.set).not.toHaveBeenCalled(); + expect(basicPlatform.storage.set).toHaveBeenCalledTimes(1); expect(emitter.emit).not.toHaveBeenCalledWith('change'); }); }); diff --git a/packages/shared/sdk-client/src/LDClientImpl.test.ts b/packages/shared/sdk-client/src/LDClientImpl.test.ts index 39b89e132c..52395b85cd 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.test.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.test.ts @@ -130,7 +130,7 @@ describe('sdk-client object', () => { expect(ldc.getContext()).toBeUndefined(); }); - test('identify ready and error listeners', async () => { + test('identify change and error listeners', async () => { // @ts-ignore const { emitter } = ldc; @@ -142,7 +142,7 @@ describe('sdk-client object', () => { const carContext2: LDContext = { kind: 'car', key: 'subaru-forrester' }; await ldc.identify(carContext2); - expect(emitter.listenerCount('ready')).toEqual(1); + expect(emitter.listenerCount('change')).toEqual(1); expect(emitter.listenerCount('error')).toEqual(1); }); }); diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index 459447e5e0..e4037f33d8 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -2,13 +2,11 @@ import { ClientContext, clone, Context, - fastDeepEqual, internal, LDClientError, LDContext, LDEvaluationDetail, LDEvaluationDetailTyped, - LDFlagChangeset, LDFlagSet, LDFlagValue, LDLogger, @@ -23,9 +21,10 @@ import { LDClient, type LDOptions } from './api'; import LDEmitter, { EventName } from './api/LDEmitter'; import Configuration from './configuration'; import createDiagnosticsManager from './diagnostics/createDiagnosticsManager'; -import type { DeleteFlag, Flags, PatchFlag } from './evaluation/fetchFlags'; import createEventProcessor from './events/createEventProcessor'; import EventFactory from './events/EventFactory'; +import { DeleteFlag, Flags, PatchFlag } from './types'; +import { calculateFlagChanges } from './utils'; const { createErrorEvaluationDetail, createSuccessEvaluationDetail, ClientMessages, ErrorKinds } = internal; @@ -42,7 +41,7 @@ export default class LDClientImpl implements LDClient { private eventFactoryWithReasons = new EventFactory(true); private emitter: LDEmitter; private flags: Flags = {}; - private identifyReadyListener?: (c: LDContext) => void; + private identifyChangeListener?: (c: LDContext, changedKeys: string[]) => void; private identifyErrorListener?: (c: LDContext, err: any) => void; private readonly clientContext: ClientContext; @@ -107,7 +106,7 @@ export default class LDClientImpl implements LDClient { private createStreamListeners( context: LDContext, canonicalKey: string, - initializedFromStorage: boolean, + identifyResolve: any, ): Map { const listeners = new Map(); @@ -115,40 +114,19 @@ export default class LDClientImpl implements LDClient { deserializeData: JSON.parse, processJson: async (dataJson: Flags) => { this.logger.debug(`Streamer PUT: ${Object.keys(dataJson)}`); - if (initializedFromStorage) { - this.logger.debug('Synchronizing all data'); - const changeset: LDFlagChangeset = {}; - - Object.entries(this.flags).forEach(([k, f]) => { - const flagFromPut = dataJson[k]; - if (!flagFromPut) { - // flag deleted - changeset[k] = { previous: f.value }; - } else if (!fastDeepEqual(f, flagFromPut)) { - // flag changed - changeset[k] = { previous: f.value, current: flagFromPut.value }; - } - }); - - Object.entries(dataJson).forEach(([k, f]) => { - const flagFromStorage = this.flags[k]; - if (!flagFromStorage) { - // flag added - changeset[k] = { current: f.value }; - } - }); - - if (Object.keys(changeset).length > 0) { - this.flags = dataJson; - await this.platform.storage?.set(canonicalKey, JSON.stringify(this.flags)); - this.emitter.emit('change', context, changeset); - } + const changedKeys = calculateFlagChanges(this.flags, dataJson); + this.context = context; + this.flags = dataJson; + await this.platform.storage?.set(canonicalKey, JSON.stringify(this.flags)); + + if (changedKeys.length > 0) { + this.logger.debug(`Emitting changes from PUT: ${changedKeys}`); + // emitting change resolves identify + this.emitter.emit('change', context, changedKeys); } else { - this.logger.debug('Initializing all data from stream'); - this.context = context; - this.flags = dataJson; - await this.platform.storage?.set(canonicalKey, JSON.stringify(this.flags)); - this.emitter.emit('ready', context); + // manually resolve identify + this.logger.debug('Not emitting changes from PUT'); + identifyResolve(); } }, }); @@ -159,21 +137,13 @@ export default class LDClientImpl implements LDClient { this.logger.debug(`Streamer PATCH ${JSON.stringify(dataJson, null, 2)}`); const existing = this.flags[dataJson.key]; - // add flag if it doesn't exist - // if does, update it if version is newer + // add flag if it doesn't exist or update it if version is newer if (!existing || (existing && dataJson.version > existing.version)) { - const changeset: LDFlagChangeset = {}; - changeset[dataJson.key] = { - current: dataJson.value, - }; - - if (existing) { - changeset[dataJson.key].previous = existing.value; - } - this.flags[dataJson.key] = dataJson; await this.platform.storage?.set(canonicalKey, JSON.stringify(this.flags)); - this.emitter.emit('change', context, changeset); + const changedKeys = [dataJson.key]; + this.logger.debug(`Emitting changes from PATCH: ${changedKeys}`); + this.emitter.emit('change', context, changedKeys); } }, }); @@ -185,13 +155,11 @@ export default class LDClientImpl implements LDClient { const existing = this.flags[dataJson.key]; if (existing && existing.version <= dataJson.version) { - const changeset: LDFlagChangeset = {}; - changeset[dataJson.key] = { - previous: this.flags[dataJson.key].value, - }; delete this.flags[dataJson.key]; await this.platform.storage?.set(canonicalKey, JSON.stringify(this.flags)); - this.emitter.emit('change', context, changeset); + const changedKeys = [dataJson.key]; + this.logger.debug(`Emitting changes from DELETE: ${changedKeys}`); + this.emitter.emit('change', context, changedKeys); } }, }); @@ -205,11 +173,11 @@ export default class LDClientImpl implements LDClient { * For mobile key: /meval/${base64-encoded-context} * For clientSideId: /eval/${envId}/${base64-encoded-context} * - * @param context The LD context object to be base64 encoded and appended to * the path. * * @protected This function must be overridden in subclasses for streamer * to work. + * @param _context The LDContext object */ protected createStreamUriPath(_context: LDContext): string { throw new Error( @@ -218,16 +186,19 @@ export default class LDClientImpl implements LDClient { } private createPromiseWithListeners() { - return new Promise((resolve, reject) => { - if (this.identifyReadyListener) { - this.emitter.off('ready', this.identifyReadyListener); + let res: any; + const p = new Promise((resolve, reject) => { + res = resolve; + + if (this.identifyChangeListener) { + this.emitter.off('change', this.identifyChangeListener); } if (this.identifyErrorListener) { this.emitter.off('error', this.identifyErrorListener); } - this.identifyReadyListener = (c: LDContext) => { - this.logger.debug(`ready: ${JSON.stringify(c)}`); + this.identifyChangeListener = (c: LDContext, changedKeys: string[]) => { + this.logger.debug(`change: context: ${JSON.stringify(c)}, flags: ${changedKeys}`); resolve(); }; this.identifyErrorListener = (c: LDContext, err: any) => { @@ -235,9 +206,11 @@ export default class LDClientImpl implements LDClient { reject(err); }; - this.emitter.on('ready', this.identifyReadyListener); + this.emitter.on('change', this.identifyChangeListener); this.emitter.on('error', this.identifyErrorListener); }); + + return { identifyPromise: p, identifyResolve: res }; } private async getFlagsFromStorage(canonicalKey: string): Promise { @@ -255,15 +228,18 @@ export default class LDClientImpl implements LDClient { return Promise.reject(error); } - const p = this.createPromiseWithListeners(); - this.emitter.emit('initializing', context); + const { identifyPromise, identifyResolve } = this.createPromiseWithListeners(); + this.logger.debug(`Identifying ${JSON.stringify(context)}`); + this.emitter.emit('identifying', context); const flagsStorage = await this.getFlagsFromStorage(checkedContext.canonicalKey); if (flagsStorage) { - this.logger.debug('Initializing all data from storage'); + this.logger.debug('Using storage'); + + const changedKeys = calculateFlagChanges(this.flags, flagsStorage); this.context = context; this.flags = flagsStorage; - this.emitter.emit('ready', context); + this.emitter.emit('change', context, changedKeys); } this.streamer?.close(); @@ -271,7 +247,7 @@ export default class LDClientImpl implements LDClient { this.sdkKey, this.clientContext, this.createStreamUriPath(context), - this.createStreamListeners(context, checkedContext.canonicalKey, !!flagsStorage), + this.createStreamListeners(context, checkedContext.canonicalKey, identifyResolve), this.diagnosticsManager, (e) => { this.logger.error(e); @@ -280,7 +256,7 @@ export default class LDClientImpl implements LDClient { ); this.streamer.start(); - return p; + return identifyPromise; } off(eventName: EventName, listener: Function): void { diff --git a/packages/shared/sdk-client/src/api/LDClient.ts b/packages/shared/sdk-client/src/api/LDClient.ts index efa1087eb2..d378bad450 100644 --- a/packages/shared/sdk-client/src/api/LDClient.ts +++ b/packages/shared/sdk-client/src/api/LDClient.ts @@ -196,21 +196,14 @@ export interface LDClient { * * The following event names (keys) are used by the client: * - * - `"ready"`: The client has finished starting up. This event will be sent regardless - * of whether it successfully connected to LaunchDarkly, or encountered an error - * and had to give up; to distinguish between these cases, see below. - * - `"initialized"`: The client successfully started up and has valid feature flag - * data. This will always be accompanied by `"ready"`. - * - `"failed"`: The client encountered an error that prevented it from connecting to - * LaunchDarkly, such as an invalid environment ID. All flag evaluations will - * therefore receive default values. This will always be accompanied by `"ready"`. + * - `"identifying"`: The client starts to fetch feature flags. * - `"error"`: General event for any kind of error condition during client operation. * The callback parameter is an Error object. If you do not listen for "error" * events, then the errors will be logged with `console.log()`. * - `"change"`: The client has received new feature flag data. This can happen either * because you have switched contexts with {@link identify}, or because the client has a * stream connection and has received a live change to a flag value (see below). - * The callback parameter is an {@link LDFlagChangeset}. + * The callback parameters are the context and an array of flag keys that have changed. * * @param key * The name of the event for which to listen. diff --git a/packages/shared/sdk-client/src/api/LDEmitter.test.ts b/packages/shared/sdk-client/src/api/LDEmitter.test.ts index 8a66a17180..6cf6a7593c 100644 --- a/packages/shared/sdk-client/src/api/LDEmitter.test.ts +++ b/packages/shared/sdk-client/src/api/LDEmitter.test.ts @@ -57,11 +57,11 @@ describe('LDEmitter', () => { const changeHandler = jest.fn(); const readyHandler = jest.fn(); + emitter.on('identifying', readyHandler); emitter.on('error', errorHandler1); emitter.on('change', changeHandler); - emitter.on('ready', readyHandler); - expect(emitter.eventNames()).toEqual(['error', 'change', 'ready']); + expect(emitter.eventNames()).toEqual(['identifying', 'error', 'change']); }); test('listener count', () => { diff --git a/packages/shared/sdk-client/src/api/LDEmitter.ts b/packages/shared/sdk-client/src/api/LDEmitter.ts index 3b5f0f5fe4..9248f58745 100644 --- a/packages/shared/sdk-client/src/api/LDEmitter.ts +++ b/packages/shared/sdk-client/src/api/LDEmitter.ts @@ -1,4 +1,4 @@ -export type EventName = 'initializing' | 'ready' | 'error' | 'change'; +export type EventName = 'identifying' | 'error' | 'change'; type CustomEventListeners = { original: Function; diff --git a/packages/shared/sdk-client/src/configuration/Configuration.test.ts b/packages/shared/sdk-client/src/configuration/Configuration.test.ts index e501b3d849..6a569b296a 100644 --- a/packages/shared/sdk-client/src/configuration/Configuration.test.ts +++ b/packages/shared/sdk-client/src/configuration/Configuration.test.ts @@ -36,8 +36,8 @@ describe('Configuration', () => { }); test('specified options should be set', () => { - const config = new Configuration({ wrapperName: 'test', stream: true }); - expect(config).toMatchObject({ wrapperName: 'test', stream: true }); + const config = new Configuration({ wrapperName: 'test' }); + expect(config).toMatchObject({ wrapperName: 'test' }); }); test('unknown option', () => { @@ -52,7 +52,7 @@ describe('Configuration', () => { // @ts-ignore const config = new Configuration({ sendEvents: 0 }); - expect(config.stream).toBeFalsy(); + expect(config.sendEvents).toBeFalsy(); expect(console.error).toHaveBeenCalledWith( expect.stringContaining('should be a boolean, got number, converting'), ); @@ -78,29 +78,6 @@ describe('Configuration', () => { ); }); - test('undefined stream should not log warning', () => { - const config = new Configuration({ stream: undefined }); - - expect(config.stream).toBeUndefined(); - expect(console.error).not.toHaveBeenCalled(); - }); - - test('null stream should default to undefined', () => { - // @ts-ignore - const config = new Configuration({ stream: null }); - - expect(config.stream).toBeUndefined(); - expect(console.error).not.toHaveBeenCalled(); - }); - - test('wrong stream type should be converted to boolean', () => { - // @ts-ignore - const config = new Configuration({ stream: 1 }); - - expect(config.stream).toBeTruthy(); - expect(console.error).toHaveBeenCalled(); - }); - test('invalid bootstrap should use default', () => { // @ts-ignore const config = new Configuration({ bootstrap: 'localStora' }); diff --git a/packages/shared/sdk-client/src/diagnostics/createDiagnosticsInitConfig.test.ts b/packages/shared/sdk-client/src/diagnostics/createDiagnosticsInitConfig.test.ts index 64f7410446..c25c0a353e 100644 --- a/packages/shared/sdk-client/src/diagnostics/createDiagnosticsInitConfig.test.ts +++ b/packages/shared/sdk-client/src/diagnostics/createDiagnosticsInitConfig.test.ts @@ -23,7 +23,6 @@ describe('createDiagnosticsInitConfig', () => { eventsCapacity: 100, eventsFlushIntervalMillis: secondsToMillis(2), reconnectTimeMillis: secondsToMillis(1), - streamingDisabled: true, usingSecureMode: false, }); }); @@ -38,7 +37,6 @@ describe('createDiagnosticsInitConfig', () => { flushInterval: 2, streamInitialReconnectDelay: 3, diagnosticRecordingInterval: 4, - stream: true, allAttributesPrivate: true, hash: 'test-hash', bootstrap: 'localStorage', @@ -54,7 +52,6 @@ describe('createDiagnosticsInitConfig', () => { eventsCapacity: 1, eventsFlushIntervalMillis: 2000, reconnectTimeMillis: 3000, - streamingDisabled: false, usingSecureMode: true, }); }); diff --git a/packages/shared/sdk-client/src/diagnostics/createDiagnosticsInitConfig.ts b/packages/shared/sdk-client/src/diagnostics/createDiagnosticsInitConfig.ts index 6bf267381c..f653b581fa 100644 --- a/packages/shared/sdk-client/src/diagnostics/createDiagnosticsInitConfig.ts +++ b/packages/shared/sdk-client/src/diagnostics/createDiagnosticsInitConfig.ts @@ -11,7 +11,6 @@ export type DiagnosticsInitConfig = { eventsFlushIntervalMillis: number; reconnectTimeMillis: number; diagnosticRecordingIntervalMillis: number; - streamingDisabled: boolean; allAttributesPrivate: boolean; // client specific properties @@ -26,7 +25,6 @@ const createDiagnosticsInitConfig = (config: Configuration): DiagnosticsInitConf eventsFlushIntervalMillis: secondsToMillis(config.flushInterval), reconnectTimeMillis: secondsToMillis(config.streamInitialReconnectDelay), diagnosticRecordingIntervalMillis: secondsToMillis(config.diagnosticRecordingInterval), - streamingDisabled: !config.stream, allAttributesPrivate: config.allAttributesPrivate, usingSecureMode: !!config.hash, bootstrapMode: !!config.bootstrap, diff --git a/packages/shared/sdk-client/src/evaluation/fetchFlags.test.ts b/packages/shared/sdk-client/src/evaluation/fetchFlags.test.ts index 4a5975af9c..2f6bbc9a91 100644 --- a/packages/shared/sdk-client/src/evaluation/fetchFlags.test.ts +++ b/packages/shared/sdk-client/src/evaluation/fetchFlags.test.ts @@ -14,12 +14,6 @@ describe('fetchFeatures', () => { 'user-agent': 'TestUserAgent/2.0.2', 'x-launchdarkly-wrapper': 'Rapper/1.2.3', }; - const reportHeaders = { - authorization: 'testSdkKey1', - 'content-type': 'application/json', - 'user-agent': 'TestUserAgent/2.0.2', - 'x-launchdarkly-wrapper': 'Rapper/1.2.3', - }; let config: Configuration; const platformFetch = basicPlatform.requests.fetch as jest.Mock; @@ -46,21 +40,6 @@ describe('fetchFeatures', () => { expect(json).toEqual(mockResponse); }); - test('report', async () => { - config = new Configuration({ useReport: true }); - const json = await fetchFlags(sdkKey, context, config, basicPlatform); - - expect(platformFetch).toBeCalledWith( - 'https://sdk.launchdarkly.com/sdk/evalx/testSdkKey1/context', - { - method: 'REPORT', - headers: reportHeaders, - body: '{"kind":"user","key":"test-user-key-1"}', - }, - ); - expect(json).toEqual(mockResponse); - }); - test('withReasons', async () => { mockFetch(mockResponseWithReasons); config = new Configuration({ withReasons: true }); diff --git a/packages/shared/sdk-client/src/evaluation/fetchFlags.ts b/packages/shared/sdk-client/src/evaluation/fetchFlags.ts index d9486a0a1f..755de56b8c 100644 --- a/packages/shared/sdk-client/src/evaluation/fetchFlags.ts +++ b/packages/shared/sdk-client/src/evaluation/fetchFlags.ts @@ -1,29 +1,9 @@ -import { LDContext, LDEvaluationReason, LDFlagValue, Platform } from '@launchdarkly/js-sdk-common'; +import { LDContext, Platform } from '@launchdarkly/js-sdk-common'; import Configuration from '../configuration'; +import { Flags } from '../types'; import { createFetchOptions, createFetchUrl } from './fetchUtils'; -export interface Flag { - version: number; - flagVersion: number; - value: LDFlagValue; - variation: number; - trackEvents: boolean; - trackReason?: boolean; - reason?: LDEvaluationReason; - debugEventsUntilDate?: number; -} - -export interface PatchFlag extends Flag { - key: string; -} - -export type DeleteFlag = Pick; - -export type Flags = { - [k: string]: Flag; -}; - const fetchFlags = async ( sdkKey: string, context: LDContext, diff --git a/packages/shared/sdk-client/src/events/EventFactory.ts b/packages/shared/sdk-client/src/events/EventFactory.ts index 69bf4f889e..25ef0e0550 100644 --- a/packages/shared/sdk-client/src/events/EventFactory.ts +++ b/packages/shared/sdk-client/src/events/EventFactory.ts @@ -1,6 +1,6 @@ import { Context, internal, LDEvaluationReason, LDFlagValue } from '@launchdarkly/js-sdk-common'; -import { Flag } from '../evaluation/fetchFlags'; +import { Flag } from '../types'; /** * @internal diff --git a/packages/shared/sdk-client/src/types/index.ts b/packages/shared/sdk-client/src/types/index.ts new file mode 100644 index 0000000000..1ab9cb4816 --- /dev/null +++ b/packages/shared/sdk-client/src/types/index.ts @@ -0,0 +1,22 @@ +import { LDEvaluationReason, LDFlagValue } from '@launchdarkly/js-sdk-common'; + +export interface Flag { + version: number; + flagVersion: number; + value: LDFlagValue; + variation: number; + trackEvents: boolean; + trackReason?: boolean; + reason?: LDEvaluationReason; + debugEventsUntilDate?: number; +} + +export interface PatchFlag extends Flag { + key: string; +} + +export type DeleteFlag = Pick; + +export type Flags = { + [k: string]: Flag; +}; diff --git a/packages/shared/sdk-client/src/utils/index.ts b/packages/shared/sdk-client/src/utils/index.ts new file mode 100644 index 0000000000..9b443b96b4 --- /dev/null +++ b/packages/shared/sdk-client/src/utils/index.ts @@ -0,0 +1,25 @@ +import { fastDeepEqual } from '@launchdarkly/js-sdk-common'; + +import { Flags } from '../types'; + +// eslint-disable-next-line import/prefer-default-export +export function calculateFlagChanges(flags: Flags, incomingFlags: Flags) { + const changedKeys: string[] = []; + + // flag deleted or updated + Object.entries(flags).forEach(([k, f]) => { + const incoming = incomingFlags[k]; + if (!incoming || !fastDeepEqual(f, incoming)) { + changedKeys.push(k); + } + }); + + // flag added + Object.keys(incomingFlags).forEach((k) => { + if (!flags[k]) { + changedKeys.push(k); + } + }); + + return changedKeys; +}