diff --git a/packages/sdk/react-native/example/src/welcome.tsx b/packages/sdk/react-native/example/src/welcome.tsx index 028c0dffb6..f25730575c 100644 --- a/packages/sdk/react-native/example/src/welcome.tsx +++ b/packages/sdk/react-native/example/src/welcome.tsx @@ -16,6 +16,7 @@ export default function Welcome() { .catch((e: any) => console.error(`error identifying ${userKey}: ${e}`)); }; + const setConnectionMode = (m: ConnectionMode) => { ldc.setConnectionMode(m); }; @@ -56,7 +57,13 @@ export default function Welcome() { style={styles.buttonContainer} onPress={() => setConnectionMode('streaming')} > - Set online + Set streaming + + setConnectionMode('polling')} + > + Set polling ); diff --git a/packages/sdk/react-native/src/RNStateDetector.ts b/packages/sdk/react-native/src/RNStateDetector.ts index c0a2c42217..2a9cf3c056 100644 --- a/packages/sdk/react-native/src/RNStateDetector.ts +++ b/packages/sdk/react-native/src/RNStateDetector.ts @@ -2,6 +2,9 @@ import { AppState, AppStateStatus } from 'react-native'; import { ApplicationState, NetworkState, StateDetector } from './platform/ConnectionManager'; +/** + * @internal + */ function translateAppState(state: AppStateStatus): ApplicationState { switch (state) { case 'active': @@ -14,6 +17,9 @@ function translateAppState(state: AppStateStatus): ApplicationState { } } +/** + * @internal + */ export default class RNStateDetector implements StateDetector { private applicationStateListener?: (state: ApplicationState) => void; private networkStateListener?: (state: NetworkState) => void; diff --git a/packages/sdk/react-native/src/ReactNativeLDClient.ts b/packages/sdk/react-native/src/ReactNativeLDClient.ts index 7ba562cd07..430ef92093 100644 --- a/packages/sdk/react-native/src/ReactNativeLDClient.ts +++ b/packages/sdk/react-native/src/ReactNativeLDClient.ts @@ -98,8 +98,16 @@ export default class ReactNativeLDClient extends LDClientImpl { super.setConnectionMode(mode); } + private encodeContext(context: LDContext) { + return base64UrlEncode(JSON.stringify(context), this.platform.encoding!); + } + override createStreamUriPath(context: LDContext) { - return `/meval/${base64UrlEncode(JSON.stringify(context), this.platform.encoding!)}`; + return `/meval/${this.encodeContext(context)}`; + } + + override createPollUriPath(context: LDContext): string { + return `/msdk/evalx/contexts/${this.encodeContext(context)}`; } override async setConnectionMode(mode: ConnectionMode): Promise { diff --git a/packages/sdk/react-native/src/platform/ConnectionManager.ts b/packages/sdk/react-native/src/platform/ConnectionManager.ts index 4af13239a8..7c04ced3c7 100644 --- a/packages/sdk/react-native/src/platform/ConnectionManager.ts +++ b/packages/sdk/react-native/src/platform/ConnectionManager.ts @@ -1,5 +1,8 @@ import { ConnectionMode, LDLogger } from '@launchdarkly/js-client-sdk-common'; +/** + * @internal + */ export enum ApplicationState { /// The application is in the foreground. Foreground = 'foreground', @@ -11,6 +14,9 @@ export enum ApplicationState { Background = 'background', } +/** + * @internal + */ export enum NetworkState { /// There is no network available for the SDK to use. Unavailable = 'unavailable', @@ -20,12 +26,18 @@ export enum NetworkState { Available = 'available', } +/** + * @internal + */ export interface ConnectionDestination { setNetworkAvailability(available: boolean): void; setEventSendingEnabled(enabled: boolean, flush: boolean): void; setConnectionMode(mode: ConnectionMode): Promise; } +/** + * @internal + */ export interface StateDetector { setApplicationStateListener(fn: (state: ApplicationState) => void): void; setNetworkStateListener(fn: (state: NetworkState) => void): void; @@ -33,6 +45,9 @@ export interface StateDetector { stopListening(): void; } +/** + * @internal + */ export interface ConnectionManagerConfig { /// The initial connection mode the SDK should use. readonly initialConnectionMode: ConnectionMode; @@ -51,6 +66,9 @@ export interface ConnectionManagerConfig { readonly automaticBackgroundHandling: boolean; } +/** + * @internal + */ export class ConnectionManager { private applicationState: ApplicationState = ApplicationState.Foreground; private networkState: NetworkState = NetworkState.Available; diff --git a/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts b/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts index a999923bfa..887896029c 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts @@ -37,7 +37,7 @@ const onChangePromise = () => }); // Common setup code for all tests -// 1. Sets up streamer +// 1. Sets up streaming // 2. Sets up the change listener // 3. Runs identify // 4. Get all flags @@ -58,7 +58,7 @@ const identifyGetAllFlags = async ( } jest.runAllTimers(); - // if streamer errors, don't wait for 'change' because it will not be sent. + // if streaming errors, don't wait for 'change' because it will not be sent. if (waitForChange && !shouldError) { await changePromise; } @@ -91,8 +91,8 @@ describe('sdk-client storage', () => { jest.resetAllMocks(); }); - test('initialize from storage succeeds without streamer', async () => { - // make sure streamer errors + test('initialize from storage succeeds without streaming', async () => { + // make sure streaming errors const allFlags = await identifyGetAllFlags(true, defaultPutResponse); expect(basicPlatform.storage.get).toHaveBeenCalledWith('org:Testy Pizza'); @@ -185,7 +185,7 @@ describe('sdk-client storage', () => { expect(emitter.emit).not.toHaveBeenCalled(); }); - test('no storage, cold start from streamer', async () => { + test('no storage, cold start from streaming', async () => { // fake previously cached flags even though there's no storage for this context // @ts-ignore ldc.flags = defaultPutResponse; @@ -201,7 +201,7 @@ describe('sdk-client storage', () => { JSON.stringify(defaultPutResponse), ); expect(ldc.logger.debug).toHaveBeenCalledWith( - 'OnIdentifyResolve no changes to emit from: streamer PUT.', + 'OnIdentifyResolve no changes to emit from: stream PUT.', ); // this is defaultPutResponse diff --git a/packages/shared/sdk-client/src/LDClientImpl.timeout.test.ts b/packages/shared/sdk-client/src/LDClientImpl.timeout.test.ts index 85e8e3d59b..69356c11b8 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.timeout.test.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.timeout.test.ts @@ -34,7 +34,7 @@ describe('sdk-client identify timeout', () => { beforeEach(() => { defaultPutResponse = clone(mockResponseJson); - // simulate streamer error after a long timeout + // simulate streaming error after a long timeout setupMockStreamingProcessor(true, defaultPutResponse, undefined, undefined, 30); ldc = new LDClientImpl(testSdkKey, AutoEnvAttributes.Enabled, basicPlatform, { @@ -50,14 +50,14 @@ describe('sdk-client identify timeout', () => { jest.resetAllMocks(); }); - // streamer is setup to error in beforeEach to cause a timeout + // streaming is setup to error in beforeEach to cause a timeout test('rejects with default timeout of 5s', async () => { jest.advanceTimersByTimeAsync(ldc.identifyTimeout * 1000).then(); await expect(ldc.identify(carContext)).rejects.toThrow(/identify timed out/); expect(logger.error).toHaveBeenCalledWith(expect.stringMatching(/identify timed out/)); }); - // streamer is setup to error in beforeEach to cause a timeout + // streaming is setup to error in beforeEach to cause a timeout test('rejects with custom timeout', async () => { const timeout = 15; jest.advanceTimersByTimeAsync(timeout * 1000).then(); diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index 6280f790cb..9b34577650 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -17,6 +17,7 @@ import { 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 LDEmitter, { EventName } from './api/LDEmitter'; @@ -25,6 +26,7 @@ import Configuration from './configuration'; import createDiagnosticsManager from './diagnostics/createDiagnosticsManager'; import createEventProcessor from './events/createEventProcessor'; import EventFactory from './events/EventFactory'; +import PollingProcessor from './polling/PollingProcessor'; import { DeleteFlag, Flags, PatchFlag } from './types'; import { addAutoEnv, calculateFlagChanges, ensureKey } from './utils'; @@ -38,7 +40,7 @@ export default class LDClientImpl implements LDClient { eventProcessor?: internal.EventProcessor; identifyTimeout: number = 5; logger: LDLogger; - streamer?: internal.StreamingProcessor; + updateProcessor?: LDStreamProcessor; readonly highTimeoutThreshold: number = 15; @@ -50,6 +52,7 @@ export default class LDClientImpl implements LDClient { private readonly clientContext: ClientContext; private eventSendingEnabled: boolean = true; private networkAvailable: boolean = true; + private connectionMode: ConnectionMode; /** * Creates the client object synchronously. No async, no network calls. @@ -70,6 +73,7 @@ export default class LDClientImpl implements LDClient { } this.config = new Configuration(options, internalOptions); + this.connectionMode = this.config.initialConnectionMode; this.clientContext = new ClientContext(sdkKey, this.config, platform); this.logger = this.config.logger; this.diagnosticsManager = createDiagnosticsManager(sdkKey, this.config, platform); @@ -95,31 +99,29 @@ export default class LDClientImpl implements LDClient { * @param mode - One of supported {@link ConnectionMode}. Default is 'streaming'. */ async setConnectionMode(mode: ConnectionMode): Promise { - if (this.config.connectionMode === mode) { + if (this.connectionMode === mode) { this.logger.debug(`setConnectionMode ignored. Mode is already '${mode}'.`); return Promise.resolve(); } - this.config.connectionMode = mode; + this.connectionMode = mode; this.logger.debug(`setConnectionMode ${mode}.`); switch (mode) { case 'offline': - this.streamer?.close(); + this.updateProcessor?.close(); break; case 'polling': - this.logger.warn('Polling not supported. Using streaming.'); - // Intentionally falling through to streaming. - // eslint-disable-next-line no-fallthrough case 'streaming': if (this.context) { - // identify will start streamer + // identify will start the update processor return this.identify(this.context, { timeout: this.identifyTimeout }); } + break; default: this.logger.warn( - `Unknown ConnectionMode: ${mode}. Only 'offline' and 'streaming' are supported.`, + `Unknown ConnectionMode: ${mode}. Only 'offline', 'streaming', and 'polling' are supported.`, ); break; } @@ -131,11 +133,11 @@ export default class LDClientImpl implements LDClient { * Gets the SDK connection mode. */ getConnectionMode(): ConnectionMode { - return this.config.connectionMode; + return this.connectionMode; } isOffline() { - return this.config.connectionMode === 'offline'; + return this.connectionMode === 'offline'; } allFlags(): LDFlagSet { @@ -151,16 +153,16 @@ export default class LDClientImpl implements LDClient { async close(): Promise { await this.flush(); this.eventProcessor?.close(); - this.streamer?.close(); - this.logger.debug('Shutdown the launchdarkly client.'); + this.updateProcessor?.close(); + this.logger.debug('Closed event processor and data source.'); } async flush(): Promise<{ error?: Error; result: boolean }> { try { await this.eventProcessor?.flush(); - this.logger.debug('Successfully flushed eventProcessor.'); + this.logger.debug('Successfully flushed event processor.'); } catch (e) { - this.logger.error(`Error flushing eventProcessor: ${e}.`); + this.logger.error(`Error flushing event processor: ${e}.`); return { error: e as Error, result: false }; } @@ -181,8 +183,8 @@ export default class LDClientImpl implements LDClient { listeners.set('put', { deserializeData: JSON.parse, processJson: async (dataJson: Flags) => { - this.logger.debug(`Streamer PUT: ${Object.keys(dataJson)}`); - this.onIdentifyResolve(identifyResolve, dataJson, context, 'streamer PUT'); + this.logger.debug(`Stream PUT: ${Object.keys(dataJson)}`); + this.onIdentifyResolve(identifyResolve, dataJson, context, 'stream PUT'); await this.platform.storage?.set(canonicalKey, JSON.stringify(this.flags)); }, }); @@ -190,7 +192,7 @@ export default class LDClientImpl implements LDClient { listeners.set('patch', { deserializeData: JSON.parse, processJson: async (dataJson: PatchFlag) => { - this.logger.debug(`Streamer PATCH ${JSON.stringify(dataJson, null, 2)}`); + this.logger.debug(`Stream PATCH ${JSON.stringify(dataJson, null, 2)}`); const existing = this.flags[dataJson.key]; // add flag if it doesn't exist or update it if version is newer @@ -207,7 +209,7 @@ export default class LDClientImpl implements LDClient { listeners.set('delete', { deserializeData: JSON.parse, processJson: async (dataJson: DeleteFlag) => { - this.logger.debug(`Streamer DELETE ${JSON.stringify(dataJson, null, 2)}`); + this.logger.debug(`Stream DELETE ${JSON.stringify(dataJson, null, 2)}`); const existing = this.flags[dataJson.key]; // the deleted flag is saved as tombstoned @@ -234,20 +236,29 @@ export default class LDClientImpl implements LDClient { } /** - * Generates the url path for streamer. - * - * For mobile key: /meval/${base64-encoded-context} - * For clientSideId: /eval/${envId}/${base64-encoded-context} + * Generates the url path for streaming. * - * the path. - * - * @protected This function must be overridden in subclasses for streamer + * @protected This function must be overridden in subclasses for streaming * to work. * @param _context The LDContext object */ protected createStreamUriPath(_context: LDContext): string { throw new Error( - 'createStreamUriPath not implemented. Client sdks must implement createStreamUriPath for streamer to work.', + 'createStreamUriPath not implemented. Client sdks must implement createStreamUriPath for streaming to work.', + ); + } + + /** + * Generates the url path for polling. + * @param _context + * + * @protected This function must be overridden in subclasses for polling + * to work. + * @param _context The LDContext object + */ + protected createPollUriPath(_context: LDContext): string { + throw new Error( + 'createPollUriPath not implemented. Client sdks must implement createPollUriPath for polling to work.', ); } @@ -339,28 +350,76 @@ export default class LDClientImpl implements LDClient { identifyResolve(); } } else { - this.streamer?.close(); - let streamUri = this.createStreamUriPath(context); - if (this.config.withReasons) { - streamUri = `${streamUri}?withReasons=true`; + this.updateProcessor?.close(); + + switch (this.getConnectionMode()) { + case 'streaming': + this.createStreamingProcessor(context, checkedContext, identifyResolve, identifyReject); + break; + case 'polling': + this.createPollingProcessor(identifyResolve, context, checkedContext, identifyReject); + break; + default: + break; } - this.streamer = new internal.StreamingProcessor( - this.sdkKey, - this.clientContext, - streamUri, - this.createStreamListeners(context, checkedContext.canonicalKey, identifyResolve), - this.diagnosticsManager, - (e) => { - identifyReject(e); - this.emitter.emit('error', context, e); - }, - ); - this.streamer.start(); + this.updateProcessor!.start(); } return identifyPromise; } + private createPollingProcessor( + identifyResolve: any, + context: any, + checkedContext: Context, + identifyReject: any, + ) { + let pollingPath = this.createPollUriPath(context); + if (this.config.withReasons) { + pollingPath = `${pollingPath}?withReasons=true`; + } + this.updateProcessor = new PollingProcessor( + this.sdkKey, + this.clientContext.platform.requests, + this.clientContext.platform.info, + pollingPath, + this.config, + async (flags) => { + this.logger.debug(`Handling polling result: ${Object.keys(flags)}`); + this.onIdentifyResolve(identifyResolve, flags, context, 'polling'); + await this.platform.storage?.set(checkedContext.canonicalKey, JSON.stringify(this.flags)); + }, + (err) => { + identifyReject(err); + this.emitter.emit('error', context, err); + }, + ); + } + + private createStreamingProcessor( + context: any, + checkedContext: Context, + identifyResolve: any, + identifyReject: any, + ) { + let streamingPath = this.createStreamUriPath(context); + if (this.config.withReasons) { + streamingPath = `${streamingPath}?withReasons=true`; + } + + this.updateProcessor = new internal.StreamingProcessor( + this.sdkKey, + this.clientContext, + streamingPath, + this.createStreamListeners(context, checkedContext.canonicalKey, identifyResolve), + this.diagnosticsManager, + (e) => { + identifyReject(e); + this.emitter.emit('error', context, e); + }, + ); + } + /** * Performs common tasks when resolving the identify promise: * - resolve the promise @@ -571,6 +630,7 @@ export default class LDClientImpl implements LDClient { this.logger.debug('Starting event processor'); this.eventProcessor?.start(); } else if (flush) { + this.logger?.debug('Flushing event processor before disabling.'); // Disable and flush. this.flush().then(() => { // While waiting for the flush event sending could be re-enabled, in which case diff --git a/packages/shared/sdk-client/src/api/ConnectionMode.ts b/packages/shared/sdk-client/src/api/ConnectionMode.ts index 3327d68af4..b0e84906a7 100644 --- a/packages/shared/sdk-client/src/api/ConnectionMode.ts +++ b/packages/shared/sdk-client/src/api/ConnectionMode.ts @@ -8,6 +8,8 @@ * analytic and diagnostic events. * * streaming - The SDK will use a streaming connection to receive updates from LaunchDarkly. + * + * polling - The SDK will make polling requests to receive updates from LaunchDarkly. */ type ConnectionMode = 'offline' | 'streaming' | 'polling'; diff --git a/packages/shared/sdk-client/src/api/LDOptions.ts b/packages/shared/sdk-client/src/api/LDOptions.ts index d79a10753b..d1d972341e 100644 --- a/packages/shared/sdk-client/src/api/LDOptions.ts +++ b/packages/shared/sdk-client/src/api/LDOptions.ts @@ -183,6 +183,13 @@ export interface LDOptions { */ streamUri?: string; + /** + * The time between polling requests, in seconds. Ignored in streaming mode. + * + * The minimum polling interval is 30 seconds. + */ + pollInterval?: number; + /** * Whether LaunchDarkly should provide additional information about how flag values were * calculated. diff --git a/packages/shared/sdk-client/src/configuration/Configuration.ts b/packages/shared/sdk-client/src/configuration/Configuration.ts index 833c502d85..3b3d8173db 100644 --- a/packages/shared/sdk-client/src/configuration/Configuration.ts +++ b/packages/shared/sdk-client/src/configuration/Configuration.ts @@ -13,6 +13,8 @@ import { ConnectionMode, type LDOptions } from '../api'; import { LDInspection } from '../api/LDInspection'; import validators from './validators'; +const DEFAULT_POLLING_INTERVAL: number = 60 * 5; + export default class Configuration { public static DEFAULT_POLLING = 'https://clientsdk.launchdarkly.com'; public static DEFAULT_STREAM = 'https://clientstream.launchdarkly.com'; @@ -41,7 +43,6 @@ export default class Configuration { public readonly privateAttributes: string[] = []; public readonly initialConnectionMode: ConnectionMode = 'streaming'; - public connectionMode: ConnectionMode; public readonly tags: ApplicationTags; public readonly applicationInfo?: { @@ -61,6 +62,8 @@ export default class Configuration { public readonly serviceEndpoints: ServiceEndpoints; + public readonly pollInterval: number = DEFAULT_POLLING_INTERVAL; + // Allow indexing Configuration by a string [index: string]: any; @@ -77,7 +80,6 @@ export default class Configuration { internalOptions.includeAuthorizationHeader, ); this.tags = new ApplicationTags({ application: this.applicationInfo, logger: this.logger }); - this.connectionMode = this.initialConnectionMode; } validateTypesAndNames(pristineOptions: LDOptions): string[] { diff --git a/packages/shared/sdk-client/src/configuration/validators.ts b/packages/shared/sdk-client/src/configuration/validators.ts index 8cfd236284..4e3232674c 100644 --- a/packages/shared/sdk-client/src/configuration/validators.ts +++ b/packages/shared/sdk-client/src/configuration/validators.ts @@ -16,11 +16,11 @@ class BootStrapValidator implements TypeValidator { class ConnectionModeValidator implements TypeValidator { is(u: unknown): boolean { - return u === 'offline' || u === 'streaming'; + return u === 'offline' || u === 'streaming' || u === 'polling'; } getType(): string { - return `'offline' | streaming`; + return `offline | streaming | polling`; } } @@ -43,6 +43,8 @@ const validators: Record = { withReasons: TypeValidators.Boolean, sendEvents: TypeValidators.Boolean, + pollInterval: TypeValidators.numberWithMin(30), + // TODO: inspectors // @ts-ignore inspectors: TypeValidators.createTypeArray('LDInspection[]', { diff --git a/packages/shared/sdk-client/src/evaluation/fetchFlags.test.ts b/packages/shared/sdk-client/src/evaluation/fetchFlags.test.ts deleted file mode 100644 index 07a6b98467..0000000000 --- a/packages/shared/sdk-client/src/evaluation/fetchFlags.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { LDContext } from '@launchdarkly/js-sdk-common'; -import { basicPlatform, mockFetch } from '@launchdarkly/private-js-mocks'; - -import Configuration from '../configuration'; -import fetchFlags from './fetchFlags'; -import * as mockResponse from './mockResponse.json'; -import * as mockResponseWithReasons from './mockResponseWithReasons.json'; - -describe('fetchFeatures', () => { - const sdkKey = 'testSdkKey1'; - const context: LDContext = { kind: 'user', key: 'test-user-key-1' }; - const getHeaders = { - authorization: 'testSdkKey1', - 'user-agent': 'TestUserAgent/2.0.2', - 'x-launchdarkly-wrapper': 'Rapper/1.2.3', - }; - - let config: Configuration; - let platformFetch: jest.Mock; - - beforeEach(() => { - platformFetch = basicPlatform.requests.fetch as jest.Mock; - mockFetch(mockResponse); - config = new Configuration(); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - test('get', async () => { - const json = await fetchFlags(sdkKey, context, config, basicPlatform); - - expect(platformFetch).toHaveBeenCalledWith( - 'https://clientsdk.launchdarkly.com/sdk/evalx/testSdkKey1/contexts/eyJraW5kIjoidXNlciIsImtleSI6InRlc3QtdXNlci1rZXktMSJ9', - { - method: 'GET', - headers: getHeaders, - }, - ); - expect(json).toEqual(mockResponse); - }); - - test('withReasons', async () => { - mockFetch(mockResponseWithReasons); - config = new Configuration({ withReasons: true }); - const json = await fetchFlags(sdkKey, context, config, basicPlatform); - - expect(platformFetch).toHaveBeenCalledWith( - 'https://clientsdk.launchdarkly.com/sdk/evalx/testSdkKey1/contexts/eyJraW5kIjoidXNlciIsImtleSI6InRlc3QtdXNlci1rZXktMSJ9?withReasons=true', - { - method: 'GET', - headers: getHeaders, - }, - ); - expect(json).toEqual(mockResponseWithReasons); - }); - - // TODO: test fetchFlags with hash - // test('hash', async () => { - // config = new Configuration({ hash: 'test-hash', withReasons: false }); - // const json = await fetchFlags(sdkKey, context, config, basicPlatform); - // - // expect(platformFetch).toHaveBeenCalledWith( - // 'https://clientsdk.launchdarkly.com/sdk/evalx/testSdkKey1/contexts/eyJraW5kIjoidXNlciIsImtleSI6InRlc3QtdXNlci1rZXktMSJ9?h=test-hash', - // { - // method: 'GET', - // headers: getHeaders, - // }, - // ); - // expect(json).toEqual(mockResponse); - // }); -}); diff --git a/packages/shared/sdk-client/src/evaluation/fetchFlags.ts b/packages/shared/sdk-client/src/evaluation/fetchFlags.ts deleted file mode 100644 index 755de56b8c..0000000000 --- a/packages/shared/sdk-client/src/evaluation/fetchFlags.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { LDContext, Platform } from '@launchdarkly/js-sdk-common'; - -import Configuration from '../configuration'; -import { Flags } from '../types'; -import { createFetchOptions, createFetchUrl } from './fetchUtils'; - -const fetchFlags = async ( - sdkKey: string, - context: LDContext, - config: Configuration, - { encoding, info, requests }: Platform, -): Promise => { - const fetchUrl = createFetchUrl(sdkKey, context, config, encoding!); - const fetchOptions = createFetchOptions(sdkKey, context, config, info); - - // TODO: add error handling, retry and timeout - const response = await requests.fetch(fetchUrl, fetchOptions); - return response.json(); -}; - -export default fetchFlags; diff --git a/packages/shared/sdk-client/src/evaluation/fetchUtils.test.ts b/packages/shared/sdk-client/src/evaluation/fetchUtils.test.ts deleted file mode 100644 index be06225fbd..0000000000 --- a/packages/shared/sdk-client/src/evaluation/fetchUtils.test.ts +++ /dev/null @@ -1,4 +0,0 @@ -// TODO: add fetchUtils tests -describe('fetchUtils', () => { - test('sucesss', () => {}); -}); diff --git a/packages/shared/sdk-client/src/evaluation/fetchUtils.ts b/packages/shared/sdk-client/src/evaluation/fetchUtils.ts deleted file mode 100644 index ea415ee645..0000000000 --- a/packages/shared/sdk-client/src/evaluation/fetchUtils.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { - base64UrlEncode, - defaultHeaders, - Encoding, - Info, - LDContext, - Options, -} from '@launchdarkly/js-sdk-common'; - -import Configuration from '../configuration'; - -export const createFetchPath = ( - sdkKey: string, - context: LDContext, - baseUrlPolling: string, - useReport: boolean, - encoding: Encoding, -) => - useReport - ? `${baseUrlPolling}/sdk/evalx/${sdkKey}/context` - : `${baseUrlPolling}/sdk/evalx/${sdkKey}/contexts/${base64UrlEncode( - JSON.stringify(context), - encoding, - )}`; - -export const createQueryString = (hash: string | undefined, withReasons: boolean) => { - const qs = { - h: hash, - withReasons, - }; - - const qsArray: string[] = []; - Object.entries(qs).forEach(([key, value]) => { - if (value) { - qsArray.push(`${key}=${value}`); - } - }); - - return qsArray.join('&'); -}; - -export const createFetchUrl = ( - sdkKey: string, - context: LDContext, - config: Configuration, - encoding: Encoding, -) => { - const { - withReasons, - hash, - serviceEndpoints: { polling }, - useReport, - } = config; - const path = createFetchPath(sdkKey, context, polling, useReport, encoding); - const qs = createQueryString(hash, withReasons); - - return qs ? `${path}?${qs}` : path; -}; - -export const createFetchOptions = ( - sdkKey: string, - context: LDContext, - config: Configuration, - info: Info, -): Options => { - const { useReport, tags } = config; - const headers = defaultHeaders(sdkKey, info, tags); - - if (useReport) { - return { - method: 'REPORT', - headers: { ...headers, 'content-type': 'application/json' }, - body: JSON.stringify(context), - }; - } - - return { - method: 'GET', - headers, - }; -}; diff --git a/packages/shared/sdk-client/src/evaluation/mockResponseWithReasons.json b/packages/shared/sdk-client/src/evaluation/mockResponseWithReasons.json deleted file mode 100644 index 0e198ad32b..0000000000 --- a/packages/shared/sdk-client/src/evaluation/mockResponseWithReasons.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "fdsafdsafdsafdsa": { - "version": 827, - "flagVersion": 3, - "value": true, - "variation": 0, - "trackEvents": false, - "reason": { "kind": "FALLTHROUGH" } - }, - "this-is-a-test": { - "version": 827, - "flagVersion": 5, - "value": true, - "variation": 0, - "trackEvents": false, - "reason": { "kind": "FALLTHROUGH" } - }, - "dev-test-flag": { - "version": 827, - "flagVersion": 555, - "value": true, - "variation": 0, - "trackEvents": true, - "reason": { "kind": "FALLTHROUGH" } - }, - "easter-specials": { - "version": 827, - "flagVersion": 37, - "value": "no specials", - "variation": 3, - "trackEvents": false, - "reason": { "kind": "FALLTHROUGH" } - }, - "moonshot-demo": { - "version": 827, - "flagVersion": 91, - "value": true, - "variation": 0, - "trackEvents": true, - "reason": { "kind": "FALLTHROUGH" } - }, - "test1": { - "version": 827, - "flagVersion": 5, - "value": "s1", - "variation": 0, - "trackEvents": false, - "reason": { "kind": "FALLTHROUGH" } - }, - "easter-i-tunes-special": { - "version": 827, - "flagVersion": 15, - "value": false, - "variation": 1, - "trackEvents": false, - "reason": { "kind": "FALLTHROUGH" } - }, - "log-level": { - "version": 827, - "flagVersion": 14, - "value": "warn", - "variation": 3, - "trackEvents": false, - "reason": { "kind": "OFF" } - } -} diff --git a/packages/shared/sdk-client/src/polling/PollingProcessor.ts b/packages/shared/sdk-client/src/polling/PollingProcessor.ts new file mode 100644 index 0000000000..23fe35770a --- /dev/null +++ b/packages/shared/sdk-client/src/polling/PollingProcessor.ts @@ -0,0 +1,126 @@ +import { + ApplicationTags, + httpErrorMessage, + HttpErrorResponse, + Info, + isHttpRecoverable, + LDLogger, + LDPollingError, + Requests, + ServiceEndpoints, + subsystem, +} from '@launchdarkly/js-sdk-common'; + +import { Flags } from '../types'; +import Requestor, { LDRequestError } from './Requestor'; + +export type PollingErrorHandler = (err: LDPollingError) => void; + +/** + * Subset of configuration required for polling. + * + * @internal + */ +export type PollingConfig = { + logger: LDLogger; + pollInterval: number; + tags: ApplicationTags; + useReport: boolean; + serviceEndpoints: ServiceEndpoints; +}; + +/** + * @internal + */ +export default class PollingProcessor implements subsystem.LDStreamProcessor { + private stopped = false; + + private logger?: LDLogger; + + private pollInterval: number; + + private timeoutHandle: any; + + private requestor: Requestor; + + constructor( + sdkKey: string, + requests: Requests, + info: Info, + uriPath: string, + config: PollingConfig, + private readonly dataHandler: (flags: Flags) => void, + private readonly errorHandler?: PollingErrorHandler, + ) { + const uri = `${config.serviceEndpoints.polling}${uriPath}`; + this.logger = config.logger; + this.pollInterval = config.pollInterval; + + this.requestor = new Requestor(sdkKey, requests, info, uri, config.useReport, config.tags); + } + + private async poll() { + if (this.stopped) { + return; + } + + const reportJsonError = (data: string) => { + this.logger?.error('Polling received invalid data'); + this.logger?.debug(`Invalid JSON follows: ${data}`); + this.errorHandler?.(new LDPollingError('Malformed JSON data in polling response')); + }; + + this.logger?.debug('Polling LaunchDarkly for feature flag updates'); + const startTime = Date.now(); + try { + const res = await this.requestor.requestPayload(); + try { + const flags = JSON.parse(res); + try { + this.dataHandler?.(flags); + } catch (err) { + this.logger?.error(`Exception from data handler: ${err}`); + } + } catch { + reportJsonError(res); + } + } catch (err) { + const requestError = err as LDRequestError; + if (requestError.status !== undefined) { + if (!isHttpRecoverable(requestError.status)) { + this.logger?.error(httpErrorMessage(err as HttpErrorResponse, 'polling request')); + this.errorHandler?.(new LDPollingError(requestError.message, requestError.status)); + return; + } + } + this.logger?.error( + httpErrorMessage(err as HttpErrorResponse, 'polling request', 'will retry'), + ); + } + + const elapsed = Date.now() - startTime; + const sleepFor = Math.max(this.pollInterval * 1000 - elapsed, 0); + + this.logger?.debug('Elapsed: %d ms, sleeping for %d ms', elapsed, sleepFor); + + this.timeoutHandle = setTimeout(() => { + this.poll(); + }, sleepFor); + } + + start() { + this.poll(); + } + + stop() { + if (this.timeoutHandle) { + clearTimeout(this.timeoutHandle); + this.timeoutHandle = undefined; + } + this.stopped = true; + } + + close() { + this.stop(); + } +} diff --git a/packages/shared/sdk-client/src/polling/PollingProcessot.test.ts b/packages/shared/sdk-client/src/polling/PollingProcessot.test.ts new file mode 100644 index 0000000000..4e1abdd26a --- /dev/null +++ b/packages/shared/sdk-client/src/polling/PollingProcessot.test.ts @@ -0,0 +1,382 @@ +import { waitFor } from '@testing-library/dom'; + +import { + EventSource, + EventSourceInitDict, + Info, + PlatformData, + Requests, + Response, + SdkData, +} from '@launchdarkly/js-sdk-common'; + +import PollingProcessor, { PollingConfig } from './PollingProcessor'; + +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); +} + +/** + * Mocks basicPlatform fetch. Returns the fetch jest.Mock object. + * @param remoteJson + * @param statusCode + */ +function mockFetch(value: string, statusCode: number = 200) { + const f = jest.fn(); + f.mockResolvedValue(mockResponse(value, statusCode)); + return f; +} + +function makeRequests(): Requests { + return { + fetch: mockFetch('{ "flagA": true }', 200), + createEventSource(_url: string, _eventSourceInitDict: EventSourceInitDict): EventSource { + throw new Error('Function not implemented.'); + }, + }; +} + +function makeInfo(sdkData: SdkData = {}, platformData: PlatformData = {}): Info { + return { + sdkData: () => sdkData, + platformData: () => platformData, + }; +} + +function makeConfig(config?: { pollInterval?: number; useReport?: boolean }): PollingConfig { + return { + pollInterval: config?.pollInterval ?? 60 * 5, + // eslint-disable-next-line no-console + logger: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }, + tags: {}, + useReport: config?.useReport ?? false, + serviceEndpoints: { + streaming: '', + polling: 'http://example.example.example', + events: '', + analyticsEventPath: '', + diagnosticEventPath: '', + includeAuthorizationHeader: false, + }, + }; +} + +it('makes no requests until it is started', () => { + const requests = makeRequests(); + // eslint-disable-next-line no-new + new PollingProcessor( + 'the-sdk-key', + requests, + makeInfo(), + '/polling', + makeConfig(), + (_flags) => {}, + (_error) => {}, + ); + + expect(requests.fetch).toHaveBeenCalledTimes(0); +}); + +it('polls immediately when started', () => { + const requests = makeRequests(); + + const polling = new PollingProcessor( + 'the-sdk-key', + requests, + makeInfo(), + '/polling', + makeConfig(), + (_flags) => {}, + (_error) => {}, + ); + polling.start(); + + expect(requests.fetch).toHaveBeenCalledTimes(1); + polling.stop(); +}); + +it('calls callback on success', async () => { + const requests = makeRequests(); + const dataCallback = jest.fn(); + const errorCallback = jest.fn(); + + const polling = new PollingProcessor( + 'the-sdk-key', + requests, + makeInfo(), + '/polling', + makeConfig(), + dataCallback, + errorCallback, + ); + polling.start(); + + await waitFor(() => expect(requests.fetch).toHaveBeenCalled()); + polling.stop(); +}); + +it('polls repeatedly', async () => { + const requests = makeRequests(); + const dataCallback = jest.fn(); + const errorCallback = jest.fn(); + + requests.fetch = mockFetch('{ "flagA": true }', 200); + const polling = new PollingProcessor( + 'the-sdk-key', + requests, + makeInfo(), + '/polling', + makeConfig({ pollInterval: 0.1 }), + dataCallback, + errorCallback, + ); + polling.start(); + + // There is not a check for called at least N times. So we make a new mock and wait for it + // to be called at least a second time. If you use toHaveBeenCalledNTimes(2), the it could + // get called 3 times before being checked and the test would fail. + await waitFor(() => expect(requests.fetch).toHaveBeenCalled()); + requests.fetch = mockFetch('{ "flagA": true }', 200); + await waitFor(() => expect(requests.fetch).toHaveBeenCalled()); + + polling.stop(); +}); + +it('stops polling when stopped', (done) => { + const requests = { + fetch: jest.fn(), + createEventSource(_url: string, _eventSourceInitDict: EventSourceInitDict): EventSource { + throw new Error('Function not implemented.'); + }, + }; + const dataCallback = jest.fn(); + const errorCallback = jest.fn(); + + const polling = new PollingProcessor( + 'the-sdk-key', + requests, + makeInfo(), + '/stops', + makeConfig({ pollInterval: 0.01 }), + dataCallback, + errorCallback, + ); + polling.start(); + polling.stop(); + + // Give a little time for potentially multiple polls to complete. + setTimeout(() => { + expect(requests.fetch).toHaveBeenCalledTimes(1); + done(); + }, 50); +}); + +it('includes the correct headers on requests', () => { + const requests = makeRequests(); + + const polling = new PollingProcessor( + 'the-sdk-key', + requests, + makeInfo({ + userAgentBase: 'AnSDK', + version: '42', + }), + '/polling', + makeConfig(), + (_flags) => {}, + (_error) => {}, + ); + polling.start(); + + expect(requests.fetch).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + headers: { + authorization: 'the-sdk-key', + 'user-agent': 'AnSDK/42', + }, + }), + ); + polling.stop(); +}); + +it('defaults to using the "GET" verb', () => { + const requests = makeRequests(); + + const polling = new PollingProcessor( + 'the-sdk-key', + requests, + makeInfo(), + '/polling', + makeConfig(), + (_flags) => {}, + (_error) => {}, + ); + polling.start(); + + expect(requests.fetch).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'GET', + }), + ); + polling.stop(); +}); + +it('can be configured to use the "REPORT" verb', () => { + const requests = makeRequests(); + + const polling = new PollingProcessor( + 'the-sdk-key', + requests, + makeInfo(), + '/polling', + makeConfig({ useReport: true }), + (_flags) => {}, + (_error) => {}, + ); + polling.start(); + + expect(requests.fetch).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'REPORT', + }), + ); + polling.stop(); +}); + +it('continues polling after receiving bad JSON', async () => { + const requests = makeRequests(); + const dataCallback = jest.fn(); + const errorCallback = jest.fn(); + const config = makeConfig({ pollInterval: 0.1 }); + + const polling = new PollingProcessor( + 'the-sdk-key', + requests, + makeInfo(), + '/polling', + config, + dataCallback, + errorCallback, + ); + polling.start(); + + // There is not a check for called at least N times. So we make a new mock and wait for it + // to be called at least a second time. If you use toHaveBeenCalledNTimes(2), the it could + // get called 3 times before being checked and the test would fail. + await waitFor(() => expect(requests.fetch).toHaveBeenCalled()); + requests.fetch = mockFetch('{ham', 200); + await waitFor(() => expect(requests.fetch).toHaveBeenCalled()); + await waitFor(() => expect(errorCallback).toHaveBeenCalled()); + expect(config.logger.error).toHaveBeenCalledWith('Polling received invalid data'); + polling.stop(); +}); + +it('continues polling after an exception thrown during a request', async () => { + const requests = makeRequests(); + const dataCallback = jest.fn(); + const errorCallback = jest.fn(); + const config = makeConfig({ pollInterval: 0.1 }); + + const polling = new PollingProcessor( + 'the-sdk-key', + requests, + makeInfo(), + '/polling', + config, + dataCallback, + errorCallback, + ); + polling.start(); + + // There is not a check for called at least N times. So we make a new mock and wait for it + // to be called at least a second time. If you use toHaveBeenCalledNTimes(2), the it could + // get called 3 times before being checked and the test would fail. + await waitFor(() => expect(requests.fetch).toHaveBeenCalled()); + requests.fetch = jest.fn(() => { + throw new Error('bad'); + }); + await waitFor(() => expect(requests.fetch).toHaveBeenCalled()); + polling.stop(); + expect(config.logger.error).toHaveBeenCalledWith( + 'Received I/O error (bad) for polling request - will retry', + ); +}); + +it('can handle recoverable http errors', async () => { + const requests = makeRequests(); + const dataCallback = jest.fn(); + const errorCallback = jest.fn(); + const config = makeConfig({ pollInterval: 0.1 }); + + const polling = new PollingProcessor( + 'the-sdk-key', + requests, + makeInfo(), + '/polling', + config, + dataCallback, + errorCallback, + ); + polling.start(); + + // There is not a check for called at least N times. So we make a new mock and wait for it + // to be called at least a second time. If you use toHaveBeenCalledNTimes(2), the it could + // get called 3 times before being checked and the test would fail. + await waitFor(() => expect(requests.fetch).toHaveBeenCalled()); + requests.fetch = mockFetch('', 408); + await waitFor(() => expect(requests.fetch).toHaveBeenCalled()); + polling.stop(); + expect(config.logger.error).toHaveBeenCalledWith( + 'Received error 408 for polling request - will retry', + ); +}); + +it('stops polling on unrecoverable error codes', (done) => { + const requests = makeRequests(); + const dataCallback = jest.fn(); + const errorCallback = jest.fn(); + const config = makeConfig({ pollInterval: 0.01 }); + + const polling = new PollingProcessor( + 'the-sdk-key', + requests, + makeInfo(), + '/polling', + config, + dataCallback, + errorCallback, + ); + polling.start(); + + requests.fetch = mockFetch('', 401); + + // Polling should stop on the 401, but we need to give some time for more + // polls to be done. + setTimeout(() => { + expect(config.logger.error).toHaveBeenCalledWith( + 'Received error 401 (invalid SDK key) for polling request - giving up permanently', + ); + expect(requests.fetch).toHaveBeenCalledTimes(1); + done(); + }, 50); +}); diff --git a/packages/shared/sdk-client/src/polling/Requestor.ts b/packages/shared/sdk-client/src/polling/Requestor.ts new file mode 100644 index 0000000000..6a46dfcff2 --- /dev/null +++ b/packages/shared/sdk-client/src/polling/Requestor.ts @@ -0,0 +1,63 @@ +// eslint-disable-next-line max-classes-per-file +import { + ApplicationTags, + defaultHeaders, + HttpErrorResponse, + Info, + Requests, +} from '@launchdarkly/js-sdk-common'; + +function isOk(status: number) { + return status >= 200 && status <= 299; +} + +export class LDRequestError extends Error implements HttpErrorResponse { + public status?: number; + + constructor(message: string, status?: number) { + super(message); + this.status = status; + this.name = 'LaunchDarklyRequestError'; + } +} + +/** + * 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 { + private readonly headers: { [key: string]: string }; + private verb: string; + + constructor( + sdkKey: string, + private requests: Requests, + info: Info, + private readonly uri: string, + useReport: boolean, + tags: ApplicationTags, + ) { + this.headers = defaultHeaders(sdkKey, info, tags); + this.verb = useReport ? 'REPORT' : 'GET'; + } + + async requestPayload(): Promise { + let status: number | undefined; + try { + const res = await this.requests.fetch(this.uri, { + method: this.verb, + headers: this.headers, + }); + if (isOk(res.status)) { + return await res.text(); + } + // Assigning so it can be thrown after the try/catch. + status = res.status; + } catch (err: any) { + throw new LDRequestError(err?.message); + } + throw new LDRequestError(`Unexpected status code: ${status}`, status); + } +}