Skip to content

Commit 5d529f1

Browse files
committed
Merge branch 'rlamb/SDK-564/visibility-state-handling' of github.com:launchdarkly/js-server-sdk-private into rlamb/SDK-564/visibility-state-handling
2 parents cabf104 + 18467ca commit 5d529f1

File tree

11 files changed

+393
-10
lines changed

11 files changed

+393
-10
lines changed

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212

1313
import { BrowserClient } from '../src/BrowserClient';
1414
import { MockHasher } from './MockHasher';
15+
import { goodBootstrapDataWithReasons } from './testBootstrapData';
1516

1617
function mockResponse(value: string, statusCode: number) {
1718
const response: Response = {
@@ -257,4 +258,31 @@ describe('given a mock platform for a BrowserClient', () => {
257258
url: 'http://filtered.com',
258259
});
259260
});
261+
262+
it('can use bootstrap data', async () => {
263+
const client = new BrowserClient(
264+
'client-side-id',
265+
AutoEnvAttributes.Disabled,
266+
{
267+
streaming: false,
268+
logger,
269+
diagnosticOptOut: true,
270+
},
271+
platform,
272+
);
273+
await client.identify(
274+
{ kind: 'user', key: 'bob' },
275+
{
276+
bootstrap: goodBootstrapDataWithReasons,
277+
},
278+
);
279+
280+
expect(client.jsonVariationDetail('json', undefined)).toEqual({
281+
reason: {
282+
kind: 'OFF',
283+
},
284+
value: ['a', 'b', 'c', 'd'],
285+
variationIndex: 1,
286+
});
287+
});
260288
});

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import BrowserEncoding from '../src/platform/BrowserEncoding';
2424
import BrowserInfo from '../src/platform/BrowserInfo';
2525
import LocalStorage from '../src/platform/LocalStorage';
2626
import { MockHasher } from './MockHasher';
27+
import { goodBootstrapData } from './testBootstrapData';
2728

2829
global.TextEncoder = TextEncoder;
2930

