Skip to content

Commit 27f5d91

Browse files
committed
feat: Add basic secure mode support for browser SDK.
1 parent f2e5cbf commit 27f5d91

File tree

10 files changed

+272
-17
lines changed

10 files changed

+272
-17
lines changed
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { LDIdentifyOptions } from '@launchdarkly/js-client-sdk-common';
2+
3+
export interface BrowserIdentifyOptions extends Omit<LDIdentifyOptions, 'waitForNetworkresults'> {
4+
/**
5+
* The signed context key if you are using [Secure Mode]
6+
* (https://docs.launchdarkly.com/sdk/features/secure-mode#configuring-secure-mode-in-the-javascript-client-side-sdk).
7+
*/
8+
hash?: string;
9+
}

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

Lines changed: 91 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,13 @@ import {
1111
internal,
1212
LDEmitter,
1313
LDHeaders,
14-
LDIdentifyOptions,
1514
LDLogger,
1615
Platform,
1716
Response,
1817
ServiceEndpoints,
1918
} from '@launchdarkly/js-client-sdk-common';
2019

20+
import { BrowserIdentifyOptions } from '../BrowserIdentifyOptions';
2121
import BrowserDataManager from '../src/BrowserDataManager';
2222
import validateOptions, { ValidatedOptions } from '../src/options';
2323
import BrowserEncoding from '../src/platform/BrowserEncoding';
@@ -196,7 +196,7 @@ describe('given a BrowserDataManager with mocked dependencies', () => {
196196
);
197197

198198
const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
199-
const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false };
199+
const identifyOptions: BrowserIdentifyOptions = {};
200200
const identifyResolve = jest.fn();
201201
const identifyReject = jest.fn();
202202

@@ -205,9 +205,91 @@ describe('given a BrowserDataManager with mocked dependencies', () => {
205205
expect(platform.requests.createEventSource).toHaveBeenCalled();
206206
});
207207

