Skip to content
Closed
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
cb14960
feat: Add experimental FDv2 configuration (unused)
kinyoklion Mar 10, 2026
9f88376
refactor: Use compound validator for dataSystem with built-in defaults
kinyoklion Mar 10, 2026
a90b962
fix: Lint fixes for dataSystem configuration
kinyoklion Mar 10, 2026
fa111d5
Merge branch 'main' into rlamb/SDK-1935/FDv2-configuration
kinyoklion Mar 10, 2026
73c6f2a
WIP: Example of this working together
keelerm84 Mar 5, 2026
109aeea
Fix interrupted state for non-status codes.
kinyoklion Mar 5, 2026
e7f5049
Tests
kinyoklion Mar 9, 2026
e2821cd
Fix basis.
kinyoklion Mar 9, 2026
a7413c0
rebase data system config
kinyoklion Mar 10, 2026
e40b0d5
Integrate mode switching and caching.
kinyoklion Mar 10, 2026
7bfa7ac
Merge branch 'main' into mk/NOTICKET/testing-fdv2
kinyoklion Mar 11, 2026
e2b7d15
Streaming control interface
kinyoklion Mar 11, 2026
9d242d4
Skip cache when bootstrap is available.
kinyoklion Mar 11, 2026
cee85ee
Add event flush on backgrounding.
kinyoklion Mar 11, 2026
0d92a78
remove flag_eval support; use flag-eval only
keelerm84 Mar 12, 2026
5ee0469
Merge branch 'main' into mk/NOTICKET/testing-fdv2
kinyoklion Mar 12, 2026
cfc1207
Remove debug logging from FDv2DataSource.
kinyoklion Mar 12, 2026
ea3eb1d
Commonize stream control input between browser and RN for FDv2.
kinyoklion Mar 12, 2026
67eff51
Merge branch 'main' into mk/NOTICKET/testing-fdv2
kinyoklion Mar 12, 2026
4f59d39
Increase package size limits
kinyoklion Mar 12, 2026
c603f56
Don't export internals
kinyoklion Mar 12, 2026
2a80fa7
Better type alignment
kinyoklion Mar 12, 2026
4eb5a59
Stricter validation and logging.
kinyoklion Mar 12, 2026
3e287df
Merge branch 'main' into mk/NOTICKET/testing-fdv2
kinyoklion Mar 13, 2026
bd80512
Remove browser specific data manager.
kinyoklion Mar 13, 2026
4824560
Add cache comment
kinyoklion Mar 13, 2026
304e5f3
Ensure streaming is off when setStreaming(false)
kinyoklion Mar 13, 2026
0a03954
Fix mode switching.
kinyoklion Mar 17, 2026
0d1b333
contract test changes - wip
tanderson-ld Mar 17, 2026
88ab6a4
Additional handling.
kinyoklion Mar 17, 2026
70bdf7d
Merge remote-tracking branch 'origin/mk/NOTICKET/testing-fdv2' into m…
kinyoklion Mar 17, 2026
04d2644
Connect withReasons.
kinyoklion Mar 17, 2026
6c1996d
Allow mode customization. Connect withReasons.
kinyoklion Mar 18, 2026
266104a
Trim failing tests.
kinyoklion Mar 18, 2026
a542dc1
More testing, add data source updates sink.
kinyoklion Mar 18, 2026
5759cef
No basis.
kinyoklion Mar 18, 2026
79fe48c
Merge origin/main into mk/NOTICKET/testing-fdv2
kinyoklion Mar 18, 2026
685aa67
Lint fix.
kinyoklion Mar 18, 2026
0b44efd
Second example for FDv2.
kinyoklion Mar 19, 2026
57525f3
Mock fix.
kinyoklion Mar 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions packages/sdk/browser/__tests__/BrowserClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/');
});
});
8 changes: 6 additions & 2 deletions packages/sdk/browser/example/src/app.ts
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We will want to remove this before we commit.

Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -24,7 +24,11 @@ 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, {
// @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}.`;
Expand Down
109 changes: 56 additions & 53 deletions packages/sdk/browser/src/BrowserClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -78,57 +79,63 @@ 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 = (
flagManager: FlagManager,
configuration: Configuration,
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,
);

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);

this.dataManager.setFlushCallback?.(() => this.flush());

this._plugins = validatedBrowserOptions.plugins;

if (validatedBrowserOptions.fetchGoals) {
Expand Down Expand Up @@ -281,18 +288,14 @@ 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);
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);
this.dataManager.setAutomaticStreamingState?.(hasListeners);
}

override on(eventName: LDEmitterEventName, listener: Function): void {
Expand Down
123 changes: 123 additions & 0 deletions packages/sdk/browser/src/BrowserFDv2DataManager.ts
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think, with a few tweaks, we can maybe eliminate this entirely.

Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
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<void> {
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);
}
}
}
27 changes: 27 additions & 0 deletions packages/shared/sdk-client/src/DataManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,33 @@ export interface DataManager {
* Closes the data manager. Any active connections are closed.
*/
close(): void;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may change when we determine how we are going to handle setConnectionMode.

/**
* 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;
}

/**
Expand Down
Loading
Loading