Skip to content

Commit f2e5cbf

Browse files
authored
feat: Browser-SDK Automatically start streaming based on event handlers. (#592)
1 parent 79d0b3f commit f2e5cbf

File tree

6 files changed

+163
-27
lines changed

6 files changed

+163
-27
lines changed

packages/sdk/browser/__tests__/BrowserDataManager.test.ts

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ describe('given a BrowserDataManager with mocked dependencies', () => {
128128
off: jest.fn(),
129129
} as unknown as jest.Mocked<FlagManager>;
130130

131-
browserConfig = validateOptions({ streaming: false }, logger);
131+
browserConfig = validateOptions({}, logger);
132132
baseHeaders = {};
133133
emitter = {
134134
emit: jest.fn(),
@@ -262,7 +262,7 @@ describe('given a BrowserDataManager with mocked dependencies', () => {
262262
await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions);
263263

264264
expect(platform.requests.createEventSource).not.toHaveBeenCalled();
265-
dataManager.startDataSource();
265+
dataManager.setForcedStreaming(true);
266266
expect(platform.requests.createEventSource).toHaveBeenCalled();
267267
});
268268

@@ -277,8 +277,8 @@ describe('given a BrowserDataManager with mocked dependencies', () => {
277277
await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions);
278278

279279
expect(platform.requests.createEventSource).not.toHaveBeenCalled();
280-
dataManager.startDataSource();
281-
dataManager.startDataSource();
280+
dataManager.setForcedStreaming(true);
281+
dataManager.setForcedStreaming(true);
282282
expect(platform.requests.createEventSource).toHaveBeenCalledTimes(1);
283283
expect(logger.debug).toHaveBeenCalledWith(
284284
'[BrowserDataManager] Update processor already active. Not changing state.',
@@ -287,10 +287,66 @@ describe('given a BrowserDataManager with mocked dependencies', () => {
287287

288288
it('does not start a stream if identify has not been called', async () => {
289289
expect(platform.requests.createEventSource).not.toHaveBeenCalled();
290-
dataManager.startDataSource();
290+
dataManager.setForcedStreaming(true);
291291
expect(platform.requests.createEventSource).not.toHaveBeenCalledTimes(1);
292292
expect(logger.debug).toHaveBeenCalledWith(
293293
'[BrowserDataManager] Context not set, not starting update processor.',
294294
);
295295
});
296+
297+
it('starts a stream on demand when not forced on/off', async () => {
298+
const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
299+
const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false };
300+
const identifyResolve = jest.fn();
301+
const identifyReject = jest.fn();
302+
303+
flagManager.loadCached.mockResolvedValue(false);
304+
305+
await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions);
306+
307+
expect(platform.requests.createEventSource).not.toHaveBeenCalled();
308+
dataManager.setAutomaticStreamingState(true);
309+
expect(platform.requests.createEventSource).toHaveBeenCalled();
310+
expect(logger.debug).toHaveBeenCalledWith('[BrowserDataManager] Starting update processor.');
311+
expect(logger.debug).toHaveBeenCalledWith(
312+
'[BrowserDataManager] Updating streaming state. forced(undefined) automatic(true)',
313+
);
314+
});
315+
316+
it('does not start a stream when forced off', async () => {
317+
const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
318+
const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false };
319+
const identifyResolve = jest.fn();
320+
const identifyReject = jest.fn();
321+
322+
dataManager.setForcedStreaming(false);
323+
324+
flagManager.loadCached.mockResolvedValue(false);
325+
326+
await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions);
327+
328+
expect(platform.requests.createEventSource).not.toHaveBeenCalled();
329+
dataManager.setAutomaticStreamingState(true);
330+
expect(platform.requests.createEventSource).not.toHaveBeenCalled();
331+
expect(logger.debug).toHaveBeenCalledWith(
332+
'[BrowserDataManager] Updating streaming state. forced(false) automatic(true)',
333+
);
334+
});
335+
336+
it('starts streaming on identify if the automatic state is true', async () => {
337+
const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
338+
const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false };
339+
const identifyResolve = jest.fn();
340+
const identifyReject = jest.fn();
341+
342+
dataManager.setForcedStreaming(undefined);
343+
dataManager.setAutomaticStreamingState(true);
344+
expect(platform.requests.createEventSource).not.toHaveBeenCalled();
345+
346+
flagManager.loadCached.mockResolvedValue(false);
347+
348+
await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions);
349+
350+
expect(platform.requests.createEventSource).toHaveBeenCalled();
351+
});
296352
});

