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