From cb149600f5cfddaa6cf530ab4da95fec38e7569c Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:13:54 -0700 Subject: [PATCH 01/28] feat: Add experimental FDv2 configuration (unused) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `dataSystem` option to LDOptions for FDv2 data system configuration. The field is marked @internal and stripped from public type declarations via stripInternal. When present, ConfigurationImpl deep-validates it using dataSystemValidators with platform-specific defaults passed via LDClientInternalOptions.dataSystemDefaults. - Browser SDK passes BROWSER_DATA_SYSTEM_DEFAULTS - React Native SDK passes MOBILE_DATA_SYSTEM_DEFAULTS - No behavioral changes — the configuration is accepted and validated but not yet wired to any data manager selection logic SDK-1935 --- .../sdk/browser/__tests__/options.test.ts | 16 ++++ packages/sdk/browser/src/BrowserClient.ts | 2 + .../react-native/src/ReactNativeLDClient.ts | 2 + .../configuration/Configuration.test.ts | 89 +++++++++++++++++++ .../shared/sdk-client/src/api/LDOptions.ts | 16 ++++ .../src/configuration/Configuration.ts | 19 ++++ .../src/configuration/validators.ts | 1 + packages/shared/sdk-client/src/index.ts | 8 ++ 8 files changed, 153 insertions(+) diff --git a/packages/sdk/browser/__tests__/options.test.ts b/packages/sdk/browser/__tests__/options.test.ts index d8aefc63b1..79ae487401 100644 --- a/packages/sdk/browser/__tests__/options.test.ts +++ b/packages/sdk/browser/__tests__/options.test.ts @@ -90,3 +90,19 @@ it('does not override common config flushInterval if it is set', () => { const result = filterToBaseOptionsWithDefaults(opts); expect(result.flushInterval).toEqual(15); }); + +it('passes dataSystem through to base options without stripping', () => { + const opts = { + dataSystem: { initialConnectionMode: 'polling' }, + } as any; + const result = filterToBaseOptionsWithDefaults(opts); + expect((result as any).dataSystem).toEqual({ initialConnectionMode: 'polling' }); +}); + +it('passes an empty dataSystem through to base options', () => { + const opts = { + dataSystem: {}, + } as any; + const result = filterToBaseOptionsWithDefaults(opts); + expect((result as any).dataSystem).toEqual({}); +}); diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index 72b9757264..a9de2937c1 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -1,6 +1,7 @@ import { AutoEnvAttributes, BasicLogger, + BROWSER_DATA_SYSTEM_DEFAULTS, browserFdv1Endpoints, Configuration, FlagManager, @@ -110,6 +111,7 @@ class BrowserClientImpl extends LDClientImpl { includeAuthorizationHeader: false, highTimeoutThreshold: 5, userAgentHeaderName: 'x-launchdarkly-user-agent', + dataSystemDefaults: BROWSER_DATA_SYSTEM_DEFAULTS, trackEventModifier: (event: internal.InputCustomEvent) => new internal.InputCustomEvent( event.context, diff --git a/packages/sdk/react-native/src/ReactNativeLDClient.ts b/packages/sdk/react-native/src/ReactNativeLDClient.ts index 7ece5a0ac4..48cd045cd8 100644 --- a/packages/sdk/react-native/src/ReactNativeLDClient.ts +++ b/packages/sdk/react-native/src/ReactNativeLDClient.ts @@ -11,6 +11,7 @@ import { LDEmitter, LDHeaders, LDPluginEnvironmentMetadata, + MOBILE_DATA_SYSTEM_DEFAULTS, mobileFdv1Endpoints, } from '@launchdarkly/js-client-sdk-common'; @@ -65,6 +66,7 @@ export default class ReactNativeLDClient extends LDClientImpl { getImplementationHooks: (_environmentMetadata: LDPluginEnvironmentMetadata) => internal.safeGetHooks(logger, _environmentMetadata, validatedRnOptions.plugins), credentialType: 'mobileKey', + dataSystemDefaults: MOBILE_DATA_SYSTEM_DEFAULTS, }; const platform = createPlatform(logger, options, validatedRnOptions.storage); diff --git a/packages/shared/sdk-client/__tests__/configuration/Configuration.test.ts b/packages/shared/sdk-client/__tests__/configuration/Configuration.test.ts index de879f008d..b0511922f1 100644 --- a/packages/shared/sdk-client/__tests__/configuration/Configuration.test.ts +++ b/packages/shared/sdk-client/__tests__/configuration/Configuration.test.ts @@ -178,3 +178,92 @@ it('does not wrap already safe loggers', () => { const config = new ConfigurationImpl({ logger }); expect(config.logger).toBe(logger); }); + +describe('dataSystem validation', () => { + it('does not set dataSystem when not provided', () => { + const config = new ConfigurationImpl( + {}, + { + getImplementationHooks: () => [], + credentialType: 'clientSideId', + dataSystemDefaults: { + initialConnectionMode: 'one-shot', + automaticModeSwitching: false, + }, + }, + ); + expect(config.dataSystem).toBeUndefined(); + }); + + it('validates dataSystem with platform defaults when provided as empty object', () => { + const config = new ConfigurationImpl( + // @ts-ignore dataSystem is @internal + { dataSystem: {} }, + { + getImplementationHooks: () => [], + credentialType: 'clientSideId', + dataSystemDefaults: { + initialConnectionMode: 'one-shot', + automaticModeSwitching: false, + }, + }, + ); + expect(config.dataSystem).toBeDefined(); + expect(config.dataSystem!.initialConnectionMode).toBe('one-shot'); + expect(config.dataSystem!.automaticModeSwitching).toBe(false); + }); + + it('validates dataSystem with user overrides applied over platform defaults', () => { + const config = new ConfigurationImpl( + // @ts-ignore dataSystem is @internal + { dataSystem: { initialConnectionMode: 'polling' } }, + { + getImplementationHooks: () => [], + credentialType: 'mobileKey', + dataSystemDefaults: { + initialConnectionMode: 'streaming', + backgroundConnectionMode: 'background', + automaticModeSwitching: true, + }, + }, + ); + expect(config.dataSystem).toBeDefined(); + expect(config.dataSystem!.initialConnectionMode).toBe('polling'); + expect(config.dataSystem!.backgroundConnectionMode).toBe('background'); + expect(config.dataSystem!.automaticModeSwitching).toBe(true); + }); + + it('warns and falls back to default for invalid dataSystem sub-fields', () => { + console.error = jest.fn(); + const config = new ConfigurationImpl( + // @ts-ignore dataSystem is @internal + { dataSystem: { initialConnectionMode: 'turbo' } }, + { + getImplementationHooks: () => [], + credentialType: 'clientSideId', + dataSystemDefaults: { + initialConnectionMode: 'one-shot', + automaticModeSwitching: false, + }, + }, + ); + expect(config.dataSystem).toBeDefined(); + expect(config.dataSystem!.initialConnectionMode).toBe('one-shot'); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('dataSystem.initialConnectionMode'), + ); + }); + + it('does not deep-validate dataSystem when dataSystemDefaults is not provided', () => { + const config = new ConfigurationImpl( + // @ts-ignore dataSystem is @internal + { dataSystem: { initialConnectionMode: 'polling' } }, + { + getImplementationHooks: () => [], + credentialType: 'clientSideId', + }, + ); + // Without defaults, deep validation is skipped — raw object from basic validator + expect(config.dataSystem).toBeDefined(); + }); +}); diff --git a/packages/shared/sdk-client/src/api/LDOptions.ts b/packages/shared/sdk-client/src/api/LDOptions.ts index d5f1e107ad..b06f1959cd 100644 --- a/packages/shared/sdk-client/src/api/LDOptions.ts +++ b/packages/shared/sdk-client/src/api/LDOptions.ts @@ -1,5 +1,6 @@ import type { LDLogger } from '@launchdarkly/js-sdk-common'; +import { LDClientDataSystemOptions } from './datasource/LDClientDataSystemOptions'; import { Hook } from './integrations/Hooks'; import { LDInspection } from './LDInspection'; @@ -279,4 +280,19 @@ export interface LDOptions { * @defaultValue false. */ cleanOldPersistentData?: boolean; + + /** + * @internal + * + * WARNING: This option is EXPERIMENTAL and UNSUPPORTED. It is subject to + * change or removal without notice. Do not use in production applications. + * Using this option may result in unexpected behavior, data loss, or + * breaking changes in future SDK versions. LaunchDarkly does not provide + * support for configurations using this option. + * + * Configuration for the FDv2 data system. When present, the SDK uses + * the FDv2 protocol for flag delivery instead of the default FDv1 + * protocol. + */ + dataSystem?: LDClientDataSystemOptions; } diff --git a/packages/shared/sdk-client/src/configuration/Configuration.ts b/packages/shared/sdk-client/src/configuration/Configuration.ts index 2ea8301ec4..ab510fe2ee 100644 --- a/packages/shared/sdk-client/src/configuration/Configuration.ts +++ b/packages/shared/sdk-client/src/configuration/Configuration.ts @@ -10,7 +10,12 @@ import { } from '@launchdarkly/js-sdk-common'; import { Hook, type LDOptions } from '../api'; +import type { + LDClientDataSystemOptions, + PlatformDataSystemDefaults, +} from '../api/datasource/LDClientDataSystemOptions'; import { LDInspection } from '../api/LDInspection'; +import { dataSystemValidators } from '../datasource/LDClientDataSystemOptions'; import validateOptions from './validateOptions'; import validators from './validators'; @@ -21,6 +26,7 @@ export interface LDClientInternalOptions extends internal.LDInternalOptions { getImplementationHooks: (environmentMetadata: LDPluginEnvironmentMetadata) => Hook[]; credentialType: 'clientSideId' | 'mobileKey'; getLegacyStorageKeys?: () => string[]; + dataSystemDefaults?: PlatformDataSystemDefaults; } export interface Configuration { @@ -59,6 +65,7 @@ export interface Configuration { readonly inspectors: LDInspection[]; readonly credentialType: 'clientSideId' | 'mobileKey'; readonly getImplementationHooks: (environmentMetadata: LDPluginEnvironmentMetadata) => Hook[]; + readonly dataSystem?: LDClientDataSystemOptions; } const DEFAULT_POLLING: string = 'https://clientsdk.launchdarkly.com'; @@ -138,6 +145,7 @@ export default class ConfigurationImpl implements Configuration { public readonly getImplementationHooks: ( environmentMetadata: LDPluginEnvironmentMetadata, ) => Hook[]; + public readonly dataSystem?: LDClientDataSystemOptions; // Allow indexing Configuration by a string [index: string]: any; @@ -179,5 +187,16 @@ export default class ConfigurationImpl implements Configuration { this.credentialType = internalOptions.credentialType; this.getImplementationHooks = internalOptions.getImplementationHooks; + + // Deep-validate dataSystem if present, using platform-specific defaults. + if (pristineOptions.dataSystem !== undefined && internalOptions.dataSystemDefaults) { + this.dataSystem = validateOptions( + pristineOptions.dataSystem, + dataSystemValidators, + internalOptions.dataSystemDefaults as unknown as Record, + this.logger, + 'dataSystem', + ) as unknown as LDClientDataSystemOptions; + } } } diff --git a/packages/shared/sdk-client/src/configuration/validators.ts b/packages/shared/sdk-client/src/configuration/validators.ts index b8a89de6c3..f923f5fd77 100644 --- a/packages/shared/sdk-client/src/configuration/validators.ts +++ b/packages/shared/sdk-client/src/configuration/validators.ts @@ -35,6 +35,7 @@ const validators: Record = { hooks: TypeValidators.createTypeArray('Hook[]', {}), inspectors: TypeValidators.createTypeArray('LDInspection', {}), cleanOldPersistentData: TypeValidators.Boolean, + dataSystem: TypeValidators.Object, }; export default validators; diff --git a/packages/shared/sdk-client/src/index.ts b/packages/shared/sdk-client/src/index.ts index 8fbdfdbf89..192f812dba 100644 --- a/packages/shared/sdk-client/src/index.ts +++ b/packages/shared/sdk-client/src/index.ts @@ -94,6 +94,14 @@ export type { ModeResolutionTable, } from './api/datasource'; +// FDv2 data system validators and platform defaults. +export { + dataSystemValidators, + BROWSER_DATA_SYSTEM_DEFAULTS, + MOBILE_DATA_SYSTEM_DEFAULTS, + DESKTOP_DATA_SYSTEM_DEFAULTS, +} from './datasource/LDClientDataSystemOptions'; + // FDv2 connection mode type system — internal implementation. export type { ModeTable } from './datasource/ConnectionModeConfig'; export { From 9f8837639dbc3af0f056f5bd5369400a609156fd Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:28:41 -0700 Subject: [PATCH 02/28] refactor: Use compound validator for dataSystem with built-in defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert validators from a static constant to a createValidators() factory function that accepts platform-specific dataSystemDefaults. When defaults are provided, dataSystem uses validatorOf() with built-in defaults for deep nested validation in a single pass. Add builtInDefaults parameter to validatorOf() so the compound validator carries its own defaults without relying on parent-level defaults (which would leak into the result for absent fields). Remove the separate deep-validation block from ConfigurationImpl — the generic validateOptions call now handles everything. Add tests for non-object dataSystem, granular automaticModeSwitching, and no-leakage of dataSystem defaults to other config fields. --- .../configuration/Configuration.test.ts | 59 ++++++++++++ .../src/configuration/Configuration.ts | 23 ++--- .../src/configuration/validateOptions.ts | 10 ++- .../src/configuration/validators.ts | 89 +++++++++++-------- 4 files changed, 122 insertions(+), 59 deletions(-) diff --git a/packages/shared/sdk-client/__tests__/configuration/Configuration.test.ts b/packages/shared/sdk-client/__tests__/configuration/Configuration.test.ts index b0511922f1..7994f7e8f3 100644 --- a/packages/shared/sdk-client/__tests__/configuration/Configuration.test.ts +++ b/packages/shared/sdk-client/__tests__/configuration/Configuration.test.ts @@ -266,4 +266,63 @@ describe('dataSystem validation', () => { // Without defaults, deep validation is skipped — raw object from basic validator expect(config.dataSystem).toBeDefined(); }); + + it('warns and ignores dataSystem when set to a non-object value', () => { + console.error = jest.fn(); + const config = new ConfigurationImpl( + // @ts-ignore dataSystem is @internal + { dataSystem: 'streaming' }, + { + getImplementationHooks: () => [], + credentialType: 'clientSideId', + dataSystemDefaults: { + initialConnectionMode: 'one-shot', + automaticModeSwitching: false, + }, + }, + ); + expect(config.dataSystem).toBeUndefined(); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('dataSystem'), + ); + }); + + it('validates automaticModeSwitching as a granular config object', () => { + const config = new ConfigurationImpl( + // @ts-ignore dataSystem is @internal + { dataSystem: { automaticModeSwitching: { lifecycle: true, network: false } } }, + { + getImplementationHooks: () => [], + credentialType: 'mobileKey', + dataSystemDefaults: { + initialConnectionMode: 'streaming', + automaticModeSwitching: true, + }, + }, + ); + expect(config.dataSystem).toBeDefined(); + expect(config.dataSystem!.automaticModeSwitching).toEqual({ + lifecycle: true, + network: false, + }); + }); + + it('does not set other config fields when dataSystem defaults are provided', () => { + const config = new ConfigurationImpl( + {}, + { + getImplementationHooks: () => [], + credentialType: 'clientSideId', + dataSystemDefaults: { + initialConnectionMode: 'one-shot', + automaticModeSwitching: false, + }, + }, + ); + // dataSystem defaults should not leak into the config when dataSystem is not provided + expect(config.dataSystem).toBeUndefined(); + // Other fields should retain their normal defaults + expect(config.sendEvents).toBe(true); + expect(config.capacity).toBe(100); + }); }); diff --git a/packages/shared/sdk-client/src/configuration/Configuration.ts b/packages/shared/sdk-client/src/configuration/Configuration.ts index ab510fe2ee..c20ce283ed 100644 --- a/packages/shared/sdk-client/src/configuration/Configuration.ts +++ b/packages/shared/sdk-client/src/configuration/Configuration.ts @@ -10,14 +10,11 @@ import { } from '@launchdarkly/js-sdk-common'; import { Hook, type LDOptions } from '../api'; -import type { - LDClientDataSystemOptions, - PlatformDataSystemDefaults, -} from '../api/datasource/LDClientDataSystemOptions'; +import type { LDClientDataSystemOptions } from '../api/datasource/LDClientDataSystemOptions'; +import type { PlatformDataSystemDefaults } from '../api/datasource/LDClientDataSystemOptions'; import { LDInspection } from '../api/LDInspection'; -import { dataSystemValidators } from '../datasource/LDClientDataSystemOptions'; import validateOptions from './validateOptions'; -import validators from './validators'; +import createValidators from './validators'; const DEFAULT_POLLING_INTERVAL: number = 60 * 5; @@ -158,6 +155,9 @@ export default class ConfigurationImpl implements Configuration { }, ) { this.logger = ensureSafeLogger(pristineOptions.logger); + const validators = createValidators({ + dataSystemDefaults: internalOptions.dataSystemDefaults, + }); const validated = validateOptions( pristineOptions as Record, validators, @@ -187,16 +187,5 @@ export default class ConfigurationImpl implements Configuration { this.credentialType = internalOptions.credentialType; this.getImplementationHooks = internalOptions.getImplementationHooks; - - // Deep-validate dataSystem if present, using platform-specific defaults. - if (pristineOptions.dataSystem !== undefined && internalOptions.dataSystemDefaults) { - this.dataSystem = validateOptions( - pristineOptions.dataSystem, - dataSystemValidators, - internalOptions.dataSystemDefaults as unknown as Record, - this.logger, - 'dataSystem', - ) as unknown as LDClientDataSystemOptions; - } } } diff --git a/packages/shared/sdk-client/src/configuration/validateOptions.ts b/packages/shared/sdk-client/src/configuration/validateOptions.ts index 79a95dcb14..191da0e7e7 100644 --- a/packages/shared/sdk-client/src/configuration/validateOptions.ts +++ b/packages/shared/sdk-client/src/configuration/validateOptions.ts @@ -111,7 +111,10 @@ export default function validateOptions( * `validateOptions` will recursively validate the nested object's properties. * Defaults for nested fields are passed through from the parent. */ -export function validatorOf(validators: Record): CompoundValidator { +export function validatorOf( + validators: Record, + builtInDefaults?: Record, +): CompoundValidator { return { is: (u: unknown) => TypeValidators.Object.is(u), getType: () => 'object', @@ -120,9 +123,8 @@ export function validatorOf(validators: Record): Compound logger?.warn(OptionMessages.wrongOptionType(name, 'object', typeof value)); return undefined; } - const nestedDefaults = TypeValidators.Object.is(defaults) - ? (defaults as Record) - : {}; + const nestedDefaults = builtInDefaults + ?? (TypeValidators.Object.is(defaults) ? (defaults as Record) : {}); const nested = validateOptions( value as Record, validators, diff --git a/packages/shared/sdk-client/src/configuration/validators.ts b/packages/shared/sdk-client/src/configuration/validators.ts index f923f5fd77..5e6254cd31 100644 --- a/packages/shared/sdk-client/src/configuration/validators.ts +++ b/packages/shared/sdk-client/src/configuration/validators.ts @@ -1,41 +1,54 @@ -// eslint-disable-next-line max-classes-per-file import { TypeValidator, TypeValidators } from '@launchdarkly/js-sdk-common'; import { type LDOptions } from '../api'; - -const validators: Record = { - logger: TypeValidators.Object, - maxCachedContexts: TypeValidators.numberWithMin(0), - - baseUri: TypeValidators.String, - streamUri: TypeValidators.String, - eventsUri: TypeValidators.String, - - capacity: TypeValidators.numberWithMin(1), - diagnosticRecordingInterval: TypeValidators.numberWithMin(2), - flushInterval: TypeValidators.numberWithMin(2), - streamInitialReconnectDelay: TypeValidators.numberWithMin(0), - - allAttributesPrivate: TypeValidators.Boolean, - debug: TypeValidators.Boolean, - diagnosticOptOut: TypeValidators.Boolean, - withReasons: TypeValidators.Boolean, - sendEvents: TypeValidators.Boolean, - - pollInterval: TypeValidators.numberWithMin(30), - - useReport: TypeValidators.Boolean, - - privateAttributes: TypeValidators.StringArray, - - applicationInfo: TypeValidators.Object, - wrapperName: TypeValidators.String, - wrapperVersion: TypeValidators.String, - payloadFilterKey: TypeValidators.stringMatchingRegex(/^[a-zA-Z0-9](\w|\.|-)*$/), - hooks: TypeValidators.createTypeArray('Hook[]', {}), - inspectors: TypeValidators.createTypeArray('LDInspection', {}), - cleanOldPersistentData: TypeValidators.Boolean, - dataSystem: TypeValidators.Object, -}; - -export default validators; +import type { PlatformDataSystemDefaults } from '../api/datasource/LDClientDataSystemOptions'; +import { dataSystemValidators } from '../datasource/LDClientDataSystemOptions'; +import { validatorOf } from './validateOptions'; + +export interface ValidatorOptions { + dataSystemDefaults?: PlatformDataSystemDefaults; +} + +export default function createValidators( + options?: ValidatorOptions, +): Record { + return { + logger: TypeValidators.Object, + maxCachedContexts: TypeValidators.numberWithMin(0), + + baseUri: TypeValidators.String, + streamUri: TypeValidators.String, + eventsUri: TypeValidators.String, + + capacity: TypeValidators.numberWithMin(1), + diagnosticRecordingInterval: TypeValidators.numberWithMin(2), + flushInterval: TypeValidators.numberWithMin(2), + streamInitialReconnectDelay: TypeValidators.numberWithMin(0), + + allAttributesPrivate: TypeValidators.Boolean, + debug: TypeValidators.Boolean, + diagnosticOptOut: TypeValidators.Boolean, + withReasons: TypeValidators.Boolean, + sendEvents: TypeValidators.Boolean, + + pollInterval: TypeValidators.numberWithMin(30), + + useReport: TypeValidators.Boolean, + + privateAttributes: TypeValidators.StringArray, + + applicationInfo: TypeValidators.Object, + wrapperName: TypeValidators.String, + wrapperVersion: TypeValidators.String, + payloadFilterKey: TypeValidators.stringMatchingRegex(/^[a-zA-Z0-9](\w|\.|-)*$/), + hooks: TypeValidators.createTypeArray('Hook[]', {}), + inspectors: TypeValidators.createTypeArray('LDInspection', {}), + cleanOldPersistentData: TypeValidators.Boolean, + dataSystem: options?.dataSystemDefaults + ? validatorOf( + dataSystemValidators, + options.dataSystemDefaults as unknown as Record, + ) + : TypeValidators.Object, + }; +} From a90b96291a2921a3783d248043bc8e90c2f5295a Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:33:57 -0700 Subject: [PATCH 03/28] fix: Lint fixes for dataSystem configuration --- .../__tests__/configuration/Configuration.test.ts | 4 +--- .../shared/sdk-client/src/configuration/Configuration.ts | 6 ++++-- .../shared/sdk-client/src/configuration/validateOptions.ts | 5 +++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/shared/sdk-client/__tests__/configuration/Configuration.test.ts b/packages/shared/sdk-client/__tests__/configuration/Configuration.test.ts index 7994f7e8f3..6c747a0e76 100644 --- a/packages/shared/sdk-client/__tests__/configuration/Configuration.test.ts +++ b/packages/shared/sdk-client/__tests__/configuration/Configuration.test.ts @@ -282,9 +282,7 @@ describe('dataSystem validation', () => { }, ); expect(config.dataSystem).toBeUndefined(); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining('dataSystem'), - ); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining('dataSystem')); }); it('validates automaticModeSwitching as a granular config object', () => { diff --git a/packages/shared/sdk-client/src/configuration/Configuration.ts b/packages/shared/sdk-client/src/configuration/Configuration.ts index c20ce283ed..d8a4e6b5fe 100644 --- a/packages/shared/sdk-client/src/configuration/Configuration.ts +++ b/packages/shared/sdk-client/src/configuration/Configuration.ts @@ -10,8 +10,10 @@ import { } from '@launchdarkly/js-sdk-common'; import { Hook, type LDOptions } from '../api'; -import type { LDClientDataSystemOptions } from '../api/datasource/LDClientDataSystemOptions'; -import type { PlatformDataSystemDefaults } from '../api/datasource/LDClientDataSystemOptions'; +import type { + LDClientDataSystemOptions, + PlatformDataSystemDefaults, +} from '../api/datasource/LDClientDataSystemOptions'; import { LDInspection } from '../api/LDInspection'; import validateOptions from './validateOptions'; import createValidators from './validators'; diff --git a/packages/shared/sdk-client/src/configuration/validateOptions.ts b/packages/shared/sdk-client/src/configuration/validateOptions.ts index 191da0e7e7..f0f1b08afe 100644 --- a/packages/shared/sdk-client/src/configuration/validateOptions.ts +++ b/packages/shared/sdk-client/src/configuration/validateOptions.ts @@ -123,8 +123,9 @@ export function validatorOf( logger?.warn(OptionMessages.wrongOptionType(name, 'object', typeof value)); return undefined; } - const nestedDefaults = builtInDefaults - ?? (TypeValidators.Object.is(defaults) ? (defaults as Record) : {}); + const nestedDefaults = + builtInDefaults ?? + (TypeValidators.Object.is(defaults) ? (defaults as Record) : {}); const nested = validateOptions( value as Record, validators, From 73c6f2a01d73a02ca0c5678187661e07a43e9a81 Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Thu, 5 Mar 2026 17:17:32 -0500 Subject: [PATCH 04/28] WIP: Example of this working together --- packages/sdk/browser/example/src/app.ts | 4 +- packages/sdk/browser/src/BrowserClient.ts | 122 +++++++----- .../sdk/browser/src/BrowserFDv2DataManager.ts | 184 ++++++++++++++++++ packages/sdk/browser/src/options.ts | 12 ++ .../datasource/fdv2/PollingBase.test.ts | 4 +- .../datasource/fdv2/StreamingFDv2Base.test.ts | 10 +- .../fdv2/StreamingSynchronizerFDv2.test.ts | 2 +- .../datasource/fdv2/streamingTestHelpers.ts | 2 +- .../__tests__/datasource/fdv2/testHelpers.ts | 2 +- .../datasource/flagEvalMapper.test.ts | 28 +-- .../datasource/flagEvalProtocol.test.ts | 28 +-- .../src/datasource/fdv2/FDv2DataSource.ts | 17 +- .../src/datasource/fdv2/PollingBase.ts | 3 +- .../src/datasource/fdv2/StreamingFDv2Base.ts | 7 +- .../src/datasource/flagEvalMapper.ts | 6 +- packages/shared/sdk-client/src/index.ts | 22 +++ 16 files changed, 351 insertions(+), 102 deletions(-) create mode 100644 packages/sdk/browser/src/BrowserFDv2DataManager.ts diff --git a/packages/sdk/browser/example/src/app.ts b/packages/sdk/browser/example/src/app.ts index 2e9e21c0d9..56b82950b8 100644 --- a/packages/sdk/browser/example/src/app.ts +++ b/packages/sdk/browser/example/src/app.ts @@ -24,7 +24,9 @@ div.appendChild(document.createTextNode('No flag evaluations yet')); statusBox.appendChild(document.createTextNode('Initializing...')); const main = async () => { - const ldclient = createClient(clientSideID, context); + const ldclient = createClient(clientSideID, context, { + useFDv2: true, + }); const render = () => { const flagValue = ldclient.variation(flagKey, false); const label = `The ${flagKey} feature flag evaluates to ${flagValue}.`; diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index a9de2937c1..02e8371761 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -24,6 +24,7 @@ import { import { getHref } from './BrowserApi'; import BrowserDataManager from './BrowserDataManager'; +import BrowserFDv2DataManager from './BrowserFDv2DataManager'; import { BrowserIdentifyOptions as LDIdentifyOptions } from './BrowserIdentifyOptions'; import { registerStateDetection } from './BrowserStateDetector'; import GoalManager from './goals/GoalManager'; @@ -78,54 +79,65 @@ class BrowserClientImpl extends LDClientImpl { const { eventUrlTransformer } = validatedBrowserOptions; const endpoints = browserFdv1Endpoints(clientSideId); - super( - clientSideId, - autoEnvAttributes, - platform, - baseOptionsWithDefaults, - ( - flagManager: FlagManager, - configuration: Configuration, - baseHeaders: LDHeaders, - emitter: LDEmitter, - diagnosticsManager?: internal.DiagnosticsManager, - ) => - new BrowserDataManager( - platform, - flagManager, - clientSideId, - configuration, - validatedBrowserOptions, - endpoints.polling, - endpoints.streaming, - baseHeaders, - emitter, - diagnosticsManager, + const dataManagerFactory = validatedBrowserOptions.useFDv2 + ? ( + flagManager: FlagManager, + configuration: Configuration, + baseHeaders: LDHeaders, + emitter: LDEmitter, + _diagnosticsManager?: internal.DiagnosticsManager, + ) => + new BrowserFDv2DataManager( + platform, + flagManager, + clientSideId, + configuration, + baseHeaders, + emitter, + ) + : ( + flagManager: FlagManager, + configuration: Configuration, + baseHeaders: LDHeaders, + emitter: LDEmitter, + diagnosticsManager?: internal.DiagnosticsManager, + ) => + new BrowserDataManager( + platform, + flagManager, + clientSideId, + configuration, + validatedBrowserOptions, + endpoints.polling, + endpoints.streaming, + baseHeaders, + emitter, + diagnosticsManager, + ); + + super(clientSideId, autoEnvAttributes, platform, baseOptionsWithDefaults, dataManagerFactory, { + // This logic is derived from https://github.com/launchdarkly/js-sdk-common/blob/main/src/PersistentFlagStore.js + getLegacyStorageKeys: () => + getAllStorageKeys().filter((key) => key.startsWith(`ld:${clientSideId}:`)), + analyticsEventPath: `/events/bulk/${clientSideId}`, + diagnosticEventPath: `/events/diagnostic/${clientSideId}`, + includeAuthorizationHeader: false, + highTimeoutThreshold: 5, + userAgentHeaderName: 'x-launchdarkly-user-agent', + dataSystemDefaults: BROWSER_DATA_SYSTEM_DEFAULTS, + trackEventModifier: (event: internal.InputCustomEvent) => + new internal.InputCustomEvent( + event.context, + event.key, + event.data, + event.metricValue, + event.samplingRatio, + eventUrlTransformer(getHref()), ), - { - // This logic is derived from https://github.com/launchdarkly/js-sdk-common/blob/main/src/PersistentFlagStore.js - getLegacyStorageKeys: () => - getAllStorageKeys().filter((key) => key.startsWith(`ld:${clientSideId}:`)), - analyticsEventPath: `/events/bulk/${clientSideId}`, - diagnosticEventPath: `/events/diagnostic/${clientSideId}`, - includeAuthorizationHeader: false, - highTimeoutThreshold: 5, - userAgentHeaderName: 'x-launchdarkly-user-agent', - dataSystemDefaults: BROWSER_DATA_SYSTEM_DEFAULTS, - trackEventModifier: (event: internal.InputCustomEvent) => - new internal.InputCustomEvent( - event.context, - event.key, - event.data, - event.metricValue, - event.samplingRatio, - eventUrlTransformer(getHref()), - ), - getImplementationHooks: (environmentMetadata: LDPluginEnvironmentMetadata) => - internal.safeGetHooks(logger, environmentMetadata, validatedBrowserOptions.plugins), - credentialType: 'clientSideId', - }, - ); + getImplementationHooks: (environmentMetadata: LDPluginEnvironmentMetadata) => + internal.safeGetHooks(logger, environmentMetadata, validatedBrowserOptions.plugins), + credentialType: 'clientSideId', + }); this.setEventSendingEnabled(true, false); @@ -283,16 +295,18 @@ class BrowserClientImpl extends LDClientImpl { 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'. - const browserDataManager = this.dataManager as BrowserDataManager; - browserDataManager.setForcedStreaming(streaming); + if (this.dataManager instanceof BrowserDataManager) { + this.dataManager.setForcedStreaming(streaming); + } } private _updateAutomaticStreamingState() { - const browserDataManager = this.dataManager as BrowserDataManager; - const hasListeners = this.emitter - .eventNames() - .some((name) => name.startsWith('change:') || name === 'change'); - browserDataManager.setAutomaticStreamingState(hasListeners); + if (this.dataManager instanceof BrowserDataManager) { + const hasListeners = this.emitter + .eventNames() + .some((name) => name.startsWith('change:') || name === 'change'); + this.dataManager.setAutomaticStreamingState(hasListeners); + } } override on(eventName: LDEmitterEventName, listener: Function): void { diff --git a/packages/sdk/browser/src/BrowserFDv2DataManager.ts b/packages/sdk/browser/src/BrowserFDv2DataManager.ts new file mode 100644 index 0000000000..e666ffcb5e --- /dev/null +++ b/packages/sdk/browser/src/BrowserFDv2DataManager.ts @@ -0,0 +1,184 @@ +import { + Configuration, + Context, + createDataSourceStatusManager, + createFDv2DataSource, + createPollingInitializer, + createPollingSynchronizer, + createStreamingBase, + createStreamingSynchronizer, + createSynchronizerSlot, + DataManager, + DataSourceStatusManager, + FDv2DataSource, + fdv2Endpoints, + fdv2Poll, + flagEvalPayloadToItemDescriptors, + FlagManager, + internal, + LDEmitter, + LDHeaders, + LDIdentifyOptions, + LDLogger, + makeFDv2Requestor, + Platform, +} from '@launchdarkly/js-client-sdk-common'; + +import { BrowserIdentifyOptions } from './BrowserIdentifyOptions'; + +const logTag = '[BrowserFDv2DataManager]'; + +/** + * A DataManager that uses the FDv2 protocol for flag delivery. + * + * Uses the FDv2DataSource orchestrator with: + * - Polling initializer (fast one-shot for initial data) + * - Streaming synchronizer (primary, for live updates) + * - Polling synchronizer (fallback) + */ +export default class BrowserFDv2DataManager implements DataManager { + private _dataSource?: FDv2DataSource; + private _selector?: string; + private _closed = false; + private readonly _logger: LDLogger; + private readonly _statusManager: DataSourceStatusManager; + + constructor( + private readonly _platform: Platform, + private readonly _flagManager: FlagManager, + private readonly _credential: string, + private readonly _config: Configuration, + private readonly _baseHeaders: LDHeaders, + emitter: LDEmitter, + ) { + this._logger = _config.logger; + this._statusManager = createDataSourceStatusManager(emitter); + } + + async identify( + identifyResolve: () => void, + identifyReject: (err: Error) => void, + context: Context, + identifyOptions?: LDIdentifyOptions, + ): Promise { + if (this._closed) { + this._logger.debug(`${logTag} Identify called after data manager was closed.`); + return; + } + + // Tear down previous data source if any. + this._dataSource?.close(); + this._dataSource = undefined; + this._selector = undefined; + + const plainContextString = JSON.stringify(Context.toLDContext(context)); + const endpoints = fdv2Endpoints(); + + // Build query params: auth (required for browser — no auth header) and secure mode hash. + const queryParams: { key: string; value: string }[] = [ + { key: 'auth', value: this._credential }, + ]; + const browserIdentifyOptions = identifyOptions as BrowserIdentifyOptions | undefined; + if (browserIdentifyOptions?.hash) { + queryParams.push({ key: 'h', value: browserIdentifyOptions.hash }); + } + + const requestor = makeFDv2Requestor( + plainContextString, + this._config.serviceEndpoints, + endpoints.polling(), + this._platform.requests, + this._platform.encoding!, + this._baseHeaders, + queryParams, + ); + + const selectorGetter = () => this._selector; + + const dataCallback = (payload: internal.Payload) => { + this._logger.debug( + `${logTag} dataCallback: type=${payload.type}, updates=${payload.updates.length}, state=${payload.state}`, + ); + + // Track selector for subsequent basis requests. + if (payload.state) { + this._selector = payload.state; + } + + if (payload.type === 'none') { + // 304 / no changes — nothing to apply. + return; + } + + const descriptors = flagEvalPayloadToItemDescriptors(payload.updates); + this._logger.debug(`${logTag} descriptors: ${JSON.stringify(Object.keys(descriptors))}`); + + if (payload.type === 'full') { + this._flagManager.init(context, descriptors); + } else { + // 'partial' — incremental updates + Object.entries(descriptors).forEach(([key, descriptor]) => { + this._logger.debug(`${logTag} upserting: key=${key}, version=${descriptor.version}`); + this._flagManager.upsert(context, key, descriptor); + }); + } + }; + + // Polling initializer — fast one-shot for initial data. + const pollingInitFactory = (sg: () => string | undefined) => + createPollingInitializer(requestor, this._logger, sg); + + // Streaming synchronizer — primary, for live updates. + const streamingEndpoints = endpoints.streaming(); + const streamingSyncFactory = (_sg: () => string | undefined) => { + const streamUriPath = streamingEndpoints.pathGet( + this._platform.encoding!, + plainContextString, + ); + const base = createStreamingBase({ + requests: this._platform.requests, + serviceEndpoints: this._config.serviceEndpoints, + streamUriPath, + parameters: queryParams, + headers: this._baseHeaders, + initialRetryDelayMillis: this._config.streamInitialReconnectDelay * 1000, + logger: this._logger, + pingHandler: { + handlePing: () => fdv2Poll(requestor, selectorGetter(), false, this._logger), + }, + }); + return createStreamingSynchronizer(base); + }; + + // Polling synchronizer — fallback if streaming fails. + const pollingSyncFactory = (sg: () => string | undefined) => + createPollingSynchronizer(requestor, this._logger, sg, this._config.pollInterval * 1000); + + this._dataSource = createFDv2DataSource({ + initializerFactories: [pollingInitFactory], + synchronizerSlots: [ + createSynchronizerSlot(streamingSyncFactory), + createSynchronizerSlot(pollingSyncFactory), + ], + dataCallback, + statusManager: this._statusManager, + selectorGetter, + logger: this._logger, + // Shorter fallback for easier manual testing (default is 120s). + fallbackTimeoutMs: 10_000, + }); + + try { + await this._dataSource.start(); + identifyResolve(); + } catch (err) { + identifyReject(err instanceof Error ? err : new Error(String(err))); + } + } + + close(): void { + this._closed = true; + this._dataSource?.close(); + this._dataSource = undefined; + } +} diff --git a/packages/sdk/browser/src/options.ts b/packages/sdk/browser/src/options.ts index e4e15d50a6..30efe75b8f 100644 --- a/packages/sdk/browser/src/options.ts +++ b/packages/sdk/browser/src/options.ts @@ -59,6 +59,15 @@ export interface BrowserOptions extends Omit url, streaming: undefined, plugins: [], + useFDv2: false, }; const validators: { [Property in keyof BrowserOptions]: TypeValidator | undefined } = { @@ -81,6 +92,7 @@ const validators: { [Property in keyof BrowserOptions]: TypeValidator | undefine eventUrlTransformer: TypeValidators.Function, streaming: TypeValidators.Boolean, plugins: TypeValidators.createTypeArray('LDPlugin', {}), + useFDv2: TypeValidators.Boolean, }; function withBrowserDefaults(opts: BrowserOptions): BrowserOptions { diff --git a/packages/shared/sdk-client/__tests__/datasource/fdv2/PollingBase.test.ts b/packages/shared/sdk-client/__tests__/datasource/fdv2/PollingBase.test.ts index ac36bfa7af..2a3ac8ae38 100644 --- a/packages/shared/sdk-client/__tests__/datasource/fdv2/PollingBase.test.ts +++ b/packages/shared/sdk-client/__tests__/datasource/fdv2/PollingBase.test.ts @@ -456,7 +456,7 @@ describe('given a partial (changes) transfer', () => { { event: 'put-object', data: { - kind: 'flagEval', + kind: 'flag-eval', key: 'updatedFlag', version: 5, object: { value: 'new-value', trackEvents: true }, @@ -499,7 +499,7 @@ describe('given a delete-object event', () => { { event: 'delete-object', data: { - kind: 'flagEval', + kind: 'flag-eval', key: 'deletedFlag', version: 3, }, diff --git a/packages/shared/sdk-client/__tests__/datasource/fdv2/StreamingFDv2Base.test.ts b/packages/shared/sdk-client/__tests__/datasource/fdv2/StreamingFDv2Base.test.ts index ee28828e47..cb67c61ee9 100644 --- a/packages/shared/sdk-client/__tests__/datasource/fdv2/StreamingFDv2Base.test.ts +++ b/packages/shared/sdk-client/__tests__/datasource/fdv2/StreamingFDv2Base.test.ts @@ -70,13 +70,13 @@ it('produces a partial changeSet for incremental updates', async () => { await base.takeResult(); simulateEvent(mockEventSource, 'put-object', { - kind: 'flagEval', + kind: 'flag-eval', key: 'flag-a', version: 2, object: { value: false, trackEvents: true }, }); simulateEvent(mockEventSource, 'delete-object', { - kind: 'flagEval', + kind: 'flag-eval', key: 'flag-b', version: 3, }); @@ -165,7 +165,7 @@ it('silently ignores unrecognized object kinds', async () => { payloads: [{ intentCode: 'xfer-full', id: 'p1', target: 1, reason: 'test' }], }); simulateEvent(mockEventSource, 'put-object', { - kind: 'flagEval', + kind: 'flag-eval', key: 'known', version: 1, object: { value: true, trackEvents: false }, @@ -291,7 +291,7 @@ it('resets protocol handler on reconnection (onopen)', async () => { payloads: [{ intentCode: 'xfer-full', id: 'p1', target: 1, reason: 'test' }], }); simulateEvent(mockEventSource, 'put-object', { - kind: 'flagEval', + kind: 'flag-eval', key: 'flag-a', version: 1, object: { value: true, trackEvents: false }, @@ -322,7 +322,7 @@ it('handles ping events by calling ping handler and queuing the result', async ( version: 1, state: '(p:p1:1)', type: 'full' as const, - updates: [{ kind: 'flagEval', key: 'ping-flag', version: 1, object: { value: 'from-ping' } }], + updates: [{ kind: 'flag-eval', key: 'ping-flag', version: 1, object: { value: 'from-ping' } }], }, fdv1Fallback: false, }; diff --git a/packages/shared/sdk-client/__tests__/datasource/fdv2/StreamingSynchronizerFDv2.test.ts b/packages/shared/sdk-client/__tests__/datasource/fdv2/StreamingSynchronizerFDv2.test.ts index 188b62cccd..b4b8c8caab 100644 --- a/packages/shared/sdk-client/__tests__/datasource/fdv2/StreamingSynchronizerFDv2.test.ts +++ b/packages/shared/sdk-client/__tests__/datasource/fdv2/StreamingSynchronizerFDv2.test.ts @@ -73,7 +73,7 @@ it('returns successive changeSet results', async () => { const p2 = synchronizer.next(); simulateEvent(mockEventSource, 'put-object', { - kind: 'flagEval', + kind: 'flag-eval', key: 'flag-2', version: 2, object: { value: 'second', trackEvents: false }, diff --git a/packages/shared/sdk-client/__tests__/datasource/fdv2/streamingTestHelpers.ts b/packages/shared/sdk-client/__tests__/datasource/fdv2/streamingTestHelpers.ts index 28162299dc..9b140d8cab 100644 --- a/packages/shared/sdk-client/__tests__/datasource/fdv2/streamingTestHelpers.ts +++ b/packages/shared/sdk-client/__tests__/datasource/fdv2/streamingTestHelpers.ts @@ -82,7 +82,7 @@ export function sendFullTransfer( flags.forEach((flag) => { simulateEvent(mockEventSource, 'put-object', { - kind: 'flagEval', + kind: 'flag-eval', key: flag.key, version: flag.version, object: { value: flag.value, trackEvents: false }, diff --git a/packages/shared/sdk-client/__tests__/datasource/fdv2/testHelpers.ts b/packages/shared/sdk-client/__tests__/datasource/fdv2/testHelpers.ts index 53a1f07b26..996fedf6c6 100644 --- a/packages/shared/sdk-client/__tests__/datasource/fdv2/testHelpers.ts +++ b/packages/shared/sdk-client/__tests__/datasource/fdv2/testHelpers.ts @@ -32,7 +32,7 @@ export function makeFullPayloadBody( events.push({ event: 'put-object', data: { - kind: 'flagEval', + kind: 'flag-eval', key, version: 1, object: { value: flag.value, trackEvents: flag.trackEvents ?? false }, diff --git a/packages/shared/sdk-client/__tests__/datasource/flagEvalMapper.test.ts b/packages/shared/sdk-client/__tests__/datasource/flagEvalMapper.test.ts index 2355d96098..0155ec5229 100644 --- a/packages/shared/sdk-client/__tests__/datasource/flagEvalMapper.test.ts +++ b/packages/shared/sdk-client/__tests__/datasource/flagEvalMapper.test.ts @@ -43,7 +43,7 @@ it('passes through objects with extra unrecognized fields in processFlagEval', ( it('converts a put update with all fields into an ItemDescriptor', () => { const update: internal.Update = { - kind: 'flagEval', + kind: 'flag-eval', key: 'my-flag', version: 5, object: fullEvalResult, @@ -66,7 +66,7 @@ it('converts a put update with all fields into an ItemDescriptor', () => { it('preserves all FlagEvaluationResult fields on the mapped flag', () => { const update: internal.Update = { - kind: 'flagEval', + kind: 'flag-eval', key: 'all-fields', version: 5, object: fullEvalResult, @@ -84,7 +84,7 @@ it('preserves all FlagEvaluationResult fields on the mapped flag', () => { it('converts a put update with only required fields', () => { const update: internal.Update = { - kind: 'flagEval', + kind: 'flag-eval', key: 'minimal-flag', version: 1, object: minimalEvalResult, @@ -106,7 +106,7 @@ it('converts a put update with only required fields', () => { it('converts a delete update into a tombstone descriptor', () => { const update: internal.Update = { - kind: 'flagEval', + kind: 'flag-eval', key: 'deleted-flag', version: 10, deleted: true, @@ -123,7 +123,7 @@ it('converts a delete update into a tombstone descriptor', () => { it('uses the envelope version as both ItemDescriptor.version and Flag.version', () => { const update: internal.Update = { - kind: 'flagEval', + kind: 'flag-eval', key: 'versioned-flag', version: 99, object: { ...minimalEvalResult, flagVersion: 7 }, @@ -138,7 +138,7 @@ it('uses the envelope version as both ItemDescriptor.version and Flag.version', it('handles null value in evaluation result', () => { const update: internal.Update = { - kind: 'flagEval', + kind: 'flag-eval', key: 'null-flag', version: 1, object: { value: null, trackEvents: false }, @@ -152,7 +152,7 @@ it('handles null value in evaluation result', () => { it('handles complex object values', () => { const complexValue = { nested: { deeply: { value: [1, 2, 3] } } }; const update: internal.Update = { - kind: 'flagEval', + kind: 'flag-eval', key: 'complex-flag', version: 3, object: { value: complexValue, trackEvents: true }, @@ -166,13 +166,13 @@ it('handles complex object values', () => { it('converts multiple flagEval updates into a map of ItemDescriptors', () => { const updates: internal.Update[] = [ { - kind: 'flagEval', + kind: 'flag-eval', key: 'flag-1', version: 1, object: { value: true, trackEvents: false }, }, { - kind: 'flagEval', + kind: 'flag-eval', key: 'flag-2', version: 2, object: { value: 'blue', trackEvents: true, variation: 0 }, @@ -190,7 +190,7 @@ it('converts multiple flagEval updates into a map of ItemDescriptors', () => { it('silently ignores updates with unrecognized kinds', () => { const updates: internal.Update[] = [ { - kind: 'flagEval', + kind: 'flag-eval', key: 'known-flag', version: 1, object: { value: true, trackEvents: false }, @@ -225,13 +225,13 @@ it('returns an empty map for an empty updates array', () => { it('handles a mix of puts and deletes', () => { const updates: internal.Update[] = [ { - kind: 'flagEval', + kind: 'flag-eval', key: 'active-flag', version: 5, object: { value: 'red', trackEvents: true }, }, { - kind: 'flagEval', + kind: 'flag-eval', key: 'removed-flag', version: 3, deleted: true, @@ -250,13 +250,13 @@ it('handles a mix of puts and deletes', () => { it('uses the last update when a key appears multiple times', () => { const updates: internal.Update[] = [ { - kind: 'flagEval', + kind: 'flag-eval', key: 'dup-flag', version: 1, object: { value: 'first', trackEvents: false }, }, { - kind: 'flagEval', + kind: 'flag-eval', key: 'dup-flag', version: 2, object: { value: 'second', trackEvents: true }, diff --git a/packages/shared/sdk-client/__tests__/datasource/flagEvalProtocol.test.ts b/packages/shared/sdk-client/__tests__/datasource/flagEvalProtocol.test.ts index 2b3259fed1..6ef1dafa70 100644 --- a/packages/shared/sdk-client/__tests__/datasource/flagEvalProtocol.test.ts +++ b/packages/shared/sdk-client/__tests__/datasource/flagEvalProtocol.test.ts @@ -27,14 +27,14 @@ function transferredEvent(state: string, version: number): internal.FDv2Event { } function createHandler() { - return internal.createProtocolHandler({ flagEval: processFlagEval }); + return internal.createProtocolHandler({ 'flag-eval': processFlagEval }); } it('processes flagEval put-object events through the protocol handler', () => { const handler = createHandler(); handler.processEvent(intentEvent('xfer-full', 'p1', 1)); handler.processEvent( - putEvent('flagEval', 'my-flag', 5, { + putEvent('flag-eval', 'my-flag', 5, { flagVersion: 42, value: 'green', variation: 1, @@ -47,7 +47,7 @@ it('processes flagEval put-object events through the protocol handler', () => { if (action.type !== 'payload') return; expect(action.payload.updates).toHaveLength(1); - expect(action.payload.updates[0].kind).toBe('flagEval'); + expect(action.payload.updates[0].kind).toBe('flag-eval'); expect(action.payload.updates[0].key).toBe('my-flag'); expect(action.payload.updates[0].version).toBe(5); expect(action.payload.updates[0].object.value).toBe('green'); @@ -56,14 +56,14 @@ it('processes flagEval put-object events through the protocol handler', () => { it('processes flagEval delete-object events through the protocol handler', () => { const handler = createHandler(); handler.processEvent(intentEvent('xfer-changes', 'p1', 1)); - handler.processEvent(deleteEvent('flagEval', 'old-flag', 10)); + handler.processEvent(deleteEvent('flag-eval', 'old-flag', 10)); const action = handler.processEvent(transferredEvent('(p:p1:1)', 1)); expect(action.type).toBe('payload'); if (action.type !== 'payload') return; expect(action.payload.updates).toHaveLength(1); - expect(action.payload.updates[0].kind).toBe('flagEval'); + expect(action.payload.updates[0].kind).toBe('flag-eval'); expect(action.payload.updates[0].key).toBe('old-flag'); expect(action.payload.updates[0].version).toBe(10); expect(action.payload.updates[0].deleted).toBe(true); @@ -72,7 +72,7 @@ it('processes flagEval delete-object events through the protocol handler', () => it('silently ignores unrecognized object kinds when flagEval is the only processor', () => { const handler = createHandler(); handler.processEvent(intentEvent('xfer-full', 'p1', 1)); - handler.processEvent(putEvent('flagEval', 'known', 1, { value: true, trackEvents: false })); + handler.processEvent(putEvent('flag-eval', 'known', 1, { value: true, trackEvents: false })); handler.processEvent(putEvent('unknown_kind', 'mystery', 1, { data: 'ignored' })); const action = handler.processEvent(transferredEvent('(p:p1:1)', 1)); @@ -86,11 +86,11 @@ it('silently ignores unrecognized object kinds when flagEval is the only process it('handles a full transfer of multiple flagEval objects', () => { const handler = createHandler(); handler.processEvent(intentEvent('xfer-full', 'p1', 52)); - handler.processEvent(putEvent('flagEval', 'flag-a', 1, { value: true, trackEvents: false })); + handler.processEvent(putEvent('flag-eval', 'flag-a', 1, { value: true, trackEvents: false })); handler.processEvent( - putEvent('flagEval', 'flag-b', 2, { value: 'blue', trackEvents: true, variation: 1 }), + putEvent('flag-eval', 'flag-b', 2, { value: 'blue', trackEvents: true, variation: 1 }), ); - handler.processEvent(putEvent('flagEval', 'flag-c', 3, { value: 42, trackEvents: false })); + handler.processEvent(putEvent('flag-eval', 'flag-c', 3, { value: 42, trackEvents: false })); const action = handler.processEvent(transferredEvent('(p:p1:52)', 52)); expect(action.type).toBe('payload'); @@ -106,12 +106,12 @@ it('handles incremental updates with flagEval after a full transfer', () => { // Full transfer handler.processEvent(intentEvent('xfer-full', 'p1', 1)); - handler.processEvent(putEvent('flagEval', 'flag-a', 1, { value: true, trackEvents: false })); + handler.processEvent(putEvent('flag-eval', 'flag-a', 1, { value: true, trackEvents: false })); handler.processEvent(transferredEvent('(p:p1:1)', 1)); // Incremental update - handler.processEvent(putEvent('flagEval', 'flag-a', 2, { value: false, trackEvents: true })); - handler.processEvent(deleteEvent('flagEval', 'flag-b', 3)); + handler.processEvent(putEvent('flag-eval', 'flag-a', 2, { value: false, trackEvents: true })); + handler.processEvent(deleteEvent('flag-eval', 'flag-b', 3)); const action = handler.processEvent(transferredEvent('(p:p1:2)', 2)); expect(action.type).toBe('payload'); @@ -120,12 +120,12 @@ it('handles incremental updates with flagEval after a full transfer', () => { expect(action.payload.type).toBe('partial'); expect(action.payload.updates).toHaveLength(2); expect(action.payload.updates[0]).toMatchObject({ - kind: 'flagEval', + kind: 'flag-eval', key: 'flag-a', object: { value: false }, }); expect(action.payload.updates[1]).toMatchObject({ - kind: 'flagEval', + kind: 'flag-eval', key: 'flag-b', deleted: true, }); diff --git a/packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts b/packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts index d8c122c908..fe8d0871e0 100644 --- a/packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts +++ b/packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts @@ -201,13 +201,24 @@ export function createFDv2DataSource(config: FDv2DataSourceConfig): FDv2DataSour return; } + const isPrime = sourceManager.isPrimeSynchronizer(); + const availableCount = sourceManager.getAvailableSynchronizerCount(); + logger?.warn( + `Synchronizer started: isPrime=${isPrime}, availableCount=${availableCount}, ` + + `fallbackMs=${fallbackTimeoutMs}, recoveryMs=${recoveryTimeoutMs}`, + ); + const conditions: ConditionGroup = getConditions( - sourceManager.getAvailableSynchronizerCount(), - sourceManager.isPrimeSynchronizer(), + availableCount, + isPrime, fallbackTimeoutMs, recoveryTimeoutMs, ); + if (conditions.promise) { + logger?.warn('Fallback condition active for current synchronizer.'); + } + // try/finally ensures conditions are closed on all code paths. let synchronizerRunning = true; try { @@ -234,7 +245,7 @@ export function createFDv2DataSource(config: FDv2DataSourceConfig): FDv2DataSour if (conditionType === 'fallback') { logger?.warn('Fallback condition fired, moving to next synchronizer.'); } else if (conditionType === 'recovery') { - logger?.info('Recovery condition fired, resetting to primary synchronizer.'); + logger?.warn('Recovery condition fired, resetting to primary synchronizer.'); sourceManager.resetSourceIndex(); } diff --git a/packages/shared/sdk-client/src/datasource/fdv2/PollingBase.ts b/packages/shared/sdk-client/src/datasource/fdv2/PollingBase.ts index 942bb9d86d..7b8a330573 100644 --- a/packages/shared/sdk-client/src/datasource/fdv2/PollingBase.ts +++ b/packages/shared/sdk-client/src/datasource/fdv2/PollingBase.ts @@ -40,7 +40,8 @@ function processEvents( ): FDv2SourceResult { const handler = internal.createProtocolHandler( { - flagEval: processFlagEval, + 'flag-eval': processFlagEval, + flag_eval: processFlagEval, }, logger, ); diff --git a/packages/shared/sdk-client/src/datasource/fdv2/StreamingFDv2Base.ts b/packages/shared/sdk-client/src/datasource/fdv2/StreamingFDv2Base.ts index 89bdd50ba4..e59836158b 100644 --- a/packages/shared/sdk-client/src/datasource/fdv2/StreamingFDv2Base.ts +++ b/packages/shared/sdk-client/src/datasource/fdv2/StreamingFDv2Base.ts @@ -91,7 +91,7 @@ export function createStreamingBase(config: { }): StreamingFDv2Base { const resultQueue = createAsyncQueue(); const protocolHandler = internal.createProtocolHandler( - { flagEval: processFlagEval }, + { 'flag-eval': processFlagEval, flag_eval: processFlagEval }, config.logger, ); @@ -188,7 +188,10 @@ export function createStreamingBase(config: { } if (!event?.data) { - config.logger?.warn(`Event from EventStream missing data for "${eventName}".`); + // Some events (e.g. 'error') may legitimately arrive without a body. + if (eventName !== 'error') { + config.logger?.warn(`Event from EventStream missing data for "${eventName}".`); + } return; } diff --git a/packages/shared/sdk-client/src/datasource/flagEvalMapper.ts b/packages/shared/sdk-client/src/datasource/flagEvalMapper.ts index 5f4213e9a5..500f4148c3 100644 --- a/packages/shared/sdk-client/src/datasource/flagEvalMapper.ts +++ b/packages/shared/sdk-client/src/datasource/flagEvalMapper.ts @@ -4,7 +4,7 @@ import { ItemDescriptor } from '../flag-manager/ItemDescriptor'; import { FlagEvaluationResult } from '../types'; /** - * ObjProcessor for the `flagEval` object kind. Used by the protocol handler to + * ObjProcessor for the `flag_eval` object kind. Used by the protocol handler to * process objects received in `put-object` events. * * Client-side evaluation results are already in their final form (pre-evaluated @@ -49,7 +49,7 @@ export function flagEvalUpdateToItemDescriptor(update: internal.Update): ItemDes /** * Converts an array of FDv2 payload updates into a map of flag key to - * {@link ItemDescriptor}. Only `flagEval` kind updates are processed; + * {@link ItemDescriptor}. Only `flag_eval` kind updates are processed; * unrecognized kinds are silently ignored. */ export function flagEvalPayloadToItemDescriptors(updates: internal.Update[]): { @@ -58,7 +58,7 @@ export function flagEvalPayloadToItemDescriptors(updates: internal.Update[]): { const descriptors: { [key: string]: ItemDescriptor } = {}; updates.forEach((update) => { - if (update.kind === 'flagEval') { + if (update.kind === 'flag-eval' || update.kind === 'flag_eval') { descriptors[update.key] = flagEvalUpdateToItemDescriptor(update); } }); diff --git a/packages/shared/sdk-client/src/index.ts b/packages/shared/sdk-client/src/index.ts index 192f812dba..c293a36cba 100644 --- a/packages/shared/sdk-client/src/index.ts +++ b/packages/shared/sdk-client/src/index.ts @@ -94,6 +94,28 @@ export type { ModeResolutionTable, } from './api/datasource'; +// FDv2 data source orchestration — exported for platform SDK integration. +export type { + FDv2DataSource, + FDv2DataSourceConfig, + DataCallback, +} from './datasource/fdv2/FDv2DataSource'; +export { createFDv2DataSource } from './datasource/fdv2/FDv2DataSource'; +export type { InitializerFactory, SynchronizerSlot } from './datasource/fdv2/SourceManager'; +export { createSynchronizerSlot } from './datasource/fdv2/SourceManager'; +export { makeFDv2Requestor } from './datasource/fdv2/FDv2Requestor'; +export type { FDv2Requestor } from './datasource/fdv2/FDv2Requestor'; +export { createPollingInitializer } from './datasource/fdv2/PollingInitializer'; +export { createPollingSynchronizer } from './datasource/fdv2/PollingSynchronizer'; +export type { PingHandler, StreamingFDv2Base } from './datasource/fdv2/StreamingFDv2Base'; +export { createStreamingBase } from './datasource/fdv2/StreamingFDv2Base'; +export { createStreamingInitializer } from './datasource/fdv2/StreamingInitializerFDv2'; +export { createStreamingSynchronizer } from './datasource/fdv2/StreamingSynchronizerFDv2'; +export { poll as fdv2Poll } from './datasource/fdv2/PollingBase'; +export { flagEvalPayloadToItemDescriptors } from './datasource/flagEvalMapper'; +export { createDataSourceStatusManager } from './datasource/DataSourceStatusManager'; +export type { DataSourceStatusManager } from './datasource/DataSourceStatusManager'; + // FDv2 data system validators and platform defaults. export { dataSystemValidators, From 109aeeafb28e7a4e645e646024baa51770a24695 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 5 Mar 2026 15:49:23 -0800 Subject: [PATCH 05/28] Fix interrupted state for non-status codes. --- .../src/datasource/fdv2/StreamingFDv2Base.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/shared/sdk-client/src/datasource/fdv2/StreamingFDv2Base.ts b/packages/shared/sdk-client/src/datasource/fdv2/StreamingFDv2Base.ts index e59836158b..8d798aa47f 100644 --- a/packages/shared/sdk-client/src/datasource/fdv2/StreamingFDv2Base.ts +++ b/packages/shared/sdk-client/src/datasource/fdv2/StreamingFDv2Base.ts @@ -9,6 +9,7 @@ import { Requests, ServiceEndpoints, shouldRetry, + DataSourceErrorKind, } from '@launchdarkly/js-sdk-common'; import { processFlagEval } from '../flagEvalMapper'; @@ -280,8 +281,16 @@ export function createStreamingBase(config: { config.logger?.info('Closed LaunchDarkly stream connection'); }; - es.onerror = () => { - // Error handling is done by errorFilter. + es.onerror = (err?: HttpErrorResponse) => { + if(err && typeof err.status === 'number') { + // This condition will be handled by the error filter. + return; + } + resultQueue.put(interrupted({ + kind: DataSourceErrorKind.NetworkError, + message: err?.message ?? 'IO Error', + time: Date.now(), + }, fdv1Fallback)); }; es.onopen = () => { From e7f50490d4b21d099bf44d3b7fddfd4299fce15d Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:19:34 -0700 Subject: [PATCH 06/28] Tests --- .../browser/__tests__/BrowserClient.test.ts | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/packages/sdk/browser/__tests__/BrowserClient.test.ts b/packages/sdk/browser/__tests__/BrowserClient.test.ts index f105205f8a..1eaa579f92 100644 --- a/packages/sdk/browser/__tests__/BrowserClient.test.ts +++ b/packages/sdk/browser/__tests__/BrowserClient.test.ts @@ -872,4 +872,72 @@ describe('given a mock platform for a BrowserClient', () => { // Verify that no fetch calls were made expect(platform.requests.fetch.mock.calls.length).toBe(0); }); + + it('uses FDv1 endpoints when dataSystem is not set', async () => { + const client = makeClient( + 'client-side-id', + { key: 'user-key', kind: 'user' }, + AutoEnvAttributes.Disabled, + { streaming: false, logger, diagnosticOptOut: true, sendEvents: false, fetchGoals: false }, + platform, + ); + + await client.start(); + + const fetchUrl = platform.requests.fetch.mock.calls[0][0]; + expect(fetchUrl).toContain('/sdk/evalx/'); + expect(fetchUrl).not.toContain('/sdk/poll/eval'); + }); + + it('uses FDv2 endpoints when dataSystem is set', async () => { + const client = makeClient( + 'client-side-id', + { key: 'user-key', kind: 'user' }, + AutoEnvAttributes.Disabled, + { + streaming: false, + logger, + diagnosticOptOut: true, + sendEvents: false, + fetchGoals: false, + // @ts-ignore dataSystem is @internal + dataSystem: {}, + }, + platform, + ); + + await client.start(); + + const fetchUrl = platform.requests.fetch.mock.calls[0][0]; + expect(fetchUrl).toContain('/sdk/poll/eval/'); + }); + + it('validates dataSystem options and applies browser defaults', async () => { + const client = makeClient( + 'client-side-id', + { key: 'user-key', kind: 'user' }, + AutoEnvAttributes.Disabled, + { + streaming: false, + logger, + diagnosticOptOut: true, + sendEvents: false, + fetchGoals: false, + // @ts-ignore dataSystem is @internal + dataSystem: { initialConnectionMode: 'invalid-mode' }, + }, + platform, + ); + + // Invalid mode should produce a warning + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('dataSystem.initialConnectionMode'), + ); + + await client.start(); + + // Should still use FDv2 — invalid sub-fields fall back to defaults, not disable FDv2 + const fetchUrl = platform.requests.fetch.mock.calls[0][0]; + expect(fetchUrl).toContain('/sdk/poll/eval/'); + }); }); From e2821cd39fd619c31028d1b6169283d54ccd5dc9 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:46:29 -0700 Subject: [PATCH 07/28] Fix basis. --- packages/sdk/browser/src/BrowserFDv2DataManager.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/sdk/browser/src/BrowserFDv2DataManager.ts b/packages/sdk/browser/src/BrowserFDv2DataManager.ts index e666ffcb5e..0bd35f7044 100644 --- a/packages/sdk/browser/src/BrowserFDv2DataManager.ts +++ b/packages/sdk/browser/src/BrowserFDv2DataManager.ts @@ -130,7 +130,7 @@ export default class BrowserFDv2DataManager implements DataManager { // Streaming synchronizer — primary, for live updates. const streamingEndpoints = endpoints.streaming(); - const streamingSyncFactory = (_sg: () => string | undefined) => { + const streamingSyncFactory = (sg: () => string | undefined) => { const streamUriPath = streamingEndpoints.pathGet( this._platform.encoding!, plainContextString, @@ -140,6 +140,7 @@ export default class BrowserFDv2DataManager implements DataManager { serviceEndpoints: this._config.serviceEndpoints, streamUriPath, parameters: queryParams, + selectorGetter: sg, headers: this._baseHeaders, initialRetryDelayMillis: this._config.streamInitialReconnectDelay * 1000, logger: this._logger, From a7413c0bd44850ec7e474b75897618a0460a8c49 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:20:02 -0700 Subject: [PATCH 08/28] rebase data system config --- packages/sdk/browser/example/src/app.ts | 11 ++++----- packages/sdk/browser/src/BrowserClient.ts | 27 +++++++++-------------- packages/sdk/browser/src/options.ts | 12 ---------- 3 files changed, 16 insertions(+), 34 deletions(-) diff --git a/packages/sdk/browser/example/src/app.ts b/packages/sdk/browser/example/src/app.ts index 56b82950b8..4d6650f969 100644 --- a/packages/sdk/browser/example/src/app.ts +++ b/packages/sdk/browser/example/src/app.ts @@ -25,7 +25,8 @@ statusBox.appendChild(document.createTextNode('Initializing...')); const main = async () => { const ldclient = createClient(clientSideID, context, { - useFDv2: true, + // @ts-ignore dataSystem is @internal — experimental FDv2 opt-in + dataSystem: {}, }); const render = () => { const flagValue = ldclient.variation(flagKey, false); @@ -41,10 +42,10 @@ const main = async () => { ); }); - // Listen for flag changes - ldclient.on('change', () => { - render(); - }); + // // Listen for flag changes + // ldclient.on('change', () => { + // render(); + // }); ldclient.start(); diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index 02e8371761..c627396b0f 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -79,15 +79,15 @@ class BrowserClientImpl extends LDClientImpl { const { eventUrlTransformer } = validatedBrowserOptions; const endpoints = browserFdv1Endpoints(clientSideId); - const dataManagerFactory = validatedBrowserOptions.useFDv2 - ? ( - flagManager: FlagManager, - configuration: Configuration, - baseHeaders: LDHeaders, - emitter: LDEmitter, - _diagnosticsManager?: internal.DiagnosticsManager, - ) => - new BrowserFDv2DataManager( + const dataManagerFactory = ( + flagManager: FlagManager, + configuration: Configuration, + baseHeaders: LDHeaders, + emitter: LDEmitter, + diagnosticsManager?: internal.DiagnosticsManager, + ) => + configuration.dataSystem + ? new BrowserFDv2DataManager( platform, flagManager, clientSideId, @@ -95,14 +95,7 @@ class BrowserClientImpl extends LDClientImpl { baseHeaders, emitter, ) - : ( - flagManager: FlagManager, - configuration: Configuration, - baseHeaders: LDHeaders, - emitter: LDEmitter, - diagnosticsManager?: internal.DiagnosticsManager, - ) => - new BrowserDataManager( + : new BrowserDataManager( platform, flagManager, clientSideId, diff --git a/packages/sdk/browser/src/options.ts b/packages/sdk/browser/src/options.ts index 30efe75b8f..e4e15d50a6 100644 --- a/packages/sdk/browser/src/options.ts +++ b/packages/sdk/browser/src/options.ts @@ -59,15 +59,6 @@ export interface BrowserOptions extends Omit url, streaming: undefined, plugins: [], - useFDv2: false, }; const validators: { [Property in keyof BrowserOptions]: TypeValidator | undefined } = { @@ -92,7 +81,6 @@ const validators: { [Property in keyof BrowserOptions]: TypeValidator | undefine eventUrlTransformer: TypeValidators.Function, streaming: TypeValidators.Boolean, plugins: TypeValidators.createTypeArray('LDPlugin', {}), - useFDv2: TypeValidators.Boolean, }; function withBrowserDefaults(opts: BrowserOptions): BrowserOptions { From e40b0d5bba12aa4547a0113869370d1f1714273c Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:21:35 -0700 Subject: [PATCH 09/28] Integrate mode switching and caching. --- packages/sdk/browser/example/src/app.ts | 11 +- packages/sdk/browser/src/BrowserClient.ts | 13 +- .../sdk/browser/src/BrowserFDv2DataManager.ts | 230 +++------ .../datasource/fdv2/FDv2DataSource.test.ts | 2 +- .../datasource/fdv2/StreamingFDv2Base.test.ts | 4 +- .../src/datasource/FDv2DataManagerBase.ts | 478 ++++++++++++++++++ .../src/datasource/SourceFactoryProvider.ts | 185 +++++++ .../src/datasource/fdv2/PollingBase.ts | 2 +- .../src/datasource/fdv2/StreamingFDv2Base.ts | 19 +- packages/shared/sdk-client/src/index.ts | 25 + 10 files changed, 801 insertions(+), 168 deletions(-) create mode 100644 packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts create mode 100644 packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts diff --git a/packages/sdk/browser/example/src/app.ts b/packages/sdk/browser/example/src/app.ts index 4d6650f969..e80e0295d2 100644 --- a/packages/sdk/browser/example/src/app.ts +++ b/packages/sdk/browser/example/src/app.ts @@ -1,4 +1,4 @@ -import { createClient } from '@launchdarkly/js-client-sdk'; +import { basicLogger, createClient } from '@launchdarkly/js-client-sdk'; // Set clientSideID to your LaunchDarkly client-side ID const clientSideID = 'LD_CLIENT_SIDE_ID'; @@ -27,6 +27,7 @@ const main = async () => { const ldclient = createClient(clientSideID, context, { // @ts-ignore dataSystem is @internal — experimental FDv2 opt-in dataSystem: {}, + logger: basicLogger({level: 'debug'}), }); const render = () => { const flagValue = ldclient.variation(flagKey, false); @@ -42,10 +43,10 @@ const main = async () => { ); }); - // // Listen for flag changes - // ldclient.on('change', () => { - // render(); - // }); + // Listen for flag changes + ldclient.on('change', () => { + render(); + }); ldclient.start(); diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index c627396b0f..0250439922 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -286,18 +286,21 @@ class BrowserClientImpl extends LDClientImpl { } 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'. if (this.dataManager instanceof BrowserDataManager) { this.dataManager.setForcedStreaming(streaming); + } else if (this.dataManager instanceof BrowserFDv2DataManager) { + this.dataManager.setForcedStreaming(streaming); } } private _updateAutomaticStreamingState() { + const hasListeners = this.emitter + .eventNames() + .some((name) => name.startsWith('change:') || name === 'change'); + if (this.dataManager instanceof BrowserDataManager) { - const hasListeners = this.emitter - .eventNames() - .some((name) => name.startsWith('change:') || name === 'change'); + this.dataManager.setAutomaticStreamingState(hasListeners); + } else if (this.dataManager instanceof BrowserFDv2DataManager) { this.dataManager.setAutomaticStreamingState(hasListeners); } } diff --git a/packages/sdk/browser/src/BrowserFDv2DataManager.ts b/packages/sdk/browser/src/BrowserFDv2DataManager.ts index 0bd35f7044..11b45c9b7d 100644 --- a/packages/sdk/browser/src/BrowserFDv2DataManager.ts +++ b/packages/sdk/browser/src/BrowserFDv2DataManager.ts @@ -1,58 +1,85 @@ import { + BROWSER_TRANSITION_TABLE, + browserFdv1Endpoints, Configuration, Context, - createDataSourceStatusManager, - createFDv2DataSource, - createPollingInitializer, - createPollingSynchronizer, - createStreamingBase, - createStreamingSynchronizer, - createSynchronizerSlot, + createDefaultSourceFactoryProvider, + createFDv2DataManagerBase, DataManager, - DataSourceStatusManager, - FDv2DataSource, - fdv2Endpoints, - fdv2Poll, - flagEvalPayloadToItemDescriptors, + FDv2ConnectionMode, + FDv2DataManagerControl, FlagManager, - internal, LDEmitter, LDHeaders, LDIdentifyOptions, - LDLogger, - makeFDv2Requestor, + MODE_TABLE, Platform, } from '@launchdarkly/js-client-sdk-common'; import { BrowserIdentifyOptions } from './BrowserIdentifyOptions'; -const logTag = '[BrowserFDv2DataManager]'; - /** - * A DataManager that uses the FDv2 protocol for flag delivery. + * A DataManager that uses the FDv2 protocol for flag delivery with + * mode switching and debouncing support. * - * Uses the FDv2DataSource orchestrator with: - * - Polling initializer (fast one-shot for initial data) - * - Streaming synchronizer (primary, for live updates) - * - Polling synchronizer (fallback) + * Delegates to a shared {@link FDv2DataManagerControl} (from sdk-client) + * and adds browser-specific behavior: + * - Auth via query params (no Authorization header in browser) + * - Listener-driven streaming auto-promotion + * - Forced streaming via `setStreaming()` API */ export default class BrowserFDv2DataManager implements DataManager { - private _dataSource?: FDv2DataSource; - private _selector?: string; - private _closed = false; - private readonly _logger: LDLogger; - private readonly _statusManager: DataSourceStatusManager; + private readonly _base: FDv2DataManagerControl; + + // If streaming is forced on or off, then we follow that setting. + // Otherwise we automatically manage streaming state. + private _forcedStreaming?: boolean = undefined; + private _automaticStreamingState: boolean = false; + + // +-----------+-----------+------------------+ + // | forced | automatic | state | + // +-----------+-----------+------------------+ + // | true | false | streaming | + // | true | true | streaming | + // | false | true | not streaming | + // | false | false | not streaming | + // | undefined | true | streaming | + // | undefined | false | configured mode | + // +-----------+-----------+------------------+ constructor( - private readonly _platform: Platform, - private readonly _flagManager: FlagManager, - private readonly _credential: string, - private readonly _config: Configuration, - private readonly _baseHeaders: LDHeaders, + platform: Platform, + flagManager: FlagManager, + credential: string, + config: Configuration, + baseHeaders: LDHeaders, emitter: LDEmitter, ) { - this._logger = _config.logger; - this._statusManager = createDataSourceStatusManager(emitter); + const initialForegroundMode: FDv2ConnectionMode = + (config.dataSystem?.initialConnectionMode as FDv2ConnectionMode) ?? 'one-shot'; + + this._base = createFDv2DataManagerBase({ + platform, + flagManager, + credential, + config, + baseHeaders, + emitter, + transitionTable: BROWSER_TRANSITION_TABLE, + initialForegroundMode, + backgroundMode: undefined, + modeTable: MODE_TABLE, + sourceFactoryProvider: createDefaultSourceFactoryProvider(), + fdv1Endpoints: browserFdv1Endpoints(credential), + buildQueryParams: (identifyOptions?: LDIdentifyOptions) => { + const params: { key: string; value: string }[] = [{ key: 'auth', value: credential }]; + const browserOpts = identifyOptions as BrowserIdentifyOptions | undefined; + if (browserOpts?.hash) { + params.push({ key: 'h', value: browserOpts.hash }); + } + return params; + }, + }); } async identify( @@ -61,125 +88,32 @@ export default class BrowserFDv2DataManager implements DataManager { context: Context, identifyOptions?: LDIdentifyOptions, ): Promise { - if (this._closed) { - this._logger.debug(`${logTag} Identify called after data manager was closed.`); - return; - } - - // Tear down previous data source if any. - this._dataSource?.close(); - this._dataSource = undefined; - this._selector = undefined; - - const plainContextString = JSON.stringify(Context.toLDContext(context)); - const endpoints = fdv2Endpoints(); - - // Build query params: auth (required for browser — no auth header) and secure mode hash. - const queryParams: { key: string; value: string }[] = [ - { key: 'auth', value: this._credential }, - ]; - const browserIdentifyOptions = identifyOptions as BrowserIdentifyOptions | undefined; - if (browserIdentifyOptions?.hash) { - queryParams.push({ key: 'h', value: browserIdentifyOptions.hash }); - } - - const requestor = makeFDv2Requestor( - plainContextString, - this._config.serviceEndpoints, - endpoints.polling(), - this._platform.requests, - this._platform.encoding!, - this._baseHeaders, - queryParams, - ); - - const selectorGetter = () => this._selector; - - const dataCallback = (payload: internal.Payload) => { - this._logger.debug( - `${logTag} dataCallback: type=${payload.type}, updates=${payload.updates.length}, state=${payload.state}`, - ); - - // Track selector for subsequent basis requests. - if (payload.state) { - this._selector = payload.state; - } - - if (payload.type === 'none') { - // 304 / no changes — nothing to apply. - return; - } - - const descriptors = flagEvalPayloadToItemDescriptors(payload.updates); - this._logger.debug(`${logTag} descriptors: ${JSON.stringify(Object.keys(descriptors))}`); - - if (payload.type === 'full') { - this._flagManager.init(context, descriptors); - } else { - // 'partial' — incremental updates - Object.entries(descriptors).forEach(([key, descriptor]) => { - this._logger.debug(`${logTag} upserting: key=${key}, version=${descriptor.version}`); - this._flagManager.upsert(context, key, descriptor); - }); - } - }; + return this._base.identify(identifyResolve, identifyReject, context, identifyOptions); + } - // Polling initializer — fast one-shot for initial data. - const pollingInitFactory = (sg: () => string | undefined) => - createPollingInitializer(requestor, this._logger, sg); + close(): void { + this._base.close(); + } - // Streaming synchronizer — primary, for live updates. - const streamingEndpoints = endpoints.streaming(); - const streamingSyncFactory = (sg: () => string | undefined) => { - const streamUriPath = streamingEndpoints.pathGet( - this._platform.encoding!, - plainContextString, - ); - const base = createStreamingBase({ - requests: this._platform.requests, - serviceEndpoints: this._config.serviceEndpoints, - streamUriPath, - parameters: queryParams, - selectorGetter: sg, - headers: this._baseHeaders, - initialRetryDelayMillis: this._config.streamInitialReconnectDelay * 1000, - logger: this._logger, - pingHandler: { - handlePing: () => fdv2Poll(requestor, selectorGetter(), false, this._logger), - }, - }); - return createStreamingSynchronizer(base); - }; + setForcedStreaming(streaming?: boolean): void { + this._forcedStreaming = streaming; + this._updateStreamingState(); + } - // Polling synchronizer — fallback if streaming fails. - const pollingSyncFactory = (sg: () => string | undefined) => - createPollingSynchronizer(requestor, this._logger, sg, this._config.pollInterval * 1000); + setAutomaticStreamingState(streaming: boolean): void { + this._automaticStreamingState = streaming; + this._updateStreamingState(); + } - this._dataSource = createFDv2DataSource({ - initializerFactories: [pollingInitFactory], - synchronizerSlots: [ - createSynchronizerSlot(streamingSyncFactory), - createSynchronizerSlot(pollingSyncFactory), - ], - dataCallback, - statusManager: this._statusManager, - selectorGetter, - logger: this._logger, - // Shorter fallback for easier manual testing (default is 120s). - fallbackTimeoutMs: 10_000, - }); + private _updateStreamingState(): void { + const shouldBeStreaming = + this._forcedStreaming || + (this._automaticStreamingState && this._forcedStreaming === undefined); - try { - await this._dataSource.start(); - identifyResolve(); - } catch (err) { - identifyReject(err instanceof Error ? err : new Error(String(err))); + if (shouldBeStreaming) { + this._base.setForegroundMode('streaming'); + } else { + this._base.setForegroundMode(this._base.configuredForegroundMode); } } - - close(): void { - this._closed = true; - this._dataSource?.close(); - this._dataSource = undefined; - } } diff --git a/packages/shared/sdk-client/__tests__/datasource/fdv2/FDv2DataSource.test.ts b/packages/shared/sdk-client/__tests__/datasource/fdv2/FDv2DataSource.test.ts index 13e6fc0b88..cd4efda5c9 100644 --- a/packages/shared/sdk-client/__tests__/datasource/fdv2/FDv2DataSource.test.ts +++ b/packages/shared/sdk-client/__tests__/datasource/fdv2/FDv2DataSource.test.ts @@ -470,7 +470,7 @@ it('recovers to primary synchronizer when recovery condition fires', async () => // recovery (20ms) → sync1 second invocation delivers changeSet. await ds.start(); - expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Recovery condition fired')); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Recovery condition fired')); expect(dataCallback).toHaveBeenCalledWith(payload); ds.close(); }); diff --git a/packages/shared/sdk-client/__tests__/datasource/fdv2/StreamingFDv2Base.test.ts b/packages/shared/sdk-client/__tests__/datasource/fdv2/StreamingFDv2Base.test.ts index cb67c61ee9..33086a5bb4 100644 --- a/packages/shared/sdk-client/__tests__/datasource/fdv2/StreamingFDv2Base.test.ts +++ b/packages/shared/sdk-client/__tests__/datasource/fdv2/StreamingFDv2Base.test.ts @@ -322,7 +322,9 @@ it('handles ping events by calling ping handler and queuing the result', async ( version: 1, state: '(p:p1:1)', type: 'full' as const, - updates: [{ kind: 'flag-eval', key: 'ping-flag', version: 1, object: { value: 'from-ping' } }], + updates: [ + { kind: 'flag-eval', key: 'ping-flag', version: 1, object: { value: 'from-ping' } }, + ], }, fdv1Fallback: false, }; diff --git a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts new file mode 100644 index 0000000000..8e247e97f5 --- /dev/null +++ b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts @@ -0,0 +1,478 @@ +import { Context, internal, LDHeaders, Platform } from '@launchdarkly/js-sdk-common'; + +import { + FDv2ConnectionMode, + ModeDefinition, + ModeResolutionTable, + ModeState, +} from '../api/datasource'; +import { LDIdentifyOptions } from '../api/LDIdentifyOptions'; +import { Configuration } from '../configuration/Configuration'; +import { DataManager } from '../DataManager'; +import { FlagManager } from '../flag-manager/FlagManager'; +import LDEmitter from '../LDEmitter'; +import { namespaceForEnvironment } from '../storage/namespaceUtils'; +import { ModeTable } from './ConnectionModeConfig'; +import { createDataSourceStatusManager, DataSourceStatusManager } from './DataSourceStatusManager'; +import { DataSourceEndpoints, fdv2Endpoints } from './Endpoints'; +import { createFDv1PollingSynchronizer } from './fdv2/FDv1PollingSynchronizer'; +import { createFDv2DataSource, FDv2DataSource } from './fdv2/FDv2DataSource'; +import { makeFDv2Requestor } from './fdv2/FDv2Requestor'; +import { createSynchronizerSlot, InitializerFactory, SynchronizerSlot } from './fdv2/SourceManager'; +import { flagEvalPayloadToItemDescriptors } from './flagEvalMapper'; +import { resolveConnectionMode } from './ModeResolver'; +import { makeRequestor } from './Requestor'; +import { SourceFactoryContext, SourceFactoryProvider } from './SourceFactoryProvider'; +import { + createStateDebounceManager, + LifecycleState, + NetworkState, + PendingState, + StateDebounceManager, +} from './StateDebounceManager'; + +const logTag = '[FDv2DataManagerBase]'; + +/** + * Configuration for creating an {@link FDv2DataManagerControl}. + */ +export interface FDv2DataManagerBaseConfig { + platform: Platform; + flagManager: FlagManager; + credential: string; + config: Configuration; + baseHeaders: LDHeaders; + emitter: LDEmitter; + + /** Mode resolution table for this platform. */ + transitionTable: ModeResolutionTable; + /** The initial foreground connection mode. */ + initialForegroundMode: FDv2ConnectionMode; + /** The background connection mode, if any. */ + backgroundMode: FDv2ConnectionMode | undefined; + /** The mode table mapping modes to data source definitions. */ + modeTable: ModeTable; + /** Provider that converts DataSourceEntry descriptors to concrete factories. */ + sourceFactoryProvider: SourceFactoryProvider; + /** + * Platform-specific function to build query params for each identify call. + * Browser returns `[{ key: 'auth', value: credential }]` + optional hash. + * Mobile returns `[]` (uses Authorization header instead). + */ + buildQueryParams: (identifyOptions?: LDIdentifyOptions) => { key: string; value: string }[]; + + /** + * FDv1 endpoint factory for fallback. When provided, a blocked FDv1 + * polling synchronizer slot is automatically appended to every data + * source. It is activated when an FDv2 response includes the + * `x-ld-fd-fallback` header. + * + * Browser: `browserFdv1Endpoints(clientSideId)` + * Mobile: `mobileFdv1Endpoints()` + */ + fdv1Endpoints?: DataSourceEndpoints; + + /** Fallback condition timeout in ms (default 120s). */ + fallbackTimeoutMs?: number; + /** Recovery condition timeout in ms (default 300s). */ + recoveryTimeoutMs?: number; +} + +/** + * The public interface returned by {@link createFDv2DataManagerBase}. + * Extends {@link DataManager} with mode control methods. + */ +export interface FDv2DataManagerControl extends DataManager { + /** Update the pending network state. Goes through debounce. */ + setNetworkState(state: NetworkState): void; + /** Update the pending lifecycle state. Goes through debounce. */ + setLifecycleState(state: LifecycleState): void; + /** Update the requested connection mode. Goes through debounce. */ + setRequestedMode(mode: FDv2ConnectionMode): void; + /** + * Set the effective foreground mode directly. Used by browser + * listener-driven streaming to promote/demote the foreground mode. + * Goes through debounce. + */ + setForegroundMode(mode: FDv2ConnectionMode): void; + /** Get the currently resolved connection mode. */ + getCurrentMode(): FDv2ConnectionMode; + /** The configured default foreground mode (from config, not auto-promoted). */ + readonly configuredForegroundMode: FDv2ConnectionMode; +} + +/** + * Creates a shared FDv2 data manager that owns mode resolution, debouncing, + * selector state, and FDv2DataSource lifecycle. Platform SDKs (browser, RN) + * wrap this with platform-specific config and event wiring. + */ +export function createFDv2DataManagerBase( + baseConfig: FDv2DataManagerBaseConfig, +): FDv2DataManagerControl { + const { + platform, + flagManager, + config, + baseHeaders, + emitter, + transitionTable, + initialForegroundMode, + backgroundMode, + modeTable, + sourceFactoryProvider, + buildQueryParams, + fdv1Endpoints, + fallbackTimeoutMs, + recoveryTimeoutMs, + } = baseConfig; + + const { logger } = config; + const statusManager: DataSourceStatusManager = createDataSourceStatusManager(emitter); + const endpoints = fdv2Endpoints(); + + // --- Mutable state --- + let selector: string | undefined; + let currentResolvedMode: FDv2ConnectionMode = initialForegroundMode; + let foregroundMode: FDv2ConnectionMode = initialForegroundMode; + let dataSource: FDv2DataSource | undefined; + let debounceManager: StateDebounceManager | undefined; + let identifiedContext: Context | undefined; + let factoryContext: SourceFactoryContext | undefined; + let initialized = false; + let closed = false; + + // Outstanding identify promise callbacks — needed so that mode switches + // during identify can wire the new data source's completion to the + // original identify promise. + let pendingIdentifyResolve: (() => void) | undefined; + let pendingIdentifyReject: ((err: Error) => void) | undefined; + + // Current debounce input state. + let networkState: NetworkState = 'available'; + let lifecycleState: LifecycleState = 'foreground'; + + // --- Helpers --- + + function getModeDefinition(mode: FDv2ConnectionMode): ModeDefinition { + return modeTable[mode]; + } + + function buildModeState(): ModeState { + return { + lifecycle: lifecycleState, + networkAvailable: networkState === 'available', + foregroundMode, + backgroundMode: backgroundMode ?? 'offline', + }; + } + + function resolveMode(): FDv2ConnectionMode { + return resolveConnectionMode(transitionTable, buildModeState()); + } + + /** + * Convert a ModeDefinition's entries into concrete InitializerFactory[] + * and SynchronizerSlot[] using the source factory provider. + */ + function buildFactories( + modeDef: ModeDefinition, + ctx: SourceFactoryContext, + includeInitializers: boolean, + ): { + initializerFactories: InitializerFactory[]; + synchronizerSlots: SynchronizerSlot[]; + } { + const initializerFactories: InitializerFactory[] = includeInitializers + ? modeDef.initializers + .map((entry) => sourceFactoryProvider.createInitializerFactory(entry, ctx)) + .filter((f): f is InitializerFactory => f !== undefined) + : []; + + const synchronizerSlots: SynchronizerSlot[] = modeDef.synchronizers + .map((entry) => sourceFactoryProvider.createSynchronizerSlot(entry, ctx)) + .filter((s): s is SynchronizerSlot => s !== undefined); + + // Append a blocked FDv1 fallback synchronizer when configured and + // when there are FDv2 synchronizers to fall back from. + if (fdv1Endpoints && synchronizerSlots.length > 0) { + const fdv1RequestorFactory = () => + makeRequestor( + ctx.plainContextString, + ctx.serviceEndpoints, + fdv1Endpoints.polling(), + ctx.requests, + ctx.encoding, + ctx.baseHeaders, + ctx.queryParams, + config.withReasons, + config.useReport, + ); + + const fdv1SyncFactory = () => + createFDv1PollingSynchronizer(fdv1RequestorFactory(), config.pollInterval * 1000, logger); + + synchronizerSlots.push(createSynchronizerSlot(fdv1SyncFactory, { isFDv1Fallback: true })); + } + + return { initializerFactories, synchronizerSlots }; + } + + /** + * The data callback shared across all FDv2DataSource instances for + * the current identify. Handles selector tracking and flag updates. + */ + function dataCallback(payload: internal.Payload): void { + logger.debug( + `${logTag} dataCallback: type=${payload.type}, updates=${payload.updates.length}, state=${payload.state}`, + ); + + if (payload.state) { + selector = payload.state; + } + + if (payload.type === 'none') { + return; + } + + const context = identifiedContext; + if (!context) { + logger.warn(`${logTag} dataCallback called without an identified context.`); + return; + } + + const descriptors = flagEvalPayloadToItemDescriptors(payload.updates); + + if (payload.type === 'full') { + flagManager.init(context, descriptors); + } else { + Object.entries(descriptors).forEach(([key, descriptor]) => { + flagManager.upsert(context, key, descriptor); + }); + } + } + + /** + * Create and start a new FDv2DataSource for the given mode. + * + * @param mode The connection mode to use. + * @param includeInitializers Whether to include initializers (true on + * first identify, false on mode switch after initialization). + */ + function createAndStartDataSource(mode: FDv2ConnectionMode, includeInitializers: boolean): void { + if (!factoryContext) { + logger.warn(`${logTag} Cannot create data source without factory context.`); + return; + } + + const modeDef = getModeDefinition(mode); + const { initializerFactories, synchronizerSlots } = buildFactories( + modeDef, + factoryContext, + includeInitializers, + ); + + // If there are no sources at all (e.g., offline mode), don't create + // a data source — just mark as initialized if we have data. + if (initializerFactories.length === 0 && synchronizerSlots.length === 0) { + logger.debug(`${logTag} Mode '${mode}' has no sources. No data source created.`); + if (!initialized && pendingIdentifyResolve) { + // Offline mode during initial identify — resolve immediately. + // The SDK will use cached data if any. + initialized = true; + pendingIdentifyResolve(); + pendingIdentifyResolve = undefined; + pendingIdentifyReject = undefined; + } + return; + } + + const selectorGetter = () => selector; + + dataSource = createFDv2DataSource({ + initializerFactories, + synchronizerSlots, + dataCallback, + statusManager, + selectorGetter, + logger, + fallbackTimeoutMs, + recoveryTimeoutMs, + }); + + dataSource + .start() + .then(() => { + initialized = true; + if (pendingIdentifyResolve) { + pendingIdentifyResolve(); + pendingIdentifyResolve = undefined; + pendingIdentifyReject = undefined; + } + }) + .catch((err) => { + if (pendingIdentifyReject) { + pendingIdentifyReject(err instanceof Error ? err : new Error(String(err))); + pendingIdentifyResolve = undefined; + pendingIdentifyReject = undefined; + } + }); + + currentResolvedMode = mode; + } + + /** + * Reconciliation callback invoked when the debounce timer fires. + * Resolves the new mode and switches data sources if needed. + */ + function onReconcile(pendingState: PendingState): void { + if (closed || !factoryContext) { + return; + } + + // Update local state from the debounced pending state. + networkState = pendingState.networkState; + lifecycleState = pendingState.lifecycleState; + foregroundMode = pendingState.requestedMode; + + const newMode = resolveMode(); + + if (newMode === currentResolvedMode) { + logger.debug(`${logTag} Reconcile: mode unchanged (${newMode}). No action.`); + return; + } + + logger.debug( + `${logTag} Reconcile: mode switching from '${currentResolvedMode}' to '${newMode}'.`, + ); + + // Close the current data source. + dataSource?.close(); + dataSource = undefined; + + // After initialization, only synchronizers change (spec 5.3.8). + const includeInitializers = !initialized; + + createAndStartDataSource(newMode, includeInitializers); + } + + // --- Public interface --- + + return { + get configuredForegroundMode(): FDv2ConnectionMode { + return initialForegroundMode; + }, + + async identify( + identifyResolve: () => void, + identifyReject: (err: Error) => void, + context: Context, + identifyOptions?: LDIdentifyOptions, + ): Promise { + if (closed) { + logger.debug(`${logTag} Identify called after close.`); + return; + } + + // Tear down previous state. + dataSource?.close(); + dataSource = undefined; + debounceManager?.close(); + debounceManager = undefined; + selector = undefined; + initialized = false; + identifiedContext = context; + pendingIdentifyResolve = identifyResolve; + pendingIdentifyReject = identifyReject; + + const plainContextString = JSON.stringify(Context.toLDContext(context)); + const queryParams = buildQueryParams(identifyOptions); + const streamingEndpoints = endpoints.streaming(); + const pollingEndpoints = endpoints.polling(); + + const requestor = makeFDv2Requestor( + plainContextString, + config.serviceEndpoints, + pollingEndpoints, + platform.requests, + platform.encoding!, + baseHeaders, + queryParams, + ); + + const environmentNamespace = await namespaceForEnvironment( + platform.crypto, + baseConfig.credential, + ); + + factoryContext = { + requestor, + requests: platform.requests, + encoding: platform.encoding!, + serviceEndpoints: config.serviceEndpoints, + streamingPaths: streamingEndpoints, + baseHeaders, + queryParams, + plainContextString, + selectorGetter: () => selector, + streamInitialReconnectDelay: config.streamInitialReconnectDelay, + pollInterval: config.pollInterval, + logger, + storage: platform.storage, + crypto: platform.crypto, + environmentNamespace, + context, + }; + + // Resolve the initial mode. + const mode = resolveMode(); + logger.debug(`${logTag} Identify: initial mode resolved to '${mode}'.`); + + // Create and start the data source with full pipeline. + createAndStartDataSource(mode, true); + + // Set up debouncing for subsequent state changes. + debounceManager = createStateDebounceManager({ + initialState: { + networkState, + lifecycleState, + requestedMode: foregroundMode, + }, + onReconcile, + }); + }, + + close(): void { + closed = true; + dataSource?.close(); + dataSource = undefined; + debounceManager?.close(); + debounceManager = undefined; + pendingIdentifyResolve = undefined; + pendingIdentifyReject = undefined; + }, + + setNetworkState(state: NetworkState): void { + networkState = state; + debounceManager?.setNetworkState(state); + }, + + setLifecycleState(state: LifecycleState): void { + lifecycleState = state; + debounceManager?.setLifecycleState(state); + }, + + setRequestedMode(mode: FDv2ConnectionMode): void { + foregroundMode = mode; + debounceManager?.setRequestedMode(mode); + }, + + setForegroundMode(mode: FDv2ConnectionMode): void { + foregroundMode = mode; + debounceManager?.setRequestedMode(mode); + }, + + getCurrentMode(): FDv2ConnectionMode { + return currentResolvedMode; + }, + }; +} diff --git a/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts b/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts new file mode 100644 index 0000000000..89ccabf1e9 --- /dev/null +++ b/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts @@ -0,0 +1,185 @@ +import { + Context, + Crypto, + Encoding, + LDHeaders, + LDLogger, + Requests, + ServiceEndpoints, + Storage, +} from '@launchdarkly/js-sdk-common'; + +import { DataSourceEntry } from '../api/datasource'; +import { DataSourcePaths } from './DataSourceConfig'; +import { createCacheInitializerFactory } from './fdv2/CacheInitializer'; +import { FDv2Requestor } from './fdv2/FDv2Requestor'; +import { poll as fdv2Poll } from './fdv2/PollingBase'; +import { createPollingInitializer } from './fdv2/PollingInitializer'; +import { createPollingSynchronizer } from './fdv2/PollingSynchronizer'; +import { createSynchronizerSlot, InitializerFactory, SynchronizerSlot } from './fdv2/SourceManager'; +import { createStreamingBase, PingHandler } from './fdv2/StreamingFDv2Base'; +import { createStreamingInitializer } from './fdv2/StreamingInitializerFDv2'; +import { createStreamingSynchronizer } from './fdv2/StreamingSynchronizerFDv2'; + +/** + * Context needed to create concrete initializer/synchronizer factories + * for a given identify call. Built once per identify and reused across + * mode switches. + */ +export interface SourceFactoryContext { + /** The FDv2 requestor for polling requests. */ + requestor: FDv2Requestor; + /** Platform request abstraction. */ + requests: Requests; + /** Platform encoding abstraction. */ + encoding: Encoding; + /** Service endpoint configuration. */ + serviceEndpoints: ServiceEndpoints; + /** The streaming endpoint paths. */ + streamingPaths: DataSourcePaths; + /** Default HTTP headers. */ + baseHeaders: LDHeaders; + /** Query parameters for requests (e.g., auth, secure mode hash). */ + queryParams: { key: string; value: string }[]; + /** JSON-serialized evaluation context. */ + plainContextString: string; + /** Getter for the current selector (basis) string. */ + selectorGetter: () => string | undefined; + /** Initial reconnect delay for streaming, in seconds. */ + streamInitialReconnectDelay: number; + /** Poll interval in seconds. */ + pollInterval: number; + /** Logger. */ + logger: LDLogger; + + // Cache-related fields (needed for cache initializer). + /** Platform storage for reading cached data. */ + storage: Storage | undefined; + /** Platform crypto for computing storage keys. */ + crypto: Crypto; + /** Environment namespace (hashed SDK key). */ + environmentNamespace: string; + /** The context being identified. */ + context: Context; +} + +/** + * Converts declarative {@link DataSourceEntry} descriptors from the mode + * table into concrete {@link InitializerFactory} and {@link SynchronizerSlot} + * instances that the {@link FDv2DataSource} orchestrator can use. + */ +export interface SourceFactoryProvider { + /** + * Create an initializer factory from a data source entry descriptor. + * Returns `undefined` if the entry type is not supported as an initializer. + */ + createInitializerFactory( + entry: DataSourceEntry, + ctx: SourceFactoryContext, + ): InitializerFactory | undefined; + + /** + * Create a synchronizer slot from a data source entry descriptor. + * Returns `undefined` if the entry type is not supported as a synchronizer. + */ + createSynchronizerSlot( + entry: DataSourceEntry, + ctx: SourceFactoryContext, + ): SynchronizerSlot | undefined; +} + +function createPingHandler(ctx: SourceFactoryContext): PingHandler { + return { + handlePing: () => fdv2Poll(ctx.requestor, ctx.selectorGetter(), false, ctx.logger), + }; +} + +/** + * Creates a {@link SourceFactoryProvider} that handles `cache`, `polling`, + * and `streaming` data source entries. + */ +export function createDefaultSourceFactoryProvider(): SourceFactoryProvider { + return { + createInitializerFactory( + entry: DataSourceEntry, + ctx: SourceFactoryContext, + ): InitializerFactory | undefined { + switch (entry.type) { + case 'polling': + return (sg: () => string | undefined) => + createPollingInitializer(ctx.requestor, ctx.logger, sg); + + case 'streaming': + return (sg: () => string | undefined) => { + const streamUriPath = ctx.streamingPaths.pathGet(ctx.encoding, ctx.plainContextString); + const base = createStreamingBase({ + requests: ctx.requests, + serviceEndpoints: ctx.serviceEndpoints, + streamUriPath, + parameters: ctx.queryParams, + selectorGetter: sg, + headers: ctx.baseHeaders, + initialRetryDelayMillis: + (entry.initialReconnectDelay ?? ctx.streamInitialReconnectDelay) * 1000, + logger: ctx.logger, + pingHandler: createPingHandler(ctx), + }); + return createStreamingInitializer(base); + }; + + case 'cache': + return createCacheInitializerFactory({ + storage: ctx.storage, + crypto: ctx.crypto, + environmentNamespace: ctx.environmentNamespace, + context: ctx.context, + logger: ctx.logger, + }); + + default: + return undefined; + } + }, + + createSynchronizerSlot( + entry: DataSourceEntry, + ctx: SourceFactoryContext, + ): SynchronizerSlot | undefined { + switch (entry.type) { + case 'polling': { + const intervalMs = (entry.pollInterval ?? ctx.pollInterval) * 1000; + const factory = (sg: () => string | undefined) => + createPollingSynchronizer(ctx.requestor, ctx.logger, sg, intervalMs); + return createSynchronizerSlot(factory); + } + + case 'streaming': { + const factory = (sg: () => string | undefined) => { + const streamUriPath = ctx.streamingPaths.pathGet(ctx.encoding, ctx.plainContextString); + const base = createStreamingBase({ + requests: ctx.requests, + serviceEndpoints: ctx.serviceEndpoints, + streamUriPath, + parameters: ctx.queryParams, + selectorGetter: sg, + headers: ctx.baseHeaders, + initialRetryDelayMillis: + (entry.initialReconnectDelay ?? ctx.streamInitialReconnectDelay) * 1000, + logger: ctx.logger, + pingHandler: createPingHandler(ctx), + }); + return createStreamingSynchronizer(base); + }; + return createSynchronizerSlot(factory); + } + + case 'cache': + // Cache synchronizer doesn't make sense. + return undefined; + + default: + return undefined; + } + }, + }; +} diff --git a/packages/shared/sdk-client/src/datasource/fdv2/PollingBase.ts b/packages/shared/sdk-client/src/datasource/fdv2/PollingBase.ts index 7b8a330573..cb133dfe6e 100644 --- a/packages/shared/sdk-client/src/datasource/fdv2/PollingBase.ts +++ b/packages/shared/sdk-client/src/datasource/fdv2/PollingBase.ts @@ -41,7 +41,7 @@ function processEvents( const handler = internal.createProtocolHandler( { 'flag-eval': processFlagEval, - flag_eval: processFlagEval, + flag_eval: processFlagEval, }, logger, ); diff --git a/packages/shared/sdk-client/src/datasource/fdv2/StreamingFDv2Base.ts b/packages/shared/sdk-client/src/datasource/fdv2/StreamingFDv2Base.ts index 8d798aa47f..2ce4b896db 100644 --- a/packages/shared/sdk-client/src/datasource/fdv2/StreamingFDv2Base.ts +++ b/packages/shared/sdk-client/src/datasource/fdv2/StreamingFDv2Base.ts @@ -1,4 +1,5 @@ import { + DataSourceErrorKind, EventSource, getStreamingUri, httpErrorMessage, @@ -9,7 +10,6 @@ import { Requests, ServiceEndpoints, shouldRetry, - DataSourceErrorKind, } from '@launchdarkly/js-sdk-common'; import { processFlagEval } from '../flagEvalMapper'; @@ -282,15 +282,20 @@ export function createStreamingBase(config: { }; es.onerror = (err?: HttpErrorResponse) => { - if(err && typeof err.status === 'number') { + if (err && typeof err.status === 'number') { // This condition will be handled by the error filter. return; } - resultQueue.put(interrupted({ - kind: DataSourceErrorKind.NetworkError, - message: err?.message ?? 'IO Error', - time: Date.now(), - }, fdv1Fallback)); + resultQueue.put( + interrupted( + { + kind: DataSourceErrorKind.NetworkError, + message: err?.message ?? 'IO Error', + time: Date.now(), + }, + fdv1Fallback, + ), + ); }; es.onopen = () => { diff --git a/packages/shared/sdk-client/src/index.ts b/packages/shared/sdk-client/src/index.ts index c293a36cba..cfa848a3d8 100644 --- a/packages/shared/sdk-client/src/index.ts +++ b/packages/shared/sdk-client/src/index.ts @@ -105,6 +105,8 @@ export type { InitializerFactory, SynchronizerSlot } from './datasource/fdv2/Sou export { createSynchronizerSlot } from './datasource/fdv2/SourceManager'; export { makeFDv2Requestor } from './datasource/fdv2/FDv2Requestor'; export type { FDv2Requestor } from './datasource/fdv2/FDv2Requestor'; +export { createCacheInitializerFactory } from './datasource/fdv2/CacheInitializer'; +export type { CacheInitializerConfig } from './datasource/fdv2/CacheInitializer'; export { createPollingInitializer } from './datasource/fdv2/PollingInitializer'; export { createPollingSynchronizer } from './datasource/fdv2/PollingSynchronizer'; export type { PingHandler, StreamingFDv2Base } from './datasource/fdv2/StreamingFDv2Base'; @@ -126,9 +128,32 @@ export { // FDv2 connection mode type system — internal implementation. export type { ModeTable } from './datasource/ConnectionModeConfig'; +export { MODE_TABLE } from './datasource/ConnectionModeConfig'; export { resolveConnectionMode, MOBILE_TRANSITION_TABLE, BROWSER_TRANSITION_TABLE, DESKTOP_TRANSITION_TABLE, } from './datasource/ModeResolver'; + +// FDv2 shared data manager — mode switching, debouncing, and data source lifecycle. +export type { + FDv2DataManagerBaseConfig, + FDv2DataManagerControl, +} from './datasource/FDv2DataManagerBase'; +export { createFDv2DataManagerBase } from './datasource/FDv2DataManagerBase'; +export type { + SourceFactoryContext, + SourceFactoryProvider, +} from './datasource/SourceFactoryProvider'; +export { createDefaultSourceFactoryProvider } from './datasource/SourceFactoryProvider'; + +// State debounce manager. +export type { + StateDebounceManager, + StateDebounceManagerConfig, + NetworkState, + PendingState, + ReconciliationCallback, +} from './datasource/StateDebounceManager'; +export { createStateDebounceManager } from './datasource/StateDebounceManager'; From e2b7d156ae5352063ba1b092f69c742ea0d3e638 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:39:04 -0700 Subject: [PATCH 10/28] Streaming control interface --- packages/sdk/browser/example/src/app.ts | 2 +- packages/sdk/browser/src/BrowserClient.ts | 24 +++++++++++++---------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/packages/sdk/browser/example/src/app.ts b/packages/sdk/browser/example/src/app.ts index e80e0295d2..36cf6fcd64 100644 --- a/packages/sdk/browser/example/src/app.ts +++ b/packages/sdk/browser/example/src/app.ts @@ -27,7 +27,7 @@ const main = async () => { const ldclient = createClient(clientSideID, context, { // @ts-ignore dataSystem is @internal — experimental FDv2 opt-in dataSystem: {}, - logger: basicLogger({level: 'debug'}), + logger: basicLogger({ level: 'debug' }), }); const render = () => { const flagValue = ldclient.variation(flagKey, false); diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index 0250439922..32e74470d1 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -35,6 +35,15 @@ import validateBrowserOptions, { BrowserOptions, filterToBaseOptionsWithDefaults import BrowserPlatform from './platform/BrowserPlatform'; import { getAllStorageKeys } from './platform/LocalStorage'; +interface StreamingControl { + setForcedStreaming(streaming?: boolean): void; + setAutomaticStreamingState(streaming: boolean): void; +} + +function hasStreamingControl(dm: unknown): dm is StreamingControl { + return dm instanceof BrowserDataManager || dm instanceof BrowserFDv2DataManager; +} + class BrowserClientImpl extends LDClientImpl { private readonly _goalManager?: GoalManager; private readonly _plugins?: LDPlugin[]; @@ -286,21 +295,16 @@ class BrowserClientImpl extends LDClientImpl { } setStreaming(streaming?: boolean): void { - if (this.dataManager instanceof BrowserDataManager) { - this.dataManager.setForcedStreaming(streaming); - } else if (this.dataManager instanceof BrowserFDv2DataManager) { + if (hasStreamingControl(this.dataManager)) { this.dataManager.setForcedStreaming(streaming); } } private _updateAutomaticStreamingState() { - const hasListeners = this.emitter - .eventNames() - .some((name) => name.startsWith('change:') || name === 'change'); - - if (this.dataManager instanceof BrowserDataManager) { - this.dataManager.setAutomaticStreamingState(hasListeners); - } else if (this.dataManager instanceof BrowserFDv2DataManager) { + if (hasStreamingControl(this.dataManager)) { + const hasListeners = this.emitter + .eventNames() + .some((name) => name.startsWith('change:') || name === 'change'); this.dataManager.setAutomaticStreamingState(hasListeners); } } From 9d242d449be27decdf2d34cc50614fc03dc0bcb6 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:05:00 -0700 Subject: [PATCH 11/28] Skip cache when bootstrap is available. --- .../src/datasource/FDv2DataManagerBase.ts | 39 +++++++++++++++++-- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts index 8e247e97f5..816cf3a153 100644 --- a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts +++ b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts @@ -139,6 +139,7 @@ export function createFDv2DataManagerBase( let identifiedContext: Context | undefined; let factoryContext: SourceFactoryContext | undefined; let initialized = false; + let bootstrapped = false; let closed = false; // Outstanding identify promise callbacks — needed so that mode switches @@ -184,6 +185,7 @@ export function createFDv2DataManagerBase( } { const initializerFactories: InitializerFactory[] = includeInitializers ? modeDef.initializers + .filter((entry) => !(bootstrapped && entry.type === 'cache')) .map((entry) => sourceFactoryProvider.createInitializerFactory(entry, ctx)) .filter((f): f is InitializerFactory => f !== undefined) : []; @@ -349,8 +351,12 @@ export function createFDv2DataManagerBase( dataSource?.close(); dataSource = undefined; - // After initialization, only synchronizers change (spec 5.3.8). - const includeInitializers = !initialized; + // Include initializers if we don't have a selector yet. This covers: + // - Not yet initialized (normal case) + // - Initialized from bootstrap (no selector) — need initializers to + // get a full payload via poll before starting synchronizers + // When we have a selector, only synchronizers change (spec 5.3.8). + const includeInitializers = !selector; createAndStartDataSource(newMode, includeInitializers); } @@ -380,6 +386,7 @@ export function createFDv2DataManagerBase( debounceManager = undefined; selector = undefined; initialized = false; + bootstrapped = false; identifiedContext = context; pendingIdentifyResolve = identifyResolve; pendingIdentifyReject = identifyReject; @@ -427,8 +434,32 @@ export function createFDv2DataManagerBase( const mode = resolveMode(); logger.debug(`${logTag} Identify: initial mode resolved to '${mode}'.`); - // Create and start the data source with full pipeline. - createAndStartDataSource(mode, true); + bootstrapped = !!(identifyOptions as any)?.bootstrap; + + if (bootstrapped) { + // Bootstrap data was already applied to the flag store by the + // caller (BrowserClient.start → presetFlags) before identify + // was called. Resolve immediately — flag evaluations will use + // the bootstrap data synchronously. + initialized = true; + // selector remains undefined — bootstrap data has no selector. + pendingIdentifyResolve?.(); + pendingIdentifyResolve = undefined; + pendingIdentifyReject = undefined; + + // Only create a data source if the mode has synchronizers. + // For one-shot (no synchronizers), there's nothing more to do. + const modeDef = getModeDefinition(mode); + if (modeDef.synchronizers.length > 0) { + // Start synchronizers without initializers — we already have + // data from bootstrap. Initializers will run on mode switches + // if selector is still undefined (see onReconcile). + createAndStartDataSource(mode, false); + } + } else { + // Normal identify — create and start the data source with full pipeline. + createAndStartDataSource(mode, true); + } // Set up debouncing for subsequent state changes. debounceManager = createStateDebounceManager({ From cee85eeb102a6d84350688fe36e0334c945408a8 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:40:47 -0700 Subject: [PATCH 12/28] Add event flush on backgrounding. --- packages/sdk/browser/src/BrowserClient.ts | 4 ++++ .../sdk/browser/src/BrowserFDv2DataManager.ts | 4 ++++ .../src/datasource/FDv2DataManagerBase.ts | 15 +++++++++++++++ 3 files changed, 23 insertions(+) diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index 32e74470d1..abfad3a8ae 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -143,6 +143,10 @@ class BrowserClientImpl extends LDClientImpl { this.setEventSendingEnabled(true, false); + if (this.dataManager instanceof BrowserFDv2DataManager) { + this.dataManager.setFlushCallback(() => this.flush()); + } + this._plugins = validatedBrowserOptions.plugins; if (validatedBrowserOptions.fetchGoals) { diff --git a/packages/sdk/browser/src/BrowserFDv2DataManager.ts b/packages/sdk/browser/src/BrowserFDv2DataManager.ts index 11b45c9b7d..c11b62f498 100644 --- a/packages/sdk/browser/src/BrowserFDv2DataManager.ts +++ b/packages/sdk/browser/src/BrowserFDv2DataManager.ts @@ -95,6 +95,10 @@ export default class BrowserFDv2DataManager implements DataManager { this._base.close(); } + setFlushCallback(callback: () => void): void { + this._base.setFlushCallback(callback); + } + setForcedStreaming(streaming?: boolean): void { this._forcedStreaming = streaming; this._updateStreamingState(); diff --git a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts index 816cf3a153..181b50d397 100644 --- a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts +++ b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts @@ -99,6 +99,11 @@ export interface FDv2DataManagerControl extends DataManager { getCurrentMode(): FDv2ConnectionMode; /** The configured default foreground mode (from config, not auto-promoted). */ readonly configuredForegroundMode: FDv2ConnectionMode; + /** + * Set a callback to flush pending analytics events. Called immediately + * (not debounced) when the lifecycle transitions to background. + */ + setFlushCallback(callback: () => void): void; } /** @@ -141,6 +146,7 @@ export function createFDv2DataManagerBase( let initialized = false; let bootstrapped = false; let closed = false; + let flushCallback: (() => void) | undefined; // Outstanding identify promise callbacks — needed so that mode switches // during identify can wire the new data source's completion to the @@ -488,6 +494,11 @@ export function createFDv2DataManagerBase( }, setLifecycleState(state: LifecycleState): void { + // Flush immediately when going to background — the app may be + // about to close. This is not debounced (CONNMODE spec 3.3.1). + if (state === 'background' && lifecycleState !== 'background') { + flushCallback?.(); + } lifecycleState = state; debounceManager?.setLifecycleState(state); }, @@ -505,5 +516,9 @@ export function createFDv2DataManagerBase( getCurrentMode(): FDv2ConnectionMode { return currentResolvedMode; }, + + setFlushCallback(callback: () => void): void { + flushCallback = callback; + }, }; } From 0d92a7836a74b3c0d0a096b316b0bdfac30e3e5a Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Thu, 12 Mar 2026 11:52:59 -0400 Subject: [PATCH 13/28] remove flag_eval support; use flag-eval only --- .../shared/sdk-client/src/datasource/fdv2/PollingBase.ts | 5 +---- .../sdk-client/src/datasource/fdv2/StreamingFDv2Base.ts | 2 +- packages/shared/sdk-client/src/datasource/flagEvalMapper.ts | 6 +++--- packages/shared/sdk-client/src/types/index.ts | 4 ++-- 4 files changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/shared/sdk-client/src/datasource/fdv2/PollingBase.ts b/packages/shared/sdk-client/src/datasource/fdv2/PollingBase.ts index cb133dfe6e..4afab0923f 100644 --- a/packages/shared/sdk-client/src/datasource/fdv2/PollingBase.ts +++ b/packages/shared/sdk-client/src/datasource/fdv2/PollingBase.ts @@ -39,10 +39,7 @@ function processEvents( logger?: LDLogger, ): FDv2SourceResult { const handler = internal.createProtocolHandler( - { - 'flag-eval': processFlagEval, - flag_eval: processFlagEval, - }, + { 'flag-eval': processFlagEval }, logger, ); diff --git a/packages/shared/sdk-client/src/datasource/fdv2/StreamingFDv2Base.ts b/packages/shared/sdk-client/src/datasource/fdv2/StreamingFDv2Base.ts index 2ce4b896db..cc39ab7502 100644 --- a/packages/shared/sdk-client/src/datasource/fdv2/StreamingFDv2Base.ts +++ b/packages/shared/sdk-client/src/datasource/fdv2/StreamingFDv2Base.ts @@ -92,7 +92,7 @@ export function createStreamingBase(config: { }): StreamingFDv2Base { const resultQueue = createAsyncQueue(); const protocolHandler = internal.createProtocolHandler( - { 'flag-eval': processFlagEval, flag_eval: processFlagEval }, + { 'flag-eval': processFlagEval }, config.logger, ); diff --git a/packages/shared/sdk-client/src/datasource/flagEvalMapper.ts b/packages/shared/sdk-client/src/datasource/flagEvalMapper.ts index 500f4148c3..6fa5335579 100644 --- a/packages/shared/sdk-client/src/datasource/flagEvalMapper.ts +++ b/packages/shared/sdk-client/src/datasource/flagEvalMapper.ts @@ -4,7 +4,7 @@ import { ItemDescriptor } from '../flag-manager/ItemDescriptor'; import { FlagEvaluationResult } from '../types'; /** - * ObjProcessor for the `flag_eval` object kind. Used by the protocol handler to + * ObjProcessor for the `flag-eval` object kind. Used by the protocol handler to * process objects received in `put-object` events. * * Client-side evaluation results are already in their final form (pre-evaluated @@ -49,7 +49,7 @@ export function flagEvalUpdateToItemDescriptor(update: internal.Update): ItemDes /** * Converts an array of FDv2 payload updates into a map of flag key to - * {@link ItemDescriptor}. Only `flag_eval` kind updates are processed; + * {@link ItemDescriptor}. Only `flag-eval` kind updates are processed; * unrecognized kinds are silently ignored. */ export function flagEvalPayloadToItemDescriptors(updates: internal.Update[]): { @@ -58,7 +58,7 @@ export function flagEvalPayloadToItemDescriptors(updates: internal.Update[]): { const descriptors: { [key: string]: ItemDescriptor } = {}; updates.forEach((update) => { - if (update.kind === 'flag-eval' || update.kind === 'flag_eval') { + if (update.kind === 'flag-eval') { descriptors[update.key] = flagEvalUpdateToItemDescriptor(update); } }); diff --git a/packages/shared/sdk-client/src/types/index.ts b/packages/shared/sdk-client/src/types/index.ts index 9ffe3dbe31..19be292b8b 100644 --- a/packages/shared/sdk-client/src/types/index.ts +++ b/packages/shared/sdk-client/src/types/index.ts @@ -21,10 +21,10 @@ export type DeleteFlag = Pick; /** * Represents a pre-evaluated flag result for a specific context, as delivered - * by the FDv2 protocol via `put-object` events with `kind: 'flag_eval'`. + * by the FDv2 protocol via `put-object` events with `kind: 'flag-eval'`. * * This is the shape of the `object` field in a `put-object` event with - * `kind: 'flag_eval'`. It contains all the same fields as {@link Flag} except + * `kind: 'flag-eval'`. It contains all the same fields as {@link Flag} except * `version`, which is provided separately in the `put-object` envelope. * * There is no aggregate payload-level version field; per-flag versioning is From cfc1207b0cad6898f4e6be87992a165a501e400c Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 12 Mar 2026 10:46:05 -0700 Subject: [PATCH 14/28] Remove debug logging from FDv2DataSource. --- .../src/datasource/fdv2/FDv2DataSource.ts | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts b/packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts index fe8d0871e0..6585163fc3 100644 --- a/packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts +++ b/packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts @@ -201,16 +201,9 @@ export function createFDv2DataSource(config: FDv2DataSourceConfig): FDv2DataSour return; } - const isPrime = sourceManager.isPrimeSynchronizer(); - const availableCount = sourceManager.getAvailableSynchronizerCount(); - logger?.warn( - `Synchronizer started: isPrime=${isPrime}, availableCount=${availableCount}, ` + - `fallbackMs=${fallbackTimeoutMs}, recoveryMs=${recoveryTimeoutMs}`, - ); - const conditions: ConditionGroup = getConditions( - availableCount, - isPrime, + sourceManager.getAvailableSynchronizerCount(), + sourceManager.isPrimeSynchronizer(), fallbackTimeoutMs, recoveryTimeoutMs, ); @@ -245,7 +238,7 @@ export function createFDv2DataSource(config: FDv2DataSourceConfig): FDv2DataSour if (conditionType === 'fallback') { logger?.warn('Fallback condition fired, moving to next synchronizer.'); } else if (conditionType === 'recovery') { - logger?.warn('Recovery condition fired, resetting to primary synchronizer.'); + logger?.info('Recovery condition fired, resetting to primary synchronizer.'); sourceManager.resetSourceIndex(); } From ea3eb1d01c7a6b1f09d08a029028259f7aaeba9d Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:22:32 -0700 Subject: [PATCH 15/28] Commonize stream control input between browser and RN for FDv2. --- packages/sdk/browser/src/BrowserClient.ts | 27 +++++-------------- .../datasource/fdv2/FDv2DataSource.test.ts | 2 +- packages/shared/sdk-client/src/DataManager.ts | 27 +++++++++++++++++++ 3 files changed, 34 insertions(+), 22 deletions(-) diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index abfad3a8ae..620d4f8f1c 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -35,15 +35,6 @@ import validateBrowserOptions, { BrowserOptions, filterToBaseOptionsWithDefaults import BrowserPlatform from './platform/BrowserPlatform'; import { getAllStorageKeys } from './platform/LocalStorage'; -interface StreamingControl { - setForcedStreaming(streaming?: boolean): void; - setAutomaticStreamingState(streaming: boolean): void; -} - -function hasStreamingControl(dm: unknown): dm is StreamingControl { - return dm instanceof BrowserDataManager || dm instanceof BrowserFDv2DataManager; -} - class BrowserClientImpl extends LDClientImpl { private readonly _goalManager?: GoalManager; private readonly _plugins?: LDPlugin[]; @@ -143,9 +134,7 @@ class BrowserClientImpl extends LDClientImpl { this.setEventSendingEnabled(true, false); - if (this.dataManager instanceof BrowserFDv2DataManager) { - this.dataManager.setFlushCallback(() => this.flush()); - } + this.dataManager.setFlushCallback?.(() => this.flush()); this._plugins = validatedBrowserOptions.plugins; @@ -299,18 +288,14 @@ class BrowserClientImpl extends LDClientImpl { } setStreaming(streaming?: boolean): void { - if (hasStreamingControl(this.dataManager)) { - this.dataManager.setForcedStreaming(streaming); - } + this.dataManager.setForcedStreaming?.(streaming); } private _updateAutomaticStreamingState() { - if (hasStreamingControl(this.dataManager)) { - const hasListeners = this.emitter - .eventNames() - .some((name) => name.startsWith('change:') || name === 'change'); - this.dataManager.setAutomaticStreamingState(hasListeners); - } + const hasListeners = this.emitter + .eventNames() + .some((name) => name.startsWith('change:') || name === 'change'); + this.dataManager.setAutomaticStreamingState?.(hasListeners); } override on(eventName: LDEmitterEventName, listener: Function): void { diff --git a/packages/shared/sdk-client/__tests__/datasource/fdv2/FDv2DataSource.test.ts b/packages/shared/sdk-client/__tests__/datasource/fdv2/FDv2DataSource.test.ts index cd4efda5c9..13e6fc0b88 100644 --- a/packages/shared/sdk-client/__tests__/datasource/fdv2/FDv2DataSource.test.ts +++ b/packages/shared/sdk-client/__tests__/datasource/fdv2/FDv2DataSource.test.ts @@ -470,7 +470,7 @@ it('recovers to primary synchronizer when recovery condition fires', async () => // recovery (20ms) → sync1 second invocation delivers changeSet. await ds.start(); - expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Recovery condition fired')); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Recovery condition fired')); expect(dataCallback).toHaveBeenCalledWith(payload); ds.close(); }); diff --git a/packages/shared/sdk-client/src/DataManager.ts b/packages/shared/sdk-client/src/DataManager.ts index d379952c31..644b06d399 100644 --- a/packages/shared/sdk-client/src/DataManager.ts +++ b/packages/shared/sdk-client/src/DataManager.ts @@ -53,6 +53,33 @@ export interface DataManager { * Closes the data manager. Any active connections are closed. */ close(): void; + + /** + * Force streaming on or off. When `true`, the data manager should + * maintain a streaming connection. When `false`, streaming is disabled. + * When `undefined`, the forced state is cleared and automatic behavior + * takes over. + * + * Optional — only browser data managers implement this. + */ + setForcedStreaming?(streaming?: boolean): void; + + /** + * Update the automatic streaming state based on whether change listeners + * are registered. When `true` and forced streaming is not set, the data + * manager should activate streaming. + * + * Optional — only browser data managers implement this. + */ + setAutomaticStreamingState?(streaming: boolean): void; + + /** + * Set a callback to flush pending analytics events. Called immediately + * (not debounced) when the lifecycle transitions to background. + * + * Optional — only FDv2 data managers implement this. + */ + setFlushCallback?(callback: () => void): void; } /** From 4f59d3981c7dbb0d0ce559e1f1dd2692f7cb79de Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:44:12 -0700 Subject: [PATCH 16/28] Increase package size limits --- .github/workflows/browser.yml | 2 +- .github/workflows/combined-browser.yml | 2 +- .github/workflows/common.yml | 2 +- .github/workflows/sdk-client.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/browser.yml b/.github/workflows/browser.yml index 6e24d4ec0c..3e20588456 100644 --- a/.github/workflows/browser.yml +++ b/.github/workflows/browser.yml @@ -41,7 +41,7 @@ jobs: target_file: 'packages/sdk/browser/dist/index.js' package_name: '@launchdarkly/js-client-sdk' pr_number: ${{ github.event.number }} - size_limit: 25000 + size_limit: 34000 # Contract Tests - name: Install contract test dependencies diff --git a/.github/workflows/combined-browser.yml b/.github/workflows/combined-browser.yml index 159a6f9e7a..e83eb4b87b 100644 --- a/.github/workflows/combined-browser.yml +++ b/.github/workflows/combined-browser.yml @@ -41,4 +41,4 @@ jobs: target_file: 'packages/sdk/combined-browser/dist/index.js' package_name: '@launchdarkly/browser' pr_number: ${{ github.event.number }} - size_limit: 200000 + size_limit: 194000 diff --git a/.github/workflows/common.yml b/.github/workflows/common.yml index f0161d845d..9034131d1d 100644 --- a/.github/workflows/common.yml +++ b/.github/workflows/common.yml @@ -35,4 +35,4 @@ jobs: target_file: 'packages/shared/common/dist/esm/index.mjs' package_name: '@launchdarkly/js-sdk-common' pr_number: ${{ github.event.number }} - size_limit: 26000 + size_limit: 29000 diff --git a/.github/workflows/sdk-client.yml b/.github/workflows/sdk-client.yml index 8b3ba882b1..3f3d91f7cb 100644 --- a/.github/workflows/sdk-client.yml +++ b/.github/workflows/sdk-client.yml @@ -32,4 +32,4 @@ jobs: target_file: 'packages/shared/sdk-client/dist/esm/index.mjs' package_name: '@launchdarkly/js-client-sdk-common' pr_number: ${{ github.event.number }} - size_limit: 24000 + size_limit: 38000 From c603f560ba2e7416905ba11ec16741e0fb33fd18 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 12 Mar 2026 15:31:46 -0700 Subject: [PATCH 17/28] Don't export internals --- packages/shared/sdk-client/src/index.ts | 22 +--------------------- 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/packages/shared/sdk-client/src/index.ts b/packages/shared/sdk-client/src/index.ts index cfa848a3d8..8e2e343962 100644 --- a/packages/shared/sdk-client/src/index.ts +++ b/packages/shared/sdk-client/src/index.ts @@ -94,27 +94,7 @@ export type { ModeResolutionTable, } from './api/datasource'; -// FDv2 data source orchestration — exported for platform SDK integration. -export type { - FDv2DataSource, - FDv2DataSourceConfig, - DataCallback, -} from './datasource/fdv2/FDv2DataSource'; -export { createFDv2DataSource } from './datasource/fdv2/FDv2DataSource'; -export type { InitializerFactory, SynchronizerSlot } from './datasource/fdv2/SourceManager'; -export { createSynchronizerSlot } from './datasource/fdv2/SourceManager'; -export { makeFDv2Requestor } from './datasource/fdv2/FDv2Requestor'; -export type { FDv2Requestor } from './datasource/fdv2/FDv2Requestor'; -export { createCacheInitializerFactory } from './datasource/fdv2/CacheInitializer'; -export type { CacheInitializerConfig } from './datasource/fdv2/CacheInitializer'; -export { createPollingInitializer } from './datasource/fdv2/PollingInitializer'; -export { createPollingSynchronizer } from './datasource/fdv2/PollingSynchronizer'; -export type { PingHandler, StreamingFDv2Base } from './datasource/fdv2/StreamingFDv2Base'; -export { createStreamingBase } from './datasource/fdv2/StreamingFDv2Base'; -export { createStreamingInitializer } from './datasource/fdv2/StreamingInitializerFDv2'; -export { createStreamingSynchronizer } from './datasource/fdv2/StreamingSynchronizerFDv2'; -export { poll as fdv2Poll } from './datasource/fdv2/PollingBase'; -export { flagEvalPayloadToItemDescriptors } from './datasource/flagEvalMapper'; +// FDv2 data source status manager. export { createDataSourceStatusManager } from './datasource/DataSourceStatusManager'; export type { DataSourceStatusManager } from './datasource/DataSourceStatusManager'; From 2a80fa7d43ced7be76a75044d7e109597541b82a Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 12 Mar 2026 15:47:36 -0700 Subject: [PATCH 18/28] Better type alignment --- .../src/api/datasource/DataSourceEntry.ts | 16 ++++++++++ .../src/api/datasource/ModeDefinition.ts | 6 ++-- .../sdk-client/src/api/datasource/index.ts | 2 ++ .../src/datasource/SourceFactoryProvider.ts | 29 +++++++++---------- packages/shared/sdk-client/src/index.ts | 2 ++ 5 files changed, 36 insertions(+), 19 deletions(-) diff --git a/packages/shared/sdk-client/src/api/datasource/DataSourceEntry.ts b/packages/shared/sdk-client/src/api/datasource/DataSourceEntry.ts index 873576edb9..0385fe6156 100644 --- a/packages/shared/sdk-client/src/api/datasource/DataSourceEntry.ts +++ b/packages/shared/sdk-client/src/api/datasource/DataSourceEntry.ts @@ -14,6 +14,7 @@ export interface EndpointConfig { /** * Configuration for a cache data source entry. + * Cache is only valid as an initializer (not a synchronizer). */ export interface CacheDataSourceEntry { readonly type: 'cache'; @@ -45,6 +46,21 @@ export interface StreamingDataSourceEntry { readonly endpoints?: EndpointConfig; } +/** + * An entry in the initializers list of a mode definition. Initializers + * can be cache, polling, or streaming sources. + */ +export type InitializerEntry = + | CacheDataSourceEntry + | PollingDataSourceEntry + | StreamingDataSourceEntry; + +/** + * An entry in the synchronizers list of a mode definition. Synchronizers + * can be polling or streaming sources (not cache). + */ +export type SynchronizerEntry = PollingDataSourceEntry | StreamingDataSourceEntry; + /** * A data source entry in a mode table. Each entry identifies a data source type * and carries type-specific configuration overrides. diff --git a/packages/shared/sdk-client/src/api/datasource/ModeDefinition.ts b/packages/shared/sdk-client/src/api/datasource/ModeDefinition.ts index 475eb4fb9b..97d221f923 100644 --- a/packages/shared/sdk-client/src/api/datasource/ModeDefinition.ts +++ b/packages/shared/sdk-client/src/api/datasource/ModeDefinition.ts @@ -1,4 +1,4 @@ -import { DataSourceEntry } from './DataSourceEntry'; +import { InitializerEntry, SynchronizerEntry } from './DataSourceEntry'; /** * Defines the data pipeline for a connection mode: which data sources @@ -10,7 +10,7 @@ export interface ModeDefinition { * Sources are tried in order; the first that successfully provides a full * data set transitions the SDK out of the initialization phase. */ - readonly initializers: ReadonlyArray; + readonly initializers: ReadonlyArray; /** * Ordered list of data sources for ongoing synchronization after @@ -18,5 +18,5 @@ export interface ModeDefinition { * failover to the next source if the primary fails. * An empty array means no synchronization occurs (e.g., offline, one-shot). */ - readonly synchronizers: ReadonlyArray; + readonly synchronizers: ReadonlyArray; } diff --git a/packages/shared/sdk-client/src/api/datasource/index.ts b/packages/shared/sdk-client/src/api/datasource/index.ts index e9a50e129d..95a688808b 100644 --- a/packages/shared/sdk-client/src/api/datasource/index.ts +++ b/packages/shared/sdk-client/src/api/datasource/index.ts @@ -4,6 +4,8 @@ export type { CacheDataSourceEntry, PollingDataSourceEntry, StreamingDataSourceEntry, + InitializerEntry, + SynchronizerEntry, DataSourceEntry, } from './DataSourceEntry'; export type { ModeDefinition } from './ModeDefinition'; diff --git a/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts b/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts index 89ccabf1e9..d653bd59a5 100644 --- a/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts +++ b/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts @@ -9,7 +9,7 @@ import { Storage, } from '@launchdarkly/js-sdk-common'; -import { DataSourceEntry } from '../api/datasource'; +import { InitializerEntry, SynchronizerEntry } from '../api/datasource'; import { DataSourcePaths } from './DataSourceConfig'; import { createCacheInitializerFactory } from './fdv2/CacheInitializer'; import { FDv2Requestor } from './fdv2/FDv2Requestor'; @@ -64,26 +64,27 @@ export interface SourceFactoryContext { } /** - * Converts declarative {@link DataSourceEntry} descriptors from the mode - * table into concrete {@link InitializerFactory} and {@link SynchronizerSlot} - * instances that the {@link FDv2DataSource} orchestrator can use. + * Converts declarative {@link InitializerEntry} and {@link SynchronizerEntry} + * descriptors from the mode table into concrete {@link InitializerFactory} + * and {@link SynchronizerSlot} instances that the {@link FDv2DataSource} + * orchestrator can use. */ export interface SourceFactoryProvider { /** - * Create an initializer factory from a data source entry descriptor. - * Returns `undefined` if the entry type is not supported as an initializer. + * Create an initializer factory from an initializer entry descriptor. + * Returns `undefined` if the entry type is not supported. */ createInitializerFactory( - entry: DataSourceEntry, + entry: InitializerEntry, ctx: SourceFactoryContext, ): InitializerFactory | undefined; /** - * Create a synchronizer slot from a data source entry descriptor. - * Returns `undefined` if the entry type is not supported as a synchronizer. + * Create a synchronizer slot from a synchronizer entry descriptor. + * Returns `undefined` if the entry type is not supported. */ createSynchronizerSlot( - entry: DataSourceEntry, + entry: SynchronizerEntry, ctx: SourceFactoryContext, ): SynchronizerSlot | undefined; } @@ -101,7 +102,7 @@ function createPingHandler(ctx: SourceFactoryContext): PingHandler { export function createDefaultSourceFactoryProvider(): SourceFactoryProvider { return { createInitializerFactory( - entry: DataSourceEntry, + entry: InitializerEntry, ctx: SourceFactoryContext, ): InitializerFactory | undefined { switch (entry.type) { @@ -142,7 +143,7 @@ export function createDefaultSourceFactoryProvider(): SourceFactoryProvider { }, createSynchronizerSlot( - entry: DataSourceEntry, + entry: SynchronizerEntry, ctx: SourceFactoryContext, ): SynchronizerSlot | undefined { switch (entry.type) { @@ -173,10 +174,6 @@ export function createDefaultSourceFactoryProvider(): SourceFactoryProvider { return createSynchronizerSlot(factory); } - case 'cache': - // Cache synchronizer doesn't make sense. - return undefined; - default: return undefined; } diff --git a/packages/shared/sdk-client/src/index.ts b/packages/shared/sdk-client/src/index.ts index 8e2e343962..496ed1acb7 100644 --- a/packages/shared/sdk-client/src/index.ts +++ b/packages/shared/sdk-client/src/index.ts @@ -81,6 +81,8 @@ export type { CacheDataSourceEntry, PollingDataSourceEntry, StreamingDataSourceEntry, + InitializerEntry, + SynchronizerEntry, DataSourceEntry, ModeDefinition, LDClientDataSystemOptions, From 4eb5a5945230409efd6804d06d6bdd389d621a03 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 12 Mar 2026 15:52:33 -0700 Subject: [PATCH 19/28] Stricter validation and logging. --- .../datasource/ConnectionModeConfig.test.ts | 38 +++++++++++++++++++ .../src/datasource/ConnectionModeConfig.ts | 11 ++++-- .../src/datasource/FDv2DataManagerBase.ts | 35 ++++++++++++----- 3 files changed, 71 insertions(+), 13 deletions(-) diff --git a/packages/shared/sdk-client/__tests__/datasource/ConnectionModeConfig.test.ts b/packages/shared/sdk-client/__tests__/datasource/ConnectionModeConfig.test.ts index b2422620dc..25ae203897 100644 --- a/packages/shared/sdk-client/__tests__/datasource/ConnectionModeConfig.test.ts +++ b/packages/shared/sdk-client/__tests__/datasource/ConnectionModeConfig.test.ts @@ -257,6 +257,44 @@ describe('given entries with invalid type field', () => { }); }); +describe('given cache entries in synchronizers', () => { + it('discards a cache entry from synchronizers and warns', () => { + const result = validateModeDefinition( + { initializers: [], synchronizers: [{ type: 'cache' }] }, + 'testMode', + logger, + ); + + expect(result.synchronizers).toEqual([]); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('got cache')); + }); + + it('keeps valid synchronizer entries and discards cache', () => { + const result = validateModeDefinition( + { + initializers: [], + synchronizers: [{ type: 'polling' }, { type: 'cache' }, { type: 'streaming' }], + }, + 'testMode', + logger, + ); + + expect(result.synchronizers).toEqual([{ type: 'polling' }, { type: 'streaming' }]); + expect(logger.warn).toHaveBeenCalledTimes(1); + }); + + it('allows cache as an initializer', () => { + const result = validateModeDefinition( + { initializers: [{ type: 'cache' }], synchronizers: [] }, + 'testMode', + logger, + ); + + expect(result.initializers).toEqual([{ type: 'cache' }]); + expect(logger.warn).not.toHaveBeenCalled(); + }); +}); + describe('given polling entries with invalid config', () => { it('drops pollInterval when it is a string and warns', () => { const result = validateModeDefinition( diff --git a/packages/shared/sdk-client/src/datasource/ConnectionModeConfig.ts b/packages/shared/sdk-client/src/datasource/ConnectionModeConfig.ts index 03b0eb7082..51573af7e3 100644 --- a/packages/shared/sdk-client/src/datasource/ConnectionModeConfig.ts +++ b/packages/shared/sdk-client/src/datasource/ConnectionModeConfig.ts @@ -42,15 +42,20 @@ const streamingEntryValidators = { endpoints: validatorOf(endpointValidators), }; -const dataSourceEntryArrayValidator = arrayOf('type', { +const initializerEntryArrayValidator = arrayOf('type', { cache: cacheEntryValidators, polling: pollingEntryValidators, streaming: streamingEntryValidators, }); +const synchronizerEntryArrayValidator = arrayOf('type', { + polling: pollingEntryValidators, + streaming: streamingEntryValidators, +}); + const modeDefinitionValidators = { - initializers: dataSourceEntryArrayValidator, - synchronizers: dataSourceEntryArrayValidator, + initializers: initializerEntryArrayValidator, + synchronizers: synchronizerEntryArrayValidator, }; const MODE_DEFINITION_DEFAULTS: Record = { diff --git a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts index 181b50d397..a4cac52809 100644 --- a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts +++ b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts @@ -189,16 +189,31 @@ export function createFDv2DataManagerBase( initializerFactories: InitializerFactory[]; synchronizerSlots: SynchronizerSlot[]; } { - const initializerFactories: InitializerFactory[] = includeInitializers - ? modeDef.initializers - .filter((entry) => !(bootstrapped && entry.type === 'cache')) - .map((entry) => sourceFactoryProvider.createInitializerFactory(entry, ctx)) - .filter((f): f is InitializerFactory => f !== undefined) - : []; - - const synchronizerSlots: SynchronizerSlot[] = modeDef.synchronizers - .map((entry) => sourceFactoryProvider.createSynchronizerSlot(entry, ctx)) - .filter((s): s is SynchronizerSlot => s !== undefined); + const initializerFactories: InitializerFactory[] = []; + if (includeInitializers) { + modeDef.initializers + .filter((entry) => !(bootstrapped && entry.type === 'cache')) + .forEach((entry) => { + const factory = sourceFactoryProvider.createInitializerFactory(entry, ctx); + if (factory) { + initializerFactories.push(factory); + } else { + logger.warn( + `${logTag} Unsupported initializer type '${entry.type}'. It will be skipped.`, + ); + } + }); + } + + const synchronizerSlots: SynchronizerSlot[] = []; + modeDef.synchronizers.forEach((entry) => { + const slot = sourceFactoryProvider.createSynchronizerSlot(entry, ctx); + if (slot) { + synchronizerSlots.push(slot); + } else { + logger.warn(`${logTag} Unsupported synchronizer type '${entry.type}'. It will be skipped.`); + } + }); // Append a blocked FDv1 fallback synchronizer when configured and // when there are FDv2 synchronizers to fall back from. From bd80512c8d10f5f81091b51ca44a7526d442cdd5 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 13 Mar 2026 10:46:06 -0700 Subject: [PATCH 20/28] Remove browser specific data manager. --- packages/sdk/browser/src/BrowserClient.ts | 71 ++++++---- .../sdk/browser/src/BrowserFDv2DataManager.ts | 123 ------------------ .../src/datasource/FDv2DataManagerBase.ts | 18 +++ 3 files changed, 66 insertions(+), 146 deletions(-) delete mode 100644 packages/sdk/browser/src/BrowserFDv2DataManager.ts diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index 620d4f8f1c..678c3c1b26 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -2,11 +2,16 @@ import { AutoEnvAttributes, BasicLogger, BROWSER_DATA_SYSTEM_DEFAULTS, + BROWSER_TRANSITION_TABLE, browserFdv1Endpoints, Configuration, + createDefaultSourceFactoryProvider, + createFDv2DataManagerBase, + FDv2ConnectionMode, FlagManager, Hook, internal, + LDIdentifyOptions as LDBaseIdentifyOptions, LDClientImpl, LDContext, LDEmitter, @@ -17,6 +22,7 @@ import { LDPluginEnvironmentMetadata, LDWaitForInitializationOptions, LDWaitForInitializationResult, + MODE_TABLE, Platform, readFlagsFromBootstrap, safeRegisterDebugOverridePlugins, @@ -24,7 +30,6 @@ import { import { getHref } from './BrowserApi'; import BrowserDataManager from './BrowserDataManager'; -import BrowserFDv2DataManager from './BrowserFDv2DataManager'; import { BrowserIdentifyOptions as LDIdentifyOptions } from './BrowserIdentifyOptions'; import { registerStateDetection } from './BrowserStateDetector'; import GoalManager from './goals/GoalManager'; @@ -85,28 +90,48 @@ class BrowserClientImpl extends LDClientImpl { baseHeaders: LDHeaders, emitter: LDEmitter, diagnosticsManager?: internal.DiagnosticsManager, - ) => - configuration.dataSystem - ? new BrowserFDv2DataManager( - platform, - flagManager, - clientSideId, - configuration, - baseHeaders, - emitter, - ) - : new BrowserDataManager( - platform, - flagManager, - clientSideId, - configuration, - validatedBrowserOptions, - endpoints.polling, - endpoints.streaming, - baseHeaders, - emitter, - diagnosticsManager, - ); + ) => { + if (configuration.dataSystem) { + const initialForegroundMode: FDv2ConnectionMode = + (configuration.dataSystem.initialConnectionMode as FDv2ConnectionMode) ?? 'one-shot'; + + return createFDv2DataManagerBase({ + platform, + flagManager, + credential: clientSideId, + config: configuration, + baseHeaders, + emitter, + transitionTable: BROWSER_TRANSITION_TABLE, + initialForegroundMode, + backgroundMode: undefined, + modeTable: MODE_TABLE, + sourceFactoryProvider: createDefaultSourceFactoryProvider(), + fdv1Endpoints: browserFdv1Endpoints(clientSideId), + buildQueryParams: (identifyOptions?: LDBaseIdentifyOptions) => { + const params: { key: string; value: string }[] = [{ key: 'auth', value: clientSideId }]; + const browserOpts = identifyOptions as LDIdentifyOptions | undefined; + if (browserOpts?.hash) { + params.push({ key: 'h', value: browserOpts.hash }); + } + return params; + }, + }); + } + + return new BrowserDataManager( + platform, + flagManager, + clientSideId, + configuration, + validatedBrowserOptions, + endpoints.polling, + endpoints.streaming, + baseHeaders, + emitter, + diagnosticsManager, + ); + }; super(clientSideId, autoEnvAttributes, platform, baseOptionsWithDefaults, dataManagerFactory, { // This logic is derived from https://github.com/launchdarkly/js-sdk-common/blob/main/src/PersistentFlagStore.js diff --git a/packages/sdk/browser/src/BrowserFDv2DataManager.ts b/packages/sdk/browser/src/BrowserFDv2DataManager.ts deleted file mode 100644 index c11b62f498..0000000000 --- a/packages/sdk/browser/src/BrowserFDv2DataManager.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { - BROWSER_TRANSITION_TABLE, - browserFdv1Endpoints, - Configuration, - Context, - createDefaultSourceFactoryProvider, - createFDv2DataManagerBase, - DataManager, - FDv2ConnectionMode, - FDv2DataManagerControl, - FlagManager, - LDEmitter, - LDHeaders, - LDIdentifyOptions, - MODE_TABLE, - Platform, -} from '@launchdarkly/js-client-sdk-common'; - -import { BrowserIdentifyOptions } from './BrowserIdentifyOptions'; - -/** - * A DataManager that uses the FDv2 protocol for flag delivery with - * mode switching and debouncing support. - * - * Delegates to a shared {@link FDv2DataManagerControl} (from sdk-client) - * and adds browser-specific behavior: - * - Auth via query params (no Authorization header in browser) - * - Listener-driven streaming auto-promotion - * - Forced streaming via `setStreaming()` API - */ -export default class BrowserFDv2DataManager implements DataManager { - private readonly _base: FDv2DataManagerControl; - - // If streaming is forced on or off, then we follow that setting. - // Otherwise we automatically manage streaming state. - private _forcedStreaming?: boolean = undefined; - private _automaticStreamingState: boolean = false; - - // +-----------+-----------+------------------+ - // | forced | automatic | state | - // +-----------+-----------+------------------+ - // | true | false | streaming | - // | true | true | streaming | - // | false | true | not streaming | - // | false | false | not streaming | - // | undefined | true | streaming | - // | undefined | false | configured mode | - // +-----------+-----------+------------------+ - - constructor( - platform: Platform, - flagManager: FlagManager, - credential: string, - config: Configuration, - baseHeaders: LDHeaders, - emitter: LDEmitter, - ) { - const initialForegroundMode: FDv2ConnectionMode = - (config.dataSystem?.initialConnectionMode as FDv2ConnectionMode) ?? 'one-shot'; - - this._base = createFDv2DataManagerBase({ - platform, - flagManager, - credential, - config, - baseHeaders, - emitter, - transitionTable: BROWSER_TRANSITION_TABLE, - initialForegroundMode, - backgroundMode: undefined, - modeTable: MODE_TABLE, - sourceFactoryProvider: createDefaultSourceFactoryProvider(), - fdv1Endpoints: browserFdv1Endpoints(credential), - buildQueryParams: (identifyOptions?: LDIdentifyOptions) => { - const params: { key: string; value: string }[] = [{ key: 'auth', value: credential }]; - const browserOpts = identifyOptions as BrowserIdentifyOptions | undefined; - if (browserOpts?.hash) { - params.push({ key: 'h', value: browserOpts.hash }); - } - return params; - }, - }); - } - - async identify( - identifyResolve: () => void, - identifyReject: (err: Error) => void, - context: Context, - identifyOptions?: LDIdentifyOptions, - ): Promise { - return this._base.identify(identifyResolve, identifyReject, context, identifyOptions); - } - - close(): void { - this._base.close(); - } - - setFlushCallback(callback: () => void): void { - this._base.setFlushCallback(callback); - } - - setForcedStreaming(streaming?: boolean): void { - this._forcedStreaming = streaming; - this._updateStreamingState(); - } - - setAutomaticStreamingState(streaming: boolean): void { - this._automaticStreamingState = streaming; - this._updateStreamingState(); - } - - private _updateStreamingState(): void { - const shouldBeStreaming = - this._forcedStreaming || - (this._automaticStreamingState && this._forcedStreaming === undefined); - - if (shouldBeStreaming) { - this._base.setForegroundMode('streaming'); - } else { - this._base.setForegroundMode(this._base.configuredForegroundMode); - } - } -} diff --git a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts index a4cac52809..6c3d0e8631 100644 --- a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts +++ b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts @@ -148,6 +148,10 @@ export function createFDv2DataManagerBase( let closed = false; let flushCallback: (() => void) | undefined; + // Forced/automatic streaming state for browser listener-driven streaming. + let forcedStreaming: boolean | undefined; + let automaticStreamingState = false; + // Outstanding identify promise callbacks — needed so that mode switches // during identify can wire the new data source's completion to the // original identify promise. @@ -535,5 +539,19 @@ export function createFDv2DataManagerBase( setFlushCallback(callback: () => void): void { flushCallback = callback; }, + + setForcedStreaming(streaming?: boolean): void { + forcedStreaming = streaming; + const shouldStream = + forcedStreaming || (automaticStreamingState && forcedStreaming === undefined); + this.setForegroundMode(shouldStream ? 'streaming' : initialForegroundMode); + }, + + setAutomaticStreamingState(streaming: boolean): void { + automaticStreamingState = streaming; + const shouldStream = + forcedStreaming || (automaticStreamingState && forcedStreaming === undefined); + this.setForegroundMode(shouldStream ? 'streaming' : initialForegroundMode); + }, }; } From 482456007592a0456afe899dc287796bb5e34e01 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 13 Mar 2026 10:53:39 -0700 Subject: [PATCH 21/28] Add cache comment --- .../shared/sdk-client/src/datasource/FDv2DataManagerBase.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts index 6c3d0e8631..f9d80e18e3 100644 --- a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts +++ b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts @@ -196,6 +196,8 @@ export function createFDv2DataManagerBase( const initializerFactories: InitializerFactory[] = []; if (includeInitializers) { modeDef.initializers + // Skip cache when bootstrapped — bootstrap data was applied to the + // flag store before identify, so the cache would only load older data. .filter((entry) => !(bootstrapped && entry.type === 'cache')) .forEach((entry) => { const factory = sourceFactoryProvider.createInitializerFactory(entry, ctx); From 304e5f3161227d1b29e1e9b6afbaac8cef4c42fa Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:47:53 -0700 Subject: [PATCH 22/28] Ensure streaming is off when setStreaming(false) --- .../src/datasource/FDv2DataManagerBase.ts | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts index f9d80e18e3..46443a8736 100644 --- a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts +++ b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts @@ -181,6 +181,30 @@ export function createFDv2DataManagerBase( return resolveConnectionMode(transitionTable, buildModeState()); } + /** + * Determine the foreground mode based on forced/automatic streaming state. + * + * +-----------+-----------+---------------------------+ + * | forced | automatic | result | + * +-----------+-----------+---------------------------+ + * | true | any | 'streaming' | + * | false | any | configured, never streaming| + * | undefined | true | 'streaming' | + * | undefined | false | configured mode | + * +-----------+-----------+---------------------------+ + */ + function resolveStreamingMode(): FDv2ConnectionMode { + if (forcedStreaming === true) { + return 'streaming'; + } + if (forcedStreaming === false) { + // Explicitly forced off — use configured mode, but never streaming. + return initialForegroundMode === 'streaming' ? 'one-shot' : initialForegroundMode; + } + // forcedStreaming === undefined — automatic behavior. + return automaticStreamingState ? 'streaming' : initialForegroundMode; + } + /** * Convert a ModeDefinition's entries into concrete InitializerFactory[] * and SynchronizerSlot[] using the source factory provider. @@ -544,16 +568,12 @@ export function createFDv2DataManagerBase( setForcedStreaming(streaming?: boolean): void { forcedStreaming = streaming; - const shouldStream = - forcedStreaming || (automaticStreamingState && forcedStreaming === undefined); - this.setForegroundMode(shouldStream ? 'streaming' : initialForegroundMode); + this.setForegroundMode(resolveStreamingMode()); }, setAutomaticStreamingState(streaming: boolean): void { automaticStreamingState = streaming; - const shouldStream = - forcedStreaming || (automaticStreamingState && forcedStreaming === undefined); - this.setForegroundMode(shouldStream ? 'streaming' : initialForegroundMode); + this.setForegroundMode(resolveStreamingMode()); }, }; } From 0a0395463ef0e9769f24d403f53133c8a0d253eb Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 17 Mar 2026 08:55:23 -0700 Subject: [PATCH 23/28] Fix mode switching. --- .../sdk-client/src/datasource/FDv2DataManagerBase.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts index 46443a8736..27778ef633 100644 --- a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts +++ b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts @@ -324,8 +324,10 @@ export function createFDv2DataManagerBase( includeInitializers, ); - // If there are no sources at all (e.g., offline mode), don't create - // a data source — just mark as initialized if we have data. + currentResolvedMode = mode; + + // If there are no sources at all (e.g., offline or one-shot mode + // post-initialization), don't create a data source. if (initializerFactories.length === 0 && synchronizerSlots.length === 0) { logger.debug(`${logTag} Mode '${mode}' has no sources. No data source created.`); if (!initialized && pendingIdentifyResolve) { @@ -369,8 +371,6 @@ export function createFDv2DataManagerBase( pendingIdentifyReject = undefined; } }); - - currentResolvedMode = mode; } /** From 0d1b3330f58079a331f37acbc5933af40e83c195 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Tue, 17 Mar 2026 18:32:36 -0400 Subject: [PATCH 24/28] contract test changes - wip --- .../contract-tests/entity/src/ClientEntity.ts | 63 ++++++++++++++----- .../suppressions_datamode_changes.txt | 62 ++++++++++++++++++ .../src/internal/fdv2/protocolHandler.ts | 6 +- .../src/types/ConfigParams.ts | 28 +++++++++ 4 files changed, 139 insertions(+), 20 deletions(-) create mode 100644 packages/sdk/browser/contract-tests/suppressions_datamode_changes.txt diff --git a/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts b/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts index da8165a9af..fadda81428 100644 --- a/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts +++ b/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts @@ -26,27 +26,56 @@ function makeSdkConfig(options: SDKConfigParams, tag: string) { useReport: options.clientSide.useReport, }; - if (options.serviceEndpoints) { - cf.streamUri = options.serviceEndpoints.streaming; - cf.baseUri = options.serviceEndpoints.polling; - cf.eventsUri = options.serviceEndpoints.events; - } + if (options.dataSystem?.connectionModeConfig) { + const connMode = options.dataSystem.connectionModeConfig; + (cf as any).dataSystem = { + initialConnectionMode: connMode.initialConnectionMode, + }; + + if (connMode.customConnectionModes) { + for (const modeDef of Object.values(connMode.customConnectionModes)) { + for (const sync of modeDef.synchronizers ?? []) { + if (sync.streaming?.baseUri) { + cf.streamUri = sync.streaming.baseUri; + cf.streamInitialReconnectDelay = maybeTime(sync.streaming.initialRetryDelayMs); + } + if (sync.polling?.baseUri) { + cf.baseUri = sync.polling.baseUri; + } + } + + // prefer to use polling endpoint from initializers, so we set this later + for (const init of modeDef.initializers ?? []) { + if (init.polling?.baseUri) { + cf.baseUri = init.polling.baseUri; + } + } + } + } - if (options.polling) { - if (options.polling.baseUri) { - cf.baseUri = options.polling.baseUri; + if (options.dataSystem.payloadFilter) { + cf.payloadFilterKey = options.dataSystem.payloadFilter; + } + } else { + if (options.serviceEndpoints) { + cf.streamUri = options.serviceEndpoints.streaming; + cf.baseUri = options.serviceEndpoints.polling; + cf.eventsUri = options.serviceEndpoints.events; } - } - // Can contain streaming and polling, if streaming is set override the initial connection - // mode. This can be removed when we add JS specific initialization that uses polling - // and then streaming. - if (options.streaming) { - if (options.streaming.baseUri) { - cf.streamUri = options.streaming.baseUri; + if (options.polling) { + if (options.polling.baseUri) { + cf.baseUri = options.polling.baseUri; + } + } + + if (options.streaming) { + if (options.streaming.baseUri) { + cf.streamUri = options.streaming.baseUri; + } + cf.streaming = true; + cf.streamInitialReconnectDelay = maybeTime(options.streaming.initialRetryDelayMs); } - cf.streaming = true; - cf.streamInitialReconnectDelay = maybeTime(options.streaming.initialRetryDelayMs); } if (options.events) { diff --git a/packages/sdk/browser/contract-tests/suppressions_datamode_changes.txt b/packages/sdk/browser/contract-tests/suppressions_datamode_changes.txt new file mode 100644 index 0000000000..d2774f965d --- /dev/null +++ b/packages/sdk/browser/contract-tests/suppressions_datamode_changes.txt @@ -0,0 +1,62 @@ +streaming/requests/method and headers/REPORT/http +streaming/requests/URL path is computed correctly/no environment filter/base URI has no trailing slash/REPORT +streaming/requests/URL path is computed correctly/no environment filter/base URI has a trailing slash/REPORT +streaming/requests/query parameters/evaluationReasons set to [none]/GET +streaming/requests/query parameters/evaluationReasons set to [none]/REPORT +streaming/requests/query parameters/evaluationReasons set to false/GET +streaming/requests/query parameters/evaluationReasons set to false/REPORT +streaming/requests/query parameters/evaluationReasons set to true/GET +streaming/requests/query parameters/evaluationReasons set to true/REPORT +streaming/requests/context properties/single kind minimal/GET +streaming/requests/context properties/single kind minimal/REPORT +streaming/requests/context properties/single kind with all attributes/GET +streaming/requests/context properties/single kind with all attributes/REPORT +streaming/requests/context properties/multi-kind/GET +streaming/requests/context properties/multi-kind/REPORT +polling/requests/method and headers/GET/http +polling/requests/method and headers/REPORT/http +polling/requests/URL path is computed correctly/no environment filter/base URI has no trailing slash/GET +polling/requests/URL path is computed correctly/no environment filter/base URI has no trailing slash/REPORT +polling/requests/URL path is computed correctly/no environment filter/base URI has a trailing slash/GET +polling/requests/URL path is computed correctly/no environment filter/base URI has a trailing slash/REPORT +polling/requests/query parameters/evaluationReasons set to [none]/GET +polling/requests/query parameters/evaluationReasons set to [none]/REPORT +polling/requests/query parameters/evaluationReasons set to false/GET +polling/requests/query parameters/evaluationReasons set to false/REPORT +polling/requests/query parameters/evaluationReasons set to true/GET +polling/requests/query parameters/evaluationReasons set to true/REPORT +polling/requests/context properties/single kind minimal/GET +polling/requests/context properties/single kind minimal/REPORT +polling/requests/context properties/single kind with all attributes/GET +polling/requests/context properties/single kind with all attributes/REPORT +polling/requests/context properties/multi-kind/GET +polling/requests/context properties/multi-kind/REPORT +tags/stream requests/{"applicationId":null,"applicationVersion":null} +tags/stream requests/{"applicationId":null,"applicationVersion":""} +tags/stream requests/{"applicationId":null,"applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"} +tags/stream requests/{"applicationId":null,"applicationVersion":"________________________________________________________________"} +tags/stream requests/{"applicationId":"","applicationVersion":null} +tags/stream requests/{"applicationId":"","applicationVersion":""} +tags/stream requests/{"applicationId":"","applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"} +tags/stream requests/{"applicationId":"","applicationVersion":"________________________________________________________________"} +tags/stream requests/{"applicationId":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678","applicationVersion":null} +tags/stream requests/{"applicationId":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678","applicationVersion":""} +tags/stream requests/{"applicationId":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678","applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"} +tags/stream requests/{"applicationId":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678","applicationVersion":"________________________________________________________________"} +tags/stream requests/{"applicationId":"________________________________________________________________","applicationVersion":null} +tags/stream requests/{"applicationId":"________________________________________________________________","applicationVersion":""} +tags/stream requests/{"applicationId":"________________________________________________________________","applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"} +tags/stream requests/{"applicationId":"________________________________________________________________","applicationVersion":"________________________________________________________________"} +tags/poll requests/{"applicationId":null,"applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"} +tags/poll requests/{"applicationId":null,"applicationVersion":"________________________________________________________________"} +tags/poll requests/{"applicationId":"","applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"} +tags/poll requests/{"applicationId":"","applicationVersion":"________________________________________________________________"} +tags/poll requests/{"applicationId":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678","applicationVersion":null} +tags/poll requests/{"applicationId":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678","applicationVersion":""} +tags/poll requests/{"applicationId":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678","applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"} +tags/poll requests/{"applicationId":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678","applicationVersion":"________________________________________________________________"} +tags/poll requests/{"applicationId":"________________________________________________________________","applicationVersion":null} +tags/poll requests/{"applicationId":"________________________________________________________________","applicationVersion":""} +tags/poll requests/{"applicationId":"________________________________________________________________","applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"} +tags/poll requests/{"applicationId":"________________________________________________________________","applicationVersion":"________________________________________________________________"} +tags/disallowed characters diff --git a/packages/shared/common/src/internal/fdv2/protocolHandler.ts b/packages/shared/common/src/internal/fdv2/protocolHandler.ts index 5d13654ebf..d282784d06 100644 --- a/packages/shared/common/src/internal/fdv2/protocolHandler.ts +++ b/packages/shared/common/src/internal/fdv2/protocolHandler.ts @@ -169,7 +169,7 @@ export function createProtocolHandler( !tempId || !data.kind || !data.key || - !data.version || + data.version == null || !data.object ) { return ACTION_NONE; @@ -191,7 +191,7 @@ export function createProtocolHandler( } function processDeleteObject(data: DeleteObject): ProtocolAction { - if (protocolState === 'inactive' || !tempId || !data.kind || !data.key || !data.version) { + if (protocolState === 'inactive' || !tempId || !data.kind || !data.key || data.version == null) { return ACTION_NONE; } @@ -214,7 +214,7 @@ export function createProtocolHandler( }; } - if (!tempId || data.state === null || data.state === undefined || !data.version) { + if (!tempId || data.state === null || data.state === undefined || data.version == null) { resetAll(); return ACTION_NONE; } diff --git a/packages/tooling/contract-test-utils/src/types/ConfigParams.ts b/packages/tooling/contract-test-utils/src/types/ConfigParams.ts index 6727ff5e0b..0c7d57dbca 100644 --- a/packages/tooling/contract-test-utils/src/types/ConfigParams.ts +++ b/packages/tooling/contract-test-utils/src/types/ConfigParams.ts @@ -21,6 +21,34 @@ export interface SDKConfigParams { hooks?: SDKConfigHooksParams; wrapper?: SDKConfigWrapper; proxy?: SDKConfigProxyParams; + dataSystem?: SDKConfigDataSystem; +} + +export interface SDKConfigDataSystem { + useDefaultDataSystem?: boolean; + initializers?: SDKConfigDataInitializer[]; + synchronizers?: SDKConfigDataSynchronizer[]; + payloadFilter?: string; + connectionModeConfig?: SDKConfigConnectionModeConfig; +} + +export interface SDKConfigConnectionModeConfig { + initialConnectionMode?: string; + customConnectionModes?: Record; +} + +export interface SDKConfigModeDefinition { + initializers?: SDKConfigDataInitializer[]; + synchronizers?: SDKConfigDataSynchronizer[]; +} + +export interface SDKConfigDataInitializer { + polling?: SDKConfigPollingParams; +} + +export interface SDKConfigDataSynchronizer { + streaming?: SDKConfigStreamingParams; + polling?: SDKConfigPollingParams; } export interface SDKConfigTLSParams { From 88ab6a4aa68412d274ae47a2d05a4b6c19f26786 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:39:44 -0700 Subject: [PATCH 25/28] Additional handling. --- packages/sdk/browser/example/index.css | 60 +++- packages/sdk/browser/example/src/app.ts | 280 ++++++++++++++---- .../src/internal/fdv2/protocolHandler.ts | 38 ++- 3 files changed, 311 insertions(+), 67 deletions(-) diff --git a/packages/sdk/browser/example/index.css b/packages/sdk/browser/example/index.css index 90dc2b50f1..894c557bfc 100644 --- a/packages/sdk/browser/example/index.css +++ b/packages/sdk/browser/example/index.css @@ -1,5 +1,6 @@ body { margin: 0; + padding: 20px; background: #373841; color: white; font-family: @@ -7,5 +8,62 @@ body { 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - text-align: center; +} + +#status { + padding: 10px; + margin-bottom: 10px; + background: rgba(255,255,255,0.1); + border-radius: 4px; +} + +#flag { + font-size: 1.4em; + padding: 15px; + margin-bottom: 20px; + background: rgba(255,255,255,0.05); + border-radius: 4px; +} + +#controls { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 15px; +} + +#controls > div { + padding: 10px; + background: rgba(255,255,255,0.05); + border-radius: 4px; +} + +#controls h3 { + margin: 0 0 8px 0; + font-size: 0.9em; + text-transform: uppercase; + opacity: 0.7; +} + +button { + padding: 6px 14px; + margin: 3px 2px; + border: 1px solid rgba(255,255,255,0.3); + border-radius: 4px; + background: rgba(255,255,255,0.1); + color: white; + cursor: pointer; + font-size: 0.9em; +} + +button:hover { + background: rgba(255,255,255,0.2); +} + +#log { + max-height: 200px; + overflow-y: auto; + font-family: monospace; + font-size: 0.8em; + line-height: 1.5; + opacity: 0.8; } diff --git a/packages/sdk/browser/example/src/app.ts b/packages/sdk/browser/example/src/app.ts index 36cf6fcd64..655921e952 100644 --- a/packages/sdk/browser/example/src/app.ts +++ b/packages/sdk/browser/example/src/app.ts @@ -1,4 +1,4 @@ -import { basicLogger, createClient } from '@launchdarkly/js-client-sdk'; +import { basicLogger, createClient, type LDClient } from '@launchdarkly/js-client-sdk'; // Set clientSideID to your LaunchDarkly client-side ID const clientSideID = 'LD_CLIENT_SIDE_ID'; @@ -6,75 +6,245 @@ const clientSideID = 'LD_CLIENT_SIDE_ID'; // Set flagKey to the feature flag key you want to evaluate const flagKey = 'LD_FLAG_KEY'; -// Set up the evaluation context. This context should appear on your -// LaunchDarkly contexts dashboard soon after you run the demo. -const context = { - kind: 'user', - key: 'example-user-key', - name: 'Sandy', -}; +const contexts = [ + { kind: 'user', key: 'user-1', name: 'Sandy' }, + { kind: 'user', key: 'user-2', name: 'Alex' }, + { kind: 'user', key: 'user-3', name: 'Jordan' }, + { kind: 'org', key: 'org-1', name: 'Acme Corp' }, +]; + +let currentContextIndex = 0; +let eventHandlersRegistered = false; +let changeHandler: (() => void) | undefined; +let errorHandler: (() => void) | undefined; + +function el(tag: string, attrs?: Record): HTMLElement { + const e = document.createElement(tag); + if (attrs) { + Object.entries(attrs).forEach(([k, v]) => e.setAttribute(k, v)); + } + return e; +} + +function text(s: string): Text { + return document.createTextNode(s); +} + +function buildUI() { + const container = el('div', { id: 'app' }); + + // Status + const statusBox = el('div', { id: 'status' }); + statusBox.appendChild(text('Initializing...')); + container.appendChild(statusBox); + + // Flag value + const flagBox = el('div', { id: 'flag' }); + flagBox.appendChild(text('No flag evaluations yet')); + container.appendChild(flagBox); + + // Controls + const controls = el('div', { id: 'controls' }); + + // Context switcher + const ctxSection = el('div'); + ctxSection.appendChild(el('h3')); + ctxSection.querySelector('h3')!.textContent = 'Context'; + const ctxLabel = el('span', { id: 'ctx-label' }); + ctxLabel.textContent = formatContext(contexts[0]); + ctxSection.appendChild(ctxLabel); + ctxSection.appendChild(text(' ')); + const ctxBtn = el('button', { id: 'btn-ctx' }); + ctxBtn.textContent = 'Switch Context'; + ctxSection.appendChild(ctxBtn); + controls.appendChild(ctxSection); -const div = document.createElement('div'); -const statusBox = document.createElement('div'); + // Event handlers + const evtSection = el('div'); + evtSection.appendChild(el('h3')); + evtSection.querySelector('h3')!.textContent = 'Event Handlers'; + const evtStatus = el('span', { id: 'evt-status' }); + evtStatus.textContent = 'Not registered'; + evtSection.appendChild(evtStatus); + evtSection.appendChild(text(' ')); + const evtBtn = el('button', { id: 'btn-evt' }); + evtBtn.textContent = 'Register'; + evtSection.appendChild(evtBtn); + controls.appendChild(evtSection); -document.body.appendChild(statusBox); -document.body.appendChild(div); + // Streaming control + const streamSection = el('div'); + streamSection.appendChild(el('h3')); + streamSection.querySelector('h3')!.textContent = 'Streaming'; + const streamStatus = el('span', { id: 'stream-status' }); + streamStatus.textContent = 'undefined (automatic)'; + streamSection.appendChild(streamStatus); + streamSection.appendChild(el('br')); + const btnTrue = el('button', { id: 'btn-stream-true' }); + btnTrue.textContent = 'Force On'; + const btnFalse = el('button', { id: 'btn-stream-false' }); + btnFalse.textContent = 'Force Off'; + const btnUndef = el('button', { id: 'btn-stream-undef' }); + btnUndef.textContent = 'Automatic'; + streamSection.appendChild(btnTrue); + streamSection.appendChild(text(' ')); + streamSection.appendChild(btnFalse); + streamSection.appendChild(text(' ')); + streamSection.appendChild(btnUndef); + controls.appendChild(streamSection); -div.appendChild(document.createTextNode('No flag evaluations yet')); -statusBox.appendChild(document.createTextNode('Initializing...')); + // Log + const logSection = el('div'); + logSection.appendChild(el('h3')); + logSection.querySelector('h3')!.textContent = 'Event Log'; + const logBox = el('div', { id: 'log' }); + logSection.appendChild(logBox); + controls.appendChild(logSection); + + container.appendChild(controls); + document.body.appendChild(container); +} + +function formatContext(ctx: (typeof contexts)[0]): string { + return `${ctx.kind}:${ctx.key} (${ctx.name})`; +} + +function log(msg: string) { + const logBox = document.getElementById('log')!; + const entry = el('div'); + const time = new Date().toLocaleTimeString(); + entry.textContent = `[${time}] ${msg}`; + logBox.insertBefore(entry, logBox.firstChild); + // Keep last 50 entries + while (logBox.children.length > 50) { + logBox.removeChild(logBox.lastChild!); + } +} + +function renderFlag(client: LDClient) { + const flagValue = client.variation(flagKey, false); + const flagBox = document.getElementById('flag')!; + flagBox.textContent = `${flagKey} = ${JSON.stringify(flagValue)}`; + document.body.style.background = flagValue ? '#00844B' : '#373841'; +} + +function updateStatus(msg: string) { + document.getElementById('status')!.textContent = msg; +} + +function updateCtxLabel() { + document.getElementById('ctx-label')!.textContent = formatContext(contexts[currentContextIndex]); +} + +function updateEvtStatus() { + const evtStatus = document.getElementById('evt-status')!; + const btn = document.getElementById('btn-evt')!; + if (eventHandlersRegistered) { + evtStatus.textContent = 'Registered (change + error)'; + btn.textContent = 'Unregister'; + } else { + evtStatus.textContent = 'Not registered'; + btn.textContent = 'Register'; + } +} + +function updateStreamStatus(value: boolean | undefined) { + const label = document.getElementById('stream-status')!; + if (value === true) { + label.textContent = 'true (forced on)'; + } else if (value === false) { + label.textContent = 'false (forced off)'; + } else { + label.textContent = 'undefined (automatic)'; + } +} + +function registerHandlers(client: LDClient) { + if (eventHandlersRegistered) return; + + changeHandler = () => { + log('change event received'); + renderFlag(client); + }; + errorHandler = () => { + log('error event received'); + }; + + client.on('change', changeHandler); + client.on('error', errorHandler); + eventHandlersRegistered = true; + updateEvtStatus(); + log('Event handlers registered'); +} + +function unregisterHandlers(client: LDClient) { + if (!eventHandlersRegistered) return; + + if (changeHandler) { + client.off('change', changeHandler); + changeHandler = undefined; + } + if (errorHandler) { + client.off('error', errorHandler); + errorHandler = undefined; + } + eventHandlersRegistered = false; + updateEvtStatus(); + log('Event handlers unregistered'); +} const main = async () => { - const ldclient = createClient(clientSideID, context, { + buildUI(); + + const client = createClient(clientSideID, contexts[currentContextIndex], { // @ts-ignore dataSystem is @internal — experimental FDv2 opt-in dataSystem: {}, logger: basicLogger({ level: 'debug' }), }); - const render = () => { - const flagValue = ldclient.variation(flagKey, false); - const label = `The ${flagKey} feature flag evaluates to ${flagValue}.`; - document.body.style.background = flagValue ? '#00844B' : '#373841'; - div.replaceChild(document.createTextNode(label), div.firstChild as Node); - }; - ldclient.on('error', () => { - statusBox.replaceChild( - document.createTextNode('Error caught in client SDK'), - statusBox.firstChild as Node, - ); + // Context switching + document.getElementById('btn-ctx')!.addEventListener('click', async () => { + currentContextIndex = (currentContextIndex + 1) % contexts.length; + const ctx = contexts[currentContextIndex]; + updateCtxLabel(); + log(`Identifying as ${formatContext(ctx)}...`); + const result = await client.identify(ctx); + log(`Identify result: ${result.status}`); + renderFlag(client); }); - // Listen for flag changes - ldclient.on('change', () => { - render(); + // Event handler toggle + document.getElementById('btn-evt')!.addEventListener('click', () => { + if (eventHandlersRegistered) { + unregisterHandlers(client); + } else { + registerHandlers(client); + } }); - ldclient.start(); - - const { status } = await ldclient.waitForInitialization(); - - if (status === 'complete') { - statusBox.replaceChild( - document.createTextNode(`Initialized with context: ${JSON.stringify(ldclient.getContext())}`), - statusBox.firstChild as Node, - ); - } else if (status === 'failed') { - statusBox.replaceChild( - document.createTextNode('Error identifying client'), - statusBox.firstChild as Node, - ); - } else if (status === 'timeout') { - statusBox.replaceChild( - document.createTextNode('Timeout identifying client'), - statusBox.firstChild as Node, - ); - } else { - statusBox.replaceChild( - document.createTextNode('Unknown error identifying client'), - statusBox.firstChild as Node, - ); - } + // Streaming controls + document.getElementById('btn-stream-true')!.addEventListener('click', () => { + client.setStreaming(true); + updateStreamStatus(true); + log('setStreaming(true)'); + }); + document.getElementById('btn-stream-false')!.addEventListener('click', () => { + client.setStreaming(false); + updateStreamStatus(false); + log('setStreaming(false)'); + }); + document.getElementById('btn-stream-undef')!.addEventListener('click', () => { + client.setStreaming(undefined); + updateStreamStatus(undefined); + log('setStreaming(undefined)'); + }); - render(); + // Start + client.start(); + const { status } = await client.waitForInitialization(); + updateStatus(`Initialized (${status}) - ${formatContext(contexts[currentContextIndex])}`); + log(`Initialization: ${status}`); + renderFlag(client); }; main(); diff --git a/packages/shared/common/src/internal/fdv2/protocolHandler.ts b/packages/shared/common/src/internal/fdv2/protocolHandler.ts index 5d13654ebf..3051c262ce 100644 --- a/packages/shared/common/src/internal/fdv2/protocolHandler.ts +++ b/packages/shared/common/src/internal/fdv2/protocolHandler.ts @@ -1,4 +1,5 @@ import { LDLogger } from '../../api'; +import { isNullish } from '../../validators'; import { DeleteObject, FDv2Event, @@ -111,7 +112,10 @@ export function createProtocolHandler( } function processIntentNone(intent: PayloadIntent): ProtocolAction { - if (!intent.id || !intent.target) { + if (!intent.id || isNullish(intent.target)) { + logger?.warn( + `Ignoring 'none' intent with missing fields: id=${intent.id}, target=${intent.target}`, + ); return ACTION_NONE; } @@ -164,14 +168,15 @@ export function createProtocolHandler( } function processPutObject(data: PutObject): ProtocolAction { - if ( - protocolState === 'inactive' || - !tempId || - !data.kind || - !data.key || - !data.version || - !data.object - ) { + if (protocolState === 'inactive' || !tempId) { + logger?.warn('Received put-object before server-intent was established. Ignoring.'); + return ACTION_NONE; + } + + if (!data.kind || !data.key || isNullish(data.version) || !data.object) { + logger?.warn( + `Ignoring put-object with missing fields: kind=${data.kind}, key=${data.key}, version=${data.version}`, + ); return ACTION_NONE; } @@ -191,7 +196,15 @@ export function createProtocolHandler( } function processDeleteObject(data: DeleteObject): ProtocolAction { - if (protocolState === 'inactive' || !tempId || !data.kind || !data.key || !data.version) { + if (protocolState === 'inactive' || !tempId) { + logger?.warn('Received delete-object before server-intent was established. Ignoring.'); + return ACTION_NONE; + } + + if (!data.kind || !data.key || isNullish(data.version)) { + logger?.warn( + `Ignoring delete-object with missing fields: kind=${data.kind}, key=${data.key}, version=${data.version}`, + ); return ACTION_NONE; } @@ -214,7 +227,10 @@ export function createProtocolHandler( }; } - if (!tempId || data.state === null || data.state === undefined || !data.version) { + if (!tempId || isNullish(data.state) || isNullish(data.version)) { + logger?.warn( + `Ignoring payload-transferred with missing fields: state=${data.state}, version=${data.version}`, + ); resetAll(); return ACTION_NONE; } From 04d2644b0cf8b1d2c147f258ae25aa4c40f71fbb Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:13:09 -0700 Subject: [PATCH 26/28] Connect withReasons. --- .../shared/sdk-client/src/datasource/FDv2DataManagerBase.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts index 27778ef633..bcc1ec154f 100644 --- a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts +++ b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts @@ -444,6 +444,9 @@ export function createFDv2DataManagerBase( const plainContextString = JSON.stringify(Context.toLDContext(context)); const queryParams = buildQueryParams(identifyOptions); + if (config.withReasons) { + queryParams.push({ key: 'withReasons', value: 'true' }); + } const streamingEndpoints = endpoints.streaming(); const pollingEndpoints = endpoints.polling(); From 6c1996dd5784e5fe218905e4833522346cc29b29 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 17 Mar 2026 17:16:15 -0700 Subject: [PATCH 27/28] Allow mode customization. Connect withReasons. --- .../contract-tests/entity/src/ClientEntity.ts | 82 ++++++++++++++++--- .../suppressions_datamode_changes.txt | 8 -- packages/sdk/browser/example/src/app.ts | 8 +- .../datasource/LDClientDataSystemOptions.ts | 24 +++++- .../src/datasource/FDv2DataManagerBase.ts | 8 +- .../datasource/LDClientDataSystemOptions.ts | 3 +- .../src/datasource/SourceFactoryProvider.ts | 67 +++++++++++++-- .../tooling/contract-test-utils/src/index.ts | 5 ++ 8 files changed, 171 insertions(+), 34 deletions(-) diff --git a/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts b/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts index fadda81428..34c96d9936 100644 --- a/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts +++ b/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts @@ -4,6 +4,9 @@ import { CommandType, CreateInstanceParams, makeLogger, + SDKConfigDataInitializer, + SDKConfigDataSynchronizer, + SDKConfigModeDefinition, SDKConfigParams, ClientSideTestHook as TestHook, ValueType, @@ -12,6 +15,59 @@ import { export const badCommandError = new Error('unsupported command'); export const malformedCommand = new Error('command was malformed'); +function translateInitializer(init: SDKConfigDataInitializer): any | undefined { + if (init.polling) { + return { + type: 'polling', + ...(init.polling.pollIntervalMs !== undefined && { + pollInterval: init.polling.pollIntervalMs / 1000, + }), + ...(init.polling.baseUri && { + endpoints: { pollingBaseUri: init.polling.baseUri }, + }), + }; + } + return undefined; +} + +function translateSynchronizer(sync: SDKConfigDataSynchronizer): any | undefined { + if (sync.streaming) { + return { + type: 'streaming', + ...(sync.streaming.initialRetryDelayMs !== undefined && { + initialReconnectDelay: sync.streaming.initialRetryDelayMs / 1000, + }), + ...(sync.streaming.baseUri && { + endpoints: { streamingBaseUri: sync.streaming.baseUri }, + }), + }; + } + if (sync.polling) { + return { + type: 'polling', + ...(sync.polling.pollIntervalMs !== undefined && { + pollInterval: sync.polling.pollIntervalMs / 1000, + }), + ...(sync.polling.baseUri && { + endpoints: { pollingBaseUri: sync.polling.baseUri }, + }), + }; + } + return undefined; +} + +function translateModeDefinition(modeDef: SDKConfigModeDefinition): any { + const initializers = (modeDef.initializers ?? []) + .map(translateInitializer) + .filter((x: any) => x !== undefined); + + const synchronizers = (modeDef.synchronizers ?? []) + .map(translateSynchronizer) + .filter((x: any) => x !== undefined); + + return { initializers, synchronizers }; +} + function makeSdkConfig(options: SDKConfigParams, tag: string) { if (!options.clientSide) { throw new Error('configuration did not include clientSide options'); @@ -23,18 +79,23 @@ function makeSdkConfig(options: SDKConfigParams, tag: string) { const cf: LDOptions = { withReasons: options.clientSide.evaluationReasons, logger: makeLogger(`${tag}.sdk`), - useReport: options.clientSide.useReport, + useReport: options.clientSide.useReport ?? undefined, }; if (options.dataSystem?.connectionModeConfig) { const connMode = options.dataSystem.connectionModeConfig; - (cf as any).dataSystem = { + const dataSystem: any = { initialConnectionMode: connMode.initialConnectionMode, + automaticModeSwitching: false, }; if (connMode.customConnectionModes) { - for (const modeDef of Object.values(connMode.customConnectionModes)) { - for (const sync of modeDef.synchronizers ?? []) { + const connectionModes: Record = {}; + Object.entries(connMode.customConnectionModes).forEach(([modeName, modeDef]) => { + connectionModes[modeName] = translateModeDefinition(modeDef); + + // Also set global endpoint URIs for compatibility with ServiceEndpoints. + (modeDef.synchronizers ?? []).forEach((sync) => { if (sync.streaming?.baseUri) { cf.streamUri = sync.streaming.baseUri; cf.streamInitialReconnectDelay = maybeTime(sync.streaming.initialRetryDelayMs); @@ -42,17 +103,18 @@ function makeSdkConfig(options: SDKConfigParams, tag: string) { if (sync.polling?.baseUri) { cf.baseUri = sync.polling.baseUri; } - } - - // prefer to use polling endpoint from initializers, so we set this later - for (const init of modeDef.initializers ?? []) { + }); + (modeDef.initializers ?? []).forEach((init) => { if (init.polling?.baseUri) { cf.baseUri = init.polling.baseUri; } - } - } + }); + }); + dataSystem.connectionModes = connectionModes; } + (cf as any).dataSystem = dataSystem; + if (options.dataSystem.payloadFilter) { cf.payloadFilterKey = options.dataSystem.payloadFilter; } diff --git a/packages/sdk/browser/contract-tests/suppressions_datamode_changes.txt b/packages/sdk/browser/contract-tests/suppressions_datamode_changes.txt index d2774f965d..dae14d026b 100644 --- a/packages/sdk/browser/contract-tests/suppressions_datamode_changes.txt +++ b/packages/sdk/browser/contract-tests/suppressions_datamode_changes.txt @@ -7,17 +7,11 @@ streaming/requests/query parameters/evaluationReasons set to false/GET streaming/requests/query parameters/evaluationReasons set to false/REPORT streaming/requests/query parameters/evaluationReasons set to true/GET streaming/requests/query parameters/evaluationReasons set to true/REPORT -streaming/requests/context properties/single kind minimal/GET streaming/requests/context properties/single kind minimal/REPORT -streaming/requests/context properties/single kind with all attributes/GET streaming/requests/context properties/single kind with all attributes/REPORT -streaming/requests/context properties/multi-kind/GET streaming/requests/context properties/multi-kind/REPORT -polling/requests/method and headers/GET/http polling/requests/method and headers/REPORT/http -polling/requests/URL path is computed correctly/no environment filter/base URI has no trailing slash/GET polling/requests/URL path is computed correctly/no environment filter/base URI has no trailing slash/REPORT -polling/requests/URL path is computed correctly/no environment filter/base URI has a trailing slash/GET polling/requests/URL path is computed correctly/no environment filter/base URI has a trailing slash/REPORT polling/requests/query parameters/evaluationReasons set to [none]/GET polling/requests/query parameters/evaluationReasons set to [none]/REPORT @@ -25,11 +19,9 @@ polling/requests/query parameters/evaluationReasons set to false/GET polling/requests/query parameters/evaluationReasons set to false/REPORT polling/requests/query parameters/evaluationReasons set to true/GET polling/requests/query parameters/evaluationReasons set to true/REPORT -polling/requests/context properties/single kind minimal/GET polling/requests/context properties/single kind minimal/REPORT polling/requests/context properties/single kind with all attributes/GET polling/requests/context properties/single kind with all attributes/REPORT -polling/requests/context properties/multi-kind/GET polling/requests/context properties/multi-kind/REPORT tags/stream requests/{"applicationId":null,"applicationVersion":null} tags/stream requests/{"applicationId":null,"applicationVersion":""} diff --git a/packages/sdk/browser/example/src/app.ts b/packages/sdk/browser/example/src/app.ts index 655921e952..4e41fea77b 100644 --- a/packages/sdk/browser/example/src/app.ts +++ b/packages/sdk/browser/example/src/app.ts @@ -30,6 +30,10 @@ function text(s: string): Text { return document.createTextNode(s); } +function formatContext(ctx: (typeof contexts)[0]): string { + return `${ctx.kind}:${ctx.key} (${ctx.name})`; +} + function buildUI() { const container = el('div', { id: 'app' }); @@ -105,10 +109,6 @@ function buildUI() { document.body.appendChild(container); } -function formatContext(ctx: (typeof contexts)[0]): string { - return `${ctx.kind}:${ctx.key} (${ctx.name})`; -} - function log(msg: string) { const logBox = document.getElementById('log')!; const entry = el('div'); diff --git a/packages/shared/sdk-client/src/api/datasource/LDClientDataSystemOptions.ts b/packages/shared/sdk-client/src/api/datasource/LDClientDataSystemOptions.ts index 470a481326..bcd45c1fc4 100644 --- a/packages/shared/sdk-client/src/api/datasource/LDClientDataSystemOptions.ts +++ b/packages/shared/sdk-client/src/api/datasource/LDClientDataSystemOptions.ts @@ -1,4 +1,5 @@ import FDv2ConnectionMode from './FDv2ConnectionMode'; +import { ModeDefinition } from './ModeDefinition'; // When FDv2 becomes the default, this should be integrated into the // main LDOptions interface (api/LDOptions.ts). @@ -48,8 +49,27 @@ export interface LDClientDataSystemOptions { */ automaticModeSwitching?: boolean | AutomaticModeSwitchingConfig; - // Req 5.3.5 TBD — custom named modes reserved for future use. - // customModes?: Record; + /** + * Override the data source pipeline for specific connection modes. + * + * Each key is a connection mode name (`'streaming'`, `'polling'`, `'offline'`, + * `'one-shot'`, `'background'`). The value defines the initializers and + * synchronizers for that mode, replacing the built-in defaults. + * + * Only the modes you specify are overridden — unspecified modes retain + * their built-in definitions. + * + * @example + * ``` + * connectionModes: { + * streaming: { + * initializers: [{ type: 'polling' }], + * synchronizers: [{ type: 'streaming' }], + * }, + * } + * ``` + */ + connectionModes?: Partial>; } /** diff --git a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts index bcc1ec154f..cf343eae7e 100644 --- a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts +++ b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts @@ -135,6 +135,11 @@ export function createFDv2DataManagerBase( const statusManager: DataSourceStatusManager = createDataSourceStatusManager(emitter); const endpoints = fdv2Endpoints(); + // Merge user-provided connection mode overrides into the mode table. + const effectiveModeTable: ModeTable = config.dataSystem?.connectionModes + ? { ...modeTable, ...config.dataSystem.connectionModes } + : modeTable; + // --- Mutable state --- let selector: string | undefined; let currentResolvedMode: FDv2ConnectionMode = initialForegroundMode; @@ -165,7 +170,7 @@ export function createFDv2DataManagerBase( // --- Helpers --- function getModeDefinition(mode: FDv2ConnectionMode): ModeDefinition { - return modeTable[mode]; + return effectiveModeTable[mode]; } function buildModeState(): ModeState { @@ -470,6 +475,7 @@ export function createFDv2DataManagerBase( requests: platform.requests, encoding: platform.encoding!, serviceEndpoints: config.serviceEndpoints, + pollingPaths: pollingEndpoints, streamingPaths: streamingEndpoints, baseHeaders, queryParams, diff --git a/packages/shared/sdk-client/src/datasource/LDClientDataSystemOptions.ts b/packages/shared/sdk-client/src/datasource/LDClientDataSystemOptions.ts index c1595dcb22..639b875176 100644 --- a/packages/shared/sdk-client/src/datasource/LDClientDataSystemOptions.ts +++ b/packages/shared/sdk-client/src/datasource/LDClientDataSystemOptions.ts @@ -2,7 +2,7 @@ import { TypeValidators } from '@launchdarkly/js-sdk-common'; import type { PlatformDataSystemDefaults } from '../api/datasource'; import { anyOf, validatorOf } from '../configuration/validateOptions'; -import { connectionModeValidator } from './ConnectionModeConfig'; +import { connectionModesValidator, connectionModeValidator } from './ConnectionModeConfig'; const modeSwitchingValidators = { lifecycle: TypeValidators.Boolean, @@ -13,6 +13,7 @@ const dataSystemValidators = { initialConnectionMode: connectionModeValidator, backgroundConnectionMode: connectionModeValidator, automaticModeSwitching: anyOf(TypeValidators.Boolean, validatorOf(modeSwitchingValidators)), + connectionModes: connectionModesValidator, }; /** diff --git a/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts b/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts index d653bd59a5..5ca2a3a679 100644 --- a/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts +++ b/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts @@ -9,10 +9,10 @@ import { Storage, } from '@launchdarkly/js-sdk-common'; -import { InitializerEntry, SynchronizerEntry } from '../api/datasource'; +import { EndpointConfig, InitializerEntry, SynchronizerEntry } from '../api/datasource'; import { DataSourcePaths } from './DataSourceConfig'; import { createCacheInitializerFactory } from './fdv2/CacheInitializer'; -import { FDv2Requestor } from './fdv2/FDv2Requestor'; +import { FDv2Requestor, makeFDv2Requestor } from './fdv2/FDv2Requestor'; import { poll as fdv2Poll } from './fdv2/PollingBase'; import { createPollingInitializer } from './fdv2/PollingInitializer'; import { createPollingSynchronizer } from './fdv2/PollingSynchronizer'; @@ -35,6 +35,8 @@ export interface SourceFactoryContext { encoding: Encoding; /** Service endpoint configuration. */ serviceEndpoints: ServiceEndpoints; + /** The polling endpoint paths. */ + pollingPaths: DataSourcePaths; /** The streaming endpoint paths. */ streamingPaths: DataSourcePaths; /** Default HTTP headers. */ @@ -95,6 +97,49 @@ function createPingHandler(ctx: SourceFactoryContext): PingHandler { }; } +/** + * Create a {@link ServiceEndpoints} with per-entry endpoint overrides applied. + * Returns the original endpoints if no overrides are specified. + */ +function resolveEndpoints(ctx: SourceFactoryContext, endpoints?: EndpointConfig): ServiceEndpoints { + if (!endpoints?.pollingBaseUri && !endpoints?.streamingBaseUri) { + return ctx.serviceEndpoints; + } + return new ServiceEndpoints( + endpoints.streamingBaseUri ?? ctx.serviceEndpoints.streaming, + endpoints.pollingBaseUri ?? ctx.serviceEndpoints.polling, + ctx.serviceEndpoints.events, + ctx.serviceEndpoints.analyticsEventPath, + ctx.serviceEndpoints.diagnosticEventPath, + ctx.serviceEndpoints.includeAuthorizationHeader, + ctx.serviceEndpoints.payloadFilterKey, + ); +} + +/** + * Get the FDv2 requestor for a polling entry. If the entry has custom + * endpoints, creates a new requestor targeting those endpoints. Otherwise + * returns the shared requestor from the context. + */ +function resolvePollingRequestor( + ctx: SourceFactoryContext, + endpoints?: EndpointConfig, +): FDv2Requestor { + if (!endpoints?.pollingBaseUri) { + return ctx.requestor; + } + const overriddenEndpoints = resolveEndpoints(ctx, endpoints); + return makeFDv2Requestor( + ctx.plainContextString, + overriddenEndpoints, + ctx.pollingPaths, + ctx.requests, + ctx.encoding, + ctx.baseHeaders, + ctx.queryParams, + ); +} + /** * Creates a {@link SourceFactoryProvider} that handles `cache`, `polling`, * and `streaming` data source entries. @@ -106,16 +151,19 @@ export function createDefaultSourceFactoryProvider(): SourceFactoryProvider { ctx: SourceFactoryContext, ): InitializerFactory | undefined { switch (entry.type) { - case 'polling': + case 'polling': { + const requestor = resolvePollingRequestor(ctx, entry.endpoints); return (sg: () => string | undefined) => - createPollingInitializer(ctx.requestor, ctx.logger, sg); + createPollingInitializer(requestor, ctx.logger, sg); + } - case 'streaming': + case 'streaming': { + const entryEndpoints = resolveEndpoints(ctx, entry.endpoints); return (sg: () => string | undefined) => { const streamUriPath = ctx.streamingPaths.pathGet(ctx.encoding, ctx.plainContextString); const base = createStreamingBase({ requests: ctx.requests, - serviceEndpoints: ctx.serviceEndpoints, + serviceEndpoints: entryEndpoints, streamUriPath, parameters: ctx.queryParams, selectorGetter: sg, @@ -127,6 +175,7 @@ export function createDefaultSourceFactoryProvider(): SourceFactoryProvider { }); return createStreamingInitializer(base); }; + } case 'cache': return createCacheInitializerFactory({ @@ -149,17 +198,19 @@ export function createDefaultSourceFactoryProvider(): SourceFactoryProvider { switch (entry.type) { case 'polling': { const intervalMs = (entry.pollInterval ?? ctx.pollInterval) * 1000; + const requestor = resolvePollingRequestor(ctx, entry.endpoints); const factory = (sg: () => string | undefined) => - createPollingSynchronizer(ctx.requestor, ctx.logger, sg, intervalMs); + createPollingSynchronizer(requestor, ctx.logger, sg, intervalMs); return createSynchronizerSlot(factory); } case 'streaming': { + const entryEndpoints = resolveEndpoints(ctx, entry.endpoints); const factory = (sg: () => string | undefined) => { const streamUriPath = ctx.streamingPaths.pathGet(ctx.encoding, ctx.plainContextString); const base = createStreamingBase({ requests: ctx.requests, - serviceEndpoints: ctx.serviceEndpoints, + serviceEndpoints: entryEndpoints, streamUriPath, parameters: ctx.queryParams, selectorGetter: sg, diff --git a/packages/tooling/contract-test-utils/src/index.ts b/packages/tooling/contract-test-utils/src/index.ts index 82c54b0dcb..45c5daae95 100644 --- a/packages/tooling/contract-test-utils/src/index.ts +++ b/packages/tooling/contract-test-utils/src/index.ts @@ -15,6 +15,11 @@ export { type SDKConfigHooksParams, type SDKConfigProxyParams, type SDKConfigWrapper, + type SDKConfigDataSystem, + type SDKConfigConnectionModeConfig, + type SDKConfigModeDefinition, + type SDKConfigDataInitializer, + type SDKConfigDataSynchronizer, } from './types/ConfigParams'; export { makeLogger } from './logging/makeLogger'; export { ClientPool } from './server-side/ClientPool'; From 266104a2e8df37ca5fa87e1f475e83466186177f Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 17 Mar 2026 17:27:01 -0700 Subject: [PATCH 28/28] Trim failing tests. --- .../contract-tests/suppressions_datamode_changes.txt | 9 --------- 1 file changed, 9 deletions(-) diff --git a/packages/sdk/browser/contract-tests/suppressions_datamode_changes.txt b/packages/sdk/browser/contract-tests/suppressions_datamode_changes.txt index dae14d026b..37a64e0802 100644 --- a/packages/sdk/browser/contract-tests/suppressions_datamode_changes.txt +++ b/packages/sdk/browser/contract-tests/suppressions_datamode_changes.txt @@ -1,11 +1,8 @@ streaming/requests/method and headers/REPORT/http streaming/requests/URL path is computed correctly/no environment filter/base URI has no trailing slash/REPORT streaming/requests/URL path is computed correctly/no environment filter/base URI has a trailing slash/REPORT -streaming/requests/query parameters/evaluationReasons set to [none]/GET streaming/requests/query parameters/evaluationReasons set to [none]/REPORT -streaming/requests/query parameters/evaluationReasons set to false/GET streaming/requests/query parameters/evaluationReasons set to false/REPORT -streaming/requests/query parameters/evaluationReasons set to true/GET streaming/requests/query parameters/evaluationReasons set to true/REPORT streaming/requests/context properties/single kind minimal/REPORT streaming/requests/context properties/single kind with all attributes/REPORT @@ -13,18 +10,12 @@ streaming/requests/context properties/multi-kind/REPORT polling/requests/method and headers/REPORT/http polling/requests/URL path is computed correctly/no environment filter/base URI has no trailing slash/REPORT polling/requests/URL path is computed correctly/no environment filter/base URI has a trailing slash/REPORT -polling/requests/query parameters/evaluationReasons set to [none]/GET polling/requests/query parameters/evaluationReasons set to [none]/REPORT -polling/requests/query parameters/evaluationReasons set to false/GET polling/requests/query parameters/evaluationReasons set to false/REPORT -polling/requests/query parameters/evaluationReasons set to true/GET polling/requests/query parameters/evaluationReasons set to true/REPORT polling/requests/context properties/single kind minimal/REPORT -polling/requests/context properties/single kind with all attributes/GET polling/requests/context properties/single kind with all attributes/REPORT polling/requests/context properties/multi-kind/REPORT -tags/stream requests/{"applicationId":null,"applicationVersion":null} -tags/stream requests/{"applicationId":null,"applicationVersion":""} tags/stream requests/{"applicationId":null,"applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"} tags/stream requests/{"applicationId":null,"applicationVersion":"________________________________________________________________"} tags/stream requests/{"applicationId":"","applicationVersion":null}