Skip to content

Commit 84c62b9

Browse files
keelerm84kinyoklion
authored andcommitted
WIP: Example of this working together
1 parent bbdd6c6 commit 84c62b9

16 files changed

Lines changed: 350 additions & 101 deletions

packages/sdk/browser/example/src/app.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ div.appendChild(document.createTextNode('No flag evaluations yet'));
2424
statusBox.appendChild(document.createTextNode('Initializing...'));
2525

2626
const main = async () => {
27-
const ldclient = createClient(clientSideID, context);
27+
const ldclient = createClient(clientSideID, context, {
28+
useFDv2: true,
29+
});
2830
const render = () => {
2931
const flagValue = ldclient.variation(flagKey, false);
3032
const label = `The ${flagKey} feature flag evaluates to ${flagValue}.`;

packages/sdk/browser/src/BrowserClient.ts

Lines changed: 67 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323

2424
import { getHref } from './BrowserApi';
2525
import BrowserDataManager from './BrowserDataManager';
26+
import BrowserFDv2DataManager from './BrowserFDv2DataManager';
2627
import { BrowserIdentifyOptions as LDIdentifyOptions } from './BrowserIdentifyOptions';
2728
import { registerStateDetection } from './BrowserStateDetector';
2829
import GoalManager from './goals/GoalManager';
@@ -77,53 +78,64 @@ class BrowserClientImpl extends LDClientImpl {
7778
const { eventUrlTransformer } = validatedBrowserOptions;
7879
const endpoints = browserFdv1Endpoints(clientSideId);
7980

80-
super(
81-
clientSideId,
82-
autoEnvAttributes,
83-
platform,
84-
baseOptionsWithDefaults,
85-
(
86-
flagManager: FlagManager,
87-
configuration: Configuration,
88-
baseHeaders: LDHeaders,
89-
emitter: LDEmitter,
90-
diagnosticsManager?: internal.DiagnosticsManager,
91-
) =>
92-
new BrowserDataManager(
93-
platform,
94-
flagManager,
95-
clientSideId,
96-
configuration,
97-
validatedBrowserOptions,
98-
endpoints.polling,
99-
endpoints.streaming,
100-
baseHeaders,
101-
emitter,
102-
diagnosticsManager,
81+
const dataManagerFactory = validatedBrowserOptions.useFDv2
82+
? (
83+
flagManager: FlagManager,
84+
configuration: Configuration,
85+
baseHeaders: LDHeaders,
86+
emitter: LDEmitter,
87+
_diagnosticsManager?: internal.DiagnosticsManager,
88+
) =>
89+
new BrowserFDv2DataManager(
90+
platform,
91+
flagManager,
92+
clientSideId,
93+
configuration,
94+
baseHeaders,
95+
emitter,
96+
)
97+
: (
98+
flagManager: FlagManager,
99+
configuration: Configuration,
100+
baseHeaders: LDHeaders,
101+
emitter: LDEmitter,
102+
diagnosticsManager?: internal.DiagnosticsManager,
103+
) =>
104+
new BrowserDataManager(
105+
platform,
106+
flagManager,
107+
clientSideId,
108+
configuration,
109+
validatedBrowserOptions,
110+
endpoints.polling,
111+
endpoints.streaming,
112+
baseHeaders,
113+
emitter,
114+
diagnosticsManager,
115+
);
116+
117+
super(clientSideId, autoEnvAttributes, platform, baseOptionsWithDefaults, dataManagerFactory, {
118+
// This logic is derived from https://github.com/launchdarkly/js-sdk-common/blob/main/src/PersistentFlagStore.js
119+
getLegacyStorageKeys: () =>
120+
getAllStorageKeys().filter((key) => key.startsWith(`ld:${clientSideId}:`)),
121+
analyticsEventPath: `/events/bulk/${clientSideId}`,
122+
diagnosticEventPath: `/events/diagnostic/${clientSideId}`,
123+
includeAuthorizationHeader: false,
124+
highTimeoutThreshold: 5,
125+
userAgentHeaderName: 'x-launchdarkly-user-agent',
126+
trackEventModifier: (event: internal.InputCustomEvent) =>
127+
new internal.InputCustomEvent(
128+
event.context,
129+
event.key,
130+
event.data,
131+
event.metricValue,
132+
event.samplingRatio,
133+
eventUrlTransformer(getHref()),
103134
),
104-
{
105-
// This logic is derived from https://github.com/launchdarkly/js-sdk-common/blob/main/src/PersistentFlagStore.js
106-
getLegacyStorageKeys: () =>
107-
getAllStorageKeys().filter((key) => key.startsWith(`ld:${clientSideId}:`)),
108-
analyticsEventPath: `/events/bulk/${clientSideId}`,
109-
diagnosticEventPath: `/events/diagnostic/${clientSideId}`,
110-
includeAuthorizationHeader: false,
111-
highTimeoutThreshold: 5,
112-
userAgentHeaderName: 'x-launchdarkly-user-agent',
113-
trackEventModifier: (event: internal.InputCustomEvent) =>
114-
new internal.InputCustomEvent(
115-
event.context,
116-
event.key,
117-
event.data,
118-
event.metricValue,
119-
event.samplingRatio,
120-
eventUrlTransformer(getHref()),
121-
),
122-
getImplementationHooks: (environmentMetadata: LDPluginEnvironmentMetadata) =>
123-
internal.safeGetHooks(logger, environmentMetadata, validatedBrowserOptions.plugins),
124-
credentialType: 'clientSideId',
125-
},
126-
);
135+
getImplementationHooks: (environmentMetadata: LDPluginEnvironmentMetadata) =>
136+
internal.safeGetHooks(logger, environmentMetadata, validatedBrowserOptions.plugins),
137+
credentialType: 'clientSideId',
138+
});
127139

128140
this.setEventSendingEnabled(true, false);
129141

@@ -281,16 +293,18 @@ class BrowserClientImpl extends LDClientImpl {
281293
setStreaming(streaming?: boolean): void {
282294
// With FDv2 we may want to consider if we support connection mode directly.
283295
// Maybe with an extension to connection mode for 'automatic'.
284-
const browserDataManager = this.dataManager as BrowserDataManager;
285-
browserDataManager.setForcedStreaming(streaming);
296+
if (this.dataManager instanceof BrowserDataManager) {
297+
this.dataManager.setForcedStreaming(streaming);
298+
}
286299
}
287300

288301
private _updateAutomaticStreamingState() {
289-
const browserDataManager = this.dataManager as BrowserDataManager;
290-
const hasListeners = this.emitter
291-
.eventNames()
292-
.some((name) => name.startsWith('change:') || name === 'change');
293-
browserDataManager.setAutomaticStreamingState(hasListeners);
302+
if (this.dataManager instanceof BrowserDataManager) {
303+
const hasListeners = this.emitter
304+
.eventNames()
305+
.some((name) => name.startsWith('change:') || name === 'change');
306+
this.dataManager.setAutomaticStreamingState(hasListeners);
307+
}
294308
}
295309

296310
override on(eventName: LDEmitterEventName, listener: Function): void {
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import {
2+
Configuration,
3+
Context,
4+
createDataSourceStatusManager,
5+
createFDv2DataSource,
6+
createPollingInitializer,
7+
createPollingSynchronizer,
8+
createStreamingBase,
9+
createStreamingSynchronizer,
10+
createSynchronizerSlot,
11+
DataManager,
12+
DataSourceStatusManager,
13+
FDv2DataSource,
14+
fdv2Endpoints,
15+
fdv2Poll,
16+
flagEvalPayloadToItemDescriptors,
17+
FlagManager,
18+
internal,
19+
LDEmitter,
20+
LDHeaders,
21+
LDIdentifyOptions,
22+
LDLogger,
23+
makeFDv2Requestor,
24+
Platform,
25+
} from '@launchdarkly/js-client-sdk-common';
26+
27+
import { BrowserIdentifyOptions } from './BrowserIdentifyOptions';
28+
29+
const logTag = '[BrowserFDv2DataManager]';
30+
31+
/**
32+
* A DataManager that uses the FDv2 protocol for flag delivery.
33+
*
34+
* Uses the FDv2DataSource orchestrator with:
35+
* - Polling initializer (fast one-shot for initial data)
36+
* - Streaming synchronizer (primary, for live updates)
37+
* - Polling synchronizer (fallback)
38+
*/
39+
export default class BrowserFDv2DataManager implements DataManager {
40+
private _dataSource?: FDv2DataSource;
41+
private _selector?: string;
42+
private _closed = false;
43+
private readonly _logger: LDLogger;
44+
private readonly _statusManager: DataSourceStatusManager;
45+
46+
constructor(
47+
private readonly _platform: Platform,
48+
private readonly _flagManager: FlagManager,
49+
private readonly _credential: string,
50+
private readonly _config: Configuration,
51+
private readonly _baseHeaders: LDHeaders,
52+
emitter: LDEmitter,
53+
) {
54+
this._logger = _config.logger;
55+
this._statusManager = createDataSourceStatusManager(emitter);
56+
}
57+
58+
async identify(
59+
identifyResolve: () => void,
60+
identifyReject: (err: Error) => void,
61+
context: Context,
62+
identifyOptions?: LDIdentifyOptions,
63+
): Promise<void> {
64+
if (this._closed) {
65+
this._logger.debug(`${logTag} Identify called after data manager was closed.`);
66+
return;
67+
}
68+
69+
// Tear down previous data source if any.
70+
this._dataSource?.close();
71+
this._dataSource = undefined;
72+
this._selector = undefined;
73+
74+
const plainContextString = JSON.stringify(Context.toLDContext(context));
75+
const endpoints = fdv2Endpoints();
76+
77+
// Build query params: auth (required for browser — no auth header) and secure mode hash.
78+
const queryParams: { key: string; value: string }[] = [
79+
{ key: 'auth', value: this._credential },
80+
];
81+
const browserIdentifyOptions = identifyOptions as BrowserIdentifyOptions | undefined;
82+
if (browserIdentifyOptions?.hash) {
83+
queryParams.push({ key: 'h', value: browserIdentifyOptions.hash });
84+
}
85+
86+
const requestor = makeFDv2Requestor(
87+
plainContextString,
88+
this._config.serviceEndpoints,
89+
endpoints.polling(),
90+
this._platform.requests,
91+
this._platform.encoding!,
92+
this._baseHeaders,
93+
queryParams,
94+
);
95+
96+
const selectorGetter = () => this._selector;
97+
98+
const dataCallback = (payload: internal.Payload) => {
99+
this._logger.debug(
100+
`${logTag} dataCallback: type=${payload.type}, updates=${payload.updates.length}, state=${payload.state}`,
101+
);
102+
103+
// Track selector for subsequent basis requests.
104+
if (payload.state) {
105+
this._selector = payload.state;
106+
}
107+
108+
if (payload.type === 'none') {
109+
// 304 / no changes — nothing to apply.
110+
return;
111+
}
112+
113+
const descriptors = flagEvalPayloadToItemDescriptors(payload.updates);
114+
this._logger.debug(`${logTag} descriptors: ${JSON.stringify(Object.keys(descriptors))}`);
115+
116+
if (payload.type === 'full') {
117+
this._flagManager.init(context, descriptors);
118+
} else {
119+
// 'partial' — incremental updates
120+
Object.entries(descriptors).forEach(([key, descriptor]) => {
121+
this._logger.debug(`${logTag} upserting: key=${key}, version=${descriptor.version}`);
122+
this._flagManager.upsert(context, key, descriptor);
123+
});
124+
}
125+
};
126+
127+
// Polling initializer — fast one-shot for initial data.
128+
const pollingInitFactory = (sg: () => string | undefined) =>
129+
createPollingInitializer(requestor, this._logger, sg);
130+
131+
// Streaming synchronizer — primary, for live updates.
132+
const streamingEndpoints = endpoints.streaming();
133+
const streamingSyncFactory = (_sg: () => string | undefined) => {
134+
const streamUriPath = streamingEndpoints.pathGet(
135+
this._platform.encoding!,
136+
plainContextString,
137+
);
138+
const base = createStreamingBase({
139+
requests: this._platform.requests,
140+
serviceEndpoints: this._config.serviceEndpoints,
141+
streamUriPath,
142+
parameters: queryParams,
143+
headers: this._baseHeaders,
144+
initialRetryDelayMillis: this._config.streamInitialReconnectDelay * 1000,
145+
logger: this._logger,
146+
pingHandler: {
147+
handlePing: () => fdv2Poll(requestor, selectorGetter(), false, this._logger),
148+
},
149+
});
150+
return createStreamingSynchronizer(base);
151+
};
152+
153+
// Polling synchronizer — fallback if streaming fails.
154+
const pollingSyncFactory = (sg: () => string | undefined) =>
155+
createPollingSynchronizer(requestor, this._logger, sg, this._config.pollInterval * 1000);
156+
157+
this._dataSource = createFDv2DataSource({
158+
initializerFactories: [pollingInitFactory],
159+
synchronizerSlots: [
160+
createSynchronizerSlot(streamingSyncFactory),
161+
createSynchronizerSlot(pollingSyncFactory),
162+
],
163+
dataCallback,
164+
statusManager: this._statusManager,
165+
selectorGetter,
166+
logger: this._logger,
167+
// Shorter fallback for easier manual testing (default is 120s).
168+
fallbackTimeoutMs: 10_000,
169+
});
170+
171+
try {
172+
await this._dataSource.start();
173+
identifyResolve();
174+
} catch (err) {
175+
identifyReject(err instanceof Error ? err : new Error(String(err)));
176+
}
177+
}
178+
179+
close(): void {
180+
this._closed = true;
181+
this._dataSource?.close();
182+
this._dataSource = undefined;
183+
}
184+
}

packages/sdk/browser/src/options.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,15 @@ export interface BrowserOptions extends Omit<LDOptionsBase, 'initialConnectionMo
5959
* A list of plugins to be used with the SDK.
6060
*/
6161
plugins?: LDPlugin[];
62+
63+
/**
64+
* Opt-in to the FDv2 data delivery protocol. When enabled, the SDK uses
65+
* the new `/sdk/poll/eval` and `/sdk/stream/eval` endpoints with the
66+
* FDv2 protocol handler and orchestrator.
67+
*
68+
* Default: false
69+
*/
70+
useFDv2?: boolean;
6271
}
6372

6473
export interface ValidatedOptions {
@@ -67,20 +76,23 @@ export interface ValidatedOptions {
6776
streaming?: boolean;
6877
automaticBackgroundHandling?: boolean;
6978
plugins: LDPlugin[];
79+
useFDv2: boolean;
7080
}
7181

7282
const optDefaults = {
7383
fetchGoals: true,
7484
eventUrlTransformer: (url: string) => url,
7585
streaming: undefined,
7686
plugins: [],
87+
useFDv2: false,
7788
};
7889

7990
const validators: { [Property in keyof BrowserOptions]: TypeValidator | undefined } = {
8091
fetchGoals: TypeValidators.Boolean,
8192
eventUrlTransformer: TypeValidators.Function,
8293
streaming: TypeValidators.Boolean,
8394
plugins: TypeValidators.createTypeArray('LDPlugin', {}),
95+
useFDv2: TypeValidators.Boolean,
8496
};
8597

8698
function withBrowserDefaults(opts: BrowserOptions): BrowserOptions {

packages/shared/sdk-client/__tests__/datasource/fdv2/PollingBase.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,7 @@ describe('given a partial (changes) transfer', () => {
456456
{
457457
event: 'put-object',
458458
data: {
459-
kind: 'flagEval',
459+
kind: 'flag-eval',
460460
key: 'updatedFlag',
461461
version: 5,
462462
object: { value: 'new-value', trackEvents: true },
@@ -499,7 +499,7 @@ describe('given a delete-object event', () => {
499499
{
500500
event: 'delete-object',
501501
data: {
502-
kind: 'flagEval',
502+
kind: 'flag-eval',
503503
key: 'deletedFlag',
504504
version: 3,
505505
},

0 commit comments

Comments
 (0)