diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index 90bcc337cb..e765a71fa3 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -17,6 +17,7 @@ import { LDIdentifyResult, LDPluginEnvironmentMetadata, Platform, + safeRegisterDebugOverridePlugins, } from '@launchdarkly/js-client-sdk-common'; import { readFlagsFromBootstrap } from './bootstrap'; @@ -211,6 +212,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 { 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..d8fe440004 --- /dev/null +++ b/packages/shared/sdk-client/__tests__/flag-manager/FlagManager.test.ts @@ -0,0 +1,252 @@ +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', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'user-key' }); + const flags = { + 'test-flag': makeMockItemDescriptor(1, 'store-value'), + }; + + await flagManager.init(context, flags); + + 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(context, ['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', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'user-key' }); + const flags = { + 'test-flag': makeMockItemDescriptor(1, 'store-value'), + }; + + await flagManager.init(context, flags); + + 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, context, ['test-flag'], 'override'); + expect(mockCallback).toHaveBeenNthCalledWith(2, context, ['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', async () => { + const mockCallback: FlagsChangeCallback = jest.fn(); + flagManager.on(mockCallback); + + const debugOverride = flagManager.getDebugOverride(); + const context = Context.fromLDContext({ kind: 'user', key: 'user-key' }); + const flags = { + 'test-flag': makeMockItemDescriptor(1, 'store-value'), + }; + + await flagManager.init(context, flags); + + debugOverride?.setOverride('flag1', 'value1'); + debugOverride?.setOverride('flag2', 'value2'); + (mockCallback as jest.Mock).mockClear(); + + debugOverride?.clearAllOverrides(); + expect(mockCallback).toHaveBeenCalledTimes(1); + expect(mockCallback).toHaveBeenCalledWith(context, ['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 2c229ab0fb..1dc13c0ecf 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -45,7 +45,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 { ItemDescriptor } from './flag-manager/ItemDescriptor'; import HookRunner from './HookRunner'; @@ -607,6 +607,10 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult { this._eventProcessor?.sendEvent(event); } + protected getDebugOverrides(): LDDebugOverride | undefined { + return this._flagManager.getDebugOverride?.(); + } + private _handleInspectionChanged(flagKeys: Array, type: FlagChangeType) { if (!this._inspectorManager.hasInspectors()) { return; 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 abf6621315..7ad670a9ed 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'; @@ -64,12 +64,63 @@ export interface FlagManager { * Unregister a flag change callback. */ off(callback: FlagsChangeCallback): void; + + /** + * 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 @@ -116,10 +167,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(); } @@ -152,4 +219,65 @@ 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([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([flagKey], 'override'); + } + + clearAllOverrides() { + if (this._overrides) { + const clearedOverrides = { ...this._overrides }; + this._overrides = undefined; // Reset to undefined + this._flagUpdater.handleFlagChanges(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 { + setOverride: this.setOverride.bind(this), + removeOverride: this.removeOverride.bind(this), + clearAllOverrides: this.clearAllOverrides.bind(this), + getAllOverrides: this.getAllOverrides.bind(this), + }; + } } diff --git a/packages/shared/sdk-client/src/flag-manager/FlagUpdater.ts b/packages/shared/sdk-client/src/flag-manager/FlagUpdater.ts index 03366dacf8..57948e21d1 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 @@ -41,19 +41,29 @@ export default class FlagUpdater { this._logger = logger; } - init(context: Context, newFlags: { [key: string]: ItemDescriptor }) { - this._activeContext = context; - const oldFlags = this._flagStore.getAll(); - this._flagStore.init(newFlags); - const changed = calculateChangedKeys(oldFlags, newFlags); - if (changed.length > 0) { + handleFlagChanges(keys: string[], type: FlagChangeType): void { + if (this._activeContext) { this._changeCallbacks.forEach((callback) => { try { - callback(context, changed, 'init'); + callback(this._activeContext!, keys, type); } catch (err) { /* intentionally empty */ } }); + } else { + this._logger.warn( + 'Received a change event wihtout an active context. Changes will not be propagated.', + ); + } + } + + init(context: Context, newFlags: { [key: string]: ItemDescriptor }) { + this._activeContext = context; + const oldFlags = this._flagStore.getAll(); + this._flagStore.init(newFlags); + const changed = calculateChangedKeys(oldFlags, newFlags); + if (changed.length > 0) { + this.handleFlagChanges(changed, 'init'); } } @@ -78,13 +88,7 @@ export default class FlagUpdater { } this._flagStore.insertOrUpdate(key, item); - this._changeCallbacks.forEach((callback) => { - try { - callback(this._activeContext!, [key], 'patch'); - } catch (err) { - /* intentionally empty */ - } - }); + this.handleFlagChanges([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)}.`); + } + }); +}