Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add basic secure mode support for browser SDK. #598

Merged
merged 11 commits into from
Oct 2, 2024
103 changes: 92 additions & 11 deletions packages/sdk/browser/__tests__/BrowserDataManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ import {
internal,
LDEmitter,
LDHeaders,
LDIdentifyOptions,
LDLogger,
Platform,
Response,
ServiceEndpoints,
} from '@launchdarkly/js-client-sdk-common';

import BrowserDataManager from '../src/BrowserDataManager';
import { BrowserIdentifyOptions } from '../src/BrowserIdentifyOptions';
import validateOptions, { ValidatedOptions } from '../src/options';
import BrowserEncoding from '../src/platform/BrowserEncoding';
import BrowserInfo from '../src/platform/BrowserInfo';
Expand Down Expand Up @@ -193,7 +193,7 @@ describe('given a BrowserDataManager with mocked dependencies', () => {
);

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

Expand All @@ -202,9 +202,91 @@ describe('given a BrowserDataManager with mocked dependencies', () => {
expect(platform.requests.createEventSource).toHaveBeenCalled();
});

it('includes the secure mode hash for streaming requests', async () => {
dataManager = new BrowserDataManager(
platform,
flagManager,
'test-credential',
config,
validateOptions({ streaming: true }, logger),
() => ({
pathGet(encoding: Encoding, _plainContextString: string): string {
return `/msdk/evalx/contexts/${base64UrlEncode(_plainContextString, encoding)}`;
},
pathReport(_encoding: Encoding, _plainContextString: string): string {
return `/msdk/evalx/context`;
},
}),
() => ({
pathGet(encoding: Encoding, _plainContextString: string): string {
return `/meval/${base64UrlEncode(_plainContextString, encoding)}`;
},
pathReport(_encoding: Encoding, _plainContextString: string): string {
return `/meval`;
},
}),
baseHeaders,
emitter,
diagnosticsManager,
);

const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
const identifyOptions: BrowserIdentifyOptions = { hash: 'potato' };
const identifyResolve = jest.fn();
const identifyReject = jest.fn();

await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions);

expect(platform.requests.createEventSource).toHaveBeenCalledWith(
'/meval/eyJraW5kIjoidXNlciIsImtleSI6InRlc3QtdXNlciJ9?h=potato&withReasons=true',
expect.anything(),
);
});

it('includes secure mode hash for initial poll request', async () => {
dataManager = new BrowserDataManager(
platform,
flagManager,
'test-credential',
config,
validateOptions({ streaming: false }, logger),
() => ({
pathGet(encoding: Encoding, _plainContextString: string): string {
return `/msdk/evalx/contexts/${base64UrlEncode(_plainContextString, encoding)}`;
},
pathReport(_encoding: Encoding, _plainContextString: string): string {
return `/msdk/evalx/context`;
},
}),
() => ({
pathGet(encoding: Encoding, _plainContextString: string): string {
return `/meval/${base64UrlEncode(_plainContextString, encoding)}`;
},
pathReport(_encoding: Encoding, _plainContextString: string): string {
return `/meval`;
},
}),
baseHeaders,
emitter,
diagnosticsManager,
);

const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
const identifyOptions: BrowserIdentifyOptions = { hash: 'potato' };
const identifyResolve = jest.fn();
const identifyReject = jest.fn();

await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions);

expect(platform.requests.fetch).toHaveBeenCalledWith(
'/msdk/evalx/contexts/eyJraW5kIjoidXNlciIsImtleSI6InRlc3QtdXNlciJ9?withReasons=true&h=potato',
expect.anything(),
);
});