208+
it('includes the secure mode hash for streaming requests', async () => {
209+
dataManager = new BrowserDataManager(
210+
platform,
211+
flagManager,
212+
'test-credential',
213+
config,
214+
validateOptions({ streaming: true }, logger),
215+
() => ({
216+
pathGet(encoding: Encoding, _plainContextString: string): string {
217+
return `/msdk/evalx/contexts/${base64UrlEncode(_plainContextString, encoding)}`;
218+
},
219+
pathReport(_encoding: Encoding, _plainContextString: string): string {
220+
return `/msdk/evalx/context`;
221+
},
222+
}),
223+
() => ({
224+
pathGet(encoding: Encoding, _plainContextString: string): string {
225+
return `/meval/${base64UrlEncode(_plainContextString, encoding)}`;
226+
},
227+
pathReport(_encoding: Encoding, _plainContextString: string): string {
228+
return `/meval`;
229+
},
230+
}),
231+
baseHeaders,
232+
emitter,
233+
diagnosticsManager,
234+
);
235+
236+
const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
237+
const identifyOptions: BrowserIdentifyOptions = { hash: 'potato' };
238+
const identifyResolve = jest.fn();
239+
const identifyReject = jest.fn();
240+
241+
await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions);
242+
243+
expect(platform.requests.createEventSource).toHaveBeenCalledWith(
244+
'/meval/eyJraW5kIjoidXNlciIsImtleSI6InRlc3QtdXNlciJ9?h=potato&withReasons=true',
245+
expect.anything(),
246+
);
247+
});
248+
249+
it('includes secure mode hash for initial poll request', async () => {
250+
dataManager = new BrowserDataManager(
251+
platform,
252+
flagManager,
253+
'test-credential',
254+
config,
255+
validateOptions({ streaming: false }, logger),
256+
() => ({
257+
pathGet(encoding: Encoding, _plainContextString: string): string {
258+
return `/msdk/evalx/contexts/${base64UrlEncode(_plainContextString, encoding)}`;
259+
},
260+
pathReport(_encoding: Encoding, _plainContextString: string): string {
261+
return `/msdk/evalx/context`;
262+
},
263+
}),
264+
() => ({
265+
pathGet(encoding: Encoding, _plainContextString: string): string {
266+
return `/meval/${base64UrlEncode(_plainContextString, encoding)}`;
267+
},
268+
pathReport(_encoding: Encoding, _plainContextString: string): string {
269+
return `/meval`;
270+
},
271+
}),
272+
baseHeaders,
273+
emitter,
274+
diagnosticsManager,
275+
);
276+
277+
const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
278+
const identifyOptions: BrowserIdentifyOptions = { hash: 'potato' };
279+
const identifyResolve = jest.fn();
280+
const identifyReject = jest.fn();
281+
282+
await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions);
283+
284+
expect(platform.requests.fetch).toHaveBeenCalledWith(
285+
'/msdk/evalx/contexts/eyJraW5kIjoidXNlciIsImtleSI6InRlc3QtdXNlciJ9?withReasons=true&h=potato',
286+
expect.anything(),
287+
);
288+
});
289+
208290
it('should load cached flags and continue to poll to complete identify', async () => {
209291
const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
210-
const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false };
292+
const identifyOptions: BrowserIdentifyOptions = {};
211293
const identifyResolve = jest.fn();
212294
const identifyReject = jest.fn();
213295

@@ -230,7 +312,7 @@ describe('given a BrowserDataManager with mocked dependencies', () => {
230312

231313
it('should identify from polling when there are no cached flags', async () => {
232314
const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
233-
const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false };
315+
const identifyOptions: BrowserIdentifyOptions = {};
234316
const identifyResolve = jest.fn();
235317
const identifyReject = jest.fn();
236318

@@ -253,7 +335,7 @@ describe('given a BrowserDataManager with mocked dependencies', () => {
253335

254336
it('creates a stream when streaming is enabled after construction', async () => {
255337
const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
256-
const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false };
338+
const identifyOptions: BrowserIdentifyOptions = {};
257339
const identifyResolve = jest.fn();
258340
const identifyReject = jest.fn();
259341

@@ -268,7 +350,7 @@ describe('given a BrowserDataManager with mocked dependencies', () => {
268350

269351
it('does not re-create the stream if it already running', async () => {
270352
const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
271-
const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false };
353+
const identifyOptions: BrowserIdentifyOptions = {};
272354
const identifyResolve = jest.fn();
273355
const identifyReject = jest.fn();
274356

@@ -296,7 +378,7 @@ describe('given a BrowserDataManager with mocked dependencies', () => {
296378

297379
it('starts a stream on demand when not forced on/off', async () => {
298380
const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
299-
const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false };
381+
const identifyOptions: BrowserIdentifyOptions = {};
300382
const identifyResolve = jest.fn();
301383
const identifyReject = jest.fn();
302384

@@ -315,7 +397,7 @@ describe('given a BrowserDataManager with mocked dependencies', () => {
315397

316398
it('does not start a stream when forced off', async () => {
317399
const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
318-
const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false };
400+
const identifyOptions: BrowserIdentifyOptions = {};
319401
const identifyResolve = jest.fn();
320402
const identifyReject = jest.fn();
321403

@@ -335,7 +417,7 @@ describe('given a BrowserDataManager with mocked dependencies', () => {
335417

336418
it('starts streaming on identify if the automatic state is true', async () => {
337419
const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
338-
const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false };
420+
const identifyOptions: BrowserIdentifyOptions = {};
339421
const identifyResolve = jest.fn();
340422
const identifyReject = jest.fn();
341423

packages/sdk/browser/src/BrowserClient.ts

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,22 +13,29 @@ import {
1313
LDHeaders,
1414
Platform,
1515
} from '@launchdarkly/js-client-sdk-common';
16-
import { LDIdentifyOptions } from '@launchdarkly/js-client-sdk-common/dist/api/LDIdentifyOptions';
1716
import { EventName } from '@launchdarkly/js-client-sdk-common/dist/LDEmitter';
1817

18+
import { BrowserIdentifyOptions as LDIdentifyOptions } from '../BrowserIdentifyOptions';
1919
import BrowserDataManager from './BrowserDataManager';
2020
import GoalManager from './goals/GoalManager';
2121
import { Goal, isClick } from './goals/Goals';
2222
import validateOptions, { BrowserOptions, filterToBaseOptions } from './options';
2323
import BrowserPlatform from './platform/BrowserPlatform';
2424

2525
/**
26-
* We are not supporting dynamically setting the connection mode on the LDClient.
27-
* The SDK does not support offline mode. Instead bootstrap data can be used.
26+
*
27+
* The LaunchDarkly SDK client object.
28+
*
29+
* Applications should configure the client at page load time and reuse the same instance.
30+
*
31+
* For more information, see the [SDK Reference Guide](https://docs.launchdarkly.com/sdk/client-side/javascript).
32+
*
33+
* @ignore Implementation Note: We are not supporting dynamically setting the connection mode on the LDClient.
34+
* @ignore Implementation Note: The SDK does not support offline mode. Instead bootstrap data can be used.
2835
*/
2936
export type LDClient = Omit<
3037
CommonClient,
31-
'setConnectionMode' | 'getConnectionMode' | 'getOffline'
38+
'setConnectionMode' | 'getConnectionMode' | 'getOffline' | 'identify'
3239
> & {
3340
/**
3441
* Specifies whether or not to open a streaming connection to LaunchDarkly for live flag updates.
@@ -40,9 +47,38 @@ export type LDClient = Omit<
4047
* This can also be set as the `streaming` property of {@link LDOptions}.
4148
*/
4249
setStreaming(streaming?: boolean): void;
50+
51+
/**
52+
* Identifies a context to LaunchDarkly.
53+
*
54+
* Unlike the server-side SDKs, the client-side JavaScript SDKs maintain a current context state,
55+
* which is set when you call `identify()`.
56+
*
57+
* Changing the current context also causes all feature flag values to be reloaded. Until that has
58+
* finished, calls to {@link variation} will still return flag values for the previous context. You can
59+
* await the Promise to determine when the new flag values are available.
60+
*
61+
* @param context
62+
* The LDContext object.
63+
* @param identifyOptions
64+
* Optional configuration. Please see {@link LDIdentifyOptions}.
65+
* @returns
66+
* A Promise which resolves when the flag values for the specified
67+
* context are available. It rejects when:
68+
*
69+
* 1. The context is unspecified or has no key.
70+
*
71+
* 2. The identify timeout is exceeded. In client SDKs this defaults to 5s.
72+
* You can customize this timeout with {@link LDIdentifyOptions | identifyOptions}.
73+
*
74+
* 3. A network error is encountered during initialization.
75+
*
76+
* @ignore Implementation Note: Browser implementation has different options.
77+
*/
78+
identify(context: LDContext, identifyOptions?: LDIdentifyOptions): Promise<void>;
4379
};
4480

45-
export class BrowserClient extends LDClientImpl {
81+
export class BrowserClient extends LDClientImpl implements LDClient {
4682
private readonly goalManager?: GoalManager;
4783

4884
constructor(

packages/sdk/browser/src/BrowserDataManager.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
Requestor,
1414
} from '@launchdarkly/js-client-sdk-common';
1515

16+
import { BrowserIdentifyOptions } from '../BrowserIdentifyOptions';
1617
import { ValidatedOptions } from './options';
1718

1819
const logTag = '[BrowserDataManager]';
@@ -22,6 +23,7 @@ export default class BrowserDataManager extends BaseDataManager {
2223
// Otherwise we automatically manage streaming state.
2324
private forcedStreaming?: boolean = undefined;
2425
private automaticStreamingState: boolean = false;
26+
private secureModeHash?: string;
2527

2628
// +-----------+-----------+---------------+
2729
// | forced | automatic | state |
@@ -68,9 +70,18 @@ export default class BrowserDataManager extends BaseDataManager {
6870
identifyResolve: () => void,
6971
identifyReject: (err: Error) => void,
7072
context: Context,
71-
_identifyOptions?: LDIdentifyOptions,
73+
identifyOptions?: LDIdentifyOptions,
7274
): Promise<void> {
7375
this.context = context;
76+
const browserIdentifyOptions = identifyOptions as BrowserIdentifyOptions;
77+
if (browserIdentifyOptions.hash) {
78+
this.setConnectionParams({
79+
queryParameters: [{ key: 'h', value: browserIdentifyOptions.hash }],
80+
});
81+
} else {
82+
this.setConnectionParams();
83+
}
84+
this.secureModeHash = browserIdentifyOptions.hash;
7485
if (await this.flagManager.loadCached(context)) {
7586
this.debugLog('Identify - Flags loaded from cache. Continuing to initialize via a poll.');
7687
}
@@ -162,6 +173,9 @@ export default class BrowserDataManager extends BaseDataManager {
162173
if (this.config.withReasons) {
163174
parameters.push({ key: 'withReasons', value: 'true' });
164175
}
176+
if (this.secureModeHash) {
177+
parameters.push({ key: 'h', value: this.secureModeHash });
178+
}
165179

166180
const headers: { [key: string]: string } = { ...this.baseHeaders };
167181
let body;

packages/shared/sdk-client/__tests__/polling/PollingProcessor.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ function makeConfig(
7575
pollInterval: number,
7676
withReasons: boolean,
7777
useReport: boolean,
78+
queryParameters?: { key: string; value: string }[],
7879
): PollingDataSourceConfig {
7980
return {
8081
credential: 'the-sdk-key',
@@ -91,6 +92,7 @@ function makeConfig(
9192
withReasons,
9293
useReport,
9394
pollInterval,
95+
queryParameters,
9496
};
9597
}
9698

@@ -109,6 +111,49 @@ it('makes no requests until it is started', () => {
109111
expect(requests.fetch).toHaveBeenCalledTimes(0);
110112
});
111113

114+
it('includes custom query parameters when specified', () => {
115+
const requests = makeRequests();
116+
117+
const polling = new PollingProcessor(
118+
'mockContextString',
119+
makeConfig(1, true, false, [
120+
{ key: 'custom', value: 'value' },
121+
{ key: 'custom2', value: 'value2' },
122+
]),
123+
requests,
124+
makeEncoding(),
125+
(_flags) => {},
126+
(_error) => {},
127+
);
128+
polling.start();
129+
130+
expect(requests.fetch).toHaveBeenCalledWith(
131+
'mockPollingEndpoint/poll/path/get?custom=value&custom2=value2&withReasons=true&filter=testPayloadFilterKey',
132+
expect.anything(),
133+
);
134+
polling.stop();
135+
});
136+
137+
it('works without any custom query parameters', () => {
138+
const requests = makeRequests();
139+
140+
const polling = new PollingProcessor(
141+
'mockContextString',
142+
makeConfig(1, true, false),
143+
requests,
144+
makeEncoding(),
145+
(_flags) => {},
146+
(_error) => {},
147+
);
148+
polling.start();
149+
150+
expect(requests.fetch).toHaveBeenCalledWith(
151+
'mockPollingEndpoint/poll/path/get?withReasons=true&filter=testPayloadFilterKey',
152+
expect.anything(),
153+
);
154+
polling.stop();
155+
});
156+
112157
it('polls immediately when started', () => {
113158
const requests = makeRequests();
114159

0 commit comments

Comments
 (0)