@@ -123,6 +124,7 @@ describe('given a BrowserDataManager with mocked dependencies', () => {
123124
upsert: jest.fn(),
124125
on: jest.fn(),
125126
off: jest.fn(),
127+
setBootstrap: jest.fn(),
126128
} as unknown as jest.Mocked<FlagManager>;
127129

128130
browserConfig = validateOptions({}, logger);
@@ -314,6 +316,36 @@ describe('given a BrowserDataManager with mocked dependencies', () => {
314316
expect(platform.requests.createEventSource).not.toHaveBeenCalled();
315317
});
316318

319+
it('uses data from bootstrap and does not make an initial poll', async () => {
320+
const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
321+
const identifyOptions: BrowserIdentifyOptions = {
322+
bootstrap: goodBootstrapData,
323+
};
324+
const identifyResolve = jest.fn();
325+
const identifyReject = jest.fn();
326+
327+
flagManager.loadCached.mockResolvedValue(true);
328+
329+
await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions);
330+
331+
expect(logger.debug).toHaveBeenCalledWith(
332+
'[BrowserDataManager] Identify - Initialization completed from bootstrap',
333+
);
334+
335+
expect(flagManager.loadCached).not.toHaveBeenCalledWith(context);
336+
expect(identifyResolve).toHaveBeenCalled();
337+
expect(flagManager.init).not.toHaveBeenCalled();
338+
expect(flagManager.setBootstrap).toHaveBeenCalledWith(expect.anything(), {
339+
cat: { version: 2, flag: { version: 2, variation: 1, value: false } },
340+
json: { version: 3, flag: { version: 3, variation: 1, value: ['a', 'b', 'c', 'd'] } },
341+
killswitch: { version: 5, flag: { version: 5, variation: 0, value: true } },
342+
'my-boolean-flag': { version: 11, flag: { version: 11, variation: 1, value: false } },
343+
'string-flag': { version: 3, flag: { version: 3, variation: 1, value: 'is bob' } },
344+
});
345+
expect(platform.requests.createEventSource).not.toHaveBeenCalled();
346+
expect(platform.requests.fetch).not.toHaveBeenCalled();
347+
});
348+
317349
it('should identify from polling when there are no cached flags', async () => {
318350
const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
319351

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { jest } from '@jest/globals';
2+
3+
import { readFlagsFromBootstrap } from '../src/bootstrap';
4+
import { goodBootstrapData, goodBootstrapDataWithReasons } from './testBootstrapData';
5+
6+
it('can read valid bootstrap data', () => {
7+
const logger = {
8+
debug: jest.fn(),
9+
info: jest.fn(),
10+
warn: jest.fn(),
11+
error: jest.fn(),
12+
};
13+
14+
const readData = readFlagsFromBootstrap(logger, goodBootstrapData);
15+
expect(readData).toEqual({
16+
cat: { version: 2, flag: { version: 2, variation: 1, value: false } },
17+
json: { version: 3, flag: { version: 3, variation: 1, value: ['a', 'b', 'c', 'd'] } },
18+
killswitch: { version: 5, flag: { version: 5, variation: 0, value: true } },
19+
'my-boolean-flag': { version: 11, flag: { version: 11, variation: 1, value: false } },
20+
'string-flag': { version: 3, flag: { version: 3, variation: 1, value: 'is bob' } },
21+
});
22+
expect(logger.debug).not.toHaveBeenCalled();
23+
expect(logger.info).not.toHaveBeenCalled();
24+
expect(logger.warn).not.toHaveBeenCalled();
25+
expect(logger.error).not.toHaveBeenCalled();
26+
});
27+
28+
it('can read valid bootstrap data with reasons', () => {
29+
const logger = {
30+
debug: jest.fn(),
31+
info: jest.fn(),
32+
warn: jest.fn(),
33+
error: jest.fn(),
34+
};
35+
36+
const readData = readFlagsFromBootstrap(logger, goodBootstrapDataWithReasons);
37+
expect(readData).toEqual({
38+
cat: {
39+
version: 2,
40+
flag: {
41+
version: 2,
42+
variation: 1,
43+
value: false,
44+
reason: {
45+
kind: 'OFF',
46+
},
47+
},
48+
},
49+
json: {
50+
version: 3,
51+
flag: {
52+
version: 3,
53+
variation: 1,
54+
value: ['a', 'b', 'c', 'd'],
55+
reason: {
56+
kind: 'OFF',
57+
},
58+
},
59+
},
60+
killswitch: {
61+
version: 5,
62+
flag: {
63+
version: 5,
64+
variation: 0,
65+
value: true,
66+
reason: {
67+
kind: 'FALLTHROUGH',
68+
},
69+
},
70+
},
71+
'my-boolean-flag': {
72+
version: 11,
73+
flag: {
74+
version: 11,
75+
variation: 1,
76+
value: false,
77+
reason: {
78+
kind: 'OFF',
79+
},
80+
},
81+
},
82+
'string-flag': {
83+
version: 3,
84+
flag: {
85+
version: 3,
86+
variation: 1,
87+
value: 'is bob',
88+
reason: {
89+
kind: 'OFF',
90+
},
91+
},
92+
},
93+
});
94+
expect(logger.debug).not.toHaveBeenCalled();
95+
expect(logger.info).not.toHaveBeenCalled();
96+
expect(logger.warn).not.toHaveBeenCalled();
97+
expect(logger.error).not.toHaveBeenCalled();
98+
});
99+
100+
it('can read old bootstrap data', () => {
101+
const logger = {
102+
debug: jest.fn(),
103+
info: jest.fn(),
104+
warn: jest.fn(),
105+
error: jest.fn(),
106+
};
107+
108+
const oldData: any = { ...goodBootstrapData };
109+
delete oldData.$flagsState;
110+
111+
const readData = readFlagsFromBootstrap(logger, oldData);
112+
expect(readData).toEqual({
113+
cat: { version: 0, flag: { version: 0, value: false } },
114+
json: { version: 0, flag: { version: 0, value: ['a', 'b', 'c', 'd'] } },
115+
killswitch: { version: 0, flag: { version: 0, value: true } },
116+
'my-boolean-flag': { version: 0, flag: { version: 0, value: false } },
117+
'string-flag': { version: 0, flag: { version: 0, value: 'is bob' } },
118+
});
119+
expect(logger.debug).not.toHaveBeenCalled();
120+
expect(logger.info).not.toHaveBeenCalled();
121+
expect(logger.warn).toHaveBeenCalledWith(
122+
'LaunchDarkly client was initialized with bootstrap data that did not' +
123+
' include flag metadata. Events may not be sent correctly.',
124+
);
125+
expect(logger.warn).toHaveBeenCalledTimes(1);
126+
expect(logger.error).not.toHaveBeenCalled();
127+
});
128+
129+
it('can handle invalid bootstrap data', () => {
130+
const logger = {
131+
debug: jest.fn(),
132+
info: jest.fn(),
133+
warn: jest.fn(),
134+
error: jest.fn(),
135+
};
136+
137+
const invalid: any = { $valid: false, $flagsState: {} };
138+
139+
const readData = readFlagsFromBootstrap(logger, invalid);
140+
expect(readData).toEqual({});
141+
expect(logger.debug).not.toHaveBeenCalled();
142+
expect(logger.info).not.toHaveBeenCalled();
143+
expect(logger.warn).toHaveBeenCalledWith(
144+
'LaunchDarkly bootstrap data is not available because the back end' +
145+
' could not read the flags.',
146+
);
147+
expect(logger.warn).toHaveBeenCalledTimes(1);
148+
expect(logger.error).not.toHaveBeenCalled();
149+
});
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
export const goodBootstrapData = {
2+
cat: false,
3+
json: ['a', 'b', 'c', 'd'],
4+
killswitch: true,
5+
'my-boolean-flag': false,
6+
'string-flag': 'is bob',
7+
$flagsState: {
8+
cat: {
9+
variation: 1,
10+
version: 2,
11+
},
12+
json: {
13+
variation: 1,
14+
version: 3,
15+
},
16+
killswitch: {
17+
variation: 0,
18+
version: 5,
19+
},
20+
'my-boolean-flag': {
21+
variation: 1,
22+
version: 11,
23+
},
24+
'string-flag': {
25+
variation: 1,
26+
version: 3,
27+
},
28+
},
29+
$valid: true,
30+
};
31+
32+
export const goodBootstrapDataWithReasons = {
33+
cat: false,
34+
json: ['a', 'b', 'c', 'd'],
35+
killswitch: true,
36+
'my-boolean-flag': false,
37+
'string-flag': 'is bob',
38+
$flagsState: {
39+
cat: {
40+
variation: 1,
41+
version: 2,
42+
reason: {
43+
kind: 'OFF',
44+
},
45+
},
46+
json: {
47+
variation: 1,
48+
version: 3,
49+
reason: {
50+
kind: 'OFF',
51+
},
52+
},
53+
killswitch: {
54+
variation: 0,
55+
version: 5,
56+
reason: {
57+
kind: 'FALLTHROUGH',
58+
},
59+
},
60+
'my-boolean-flag': {
61+
variation: 1,
62+
version: 11,
63+
reason: {
64+
kind: 'OFF',
65+
},
66+
},
67+
'string-flag': {
68+
variation: 1,
69+
version: 3,
70+
reason: {
71+
kind: 'OFF',
72+
},
73+
},
74+
},
75+
$valid: true,
76+
};

packages/sdk/browser/src/BrowserDataManager.ts

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
Requestor,
1616
} from '@launchdarkly/js-client-sdk-common';
1717

18+
import { readFlagsFromBootstrap } from './bootstrap';
1819
import { BrowserIdentifyOptions } from './BrowserIdentifyOptions';
1920
import { ValidatedOptions } from './options';
2021

@@ -84,14 +85,27 @@ export default class BrowserDataManager extends BaseDataManager {
8485
this.setConnectionParams();
8586
}
8687
this.secureModeHash = browserIdentifyOptions?.hash;
87-
if (await this.flagManager.loadCached(context)) {
88-
this.debugLog('Identify - Flags loaded from cache. Continuing to initialize via a poll.');
88+
89+
if (browserIdentifyOptions?.bootstrap) {
90+
this.finishIdentifyFromBootstrap(context, browserIdentifyOptions.bootstrap, identifyResolve);
91+
} else {
92+
if (await this.flagManager.loadCached(context)) {
93+
this.debugLog('Identify - Flags loaded from cache. Continuing to initialize via a poll.');
94+
}
95+
const plainContextString = JSON.stringify(Context.toLDContext(context));
96+
const requestor = this.getRequestor(plainContextString);
97+
await this.finishIdentifyFromPoll(requestor, context, identifyResolve, identifyReject);
8998
}
90-
const plainContextString = JSON.stringify(Context.toLDContext(context));
91-
const requestor = this.getRequestor(plainContextString);
9299

93-
// TODO: Handle wait for network results in a meaningful way. SDK-707
100+
this.updateStreamingState();
101+
}
94102

103+
private async finishIdentifyFromPoll(
104+
requestor: Requestor,
105+
context: Context,
106+
identifyResolve: () => void,
107+
identifyReject: (err: Error) => void,
108+
) {
95109
try {
96110
this.dataSourceStatusManager.requestStateUpdate(DataSourceState.Initializing);
97111
const payload = await requestor.requestPayload();
@@ -113,8 +127,16 @@ export default class BrowserDataManager extends BaseDataManager {
113127
);
114128
identifyReject(e);
115129
}
130+
}
116131

117-
this.updateStreamingState();
132+
private finishIdentifyFromBootstrap(
133+
context: Context,
134+
bootstrap: unknown,
135+
identifyResolve: () => void,
136+
) {
137+
this.flagManager.setBootstrap(context, readFlagsFromBootstrap(this.logger, bootstrap));
138+
this.debugLog('Identify - Initialization completed from bootstrap');
139+
identifyResolve();
118140
}
119141

120142
setForcedStreaming(streaming?: boolean) {

packages/sdk/browser/src/BrowserIdentifyOptions.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,19 @@ export interface BrowserIdentifyOptions extends Omit<LDIdentifyOptions, 'waitFor
66
* (https://docs.launchdarkly.com/sdk/features/secure-mode#configuring-secure-mode-in-the-javascript-client-side-sdk).
77
*/
88
hash?: string;
9+
10+
/**
11+
* The initial set of flags to use until the remote set is retrieved.
12+
*
13+
* Bootstrap data can be generated by server SDKs. When bootstrap data is provided the SDK the
14+
* identification operation will complete without waiting for any values from LaunchDarkly and
15+
* the variation calls can be used immediately.
16+
*
17+
* If streaming is activated, either it is configured to always be used, or is activated
18+
* via setStreaming, or via the addition of change handlers, then a streaming connection will
19+
* subsequently be established.
20+
*
21+
* For more information, see the [SDK Reference Guide](https://docs.launchdarkly.com/sdk/features/bootstrapping#javascript).
22+
*/
23+
bootstrap?: unknown;
924
}

0 commit comments

Comments
 (0)