it('should load cached flags and continue to poll to complete identify', async () => {
const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false };

flagManager.loadCached.mockResolvedValue(true);

let identifyResolve: () => void;
Expand All @@ -216,7 +298,7 @@ describe('given a BrowserDataManager with mocked dependencies', () => {
identifyReject = jest.fn();

// this is the function under test
dataManager.identify(identifyResolve, identifyReject, context, identifyOptions);
dataManager.identify(identifyResolve, identifyReject, context, {});
});

expect(logger.debug).toHaveBeenCalledWith(
Expand All @@ -234,7 +316,6 @@ describe('given a BrowserDataManager with mocked dependencies', () => {

it('should identify from polling when there are no cached flags', async () => {
const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false };

let identifyResolve: () => void;
let identifyReject: (err: Error) => void;
Expand All @@ -245,7 +326,7 @@ describe('given a BrowserDataManager with mocked dependencies', () => {
identifyReject = jest.fn();

// this is the function under test
dataManager.identify(identifyResolve, identifyReject, context, identifyOptions);
dataManager.identify(identifyResolve, identifyReject, context, {});
});

expect(logger.debug).not.toHaveBeenCalledWith(
Expand All @@ -263,7 +344,7 @@ describe('given a BrowserDataManager with mocked dependencies', () => {

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

Expand All @@ -278,7 +359,7 @@ describe('given a BrowserDataManager with mocked dependencies', () => {

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

Expand Down Expand Up @@ -306,7 +387,7 @@ describe('given a BrowserDataManager with mocked dependencies', () => {

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

Expand All @@ -325,7 +406,7 @@ describe('given a BrowserDataManager with mocked dependencies', () => {

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

Expand All @@ -345,7 +426,7 @@ describe('given a BrowserDataManager with mocked dependencies', () => {

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

Expand Down
48 changes: 43 additions & 5 deletions packages/sdk/browser/src/BrowserClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,31 @@ import {
LDHeaders,
Platform,
} from '@launchdarkly/js-client-sdk-common';
import { LDIdentifyOptions } from '@launchdarkly/js-client-sdk-common/dist/api/LDIdentifyOptions';
import { EventName } from '@launchdarkly/js-client-sdk-common/dist/LDEmitter';

import BrowserDataManager from './BrowserDataManager';
import { BrowserIdentifyOptions as LDIdentifyOptions } from './BrowserIdentifyOptions';
import GoalManager from './goals/GoalManager';
import { Goal, isClick } from './goals/Goals';
import validateOptions, { BrowserOptions, filterToBaseOptions } from './options';
import BrowserPlatform from './platform/BrowserPlatform';

/**
* We are not supporting dynamically setting the connection mode on the LDClient.
* The SDK does not support offline mode. Instead bootstrap data can be used.
*
* The LaunchDarkly SDK client object.
*
* Applications should configure the client at page load time and reuse the same instance.
*
* For more information, see the [SDK Reference Guide](https://docs.launchdarkly.com/sdk/client-side/javascript).
*
* @ignore Implementation Note: We are not supporting dynamically setting the connection mode on the LDClient.
* @ignore Implementation Note: The SDK does not support offline mode. Instead bootstrap data can be used.
* @ignore Implementation Note: The browser SDK has different identify options, so omits the base implementation
* @ignore from the interface.
*/
export type LDClient = Omit<
CommonClient,
'setConnectionMode' | 'getConnectionMode' | 'getOffline'
'setConnectionMode' | 'getConnectionMode' | 'getOffline' | 'identify'
> & {
/**
* Specifies whether or not to open a streaming connection to LaunchDarkly for live flag updates.
Expand All @@ -40,9 +49,38 @@ export type LDClient = Omit<
* This can also be set as the `streaming` property of {@link LDOptions}.
*/
setStreaming(streaming?: boolean): void;

/**
* Identifies a context to LaunchDarkly.
*
* Unlike the server-side SDKs, the client-side JavaScript SDKs maintain a current context state,
* which is set when you call `identify()`.
*
* Changing the current context also causes all feature flag values to be reloaded. Until that has
* finished, calls to {@link variation} will still return flag values for the previous context. You can
* await the Promise to determine when the new flag values are available.
*
* @param context
* The LDContext object.
* @param identifyOptions
* Optional configuration. Please see {@link LDIdentifyOptions}.
* @returns
* A Promise which resolves when the flag values for the specified
* context are available. It rejects when:
*
* 1. The context is unspecified or has no key.
*
* 2. The identify timeout is exceeded. In client SDKs this defaults to 5s.
* You can customize this timeout with {@link LDIdentifyOptions | identifyOptions}.
*
* 3. A network error is encountered during initialization.
*
* @ignore Implementation Note: Browser implementation has different options.
*/
identify(context: LDContext, identifyOptions?: LDIdentifyOptions): Promise<void>;
};

export class BrowserClient extends LDClientImpl {
export class BrowserClient extends LDClientImpl implements LDClient {
private readonly goalManager?: GoalManager;

constructor(
Expand Down
16 changes: 15 additions & 1 deletion packages/sdk/browser/src/BrowserDataManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
Requestor,
} from '@launchdarkly/js-client-sdk-common';

import { BrowserIdentifyOptions } from './BrowserIdentifyOptions';
import { ValidatedOptions } from './options';

const logTag = '[BrowserDataManager]';
Expand All @@ -24,6 +25,7 @@ export default class BrowserDataManager extends BaseDataManager {
// Otherwise we automatically manage streaming state.
private forcedStreaming?: boolean = undefined;
private automaticStreamingState: boolean = false;
private secureModeHash?: string;

// +-----------+-----------+---------------+
// | forced | automatic | state |
Expand Down Expand Up @@ -70,9 +72,18 @@ export default class BrowserDataManager extends BaseDataManager {
identifyResolve: () => void,
identifyReject: (err: Error) => void,
context: Context,
_identifyOptions?: LDIdentifyOptions,
identifyOptions?: LDIdentifyOptions,
): Promise<void> {
this.context = context;
const browserIdentifyOptions = identifyOptions as BrowserIdentifyOptions | undefined;
if (browserIdentifyOptions?.hash) {
this.setConnectionParams({
queryParameters: [{ key: 'h', value: browserIdentifyOptions.hash }],
});
} else {
this.setConnectionParams();
}
this.secureModeHash = browserIdentifyOptions?.hash;
if (await this.flagManager.loadCached(context)) {
this.debugLog('Identify - Flags loaded from cache. Continuing to initialize via a poll.');
}
Expand Down Expand Up @@ -177,6 +188,9 @@ export default class BrowserDataManager extends BaseDataManager {
if (this.config.withReasons) {
parameters.push({ key: 'withReasons', value: 'true' });
}
if (this.secureModeHash) {
parameters.push({ key: 'h', value: this.secureModeHash });
}

const headers: { [key: string]: string } = { ...this.baseHeaders };
let body;
Expand Down
9 changes: 9 additions & 0 deletions packages/sdk/browser/src/BrowserIdentifyOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { LDIdentifyOptions } from '@launchdarkly/js-client-sdk-common';

export interface BrowserIdentifyOptions extends Omit<LDIdentifyOptions, 'waitForNetworkResults'> {
/**
* The signed context key if you are using [Secure Mode]
* (https://docs.launchdarkly.com/sdk/features/secure-mode#configuring-secure-mode-in-the-javascript-client-side-sdk).
*/
hash?: string;
}
2 changes: 2 additions & 0 deletions packages/sdk/browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
// The exported LDClient and LDOptions are the browser specific implementations.
// These shadow the common implementations.
import { BrowserClient, LDClient } from './BrowserClient';
import { BrowserIdentifyOptions as LDIdentifyOptions } from './BrowserIdentifyOptions';
import { BrowserOptions as LDOptions } from './options';

export {
Expand All @@ -32,6 +33,7 @@ export {
LDEvaluationDetail,
LDEvaluationDetailTyped,
LDEvaluationReason,
LDIdentifyOptions,
};

export function init(clientSideId: string, options?: LDOptions): LDClient {
Expand Down
Loading
Loading