From 53f5bb89754ff05405d481a959e75742fbd0d0a9 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 25 Sep 2024 15:20:42 -0700 Subject: [PATCH] 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 --- .../browser/__tests__/BrowserClient.test.ts | 22 +- .../__tests__/BrowserDataManager.test.ts | 296 ++++++++++++++++++ packages/sdk/browser/__tests__/MockHasher.ts | 13 + .../contract-tests/entity/src/ClientEntity.ts | 3 +- packages/sdk/browser/jest.config.js | 3 +- packages/sdk/browser/package.json | 2 +- packages/sdk/browser/src/BrowserClient.ts | 128 +++++--- .../sdk/browser/src/BrowserDataManager.ts | 136 ++++++++ packages/sdk/browser/src/options.ts | 16 +- .../__tests__/MobileDataManager.test.ts | 295 +++++++++++++++++ .../sdk/react-native/src/MobileDataManager.ts | 148 +++++++++ packages/sdk/react-native/src/RNOptions.ts | 12 +- .../react-native/src/ReactNativeLDClient.ts | 96 +++--- packages/sdk/react-native/src/options.ts | 16 +- .../__tests__/LDClientImpl.events.test.ts | 21 +- .../__tests__/LDClientImpl.storage.test.ts | 38 ++- .../sdk-client/__tests__/LDClientImpl.test.ts | 72 +++-- .../__tests__/LDClientImpl.timeout.test.ts | 24 +- .../__tests__/LDClientImpl.variation.test.ts | 31 +- .../sdk-client/__tests__/TestDataManager.ts | 88 ++++++ .../configuration/Configuration.test.ts | 22 +- .../__tests__/context/addAutoEnv.test.ts | 8 +- .../createDiagnosticsInitConfig.test.ts | 6 +- packages/shared/sdk-client/src/DataManager.ts | 211 +++++++++++++ .../shared/sdk-client/src/LDClientImpl.ts | 240 ++------------ .../shared/sdk-client/src/api/LDClient.ts | 16 - .../shared/sdk-client/src/api/LDOptions.ts | 12 - packages/shared/sdk-client/src/api/index.ts | 1 + .../src/configuration/Configuration.ts | 58 +++- .../sdk-client/src/configuration/index.ts | 15 +- .../src/configuration/validators.ts | 11 - .../sdk-client/src/context/addAutoEnv.ts | 2 +- .../createDiagnosticsInitConfig.ts | 6 +- .../diagnostics/createDiagnosticsManager.ts | 2 +- .../src/events/createEventProcessor.ts | 5 +- .../src/flag-manager/FlagManager.ts | 68 ++-- packages/shared/sdk-client/src/index.ts | 11 + .../sdk-client/src/polling/Requestor.ts | 2 - 38 files changed, 1643 insertions(+), 513 deletions(-) create mode 100644 packages/sdk/browser/__tests__/BrowserDataManager.test.ts create mode 100644 packages/sdk/browser/__tests__/MockHasher.ts create mode 100644 packages/sdk/browser/src/BrowserDataManager.ts create mode 100644 packages/sdk/react-native/__tests__/MobileDataManager.test.ts create mode 100644 packages/sdk/react-native/src/MobileDataManager.ts create mode 100644 packages/shared/sdk-client/__tests__/TestDataManager.ts create mode 100644 packages/shared/sdk-client/src/DataManager.ts diff --git a/packages/sdk/browser/__tests__/BrowserClient.test.ts b/packages/sdk/browser/__tests__/BrowserClient.test.ts index b32bbac16..7a8dc3ff2 100644 --- a/packages/sdk/browser/__tests__/BrowserClient.test.ts +++ b/packages/sdk/browser/__tests__/BrowserClient.test.ts @@ -4,7 +4,6 @@ import { AutoEnvAttributes, EventSourceCapabilities, EventSourceInitDict, - Hasher, LDLogger, PlatformData, Requests, @@ -12,6 +11,7 @@ import { } from '@launchdarkly/js-client-sdk-common'; import { BrowserClient } from '../src/BrowserClient'; +import { MockHasher } from './MockHasher'; function mockResponse(value: string, statusCode: number) { const response: Response = { @@ -79,18 +79,6 @@ function makeRequests(): Requests { }; } -class MockHasher implements Hasher { - update(_data: string): Hasher { - return this; - } - digest?(_encoding: string): string { - return 'hashed'; - } - async asyncDigest?(_encoding: string): Promise { - return 'hashed'; - } -} - describe('given a mock platform for a BrowserClient', () => { const logger: LDLogger = { debug: jest.fn(), @@ -141,7 +129,7 @@ describe('given a mock platform for a BrowserClient', () => { 'client-side-id', AutoEnvAttributes.Disabled, { - initialConnectionMode: 'polling', + streaming: false, logger, diagnosticOptOut: true, }, @@ -169,7 +157,7 @@ describe('given a mock platform for a BrowserClient', () => { 'client-side-id', AutoEnvAttributes.Disabled, { - initialConnectionMode: 'polling', + streaming: false, logger, diagnosticOptOut: true, eventUrlTransformer: (url: string) => @@ -202,7 +190,7 @@ describe('given a mock platform for a BrowserClient', () => { 'client-side-id', AutoEnvAttributes.Disabled, { - initialConnectionMode: 'polling', + streaming: false, logger, diagnosticOptOut: true, eventUrlTransformer: (url: string) => @@ -245,7 +233,7 @@ describe('given a mock platform for a BrowserClient', () => { 'client-side-id', AutoEnvAttributes.Disabled, { - initialConnectionMode: 'polling', + streaming: false, logger, diagnosticOptOut: true, eventUrlTransformer: (url: string) => diff --git a/packages/sdk/browser/__tests__/BrowserDataManager.test.ts b/packages/sdk/browser/__tests__/BrowserDataManager.test.ts new file mode 100644 index 000000000..7a95f1edf --- /dev/null +++ b/packages/sdk/browser/__tests__/BrowserDataManager.test.ts @@ -0,0 +1,296 @@ +import { jest } from '@jest/globals'; +import { TextEncoder } from 'node:util'; + +import { + ApplicationTags, + base64UrlEncode, + Configuration, + Context, + Encoding, + FlagManager, + internal, + LDEmitter, + LDHeaders, + LDIdentifyOptions, + LDLogger, + Platform, + Response, + ServiceEndpoints, +} from '@launchdarkly/js-client-sdk-common'; + +import BrowserDataManager from '../src/BrowserDataManager'; +import validateOptions, { ValidatedOptions } from '../src/options'; +import BrowserEncoding from '../src/platform/BrowserEncoding'; +import BrowserInfo from '../src/platform/BrowserInfo'; +import LocalStorage from '../src/platform/LocalStorage'; +import { MockHasher } from './MockHasher'; + +global.TextEncoder = TextEncoder; + +function mockResponse(value: string, statusCode: number) { + const response: Response = { + headers: { + // @ts-ignore + get: jest.fn(), + // @ts-ignore + keys: jest.fn(), + // @ts-ignore + values: jest.fn(), + // @ts-ignore + entries: jest.fn(), + // @ts-ignore + has: jest.fn(), + }, + status: statusCode, + text: () => Promise.resolve(value), + json: () => Promise.resolve(JSON.parse(value)), + }; + return Promise.resolve(response); +} + +function mockFetch(value: string, statusCode: number = 200) { + const f = jest.fn(); + // @ts-ignore + f.mockResolvedValue(mockResponse(value, statusCode)); + return f; +} + +describe('given a BrowserDataManager with mocked dependencies', () => { + let platform: jest.Mocked; + let flagManager: jest.Mocked; + let config: Configuration; + let browserConfig: ValidatedOptions; + let baseHeaders: LDHeaders; + let emitter: jest.Mocked; + let diagnosticsManager: jest.Mocked; + let dataManager: BrowserDataManager; + let logger: LDLogger; + beforeEach(() => { + logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; + config = { + logger, + baseUri: 'string', + eventsUri: 'string', + streamUri: 'string', + maxCachedContexts: 5, + capacity: 100, + diagnosticRecordingInterval: 1000, + flushInterval: 1000, + streamInitialReconnectDelay: 1000, + allAttributesPrivate: false, + debug: true, + diagnosticOptOut: false, + sendEvents: false, + sendLDHeaders: true, + useReport: false, + withReasons: true, + privateAttributes: [], + tags: new ApplicationTags({}), + serviceEndpoints: new ServiceEndpoints('', ''), + pollInterval: 1000, + userAgentHeaderName: 'user-agent', + trackEventModifier: (event) => event, + }; + const mockedFetch = mockFetch('{"flagA": true}', 200); + platform = { + crypto: { + createHash: () => new MockHasher(), + randomUUID: () => '123', + }, + info: new BrowserInfo(), + requests: { + createEventSource: jest.fn((streamUri: string = '', options: any = {}) => ({ + streamUri, + options, + onclose: jest.fn(), + addEventListener: jest.fn(), + close: jest.fn(), + })), + fetch: mockedFetch, + getEventSourceCapabilities: jest.fn(), + }, + storage: new LocalStorage(config.logger), + encoding: new BrowserEncoding(), + } as unknown as jest.Mocked; + + flagManager = { + loadCached: jest.fn(), + get: jest.fn(), + getAll: jest.fn(), + init: jest.fn(), + upsert: jest.fn(), + on: jest.fn(), + off: jest.fn(), + } as unknown as jest.Mocked; + + browserConfig = validateOptions({ streaming: false }, logger); + baseHeaders = {}; + emitter = { + emit: jest.fn(), + } as unknown as jest.Mocked; + diagnosticsManager = {} as unknown as jest.Mocked; + + dataManager = new BrowserDataManager( + platform, + flagManager, + 'test-credential', + config, + browserConfig, + () => ({ + 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, + ); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('creates an event source when stream is true', 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: LDIdentifyOptions = { waitForNetworkResults: false }; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(platform.requests.createEventSource).toHaveBeenCalled(); + }); + + 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 }; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + flagManager.loadCached.mockResolvedValue(true); + + await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(logger.debug).toHaveBeenCalledWith( + '[BrowserDataManager] Identify - Flags loaded from cache. Continuing to initialize via a poll.', + ); + + expect(flagManager.loadCached).toHaveBeenCalledWith(context); + expect(identifyResolve).toHaveBeenCalled(); + expect(flagManager.init).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ flagA: { flag: true, version: undefined } }), + ); + expect(platform.requests.createEventSource).not.toHaveBeenCalled(); + }); + + 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 }; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + flagManager.loadCached.mockResolvedValue(false); + + await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(logger.debug).not.toHaveBeenCalledWith( + 'Identify - Flags loaded from cache. Continuing to initialize via a poll.', + ); + + expect(flagManager.loadCached).toHaveBeenCalledWith(context); + expect(identifyResolve).toHaveBeenCalled(); + expect(flagManager.init).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ flagA: { flag: true, version: undefined } }), + ); + expect(platform.requests.createEventSource).not.toHaveBeenCalled(); + }); + + 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 identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + flagManager.loadCached.mockResolvedValue(false); + + await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(platform.requests.createEventSource).not.toHaveBeenCalled(); + dataManager.startDataSource(); + expect(platform.requests.createEventSource).toHaveBeenCalled(); + }); + + 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 identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + flagManager.loadCached.mockResolvedValue(false); + + await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(platform.requests.createEventSource).not.toHaveBeenCalled(); + dataManager.startDataSource(); + dataManager.startDataSource(); + expect(platform.requests.createEventSource).toHaveBeenCalledTimes(1); + expect(logger.debug).toHaveBeenCalledWith( + '[BrowserDataManager] Update processor already active. Not changing state.', + ); + }); + + it('does not start a stream if identify has not been called', async () => { + expect(platform.requests.createEventSource).not.toHaveBeenCalled(); + dataManager.startDataSource(); + expect(platform.requests.createEventSource).not.toHaveBeenCalledTimes(1); + expect(logger.debug).toHaveBeenCalledWith( + '[BrowserDataManager] Context not set, not starting update processor.', + ); + }); +}); diff --git a/packages/sdk/browser/__tests__/MockHasher.ts b/packages/sdk/browser/__tests__/MockHasher.ts new file mode 100644 index 000000000..c8da07129 --- /dev/null +++ b/packages/sdk/browser/__tests__/MockHasher.ts @@ -0,0 +1,13 @@ +import { Hasher } from '@launchdarkly/js-client-sdk-common'; + +export class MockHasher implements Hasher { + update(_data: string): Hasher { + return this; + } + digest?(_encoding: string): string { + return 'hashed'; + } + async asyncDigest?(_encoding: string): Promise { + return 'hashed'; + } +} diff --git a/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts b/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts index 5e0fbf664..2a5bf284c 100644 --- a/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts +++ b/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts @@ -29,7 +29,6 @@ function makeSdkConfig(options: SDKConfigParams, tag: string) { } if (options.polling) { - cf.initialConnectionMode = 'polling'; if (options.polling.baseUri) { cf.baseUri = options.polling.baseUri; } @@ -42,7 +41,7 @@ function makeSdkConfig(options: SDKConfigParams, tag: string) { if (options.streaming.baseUri) { cf.streamUri = options.streaming.baseUri; } - cf.initialConnectionMode = 'streaming'; + cf.streaming = true; cf.streamInitialReconnectDelay = maybeTime(options.streaming.initialRetryDelayMs); } diff --git a/packages/sdk/browser/jest.config.js b/packages/sdk/browser/jest.config.js index 21105621a..5d6bf1a51 100644 --- a/packages/sdk/browser/jest.config.js +++ b/packages/sdk/browser/jest.config.js @@ -6,5 +6,6 @@ export default { transform: { '^.+\\.tsx?$': ['ts-jest', { useESM: true, tsconfig: 'tsconfig.json' }], }, - testPathIgnorePatterns: ['./dist'], + testPathIgnorePatterns: ['./dist', './src'], + testMatch: ['**.test.ts'], }; diff --git a/packages/sdk/browser/package.json b/packages/sdk/browser/package.json index c03638f27..70ab13df7 100644 --- a/packages/sdk/browser/package.json +++ b/packages/sdk/browser/package.json @@ -27,7 +27,7 @@ ], "scripts": { "clean": "rimraf dist", - "build": "rollup -c rollup.config.js", + "build": "tsc --noEmit && rollup -c rollup.config.js", "lint": "eslint . --ext .ts,.tsx", "prettier": "prettier --write '**/*.@(js|ts|tsx|json|css)' --ignore-path ../../../.prettierignore", "test": "NODE_OPTIONS=--experimental-vm-modules npx jest --runInBand", diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index 59a1b817a..e63360768 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -3,15 +3,19 @@ import { base64UrlEncode, BasicLogger, LDClient as CommonClient, - DataSourcePaths, + Configuration, Encoding, + FlagManager, internal, LDClientImpl, LDContext, + LDEmitter, + LDHeaders, Platform, } from '@launchdarkly/js-client-sdk-common'; import { LDIdentifyOptions } from '@launchdarkly/js-client-sdk-common/dist/api/LDIdentifyOptions'; +import BrowserDataManager from './BrowserDataManager'; import GoalManager from './goals/GoalManager'; import { Goal, isClick } from './goals/Goals'; import validateOptions, { BrowserOptions, filterToBaseOptions } from './options'; @@ -19,8 +23,14 @@ 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. */ -export type LDClient = Omit; +export type LDClient = Omit< + CommonClient, + 'setConnectionMode' | 'getConnectionMode' | 'getOffline' +> & { + setStreaming(streaming: boolean): void; +}; export class BrowserClient extends LDClientImpl { private readonly goalManager?: GoalManager; @@ -43,26 +53,67 @@ export class BrowserClient extends LDClientImpl { const baseUrl = options.baseUri ?? 'https://clientsdk.launchdarkly.com'; const platform = overridePlatform ?? new BrowserPlatform(logger); - const ValidatedBrowserOptions = validateOptions(options, logger); - const { eventUrlTransformer } = ValidatedBrowserOptions; - super(clientSideId, autoEnvAttributes, platform, filterToBaseOptions(options), { - analyticsEventPath: `/events/bulk/${clientSideId}`, - diagnosticEventPath: `/events/diagnostic/${clientSideId}`, - includeAuthorizationHeader: false, - highTimeoutThreshold: 5, - userAgentHeaderName: 'x-launchdarkly-user-agent', - trackEventModifier: (event: internal.InputCustomEvent) => - new internal.InputCustomEvent( - event.context, - event.key, - event.data, - event.metricValue, - event.samplingRatio, - eventUrlTransformer(window.location.href), + const validatedBrowserOptions = validateOptions(options, logger); + const { eventUrlTransformer } = validatedBrowserOptions; + super( + clientSideId, + autoEnvAttributes, + platform, + filterToBaseOptions(options), + ( + flagManager: FlagManager, + configuration: Configuration, + baseHeaders: LDHeaders, + emitter: LDEmitter, + diagnosticsManager?: internal.DiagnosticsManager, + ) => + new BrowserDataManager( + platform, + flagManager, + clientSideId, + configuration, + validatedBrowserOptions, + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/sdk/evalx/${clientSideId}/contexts/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return `/sdk/evalx/${clientSideId}/context`; + }, + }), + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/eval/${clientSideId}/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return `/eval/${clientSideId}`; + }, + }), + baseHeaders, + emitter, + diagnosticsManager, ), - }); + { + analyticsEventPath: `/events/bulk/${clientSideId}`, + diagnosticEventPath: `/events/diagnostic/${clientSideId}`, + includeAuthorizationHeader: false, + highTimeoutThreshold: 5, + userAgentHeaderName: 'x-launchdarkly-user-agent', + trackEventModifier: (event: internal.InputCustomEvent) => + new internal.InputCustomEvent( + event.context, + event.key, + event.data, + event.metricValue, + event.samplingRatio, + eventUrlTransformer(window.location.href), + ), + }, + ); + + this.setEventSendingEnabled(true, false); - if (ValidatedBrowserOptions.fetchGoals) { + if (validatedBrowserOptions.fetchGoals) { this.goalManager = new GoalManager( clientSideId, platform.requests, @@ -108,36 +159,17 @@ export class BrowserClient extends LDClientImpl { } } - private encodeContext(context: LDContext) { - return base64UrlEncode(JSON.stringify(context), this.platform.encoding!); - } - - override getStreamingPaths(): DataSourcePaths { - const parentThis = this; - return { - pathGet(encoding: Encoding, _plainContextString: string): string { - return `/eval/${parentThis.clientSideId}/${base64UrlEncode(_plainContextString, encoding)}`; - }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - return `/eval/${parentThis.clientSideId}`; - }, - }; - } - - override getPollingPaths(): DataSourcePaths { - const parentThis = this; - return { - pathGet(encoding: Encoding, _plainContextString: string): string { - return `/sdk/evalx/${parentThis.clientSideId}/contexts/${base64UrlEncode(_plainContextString, encoding)}`; - }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - return `/sdk/evalx/${parentThis.clientSideId}/context`; - }, - }; - } - override async identify(context: LDContext, identifyOptions?: LDIdentifyOptions): Promise { await super.identify(context, identifyOptions); this.goalManager?.startTracking(); } + + setStreaming(streaming: boolean): void { + const browserDataManager = this.dataManager as BrowserDataManager; + if (streaming) { + browserDataManager.startDataSource(); + } else { + browserDataManager.stopDataSource(); + } + } } diff --git a/packages/sdk/browser/src/BrowserDataManager.ts b/packages/sdk/browser/src/BrowserDataManager.ts new file mode 100644 index 000000000..a259f068f --- /dev/null +++ b/packages/sdk/browser/src/BrowserDataManager.ts @@ -0,0 +1,136 @@ +import { + BaseDataManager, + Configuration, + Context, + DataSourcePaths, + FlagManager, + getPollingUri, + internal, + LDEmitter, + LDHeaders, + LDIdentifyOptions, + Platform, + Requestor, +} from '@launchdarkly/js-client-sdk-common'; + +import { ValidatedOptions } from './options'; + +const logTag = '[BrowserDataManager]'; + +export default class BrowserDataManager extends BaseDataManager { + constructor( + platform: Platform, + flagManager: FlagManager, + credential: string, + config: Configuration, + private readonly browserConfig: ValidatedOptions, + getPollingPaths: () => DataSourcePaths, + getStreamingPaths: () => DataSourcePaths, + baseHeaders: LDHeaders, + emitter: LDEmitter, + diagnosticsManager?: internal.DiagnosticsManager, + ) { + super( + platform, + flagManager, + credential, + config, + getPollingPaths, + getStreamingPaths, + baseHeaders, + emitter, + diagnosticsManager, + ); + } + + private debugLog(message: any, ...args: any[]) { + this.logger.debug(`${logTag} ${message}`, ...args); + } + + override async identify( + identifyResolve: () => void, + identifyReject: (err: Error) => void, + context: Context, + _identifyOptions?: LDIdentifyOptions, + ): Promise { + this.context = context; + if (await this.flagManager.loadCached(context)) { + this.debugLog('Identify - Flags loaded from cache. Continuing to initialize via a poll.'); + } + const plainContextString = JSON.stringify(Context.toLDContext(context)); + const requestor = this.getRequestor(plainContextString); + + // TODO: Handle wait for network results in a meaningful way. SDK-707 + + try { + const payload = await requestor.requestPayload(); + const listeners = this.createStreamListeners(context, identifyResolve); + const putListener = listeners.get('put'); + putListener!.processJson(putListener!.deserializeData(payload)); + } catch (e: any) { + identifyReject(e); + } + + if (this.browserConfig.streaming) { + this.setupConnection(context); + } + } + + stopDataSource() { + this.updateProcessor?.close(); + this.updateProcessor = undefined; + } + + startDataSource() { + if (this.updateProcessor) { + this.debugLog('Update processor already active. Not changing state.'); + return; + } + + if (!this.context) { + this.debugLog('Context not set, not starting update processor.'); + return; + } + + this.debugLog('Starting update processor.'); + this.setupConnection(this.context); + } + + private setupConnection( + context: Context, + identifyResolve?: () => void, + identifyReject?: (err: Error) => void, + ) { + const rawContext = Context.toLDContext(context)!; + + this.updateProcessor?.close(); + this.createStreamingProcessor(rawContext, context, identifyResolve, identifyReject); + + this.updateProcessor!.start(); + } + + private getRequestor(plainContextString: string): Requestor { + const paths = this.getPollingPaths(); + const path = this.config.useReport + ? paths.pathReport(this.platform.encoding!, plainContextString) + : paths.pathGet(this.platform.encoding!, plainContextString); + + const parameters: { key: string; value: string }[] = []; + if (this.config.withReasons) { + parameters.push({ key: 'withReasons', value: 'true' }); + } + + const headers: { [key: string]: string } = { ...this.baseHeaders }; + let body; + let method = 'GET'; + if (this.config.useReport) { + method = 'REPORT'; + headers['content-type'] = 'application/json'; + body = plainContextString; // context is in body for REPORT + } + + const uri = getPollingUri(this.config.serviceEndpoints, path, parameters); + return new Requestor(this.platform.requests, uri, headers, method, body); + } + // TODO: Automatically start streaming if event handlers are registered. +} diff --git a/packages/sdk/browser/src/options.ts b/packages/sdk/browser/src/options.ts index d08eb53b2..7a6acce14 100644 --- a/packages/sdk/browser/src/options.ts +++ b/packages/sdk/browser/src/options.ts @@ -9,7 +9,7 @@ import { /** * Initialization options for the LaunchDarkly browser SDK. */ -export interface BrowserOptions extends LDOptionsBase { +export interface BrowserOptions extends Omit { /** * Whether the client should make a request to LaunchDarkly for Experimentation metrics (goals). * @@ -24,21 +24,35 @@ export interface BrowserOptions extends LDOptionsBase { * and returns the value that should be stored in the event's `url` property. */ eventUrlTransformer?: (url: string) => string; + + /** + * Whether or not to open a streaming connection to LaunchDarkly for live flag updates. + * + * If this is true, the client will always attempt to maintain a streaming connection; if false, + * it never will. If you leave the value undefined (the default), the client will open a streaming + * connection if you subscribe to `"change"` or `"change:flag-key"` events. + * + * This is equivalent to calling `client.setStreaming()` with the same value. + */ + streaming?: boolean; } export interface ValidatedOptions { fetchGoals: boolean; eventUrlTransformer: (url: string) => string; + streaming?: boolean; } const optDefaults = { fetchGoals: true, eventUrlTransformer: (url: string) => url, + streaming: undefined, }; const validators: { [Property in keyof BrowserOptions]: TypeValidator | undefined } = { fetchGoals: TypeValidators.Boolean, eventUrlTransformer: TypeValidators.Function, + streaming: TypeValidators.Boolean, }; export function filterToBaseOptions(opts: BrowserOptions): LDOptionsBase { diff --git a/packages/sdk/react-native/__tests__/MobileDataManager.test.ts b/packages/sdk/react-native/__tests__/MobileDataManager.test.ts new file mode 100644 index 000000000..1e54ed5b5 --- /dev/null +++ b/packages/sdk/react-native/__tests__/MobileDataManager.test.ts @@ -0,0 +1,295 @@ +import { + ApplicationTags, + base64UrlEncode, + Configuration, + Context, + Encoding, + FlagManager, + internal, + LDEmitter, + LDHeaders, + LDIdentifyOptions, + LDLogger, + Platform, + Response, + ServiceEndpoints, +} from '@launchdarkly/js-client-sdk-common'; + +import MobileDataManager from '../src/MobileDataManager'; +import { ValidatedOptions } from '../src/options'; +import PlatformCrypto from '../src/platform/crypto'; +import PlatformEncoding from '../src/platform/PlatformEncoding'; +import PlatformInfo from '../src/platform/PlatformInfo'; +import PlatformStorage from '../src/platform/PlatformStorage'; + +function mockResponse(value: string, statusCode: number) { + const response: Response = { + headers: { + get: jest.fn(), + keys: jest.fn(), + values: jest.fn(), + entries: jest.fn(), + has: jest.fn(), + }, + status: statusCode, + text: () => Promise.resolve(value), + json: () => Promise.resolve(JSON.parse(value)), + }; + return Promise.resolve(response); +} + +function mockFetch(value: string, statusCode: number = 200) { + const f = jest.fn(); + f.mockResolvedValue(mockResponse(value, statusCode)); + return f; +} + +describe('given a MobileDataManager with mocked dependencies', () => { + let platform: jest.Mocked; + let flagManager: jest.Mocked; + let config: Configuration; + let rnConfig: ValidatedOptions; + let baseHeaders: LDHeaders; + let emitter: jest.Mocked; + let diagnosticsManager: jest.Mocked; + let mobileDataManager: MobileDataManager; + let logger: LDLogger; + beforeEach(() => { + logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; + config = { + logger, + baseUri: 'string', + eventsUri: 'string', + streamUri: 'string', + maxCachedContexts: 5, + capacity: 100, + diagnosticRecordingInterval: 1000, + flushInterval: 1000, + streamInitialReconnectDelay: 1000, + allAttributesPrivate: false, + debug: true, + diagnosticOptOut: false, + sendEvents: false, + sendLDHeaders: true, + useReport: false, + withReasons: true, + privateAttributes: [], + tags: new ApplicationTags({}), + serviceEndpoints: new ServiceEndpoints('', ''), + pollInterval: 1000, + userAgentHeaderName: 'user-agent', + trackEventModifier: (event) => event, + }; + const mockedFetch = mockFetch('{"flagA": true}', 200); + platform = { + crypto: new PlatformCrypto(), + info: new PlatformInfo(config.logger), + requests: { + createEventSource: jest.fn((streamUri: string = '', options: any = {}) => ({ + streamUri, + options, + onclose: jest.fn(), + addEventListener: jest.fn(), + close: jest.fn(), + })), + fetch: mockedFetch, + getEventSourceCapabilities: jest.fn(), + }, + storage: new PlatformStorage(config.logger), + encoding: new PlatformEncoding(), + } as unknown as jest.Mocked; + + flagManager = { + loadCached: jest.fn(), + get: jest.fn(), + getAll: jest.fn(), + init: jest.fn(), + upsert: jest.fn(), + on: jest.fn(), + off: jest.fn(), + } as unknown as jest.Mocked; + + rnConfig = { initialConnectionMode: 'streaming' } as ValidatedOptions; + baseHeaders = {}; + emitter = { + emit: jest.fn(), + } as unknown as jest.Mocked; + diagnosticsManager = {} as unknown as jest.Mocked; + + mobileDataManager = new MobileDataManager( + platform, + flagManager, + 'test-credential', + config, + rnConfig, + () => ({ + 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, + ); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should initialize with the correct initial connection mode', () => { + expect(mobileDataManager.getConnectionMode()).toBe('streaming'); + }); + + it('should set and get connection mode', async () => { + await mobileDataManager.setConnectionMode('polling'); + expect(mobileDataManager.getConnectionMode()).toBe('polling'); + + await mobileDataManager.setConnectionMode('streaming'); + expect(mobileDataManager.getConnectionMode()).toBe('streaming'); + + await mobileDataManager.setConnectionMode('offline'); + expect(mobileDataManager.getConnectionMode()).toBe('offline'); + }); + + it('should log when connection mode remains the same', async () => { + const initialMode = mobileDataManager.getConnectionMode(); + await mobileDataManager.setConnectionMode(initialMode); + expect(logger.debug).toHaveBeenCalledWith( + `[MobileDataManager] setConnectionMode ignored. Mode is already '${initialMode}'.`, + ); + expect(mobileDataManager.getConnectionMode()).toBe(initialMode); + }); + + it('uses streaming when the connection mode is streaming', async () => { + mobileDataManager.setConnectionMode('streaming'); + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false }; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + await mobileDataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(platform.requests.createEventSource).toHaveBeenCalled(); + expect(platform.requests.fetch).not.toHaveBeenCalled(); + }); + + it('uses polling when the connection mode is polling', async () => { + mobileDataManager.setConnectionMode('polling'); + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false }; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + await mobileDataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(platform.requests.createEventSource).not.toHaveBeenCalled(); + expect(platform.requests.fetch).toHaveBeenCalled(); + }); + + it('makes no connection when offline', async () => { + mobileDataManager.setConnectionMode('offline'); + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false }; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + await mobileDataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(platform.requests.createEventSource).not.toHaveBeenCalled(); + expect(platform.requests.fetch).not.toHaveBeenCalled(); + }); + + it('should load cached flags and resolve the identify', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false }; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + flagManager.loadCached.mockResolvedValue(true); + + await mobileDataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(logger.debug).toHaveBeenCalledWith( + '[MobileDataManager] Identify completing with cached flags', + ); + + expect(flagManager.loadCached).toHaveBeenCalledWith(context); + expect(identifyResolve).toHaveBeenCalled(); + }); + + it('should log that it loaded cached values, but is waiting for the network result', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: true }; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + flagManager.loadCached.mockResolvedValue(true); + + await mobileDataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(logger.debug).toHaveBeenCalledWith( + '[MobileDataManager] Identify - Flags loaded from cache, but identify was requested with "waitForNetworkResults"', + ); + + expect(flagManager.loadCached).toHaveBeenCalledWith(context); + expect(identifyResolve).not.toHaveBeenCalled(); + expect(identifyReject).not.toHaveBeenCalled(); + }); + + it('should handle offline identify without cache', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = {}; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + await mobileDataManager.setConnectionMode('offline'); + flagManager.loadCached.mockResolvedValue(false); + + await mobileDataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(logger.debug).toHaveBeenCalledWith( + '[MobileDataManager] Offline identify - no cached flags, using defaults or already loaded flags.', + ); + + expect(flagManager.loadCached).toHaveBeenCalledWith(context); + expect(identifyResolve).toHaveBeenCalled(); + expect(identifyReject).not.toHaveBeenCalled(); + }); + + it('should handle offline identify with cache', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = {}; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + await mobileDataManager.setConnectionMode('offline'); + flagManager.loadCached.mockResolvedValue(true); + + await mobileDataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(logger.debug).toHaveBeenCalledWith( + '[MobileDataManager] Offline identify - using cached flags.', + ); + + expect(flagManager.loadCached).toHaveBeenCalledWith(context); + expect(identifyResolve).toHaveBeenCalled(); + expect(identifyReject).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/sdk/react-native/src/MobileDataManager.ts b/packages/sdk/react-native/src/MobileDataManager.ts new file mode 100644 index 000000000..0285af2bd --- /dev/null +++ b/packages/sdk/react-native/src/MobileDataManager.ts @@ -0,0 +1,148 @@ +import { + BaseDataManager, + Configuration, + ConnectionMode, + Context, + DataSourcePaths, + FlagManager, + internal, + LDEmitter, + LDHeaders, + LDIdentifyOptions, + Platform, +} from '@launchdarkly/js-client-sdk-common'; + +import { ValidatedOptions } from './options'; + +const logTag = '[MobileDataManager]'; + +export default class MobileDataManager extends BaseDataManager { + // Not implemented yet. + protected networkAvailable: boolean = true; + protected connectionMode: ConnectionMode = 'streaming'; + + constructor( + platform: Platform, + flagManager: FlagManager, + credential: string, + config: Configuration, + private readonly rnConfig: ValidatedOptions, + getPollingPaths: () => DataSourcePaths, + getStreamingPaths: () => DataSourcePaths, + baseHeaders: LDHeaders, + emitter: LDEmitter, + diagnosticsManager?: internal.DiagnosticsManager, + ) { + super( + platform, + flagManager, + credential, + config, + getPollingPaths, + getStreamingPaths, + baseHeaders, + emitter, + diagnosticsManager, + ); + this.connectionMode = rnConfig.initialConnectionMode; + } + + private debugLog(message: any, ...args: any[]) { + this.logger.debug(`${logTag} ${message}`, ...args); + } + + override async identify( + identifyResolve: () => void, + identifyReject: (err: Error) => void, + context: Context, + identifyOptions?: LDIdentifyOptions, + ): Promise { + this.context = context; + const offline = this.connectionMode === 'offline'; + // In offline mode we do not support waiting for results. + const waitForNetworkResults = !!identifyOptions?.waitForNetworkResults && !offline; + + const loadedFromCache = await this.flagManager.loadCached(context); + if (loadedFromCache && !waitForNetworkResults) { + this.debugLog('Identify completing with cached flags'); + identifyResolve(); + } + if (loadedFromCache && waitForNetworkResults) { + this.debugLog( + 'Identify - Flags loaded from cache, but identify was requested with "waitForNetworkResults"', + ); + } + + if (this.connectionMode === 'offline') { + if (loadedFromCache) { + this.debugLog('Offline identify - using cached flags.'); + } else { + this.debugLog( + 'Offline identify - no cached flags, using defaults or already loaded flags.', + ); + identifyResolve(); + } + } else { + // Context has been validated in LDClientImpl.identify + this.setupConnection(context, identifyResolve, identifyReject); + } + } + + private setupConnection( + context: Context, + identifyResolve?: () => void, + identifyReject?: (err: Error) => void, + ) { + const rawContext = Context.toLDContext(context)!; + + this.updateProcessor?.close(); + switch (this.connectionMode) { + case 'streaming': + this.createStreamingProcessor(rawContext, context, identifyResolve, identifyReject); + break; + case 'polling': + this.createPollingProcessor(rawContext, context, identifyResolve, identifyReject); + break; + default: + break; + } + this.updateProcessor!.start(); + } + + setNetworkAvailability(available: boolean): void { + this.networkAvailable = available; + } + + async setConnectionMode(mode: ConnectionMode): Promise { + if (this.connectionMode === mode) { + this.debugLog(`setConnectionMode ignored. Mode is already '${mode}'.`); + return; + } + + this.connectionMode = mode; + this.debugLog(`setConnectionMode ${mode}.`); + + switch (mode) { + case 'offline': + this.updateProcessor?.close(); + break; + case 'polling': + case 'streaming': + if (this.context) { + // identify will start the update processor + this.setupConnection(this.context); + } + + break; + default: + this.logger.warn( + `Unknown ConnectionMode: ${mode}. Only 'offline', 'streaming', and 'polling' are supported.`, + ); + break; + } + } + + getConnectionMode(): ConnectionMode { + return this.connectionMode; + } +} diff --git a/packages/sdk/react-native/src/RNOptions.ts b/packages/sdk/react-native/src/RNOptions.ts index c48c22e3a..ba6264440 100644 --- a/packages/sdk/react-native/src/RNOptions.ts +++ b/packages/sdk/react-native/src/RNOptions.ts @@ -1,4 +1,4 @@ -import { LDOptions } from '@launchdarkly/js-client-sdk-common'; +import { ConnectionMode, LDOptions } from '@launchdarkly/js-client-sdk-common'; /** * Interface for providing custom storage implementations for react Native. @@ -95,6 +95,16 @@ export interface RNSpecificOptions { * Defaults to @react-native-async-storage/async-storage. */ readonly storage?: RNStorage; + + /** + * Sets the mode to use for connections when the SDK is initialized. + * + * @remarks + * Possible values are offline, streaming, or polling. See {@link ConnectionMode} for more information. + * + * @defaultValue streaming. + */ + initialConnectionMode?: ConnectionMode; } export default interface RNOptions extends LDOptions, RNSpecificOptions {} diff --git a/packages/sdk/react-native/src/ReactNativeLDClient.ts b/packages/sdk/react-native/src/ReactNativeLDClient.ts index a0ade06ed..b4cc0db2f 100644 --- a/packages/sdk/react-native/src/ReactNativeLDClient.ts +++ b/packages/sdk/react-native/src/ReactNativeLDClient.ts @@ -3,14 +3,17 @@ import { AutoEnvAttributes, base64UrlEncode, BasicLogger, + type Configuration, ConnectionMode, - DataSourcePaths, Encoding, + FlagManager, internal, LDClientImpl, - type LDContext, + LDEmitter, + LDHeaders, } from '@launchdarkly/js-client-sdk-common'; +import MobileDataManager from './MobileDataManager'; import validateOptions, { filterToBaseOptions } from './options'; import createPlatform from './platform'; import { ConnectionDestination, ConnectionManager } from './platform/ConnectionManager'; @@ -59,18 +62,55 @@ export default class ReactNativeLDClient extends LDClientImpl { }; const validatedRnOptions = validateOptions(options, logger); + const platform = createPlatform(logger, validatedRnOptions.storage); super( sdkKey, autoEnvAttributes, - createPlatform(logger, validatedRnOptions.storage), + platform, { ...filterToBaseOptions(options), logger }, + ( + flagManager: FlagManager, + configuration: Configuration, + baseHeaders: LDHeaders, + emitter: LDEmitter, + diagnosticsManager?: internal.DiagnosticsManager, + ) => + new MobileDataManager( + platform, + flagManager, + sdkKey, + configuration, + validatedRnOptions, + () => ({ + 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, + ), internalOptions, ); + this.setEventSendingEnabled(!this.isOffline(), false); + + const dataManager = this.dataManager as MobileDataManager; const destination: ConnectionDestination = { setNetworkAvailability: (available: boolean) => { - this.setNetworkAvailability(available); + dataManager.setNetworkAvailability(available); }, setEventSendingEnabled: (enabled: boolean, flush: boolean) => { this.setEventSendingEnabled(enabled, flush); @@ -78,7 +118,7 @@ export default class ReactNativeLDClient extends LDClientImpl { setConnectionMode: async (mode: ConnectionMode) => { // Pass the connection mode to the base implementation. // The RN implementation will pass the connection mode through the connection manager. - this.baseSetConnectionMode(mode); + dataManager.setConnectionMode(mode); }, }; @@ -96,42 +136,24 @@ export default class ReactNativeLDClient extends LDClientImpl { ); } - private baseSetConnectionMode(mode: ConnectionMode) { - // Jest had problems with calls to super from nested arrow functions, so this method proxies the call. - super.setConnectionMode(mode); - } - - private encodeContext(context: LDContext) { - return base64UrlEncode(JSON.stringify(context), this.platform.encoding!); - } - - override getStreamingPaths(): DataSourcePaths { - return { - pathGet(encoding: Encoding, _plainContextString: string): string { - return `/meval/${base64UrlEncode(_plainContextString, encoding)}`; - }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - return `/meval`; - }, - }; - } - - override getPollingPaths(): DataSourcePaths { - return { - pathGet(encoding: Encoding, _plainContextString: string): string { - return `/msdk/evalx/contexts/${base64UrlEncode(_plainContextString, encoding)}`; - }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - return `/msdk/evalx/context`; - }, - }; - } - - override async setConnectionMode(mode: ConnectionMode): Promise { + async setConnectionMode(mode: ConnectionMode): Promise { // Set the connection mode before setting offline, in case there is any mode transition work // such as flushing on entering the background. this.connectionManager.setConnectionMode(mode); // For now the data source connection and the event processing state are connected. this.connectionManager.setOffline(mode === 'offline'); } + + /** + * Gets the SDK connection mode. + */ + getConnectionMode(): ConnectionMode { + const dataManager = this.dataManager as MobileDataManager; + return dataManager.getConnectionMode(); + } + + isOffline() { + const dataManager = this.dataManager as MobileDataManager; + return dataManager.getConnectionMode() === 'offline'; + } } diff --git a/packages/sdk/react-native/src/options.ts b/packages/sdk/react-native/src/options.ts index ffc2b57e3..e981b4a31 100644 --- a/packages/sdk/react-native/src/options.ts +++ b/packages/sdk/react-native/src/options.ts @@ -1,4 +1,5 @@ import { + ConnectionMode, LDLogger, LDOptions, OptionMessages, @@ -8,18 +9,29 @@ import { import RNOptions, { RNStorage } from './RNOptions'; +class ConnectionModeValidator implements TypeValidator { + is(u: unknown): u is ConnectionMode { + return u === 'offline' || u === 'streaming' || u === 'polling'; + } + getType(): string { + return 'ConnectionMode (offline | streaming | polling)'; + } +} + export interface ValidatedOptions { runInBackground: boolean; automaticNetworkHandling: boolean; automaticBackgroundHandling: boolean; storage?: RNStorage; + initialConnectionMode: ConnectionMode; } -const optDefaults = { +const optDefaults: ValidatedOptions = { runInBackground: false, automaticNetworkHandling: true, automaticBackgroundHandling: true, storage: undefined, + initialConnectionMode: 'streaming', }; const validators: { [Property in keyof RNOptions]: TypeValidator | undefined } = { @@ -27,6 +39,7 @@ const validators: { [Property in keyof RNOptions]: TypeValidator | undefined } = automaticNetworkHandling: TypeValidators.Boolean, automaticBackgroundHandling: TypeValidators.Boolean, storage: TypeValidators.Object, + initialConnectionMode: new ConnectionModeValidator(), }; export function filterToBaseOptions(opts: RNOptions): LDOptions { @@ -48,6 +61,7 @@ export default function validateOptions(opts: RNOptions, logger: LDLogger): Vali const value = opts[key]; if (value !== undefined) { if (validator.is(value)) { + // @ts-ignore The type inference has some problems here. output[key as keyof ValidatedOptions] = value as any; } else { logger.warn(OptionMessages.wrongOptionType(key, validator.getType(), typeof value)); diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.events.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.events.test.ts index 093fcaef3..3b6c6a084 100644 --- a/packages/shared/sdk-client/__tests__/LDClientImpl.events.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.events.test.ts @@ -2,7 +2,6 @@ import { AutoEnvAttributes, ClientContext, clone, - Encoding, internal, LDContext, subsystem, @@ -17,6 +16,7 @@ import LDClientImpl from '../src/LDClientImpl'; import { Flags } from '../src/types'; import * as mockResponseJson from './evaluation/mockResponse.json'; import { MockEventSource } from './streaming/LDClientImpl.mocks'; +import { makeTestDataManagerFactory } from './TestDataManager'; type InputCustomEvent = internal.InputCustomEvent; type InputIdentifyEvent = internal.InputIdentifyEvent; @@ -80,18 +80,15 @@ describe('sdk-client object', () => { mockPlatform.crypto.randomUUID.mockReturnValue('random1'); - ldc = new LDClientImpl(testSdkKey, AutoEnvAttributes.Enabled, mockPlatform, { - logger, - }); - - jest.spyOn(LDClientImpl.prototype as any, 'getStreamingPaths').mockReturnValue({ - pathGet(_encoding: Encoding, _plainContextString: string): string { - return '/stream/path'; - }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - return '/stream/path'; + ldc = new LDClientImpl( + testSdkKey, + AutoEnvAttributes.Enabled, + mockPlatform, + { + logger, }, - }); + makeTestDataManagerFactory(testSdkKey, mockPlatform), + ); }); afterEach(() => { diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.storage.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.storage.test.ts index 976dd2092..186bbafb0 100644 --- a/packages/shared/sdk-client/__tests__/LDClientImpl.storage.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.storage.test.ts @@ -1,4 +1,4 @@ -import { AutoEnvAttributes, clone, Encoding, type LDContext } from '@launchdarkly/js-sdk-common'; +import { AutoEnvAttributes, clone, type LDContext } from '@launchdarkly/js-sdk-common'; import { createBasicPlatform, createLogger } from '@launchdarkly/private-js-mocks'; import { toMulti } from '../src/context/addAutoEnv'; @@ -7,6 +7,7 @@ import LDEmitter from '../src/LDEmitter'; import { Flags, PatchFlag } from '../src/types'; import * as mockResponseJson from './evaluation/mockResponse.json'; import { MockEventSource } from './streaming/LDClientImpl.mocks'; +import { makeTestDataManagerFactory } from './TestDataManager'; let mockPlatform: ReturnType; let logger: ReturnType; @@ -51,19 +52,16 @@ describe('sdk-client storage', () => { } }); - jest.spyOn(LDClientImpl.prototype as any, 'getStreamingPaths').mockReturnValue({ - pathGet(_encoding: Encoding, _plainContextString: string): string { - return '/stream/path'; + ldc = new LDClientImpl( + testSdkKey, + AutoEnvAttributes.Disabled, + mockPlatform, + { + logger, + sendEvents: false, }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - return '/stream/path'; - }, - }); - - ldc = new LDClientImpl(testSdkKey, AutoEnvAttributes.Disabled, mockPlatform, { - logger, - sendEvents: false, - }); + makeTestDataManagerFactory(testSdkKey, mockPlatform), + ); // @ts-ignore emitter = ldc.emitter; @@ -120,10 +118,16 @@ describe('sdk-client storage', () => { }, ); - ldc = new LDClientImpl(testSdkKey, AutoEnvAttributes.Enabled, mockPlatform, { - logger, - sendEvents: false, - }); + ldc = new LDClientImpl( + testSdkKey, + AutoEnvAttributes.Enabled, + mockPlatform, + { + logger, + sendEvents: false, + }, + makeTestDataManagerFactory(testSdkKey, mockPlatform), + ); // @ts-ignore emitter = ldc.emitter; jest.spyOn(emitter as LDEmitter, 'emit'); diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts index 4e732d114..4eda5c715 100644 --- a/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts @@ -1,10 +1,11 @@ -import { AutoEnvAttributes, clone, Encoding, Hasher, LDContext } from '@launchdarkly/js-sdk-common'; +import { AutoEnvAttributes, clone, Hasher, LDContext } from '@launchdarkly/js-sdk-common'; import { createBasicPlatform, createLogger } from '@launchdarkly/private-js-mocks'; import LDClientImpl from '../src/LDClientImpl'; import { Flags } from '../src/types'; import * as mockResponseJson from './evaluation/mockResponse.json'; import { MockEventSource } from './streaming/LDClientImpl.mocks'; +import { makeTestDataManagerFactory } from './TestDataManager'; const testSdkKey = 'test-sdk-key'; const context: LDContext = { kind: 'org', key: 'Testy Pizza' }; @@ -43,15 +44,6 @@ describe('sdk-client object', () => { }; mockPlatform.crypto.createHash.mockReturnValue(hasher); - jest.spyOn(LDClientImpl.prototype as any, 'getStreamingPaths').mockReturnValue({ - pathGet(_encoding: Encoding, _plainContextString: string): string { - return '/stream/path/get'; - }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - return '/stream/path/report'; - }, - }); - simulatedEvents = [{ data: JSON.stringify(defaultPutResponse) }]; mockPlatform.requests.getEventSourceCapabilities.mockImplementation(() => ({ readTimeout: true, @@ -66,10 +58,16 @@ describe('sdk-client object', () => { }, ); - ldc = new LDClientImpl(testSdkKey, AutoEnvAttributes.Enabled, mockPlatform, { - logger, - sendEvents: false, - }); + ldc = new LDClientImpl( + testSdkKey, + AutoEnvAttributes.Enabled, + mockPlatform, + { + logger, + sendEvents: false, + }, + makeTestDataManagerFactory(testSdkKey, mockPlatform), + ); }); afterEach(async () => { @@ -135,11 +133,17 @@ describe('sdk-client object', () => { }); mockPlatform.requests.createEventSource = mockCreateEventSource; - ldc = new LDClientImpl(testSdkKey, AutoEnvAttributes.Enabled, mockPlatform, { - logger, - sendEvents: false, - withReasons: true, - }); + ldc = new LDClientImpl( + testSdkKey, + AutoEnvAttributes.Enabled, + mockPlatform, + { + logger, + sendEvents: false, + withReasons: true, + }, + makeTestDataManagerFactory(testSdkKey, mockPlatform), + ); await ldc.identify(carContext); @@ -160,11 +164,17 @@ describe('sdk-client object', () => { }); mockPlatform.requests.createEventSource = mockCreateEventSource; - ldc = new LDClientImpl(testSdkKey, AutoEnvAttributes.Enabled, mockPlatform, { - logger, - sendEvents: false, - useReport: true, - }); + ldc = new LDClientImpl( + testSdkKey, + AutoEnvAttributes.Enabled, + mockPlatform, + { + logger, + sendEvents: false, + useReport: true, + }, + makeTestDataManagerFactory(testSdkKey, mockPlatform), + ); await ldc.identify(carContext); @@ -179,10 +189,16 @@ describe('sdk-client object', () => { simulatedEvents = [{ data: JSON.stringify(defaultPutResponse) }]; const carContext: LDContext = { kind: 'car', key: 'test-car' }; - ldc = new LDClientImpl(testSdkKey, AutoEnvAttributes.Disabled, mockPlatform, { - logger, - sendEvents: false, - }); + ldc = new LDClientImpl( + testSdkKey, + AutoEnvAttributes.Disabled, + mockPlatform, + { + logger, + sendEvents: false, + }, + makeTestDataManagerFactory(testSdkKey, mockPlatform), + ); await ldc.identify(carContext); const c = ldc.getContext(); diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.timeout.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.timeout.test.ts index bfe2a5df7..fd2bc60f5 100644 --- a/packages/shared/sdk-client/__tests__/LDClientImpl.timeout.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.timeout.test.ts @@ -1,4 +1,4 @@ -import { AutoEnvAttributes, clone, Encoding, LDContext } from '@launchdarkly/js-sdk-common'; +import { AutoEnvAttributes, clone, LDContext } from '@launchdarkly/js-sdk-common'; import { createBasicPlatform, createLogger } from '@launchdarkly/private-js-mocks'; import { toMulti } from '../src/context/addAutoEnv'; @@ -6,6 +6,7 @@ import LDClientImpl from '../src/LDClientImpl'; import { Flags } from '../src/types'; import * as mockResponseJson from './evaluation/mockResponse.json'; import { MockEventSource } from './streaming/LDClientImpl.mocks'; +import { makeTestDataManagerFactory } from './TestDataManager'; let mockPlatform: ReturnType; let logger: ReturnType; @@ -41,18 +42,16 @@ describe('sdk-client identify timeout', () => { }, ); - ldc = new LDClientImpl(testSdkKey, AutoEnvAttributes.Enabled, mockPlatform, { - logger, - sendEvents: false, - }); - jest.spyOn(LDClientImpl.prototype as any, 'getStreamingPaths').mockReturnValue({ - pathGet(_encoding: Encoding, _plainContextString: string): string { - return '/stream/path'; - }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - return '/stream/path'; + ldc = new LDClientImpl( + testSdkKey, + AutoEnvAttributes.Enabled, + mockPlatform, + { + logger, + sendEvents: false, }, - }); + makeTestDataManagerFactory(testSdkKey, mockPlatform), + ); }); afterEach(() => { @@ -129,6 +128,7 @@ describe('sdk-client identify timeout', () => { logger, sendEvents: false, }, + makeTestDataManagerFactory(testSdkKey, mockPlatform), { highTimeoutThreshold }, ); const customTimeout = 10; diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.variation.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.variation.test.ts index 664a54ca2..c360e33bd 100644 --- a/packages/shared/sdk-client/__tests__/LDClientImpl.variation.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.variation.test.ts @@ -1,16 +1,11 @@ -import { - AutoEnvAttributes, - clone, - Context, - Encoding, - LDContext, -} from '@launchdarkly/js-sdk-common'; +import { AutoEnvAttributes, clone, Context, LDContext } from '@launchdarkly/js-sdk-common'; import { createBasicPlatform, createLogger } from '@launchdarkly/private-js-mocks'; import LDClientImpl from '../src/LDClientImpl'; import { Flags } from '../src/types'; import * as mockResponseJson from './evaluation/mockResponse.json'; import { MockEventSource } from './streaming/LDClientImpl.mocks'; +import { makeTestDataManagerFactory } from './TestDataManager'; let mockPlatform: ReturnType; let logger: ReturnType; @@ -31,14 +26,6 @@ let defaultPutResponse: Flags; describe('sdk-client object', () => { beforeEach(() => { defaultPutResponse = clone(mockResponseJson); - jest.spyOn(LDClientImpl.prototype as any, 'getStreamingPaths').mockReturnValue({ - pathGet(_encoding: Encoding, _plainContextString: string): string { - return '/stream/path'; - }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - return '/stream/path'; - }, - }); simulatedEvents = [{ data: JSON.stringify(defaultPutResponse) }]; mockPlatform.requests.createEventSource.mockImplementation( @@ -49,10 +36,16 @@ describe('sdk-client object', () => { }, ); - ldc = new LDClientImpl(testSdkKey, AutoEnvAttributes.Disabled, mockPlatform, { - logger, - sendEvents: false, - }); + ldc = new LDClientImpl( + testSdkKey, + AutoEnvAttributes.Disabled, + mockPlatform, + { + logger, + sendEvents: false, + }, + makeTestDataManagerFactory(testSdkKey, mockPlatform), + ); }); afterEach(() => { diff --git a/packages/shared/sdk-client/__tests__/TestDataManager.ts b/packages/shared/sdk-client/__tests__/TestDataManager.ts new file mode 100644 index 000000000..7976a1329 --- /dev/null +++ b/packages/shared/sdk-client/__tests__/TestDataManager.ts @@ -0,0 +1,88 @@ +import { + base64UrlEncode, + Context, + Encoding, + internal, + LDHeaders, + Platform, +} from '@launchdarkly/js-sdk-common'; + +import { LDIdentifyOptions } from '../src/api'; +import { Configuration } from '../src/configuration/Configuration'; +import { BaseDataManager, DataManagerFactory } from '../src/DataManager'; +import { FlagManager } from '../src/flag-manager/FlagManager'; +import LDEmitter from '../src/LDEmitter'; + +export default class TestDataManager extends BaseDataManager { + override async identify( + identifyResolve: () => void, + identifyReject: (err: Error) => void, + context: Context, + identifyOptions?: LDIdentifyOptions, + ): Promise { + this.context = context; + const waitForNetworkResults = !!identifyOptions?.waitForNetworkResults; + + const loadedFromCache = await this.flagManager.loadCached(context); + if (loadedFromCache && !waitForNetworkResults) { + this.logger.debug('Identify completing with cached flags'); + identifyResolve(); + } + if (loadedFromCache && waitForNetworkResults) { + this.logger.debug( + 'Identify - Flags loaded from cache, but identify was requested with "waitForNetworkResults"', + ); + } + + this.setupConnection(context, identifyResolve, identifyReject); + } + + private setupConnection( + context: Context, + identifyResolve?: () => void, + identifyReject?: (err: Error) => void, + ) { + const rawContext = Context.toLDContext(context)!; + + this.updateProcessor?.close(); + + this.createStreamingProcessor(rawContext, context, identifyResolve, identifyReject); + + this.updateProcessor!.start(); + } +} + +export function makeTestDataManagerFactory(sdkKey: string, platform: Platform): DataManagerFactory { + return ( + flagManager: FlagManager, + configuration: Configuration, + baseHeaders: LDHeaders, + emitter: LDEmitter, + diagnosticsManager?: internal.DiagnosticsManager, + ) => + new TestDataManager( + platform, + flagManager, + sdkKey, + configuration, + () => ({ + 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 '/stream/path'; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return '/stream/path/report'; + }, + }), + baseHeaders, + emitter, + diagnosticsManager, + ); +} diff --git a/packages/shared/sdk-client/__tests__/configuration/Configuration.test.ts b/packages/shared/sdk-client/__tests__/configuration/Configuration.test.ts index 6ce292295..f8af0b79c 100644 --- a/packages/shared/sdk-client/__tests__/configuration/Configuration.test.ts +++ b/packages/shared/sdk-client/__tests__/configuration/Configuration.test.ts @@ -1,5 +1,5 @@ /* eslint-disable no-console */ -import Configuration from '../../src/configuration/Configuration'; +import ConfigurationImpl from '../../src/configuration/Configuration'; describe('Configuration', () => { beforeEach(() => { @@ -8,7 +8,7 @@ describe('Configuration', () => { }); it('has valid default values', () => { - const config = new Configuration(); + const config = new ConfigurationImpl(); expect(config).toMatchObject({ allAttributesPrivate: false, @@ -37,13 +37,13 @@ describe('Configuration', () => { }); it('allows specifying valid wrapperName', () => { - const config = new Configuration({ wrapperName: 'test' }); + const config = new ConfigurationImpl({ wrapperName: 'test' }); expect(config).toMatchObject({ wrapperName: 'test' }); }); it('warns and ignored invalid keys', () => { // @ts-ignore - const config = new Configuration({ baseballUri: 1 }); + const config = new ConfigurationImpl({ baseballUri: 1 }); expect(config.baseballUri).toBeUndefined(); expect(console.error).toHaveBeenCalledWith(expect.stringContaining('unknown config option')); @@ -51,7 +51,7 @@ describe('Configuration', () => { it('converts boolean types', () => { // @ts-ignore - const config = new Configuration({ sendEvents: 0 }); + const config = new ConfigurationImpl({ sendEvents: 0 }); expect(config.sendEvents).toBeFalsy(); expect(console.error).toHaveBeenCalledWith( @@ -61,7 +61,7 @@ describe('Configuration', () => { it('ignores wrong type for number and logs appropriately', () => { // @ts-ignore - const config = new Configuration({ capacity: true }); + const config = new ConfigurationImpl({ capacity: true }); expect(config.capacity).toEqual(100); expect(console.error).toHaveBeenCalledWith( @@ -70,7 +70,7 @@ describe('Configuration', () => { }); it('enforces minimum flushInterval', () => { - const config = new Configuration({ flushInterval: 1 }); + const config = new ConfigurationImpl({ flushInterval: 1 }); expect(config.flushInterval).toEqual(2); expect(console.error).toHaveBeenNthCalledWith( @@ -80,14 +80,14 @@ describe('Configuration', () => { }); it('allows setting a valid maxCachedContexts', () => { - const config = new Configuration({ maxCachedContexts: 3 }); + const config = new ConfigurationImpl({ maxCachedContexts: 3 }); expect(config.maxCachedContexts).toBeDefined(); expect(console.error).not.toHaveBeenCalled(); }); it('enforces minimum maxCachedContext', () => { - const config = new Configuration({ maxCachedContexts: -1 }); + const config = new ConfigurationImpl({ maxCachedContexts: -1 }); expect(config.maxCachedContexts).toBeDefined(); expect(console.error).toHaveBeenNthCalledWith( @@ -103,7 +103,7 @@ describe('Configuration', () => { ['kebab-case-works'], ['snake_case_works'], ])('allow setting valid payload filter keys', (filter) => { - const config = new Configuration({ payloadFilterKey: filter }); + const config = new ConfigurationImpl({ payloadFilterKey: filter }); expect(config.payloadFilterKey).toEqual(filter); expect(console.error).toHaveBeenCalledTimes(0); }); @@ -111,7 +111,7 @@ describe('Configuration', () => { it.each([['invalid-@-filter'], ['_invalid-filter'], ['-invalid-filter']])( 'ignores invalid filters and logs a warning', (filter) => { - const config = new Configuration({ payloadFilterKey: filter }); + const config = new ConfigurationImpl({ payloadFilterKey: filter }); expect(config.payloadFilterKey).toBeUndefined(); expect(console.error).toHaveBeenNthCalledWith( 1, diff --git a/packages/shared/sdk-client/__tests__/context/addAutoEnv.test.ts b/packages/shared/sdk-client/__tests__/context/addAutoEnv.test.ts index b5414b543..e6980d137 100644 --- a/packages/shared/sdk-client/__tests__/context/addAutoEnv.test.ts +++ b/packages/shared/sdk-client/__tests__/context/addAutoEnv.test.ts @@ -7,7 +7,7 @@ import { } from '@launchdarkly/js-sdk-common'; import { createBasicPlatform, createLogger } from '@launchdarkly/private-js-mocks'; -import Configuration from '../../src/configuration'; +import { Configuration, ConfigurationImpl } from '../../src/configuration'; import { addApplicationInfo, addAutoEnv, @@ -31,7 +31,7 @@ describe('automatic environment attributes', () => { beforeEach(() => { ({ crypto, info } = mockPlatform); (crypto.randomUUID as jest.Mock).mockResolvedValue('test-device-key-1'); - config = new Configuration({ logger }); + config = new ConfigurationImpl({ logger }); }); afterEach(() => { @@ -338,7 +338,7 @@ describe('automatic environment attributes', () => { describe('addApplicationInfo', () => { test('add id, version, name, versionName', async () => { - config = new Configuration({ + config = new ConfigurationImpl({ applicationInfo: { id: 'com.from-config.ld', version: '2.2.2', @@ -431,7 +431,7 @@ describe('automatic environment attributes', () => { info.platformData = jest .fn() .mockReturnValueOnce({ ld_application: { version: null, locale: '' } }); - config = new Configuration({ applicationInfo: { version: '1.2.3' } }); + config = new ConfigurationImpl({ applicationInfo: { version: '1.2.3' } }); const ldApplication = await addApplicationInfo(mockPlatform, config); expect(ldApplication).toBeUndefined(); diff --git a/packages/shared/sdk-client/__tests__/diagnostics/createDiagnosticsInitConfig.test.ts b/packages/shared/sdk-client/__tests__/diagnostics/createDiagnosticsInitConfig.test.ts index 1a30e21e1..004dd0f7b 100644 --- a/packages/shared/sdk-client/__tests__/diagnostics/createDiagnosticsInitConfig.test.ts +++ b/packages/shared/sdk-client/__tests__/diagnostics/createDiagnosticsInitConfig.test.ts @@ -1,6 +1,6 @@ import { secondsToMillis } from '@launchdarkly/js-sdk-common'; -import Configuration from '../../src/configuration'; +import { ConfigurationImpl } from '../../src/configuration'; import createDiagnosticsInitConfig, { type DiagnosticsInitConfig, } from '../../src/diagnostics/createDiagnosticsInitConfig'; @@ -9,7 +9,7 @@ describe('createDiagnosticsInitConfig', () => { let initConfig: DiagnosticsInitConfig; beforeEach(() => { - initConfig = createDiagnosticsInitConfig(new Configuration()); + initConfig = createDiagnosticsInitConfig(new ConfigurationImpl()); }); test('defaults', () => { @@ -29,7 +29,7 @@ describe('createDiagnosticsInitConfig', () => { test('non-default config', () => { const custom = createDiagnosticsInitConfig( - new Configuration({ + new ConfigurationImpl({ baseUri: 'https://dev.ld.com', streamUri: 'https://stream.ld.com', eventsUri: 'https://events.ld.com', diff --git a/packages/shared/sdk-client/src/DataManager.ts b/packages/shared/sdk-client/src/DataManager.ts new file mode 100644 index 000000000..1fb0e9c1c --- /dev/null +++ b/packages/shared/sdk-client/src/DataManager.ts @@ -0,0 +1,211 @@ +import { + Context, + EventName, + internal, + LDContext, + LDHeaders, + LDLogger, + Platform, + ProcessStreamResponse, + subsystem, +} from '@launchdarkly/js-sdk-common'; + +import { LDIdentifyOptions } from './api/LDIdentifyOptions'; +import { Configuration } from './configuration/Configuration'; +import { FlagManager } from './flag-manager/FlagManager'; +import { ItemDescriptor } from './flag-manager/ItemDescriptor'; +import LDEmitter from './LDEmitter'; +import PollingProcessor from './polling/PollingProcessor'; +import { DataSourcePaths, StreamingProcessor } from './streaming'; +import { DeleteFlag, Flags, PatchFlag } from './types'; + +export interface DataManager { + /** + * This function handles the data management aspects of the identification process. + * + * Implementation Note: The identifyResolve and identifyReject function resolve or reject the + * identify function at LDClient level. It is likely in individual implementations that these + * functions will be passed to other components, such as a datasource, do indicate when the + * identify process has been completed. The data manager identify function should return once + * everything has been set in motion to complete the identification process. + * + * @param identifyResolve Called to reject the identify operation. + * @param identifyReject Called to complete the identify operation. + * @param context The context being identified. + * @param identifyOptions Options for identification. + */ + identify( + identifyResolve: () => void, + identifyReject: (err: Error) => void, + context: Context, + identifyOptions?: LDIdentifyOptions, + ): Promise; +} + +/** + * Factory interface for constructing data managers. + */ +export interface DataManagerFactory { + ( + flagManager: FlagManager, + configuration: Configuration, + baseHeaders: LDHeaders, + emitter: LDEmitter, + diagnosticsManager?: internal.DiagnosticsManager, + ): DataManager; +} + +export abstract class BaseDataManager implements DataManager { + protected updateProcessor?: subsystem.LDStreamProcessor; + protected readonly logger: LDLogger; + protected context?: Context; + + constructor( + protected readonly platform: Platform, + protected readonly flagManager: FlagManager, + protected readonly credential: string, + protected readonly config: Configuration, + protected readonly getPollingPaths: () => DataSourcePaths, + protected readonly getStreamingPaths: () => DataSourcePaths, + protected readonly baseHeaders: LDHeaders, + protected readonly emitter: LDEmitter, + protected readonly diagnosticsManager?: internal.DiagnosticsManager, + ) { + this.logger = config.logger; + } + + abstract identify( + identifyResolve: () => void, + identifyReject: (err: Error) => void, + context: Context, + identifyOptions?: LDIdentifyOptions, + ): Promise; + + protected createPollingProcessor( + context: LDContext, + checkedContext: Context, + identifyResolve?: () => void, + identifyReject?: (err: Error) => void, + ) { + this.updateProcessor = new PollingProcessor( + JSON.stringify(context), + { + credential: this.credential, + serviceEndpoints: this.config.serviceEndpoints, + paths: this.getPollingPaths(), + baseHeaders: this.baseHeaders, + pollInterval: this.config.pollInterval, + withReasons: this.config.withReasons, + useReport: this.config.useReport, + }, + this.platform.requests, + this.platform.encoding!, + async (flags) => { + this.logger.debug(`Handling polling result: ${Object.keys(flags)}`); + + // mapping flags to item descriptors + const descriptors = Object.entries(flags).reduce( + (acc: { [k: string]: ItemDescriptor }, [key, flag]) => { + acc[key] = { version: flag.version, flag }; + return acc; + }, + {}, + ); + + await this.flagManager.init(checkedContext, descriptors); + identifyResolve?.(); + }, + (err) => { + identifyReject?.(err); + this.emitter.emit('error', context, err); + }, + ); + } + + protected createStreamingProcessor( + context: LDContext, + checkedContext: Context, + identifyResolve?: () => void, + identifyReject?: (err: Error) => void, + ) { + this.updateProcessor = new StreamingProcessor( + JSON.stringify(context), + { + credential: this.credential, + serviceEndpoints: this.config.serviceEndpoints, + paths: this.getStreamingPaths(), + baseHeaders: this.baseHeaders, + initialRetryDelayMillis: this.config.streamInitialReconnectDelay * 1000, + withReasons: this.config.withReasons, + useReport: this.config.useReport, + }, + this.createStreamListeners(checkedContext, identifyResolve), + this.platform.requests, + this.platform.encoding!, + this.diagnosticsManager, + (e) => { + identifyReject?.(e); + this.emitter.emit('error', context, e); + }, + ); + } + + protected createStreamListeners( + context: Context, + identifyResolve?: () => void, + ): Map { + const listeners = new Map(); + + listeners.set('put', { + deserializeData: JSON.parse, + processJson: async (evalResults: Flags) => { + this.logger.debug(`Stream PUT: ${Object.keys(evalResults)}`); + + // mapping flags to item descriptors + const descriptors = Object.entries(evalResults).reduce( + (acc: { [k: string]: ItemDescriptor }, [key, flag]) => { + acc[key] = { version: flag.version, flag }; + return acc; + }, + {}, + ); + await this.flagManager.init(context, descriptors); + identifyResolve?.(); + }, + }); + + listeners.set('patch', { + deserializeData: JSON.parse, + processJson: async (patchFlag: PatchFlag) => { + this.logger.debug(`Stream PATCH ${JSON.stringify(patchFlag, null, 2)}`); + this.flagManager.upsert(context, patchFlag.key, { + version: patchFlag.version, + flag: patchFlag, + }); + }, + }); + + listeners.set('delete', { + deserializeData: JSON.parse, + processJson: async (deleteFlag: DeleteFlag) => { + this.logger.debug(`Stream DELETE ${JSON.stringify(deleteFlag, null, 2)}`); + + this.flagManager.upsert(context, deleteFlag.key, { + version: deleteFlag.version, + flag: { + ...deleteFlag, + deleted: true, + // props below are set to sensible defaults. they are irrelevant + // because this flag has been deleted. + flagVersion: 0, + value: undefined, + variation: 0, + trackEvents: false, + }, + }); + }, + }); + + return listeners; + } +} diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index 8ce553859..77121d982 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -3,7 +3,6 @@ import { clone, Context, defaultHeaders, - Encoding, internal, LDClientError, LDContext, @@ -14,18 +13,18 @@ import { Platform, ProcessStreamResponse, EventName as StreamEventName, + subsystem, timedPromise, TypeValidators, } from '@launchdarkly/js-sdk-common'; -import { LDStreamProcessor } from '@launchdarkly/js-sdk-common/dist/api/subsystem'; -import { ConnectionMode, LDClient, type LDOptions } from './api'; +import { LDClient, type LDOptions } from './api'; import { LDEvaluationDetail, LDEvaluationDetailTyped } from './api/LDEvaluationDetail'; import { LDIdentifyOptions } from './api/LDIdentifyOptions'; -import Configuration from './configuration'; -import { LDClientInternalOptions } from './configuration/Configuration'; +import { Configuration, ConfigurationImpl, LDClientInternalOptions } from './configuration'; import { addAutoEnv } from './context/addAutoEnv'; import { ensureKey } from './context/ensureKey'; +import { DataManager, DataManagerFactory } from './DataManager'; import createDiagnosticsManager from './diagnostics/createDiagnosticsManager'; import { createErrorEvaluationDetail, @@ -33,12 +32,9 @@ import { } from './evaluation/evaluationDetail'; import createEventProcessor from './events/createEventProcessor'; import EventFactory from './events/EventFactory'; -import FlagManager from './flag-manager/FlagManager'; +import DefaultFlagManager, { FlagManager } from './flag-manager/FlagManager'; import { ItemDescriptor } from './flag-manager/ItemDescriptor'; import LDEmitter, { EventName } from './LDEmitter'; -import PollingProcessor from './polling/PollingProcessor'; -import { StreamingProcessor } from './streaming'; -import { DataSourcePaths } from './streaming/DataSourceConfig'; import { DeleteFlag, Flags, PatchFlag } from './types'; const { ClientMessages, ErrorKinds } = internal; @@ -51,7 +47,7 @@ export default class LDClientImpl implements LDClient { private eventProcessor?: internal.EventProcessor; private identifyTimeout: number = 5; readonly logger: LDLogger; - private updateProcessor?: LDStreamProcessor; + private updateProcessor?: subsystem.LDStreamProcessor; private readonly highTimeoutThreshold: number = 15; @@ -60,10 +56,9 @@ export default class LDClientImpl implements LDClient { private emitter: LDEmitter; private flagManager: FlagManager; - private eventSendingEnabled: boolean = true; - private networkAvailable: boolean = true; - private connectionMode: ConnectionMode; + private eventSendingEnabled: boolean = false; private baseHeaders: LDHeaders; + protected dataManager: DataManager; /** * Creates the client object synchronously. No async, no network calls. @@ -73,6 +68,7 @@ export default class LDClientImpl implements LDClient { public readonly autoEnvAttributes: AutoEnvAttributes, public readonly platform: Platform, options: LDOptions, + dataManagerFactory: DataManagerFactory, internalOptions?: LDClientInternalOptions, ) { if (!sdkKey) { @@ -83,8 +79,7 @@ export default class LDClientImpl implements LDClient { throw new Error('Platform must implement Encoding because btoa is required.'); } - this.config = new Configuration(options, internalOptions); - this.connectionMode = this.config.initialConnectionMode; + this.config = new ConfigurationImpl(options, internalOptions); this.logger = this.config.logger; this.baseHeaders = defaultHeaders( @@ -95,7 +90,7 @@ export default class LDClientImpl implements LDClient { this.config.userAgentHeaderName, ); - this.flagManager = new FlagManager( + this.flagManager = new DefaultFlagManager( this.platform, sdkKey, this.config.maxCachedContexts, @@ -108,7 +103,6 @@ export default class LDClientImpl implements LDClient { platform, this.baseHeaders, this.diagnosticsManager, - !this.isOffline(), ); this.emitter = new LDEmitter(); this.emitter.on('change', (c: LDContext, changedKeys: string[]) => { @@ -122,53 +116,14 @@ export default class LDClientImpl implements LDClient { const ldContext = Context.toLDContext(context); this.emitter.emit('change', ldContext, flagKeys); }); - } - - /** - * Sets the SDK connection mode. - * - * @param mode - One of supported {@link ConnectionMode}. Default is 'streaming'. - */ - async setConnectionMode(mode: ConnectionMode): Promise { - if (this.connectionMode === mode) { - this.logger.debug(`setConnectionMode ignored. Mode is already '${mode}'.`); - return Promise.resolve(); - } - - this.connectionMode = mode; - this.logger.debug(`setConnectionMode ${mode}.`); - - switch (mode) { - case 'offline': - this.updateProcessor?.close(); - break; - case 'polling': - case 'streaming': - if (this.uncheckedContext) { - // identify will start the update processor - return this.identify(this.uncheckedContext, { timeout: this.identifyTimeout }); - } - break; - default: - this.logger.warn( - `Unknown ConnectionMode: ${mode}. Only 'offline', 'streaming', and 'polling' are supported.`, - ); - break; - } - - return Promise.resolve(); - } - - /** - * Gets the SDK connection mode. - */ - getConnectionMode(): ConnectionMode { - return this.connectionMode; - } - - isOffline() { - return this.connectionMode === 'offline'; + this.dataManager = dataManagerFactory( + this.flagManager, + this.config, + this.baseHeaders, + this.emitter, + this.diagnosticsManager, + ); } allFlags(): LDFlagSet { @@ -275,37 +230,11 @@ export default class LDClientImpl implements LDClient { return listeners; } - protected getStreamingPaths(): DataSourcePaths { - return { - pathGet(_encoding: Encoding, _plainContextString: string): string { - throw new Error( - 'getStreamingPaths not implemented. Client sdks must implement getStreamingPaths for streaming with GET to work.', - ); - }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - throw new Error( - 'getStreamingPaths not implemented. Client sdks must implement getStreamingPaths for streaming with REPORT to work.', - ); - }, - }; - } - - protected getPollingPaths(): DataSourcePaths { - return { - pathGet(_encoding: Encoding, _plainContextString: string): string { - throw new Error( - 'getPollingPaths not implemented. Client sdks must implement getPollingPaths for polling with GET to work.', - ); - }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - throw new Error( - 'getPollingPaths not implemented. Client sdks must implement getPollingPaths for polling with REPORT to work.', - ); - }, - }; - } - - private createIdentifyPromise(timeout: number) { + private createIdentifyPromise(timeout: number): { + identifyPromise: Promise; + identifyResolve: () => void; + identifyReject: (err: Error) => void; + } { let res: any; let rej: any; @@ -341,9 +270,6 @@ export default class LDClientImpl implements LDClient { * 3. A network error is encountered during initialization. */ async identify(pristineContext: LDContext, identifyOptions?: LDIdentifyOptions): Promise { - // In offline mode we do not support waiting for results. - const waitForNetworkResults = !!identifyOptions?.waitForNetworkResults && !this.isOffline(); - if (identifyOptions?.timeout) { this.identifyTimeout = identifyOptions.timeout; } @@ -377,110 +303,14 @@ export default class LDClientImpl implements LDClient { ); this.logger.debug(`Identifying ${JSON.stringify(this.checkedContext)}`); - const loadedFromCache = await this.flagManager.loadCached(this.checkedContext); - if (loadedFromCache && !waitForNetworkResults) { - this.logger.debug('Identify completing with cached flags'); - identifyResolve(); - } - if (loadedFromCache && waitForNetworkResults) { - this.logger.debug( - 'Identify - Flags loaded from cache, but identify was requested with "waitForNetworkResults"', - ); - } - - if (this.isOffline()) { - if (loadedFromCache) { - this.logger.debug('Offline identify - using cached flags.'); - } else { - this.logger.debug( - 'Offline identify - no cached flags, using defaults or already loaded flags.', - ); - identifyResolve(); - } - } else { - this.updateProcessor?.close(); - switch (this.getConnectionMode()) { - case 'streaming': - this.createStreamingProcessor(context, checkedContext, identifyResolve, identifyReject); - break; - case 'polling': - this.createPollingProcessor(context, checkedContext, identifyResolve, identifyReject); - break; - default: - break; - } - this.updateProcessor!.start(); - } - - return identifyPromise; - } - - private createPollingProcessor( - context: LDContext, - checkedContext: Context, - identifyResolve: any, - identifyReject: any, - ) { - this.updateProcessor = new PollingProcessor( - JSON.stringify(context), - { - credential: this.sdkKey, - serviceEndpoints: this.config.serviceEndpoints, - paths: this.getPollingPaths(), - baseHeaders: this.baseHeaders, - pollInterval: this.config.pollInterval, - withReasons: this.config.withReasons, - useReport: this.config.useReport, - }, - this.platform.requests, - this.platform.encoding!, - async (flags) => { - this.logger.debug(`Handling polling result: ${Object.keys(flags)}`); - - // mapping flags to item descriptors - const descriptors = Object.entries(flags).reduce( - (acc: { [k: string]: ItemDescriptor }, [key, flag]) => { - acc[key] = { version: flag.version, flag }; - return acc; - }, - {}, - ); - - await this.flagManager.init(checkedContext, descriptors).then(identifyResolve()); - }, - (err) => { - identifyReject(err); - this.emitter.emit('error', context, err); - }, + await this.dataManager.identify( + identifyResolve, + identifyReject, + checkedContext, + identifyOptions, ); - } - private createStreamingProcessor( - context: LDContext, - checkedContext: Context, - identifyResolve: any, - identifyReject: any, - ) { - this.updateProcessor = new StreamingProcessor( - JSON.stringify(context), - { - credential: this.sdkKey, - serviceEndpoints: this.config.serviceEndpoints, - paths: this.getStreamingPaths(), - baseHeaders: this.baseHeaders, - initialRetryDelayMillis: this.config.streamInitialReconnectDelay * 1000, - withReasons: this.config.withReasons, - useReport: this.config.useReport, - }, - this.createStreamListeners(checkedContext, identifyResolve), - this.platform.requests, - this.platform.encoding!, - this.diagnosticsManager, - (e) => { - identifyReject(e); - this.emitter.emit('error', context, e); - }, - ); + return identifyPromise; } off(eventName: EventName, listener: Function): void { @@ -643,18 +473,6 @@ export default class LDClientImpl implements LDClient { return this.variationDetail(key, defaultValue); } - /** - * Inform the client of the network state. Can be used to modify connection behavior. - * - * For instance the implementation may choose to suppress errors from connections if the client - * knows that there is no network available. - * @param _available True when there is an available network. - */ - protected setNetworkAvailability(available: boolean): void { - this.networkAvailable = available; - // Not yet supported. - } - /** * Enable/Disable event sending. * @param enabled True to enable event processing, false to disable. diff --git a/packages/shared/sdk-client/src/api/LDClient.ts b/packages/shared/sdk-client/src/api/LDClient.ts index 8f26ad399..710eef814 100644 --- a/packages/shared/sdk-client/src/api/LDClient.ts +++ b/packages/shared/sdk-client/src/api/LDClient.ts @@ -1,6 +1,5 @@ import { LDContext, LDFlagSet, LDFlagValue, LDLogger } from '@launchdarkly/js-sdk-common'; -import ConnectionMode from './ConnectionMode'; import { LDEvaluationDetail, LDEvaluationDetailTyped } from './LDEvaluationDetail'; import { LDIdentifyOptions } from './LDIdentifyOptions'; @@ -75,14 +74,6 @@ export interface LDClient { */ flush(): Promise<{ error?: Error; result: boolean }>; - /** - * Gets the SDK connection mode. - * - * @remarks - * Possible values are offline or streaming. See {@link ConnectionMode} for more information. - */ - getConnectionMode(): ConnectionMode; - /** * Returns the client's current context. * @@ -231,13 +222,6 @@ export interface LDClient { */ on(key: string, callback: (...args: any[]) => void): void; - /** - * Sets the SDK connection mode. - * - * @param mode - One of supported {@link ConnectionMode}. By default, the SDK uses streaming. - */ - setConnectionMode(mode: ConnectionMode): void; - /** * Determines the string variation of a feature flag. * diff --git a/packages/shared/sdk-client/src/api/LDOptions.ts b/packages/shared/sdk-client/src/api/LDOptions.ts index 70e6599bc..5e2c11513 100644 --- a/packages/shared/sdk-client/src/api/LDOptions.ts +++ b/packages/shared/sdk-client/src/api/LDOptions.ts @@ -1,7 +1,5 @@ import type { LDLogger } from '@launchdarkly/js-sdk-common'; -import ConnectionMode from './ConnectionMode'; - export interface LDOptions { /** * Whether all context attributes (except the context key) should be marked as private, and @@ -119,16 +117,6 @@ export interface LDOptions { */ flushInterval?: number; - /** - * Sets the mode to use for connections when the SDK is initialized. - * - * @remarks - * Possible values are offline or streaming. See {@link ConnectionMode} for more information. - * - * @defaultValue streaming. - */ - initialConnectionMode?: ConnectionMode; - /** * An object that will perform logging for the client. * diff --git a/packages/shared/sdk-client/src/api/index.ts b/packages/shared/sdk-client/src/api/index.ts index 24c6c13ce..5440396ba 100644 --- a/packages/shared/sdk-client/src/api/index.ts +++ b/packages/shared/sdk-client/src/api/index.ts @@ -5,3 +5,4 @@ export * from './LDClient'; export * from './LDEvaluationDetail'; export { ConnectionMode }; +export * from './LDIdentifyOptions'; diff --git a/packages/shared/sdk-client/src/configuration/Configuration.ts b/packages/shared/sdk-client/src/configuration/Configuration.ts index 64bb9867e..1066fdd9e 100644 --- a/packages/shared/sdk-client/src/configuration/Configuration.ts +++ b/packages/shared/sdk-client/src/configuration/Configuration.ts @@ -3,13 +3,14 @@ import { createSafeLogger, internal, LDFlagSet, + LDLogger, NumberWithMinimum, OptionMessages, ServiceEndpoints, TypeValidators, } from '@launchdarkly/js-sdk-common'; -import { ConnectionMode, type LDOptions } from '../api'; +import { type LDOptions } from '../api'; import validators from './validators'; const DEFAULT_POLLING_INTERVAL: number = 60 * 5; @@ -18,15 +19,54 @@ export interface LDClientInternalOptions extends internal.LDInternalOptions { trackEventModifier?: (event: internal.InputCustomEvent) => internal.InputCustomEvent; } -export default class Configuration { - public static DEFAULT_POLLING = 'https://clientsdk.launchdarkly.com'; - public static DEFAULT_STREAM = 'https://clientstream.launchdarkly.com'; +export interface Configuration { + readonly logger: LDLogger; + readonly baseUri: string; + readonly eventsUri: string; + readonly streamUri: string; + readonly maxCachedContexts: number; + readonly capacity: number; + readonly diagnosticRecordingInterval: number; + readonly flushInterval: number; + readonly streamInitialReconnectDelay: number; + readonly allAttributesPrivate: boolean; + readonly debug: boolean; + readonly diagnosticOptOut: boolean; + readonly sendEvents: boolean; + readonly sendLDHeaders: boolean; + readonly useReport: boolean; + readonly withReasons: boolean; + readonly privateAttributes: string[]; + readonly tags: ApplicationTags; + readonly applicationInfo?: { + id?: string; + version?: string; + name?: string; + versionName?: string; + }; + readonly bootstrap?: LDFlagSet; + readonly requestHeaderTransform?: (headers: Map) => Map; + readonly stream?: boolean; + readonly hash?: string; + readonly wrapperName?: string; + readonly wrapperVersion?: string; + readonly serviceEndpoints: ServiceEndpoints; + readonly pollInterval: number; + readonly userAgentHeaderName: 'user-agent' | 'x-launchdarkly-user-agent'; + readonly trackEventModifier: (event: internal.InputCustomEvent) => internal.InputCustomEvent; +} + +const DEFAULT_POLLING: string = 'https://clientsdk.launchdarkly.com'; +const DEFAULT_STREAM: string = 'https://clientstream.launchdarkly.com'; - public readonly logger = createSafeLogger(); +export { DEFAULT_POLLING, DEFAULT_STREAM }; - public readonly baseUri = Configuration.DEFAULT_POLLING; +export default class ConfigurationImpl implements Configuration { + public readonly logger: LDLogger = createSafeLogger(); + + public readonly baseUri = DEFAULT_POLLING; public readonly eventsUri = ServiceEndpoints.DEFAULT_EVENTS; - public readonly streamUri = Configuration.DEFAULT_STREAM; + public readonly streamUri = DEFAULT_STREAM; public readonly maxCachedContexts = 5; @@ -46,8 +86,6 @@ export default class Configuration { public readonly privateAttributes: string[] = []; - public readonly initialConnectionMode: ConnectionMode = 'streaming'; - public readonly tags: ApplicationTags; public readonly applicationInfo?: { id?: string; @@ -97,7 +135,7 @@ export default class Configuration { this.trackEventModifier = internalOptions.trackEventModifier ?? ((event) => event); } - validateTypesAndNames(pristineOptions: LDOptions): string[] { + private validateTypesAndNames(pristineOptions: LDOptions): string[] { const errors: string[] = []; Object.entries(pristineOptions).forEach(([k, v]) => { diff --git a/packages/shared/sdk-client/src/configuration/index.ts b/packages/shared/sdk-client/src/configuration/index.ts index b22c3ea49..cdb5c1344 100644 --- a/packages/shared/sdk-client/src/configuration/index.ts +++ b/packages/shared/sdk-client/src/configuration/index.ts @@ -1,3 +1,14 @@ -import Configuration from './Configuration'; +import ConfigurationImpl, { + Configuration, + DEFAULT_POLLING, + DEFAULT_STREAM, + LDClientInternalOptions, +} from './Configuration'; -export default Configuration; +export { + Configuration, + ConfigurationImpl, + LDClientInternalOptions, + DEFAULT_POLLING, + DEFAULT_STREAM, +}; diff --git a/packages/shared/sdk-client/src/configuration/validators.ts b/packages/shared/sdk-client/src/configuration/validators.ts index 17851bff1..4ec0a449a 100644 --- a/packages/shared/sdk-client/src/configuration/validators.ts +++ b/packages/shared/sdk-client/src/configuration/validators.ts @@ -3,18 +3,7 @@ import { TypeValidator, TypeValidators } from '@launchdarkly/js-sdk-common'; import { type LDOptions } from '../api'; -class ConnectionModeValidator implements TypeValidator { - is(u: unknown): boolean { - return u === 'offline' || u === 'streaming' || u === 'polling'; - } - - getType(): string { - return `offline | streaming | polling`; - } -} - const validators: Record = { - initialConnectionMode: new ConnectionModeValidator(), logger: TypeValidators.Object, maxCachedContexts: TypeValidators.numberWithMin(0), diff --git a/packages/shared/sdk-client/src/context/addAutoEnv.ts b/packages/shared/sdk-client/src/context/addAutoEnv.ts index f66f61b01..682a44071 100644 --- a/packages/shared/sdk-client/src/context/addAutoEnv.ts +++ b/packages/shared/sdk-client/src/context/addAutoEnv.ts @@ -11,7 +11,7 @@ import { Platform, } from '@launchdarkly/js-sdk-common'; -import Configuration from '../configuration'; +import { Configuration } from '../configuration'; import digest from '../crypto/digest'; import { getOrGenerateKey } from '../storage/getOrGenerateKey'; import { namespaceForGeneratedContextKey } from '../storage/namespaceUtils'; diff --git a/packages/shared/sdk-client/src/diagnostics/createDiagnosticsInitConfig.ts b/packages/shared/sdk-client/src/diagnostics/createDiagnosticsInitConfig.ts index aeb8bf3c5..e3230b05c 100644 --- a/packages/shared/sdk-client/src/diagnostics/createDiagnosticsInitConfig.ts +++ b/packages/shared/sdk-client/src/diagnostics/createDiagnosticsInitConfig.ts @@ -1,6 +1,6 @@ import { secondsToMillis, ServiceEndpoints } from '@launchdarkly/js-sdk-common'; -import Configuration from '../configuration'; +import { Configuration, DEFAULT_POLLING, DEFAULT_STREAM } from '../configuration'; export type DiagnosticsInitConfig = { // client & server common properties @@ -18,8 +18,8 @@ export type DiagnosticsInitConfig = { bootstrapMode: boolean; }; const createDiagnosticsInitConfig = (config: Configuration): DiagnosticsInitConfig => ({ - customBaseURI: config.baseUri !== Configuration.DEFAULT_POLLING, - customStreamURI: config.streamUri !== Configuration.DEFAULT_STREAM, + customBaseURI: config.baseUri !== DEFAULT_POLLING, + customStreamURI: config.streamUri !== DEFAULT_STREAM, customEventsURI: config.eventsUri !== ServiceEndpoints.DEFAULT_EVENTS, eventsCapacity: config.capacity, eventsFlushIntervalMillis: secondsToMillis(config.flushInterval), diff --git a/packages/shared/sdk-client/src/diagnostics/createDiagnosticsManager.ts b/packages/shared/sdk-client/src/diagnostics/createDiagnosticsManager.ts index c1ed9928a..15ac0f4b1 100644 --- a/packages/shared/sdk-client/src/diagnostics/createDiagnosticsManager.ts +++ b/packages/shared/sdk-client/src/diagnostics/createDiagnosticsManager.ts @@ -1,6 +1,6 @@ import { internal, Platform } from '@launchdarkly/js-sdk-common'; -import Configuration from '../configuration'; +import { Configuration } from '../configuration'; import createDiagnosticsInitConfig from './createDiagnosticsInitConfig'; const createDiagnosticsManager = ( diff --git a/packages/shared/sdk-client/src/events/createEventProcessor.ts b/packages/shared/sdk-client/src/events/createEventProcessor.ts index 6d45036db..ab4887925 100644 --- a/packages/shared/sdk-client/src/events/createEventProcessor.ts +++ b/packages/shared/sdk-client/src/events/createEventProcessor.ts @@ -1,6 +1,6 @@ import { ClientContext, internal, LDHeaders, Platform } from '@launchdarkly/js-sdk-common'; -import Configuration from '../configuration'; +import { Configuration } from '../configuration'; const createEventProcessor = ( clientSideID: string, @@ -8,7 +8,6 @@ const createEventProcessor = ( platform: Platform, baseHeaders: LDHeaders, diagnosticsManager?: internal.DiagnosticsManager, - start: boolean = false, ): internal.EventProcessor | undefined => { if (config.sendEvents) { return new internal.EventProcessor( @@ -17,7 +16,7 @@ const createEventProcessor = ( baseHeaders, undefined, diagnosticsManager, - start, + false, ); } diff --git a/packages/shared/sdk-client/src/flag-manager/FlagManager.ts b/packages/shared/sdk-client/src/flag-manager/FlagManager.ts index 267895b65..915a81407 100644 --- a/packages/shared/sdk-client/src/flag-manager/FlagManager.ts +++ b/packages/shared/sdk-client/src/flag-manager/FlagManager.ts @@ -8,11 +8,50 @@ import { ItemDescriptor } from './ItemDescriptor'; /** * Top level manager of flags for the client. LDClient should be using this - * class and not any of the specific instances managed by it. Updates from + * interface and not any of the specific instances managed by it. Updates from * data sources should be directed to the [init] and [upsert] methods of this - * class. + * interface. */ -export default class FlagManager { +export interface FlagManager { + /** + * Attempts to get a flag by key from the current flags. + */ + get(key: string): ItemDescriptor | undefined; + + /** + * Gets all the current flags. + */ + getAll(): { [key: string]: ItemDescriptor }; + + /** + * Initializes the flag manager with data from a data source. + * Persistence initialization is handled by {@link FlagPersistence} + */ + init(context: Context, newFlags: { [key: string]: ItemDescriptor }): Promise; + + /** + * Attempt to update a flag. If the flag is for the wrong context, or + * it is of an older version, then an update will not be performed. + */ + upsert(context: Context, key: string, item: ItemDescriptor): Promise; + + /** + * Asynchronously load cached values from persistence. + */ + loadCached(context: Context): Promise; + + /** + * Register a flag change callback. + */ + on(callback: FlagsChangeCallback): void; + + /** + * Unregister a flag change callback. + */ + off(callback: FlagsChangeCallback): void; +} + +export default class DefaultFlagManager implements FlagManager { private flagStore = new DefaultFlagStore(); private flagUpdater: FlagUpdater; private flagPersistencePromise: Promise; @@ -61,53 +100,30 @@ export default class FlagManager { ); } - /** - * Attempts to get a flag by key from the current flags. - */ get(key: string): ItemDescriptor | undefined { return this.flagStore.get(key); } - /** - * Gets all the current flags. - */ getAll(): { [key: string]: ItemDescriptor } { return this.flagStore.getAll(); } - /** - * Initializes the flag manager with data from a data source. - * Persistence initialization is handled by {@link FlagPersistence} - */ async init(context: Context, newFlags: { [key: string]: ItemDescriptor }): Promise { return (await this.flagPersistencePromise).init(context, newFlags); } - /** - * Attempt to update a flag. If the flag is for the wrong context, or - * it is of an older version, then an update will not be performed. - */ async upsert(context: Context, key: string, item: ItemDescriptor): Promise { return (await this.flagPersistencePromise).upsert(context, key, item); } - /** - * Asynchronously load cached values from persistence. - */ async loadCached(context: Context): Promise { return (await this.flagPersistencePromise).loadCached(context); } - /** - * Register a flag change callback. - */ on(callback: FlagsChangeCallback): void { this.flagUpdater.on(callback); } - /** - * Unregister a flag change callback. - */ off(callback: FlagsChangeCallback): void { this.flagUpdater.off(callback); } diff --git a/packages/shared/sdk-client/src/index.ts b/packages/shared/sdk-client/src/index.ts index d5b3e293d..038221ba5 100644 --- a/packages/shared/sdk-client/src/index.ts +++ b/packages/shared/sdk-client/src/index.ts @@ -1,5 +1,7 @@ import { LDClientInternalOptions } from './configuration/Configuration'; import LDClientImpl from './LDClientImpl'; +import LDEmitter from './LDEmitter'; +import Requestor from './polling/Requestor'; export * from '@launchdarkly/js-sdk-common'; @@ -15,8 +17,17 @@ export type { LDClient, LDOptions, ConnectionMode, + LDIdentifyOptions, } from './api'; +export type { DataManager, DataManagerFactory } from './DataManager'; +export type { FlagManager } from './flag-manager/FlagManager'; +export type { Configuration } from './configuration/Configuration'; + +export type { LDEmitter }; + export { DataSourcePaths } from './streaming'; +export { BaseDataManager } from './DataManager'; +export { Requestor }; export { LDClientImpl, LDClientInternalOptions }; diff --git a/packages/shared/sdk-client/src/polling/Requestor.ts b/packages/shared/sdk-client/src/polling/Requestor.ts index 48ba95b26..2798f7474 100644 --- a/packages/shared/sdk-client/src/polling/Requestor.ts +++ b/packages/shared/sdk-client/src/polling/Requestor.ts @@ -18,8 +18,6 @@ export class LDRequestError extends Error implements HttpErrorResponse { /** * Note: The requestor is implemented independently from polling such that it can be used to * make a one-off request. - * - * @internal */ export default class Requestor { constructor(