diff --git a/packages/sdk/browser/__tests__/BrowserClient.test.ts b/packages/sdk/browser/__tests__/BrowserClient.test.ts index 0d06abde70..08011ab1db 100644 --- a/packages/sdk/browser/__tests__/BrowserClient.test.ts +++ b/packages/sdk/browser/__tests__/BrowserClient.test.ts @@ -373,4 +373,57 @@ describe('given a mock platform for a BrowserClient', () => { // With events and goals disabled the only fetch calls should be for polling requests. expect(platform.requests.fetch.mock.calls.length).toBe(3); }); + + it('blocks until the client is ready when waitForInitialization is called', async () => { + const client = makeClient( + 'client-side-id', + AutoEnvAttributes.Disabled, + { streaming: false, logger, diagnosticOptOut: true, sendEvents: false, fetchGoals: false }, + platform, + ); + + const waitPromise = client.waitForInitialization(10); + const identifyPromise = client.identify({ key: 'user-key', kind: 'user' }); + + await Promise.all([waitPromise, identifyPromise]); + + await expect(waitPromise).resolves.toBeUndefined(); + await expect(identifyPromise).resolves.toEqual({ status: 'completed' }); + }); + + it('rejects when initialization does not complete before the timeout', async () => { + jest.useRealTimers(); + + // Create a platform with a delayed fetch response + const delayedPlatform = makeBasicPlatform(); + let resolveFetch: (value: any) => void; + const delayedFetchPromise = new Promise((resolve) => { + resolveFetch = resolve; + }); + + // Mock fetch to return a promise that won't resolve until we explicitly resolve it + delayedPlatform.requests.fetch = jest.fn((_url: string, _options: any) => + delayedFetchPromise.then(() => ({})), + ) as any; + + const client = makeClient( + 'client-side-id', + 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' }); + + // Call waitForInitialization with a short timeout (0.1 seconds) + const waitPromise = client.waitForInitialization(0.1); + + // Verify that waitForInitialization rejects with a timeout error + await expect(waitPromise).rejects.toThrow(); + + // Clean up: resolve the fetch to avoid hanging promises and restore fake timers + resolveFetch!({}); + jest.useFakeTimers(); + }); }); diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index abb9ceb4c2..56e2f5f0f3 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -2,6 +2,7 @@ import { AutoEnvAttributes, base64UrlEncode, BasicLogger, + cancelableTimedPromise, Configuration, Encoding, FlagManager, @@ -15,7 +16,9 @@ import { LDHeaders, LDIdentifyResult, LDPluginEnvironmentMetadata, + LDTimeoutError, Platform, + safeRegisterDebugOverridePlugins, } from '@launchdarkly/js-client-sdk-common'; import { getHref } from './BrowserApi'; @@ -32,6 +35,9 @@ import BrowserPlatform from './platform/BrowserPlatform'; class BrowserClientImpl extends LDClientImpl { private readonly _goalManager?: GoalManager; private readonly _plugins?: LDPlugin[]; + private _initializedPromise?: Promise; + private _initResolve?: () => void; + private _isInitialized: boolean = false; constructor( clientSideId: string, @@ -195,6 +201,11 @@ class BrowserClientImpl extends LDClientImpl { client, this._plugins || [], ); + + const override = this.getDebugOverrides(); + if (override) { + safeRegisterDebugOverridePlugins(this.logger, override, this._plugins || []); + } } override async identify(context: LDContext, identifyOptions?: LDIdentifyOptions): Promise { @@ -212,10 +223,60 @@ class BrowserClientImpl extends LDClientImpl { identifyOptionsWithUpdatedDefaults.sheddable = true; } const res = await super.identifyResult(context, identifyOptionsWithUpdatedDefaults); + if (res.status === 'completed') { + this._isInitialized = true; + this._initResolve?.(); + } this._goalManager?.startTracking(); return res; } + waitForInitialization(timeout: number = 5): Promise { + // An initialization promise is only created if someone is going to use that promise. + // If we always created an initialization promise, and there was no call waitForInitialization + // by the time the promise was rejected, then that would result in an unhandled promise + // rejection. + + // It waitForInitialization was previously called, then we can use that promise even if it has + // been resolved or rejected. + if (this._initializedPromise) { + return this._promiseWithTimeout(this._initializedPromise, timeout); + } + + if (this._isInitialized) { + return Promise.resolve(); + } + + if (!this._initializedPromise) { + this._initializedPromise = new Promise((resolve) => { + this._initResolve = resolve; + }); + } + + return this._promiseWithTimeout(this._initializedPromise, timeout); + } + + /** + * Apply a timeout promise to a base promise. This is for use with waitForInitialization. + * + * @param basePromise The promise to race against a timeout. + * @param timeout The timeout in seconds. + * @param logger A logger to log when the timeout expires. + * @returns + */ + private _promiseWithTimeout(basePromise: Promise, timeout: number): Promise { + const cancelableTimeout = cancelableTimedPromise(timeout, 'waitForInitialization'); + return Promise.race([ + basePromise.then(() => cancelableTimeout.cancel()), + cancelableTimeout.promise, + ]).catch((reason) => { + if (reason instanceof LDTimeoutError) { + this.logger?.error(reason.message); + } + throw reason; + }); + } + setStreaming(streaming?: boolean): void { // With FDv2 we may want to consider if we support connection mode directly. // Maybe with an extension to connection mode for 'automatic'. @@ -282,6 +343,7 @@ export function makeClient( close: () => impl.close(), allFlags: () => impl.allFlags(), addHook: (hook: Hook) => impl.addHook(hook), + waitForInitialization: (timeout: number) => impl.waitForInitialization(timeout), logger: impl.logger, }; diff --git a/packages/sdk/browser/src/LDClient.ts b/packages/sdk/browser/src/LDClient.ts index 6c4f515eef..cef424279a 100644 --- a/packages/sdk/browser/src/LDClient.ts +++ b/packages/sdk/browser/src/LDClient.ts @@ -66,4 +66,42 @@ export type LDClient = Omit< pristineContext: LDContext, identifyOptions?: LDIdentifyOptions, ): Promise; + + /** + * Returns a Promise that tracks the client's initialization state. + * + * The Promise will be resolved if the client successfully initializes, or rejected if client + * initialization takes longer than the set timeout. + * + * ``` + * // using async/await + * try { + * await client.waitForInitialization(5); + * doSomethingWithSuccessfullyInitializedClient(); + * } catch (err) { + * doSomethingForFailedStartup(err); + * } + * ``` + * + * It is important that you handle the rejection case; otherwise it will become an unhandled Promise + * rejection, which is a serious error on some platforms. The Promise is not created unless you + * request it, so if you never call `waitForInitialization()` then you do not have to worry about + * unhandled rejections. + * + * Note that you can also use event listeners ({@link on}) for the same purpose: the event `"initialized"` + * indicates success, and `"error"` indicates an error. + * + * @param timeout + * The amount of time, in seconds, to wait for initialization before rejecting the promise. + * Using a large timeout is not recommended. If you use a large timeout and await it, then + * any network delays will cause your application to wait a long time before + * continuing execution. + * + * @default 5 seconds + * + * @returns + * A Promise that will be resolved if the client initializes successfully, or rejected if it + * fails or the specified timeout elapses. + */ + waitForInitialization(timeout?: number): Promise; }; diff --git a/packages/sdk/browser/src/common.ts b/packages/sdk/browser/src/common.ts index 8c643b83b7..763e56a781 100644 --- a/packages/sdk/browser/src/common.ts +++ b/packages/sdk/browser/src/common.ts @@ -43,6 +43,7 @@ export type { LDIdentifyError, LDIdentifyTimeout, LDIdentifyShed, + LDDebugOverride, } from '@launchdarkly/js-client-sdk-common'; /** diff --git a/packages/shared/sdk-client/__tests__/flag-manager/FlagManager.test.ts b/packages/shared/sdk-client/__tests__/flag-manager/FlagManager.test.ts new file mode 100644 index 0000000000..76ef9495c1 --- /dev/null +++ b/packages/shared/sdk-client/__tests__/flag-manager/FlagManager.test.ts @@ -0,0 +1,231 @@ +import { Context, Crypto, Hasher, LDLogger, Platform, Storage } from '@launchdarkly/js-sdk-common'; + +import DefaultFlagManager from '../../src/flag-manager/FlagManager'; +import { FlagsChangeCallback } from '../../src/flag-manager/FlagUpdater'; +import { ItemDescriptor } from '../../src/flag-manager/ItemDescriptor'; +import { Flag } from '../../src/types'; + +const TEST_SDK_KEY = 'test-sdk-key'; +const TEST_MAX_CACHED_CONTEXTS = 5; + +function makeMockPlatform(storage: Storage, crypto: Crypto): Platform { + return { + storage, + crypto, + info: { + platformData: jest.fn(), + sdkData: jest.fn(), + }, + requests: { + fetch: jest.fn(), + createEventSource: jest.fn(), + getEventSourceCapabilities: jest.fn(), + }, + }; +} + +function makeMemoryStorage(): Storage { + const data = new Map(); + return { + get: async (key: string) => { + const value = data.get(key); + return value !== undefined ? value : null; + }, + set: async (key: string, value: string) => { + data.set(key, value); + }, + clear: async (key: string) => { + data.delete(key); + }, + }; +} + +function makeMockCrypto() { + let counter = 0; + let lastInput = ''; + const hasher: Hasher = { + update: jest.fn((input) => { + lastInput = input; + return hasher; + }), + digest: jest.fn(() => `${lastInput}Hashed`), + }; + + return { + createHash: jest.fn(() => hasher), + createHmac: jest.fn(), + randomUUID: jest.fn(() => { + counter += 1; + return `${counter}`; + }), + }; +} + +function makeMockLogger(): LDLogger { + return { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; +} + +function makeMockFlag(version: number = 1, value: any = 'test-value'): Flag { + return { + version, + flagVersion: version, + value, + variation: 0, + trackEvents: false, + }; +} + +function makeMockItemDescriptor(version: number = 1, value: any = 'test-value'): ItemDescriptor { + return { + version, + flag: makeMockFlag(version, value), + }; +} + +describe('FlagManager override tests', () => { + let flagManager: DefaultFlagManager; + let mockPlatform: Platform; + let mockLogger: LDLogger; + + beforeEach(() => { + mockLogger = makeMockLogger(); + mockPlatform = makeMockPlatform(makeMemoryStorage(), makeMockCrypto()); + flagManager = new DefaultFlagManager( + mockPlatform, + TEST_SDK_KEY, + TEST_MAX_CACHED_CONTEXTS, + mockLogger, + ); + }); + + it('setOverride takes precedence over flag store value', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'user-key' }); + const flags = { + 'test-flag': makeMockItemDescriptor(1, 'store-value'), + }; + + await flagManager.init(context, flags); + expect(flagManager.get('test-flag')?.flag.value).toBe('store-value'); + + const debugOverride = flagManager.getDebugOverride(); + debugOverride?.setOverride('test-flag', 'override-value'); + + expect(flagManager.get('test-flag')?.flag.value).toBe('override-value'); + }); + + it('setOverride triggers flag change callback', () => { + const mockCallback: FlagsChangeCallback = jest.fn(); + flagManager.on(mockCallback); + + const debugOverride = flagManager.getDebugOverride(); + debugOverride?.setOverride('test-flag', 'override-value'); + + expect(mockCallback).toHaveBeenCalledTimes(1); + expect(mockCallback).toHaveBeenCalledWith(null, ['test-flag'], 'override'); + }); + + it('removeOverride does nothing when override does not exist', () => { + const debugOverride = flagManager.getDebugOverride(); + expect(() => { + debugOverride?.removeOverride('non-existent-flag'); + }).not.toThrow(); + }); + + it('removeOverride reverts to flag store value when override is removed', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'user-key' }); + const flags = { + 'test-flag': makeMockItemDescriptor(1, 'store-value'), + }; + + await flagManager.init(context, flags); + const debugOverride = flagManager.getDebugOverride(); + debugOverride?.setOverride('test-flag', 'override-value'); + expect(flagManager.get('test-flag')?.flag.value).toBe('override-value'); + + debugOverride?.removeOverride('test-flag'); + expect(flagManager.get('test-flag')?.flag.value).toBe('store-value'); + }); + + it('removeOverride triggers flag change callback', () => { + const mockCallback: FlagsChangeCallback = jest.fn(); + flagManager.on(mockCallback); + + const debugOverride = flagManager.getDebugOverride(); + debugOverride?.setOverride('test-flag', 'override-value'); + debugOverride?.removeOverride('test-flag'); + + expect(mockCallback).toHaveBeenCalledTimes(2); + expect(mockCallback).toHaveBeenNthCalledWith(1, null, ['test-flag'], 'override'); + expect(mockCallback).toHaveBeenNthCalledWith(2, null, ['test-flag'], 'override'); + }); + + it('clearAllOverrides removes all overrides', () => { + const debugOverride = flagManager.getDebugOverride(); + debugOverride?.setOverride('flag1', 'value1'); + debugOverride?.setOverride('flag2', 'value2'); + debugOverride?.setOverride('flag3', 'value3'); + + expect(Object.keys(flagManager.getAllOverrides())).toHaveLength(3); + + debugOverride?.clearAllOverrides(); + expect(Object.keys(flagManager.getAllOverrides())).toHaveLength(0); + }); + + it('clearAllOverrides triggers flag change callback for all flags', () => { + const mockCallback: FlagsChangeCallback = jest.fn(); + flagManager.on(mockCallback); + + const debugOverride = flagManager.getDebugOverride(); + debugOverride?.setOverride('flag1', 'value1'); + debugOverride?.setOverride('flag2', 'value2'); + (mockCallback as jest.Mock).mockClear(); + + debugOverride?.clearAllOverrides(); + expect(mockCallback).toHaveBeenCalledTimes(1); + expect(mockCallback).toHaveBeenCalledWith(null, ['flag1', 'flag2'], 'override'); + }); + + it('getAllOverrides returns all overrides as ItemDescriptors', () => { + const debugOverride = flagManager.getDebugOverride(); + debugOverride?.setOverride('flag1', 'value1'); + debugOverride?.setOverride('flag2', 42); + debugOverride?.setOverride('flag3', true); + + const overrides = debugOverride?.getAllOverrides(); + expect(overrides).toHaveProperty('flag1'); + expect(overrides).toHaveProperty('flag2'); + expect(overrides).toHaveProperty('flag3'); + expect(overrides?.flag1.flag.value).toBe('value1'); + expect(overrides?.flag2.flag.value).toBe(42); + expect(overrides?.flag3.flag.value).toBe(true); + expect(overrides?.flag1.version).toBe(0); + expect(overrides?.flag2.version).toBe(0); + expect(overrides?.flag3.version).toBe(0); + }); + + it('getAll merges overrides with flag store values', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'user-key' }); + const flags = { + 'store-flag': makeMockItemDescriptor(1, 'store-value'), + 'shared-flag': makeMockItemDescriptor(1, 'store-value'), + }; + + await flagManager.init(context, flags); + const debugOverride = flagManager.getDebugOverride(); + debugOverride?.setOverride('shared-flag', 'override-value'); + debugOverride?.setOverride('override-only-flag', 'override-value'); + + const allFlags = flagManager.getAll(); + expect(allFlags).toHaveProperty('store-flag'); + expect(allFlags).toHaveProperty('shared-flag'); + expect(allFlags).toHaveProperty('override-only-flag'); + expect(allFlags['store-flag'].flag.value).toBe('store-value'); + expect(allFlags['shared-flag'].flag.value).toBe('override-value'); + expect(allFlags['override-only-flag'].flag.value).toBe('override-value'); + }); +}); diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index 5253275b2e..5868fa9746 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -41,7 +41,7 @@ import { } from './evaluation/evaluationDetail'; import createEventProcessor from './events/createEventProcessor'; import EventFactory from './events/EventFactory'; -import DefaultFlagManager, { FlagManager } from './flag-manager/FlagManager'; +import DefaultFlagManager, { FlagManager, LDDebugOverride } from './flag-manager/FlagManager'; import { FlagChangeType } from './flag-manager/FlagUpdater'; import HookRunner from './HookRunner'; import { getInspectorHook } from './inspection/getInspectorHook'; @@ -127,7 +127,7 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult { this._flagManager.on((context, flagKeys, type) => { this._handleInspectionChanged(flagKeys, type); - const ldContext = Context.toLDContext(context); + const ldContext = context ? Context.toLDContext(context) : null; this.emitter.emit('change', ldContext, flagKeys); flagKeys.forEach((it) => { this.emitter.emit(`change:${it}`, ldContext); @@ -345,6 +345,7 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult { if (res.status === 'shed') { return { status: 'shed' } as LDIdentifyShed; } + this.emitter.emit('initialized'); return { status: 'completed' } as LDIdentifySuccess; }); @@ -581,6 +582,14 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult { this._eventProcessor?.sendEvent(event); } + protected getDebugOverrides(): LDDebugOverride | null { + if (this._flagManager.getDebugOverride) { + return this._flagManager.getDebugOverride(); + } + + return null; + } + private _handleInspectionChanged(flagKeys: Array, type: FlagChangeType) { if (!this._inspectorManager.hasInspectors()) { return; diff --git a/packages/shared/sdk-client/src/LDEmitter.ts b/packages/shared/sdk-client/src/LDEmitter.ts index 76705f90c3..cbd4b4a4d7 100644 --- a/packages/shared/sdk-client/src/LDEmitter.ts +++ b/packages/shared/sdk-client/src/LDEmitter.ts @@ -6,7 +6,7 @@ type FlagChangeKey = `change:${string}`; * Type for name of emitted events. 'change' is used for all flag changes. 'change:flag-name-here' is used * for specific flag changes. */ -export type EventName = 'change' | FlagChangeKey | 'dataSourceStatus' | 'error'; +export type EventName = 'change' | FlagChangeKey | 'dataSourceStatus' | 'error' | 'initialized'; /** * Implementation Note: There should not be any default listeners for change events in a client diff --git a/packages/shared/sdk-client/src/api/LDPlugin.ts b/packages/shared/sdk-client/src/api/LDPlugin.ts new file mode 100644 index 0000000000..d441384a01 --- /dev/null +++ b/packages/shared/sdk-client/src/api/LDPlugin.ts @@ -0,0 +1,17 @@ +import { LDPluginBase as LDPluginBaseCommon } from '@launchdarkly/js-sdk-common'; + +import { LDDebugOverride } from '../flag-manager/FlagManager'; + +export interface LDPluginBase extends LDPluginBaseCommon { + /** + * An optional function called if the plugin wants to register debug capabilities. + * This method allows plugins to receive a debug override interface for + * temporarily overriding flag values during development and testing. + * + * @experimental This interface is experimental and intended for use by LaunchDarkly tools at this time. + * The API may change in future versions. + * + * @param debugOverride The debug override interface instance + */ + registerDebug?(debugOverride: LDDebugOverride): void; +} diff --git a/packages/shared/sdk-client/src/api/index.ts b/packages/shared/sdk-client/src/api/index.ts index 1695ac67ae..d2203c2323 100644 --- a/packages/shared/sdk-client/src/api/index.ts +++ b/packages/shared/sdk-client/src/api/index.ts @@ -9,3 +9,4 @@ export { ConnectionMode }; export * from './LDIdentifyOptions'; export * from './LDInspection'; export * from './LDIdentifyResult'; +export * from './LDPlugin'; diff --git a/packages/shared/sdk-client/src/flag-manager/FlagManager.ts b/packages/shared/sdk-client/src/flag-manager/FlagManager.ts index 61338c4f74..d1856b6eff 100644 --- a/packages/shared/sdk-client/src/flag-manager/FlagManager.ts +++ b/packages/shared/sdk-client/src/flag-manager/FlagManager.ts @@ -1,4 +1,4 @@ -import { Context, LDLogger, Platform } from '@launchdarkly/js-sdk-common'; +import { Context, LDFlagValue, LDLogger, Platform } from '@launchdarkly/js-sdk-common'; import { namespaceForEnvironment } from '../storage/namespaceUtils'; import FlagPersistence from './FlagPersistence'; @@ -55,12 +55,67 @@ export interface FlagManager { * Unregister a flag change callback. */ off(callback: FlagsChangeCallback): void; + + // REVIEWER: My reasoning here is to have the flagmanager implementation determine + // whether or not we can support debug plugins so I put the override methods here. + // Would like some thoughts on this as it is a deviation from previous implementation. + + /** + * Obtain debug override functions that allows plugins + * to manipulate the outcome of the flags managed by + * this manager + * + * @experimental This function is experimental and intended for use by LaunchDarkly tools at this time. + */ + getDebugOverride?(): LDDebugOverride; +} + +/** + * Debug interface for plugins that need to override flag values during development. + * This interface provides methods to temporarily override flag values that take + * precedence over the actual flag values from LaunchDarkly. These overrides are + * useful for testing, development, and debugging scenarios. + * + * @experimental This interface is experimental and intended for use by LaunchDarkly tools at this time. + * The API may change in future versions. + */ +export interface LDDebugOverride { + /** + * Set an override value for a flag that takes precedence over the real flag value. + * + * @param flagKey The flag key. + * @param value The override value. + */ + setOverride(flagKey: string, value: LDFlagValue): void; + + /** + * Remove an override value for a flag, reverting to the real flag value. + * + * @param flagKey The flag key. + */ + removeOverride(flagKey: string): void; + + /** + * Clear all override values, reverting all flags to their real values. + */ + clearAllOverrides(): void; + + /** + * Get all currently active flag overrides. + * + * @returns + * An object containing all active overrides as key-value pairs, + * where keys are flag keys and values are the overridden flag values. + * Returns an empty object if no overrides are active. + */ + getAllOverrides(): { [key: string]: ItemDescriptor }; } export default class DefaultFlagManager implements FlagManager { private _flagStore = new DefaultFlagStore(); private _flagUpdater: FlagUpdater; private _flagPersistencePromise: Promise; + private _overrides?: { [key: string]: LDFlagValue }; /** * @param platform implementation of various platform provided functionality @@ -107,10 +162,26 @@ export default class DefaultFlagManager implements FlagManager { } get(key: string): ItemDescriptor | undefined { + if (this._overrides && Object.prototype.hasOwnProperty.call(this._overrides, key)) { + return this._convertValueToOverrideDescripter(this._overrides[key]); + } + return this._flagStore.get(key); } getAll(): { [key: string]: ItemDescriptor } { + if (this._overrides) { + return { + ...this._flagStore.getAll(), + ...Object.entries(this._overrides).reduce( + (acc: { [key: string]: ItemDescriptor }, [key, value]) => { + acc[key] = this._convertValueToOverrideDescripter(value); + return acc; + }, + {}, + ), + }; + } return this._flagStore.getAll(); } @@ -139,4 +210,60 @@ export default class DefaultFlagManager implements FlagManager { off(callback: FlagsChangeCallback): void { this._flagUpdater.off(callback); } + + private _convertValueToOverrideDescripter(value: LDFlagValue): ItemDescriptor { + return { + flag: { + value, + version: 0, + }, + version: 0, + }; + } + + setOverride(key: string, value: LDFlagValue) { + if (!this._overrides) { + this._overrides = {}; + } + this._overrides[key] = value; + this._flagUpdater.handleFlagChanges(null, [key], 'override'); + } + + removeOverride(flagKey: string) { + if (!this._overrides || !Object.prototype.hasOwnProperty.call(this._overrides, flagKey)) { + return; // No override to remove + } + + delete this._overrides[flagKey]; + + // If no more overrides, reset to undefined for performance + if (Object.keys(this._overrides).length === 0) { + this._overrides = undefined; + } + + this._flagUpdater.handleFlagChanges(null, [flagKey], 'override'); + } + + clearAllOverrides() { + if (this._overrides) { + const clearedOverrides = { ...this._overrides }; + this._overrides = undefined; // Reset to undefined + this._flagUpdater.handleFlagChanges(null, Object.keys(clearedOverrides), 'override'); + } + } + + getAllOverrides() { + if (!this._overrides) { + return {}; + } + const result = {} as { [key: string]: ItemDescriptor }; + Object.entries(this._overrides).forEach(([key, value]) => { + result[key] = this._convertValueToOverrideDescripter(value); + }); + return result; + } + + getDebugOverride(): LDDebugOverride { + return this as LDDebugOverride; + } } diff --git a/packages/shared/sdk-client/src/flag-manager/FlagUpdater.ts b/packages/shared/sdk-client/src/flag-manager/FlagUpdater.ts index c7b1a130f0..2cc9ed40ed 100644 --- a/packages/shared/sdk-client/src/flag-manager/FlagUpdater.ts +++ b/packages/shared/sdk-client/src/flag-manager/FlagUpdater.ts @@ -4,7 +4,7 @@ import calculateChangedKeys from './calculateChangedKeys'; import FlagStore from './FlagStore'; import { ItemDescriptor } from './ItemDescriptor'; -export type FlagChangeType = 'init' | 'patch'; +export type FlagChangeType = 'init' | 'patch' | 'override'; /** * This callback indicates that the details associated with one or more flags @@ -20,7 +20,12 @@ export type FlagChangeType = 'init' | 'patch'; * will call a variation method for flag values which you require. */ export type FlagsChangeCallback = ( - context: Context, + // REVIEWER: This is probably not desired, but I think there are some updates + // such as overrides that do not really have a context? Unless I am misunderstanding + // what context is exactly. Being able to support a null context may also help + // with distinguishing between being in the emphemeral state between the start of + // initialization and the end of identification and having an invalid context? + context: Context | null, flagKeys: Array, type: FlagChangeType, ) => void; @@ -41,19 +46,23 @@ export default class FlagUpdater { this._logger = logger; } + handleFlagChanges(context: Context | null, keys: string[], type: FlagChangeType): void { + this._changeCallbacks.forEach((callback) => { + try { + callback(context, keys, type); + } catch (err) { + /* intentionally empty */ + } + }); + } + init(context: Context, newFlags: { [key: string]: ItemDescriptor }) { this._activeContextKey = context.canonicalKey; const oldFlags = this._flagStore.getAll(); this._flagStore.init(newFlags); const changed = calculateChangedKeys(oldFlags, newFlags); if (changed.length > 0) { - this._changeCallbacks.forEach((callback) => { - try { - callback(context, changed, 'init'); - } catch (err) { - /* intentionally empty */ - } - }); + this.handleFlagChanges(context, changed, 'init'); } } @@ -78,13 +87,7 @@ export default class FlagUpdater { } this._flagStore.insertOrUpdate(key, item); - this._changeCallbacks.forEach((callback) => { - try { - callback(context, [key], 'patch'); - } catch (err) { - /* intentionally empty */ - } - }); + this.handleFlagChanges(context, [key], 'patch'); return true; } diff --git a/packages/shared/sdk-client/src/index.ts b/packages/shared/sdk-client/src/index.ts index 01f5f672da..5c13e7a860 100644 --- a/packages/shared/sdk-client/src/index.ts +++ b/packages/shared/sdk-client/src/index.ts @@ -36,10 +36,12 @@ export type { LDIdentifyTimeout, LDIdentifyShed, LDClientIdentifyResult, + LDPluginBase, } from './api'; export type { DataManager, DataManagerFactory, ConnectionParams } from './DataManager'; -export type { FlagManager } from './flag-manager/FlagManager'; +export type { FlagManager, LDDebugOverride } from './flag-manager/FlagManager'; +export { safeRegisterDebugOverridePlugins } from './plugins/safeRegisterDebugOverridePlugins'; export type { Configuration } from './configuration/Configuration'; export type { LDEmitter }; diff --git a/packages/shared/sdk-client/src/plugins/safeRegisterDebugOverridePlugins.ts b/packages/shared/sdk-client/src/plugins/safeRegisterDebugOverridePlugins.ts new file mode 100644 index 0000000000..ca127908d2 --- /dev/null +++ b/packages/shared/sdk-client/src/plugins/safeRegisterDebugOverridePlugins.ts @@ -0,0 +1,25 @@ +import { internal, LDLogger } from '@launchdarkly/js-sdk-common'; + +import { LDPluginBase } from '../api'; +import { LDDebugOverride } from '../flag-manager/FlagManager'; + +/** + * Safe register debug override plugins. + * + * @param logger - The logger to use for logging errors. + * @param debugOverride - The debug override to register. + * @param plugins - The plugins to register. + */ +export function safeRegisterDebugOverridePlugins( + logger: LDLogger, + debugOverride: LDDebugOverride, + plugins: LDPluginBase[], +): void { + plugins.forEach((plugin) => { + try { + plugin.registerDebug?.(debugOverride); + } catch (error) { + logger.error(`Exception thrown registering plugin ${internal.safeGetName(logger, plugin)}.`); + } + }); +}