From 33ac43a6adc331e7b1e1dec2e79b8386c7c44494 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 25 Jul 2024 16:12:32 -0700 Subject: [PATCH 01/21] First draft of polling implementation. --- packages/sdk/react-native/example/App.tsx | 1 + .../sdk/react-native/example/src/welcome.tsx | 1 + .../react-native/src/ReactNativeLDClient.ts | 10 +- .../react-native/src/provider/useAppState.ts | 4 +- .../shared/sdk-client/src/LDClientImpl.ts | 93 +++++++++--- .../sdk-client/src/api/ConnectionMode.ts | 4 +- .../shared/sdk-client/src/api/LDOptions.ts | 7 + .../src/configuration/Configuration.ts | 4 + .../src/configuration/validators.ts | 8 +- .../src/polling/PollingProcessor.ts | 134 ++++++++++++++++++ .../fetchFlags.test.ts | 0 .../src/{evaluation => polling}/fetchFlags.ts | 0 .../fetchUtils.test.ts | 0 .../src/{evaluation => polling}/fetchUtils.ts | 0 .../{evaluation => polling}/mockResponse.json | 0 .../mockResponseWithReasons.json | 0 16 files changed, 240 insertions(+), 26 deletions(-) create mode 100644 packages/shared/sdk-client/src/polling/PollingProcessor.ts rename packages/shared/sdk-client/src/{evaluation => polling}/fetchFlags.test.ts (100%) rename packages/shared/sdk-client/src/{evaluation => polling}/fetchFlags.ts (100%) rename packages/shared/sdk-client/src/{evaluation => polling}/fetchUtils.test.ts (100%) rename packages/shared/sdk-client/src/{evaluation => polling}/fetchUtils.ts (100%) rename packages/shared/sdk-client/src/{evaluation => polling}/mockResponse.json (100%) rename packages/shared/sdk-client/src/{evaluation => polling}/mockResponseWithReasons.json (100%) diff --git a/packages/sdk/react-native/example/App.tsx b/packages/sdk/react-native/example/App.tsx index fad2c67a35..698be49bd8 100644 --- a/packages/sdk/react-native/example/App.tsx +++ b/packages/sdk/react-native/example/App.tsx @@ -14,6 +14,7 @@ const featureClient = new ReactNativeLDClient(MOBILE_KEY, AutoEnvAttributes.Enab id: 'ld-rn-test-app', version: '0.0.1', }, + initialConnectionMode: 'polling', }); const App = () => { diff --git a/packages/sdk/react-native/example/src/welcome.tsx b/packages/sdk/react-native/example/src/welcome.tsx index 028c0dffb6..a5646de50e 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); }; diff --git a/packages/sdk/react-native/src/ReactNativeLDClient.ts b/packages/sdk/react-native/src/ReactNativeLDClient.ts index 9ccff1f392..c4460f392c 100644 --- a/packages/sdk/react-native/src/ReactNativeLDClient.ts +++ b/packages/sdk/react-native/src/ReactNativeLDClient.ts @@ -59,7 +59,15 @@ export default class ReactNativeLDClient extends LDClientImpl { ); } + 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)}`; } } diff --git a/packages/sdk/react-native/src/provider/useAppState.ts b/packages/sdk/react-native/src/provider/useAppState.ts index 0421881cd5..f14644b7ff 100644 --- a/packages/sdk/react-native/src/provider/useAppState.ts +++ b/packages/sdk/react-native/src/provider/useAppState.ts @@ -29,13 +29,13 @@ const useAppState = (client: ReactNativeLDClient) => { if (appState.current.match(/inactive|background/) && nextAppState === 'active') { if (isEventSourceClosed()) { client.logger.debug('Starting streamer after transitioning to foreground.'); - client.streamer?.start(); + client.updateProcessor?.start(); } else { client.logger.debug('Not starting streamer because EventSource is already open.'); } } else if (nextAppState === 'background') { client.logger.debug('App state background stopping streamer.'); - client.streamer?.stop(); + client.updateProcessor?.stop(); } else { client.logger.debug('No action needed.'); } diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index 42566702d7..165f4cc900 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; @@ -105,12 +107,15 @@ export default class LDClientImpl implements LDClient { case 'offline': return this.close(); case 'streaming': + case 'polling': + // TODO: Should only change when something changes. this.eventProcessor?.start(); 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( @@ -146,7 +151,7 @@ export default class LDClientImpl implements LDClient { async close(): Promise { await this.flush(); this.eventProcessor?.close(); - this.streamer?.close(); + this.updateProcessor?.close(); this.logger.debug('Closed eventProcessor and streamer.'); } @@ -246,6 +251,12 @@ export default class LDClientImpl implements LDClient { ); } + protected createPollUriPath(_context: LDContext): string { + throw new Error( + 'createPollUriPath not implemented. Client sdks must implement createStreamUriPath for streamer to work.', + ); + } + private createIdentifyPromise(timeout: number) { let res: any; let rej: any; @@ -334,28 +345,72 @@ 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, + ) { + this.updateProcessor = new PollingProcessor( + this.sdkKey, + this.clientContext, + this.createPollUriPath(context), + this.config, + async (flags) => { + // TODO: Names which make sense. + this.logger.debug(`Polling PUT: ${Object.keys(flags)}`); + this.onIdentifyResolve(identifyResolve, flags, context, 'polling PUT'); + 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 streamUri = this.createStreamUriPath(context); + if (this.config.withReasons) { + streamUri = `${streamUri}?withReasons=true`; + } + + this.updateProcessor = 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); + }, + ); + } + /** * Performs common tasks when resolving the identify promise: * - resolve the promise diff --git a/packages/shared/sdk-client/src/api/ConnectionMode.ts b/packages/shared/sdk-client/src/api/ConnectionMode.ts index e30c671845..b0e84906a7 100644 --- a/packages/shared/sdk-client/src/api/ConnectionMode.ts +++ b/packages/shared/sdk-client/src/api/ConnectionMode.ts @@ -8,7 +8,9 @@ * 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'; +type ConnectionMode = 'offline' | 'streaming' | 'polling'; export default ConnectionMode; 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..9b081ace25 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; + export default class Configuration { public static DEFAULT_POLLING = 'https://clientsdk.launchdarkly.com'; public static DEFAULT_STREAM = 'https://clientstream.launchdarkly.com'; @@ -61,6 +63,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; diff --git a/packages/shared/sdk-client/src/configuration/validators.ts b/packages/shared/sdk-client/src/configuration/validators.ts index 8cfd236284..682ab0bee6 100644 --- a/packages/shared/sdk-client/src/configuration/validators.ts +++ b/packages/shared/sdk-client/src/configuration/validators.ts @@ -1,5 +1,5 @@ // eslint-disable-next-line max-classes-per-file -import { noop, TypeValidator, TypeValidators } from '@launchdarkly/js-sdk-common'; +import { noop, Type, TypeValidator, TypeValidators } from '@launchdarkly/js-sdk-common'; import { type LDOptions } from '../api'; import { LDInspection } from '../api/LDInspection'; @@ -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/polling/PollingProcessor.ts b/packages/shared/sdk-client/src/polling/PollingProcessor.ts new file mode 100644 index 0000000000..25c53c61da --- /dev/null +++ b/packages/shared/sdk-client/src/polling/PollingProcessor.ts @@ -0,0 +1,134 @@ +import { + ClientContext, + defaultHeaders, + httpErrorMessage, + isHttpRecoverable, + LDLogger, + LDPollingError, + Requests, + subsystem, +} from '@launchdarkly/js-sdk-common'; + +import Configuration from '../configuration'; +import { Flags } from '../types'; + +export type PollingErrorHandler = (err: LDPollingError) => void; + +function isOk(status: number) { + return status >= 200 && status <= 299; +} + +/** + * @internal + */ +export default class PollingProcessor implements subsystem.LDStreamProcessor { + private readonly headers: { [key: string]: string }; + private stopped = false; + + private logger?: LDLogger; + + private pollInterval: number; + + private timeoutHandle: any; + + private requests: Requests; + private uri: string; + + constructor( + sdkKey: string, + clientContext: ClientContext, + uriPath: string, + config: Configuration, + private readonly dataHandler: (flags: Flags) => void, + private readonly errorHandler?: PollingErrorHandler, + ) { + const { basicConfiguration, platform } = clientContext; + const { logger, tags } = basicConfiguration; + const { info, requests } = platform; + this.uri = `${basicConfiguration.serviceEndpoints.polling}${uriPath}`; + + this.logger = logger; + this.requests = requests; + this.pollInterval = config.pollInterval; + this.headers = defaultHeaders(sdkKey, info, 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.requests.fetch(this.uri, { + method: 'GET', + headers: this.headers, + }); + + if (isOk(res.status)) { + const body = await res.text(); + + try { + const flags = JSON.parse(body); + this.dataHandler(flags); + } catch { + reportJsonError(body); + } + } else if (!isHttpRecoverable(res.status)) { + const message = httpErrorMessage( + { + message: `Unexpected status code: ${res.status}`, + status: res.status, + }, + 'polling request', + ); + this.logger?.error(message); + this.errorHandler?.(new LDPollingError(message, res.status)); + // It is not recoverable, return and do not trigger another + // poll. + return; + } else { + // TODO: Better. + // Recoverable error. + this.logger?.error('Recoverable error', res.status); + } + } catch (err) { + // TODO: Something. + this.logger?.error('[Polling] Error:', err); + } + + 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); + + // Falling through, there was some type of error and we need to trigger + // a new poll. + 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/evaluation/fetchFlags.test.ts b/packages/shared/sdk-client/src/polling/fetchFlags.test.ts similarity index 100% rename from packages/shared/sdk-client/src/evaluation/fetchFlags.test.ts rename to packages/shared/sdk-client/src/polling/fetchFlags.test.ts diff --git a/packages/shared/sdk-client/src/evaluation/fetchFlags.ts b/packages/shared/sdk-client/src/polling/fetchFlags.ts similarity index 100% rename from packages/shared/sdk-client/src/evaluation/fetchFlags.ts rename to packages/shared/sdk-client/src/polling/fetchFlags.ts diff --git a/packages/shared/sdk-client/src/evaluation/fetchUtils.test.ts b/packages/shared/sdk-client/src/polling/fetchUtils.test.ts similarity index 100% rename from packages/shared/sdk-client/src/evaluation/fetchUtils.test.ts rename to packages/shared/sdk-client/src/polling/fetchUtils.test.ts diff --git a/packages/shared/sdk-client/src/evaluation/fetchUtils.ts b/packages/shared/sdk-client/src/polling/fetchUtils.ts similarity index 100% rename from packages/shared/sdk-client/src/evaluation/fetchUtils.ts rename to packages/shared/sdk-client/src/polling/fetchUtils.ts diff --git a/packages/shared/sdk-client/src/evaluation/mockResponse.json b/packages/shared/sdk-client/src/polling/mockResponse.json similarity index 100% rename from packages/shared/sdk-client/src/evaluation/mockResponse.json rename to packages/shared/sdk-client/src/polling/mockResponse.json diff --git a/packages/shared/sdk-client/src/evaluation/mockResponseWithReasons.json b/packages/shared/sdk-client/src/polling/mockResponseWithReasons.json similarity index 100% rename from packages/shared/sdk-client/src/evaluation/mockResponseWithReasons.json rename to packages/shared/sdk-client/src/polling/mockResponseWithReasons.json From ec7883c23025e420e587ea97e72f40d917fe1dd0 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 26 Jul 2024 10:06:15 -0700 Subject: [PATCH 02/21] Correct streaming URL. --- packages/sdk/react-native/src/ReactNativeLDClient.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/react-native/src/ReactNativeLDClient.ts b/packages/sdk/react-native/src/ReactNativeLDClient.ts index c4460f392c..804fdafca3 100644 --- a/packages/sdk/react-native/src/ReactNativeLDClient.ts +++ b/packages/sdk/react-native/src/ReactNativeLDClient.ts @@ -64,7 +64,7 @@ export default class ReactNativeLDClient extends LDClientImpl { } override createStreamUriPath(context: LDContext) { - return `/meval/${this.encodeContext(context)}}`; + return `/meval/${this.encodeContext(context)}`; } override createPollUriPath(context: LDContext): string { From d5c01320ec709ab3004e9a4cd597eb4528a5875d Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 26 Jul 2024 14:39:54 -0700 Subject: [PATCH 03/21] feat: Add connection mananger. --- .../src/platform/ConnectionManager.ts | 149 ++++++++++++++++++ .../shared/sdk-client/src/LDClientImpl.ts | 8 + 2 files changed, 157 insertions(+) create mode 100644 packages/sdk/react-native/src/platform/ConnectionManager.ts diff --git a/packages/sdk/react-native/src/platform/ConnectionManager.ts b/packages/sdk/react-native/src/platform/ConnectionManager.ts new file mode 100644 index 0000000000..731ed47f05 --- /dev/null +++ b/packages/sdk/react-native/src/platform/ConnectionManager.ts @@ -0,0 +1,149 @@ +import { ConnectionMode, LDLogger } from '@launchdarkly/js-client-sdk-common'; + +export enum ApplicationState { + /// The application is in the foreground. + Foreground, + + /// The application is in the background. + /// + /// Note, the application will not be active while in the background, but + /// it will track when it is entering or exiting a background state. + Background, +} + +export enum NetworkState { + /// There is no network available for the SDK to use. + Unavailable, + + /// The network is available. Note that network requests may still fail + /// for other reasons. + Available, +} + +export interface ConnectionDestination { + setNetworkAvailability(available: boolean): void; + setEventSendingEnabled(enabled: boolean, flush: boolean): void; + setConnectionMode(mode: ConnectionMode): Promise; + flush(): Promise; +} + +export interface StateDetector { + setApplicationStateListener(fn: (state: ApplicationState) => void): void; + setNetworkStateListener(fn: (state: NetworkState) => void): void; + + stopListening(): void; +} + +export interface ConnectionManagerConfig { + /// The initial connection mode the SDK should use. + readonly initialConnectionMode: ConnectionMode; + + /// Some platforms (windows, web, mac, linux) can continue executing code + /// in the background. + readonly runInBackground: boolean; + + /// Enable handling of network availability. When this is true the + /// connection state will automatically change when network + /// availability changes. + readonly automaticNetworkHandling: boolean; + + /// Enable handling associated with transitioning between the foreground + /// and background. + readonly automaticBackgroundHandling: boolean; +} + +export class ConnectionManager { + private applicationState: ApplicationState = ApplicationState.Foreground; + private networkState: NetworkState = NetworkState.Available; + private offline: boolean = false; + private currentConnectionMode: ConnectionMode; + + constructor( + private readonly logger: LDLogger, + private readonly config: ConnectionManagerConfig, + private readonly destination: ConnectionDestination, + private readonly detector: StateDetector, + ) { + this.currentConnectionMode = config.initialConnectionMode; + if (config.automaticBackgroundHandling) { + detector.setApplicationStateListener((state) => { + this.applicationState = state; + this.handleState(); + }); + } + if (config.automaticNetworkHandling) { + detector.setNetworkStateListener((state) => { + this.networkState = state; + this.handleState(); + }); + } + } + + public setOffline(offline: boolean): void { + this.offline = offline; + this.handleState(); + } + + public setConnectionMode(mode: ConnectionMode) { + this.currentConnectionMode = mode; + this.handleState(); + } + + public close() { + this.detector.stopListening(); + } + + private handleState(): void { + this.logger.debug(`Handling state: ${this.applicationState}:${this.networkState}`); + + switch (this.networkState) { + case NetworkState.Available: + this.destination.setNetworkAvailability(false); + break; + case NetworkState.Unavailable: + this.destination.setNetworkAvailability(false); + switch (this.applicationState) { + case ApplicationState.Foreground: + this.setForegroundAvailable(); + break; + case ApplicationState.Background: + this.setBackgroundAvailable(); + break; + default: + break; + } + break; + default: + break; + } + } + + private setForegroundAvailable(): void { + if (this.offline) { + this.destination.setConnectionMode('offline'); + this.destination.setEventSendingEnabled(false, false); + return; + } + + // Currently the foreground mode will always be whatever the last active + // connection mode was. + this.destination.setConnectionMode(this.currentConnectionMode); + this.destination.setEventSendingEnabled(true, false); + } + + private setBackgroundAvailable(): void { + this.destination.flush(); + + if (!this.config.runInBackground) { + this.destination.setConnectionMode('offline'); + this.destination.setEventSendingEnabled(false, false); + return; + } + + // This SDK doesn't currently support automatic background polling. + + // If connections in the background are allowed, then use the same mode + // as is configured for the foreground. + this.setForegroundAvailable(); + } +} diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index 42566702d7..dd90025d73 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -246,6 +246,14 @@ export default class LDClientImpl implements LDClient { ); } + protected setNetworkAvailability(available: boolean): void { + + } + + protected setEventSendingEnabled(enabled: boolean, flush: boolean): void { + + } + private createIdentifyPromise(timeout: number) { let res: any; let rej: any; From 2aa885f4994f789320eb716072d664f6d65b24da Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 29 Jul 2024 11:49:42 -0700 Subject: [PATCH 04/21] Implement tests. --- .../src/platform/ConnectionManager.test.ts | 213 ++++++++++++++++++ .../src/platform/ConnectionManager.ts | 6 +- .../shared/sdk-client/src/LDClientImpl.ts | 8 - .../sdk-client/src/api/ConnectionMode.ts | 2 +- 4 files changed, 217 insertions(+), 12 deletions(-) create mode 100644 packages/sdk/react-native/src/platform/ConnectionManager.test.ts diff --git a/packages/sdk/react-native/src/platform/ConnectionManager.test.ts b/packages/sdk/react-native/src/platform/ConnectionManager.test.ts new file mode 100644 index 0000000000..8e893179fe --- /dev/null +++ b/packages/sdk/react-native/src/platform/ConnectionManager.test.ts @@ -0,0 +1,213 @@ +import { BasicLogger, ConnectionMode, LDLogger } from '@launchdarkly/js-client-sdk-common'; + +import { + ApplicationState, + ConnectionDestination, + ConnectionManager, + NetworkState, + StateDetector, +} from './ConnectionManager'; + +function mockDestination(): ConnectionDestination { + return { + setNetworkAvailability: jest.fn(), + setEventSendingEnabled: jest.fn(), + setConnectionMode: jest.fn(), + flush: jest.fn(), + }; +} + +class MockDetector implements StateDetector { + appStateListener?: (state: ApplicationState) => void; + networkStateListener?: (state: NetworkState) => void; + + setApplicationStateListener(fn: (state: ApplicationState) => void): void { + this.appStateListener = fn; + } + setNetworkStateListener(fn: (state: NetworkState) => void): void { + this.networkStateListener = fn; + } + stopListening(): void { + this.appStateListener = undefined; + this.networkStateListener = undefined; + } +} + +describe.each(['streaming', 'polling'])( + 'given initial connection modes', + (initialConnectionMode) => { + let destination: ConnectionDestination; + let stateDetector: MockDetector; + let logDestination: jest.Mock; + let logger: LDLogger; + + beforeEach(() => { + destination = mockDestination(); + stateDetector = new MockDetector(); + logDestination = jest.fn(); + logger = new BasicLogger({ destination: logDestination }); + }); + + it('can set the connection offline when entering the background', () => { + // eslint-disable-next-line no-new + new ConnectionManager( + logger, + { + initialConnectionMode, + runInBackground: false, + automaticBackgroundHandling: true, + automaticNetworkHandling: true, + }, + destination, + stateDetector, + ); + stateDetector.appStateListener!(ApplicationState.Background); + + expect(destination.setConnectionMode).toHaveBeenCalledWith('offline'); + }); + + it('can restore the connection when entering the foreground mode', () => { + // eslint-disable-next-line no-new + new ConnectionManager( + logger, + { + initialConnectionMode, + runInBackground: false, + automaticBackgroundHandling: true, + automaticNetworkHandling: true, + }, + destination, + stateDetector, + ); + stateDetector.appStateListener!(ApplicationState.Background); + stateDetector.appStateListener!(ApplicationState.Foreground); + + expect(destination.setConnectionMode).toHaveBeenNthCalledWith(1, 'offline'); + expect(destination.setConnectionMode).toHaveBeenNthCalledWith(2, initialConnectionMode); + expect(destination.setConnectionMode).toHaveBeenCalledTimes(2); + }); + + it('can continue to run in the background when configured to do so', () => { + // eslint-disable-next-line no-new + new ConnectionManager( + logger, + { + initialConnectionMode, + runInBackground: true, + automaticBackgroundHandling: true, + automaticNetworkHandling: true, + }, + destination, + stateDetector, + ); + stateDetector.appStateListener!(ApplicationState.Background); + stateDetector.appStateListener!(ApplicationState.Foreground); + expect(destination.setConnectionMode).toHaveBeenNthCalledWith(1, initialConnectionMode); + expect(destination.setConnectionMode).toHaveBeenNthCalledWith(2, initialConnectionMode); + expect(destination.setConnectionMode).toHaveBeenCalledTimes(2); + }); + + it('set the network availability to false when it detects the network is not available', () => { + // eslint-disable-next-line no-new + new ConnectionManager( + logger, + { + initialConnectionMode, + runInBackground: true, + automaticBackgroundHandling: true, + automaticNetworkHandling: true, + }, + destination, + stateDetector, + ); + stateDetector.networkStateListener!(NetworkState.Unavailable); + expect(destination.setNetworkAvailability).toHaveBeenCalledWith(false); + expect(destination.setNetworkAvailability).toHaveBeenCalledTimes(1); + }); + + it('sets the network availability to true when it detects the network is available', () => { + // eslint-disable-next-line no-new + new ConnectionManager( + logger, + { + initialConnectionMode, + runInBackground: true, + automaticBackgroundHandling: true, + automaticNetworkHandling: true, + }, + destination, + stateDetector, + ); + stateDetector.networkStateListener!(NetworkState.Unavailable); + stateDetector.networkStateListener!(NetworkState.Available); + expect(destination.setNetworkAvailability).toHaveBeenNthCalledWith(1, false); + expect(destination.setNetworkAvailability).toHaveBeenNthCalledWith(2, true); + expect(destination.setNetworkAvailability).toHaveBeenCalledTimes(2); + }); + + it('remains offline when temporarily offline', () => { + // eslint-disable-next-line no-new + const connectionManager = new ConnectionManager( + logger, + { + initialConnectionMode, + runInBackground: true, + automaticBackgroundHandling: true, + automaticNetworkHandling: true, + }, + destination, + stateDetector, + ); + connectionManager.setOffline(true); + + stateDetector.appStateListener!(ApplicationState.Background); + stateDetector.appStateListener!(ApplicationState.Foreground); + + expect(destination.setConnectionMode).toHaveBeenNthCalledWith(1, 'offline'); + expect(destination.setConnectionMode).toHaveBeenNthCalledWith(2, 'offline'); + expect(destination.setConnectionMode).toHaveBeenNthCalledWith(3, 'offline'); + expect(destination.setConnectionMode).toHaveBeenCalledTimes(3); + }); + + it('ignores application state changes when automaticBackgroundHandling is disabled', () => { + // eslint-disable-next-line no-new + new ConnectionManager( + logger, + { + initialConnectionMode, + runInBackground: true, + automaticBackgroundHandling: false, + automaticNetworkHandling: true, + }, + destination, + stateDetector, + ); + stateDetector.appStateListener?.(ApplicationState.Background); + stateDetector.appStateListener?.(ApplicationState.Foreground); + + expect(destination.setConnectionMode).toHaveBeenCalledTimes(0); + }); + + it('ignores network state changes when automaticNetworkHandling is disabled', () => { + // eslint-disable-next-line no-new + new ConnectionManager( + logger, + { + initialConnectionMode, + runInBackground: true, + automaticBackgroundHandling: true, + automaticNetworkHandling: false, + }, + destination, + stateDetector, + ); + stateDetector.networkStateListener?.(NetworkState.Unavailable); + stateDetector.networkStateListener?.(NetworkState.Available); + expect(destination.setNetworkAvailability).toHaveBeenCalledTimes(0); + }); + }, +); + +describe.each(['offline', 'streaming', 'polling'])('given requested connection modes', () => { + it('respects changes to the desired connection mode', () => {}); +}); diff --git a/packages/sdk/react-native/src/platform/ConnectionManager.ts b/packages/sdk/react-native/src/platform/ConnectionManager.ts index 731ed47f05..7046fabce8 100644 --- a/packages/sdk/react-native/src/platform/ConnectionManager.ts +++ b/packages/sdk/react-native/src/platform/ConnectionManager.ts @@ -97,11 +97,11 @@ export class ConnectionManager { this.logger.debug(`Handling state: ${this.applicationState}:${this.networkState}`); switch (this.networkState) { - case NetworkState.Available: - this.destination.setNetworkAvailability(false); - break; case NetworkState.Unavailable: this.destination.setNetworkAvailability(false); + break; + case NetworkState.Available: + this.destination.setNetworkAvailability(true); switch (this.applicationState) { case ApplicationState.Foreground: this.setForegroundAvailable(); diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index dd90025d73..42566702d7 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -246,14 +246,6 @@ export default class LDClientImpl implements LDClient { ); } - protected setNetworkAvailability(available: boolean): void { - - } - - protected setEventSendingEnabled(enabled: boolean, flush: boolean): void { - - } - private createIdentifyPromise(timeout: number) { let res: any; let rej: any; diff --git a/packages/shared/sdk-client/src/api/ConnectionMode.ts b/packages/shared/sdk-client/src/api/ConnectionMode.ts index e30c671845..3327d68af4 100644 --- a/packages/shared/sdk-client/src/api/ConnectionMode.ts +++ b/packages/shared/sdk-client/src/api/ConnectionMode.ts @@ -9,6 +9,6 @@ * * streaming - The SDK will use a streaming connection to receive updates from LaunchDarkly. */ -type ConnectionMode = 'offline' | 'streaming'; +type ConnectionMode = 'offline' | 'streaming' | 'polling'; export default ConnectionMode; From 2c896b9f7c06f291f4e357fd328ab752269b8876 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 29 Jul 2024 13:34:57 -0700 Subject: [PATCH 05/21] Start implementation of the state detector. --- .../react-native/src/ReactNativeLDClient.ts | 82 +++++++++++++ .../src/provider/LDProvider.test.tsx | 1 - .../react-native/src/provider/LDProvider.tsx | 3 - .../src/provider/useAppState.test.ts | 114 ------------------ .../react-native/src/provider/useAppState.ts | 58 --------- 5 files changed, 82 insertions(+), 176 deletions(-) delete mode 100644 packages/sdk/react-native/src/provider/useAppState.test.ts delete mode 100644 packages/sdk/react-native/src/provider/useAppState.ts diff --git a/packages/sdk/react-native/src/ReactNativeLDClient.ts b/packages/sdk/react-native/src/ReactNativeLDClient.ts index 9ccff1f392..8b291016c4 100644 --- a/packages/sdk/react-native/src/ReactNativeLDClient.ts +++ b/packages/sdk/react-native/src/ReactNativeLDClient.ts @@ -1,7 +1,11 @@ +/* eslint-disable max-classes-per-file */ +import { AppState, AppStateStatus } from 'react-native'; + import { AutoEnvAttributes, base64UrlEncode, BasicLogger, + ConnectionMode, internal, LDClientImpl, type LDContext, @@ -9,6 +13,49 @@ import { } from '@launchdarkly/js-client-sdk-common'; import createPlatform from './platform'; +import { + ApplicationState, + ConnectionDestination, + ConnectionManager, + NetworkState, + StateDetector, +} from './platform/ConnectionManager'; + +function translateAppState(state: AppStateStatus): ApplicationState { + switch (state) { + case 'active': + return ApplicationState.Foreground; + case 'inactive': + case 'background': + case 'extension': + default: + return ApplicationState.Background; + } +} + +class RNStateDetector implements StateDetector { + private applicationStateListener?: (state: ApplicationState) => void; + private networkStateListener?: (state: NetworkState) => void; + + constructor() { + AppState.addEventListener('change', (state: AppStateStatus) => { + this.applicationStateListener?.(translateAppState(state)); + }); + } + + setApplicationStateListener(fn: (state: ApplicationState) => void): void { + this.applicationStateListener = fn; + // When you listen provide the current state immediately. + this.applicationStateListener(translateAppState(AppState.currentState)); + } + setNetworkStateListener(fn: (state: NetworkState) => void): void { + this.networkStateListener = fn; + } + stopListening(): void { + this.applicationStateListener = undefined; + this.networkStateListener = undefined; + } +} /** * The React Native LaunchDarkly client. Instantiate this class to create an @@ -24,6 +71,7 @@ import createPlatform from './platform'; * ``` */ export default class ReactNativeLDClient extends LDClientImpl { + private connectionManager: ConnectionManager; /** * Creates an instance of the LaunchDarkly client. * @@ -57,9 +105,43 @@ export default class ReactNativeLDClient extends LDClientImpl { { ...options, logger }, internalOptions, ); + + const destination: ConnectionDestination = { + setNetworkAvailability: (_available: boolean) => { + // Not yet supported. + }, + setEventSendingEnabled: (_enabled: boolean, _flush: boolean) => { + // TODO: Implement + }, + setConnectionMode: async (mode: ConnectionMode) => { + // Pass the connection mode to the base implementation. + // The RN implementation will pass the connection mode through the connection manager. + super.setConnectionMode(mode); + }, + flush: async () => { + this.flush(); + }, + }; + + const initialConnectionMode = options.initialConnectionMode ?? 'streaming'; + this.connectionManager = new ConnectionManager( + logger, + { + initialConnectionMode, + automaticNetworkHandling: true, + automaticBackgroundHandling: true, + runInBackground: false, + }, + destination, + new RNStateDetector(), + ); } override createStreamUriPath(context: LDContext) { return `/meval/${base64UrlEncode(JSON.stringify(context), this.platform.encoding!)}`; } + + override async setConnectionMode(mode: ConnectionMode): Promise { + this.connectionManager.setConnectionMode(mode); + } } diff --git a/packages/sdk/react-native/src/provider/LDProvider.test.tsx b/packages/sdk/react-native/src/provider/LDProvider.test.tsx index 7cd478053b..4d5d9a5dbc 100644 --- a/packages/sdk/react-native/src/provider/LDProvider.test.tsx +++ b/packages/sdk/react-native/src/provider/LDProvider.test.tsx @@ -7,7 +7,6 @@ import ReactNativeLDClient from '../ReactNativeLDClient'; import LDProvider from './LDProvider'; import setupListeners from './setupListeners'; -jest.mock('../provider/useAppState'); jest.mock('../ReactNativeLDClient'); jest.mock('./setupListeners'); diff --git a/packages/sdk/react-native/src/provider/LDProvider.tsx b/packages/sdk/react-native/src/provider/LDProvider.tsx index 8f682ad734..652b84f4a2 100644 --- a/packages/sdk/react-native/src/provider/LDProvider.tsx +++ b/packages/sdk/react-native/src/provider/LDProvider.tsx @@ -3,7 +3,6 @@ import React, { PropsWithChildren, useEffect, useState } from 'react'; import ReactNativeLDClient from '../ReactNativeLDClient'; import { Provider, ReactContext } from './reactContext'; import setupListeners from './setupListeners'; -import useAppState from './useAppState'; type LDProps = { client: ReactNativeLDClient; @@ -26,8 +25,6 @@ const LDProvider = ({ client, children }: PropsWithChildren) => { setupListeners(client, setState); }, []); - useAppState(client); - return {children}; }; diff --git a/packages/sdk/react-native/src/provider/useAppState.test.ts b/packages/sdk/react-native/src/provider/useAppState.test.ts deleted file mode 100644 index 6bb3d1eca6..0000000000 --- a/packages/sdk/react-native/src/provider/useAppState.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { renderHook } from '@testing-library/react'; -import React, { useRef } from 'react'; -import { AppState } from 'react-native'; - -import { AutoEnvAttributes, debounce } from '@launchdarkly/js-client-sdk-common'; -import { logger } from '@launchdarkly/private-js-mocks'; - -import EventSource from '../fromExternal/react-native-sse'; -import ReactNativeLDClient from '../ReactNativeLDClient'; -import useAppState from './useAppState'; - -jest.mock('@launchdarkly/js-client-sdk-common', () => { - const actual = jest.requireActual('@launchdarkly/js-client-sdk-common'); - return { - ...actual, - debounce: jest.fn(), - }; -}); - -describe('useAppState', () => { - const eventSourceOpen = 1; - const eventSourceClosed = 2; - - let appStateSpy: jest.SpyInstance; - let ldc: ReactNativeLDClient; - let mockEventSource: Partial; - - beforeEach(() => { - (debounce as jest.Mock).mockImplementation((f) => f); - appStateSpy = jest.spyOn(AppState, 'addEventListener').mockReturnValue({ remove: jest.fn() }); - jest.spyOn(React, 'useRef').mockReturnValue({ - current: 'active', - }); - - ldc = new ReactNativeLDClient('mob-test-key', AutoEnvAttributes.Enabled, { logger }); - - mockEventSource = { - getStatus: jest.fn(() => eventSourceOpen), - OPEN: eventSourceOpen, - CLOSED: eventSourceClosed, - }; - // @ts-ignore - ldc.platform.requests = { eventSource: mockEventSource }; - // @ts-ignore - ldc.streamer = { start: jest.fn().mockName('start'), stop: jest.fn().mockName('stop') }; - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - test('stops streamer in background', () => { - renderHook(() => useAppState(ldc)); - const onChange = appStateSpy.mock.calls[0][1]; - - onChange('background'); - - expect(ldc.streamer?.stop).toHaveBeenCalledTimes(1); - }); - - test('starts streamer transitioning from background to active', () => { - (useRef as jest.Mock).mockReturnValue({ current: 'background' }); - (mockEventSource.getStatus as jest.Mock).mockReturnValue(eventSourceClosed); - - renderHook(() => useAppState(ldc)); - const onChange = appStateSpy.mock.calls[0][1]; - - onChange('active'); - - expect(ldc.streamer?.start).toHaveBeenCalledTimes(1); - expect(ldc.streamer?.stop).not.toHaveBeenCalled(); - }); - - test('starts streamer transitioning from inactive to active', () => { - (useRef as jest.Mock).mockReturnValue({ current: 'inactive' }); - (mockEventSource.getStatus as jest.Mock).mockReturnValue(eventSourceClosed); - - renderHook(() => useAppState(ldc)); - const onChange = appStateSpy.mock.calls[0][1]; - - onChange('active'); - - expect(ldc.streamer?.start).toHaveBeenCalledTimes(1); - expect(ldc.streamer?.stop).not.toHaveBeenCalled(); - }); - - test('does not start streamer in foreground because event source is already open', () => { - (useRef as jest.Mock).mockReturnValue({ current: 'background' }); - (mockEventSource.getStatus as jest.Mock).mockReturnValue(eventSourceOpen); - - renderHook(() => useAppState(ldc)); - const onChange = appStateSpy.mock.calls[0][1]; - - onChange('active'); - - expect(ldc.streamer?.start).not.toHaveBeenCalled(); - expect(ldc.streamer?.stop).not.toHaveBeenCalled(); - expect(ldc.logger.debug).toHaveBeenCalledWith(expect.stringMatching(/already open/)); - }); - - test('active state unchanged no action needed', () => { - (useRef as jest.Mock).mockReturnValue({ current: 'active' }); - (mockEventSource.getStatus as jest.Mock).mockReturnValue(eventSourceClosed); - - renderHook(() => useAppState(ldc)); - const onChange = appStateSpy.mock.calls[0][1]; - - onChange('active'); - - expect(ldc.streamer?.start).not.toHaveBeenCalled(); - expect(ldc.streamer?.stop).not.toHaveBeenCalled(); - expect(ldc.logger.debug).toHaveBeenCalledWith(expect.stringMatching(/no action needed/i)); - }); -}); diff --git a/packages/sdk/react-native/src/provider/useAppState.ts b/packages/sdk/react-native/src/provider/useAppState.ts deleted file mode 100644 index 0421881cd5..0000000000 --- a/packages/sdk/react-native/src/provider/useAppState.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { useEffect, useRef } from 'react'; -import { AppState, AppStateStatus } from 'react-native'; - -import { debounce } from '@launchdarkly/js-client-sdk-common'; - -import { PlatformRequests } from '../platform'; -import ReactNativeLDClient from '../ReactNativeLDClient'; - -/** - * Manages streamer connection based on AppState. Debouncing is used to prevent excessive starting - * and stopping of the EventSource which are expensive. - * - * background to active - start streamer. - * active to background - stop streamer. - * - * @param client - */ -const useAppState = (client: ReactNativeLDClient) => { - const appState = useRef(AppState.currentState); - - const isEventSourceClosed = () => { - const { eventSource } = client.platform.requests as PlatformRequests; - return eventSource?.getStatus() === eventSource?.CLOSED; - }; - - const onChange = (nextAppState: AppStateStatus) => { - client.logger.debug(`App state prev: ${appState.current}, next: ${nextAppState}`); - - if (appState.current.match(/inactive|background/) && nextAppState === 'active') { - if (isEventSourceClosed()) { - client.logger.debug('Starting streamer after transitioning to foreground.'); - client.streamer?.start(); - } else { - client.logger.debug('Not starting streamer because EventSource is already open.'); - } - } else if (nextAppState === 'background') { - client.logger.debug('App state background stopping streamer.'); - client.streamer?.stop(); - } else { - client.logger.debug('No action needed.'); - } - - appState.current = nextAppState; - }; - - // debounce with a default delay of 5 seconds. - const debouncedOnChange = debounce(onChange); - - useEffect(() => { - const sub = AppState.addEventListener('change', debouncedOnChange); - - return () => { - sub.remove(); - }; - }, []); -}; - -export default useAppState; From 3dd28088f56cb44837d0637eb8785c18b3f5be1f Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 29 Jul 2024 14:23:11 -0700 Subject: [PATCH 06/21] wip --- packages/sdk/react-native/src/ReactNativeLDClient.ts | 8 ++++---- packages/shared/sdk-client/src/LDClientImpl.ts | 8 ++++++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/sdk/react-native/src/ReactNativeLDClient.ts b/packages/sdk/react-native/src/ReactNativeLDClient.ts index 8b291016c4..facb3a17fb 100644 --- a/packages/sdk/react-native/src/ReactNativeLDClient.ts +++ b/packages/sdk/react-native/src/ReactNativeLDClient.ts @@ -107,11 +107,11 @@ export default class ReactNativeLDClient extends LDClientImpl { ); const destination: ConnectionDestination = { - setNetworkAvailability: (_available: boolean) => { - // Not yet supported. + setNetworkAvailability: (available: boolean) => { + this.setNetworkAvailability(available); }, - setEventSendingEnabled: (_enabled: boolean, _flush: boolean) => { - // TODO: Implement + setEventSendingEnabled: (enabled: boolean, flush: boolean) => { + this.setEventSendingEnabled(enabled, flush); }, setConnectionMode: async (mode: ConnectionMode) => { // Pass the connection mode to the base implementation. diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index 42566702d7..e1f3e04b00 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -538,4 +538,12 @@ export default class LDClientImpl implements LDClient { jsonVariationDetail(key: string, defaultValue: unknown): LDEvaluationDetailTyped { return this.variationDetail(key, defaultValue); } + + protected setNetworkAvailability(_available: boolean): void { + // Not yet supported. + } + + protected setEventSendingEnabled(_enabled: boolean, _flush: boolean): void { + // TODO: Implement. + } } From e8e44d32c4bd71039c66a422a4126a317796e8eb Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 29 Jul 2024 14:24:03 -0700 Subject: [PATCH 07/21] Make enums backed by string values for easier logging. --- .../sdk/react-native/src/platform/ConnectionManager.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/sdk/react-native/src/platform/ConnectionManager.ts b/packages/sdk/react-native/src/platform/ConnectionManager.ts index 7046fabce8..5498488486 100644 --- a/packages/sdk/react-native/src/platform/ConnectionManager.ts +++ b/packages/sdk/react-native/src/platform/ConnectionManager.ts @@ -2,22 +2,22 @@ import { ConnectionMode, LDLogger } from '@launchdarkly/js-client-sdk-common'; export enum ApplicationState { /// The application is in the foreground. - Foreground, + Foreground = 'foreground', /// The application is in the background. /// /// Note, the application will not be active while in the background, but /// it will track when it is entering or exiting a background state. - Background, + Background = 'background', } export enum NetworkState { /// There is no network available for the SDK to use. - Unavailable, + Unavailable = 'unavailable', /// The network is available. Note that network requests may still fail /// for other reasons. - Available, + Available = 'available', } export interface ConnectionDestination { From b54463ad1e30b925de3b506c7bbafb9a8e5c1a71 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 29 Jul 2024 15:12:59 -0700 Subject: [PATCH 08/21] feat: Refactor application state handling. --- .../react-native/src/ReactNativeLDClient.ts | 4 ++ .../src/platform/ConnectionManager.ts | 7 +-- .../sdk/react-native/src/platform/index.ts | 6 +-- .../shared/sdk-client/src/LDClientImpl.ts | 49 ++++++++++++++++--- 4 files changed, 51 insertions(+), 15 deletions(-) diff --git a/packages/sdk/react-native/src/ReactNativeLDClient.ts b/packages/sdk/react-native/src/ReactNativeLDClient.ts index facb3a17fb..18061d29f3 100644 --- a/packages/sdk/react-native/src/ReactNativeLDClient.ts +++ b/packages/sdk/react-native/src/ReactNativeLDClient.ts @@ -142,6 +142,10 @@ export default class ReactNativeLDClient extends LDClientImpl { } override 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'); } } diff --git a/packages/sdk/react-native/src/platform/ConnectionManager.ts b/packages/sdk/react-native/src/platform/ConnectionManager.ts index 5498488486..191babfb39 100644 --- a/packages/sdk/react-native/src/platform/ConnectionManager.ts +++ b/packages/sdk/react-native/src/platform/ConnectionManager.ts @@ -121,6 +121,8 @@ export class ConnectionManager { private setForegroundAvailable(): void { if (this.offline) { this.destination.setConnectionMode('offline'); + // Don't attempt to flush. If the user wants to flush when entering offline + // mode, then they can do that directly. this.destination.setEventSendingEnabled(false, false); return; } @@ -128,15 +130,14 @@ export class ConnectionManager { // Currently the foreground mode will always be whatever the last active // connection mode was. this.destination.setConnectionMode(this.currentConnectionMode); + this.destination.setEventSendingEnabled(true, false); } private setBackgroundAvailable(): void { - this.destination.flush(); - if (!this.config.runInBackground) { this.destination.setConnectionMode('offline'); - this.destination.setEventSendingEnabled(false, false); + this.destination.setEventSendingEnabled(false, true); return; } diff --git a/packages/sdk/react-native/src/platform/index.ts b/packages/sdk/react-native/src/platform/index.ts index fb87893fb1..df1f103ded 100644 --- a/packages/sdk/react-native/src/platform/index.ts +++ b/packages/sdk/react-native/src/platform/index.ts @@ -23,18 +23,14 @@ import AsyncStorage from './ConditionalAsyncStorage'; import PlatformCrypto from './crypto'; export class PlatformRequests implements Requests { - eventSource?: RNEventSource; - constructor(private readonly logger: LDLogger) {} createEventSource(url: string, eventSourceInitDict: EventSourceInitDict): EventSource { - this.eventSource = new RNEventSource(url, { + return new RNEventSource(url, { headers: eventSourceInitDict.headers, retryAndHandleError: eventSourceInitDict.errorFilter, logger: this.logger, }); - - return this.eventSource; } fetch(url: string, options?: Options): Promise { diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index e1f3e04b00..510c32cc9f 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -48,6 +48,8 @@ export default class LDClientImpl implements LDClient { private flags: Flags = {}; private readonly clientContext: ClientContext; + private eventSendingEnabled: boolean = true; + private networkAvailable: boolean = true; /** * Creates the client object synchronously. No async, no network calls. @@ -103,10 +105,13 @@ export default class LDClientImpl implements LDClient { switch (mode) { case 'offline': - return this.close(); + this.streamer?.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': - this.eventProcessor?.start(); - if (this.context) { // identify will start streamer return this.identify(this.context, { timeout: this.identifyTimeout }); @@ -147,7 +152,7 @@ export default class LDClientImpl implements LDClient { await this.flush(); this.eventProcessor?.close(); this.streamer?.close(); - this.logger.debug('Closed eventProcessor and streamer.'); + this.logger.debug('Shutdown the launchdarkly client.'); } async flush(): Promise<{ error?: Error; result: boolean }> { @@ -539,11 +544,41 @@ export default class LDClientImpl implements LDClient { return this.variationDetail(key, defaultValue); } - protected setNetworkAvailability(_available: boolean): void { + /** + * 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. } - protected setEventSendingEnabled(_enabled: boolean, _flush: boolean): void { - // TODO: Implement. + /** + * Enable/Disable event sending. + * @param enabled True to enable event processing, false to disable. + * @param flush True to flush while disabling. Useful to flush on certain state transitions. + */ + protected setEventSendingEnabled(enabled: boolean, flush: boolean): void { + if (this.eventSendingEnabled === enabled) { + return; + } + this.eventSendingEnabled = enabled; + + if (enabled) { + this.logger.debug('Starting event processor'); + this.eventProcessor?.start(); + } else if (flush) { + this.flush().then(() => { + // While waiting for the flush event sending could be re-enabled, in which case + // we do not want to close the event processor. + if (!this.eventSendingEnabled) { + this.logger?.debug('Stopping event processor.'); + this.eventProcessor?.close(); + } + }); + } } } From 359d71d1a3eb1e038a0bdfb42ca29653d12d7097 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 29 Jul 2024 15:36:42 -0700 Subject: [PATCH 09/21] PR Cleanup --- .../sdk/react-native/src/RNStateDetector.ts | 39 ++++++++++++ .../react-native/src/ReactNativeLDClient.ts | 59 ++++--------------- .../src/platform/ConnectionManager.test.ts | 1 - .../src/platform/ConnectionManager.ts | 1 - 4 files changed, 49 insertions(+), 51 deletions(-) create mode 100644 packages/sdk/react-native/src/RNStateDetector.ts diff --git a/packages/sdk/react-native/src/RNStateDetector.ts b/packages/sdk/react-native/src/RNStateDetector.ts new file mode 100644 index 0000000000..d6d10788ec --- /dev/null +++ b/packages/sdk/react-native/src/RNStateDetector.ts @@ -0,0 +1,39 @@ +import { AppState, AppStateStatus } from 'react-native'; + +import { ApplicationState, NetworkState, StateDetector } from './platform/ConnectionManager'; + +function translateAppState(state: AppStateStatus): ApplicationState { + switch (state) { + case 'active': + return ApplicationState.Foreground; + case 'inactive': + case 'background': + case 'extension': + default: + return ApplicationState.Background; + } +} + +export default class RNStateDetector implements StateDetector { + private applicationStateListener?: (state: ApplicationState) => void; + private networkStateListener?: (state: NetworkState) => void; + + constructor() { + AppState.addEventListener('change', (state: AppStateStatus) => { + this.applicationStateListener?.(translateAppState(state)); + }); + } + + setApplicationStateListener(fn: (state: ApplicationState) => void): void { + this.applicationStateListener = fn; + // When you listen provide the current state immediately. + this.applicationStateListener(translateAppState(AppState.currentState)); + } + setNetworkStateListener(fn: (state: NetworkState) => void): void { + this.networkStateListener = fn; + } + stopListening(): void { + this.applicationStateListener = undefined; + this.networkStateListener = undefined; + } +} diff --git a/packages/sdk/react-native/src/ReactNativeLDClient.ts b/packages/sdk/react-native/src/ReactNativeLDClient.ts index 18061d29f3..7ba562cd07 100644 --- a/packages/sdk/react-native/src/ReactNativeLDClient.ts +++ b/packages/sdk/react-native/src/ReactNativeLDClient.ts @@ -1,6 +1,4 @@ /* eslint-disable max-classes-per-file */ -import { AppState, AppStateStatus } from 'react-native'; - import { AutoEnvAttributes, base64UrlEncode, @@ -13,49 +11,8 @@ import { } from '@launchdarkly/js-client-sdk-common'; import createPlatform from './platform'; -import { - ApplicationState, - ConnectionDestination, - ConnectionManager, - NetworkState, - StateDetector, -} from './platform/ConnectionManager'; - -function translateAppState(state: AppStateStatus): ApplicationState { - switch (state) { - case 'active': - return ApplicationState.Foreground; - case 'inactive': - case 'background': - case 'extension': - default: - return ApplicationState.Background; - } -} - -class RNStateDetector implements StateDetector { - private applicationStateListener?: (state: ApplicationState) => void; - private networkStateListener?: (state: NetworkState) => void; - - constructor() { - AppState.addEventListener('change', (state: AppStateStatus) => { - this.applicationStateListener?.(translateAppState(state)); - }); - } - - setApplicationStateListener(fn: (state: ApplicationState) => void): void { - this.applicationStateListener = fn; - // When you listen provide the current state immediately. - this.applicationStateListener(translateAppState(AppState.currentState)); - } - setNetworkStateListener(fn: (state: NetworkState) => void): void { - this.networkStateListener = fn; - } - stopListening(): void { - this.applicationStateListener = undefined; - this.networkStateListener = undefined; - } -} +import { ConnectionDestination, ConnectionManager } from './platform/ConnectionManager'; +import RNStateDetector from './RNStateDetector'; /** * The React Native LaunchDarkly client. Instantiate this class to create an @@ -116,10 +73,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. - super.setConnectionMode(mode); - }, - flush: async () => { - this.flush(); + this.baseSetConnectionMode(mode); }, }; @@ -128,6 +82,8 @@ export default class ReactNativeLDClient extends LDClientImpl { logger, { initialConnectionMode, + // TODO: Add the ability to configure connection management. + // This is not yet added as the RN SDK needs package specific configuration added. automaticNetworkHandling: true, automaticBackgroundHandling: true, runInBackground: false, @@ -137,6 +93,11 @@ 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); + } + override createStreamUriPath(context: LDContext) { return `/meval/${base64UrlEncode(JSON.stringify(context), this.platform.encoding!)}`; } diff --git a/packages/sdk/react-native/src/platform/ConnectionManager.test.ts b/packages/sdk/react-native/src/platform/ConnectionManager.test.ts index 8e893179fe..2ce2db3d2b 100644 --- a/packages/sdk/react-native/src/platform/ConnectionManager.test.ts +++ b/packages/sdk/react-native/src/platform/ConnectionManager.test.ts @@ -13,7 +13,6 @@ function mockDestination(): ConnectionDestination { setNetworkAvailability: jest.fn(), setEventSendingEnabled: jest.fn(), setConnectionMode: jest.fn(), - flush: jest.fn(), }; } diff --git a/packages/sdk/react-native/src/platform/ConnectionManager.ts b/packages/sdk/react-native/src/platform/ConnectionManager.ts index 2031ca5f47..4af13239a8 100644 --- a/packages/sdk/react-native/src/platform/ConnectionManager.ts +++ b/packages/sdk/react-native/src/platform/ConnectionManager.ts @@ -24,7 +24,6 @@ export interface ConnectionDestination { setNetworkAvailability(available: boolean): void; setEventSendingEnabled(enabled: boolean, flush: boolean): void; setConnectionMode(mode: ConnectionMode): Promise; - flush(): Promise; } export interface StateDetector { From b68e4dd1a90e2ef04c279bcf8db5384e918a07c1 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 29 Jul 2024 15:44:51 -0700 Subject: [PATCH 10/21] Add some blank lines. --- packages/sdk/react-native/src/RNStateDetector.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/sdk/react-native/src/RNStateDetector.ts b/packages/sdk/react-native/src/RNStateDetector.ts index d6d10788ec..8c8ac48a24 100644 --- a/packages/sdk/react-native/src/RNStateDetector.ts +++ b/packages/sdk/react-native/src/RNStateDetector.ts @@ -29,9 +29,11 @@ export default class RNStateDetector implements StateDetector { // When you listen provide the current state immediately. this.applicationStateListener(translateAppState(AppState.currentState)); } + setNetworkStateListener(fn: (state: NetworkState) => void): void { this.networkStateListener = fn; } + stopListening(): void { this.applicationStateListener = undefined; this.networkStateListener = undefined; From 7cf3adf22397cd473ebd82298de441c282836359 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 29 Jul 2024 15:45:27 -0700 Subject: [PATCH 11/21] Add not implemented comment. --- packages/sdk/react-native/src/RNStateDetector.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/sdk/react-native/src/RNStateDetector.ts b/packages/sdk/react-native/src/RNStateDetector.ts index 8c8ac48a24..c0a2c42217 100644 --- a/packages/sdk/react-native/src/RNStateDetector.ts +++ b/packages/sdk/react-native/src/RNStateDetector.ts @@ -32,6 +32,7 @@ export default class RNStateDetector implements StateDetector { setNetworkStateListener(fn: (state: NetworkState) => void): void { this.networkStateListener = fn; + // Not implemented. } stopListening(): void { From 9559afddab459d2a536eecb0cf43f12293159923 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 30 Jul 2024 08:57:43 -0700 Subject: [PATCH 12/21] PR feedback --- packages/shared/sdk-client/src/LDClientImpl.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index 510c32cc9f..6280f790cb 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -571,6 +571,7 @@ export default class LDClientImpl implements LDClient { this.logger.debug('Starting event processor'); this.eventProcessor?.start(); } else if (flush) { + // Disable and flush. this.flush().then(() => { // While waiting for the flush event sending could be re-enabled, in which case // we do not want to close the event processor. @@ -579,6 +580,10 @@ export default class LDClientImpl implements LDClient { this.eventProcessor?.close(); } }); + } else { + // Just disabled. + this.logger?.debug('Stopping event processor.'); + this.eventProcessor?.close(); } } } From 3e159f5c46c5e6a467a263e5f4ad552441ae42cf Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 30 Jul 2024 12:26:33 -0700 Subject: [PATCH 13/21] Make debug output type for connection mode more consistent. --- packages/shared/sdk-client/src/configuration/validators.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/sdk-client/src/configuration/validators.ts b/packages/shared/sdk-client/src/configuration/validators.ts index 682ab0bee6..4c3fc1dac8 100644 --- a/packages/shared/sdk-client/src/configuration/validators.ts +++ b/packages/shared/sdk-client/src/configuration/validators.ts @@ -20,7 +20,7 @@ class ConnectionModeValidator implements TypeValidator { } getType(): string { - return `'offline' | streaming | polling`; + return `offline | streaming | polling`; } } From efe76ea2d9791adb206ca65532201591ed5f754a Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 30 Jul 2024 12:28:22 -0700 Subject: [PATCH 14/21] Fix merge marker. --- packages/shared/sdk-client/src/LDClientImpl.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index e6c3d50e54..22d3bad59c 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -629,10 +629,7 @@ export default class LDClientImpl implements LDClient { this.logger.debug('Starting event processor'); this.eventProcessor?.start(); } else if (flush) { -<<<<<<< HEAD this.logger?.debug('Flushing event processor before disabling.'); -======= ->>>>>>> feat/client-connection-handling-rework // Disable and flush. this.flush().then(() => { // While waiting for the flush event sending could be re-enabled, in which case From 53c0afb598dd1973a068bc7e209574868110ea45 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 31 Jul 2024 08:42:51 -0700 Subject: [PATCH 15/21] Implement polling tests. --- .../src/LDClientImpl.storage.test.ts | 2 +- .../shared/sdk-client/src/LDClientImpl.ts | 3 +- .../{polling => evaluation}/mockResponse.json | 0 .../src/polling/PollingProcessor.ts | 73 ++-- .../src/polling/PollingProcessot.test.ts | 382 ++++++++++++++++++ .../sdk-client/src/polling/fetchFlags.test.ts | 73 ---- .../sdk-client/src/polling/fetchFlags.ts | 21 - .../sdk-client/src/polling/fetchUtils.test.ts | 4 - .../sdk-client/src/polling/fetchUtils.ts | 81 ---- .../src/polling/mockResponseWithReasons.json | 66 --- 10 files changed, 427 insertions(+), 278 deletions(-) rename packages/shared/sdk-client/src/{polling => evaluation}/mockResponse.json (100%) create mode 100644 packages/shared/sdk-client/src/polling/PollingProcessot.test.ts delete mode 100644 packages/shared/sdk-client/src/polling/fetchFlags.test.ts delete mode 100644 packages/shared/sdk-client/src/polling/fetchFlags.ts delete mode 100644 packages/shared/sdk-client/src/polling/fetchUtils.test.ts delete mode 100644 packages/shared/sdk-client/src/polling/fetchUtils.ts delete mode 100644 packages/shared/sdk-client/src/polling/mockResponseWithReasons.json diff --git a/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts b/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts index 6263ef2c54..887896029c 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts @@ -201,7 +201,7 @@ describe('sdk-client storage', () => { JSON.stringify(defaultPutResponse), ); expect(ldc.logger.debug).toHaveBeenCalledWith( - 'OnIdentifyResolve no changes to emit from: streaming PUT.', + 'OnIdentifyResolve no changes to emit from: stream PUT.', ); // this is defaultPutResponse diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index 22d3bad59c..9b34577650 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -380,7 +380,8 @@ export default class LDClientImpl implements LDClient { } this.updateProcessor = new PollingProcessor( this.sdkKey, - this.clientContext, + this.clientContext.platform.requests, + this.clientContext.platform.info, pollingPath, this.config, async (flags) => { diff --git a/packages/shared/sdk-client/src/polling/mockResponse.json b/packages/shared/sdk-client/src/evaluation/mockResponse.json similarity index 100% rename from packages/shared/sdk-client/src/polling/mockResponse.json rename to packages/shared/sdk-client/src/evaluation/mockResponse.json diff --git a/packages/shared/sdk-client/src/polling/PollingProcessor.ts b/packages/shared/sdk-client/src/polling/PollingProcessor.ts index 25c53c61da..e0223db58a 100644 --- a/packages/shared/sdk-client/src/polling/PollingProcessor.ts +++ b/packages/shared/sdk-client/src/polling/PollingProcessor.ts @@ -1,15 +1,17 @@ import { - ClientContext, + ApplicationTags, defaultHeaders, httpErrorMessage, + HttpErrorResponse, + Info, isHttpRecoverable, LDLogger, LDPollingError, Requests, + ServiceEndpoints, subsystem, } from '@launchdarkly/js-sdk-common'; -import Configuration from '../configuration'; import { Flags } from '../types'; export type PollingErrorHandler = (err: LDPollingError) => void; @@ -18,6 +20,19 @@ function isOk(status: number) { return status >= 200 && status <= 299; } +/** + * Subset of configuration required for polling. + * + * @internal + */ +export type PollingConfig = { + logger: LDLogger; + pollInterval: number; + tags: ApplicationTags; + useReport: boolean; + serviceEndpoints: ServiceEndpoints; +}; + /** * @internal */ @@ -31,26 +46,25 @@ export default class PollingProcessor implements subsystem.LDStreamProcessor { private timeoutHandle: any; - private requests: Requests; private uri: string; + private verb: string; constructor( sdkKey: string, - clientContext: ClientContext, + private requests: Requests, + info: Info, uriPath: string, - config: Configuration, + config: PollingConfig, private readonly dataHandler: (flags: Flags) => void, private readonly errorHandler?: PollingErrorHandler, ) { - const { basicConfiguration, platform } = clientContext; - const { logger, tags } = basicConfiguration; - const { info, requests } = platform; - this.uri = `${basicConfiguration.serviceEndpoints.polling}${uriPath}`; + this.uri = `${config.serviceEndpoints.polling}${uriPath}`; - this.logger = logger; + this.logger = config.logger; this.requests = requests; this.pollInterval = config.pollInterval; - this.headers = defaultHeaders(sdkKey, info, tags); + this.headers = defaultHeaders(sdkKey, info, config.tags); + this.verb = config.useReport ? 'REPORT' : 'GET'; } private async poll() { @@ -68,7 +82,7 @@ export default class PollingProcessor implements subsystem.LDStreamProcessor { const startTime = Date.now(); try { const res = await this.requests.fetch(this.uri, { - method: 'GET', + method: this.verb, headers: this.headers, }); @@ -81,27 +95,26 @@ export default class PollingProcessor implements subsystem.LDStreamProcessor { } catch { reportJsonError(body); } - } else if (!isHttpRecoverable(res.status)) { - const message = httpErrorMessage( - { - message: `Unexpected status code: ${res.status}`, - status: res.status, - }, - 'polling request', - ); - this.logger?.error(message); - this.errorHandler?.(new LDPollingError(message, res.status)); - // It is not recoverable, return and do not trigger another - // poll. - return; } else { - // TODO: Better. + const err = { + message: `Unexpected status code: ${res.status}`, + status: res.status, + }; + if (!isHttpRecoverable(res.status)) { + const message = httpErrorMessage(err, 'polling request'); + this.logger?.error(message); + this.errorHandler?.(new LDPollingError(message, res.status)); + // It is not recoverable, return and do not trigger another + // poll. + return; + } // Recoverable error. - this.logger?.error('Recoverable error', res.status); + this.logger?.error(httpErrorMessage(err, 'polling request', 'will retry')); } } catch (err) { - // TODO: Something. - this.logger?.error('[Polling] Error:', err); + this.logger?.error( + httpErrorMessage(err as HttpErrorResponse, 'polling request', 'will retry'), + ); } const elapsed = Date.now() - startTime; @@ -109,8 +122,6 @@ export default class PollingProcessor implements subsystem.LDStreamProcessor { this.logger?.debug('Elapsed: %d ms, sleeping for %d ms', elapsed, sleepFor); - // Falling through, there was some type of error and we need to trigger - // a new poll. this.timeoutHandle = setTimeout(() => { this.poll(); }, sleepFor); 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/fetchFlags.test.ts b/packages/shared/sdk-client/src/polling/fetchFlags.test.ts deleted file mode 100644 index 07a6b98467..0000000000 --- a/packages/shared/sdk-client/src/polling/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/polling/fetchFlags.ts b/packages/shared/sdk-client/src/polling/fetchFlags.ts deleted file mode 100644 index 755de56b8c..0000000000 --- a/packages/shared/sdk-client/src/polling/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/polling/fetchUtils.test.ts b/packages/shared/sdk-client/src/polling/fetchUtils.test.ts deleted file mode 100644 index be06225fbd..0000000000 --- a/packages/shared/sdk-client/src/polling/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/polling/fetchUtils.ts b/packages/shared/sdk-client/src/polling/fetchUtils.ts deleted file mode 100644 index ea415ee645..0000000000 --- a/packages/shared/sdk-client/src/polling/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/polling/mockResponseWithReasons.json b/packages/shared/sdk-client/src/polling/mockResponseWithReasons.json deleted file mode 100644 index 0e198ad32b..0000000000 --- a/packages/shared/sdk-client/src/polling/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" } - } -} From 08c4a64017f6b65adac3431aef022f6ede05efb3 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 31 Jul 2024 08:47:09 -0700 Subject: [PATCH 16/21] Lint --- packages/shared/sdk-client/src/configuration/validators.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/sdk-client/src/configuration/validators.ts b/packages/shared/sdk-client/src/configuration/validators.ts index 4c3fc1dac8..4e3232674c 100644 --- a/packages/shared/sdk-client/src/configuration/validators.ts +++ b/packages/shared/sdk-client/src/configuration/validators.ts @@ -1,5 +1,5 @@ // eslint-disable-next-line max-classes-per-file -import { noop, Type, TypeValidator, TypeValidators } from '@launchdarkly/js-sdk-common'; +import { noop, TypeValidator, TypeValidators } from '@launchdarkly/js-sdk-common'; import { type LDOptions } from '../api'; import { LDInspection } from '../api/LDInspection'; From b6c26cb74b1681c5a7650b493882e3b605357d0e Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 31 Jul 2024 09:30:28 -0700 Subject: [PATCH 17/21] Refactoring. --- .../src/polling/PollingProcessor.ts | 50 +++++++-------- .../sdk-client/src/polling/Requestor.ts | 63 +++++++++++++++++++ 2 files changed, 84 insertions(+), 29 deletions(-) create mode 100644 packages/shared/sdk-client/src/polling/Requestor.ts diff --git a/packages/shared/sdk-client/src/polling/PollingProcessor.ts b/packages/shared/sdk-client/src/polling/PollingProcessor.ts index e0223db58a..3f38c923ed 100644 --- a/packages/shared/sdk-client/src/polling/PollingProcessor.ts +++ b/packages/shared/sdk-client/src/polling/PollingProcessor.ts @@ -13,6 +13,7 @@ import { } from '@launchdarkly/js-sdk-common'; import { Flags } from '../types'; +import Requestor, { LDRequestError } from './Requestor'; export type PollingErrorHandler = (err: LDPollingError) => void; @@ -47,11 +48,12 @@ export default class PollingProcessor implements subsystem.LDStreamProcessor { private timeoutHandle: any; private uri: string; - private verb: string; + + private requestor: Requestor; constructor( sdkKey: string, - private requests: Requests, + requests: Requests, info: Info, uriPath: string, config: PollingConfig, @@ -61,10 +63,11 @@ export default class PollingProcessor implements subsystem.LDStreamProcessor { this.uri = `${config.serviceEndpoints.polling}${uriPath}`; this.logger = config.logger; - this.requests = requests; + this.pollInterval = config.pollInterval; this.headers = defaultHeaders(sdkKey, info, config.tags); - this.verb = config.useReport ? 'REPORT' : 'GET'; + + this.requestor = new Requestor(sdkKey, requests, info, this.uri, config.useReport, config.tags); } private async poll() { @@ -81,37 +84,26 @@ export default class PollingProcessor implements subsystem.LDStreamProcessor { this.logger?.debug('Polling LaunchDarkly for feature flag updates'); const startTime = Date.now(); try { - const res = await this.requests.fetch(this.uri, { - method: this.verb, - headers: this.headers, - }); - - if (isOk(res.status)) { - const body = await res.text(); - + const res = await this.requestor.requestPayload(); + try { + const flags = JSON.parse(res); try { - const flags = JSON.parse(body); - this.dataHandler(flags); + this.dataHandler?.(flags); } catch { - reportJsonError(body); + // TODO: Data handler threw. } - } else { - const err = { - message: `Unexpected status code: ${res.status}`, - status: res.status, - }; - if (!isHttpRecoverable(res.status)) { - const message = httpErrorMessage(err, 'polling request'); - this.logger?.error(message); - this.errorHandler?.(new LDPollingError(message, res.status)); - // It is not recoverable, return and do not trigger another - // poll. + } 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; } - // Recoverable error. - this.logger?.error(httpErrorMessage(err, 'polling request', 'will retry')); } - } catch (err) { this.logger?.error( httpErrorMessage(err as HttpErrorResponse, 'polling request', 'will retry'), ); 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); + } +} From fb6e26550cbd2210eeb1938db4ffae9fd09d9bd7 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 31 Jul 2024 09:31:28 -0700 Subject: [PATCH 18/21] Address todo --- packages/shared/sdk-client/src/polling/PollingProcessor.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/sdk-client/src/polling/PollingProcessor.ts b/packages/shared/sdk-client/src/polling/PollingProcessor.ts index 3f38c923ed..fc878a59c0 100644 --- a/packages/shared/sdk-client/src/polling/PollingProcessor.ts +++ b/packages/shared/sdk-client/src/polling/PollingProcessor.ts @@ -89,8 +89,8 @@ export default class PollingProcessor implements subsystem.LDStreamProcessor { const flags = JSON.parse(res); try { this.dataHandler?.(flags); - } catch { - // TODO: Data handler threw. + } catch (err) { + this.logger?.error(`Exception from data handler: ${err}`); } } catch { reportJsonError(res); From 9b9d8a75bd718b4513eab729e92a129e2b7db86b Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 31 Jul 2024 09:35:18 -0700 Subject: [PATCH 19/21] Linting --- .../sdk-client/src/polling/PollingProcessor.ts | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/packages/shared/sdk-client/src/polling/PollingProcessor.ts b/packages/shared/sdk-client/src/polling/PollingProcessor.ts index fc878a59c0..b7d6941df1 100644 --- a/packages/shared/sdk-client/src/polling/PollingProcessor.ts +++ b/packages/shared/sdk-client/src/polling/PollingProcessor.ts @@ -17,10 +17,6 @@ import Requestor, { LDRequestError } from './Requestor'; export type PollingErrorHandler = (err: LDPollingError) => void; -function isOk(status: number) { - return status >= 200 && status <= 299; -} - /** * Subset of configuration required for polling. * @@ -38,7 +34,6 @@ export type PollingConfig = { * @internal */ export default class PollingProcessor implements subsystem.LDStreamProcessor { - private readonly headers: { [key: string]: string }; private stopped = false; private logger?: LDLogger; @@ -47,8 +42,6 @@ export default class PollingProcessor implements subsystem.LDStreamProcessor { private timeoutHandle: any; - private uri: string; - private requestor: Requestor; constructor( @@ -60,14 +53,11 @@ export default class PollingProcessor implements subsystem.LDStreamProcessor { private readonly dataHandler: (flags: Flags) => void, private readonly errorHandler?: PollingErrorHandler, ) { - this.uri = `${config.serviceEndpoints.polling}${uriPath}`; - + const uri = `${config.serviceEndpoints.polling}${uriPath}`; this.logger = config.logger; - this.pollInterval = config.pollInterval; - this.headers = defaultHeaders(sdkKey, info, config.tags); - this.requestor = new Requestor(sdkKey, requests, info, this.uri, config.useReport, config.tags); + this.requestor = new Requestor(sdkKey, requests, info, uri, config.useReport, config.tags); } private async poll() { From a515049569746f121ac36b211fc8f0f3c06d8328 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 31 Jul 2024 09:38:25 -0700 Subject: [PATCH 20/21] More lint. --- packages/shared/sdk-client/src/polling/PollingProcessor.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/shared/sdk-client/src/polling/PollingProcessor.ts b/packages/shared/sdk-client/src/polling/PollingProcessor.ts index b7d6941df1..23fe35770a 100644 --- a/packages/shared/sdk-client/src/polling/PollingProcessor.ts +++ b/packages/shared/sdk-client/src/polling/PollingProcessor.ts @@ -1,6 +1,5 @@ import { ApplicationTags, - defaultHeaders, httpErrorMessage, HttpErrorResponse, Info, From 20ba4ffa986195341b52fa41d5750f5e83b54a6c Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 31 Jul 2024 09:53:21 -0700 Subject: [PATCH 21/21] Add some internal tags. --- .../sdk/react-native/src/RNStateDetector.ts | 6 ++++++ .../src/platform/ConnectionManager.ts | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+) 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/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;