Skip to content

Commit 53f5bb8

Browse files
authored
feat: Refactor data source connection handling. (#591)
Most of this PR is refactoring to move data source handling our of the common code. This also moves functionality, such as `setConnectionMode` out of the common code. Currently the MobileDataManager is in the RN SDK, but it should be moved to some kind of non-browser common when we implement SDKs like node or electron. A large amount of the code in this PR is tests for data source handling and moving existing code. This PR does not add automatic starting/stopping of the stream. BEGIN_COMMIT_OVERRIDE feat: Refactor data source connection handling. feat: Add support for js-client-sdk style initialization. END_COMMIT_OVERRIDE
1 parent 7dfb14d commit 53f5bb8

38 files changed

+1643
-513
lines changed

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

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@ import {
44
AutoEnvAttributes,
55
EventSourceCapabilities,
66
EventSourceInitDict,
7-
Hasher,
87
LDLogger,
98
PlatformData,
109
Requests,
1110
SdkData,
1211
} from '@launchdarkly/js-client-sdk-common';
1312

1413
import { BrowserClient } from '../src/BrowserClient';
14+
import { MockHasher } from './MockHasher';
1515

1616
function mockResponse(value: string, statusCode: number) {
1717
const response: Response = {
@@ -79,18 +79,6 @@ function makeRequests(): Requests {
7979
};
8080
}
8181

82-
class MockHasher implements Hasher {
83-
update(_data: string): Hasher {
84-
return this;
85-
}
86-
digest?(_encoding: string): string {
87-
return 'hashed';
88-
}
89-
async asyncDigest?(_encoding: string): Promise<string> {
90-
return 'hashed';
91-
}
92-
}
93-
9482
describe('given a mock platform for a BrowserClient', () => {
9583
const logger: LDLogger = {
9684
debug: jest.fn(),
@@ -141,7 +129,7 @@ describe('given a mock platform for a BrowserClient', () => {
141129
'client-side-id',
142130
AutoEnvAttributes.Disabled,
143131
{
144-
initialConnectionMode: 'polling',
132+
streaming: false,
145133
logger,
146134
diagnosticOptOut: true,
147135
},
@@ -169,7 +157,7 @@ describe('given a mock platform for a BrowserClient', () => {
169157
'client-side-id',
170158
AutoEnvAttributes.Disabled,
171159
{
172-
initialConnectionMode: 'polling',
160+
streaming: false,
173161
logger,
174162
diagnosticOptOut: true,
175163
eventUrlTransformer: (url: string) =>
@@ -202,7 +190,7 @@ describe('given a mock platform for a BrowserClient', () => {
202190
'client-side-id',
203191
AutoEnvAttributes.Disabled,
204192
{
205-
initialConnectionMode: 'polling',
193+
streaming: false,
206194
logger,
207195
diagnosticOptOut: true,
208196
eventUrlTransformer: (url: string) =>
@@ -245,7 +233,7 @@ describe('given a mock platform for a BrowserClient', () => {
245233
'client-side-id',
246234
AutoEnvAttributes.Disabled,
247235
{
248-
initialConnectionMode: 'polling',
236+
streaming: false,
249237
logger,
250238
diagnosticOptOut: true,
251239
eventUrlTransformer: (url: string) =>
Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
import { jest } from '@jest/globals';
2+
import { TextEncoder } from 'node:util';
3+
4+
import {
5+
ApplicationTags,
6+
base64UrlEncode,
7+
Configuration,
8+
Context,
9+
Encoding,
10+
FlagManager,
11+
internal,
12+
LDEmitter,
13+
LDHeaders,
14+
LDIdentifyOptions,
15+
LDLogger,
16+
Platform,
17+
Response,
18+
ServiceEndpoints,
19+
} from '@launchdarkly/js-client-sdk-common';
20+
21+
import BrowserDataManager from '../src/BrowserDataManager';
22+
import validateOptions, { ValidatedOptions } from '../src/options';
23+
import BrowserEncoding from '../src/platform/BrowserEncoding';
24+
import BrowserInfo from '../src/platform/BrowserInfo';
25+
import LocalStorage from '../src/platform/LocalStorage';
26+
import { MockHasher } from './MockHasher';
27+
28+
global.TextEncoder = TextEncoder;
29+
30+
function mockResponse(value: string, statusCode: number) {
31+
const response: Response = {
32+
headers: {
33+
// @ts-ignore
34+
get: jest.fn(),
35+
// @ts-ignore
36+
keys: jest.fn(),
37+
// @ts-ignore
38+
values: jest.fn(),
39+
// @ts-ignore
40+
entries: jest.fn(),
41+
// @ts-ignore
42+
has: jest.fn(),
43+
},
44+
status: statusCode,
45+
text: () => Promise.resolve(value),
46+
json: () => Promise.resolve(JSON.parse(value)),
47+
};
48+
return Promise.resolve(response);
49+
}
50+
51+
function mockFetch(value: string, statusCode: number = 200) {
52+
const f = jest.fn();
53+
// @ts-ignore
54+
f.mockResolvedValue(mockResponse(value, statusCode));
55+
return f;
56+
}
57+
58+
describe('given a BrowserDataManager with mocked dependencies', () => {
59+
let platform: jest.Mocked<Platform>;
60+
let flagManager: jest.Mocked<FlagManager>;
61+
let config: Configuration;
62+
let browserConfig: ValidatedOptions;
63+
let baseHeaders: LDHeaders;
64+
let emitter: jest.Mocked<LDEmitter>;
65+
let diagnosticsManager: jest.Mocked<internal.DiagnosticsManager>;
66+
let dataManager: BrowserDataManager;
67+
let logger: LDLogger;
68+
beforeEach(() => {
69+
logger = {
70+
error: jest.fn(),
71+
warn: jest.fn(),
72+
info: jest.fn(),
73+
debug: jest.fn(),
74+
};
75+
config = {
76+
logger,
77+
baseUri: 'string',
78+
eventsUri: 'string',
79+
streamUri: 'string',
80+
maxCachedContexts: 5,
81+
capacity: 100,
82+
diagnosticRecordingInterval: 1000,
83+
flushInterval: 1000,
84+
streamInitialReconnectDelay: 1000,
85+
allAttributesPrivate: false,
86+
debug: true,
87+
diagnosticOptOut: false,
88+
sendEvents: false,
89+
sendLDHeaders: true,
90+
useReport: false,
91+
withReasons: true,
92+
privateAttributes: [],
93+
tags: new ApplicationTags({}),
94+
serviceEndpoints: new ServiceEndpoints('', ''),
95+
pollInterval: 1000,
96+
userAgentHeaderName: 'user-agent',
97+
trackEventModifier: (event) => event,
98+
};
99+
const mockedFetch = mockFetch('{"flagA": true}', 200);
100+
platform = {
101+
crypto: {
102+
createHash: () => new MockHasher(),
103+
randomUUID: () => '123',
104+
},
105+
info: new BrowserInfo(),
106+
requests: {
107+
createEventSource: jest.fn((streamUri: string = '', options: any = {}) => ({
108+
streamUri,
109+
options,
110+
onclose: jest.fn(),
111+
addEventListener: jest.fn(),
112+
close: jest.fn(),
113+
})),
114+
fetch: mockedFetch,
115+
getEventSourceCapabilities: jest.fn(),
116+
},
117+
storage: new LocalStorage(config.logger),
118+
encoding: new BrowserEncoding(),
119+
} as unknown as jest.Mocked<Platform>;
120+
121+
flagManager = {
122+
loadCached: jest.fn(),
123+
get: jest.fn(),
124+
getAll: jest.fn(),
125+
init: jest.fn(),
126+
upsert: jest.fn(),
127+
on: jest.fn(),
128+
off: jest.fn(),
129+
} as unknown as jest.Mocked<FlagManager>;
130+
131+
browserConfig = validateOptions({ streaming: false }, logger);
132+
baseHeaders = {};
133+
emitter = {
134+
emit: jest.fn(),
135+
} as unknown as jest.Mocked<LDEmitter>;
136+
diagnosticsManager = {} as unknown as jest.Mocked<internal.DiagnosticsManager>;
137+
138+
dataManager = new BrowserDataManager(
139+
platform,
140+
flagManager,
141+
'test-credential',
142+
config,
143+
browserConfig,
144+
() => ({
145+
pathGet(encoding: Encoding, _plainContextString: string): string {
146+
return `/msdk/evalx/contexts/${base64UrlEncode(_plainContextString, encoding)}`;
147+
},
148+
pathReport(_encoding: Encoding, _plainContextString: string): string {
149+
return `/msdk/evalx/context`;
150+
},
151+
}),
152+
() => ({
153+
pathGet(encoding: Encoding, _plainContextString: string): string {
154+
return `/meval/${base64UrlEncode(_plainContextString, encoding)}`;
155+
},
156+
pathReport(_encoding: Encoding, _plainContextString: string): string {
157+
return `/meval`;
158+
},
159+
}),
160+
baseHeaders,
161+
emitter,
162+
diagnosticsManager,
163+
);
164+
});
165+
166+
afterEach(() => {
167+
jest.resetAllMocks();
168+
});
169+
170+
it('creates an event source when stream is true', async () => {
171+
dataManager = new BrowserDataManager(
172+
platform,
173+
flagManager,
174+
'test-credential',
175+
config,
176+
validateOptions({ streaming: true }, logger),
177+
() => ({
178+
pathGet(encoding: Encoding, _plainContextString: string): string {
179+
return `/msdk/evalx/contexts/${base64UrlEncode(_plainContextString, encoding)}`;
180+
},
181+
pathReport(_encoding: Encoding, _plainContextString: string): string {
182+
return `/msdk/evalx/context`;
183+
},
184+
}),
185+
() => ({
186+
pathGet(encoding: Encoding, _plainContextString: string): string {
187+
return `/meval/${base64UrlEncode(_plainContextString, encoding)}`;
188+
},
189+
pathReport(_encoding: Encoding, _plainContextString: string): string {
190+
return `/meval`;
191+
},
192+
}),
193+
baseHeaders,
194+
emitter,
195+
diagnosticsManager,
196+
);
197+
198+
const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
199+
const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false };
200+
const identifyResolve = jest.fn();
201+
const identifyReject = jest.fn();
202+
203+
await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions);
204+
205+
expect(platform.requests.createEventSource).toHaveBeenCalled();
206+
});
207+
208+
it('should load cached flags and continue to poll to complete identify', async () => {
209+
const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
210+
const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false };
211+
const identifyResolve = jest.fn();
212+
const identifyReject = jest.fn();
213+
214+
flagManager.loadCached.mockResolvedValue(true);
215+
216+
await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions);
217+
218+
expect(logger.debug).toHaveBeenCalledWith(
219+
'[BrowserDataManager] Identify - Flags loaded from cache. Continuing to initialize via a poll.',
220+
);
221+
222+
expect(flagManager.loadCached).toHaveBeenCalledWith(context);
223+
expect(identifyResolve).toHaveBeenCalled();
224+
expect(flagManager.init).toHaveBeenCalledWith(
225+
expect.anything(),
226+
expect.objectContaining({ flagA: { flag: true, version: undefined } }),
227+
);
228+
expect(platform.requests.createEventSource).not.toHaveBeenCalled();
229+
});
230+
231+
it('should identify from polling when there are no cached flags', async () => {
232+
const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
233+
const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false };
234+
const identifyResolve = jest.fn();
235+
const identifyReject = jest.fn();
236+
237+
flagManager.loadCached.mockResolvedValue(false);
238+
239+
await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions);
240+
241+
expect(logger.debug).not.toHaveBeenCalledWith(
242+
'Identify - Flags loaded from cache. Continuing to initialize via a poll.',
243+
);
244+
245+
expect(flagManager.loadCached).toHaveBeenCalledWith(context);
246+
expect(identifyResolve).toHaveBeenCalled();
247+
expect(flagManager.init).toHaveBeenCalledWith(
248+
expect.anything(),
249+
expect.objectContaining({ flagA: { flag: true, version: undefined } }),
250+
);
251+
expect(platform.requests.createEventSource).not.toHaveBeenCalled();
252+
});
253+
254+
it('creates a stream when streaming is enabled after construction', async () => {
255+
const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
256+
const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false };
257+
const identifyResolve = jest.fn();
258+
const identifyReject = jest.fn();
259+
260+
flagManager.loadCached.mockResolvedValue(false);
261+
262+
await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions);
263+
264+
expect(platform.requests.createEventSource).not.toHaveBeenCalled();
265+
dataManager.startDataSource();
266+
expect(platform.requests.createEventSource).toHaveBeenCalled();
267+
});
268+
269+
it('does not re-create the stream if it already running', async () => {
270+
const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
271+
const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false };
272+
const identifyResolve = jest.fn();
273+
const identifyReject = jest.fn();
274+
275+
flagManager.loadCached.mockResolvedValue(false);
276+
277+
await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions);
278+
279+
expect(platform.requests.createEventSource).not.toHaveBeenCalled();
280+
dataManager.startDataSource();
281+
dataManager.startDataSource();
282+
expect(platform.requests.createEventSource).toHaveBeenCalledTimes(1);
283+
expect(logger.debug).toHaveBeenCalledWith(
284+
'[BrowserDataManager] Update processor already active. Not changing state.',
285+
);
286+
});
287+
288+
it('does not start a stream if identify has not been called', async () => {
289+
expect(platform.requests.createEventSource).not.toHaveBeenCalled();
290+
dataManager.startDataSource();
291+
expect(platform.requests.createEventSource).not.toHaveBeenCalledTimes(1);
292+
expect(logger.debug).toHaveBeenCalledWith(
293+
'[BrowserDataManager] Context not set, not starting update processor.',
294+
);
295+
});
296+
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Hasher } from '@launchdarkly/js-client-sdk-common';
2+
3+
export class MockHasher implements Hasher {
4+
update(_data: string): Hasher {
5+
return this;
6+
}
7+
digest?(_encoding: string): string {
8+
return 'hashed';
9+
}
10+
async asyncDigest?(_encoding: string): Promise<string> {
11+
return 'hashed';
12+
}
13+
}

packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ function makeSdkConfig(options: SDKConfigParams, tag: string) {
2929
}
3030

3131
if (options.polling) {
32-
cf.initialConnectionMode = 'polling';
3332
if (options.polling.baseUri) {
3433
cf.baseUri = options.polling.baseUri;
3534
}
@@ -42,7 +41,7 @@ function makeSdkConfig(options: SDKConfigParams, tag: string) {
4241
if (options.streaming.baseUri) {
4342
cf.streamUri = options.streaming.baseUri;
4443
}
45-
cf.initialConnectionMode = 'streaming';
44+
cf.streaming = true;
4645
cf.streamInitialReconnectDelay = maybeTime(options.streaming.initialRetryDelayMs);
4746
}
4847

0 commit comments

Comments
 (0)