diff --git a/packages/sdk/browser/__tests__/BrowserDataManager.test.ts b/packages/sdk/browser/__tests__/BrowserDataManager.test.ts index 3fcbd7f69..c863de92d 100644 --- a/packages/sdk/browser/__tests__/BrowserDataManager.test.ts +++ b/packages/sdk/browser/__tests__/BrowserDataManager.test.ts @@ -11,7 +11,6 @@ import { internal, LDEmitter, LDHeaders, - LDIdentifyOptions, LDLogger, Platform, Response, @@ -19,6 +18,7 @@ import { } 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'; @@ -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(); @@ -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; @@ -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( @@ -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; @@ -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( @@ -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(); @@ -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(); @@ -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(); @@ -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(); @@ -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(); diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index 7e76bd2f7..47a1c4a28 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -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. @@ -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; }; -export class BrowserClient extends LDClientImpl { +export class BrowserClient extends LDClientImpl implements LDClient { private readonly goalManager?: GoalManager; constructor( diff --git a/packages/sdk/browser/src/BrowserDataManager.ts b/packages/sdk/browser/src/BrowserDataManager.ts index 2e535c1ee..197d5b00d 100644 --- a/packages/sdk/browser/src/BrowserDataManager.ts +++ b/packages/sdk/browser/src/BrowserDataManager.ts @@ -15,6 +15,7 @@ import { Requestor, } from '@launchdarkly/js-client-sdk-common'; +import { BrowserIdentifyOptions } from './BrowserIdentifyOptions'; import { ValidatedOptions } from './options'; const logTag = '[BrowserDataManager]'; @@ -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 | @@ -70,9 +72,18 @@ export default class BrowserDataManager extends BaseDataManager { identifyResolve: () => void, identifyReject: (err: Error) => void, context: Context, - _identifyOptions?: LDIdentifyOptions, + identifyOptions?: LDIdentifyOptions, ): Promise { 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.'); } @@ -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; diff --git a/packages/sdk/browser/src/BrowserIdentifyOptions.ts b/packages/sdk/browser/src/BrowserIdentifyOptions.ts new file mode 100644 index 000000000..458178fe6 --- /dev/null +++ b/packages/sdk/browser/src/BrowserIdentifyOptions.ts @@ -0,0 +1,9 @@ +import { LDIdentifyOptions } from '@launchdarkly/js-client-sdk-common'; + +export interface BrowserIdentifyOptions extends Omit { + /** + * 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; +} diff --git a/packages/sdk/browser/src/index.ts b/packages/sdk/browser/src/index.ts index 26f5e703b..d171664d6 100644 --- a/packages/sdk/browser/src/index.ts +++ b/packages/sdk/browser/src/index.ts @@ -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 { @@ -32,6 +33,7 @@ export { LDEvaluationDetail, LDEvaluationDetailTyped, LDEvaluationReason, + LDIdentifyOptions, }; export function init(clientSideId: string, options?: LDOptions): LDClient { diff --git a/packages/shared/sdk-client/__tests__/polling/PollingProcessor.test.ts b/packages/shared/sdk-client/__tests__/polling/PollingProcessor.test.ts index 593372d83..52ea9f188 100644 --- a/packages/shared/sdk-client/__tests__/polling/PollingProcessor.test.ts +++ b/packages/shared/sdk-client/__tests__/polling/PollingProcessor.test.ts @@ -75,6 +75,7 @@ function makeConfig( pollInterval: number, withReasons: boolean, useReport: boolean, + queryParameters?: { key: string; value: string }[], ): PollingDataSourceConfig { return { credential: 'the-sdk-key', @@ -91,6 +92,7 @@ function makeConfig( withReasons, useReport, pollInterval, + queryParameters, }; } @@ -109,6 +111,49 @@ it('makes no requests until it is started', () => { expect(requests.fetch).toHaveBeenCalledTimes(0); }); +it('includes custom query parameters when specified', () => { + const requests = makeRequests(); + + const polling = new PollingProcessor( + 'mockContextString', + makeConfig(1, true, false, [ + { key: 'custom', value: 'value' }, + { key: 'custom2', value: 'value2' }, + ]), + requests, + makeEncoding(), + (_flags) => {}, + (_error) => {}, + ); + polling.start(); + + expect(requests.fetch).toHaveBeenCalledWith( + 'mockPollingEndpoint/poll/path/get?custom=value&custom2=value2&withReasons=true&filter=testPayloadFilterKey', + expect.anything(), + ); + polling.stop(); +}); + +it('works without any custom query parameters', () => { + const requests = makeRequests(); + + const polling = new PollingProcessor( + 'mockContextString', + makeConfig(1, true, false), + requests, + makeEncoding(), + (_flags) => {}, + (_error) => {}, + ); + polling.start(); + + expect(requests.fetch).toHaveBeenCalledWith( + 'mockPollingEndpoint/poll/path/get?withReasons=true&filter=testPayloadFilterKey', + expect.anything(), + ); + polling.stop(); +}); + it('polls immediately when started', () => { const requests = makeRequests(); diff --git a/packages/shared/sdk-client/__tests__/streaming/StreamingProcessor.test.ts b/packages/shared/sdk-client/__tests__/streaming/StreamingProcessor.test.ts index f1d4eda76..532a7d4cd 100644 --- a/packages/shared/sdk-client/__tests__/streaming/StreamingProcessor.test.ts +++ b/packages/shared/sdk-client/__tests__/streaming/StreamingProcessor.test.ts @@ -44,6 +44,7 @@ let basicPlatform: Platform; function getStreamingDataSourceConfig( withReasons: boolean = false, useReport: boolean = false, + queryParameters?: [{ key: string; value: string }], ): StreamingDataSourceConfig { return { credential: sdkKey, @@ -65,6 +66,7 @@ function getStreamingDataSourceConfig( initialRetryDelayMillis: 1000, withReasons, useReport, + queryParameters, }; } @@ -349,3 +351,51 @@ describe('given a stream processor', () => { }); }); }); + +it('includes custom query parameters', () => { + const { info } = basicPlatform; + const listeners = new Map(); + const mockListener = { + deserializeData: jest.fn((data) => data), + processJson: jest.fn(), + }; + listeners.set('put', mockListener); + listeners.set('patch', mockListener); + const diagnosticsManager = new internal.DiagnosticsManager(sdkKey, basicPlatform, {}); + + basicPlatform.requests = { + createEventSource: jest.fn((streamUri: string, options: any) => { + const mockEventSource = createMockEventSource(streamUri, options); + return mockEventSource; + }), + getEventSourceCapabilities: jest.fn(() => ({ + readTimeout: true, + headers: true, + customMethod: true, + })), + } as any; + + const streamingProcessor = new StreamingProcessor( + 'mockContextString', + getStreamingDataSourceConfig(undefined, undefined, [{ key: 'custom', value: 'value' }]), + listeners, + basicPlatform.requests, + basicPlatform.encoding!, + diagnosticsManager, + () => {}, + logger, + ); + + streamingProcessor.start(); + + expect(basicPlatform.requests.createEventSource).toHaveBeenCalledWith( + `${serviceEndpoints.streaming}/stream/path/get?custom=value&filter=testPayloadFilterKey`, + { + errorFilter: expect.any(Function), + headers: defaultHeaders(sdkKey, info, undefined), + initialRetryDelayMillis: 1000, + readTimeoutMillis: 300000, + retryResetIntervalMillis: 60000, + }, + ); +}); diff --git a/packages/shared/sdk-client/src/DataManager.ts b/packages/shared/sdk-client/src/DataManager.ts index 252eb0b54..444b88055 100644 --- a/packages/shared/sdk-client/src/DataManager.ts +++ b/packages/shared/sdk-client/src/DataManager.ts @@ -58,10 +58,15 @@ export interface DataManagerFactory { ): DataManager; } +export interface ConnectionParams { + queryParameters?: { key: string; value: string }[]; +} + export abstract class BaseDataManager implements DataManager { protected updateProcessor?: subsystem.LDStreamProcessor; protected readonly logger: LDLogger; protected context?: Context; + private connectionParams?: ConnectionParams; protected readonly dataSourceStatusManager: DataSourceStatusManager; private readonly dataSourceEventHandler: DataSourceEventHandler; @@ -85,6 +90,13 @@ export abstract class BaseDataManager implements DataManager { ); } + /** + * Set additional connection parameters for requests polling/streaming. + */ + protected setConnectionParams(connectionParams?: ConnectionParams) { + this.connectionParams = connectionParams; + } + abstract identify( identifyResolve: () => void, identifyReject: (err: Error) => void, @@ -108,6 +120,7 @@ export abstract class BaseDataManager implements DataManager { pollInterval: this.config.pollInterval, withReasons: this.config.withReasons, useReport: this.config.useReport, + queryParameters: this.connectionParams?.queryParameters, }, this.platform.requests, this.platform.encoding!, @@ -144,6 +157,7 @@ export abstract class BaseDataManager implements DataManager { initialRetryDelayMillis: this.config.streamInitialReconnectDelay * 1000, withReasons: this.config.withReasons, useReport: this.config.useReport, + queryParameters: this.connectionParams?.queryParameters, }, this.createStreamListeners(checkedContext, identifyResolve), this.platform.requests, diff --git a/packages/shared/sdk-client/src/index.ts b/packages/shared/sdk-client/src/index.ts index 842de68eb..5d06a391d 100644 --- a/packages/shared/sdk-client/src/index.ts +++ b/packages/shared/sdk-client/src/index.ts @@ -22,7 +22,7 @@ export type { LDIdentifyOptions, } from './api'; -export type { DataManager, DataManagerFactory } from './DataManager'; +export type { DataManager, DataManagerFactory, ConnectionParams } from './DataManager'; export type { FlagManager } from './flag-manager/FlagManager'; export type { Configuration } from './configuration/Configuration'; diff --git a/packages/shared/sdk-client/src/polling/PollingProcessor.ts b/packages/shared/sdk-client/src/polling/PollingProcessor.ts index fc25ec8b5..b421607df 100644 --- a/packages/shared/sdk-client/src/polling/PollingProcessor.ts +++ b/packages/shared/sdk-client/src/polling/PollingProcessor.ts @@ -42,7 +42,9 @@ export default class PollingProcessor implements subsystem.LDStreamProcessor { ? dataSourceConfig.paths.pathReport(encoding, plainContextString) : dataSourceConfig.paths.pathGet(encoding, plainContextString); - const parameters: { key: string; value: string }[] = []; + const parameters: { key: string; value: string }[] = [ + ...(dataSourceConfig.queryParameters ?? []), + ]; if (this.dataSourceConfig.withReasons) { parameters.push({ key: 'withReasons', value: 'true' }); } diff --git a/packages/shared/sdk-client/src/streaming/DataSourceConfig.ts b/packages/shared/sdk-client/src/streaming/DataSourceConfig.ts index 41ce87b40..01fc6f903 100644 --- a/packages/shared/sdk-client/src/streaming/DataSourceConfig.ts +++ b/packages/shared/sdk-client/src/streaming/DataSourceConfig.ts @@ -7,6 +7,7 @@ export interface DataSourceConfig { withReasons: boolean; useReport: boolean; paths: DataSourcePaths; + queryParameters?: { key: string; value: string }[]; } export interface PollingDataSourceConfig extends DataSourceConfig { diff --git a/packages/shared/sdk-client/src/streaming/StreamingProcessor.ts b/packages/shared/sdk-client/src/streaming/StreamingProcessor.ts index 03b95637f..cddb67839 100644 --- a/packages/shared/sdk-client/src/streaming/StreamingProcessor.ts +++ b/packages/shared/sdk-client/src/streaming/StreamingProcessor.ts @@ -58,7 +58,9 @@ class StreamingProcessor implements subsystem.LDStreamProcessor { ? dataSourceConfig.paths.pathReport(encoding, plainContextString) : dataSourceConfig.paths.pathGet(encoding, plainContextString); - const parameters: { key: string; value: string }[] = []; + const parameters: { key: string; value: string }[] = [ + ...(dataSourceConfig.queryParameters ?? []), + ]; if (this.dataSourceConfig.withReasons) { parameters.push({ key: 'withReasons', value: 'true' }); }