packages/sdk/browser/src/BrowserClient.ts

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import {
22
AutoEnvAttributes,
33
base64UrlEncode,
4-
BasicLogger,
54
LDClient as CommonClient,
65
Configuration,
6+
createSafeLogger,
77
Encoding,
88
FlagManager,
99
internal,
@@ -14,6 +14,7 @@ import {
1414
Platform,
1515
} from '@launchdarkly/js-client-sdk-common';
1616
import { LDIdentifyOptions } from '@launchdarkly/js-client-sdk-common/dist/api/LDIdentifyOptions';
17+
import { EventName } from '@launchdarkly/js-client-sdk-common/dist/LDEmitter';
1718

1819
import BrowserDataManager from './BrowserDataManager';
1920
import GoalManager from './goals/GoalManager';
@@ -29,24 +30,40 @@ export type LDClient = Omit<
2930
CommonClient,
3031
'setConnectionMode' | 'getConnectionMode' | 'getOffline'
3132
> & {
32-
setStreaming(streaming: boolean): void;
33+
/**
34+
* Specifies whether or not to open a streaming connection to LaunchDarkly for live flag updates.
35+
*
36+
* If this is true, the client will always attempt to maintain a streaming connection; if false,
37+
* it never will. If you leave the value undefined (the default), the client will open a streaming
38+
* connection if you subscribe to `"change"` or `"change:flag-key"` events (see {@link LDClient.on}).
39+
*
40+
* This can also be set as the `streaming` property of {@link LDOptions}.
41+
*/
42+
setStreaming(streaming?: boolean): void;
3343
};
3444

3545
export class BrowserClient extends LDClientImpl {
3646
private readonly goalManager?: GoalManager;
47+
3748
constructor(
3849
private readonly clientSideId: string,
3950
autoEnvAttributes: AutoEnvAttributes,
4051
options: BrowserOptions = {},
4152
overridePlatform?: Platform,
4253
) {
4354
const { logger: customLogger, debug } = options;
55+
// Overrides the default logger from the common implementation.
4456
const logger =
4557
customLogger ??
46-
new BasicLogger({
47-
level: debug ? 'debug' : 'info',
58+
createSafeLogger({
59+
// eslint-disable-next-line no-console
60+
debug: debug ? console.debug : () => {},
61+
// eslint-disable-next-line no-console
62+
info: console.info,
4863
// eslint-disable-next-line no-console
49-
destination: console.log,
64+
warn: console.warn,
65+
// eslint-disable-next-line no-console
66+
error: console.error,
5067
});
5168

5269
// TODO: Use the already-configured baseUri from the SDK config. SDK-560
@@ -59,7 +76,7 @@ export class BrowserClient extends LDClientImpl {
5976
clientSideId,
6077
autoEnvAttributes,
6178
platform,
62-
filterToBaseOptions(options),
79+
filterToBaseOptions({ ...options, logger }),
6380
(
6481
flagManager: FlagManager,
6582
configuration: Configuration,
@@ -164,12 +181,27 @@ export class BrowserClient extends LDClientImpl {
164181
this.goalManager?.startTracking();
165182
}
166183

167-
setStreaming(streaming: boolean): void {
184+
setStreaming(streaming?: boolean): void {
185+
// With FDv2 we may want to consider if we support connection mode directly.
186+
// Maybe with an extension to connection mode for 'automatic'.
168187
const browserDataManager = this.dataManager as BrowserDataManager;
169-
if (streaming) {
170-
browserDataManager.startDataSource();
171-
} else {
172-
browserDataManager.stopDataSource();
173-
}
188+
browserDataManager.setForcedStreaming(streaming);
189+
}
190+
191+
private updateAutomaticStreamingState() {
192+
const browserDataManager = this.dataManager as BrowserDataManager;
193+
// This will need changed if support for listening to individual flag change
194+
// events it added.
195+
browserDataManager.setAutomaticStreamingState(!!this.emitter.listenerCount('change'));
196+
}
197+
198+
override on(eventName: EventName, listener: Function): void {
199+
super.on(eventName, listener);
200+
this.updateAutomaticStreamingState();
201+
}
202+
203+
override off(eventName: EventName, listener: Function): void {
204+
super.off(eventName, listener);
205+
this.updateAutomaticStreamingState();
174206
}
175207
}

packages/sdk/browser/src/BrowserDataManager.ts

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,22 @@ import { ValidatedOptions } from './options';
1818
const logTag = '[BrowserDataManager]';
1919

2020
export default class BrowserDataManager extends BaseDataManager {
21+
// If streaming is forced on or off, then we follow that setting.
22+
// Otherwise we automatically manage streaming state.
23+
private forcedStreaming?: boolean = undefined;
24+
private automaticStreamingState: boolean = false;
25+
26+
// +-----------+-----------+---------------+
27+
// | forced | automatic | state |
28+
// +-----------+-----------+---------------+
29+
// | true | false | streaming |
30+
// | true | true | streaming |
31+
// | false | true | not streaming |
32+
// | false | false | not streaming |
33+
// | undefined | true | streaming |
34+
// | undefined | false | not streaming |
35+
// +-----------+-----------+---------------+
36+
2137
constructor(
2238
platform: Platform,
2339
flagManager: FlagManager,
@@ -41,6 +57,7 @@ export default class BrowserDataManager extends BaseDataManager {
4157
emitter,
4258
diagnosticsManager,
4359
);
60+
this.forcedStreaming = browserConfig.streaming;
4461
}
4562

4663
private debugLog(message: any, ...args: any[]) {
@@ -71,17 +88,43 @@ export default class BrowserDataManager extends BaseDataManager {
7188
identifyReject(e);
7289
}
7390

74-
if (this.browserConfig.streaming) {
75-
this.setupConnection(context);
91+
this.updateStreamingState();
92+
}
93+
94+
setForcedStreaming(streaming?: boolean) {
95+
this.forcedStreaming = streaming;
96+
this.updateStreamingState();
97+
}
98+
99+
setAutomaticStreamingState(streaming: boolean) {
100+
this.automaticStreamingState = streaming;
101+
this.updateStreamingState();
102+
}
103+
104+
private updateStreamingState() {
105+
const shouldBeStreaming =
106+
this.forcedStreaming || (this.automaticStreamingState && this.forcedStreaming === undefined);
107+
108+
this.debugLog(
109+
`Updating streaming state. forced(${this.forcedStreaming}) automatic(${this.automaticStreamingState})`,
110+
);
111+
112+
if (shouldBeStreaming) {
113+
this.startDataSource();
114+
} else {
115+
this.stopDataSource();
76116
}
77117
}
78118

79-
stopDataSource() {
119+
private stopDataSource() {
120+
if (this.updateProcessor) {
121+
this.debugLog('Stopping update processor.');
122+
}
80123
this.updateProcessor?.close();
81124
this.updateProcessor = undefined;
82125
}
83126

84-
startDataSource() {
127+
private startDataSource() {
85128
if (this.updateProcessor) {
86129
this.debugLog('Update processor already active. Not changing state.');
87130
return;
@@ -132,5 +175,4 @@ export default class BrowserDataManager extends BaseDataManager {
132175
const uri = getPollingUri(this.config.serviceEndpoints, path, parameters);
133176
return new Requestor(this.platform.requests, uri, headers, method, body);
134177
}
135-
// TODO: Automatically start streaming if event handlers are registered.
136178
}

packages/shared/sdk-client/__tests__/LDClientImpl.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,9 @@ describe('sdk-client object', () => {
268268
const carContext2: LDContext = { kind: 'car', key: 'test-car-2' };
269269
await ldc.identify(carContext2);
270270

271-
expect(emitter.listenerCount('change')).toEqual(1);
271+
// No default listeners. This is important for clients to be able to determine if there are
272+
// any listeners and act on that information.
273+
expect(emitter.listenerCount('change')).toEqual(0);
272274
expect(emitter.listenerCount('error')).toEqual(1);
273275
});
274276

packages/shared/sdk-client/src/LDClientImpl.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export default class LDClientImpl implements LDClient {
5353

5454
private eventFactoryDefault = new EventFactory(false);
5555
private eventFactoryWithReasons = new EventFactory(true);
56-
private emitter: LDEmitter;
56+
protected emitter: LDEmitter;
5757
private flagManager: FlagManager;
5858

5959
private eventSendingEnabled: boolean = false;
@@ -105,15 +105,13 @@ export default class LDClientImpl implements LDClient {
105105
this.diagnosticsManager,
106106
);
107107
this.emitter = new LDEmitter();
108-
this.emitter.on('change', (c: LDContext, changedKeys: string[]) => {
109-
this.logger.debug(`change: context: ${JSON.stringify(c)}, flags: ${changedKeys}`);
110-
});
111108
this.emitter.on('error', (c: LDContext, err: any) => {
112109
this.logger.error(`error: ${err}, context: ${JSON.stringify(c)}`);
113110
});
114111

115112
this.flagManager.on((context, flagKeys) => {
116113
const ldContext = Context.toLDContext(context);
114+
this.logger.debug(`change: context: ${JSON.stringify(ldContext)}, flags: ${flagKeys}`);
117115
this.emitter.emit('change', ldContext, flagKeys);
118116
});
119117

packages/shared/sdk-client/src/LDEmitter.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ import { LDLogger } from '@launchdarkly/js-sdk-common';
22

33
export type EventName = 'error' | 'change';
44

5+
/**
6+
* Implementation Note: There should not be any default listeners for change events in a client
7+
* implementation. Default listeners mean a client cannot determine when there are actual
8+
* application developer provided listeners. If we require default listeners, then we should add
9+
* a system to allow listeners which have counts independent of the primary listener counts.
10+
*/
511
export default class LDEmitter {
612
private listeners: Map<EventName, Function[]> = new Map();
713

0 commit comments

Comments
 (0)