diff --git a/packages/shared/sdk-server/__tests__/options/Configuration.test.ts b/packages/shared/sdk-server/__tests__/options/Configuration.test.ts index 399439be73..dede3cc009 100644 --- a/packages/shared/sdk-server/__tests__/options/Configuration.test.ts +++ b/packages/shared/sdk-server/__tests__/options/Configuration.test.ts @@ -1,5 +1,14 @@ -import { LDOptions } from '../../src'; +import { + ClientContext, + DataSourceOptions, + isStandardOptions, + LDFeatureStore, + LDOptions, + PollingDataSourceOptions, + StandardDataSourceOptions, +} from '../../src'; import Configuration from '../../src/options/Configuration'; +import InMemoryFeatureStore from '../../src/store/InMemoryFeatureStore'; import TestLogger, { LogLevel } from '../Logger'; function withLogger(options: LDOptions): LDOptions { @@ -13,7 +22,7 @@ function logger(options: LDOptions): TestLogger { describe.each([undefined, null, 'potat0', 17, [], {}])('constructed without options', (input) => { it('should have default options', () => { // JavaScript is not going to stop you from calling this with whatever - // you want. So we need to tell TS to ingore our bad behavior. + // you want. So we need to tell TS to ignore our bad behavior. // @ts-ignore const config = new Configuration(input); @@ -25,19 +34,20 @@ describe.each([undefined, null, 'potat0', 17, [], {}])('constructed without opti expect(config.flushInterval).toBe(5); expect(config.logger).toBeUndefined(); expect(config.offline).toBe(false); - expect(config.pollInterval).toBe(30); + expect((config.dataSystem.dataSource as StandardDataSourceOptions).pollInterval).toEqual(30); expect(config.privateAttributes).toStrictEqual([]); expect(config.proxyOptions).toBeUndefined(); expect(config.sendEvents).toBe(true); expect(config.serviceEndpoints.streaming).toEqual('https://stream.launchdarkly.com'); expect(config.serviceEndpoints.polling).toEqual('https://sdk.launchdarkly.com'); expect(config.serviceEndpoints.events).toEqual('https://events.launchdarkly.com'); - expect(config.stream).toBe(true); - expect(config.streamInitialReconnectDelay).toEqual(1); + expect( + (config.dataSystem.dataSource as StandardDataSourceOptions).streamInitialReconnectDelay, + ).toEqual(1); expect(config.tags.value).toBeUndefined(); expect(config.timeout).toEqual(5); expect(config.tlsParams).toBeUndefined(); - expect(config.useLdd).toBe(false); + expect(config.dataSystem.useLdd).toBe(false); expect(config.wrapperName).toBeUndefined(); expect(config.wrapperVersion).toBeUndefined(); expect(config.hooks).toBeUndefined(); @@ -179,7 +189,9 @@ describe('when setting different options', () => { ])('allow setting and validates pollInterval', (value, expected, warnings) => { // @ts-ignore const config = new Configuration(withLogger({ pollInterval: value })); - expect(config.pollInterval).toEqual(expected); + expect((config.dataSystem.dataSource as StandardDataSourceOptions).pollInterval).toEqual( + expected, + ); expect(logger(config).getCount()).toEqual(warnings); }); @@ -207,7 +219,7 @@ describe('when setting different options', () => { ])('allows setting stream and validates stream', (value, expected, warnings) => { // @ts-ignore const config = new Configuration(withLogger({ stream: value })); - expect(config.stream).toEqual(expected); + expect(isStandardOptions(config.dataSystem.dataSource)).toEqual(expected); expect(logger(config).getCount()).toEqual(warnings); }); @@ -221,7 +233,7 @@ describe('when setting different options', () => { ])('allows setting stream and validates useLdd', (value, expected, warnings) => { // @ts-ignore const config = new Configuration(withLogger({ useLdd: value })); - expect(config.useLdd).toEqual(expected); + expect(config.dataSystem.useLdd).toEqual(expected); expect(logger(config).getCount()).toEqual(warnings); }); @@ -408,4 +420,125 @@ describe('when setting different options', () => { }, ]); }); + + it('drops invalid datasystem data source options and replaces with defaults', () => { + const config = new Configuration( + withLogger({ + dataSystem: { dataSource: { bogus: 'myBogusOptions' } as unknown as DataSourceOptions }, + }), + ); + expect(isStandardOptions(config.dataSystem.dataSource)).toEqual(true); + logger(config).expectMessages([ + { + level: LogLevel.Warn, + matches: /Config option "dataSource" should be of type DataSourceOptions/, + }, + ]); + }); + + it('validates the datasystem persistent store is a factory or object', () => { + const config1 = new Configuration( + withLogger({ + dataSystem: { + persistentStore: () => new InMemoryFeatureStore(), + }, + }), + ); + expect(isStandardOptions(config1.dataSystem.dataSource)).toEqual(true); + expect(logger(config1).getCount()).toEqual(0); + + const config2 = new Configuration( + withLogger({ + dataSystem: { + persistentStore: 'bogus type' as unknown as LDFeatureStore, + }, + }), + ); + expect(isStandardOptions(config2.dataSystem.dataSource)).toEqual(true); + logger(config2).expectMessages([ + { + level: LogLevel.Warn, + matches: /Config option "persistentStore" should be of type LDFeatureStore/, + }, + ]); + }); + + it('provides reasonable defaults when datasystem is provided, but some options are missing', () => { + const config = new Configuration( + withLogger({ + dataSystem: {}, + }), + ); + expect(isStandardOptions(config.dataSystem.dataSource)).toEqual(true); + expect(logger(config).getCount()).toEqual(0); + }); + + it('provides reasonable defaults within the dataSystem.dataSource options when they are missing', () => { + const config = new Configuration( + withLogger({ + dataSystem: { dataSource: { type: 'standard' } }, + }), + ); + expect(isStandardOptions(config.dataSystem.dataSource)).toEqual(true); + expect(logger(config).getCount()).toEqual(0); + }); + + it('ignores deprecated top level options when dataSystem.dataSource options are provided', () => { + const config = new Configuration( + withLogger({ + pollInterval: 501, // should be ignored + streamInitialReconnectDelay: 502, // should be ignored + dataSystem: { + dataSource: { type: 'standard', pollInterval: 100, streamInitialReconnectDelay: 200 }, // should be used + }, + }), + ); + expect(isStandardOptions(config.dataSystem.dataSource)).toEqual(true); + expect((config.dataSystem.dataSource as StandardDataSourceOptions).pollInterval).toEqual(100); + expect( + (config.dataSystem.dataSource as StandardDataSourceOptions).streamInitialReconnectDelay, + ).toEqual(200); + expect(logger(config).getCount()).toEqual(0); + }); + + it('ignores top level featureStore in favor of the datasystem persistent store', () => { + const shouldNotBeUsed = new InMemoryFeatureStore(); + const shouldBeUsed = new InMemoryFeatureStore(); + const config = new Configuration( + withLogger({ + featureStore: shouldNotBeUsed, + dataSystem: { + persistentStore: shouldBeUsed, + }, + }), + ); + // @ts-ignore + const result = config.dataSystem.featureStoreFactory(null); + expect(result).toEqual(shouldBeUsed); + }); + + it('ignores top level useLdd option if datasystem is specified', () => { + const config = new Configuration( + withLogger({ + dataSystem: { + persistentStore: new InMemoryFeatureStore(), + }, + useLdd: true, + }), + ); + const result = config.dataSystem.useLdd; + expect(result).toEqual(undefined); + + const config2 = new Configuration( + withLogger({ + dataSystem: { + persistentStore: new InMemoryFeatureStore(), + useLdd: true, + }, + useLdd: false, + }), + ); + const result2 = config2.dataSystem.useLdd; + expect(result2).toEqual(true); + }); }); diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index 44a355158e..f149306e48 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -30,6 +30,11 @@ import { import { Hook } from './api/integrations/Hook'; import { BigSegmentStoreMembership } from './api/interfaces'; import { LDWaitForInitializationOptions } from './api/LDWaitForInitializationOptions'; +import { + isPollingOnlyOptions, + isStandardOptions, + isStreamingOnlyOptions, +} from './api/options/LDDataSystemOptions'; import BigSegmentsManager from './BigSegmentsManager'; import BigSegmentStoreStatusProvider from './BigSegmentStatusProviderImpl'; import { createStreamListeners } from './data_sources/createStreamListeners'; @@ -166,7 +171,7 @@ export default class LDClientImpl implements LDClient { const baseHeaders = defaultHeaders(_sdkKey, _platform.info, config.tags); const clientContext = new ClientContext(_sdkKey, config, _platform); - const featureStore = config.featureStoreFactory(clientContext); + const featureStore = config.dataSystem.featureStoreFactory(clientContext); const dataSourceUpdates = new DataSourceUpdates(featureStore, hasEventListeners, onUpdate); @@ -219,29 +224,38 @@ export default class LDClientImpl implements LDClient { const listeners = createStreamListeners(dataSourceUpdates, this._logger, { put: () => this._initSuccess(), }); - const makeDefaultProcessor = () => - config.stream - ? new StreamingProcessor( - clientContext, - '/all', - [], - listeners, - baseHeaders, - this._diagnosticsManager, - (e) => this._dataSourceErrorHandler(e), - this._config.streamInitialReconnectDelay, - ) - : new PollingProcessor( - config, - new Requestor(config, this._platform.requests, baseHeaders), - dataSourceUpdates, - () => this._initSuccess(), - (e) => this._dataSourceErrorHandler(e), - ); + const makeDefaultProcessor = () => { + if (isPollingOnlyOptions(config.dataSystem.dataSource)) { + return new PollingProcessor( + new Requestor(config, this._platform.requests, baseHeaders), + config.dataSystem.dataSource.pollInterval ?? 30, + dataSourceUpdates, + config.logger, + () => this._initSuccess(), + (e) => this._dataSourceErrorHandler(e), + ); + } + // TODO: SDK-858 Hook up composite data source and config + const reconnectDelay = + isStandardOptions(config.dataSystem.dataSource) || + isStreamingOnlyOptions(config.dataSystem.dataSource) + ? config.dataSystem.dataSource.streamInitialReconnectDelay + : 1; + return new StreamingProcessor( + clientContext, + '/all', + [], + listeners, + baseHeaders, + this._diagnosticsManager, + (e) => this._dataSourceErrorHandler(e), + reconnectDelay, + ); + }; - if (!(config.offline || config.useLdd)) { + if (!(config.offline || config.dataSystem.useLdd)) { this._updateProcessor = - config.updateProcessorFactory?.( + config.dataSystem.updateProcessorFactory?.( clientContext, dataSourceUpdates, () => this._initSuccess(), diff --git a/packages/shared/sdk-server/src/api/options/LDDataSystemOptions.ts b/packages/shared/sdk-server/src/api/options/LDDataSystemOptions.ts new file mode 100644 index 0000000000..29335b52bf --- /dev/null +++ b/packages/shared/sdk-server/src/api/options/LDDataSystemOptions.ts @@ -0,0 +1,155 @@ +import { LDClientContext, subsystem } from '@launchdarkly/js-sdk-common'; + +import { LDDataSourceUpdates, LDFeatureStore } from '../subsystems'; +import { PersistentDataStore } from '../interfaces'; + +/** + * Configuration options for the Data System that the SDK uses to get and maintain flags and other + * data from LaunchDarkly and other sources. + * + * Example (Recommended): + * ```typescript + * let dataSystemOptions = { + * dataSource: { + * type: 'standard'; + * }, + * } + * + * Example (Polling with DynamoDB Persistent Store): + * ```typescript + * import { DynamoDBFeatureStore } from '@launchdarkly/node-server-sdk-dynamodb'; + * + * let dataSystemOptions = { + * dataSource: { + * type: 'pollingOnly'; + * pollInterval: 300; + * }, + * persistentStore: DynamoDBFeatureStore('your-table', { cacheTTL: 30 }); + * } + * const client = init('my-sdk-key', { hooks: [new TracingHook()] }); + * ``` + */ +export interface LDDataSystemOptions { + /** + * Configuration options for the Data Source that the SDK uses to get flags and other + * data from the LaunchDarkly servers. Choose one of {@link StandardDataSourceOptions}, + * {@link StreamingDataSourceOptions}, or {@link PollingDataSourceOptions}; setting the + * type and the optional fields you want to customize. + * + * If not specified, this defaults to using the {@link StandardDataSourceOptions} which + * performs a combination of streaming and polling. + * + * See {@link LDDataSystemOptions} documentation for examples. + */ + dataSource?: DataSourceOptions; + + /** + * Before data has arrived from LaunchDarkly, the SDK is able to evaluate flags using + * data from the persistent store. Once fresh data has arrived from LaunchDarkly, the + * SDK will no longer read from the persistent store, although it will keep it up-to-date + * for future startups. + * + * Some implementations provide the store implementation object itself, while others + * provide a factory function that creates the store implementation based on the SDK + * configuration; this property accepts either. + * + * @param clientContext whose properties may be used to influence creation of the persistent store. + */ + persistentStore?: LDFeatureStore | ((clientContext: LDClientContext) => LDFeatureStore); + + /** + * Whether you are using the LaunchDarkly relay proxy in daemon mode. + * + * In this configuration, the client will not connect to LaunchDarkly to get feature flags, + * but will instead get feature state from a database (Redis or another supported feature + * store integration) that is populated by the relay. By default, this is false. + */ + useLdd?: boolean; + + /** + * A component that obtains feature flag data and puts it in the feature store. Setting + * this supersedes {@link LDDataSystemOptions#dataSource}. + */ + updateProcessor?: + | object + | (( + clientContext: LDClientContext, + dataSourceUpdates: LDDataSourceUpdates, + initSuccessHandler: VoidFunction, + errorHandler?: (e: Error) => void, + ) => subsystem.LDStreamProcessor); + +} + +export type DataSourceOptions = + | StandardDataSourceOptions + | StreamingDataSourceOptions + | PollingDataSourceOptions; + +/** + * This standard data source is the recommended datasource for most customers. It will use + * a combination of streaming and polling to initialize the SDK, provide real time updates, + * and can switch between streaming and polling automatically to provide redundancy. + */ +export interface StandardDataSourceOptions { + type: 'standard'; + + /** + * Sets the initial reconnect delay for the streaming connection, in seconds. Default if omitted. + * + * The streaming service uses a backoff algorithm (with jitter) every time the connection needs + * to be reestablished. The delay for the first reconnection will start near this value, and then + * increase exponentially for any subsequent connection failures. + * + * The default value is 1. + */ + streamInitialReconnectDelay?: number; + + /** + * The time between polling requests, in seconds. Default if omitted. + */ + pollInterval?: number; +} + +/** + * This data source will make best effort to maintain a streaming connection to LaunchDarkly services + * to provide real time data updates. + */ +export interface StreamingDataSourceOptions { + type: 'streamingOnly'; + + /** + * Sets the initial reconnect delay for the streaming connection, in seconds. Default if omitted. + * + * The streaming service uses a backoff algorithm (with jitter) every time the connection needs + * to be reestablished. The delay for the first reconnection will start near this value, and then + * increase exponentially up to a maximum for any subsequent connection failures. + * + * The default value is 1. + */ + streamInitialReconnectDelay?: number; +} + +/** + * This data source will periodically make a request to LaunchDarkly services to retrieve updated data. + */ +export interface PollingDataSourceOptions { + type: 'pollingOnly'; + + /** + * The time between polling requests, in seconds. Default if omitted. + */ + pollInterval?: number; +} + +export function isStandardOptions(u: any): u is StandardDataSourceOptions { + return u.type === 'standard'; +} + +export function isStreamingOnlyOptions(u: any): u is StreamingDataSourceOptions { + return u.type === 'streamingOnly'; +} + +export function isPollingOnlyOptions(u: any): u is PollingDataSourceOptions { + return u.type === 'pollingOnly'; +} diff --git a/packages/shared/sdk-server/src/api/options/LDOptions.ts b/packages/shared/sdk-server/src/api/options/LDOptions.ts index 7697839730..b6d48f7be9 100644 --- a/packages/shared/sdk-server/src/api/options/LDOptions.ts +++ b/packages/shared/sdk-server/src/api/options/LDOptions.ts @@ -3,6 +3,7 @@ import { LDClientContext, LDLogger, subsystem, VoidFunction } from '@launchdarkl import { Hook } from '../integrations/Hook'; import { LDDataSourceUpdates, LDFeatureStore } from '../subsystems'; import { LDBigSegmentsOptions } from './LDBigSegmentsOptions'; +import { LDDataSystemOptions } from './LDDataSystemOptions'; import { LDProxyOptions } from './LDProxyOptions'; import { LDTLSOptions } from './LDTLSOptions'; @@ -60,6 +61,8 @@ export interface LDOptions { /** * A component that stores feature flags and related data received from LaunchDarkly. * + * If you specify the {@link LDOptions#dataSystem}, this setting will be ignored. + * * By default, this is an in-memory data structure. Database integrations are also * available, as described in the * [SDK features guide](https://docs.launchdarkly.com/sdk/concepts/data-stores). @@ -70,6 +73,37 @@ export interface LDOptions { */ featureStore?: LDFeatureStore | ((clientContext: LDClientContext) => LDFeatureStore); + /** + * Configuration options for the Data System that the SDK uses to get and maintain flags and other + * data from LaunchDarkly and other sources. + * + * Setting this option supersedes + * + * Example (Recommended): + * ```typescript + * let dataSystemOptions = { + * dataSource: { + * type: 'standard'; + * // options can be customized here, though defaults are recommended + * }, + * } + * + * Example (Polling with DynamoDB Persistent Store): + * ```typescript + * import { DynamoDBFeatureStore } from '@launchdarkly/node-server-sdk-dynamodb'; + * + * let dataSystemOptions = { + * dataSource: { + * type: 'pollingOnly'; + * pollInterval: 300; + * }, + * persistentStore: DynamoDBFeatureStore('your-table', { cacheTTL: 30 }); + * } + * const client = init('my-sdk-key', { hooks: [new TracingHook()] }); + * ``` + */ + dataSystem?: LDDataSystemOptions; + /** * Additional parameters for configuring the SDK's Big Segments behavior. * @@ -86,7 +120,7 @@ export interface LDOptions { /** * A component that obtains feature flag data and puts it in the feature store. * - * By default, this is the client's default streaming or polling component. + * If you specify the {@link LDOptions#dataSystem}, this setting will be ignored. */ updateProcessor?: | object @@ -104,6 +138,8 @@ export interface LDOptions { /** * The time between polling requests, in seconds. Ignored in streaming mode. + * + * If you specify the {@link LDOptions#dataSystem}, this setting will be ignored. */ pollInterval?: number; @@ -120,6 +156,8 @@ export interface LDOptions { /** * Whether streaming mode should be used to receive flag updates. * + * If you specify the {@link LDOptions#dataSystem}, this setting will be ignored. + * * This is true by default. If you set it to false, the client will use polling. * Streaming should only be disabled on the advice of LaunchDarkly support. */ @@ -128,6 +166,8 @@ export interface LDOptions { /** * Sets the initial reconnect delay for the streaming connection, in seconds. * + * If you specify the {@link LDOptions#dataSystem}, this setting will be ignored. + * * The streaming service uses a backoff algorithm (with jitter) every time the connection needs * to be reestablished. The delay for the first reconnection will start near this value, and then * increase exponentially for any subsequent connection failures. @@ -139,6 +179,8 @@ export interface LDOptions { /** * Whether you are using the LaunchDarkly relay proxy in daemon mode. * + * If you specify the {@link LDOptions#dataSystem}, this setting will be ignored. + * * In this configuration, the client will not connect to LaunchDarkly to get feature flags, * but will instead get feature state from a database (Redis or another supported feature * store integration) that is populated by the relay. By default, this is false. @@ -151,7 +193,7 @@ export interface LDOptions { sendEvents?: boolean; /** - * Whether all context attributes (except the contexy key) should be marked as private, and + * Whether all context attributes (except the context key) should be marked as private, and * not sent to LaunchDarkly. * * By default, this is false. diff --git a/packages/shared/sdk-server/src/api/options/index.ts b/packages/shared/sdk-server/src/api/options/index.ts index 1e7b63de7b..44b81cc99c 100644 --- a/packages/shared/sdk-server/src/api/options/index.ts +++ b/packages/shared/sdk-server/src/api/options/index.ts @@ -3,3 +3,4 @@ export * from './LDOptions'; export * from './LDProxyOptions'; export * from './LDTLSOptions'; export * from './LDMigrationOptions'; +export * from './LDDataSystemOptions'; diff --git a/packages/shared/sdk-server/src/data_sources/PollingProcessor.ts b/packages/shared/sdk-server/src/data_sources/PollingProcessor.ts index d376b45354..fc5fda2d33 100644 --- a/packages/shared/sdk-server/src/data_sources/PollingProcessor.ts +++ b/packages/shared/sdk-server/src/data_sources/PollingProcessor.ts @@ -9,7 +9,6 @@ import { } from '@launchdarkly/js-sdk-common'; import { LDDataSourceUpdates } from '../api/subsystems'; -import Configuration from '../options/Configuration'; import { deserializePoll } from '../store'; import VersionedDataKinds from '../store/VersionedDataKinds'; import Requestor from './Requestor'; @@ -22,22 +21,16 @@ export type PollingErrorHandler = (err: LDPollingError) => void; export default class PollingProcessor implements subsystem.LDStreamProcessor { private _stopped = false; - private _logger?: LDLogger; - - private _pollInterval: number; - private _timeoutHandle: any; constructor( - config: Configuration, private readonly _requestor: Requestor, + private readonly _pollInterval: number, private readonly _featureStore: LDDataSourceUpdates, + private readonly _logger?: LDLogger, private readonly _initSuccessHandler: VoidFunction = () => {}, private readonly _errorHandler?: PollingErrorHandler, - ) { - this._logger = config.logger; - this._pollInterval = config.pollInterval; - } + ) {} private _poll() { if (this._stopped) { diff --git a/packages/shared/sdk-server/src/diagnostics/createDiagnosticsInitConfig.ts b/packages/shared/sdk-server/src/diagnostics/createDiagnosticsInitConfig.ts index 764efef6ea..a4892e73a9 100644 --- a/packages/shared/sdk-server/src/diagnostics/createDiagnosticsInitConfig.ts +++ b/packages/shared/sdk-server/src/diagnostics/createDiagnosticsInitConfig.ts @@ -1,6 +1,11 @@ import { Platform, secondsToMillis } from '@launchdarkly/js-sdk-common'; -import { LDFeatureStore } from '../api'; +import { + isPollingOnlyOptions, + isStandardOptions, + isStreamingOnlyOptions, + LDFeatureStore, +} from '../api'; import Configuration, { defaultValues } from '../options/Configuration'; const createDiagnosticsInitConfig = ( @@ -18,13 +23,23 @@ const createDiagnosticsInitConfig = ( connectTimeoutMillis: secondsToMillis(config.timeout), socketTimeoutMillis: secondsToMillis(config.timeout), eventsFlushIntervalMillis: secondsToMillis(config.flushInterval), - pollingIntervalMillis: secondsToMillis(config.pollInterval), - reconnectTimeMillis: secondsToMillis(config.streamInitialReconnectDelay), + // include polling interval if data source config has it + ...((isStandardOptions(config.dataSystem.dataSource) || + isPollingOnlyOptions(config.dataSystem.dataSource)) && + config.dataSystem.dataSource.pollInterval + ? { pollingIntervalMillis: config.dataSystem.dataSource.pollInterval } + : null), + // include reconnect delay if data source config has it + ...((isStandardOptions(config.dataSystem.dataSource) || + isStreamingOnlyOptions(config.dataSystem.dataSource)) && + config.dataSystem.dataSource.streamInitialReconnectDelay + ? { reconnectTimeMillis: config.dataSystem.dataSource.streamInitialReconnectDelay } + : null), contextKeysFlushIntervalMillis: secondsToMillis(config.contextKeysFlushInterval), diagnosticRecordingIntervalMillis: secondsToMillis(config.diagnosticRecordingInterval), - streamingDisabled: !config.stream, - usingRelayDaemon: config.useLdd, + streamingDisabled: isPollingOnlyOptions(config.dataSystem.dataSource), + usingRelayDaemon: config.dataSystem.useLdd, offline: config.offline, allAttributesPrivate: config.allAttributesPrivate, contextKeysCapacity: config.contextKeysCapacity, diff --git a/packages/shared/sdk-server/src/options/Configuration.ts b/packages/shared/sdk-server/src/options/Configuration.ts index fe14a43f53..bfc3d79cd0 100644 --- a/packages/shared/sdk-server/src/options/Configuration.ts +++ b/packages/shared/sdk-server/src/options/Configuration.ts @@ -14,6 +14,16 @@ import { import { LDBigSegmentsOptions, LDOptions, LDProxyOptions, LDTLSOptions } from '../api'; import { Hook } from '../api/integrations'; +import { + DataSourceOptions, + isPollingOnlyOptions, + isStandardOptions, + isStreamingOnlyOptions, + LDDataSystemOptions, + PollingDataSourceOptions, + StandardDataSourceOptions, + StreamingDataSourceOptions, +} from '../api/options/LDDataSystemOptions'; import { LDDataSourceUpdates, LDFeatureStore } from '../api/subsystems'; import InMemoryFeatureStore from '../store/InMemoryFeatureStore'; import { ValidatedOptions } from './ValidatedOptions'; @@ -35,6 +45,7 @@ const validations: Record = { capacity: TypeValidators.Number, logger: TypeValidators.Object, featureStore: TypeValidators.ObjectOrFactory, + dataSystem: TypeValidators.Object, bigSegments: TypeValidators.Object, updateProcessor: TypeValidators.ObjectOrFactory, flushInterval: TypeValidators.Number, @@ -57,6 +68,30 @@ const validations: Record = { application: TypeValidators.Object, payloadFilterKey: TypeValidators.stringMatchingRegex(/^[a-zA-Z0-9](\w|\.|-)*$/), hooks: TypeValidators.createTypeArray('Hook[]', {}), + type: TypeValidators.String, +}; + +const DEFAULT_POLL_INTERVAL = 30; +const DEFAULT_STREAM_RECONNECT_DELAY = 1; + +const defaultStandardDataSourceOptions: StandardDataSourceOptions = { + type: 'standard', + streamInitialReconnectDelay: DEFAULT_STREAM_RECONNECT_DELAY, + pollInterval: DEFAULT_POLL_INTERVAL, +}; + +const defaultStreamingDataSourceOptions: StreamingDataSourceOptions = { + type: 'streamingOnly', + streamInitialReconnectDelay: DEFAULT_STREAM_RECONNECT_DELAY, +}; + +const defaultPollingDataSourceOptions: PollingDataSourceOptions = { + type: 'pollingOnly', + pollInterval: DEFAULT_POLL_INTERVAL, +}; + +const defaultDataSystemOptions = { + dataSource: defaultStandardDataSourceOptions, }; /** @@ -67,12 +102,12 @@ export const defaultValues: ValidatedOptions = { streamUri: 'https://stream.launchdarkly.com', eventsUri: ServiceEndpoints.DEFAULT_EVENTS, stream: true, - streamInitialReconnectDelay: 1, + streamInitialReconnectDelay: DEFAULT_STREAM_RECONNECT_DELAY, sendEvents: true, timeout: 5, capacity: 10000, flushInterval: 5, - pollInterval: 30, + pollInterval: DEFAULT_POLL_INTERVAL, offline: false, useLdd: false, allAttributesPrivate: false, @@ -82,14 +117,23 @@ export const defaultValues: ValidatedOptions = { diagnosticOptOut: false, diagnosticRecordingInterval: 900, featureStore: () => new InMemoryFeatureStore(), + dataSystem: defaultDataSystemOptions, +}; + +// General options type needed by validation algorithm. Specific types can be asserted after use. +type Options = { + [k: string]: any; }; -function validateTypesAndNames(options: LDOptions): { +function validateTypesAndNames( + options: Options, + defaults: Options, +): { errors: string[]; - validatedOptions: ValidatedOptions; + validatedOptions: Options; } { const errors: string[] = []; - const validatedOptions: ValidatedOptions = { ...defaultValues }; + const validatedOptions: Options = { ...defaults }; Object.keys(options).forEach((optionName) => { // We need to tell typescript it doesn't actually know what options are. // If we don't then it complains we are doing crazy things with it. @@ -123,7 +167,7 @@ function validateTypesAndNames(options: LDOptions): { return { errors, validatedOptions }; } -function validateEndpoints(options: LDOptions, validatedOptions: ValidatedOptions) { +function validateEndpoints(options: LDOptions, validatedOptions: Options) { const { baseUri, streamUri, eventsUri } = options; const streamingEndpointSpecified = streamUri !== undefined && streamUri !== null; const pollingEndpointSpecified = baseUri !== undefined && baseUri !== null; @@ -150,6 +194,91 @@ function validateEndpoints(options: LDOptions, validatedOptions: ValidatedOption } } +function validateDataSystemOptions(options: Options): { + errors: string[]; + validatedOptions: Options; +} { + const allErrors: string[] = []; + const validatedOptions: Options = { ...options }; + + if (options.persistentStore && !TypeValidators.ObjectOrFactory.is(options.persistentStore)) { + validatedOptions.persistentStore = undefined; // default is to not use this + allErrors.push( + OptionMessages.wrongOptionType( + 'persistentStore', + 'LDFeatureStore', + typeof options.persistentStore, + ), + ); + } + + if (options.updateProcessor && !TypeValidators.ObjectOrFactory.is(options.updateProcessor)) { + validatedOptions.updateProcessor = undefined; // default is to not use this + allErrors.push( + OptionMessages.wrongOptionType( + 'updateProcessor', + 'UpdateProcessor', + typeof options.updateProcessor, + ), + ); + } + + if (options.dataSource) { + let errors: string[]; + let validatedDataSourceOptions: Options; + if (isStandardOptions(options.dataSource)) { + ({ errors, validatedOptions: validatedDataSourceOptions } = validateTypesAndNames( + options.dataSource, + defaultStandardDataSourceOptions, + )); + } else if (isStreamingOnlyOptions(options.dataSource)) { + ({ errors, validatedOptions: validatedDataSourceOptions } = validateTypesAndNames( + options.dataSource, + defaultStreamingDataSourceOptions, + )); + } else if (isPollingOnlyOptions(options.dataSource)) { + ({ errors, validatedOptions: validatedDataSourceOptions } = validateTypesAndNames( + options.dataSource, + defaultPollingDataSourceOptions, + )); + } else { + // provided datasource options don't fit any expected form, drop them and use defaults + validatedDataSourceOptions = defaultStandardDataSourceOptions; + errors = [ + OptionMessages.wrongOptionType( + 'dataSource', + 'DataSourceOptions', + typeof options.dataSource, + ), + ]; + } + validatedOptions.dataSource = validatedDataSourceOptions; + allErrors.push(...errors); + } else { + // use default datasource options if no datasource was specified + validatedOptions.dataSource = defaultStandardDataSourceOptions; + } + + return { errors: allErrors, validatedOptions }; +} + +/** + * Configuration for the Data System + * + * @internal + */ +export interface DataSystemConfiguration { + dataSource?: DataSourceOptions; + featureStoreFactory: (clientContext: LDClientContext) => LDFeatureStore; + useLdd?: boolean; + updateProcessorFactory?: ( + clientContext: LDClientContext, + dataSourceUpdates: LDDataSourceUpdates, + initSuccessHandler: VoidFunction, + errorHandler?: (e: Error) => void, + ) => subsystem.LDStreamProcessor; +} + /** * Configuration options for the LDClient. * @@ -166,18 +295,10 @@ export default class Configuration { public readonly flushInterval: number; - public readonly pollInterval: number; - public readonly proxyOptions?: LDProxyOptions; public readonly offline: boolean; - public readonly stream: boolean; - - public readonly streamInitialReconnectDelay: number; - - public readonly useLdd: boolean; - public readonly sendEvents: boolean; public readonly allAttributesPrivate: boolean; @@ -202,14 +323,7 @@ export default class Configuration { public readonly diagnosticRecordingInterval: number; - public readonly featureStoreFactory: (clientContext: LDClientContext) => LDFeatureStore; - - public readonly updateProcessorFactory?: ( - clientContext: LDClientContext, - dataSourceUpdates: LDDataSourceUpdates, - initSuccessHandler: VoidFunction, - errorHandler?: (e: Error) => void, - ) => subsystem.LDStreamProcessor; + public readonly dataSystem: DataSystemConfiguration; public readonly bigSegments?: LDBigSegmentsOptions; @@ -223,13 +337,83 @@ export default class Configuration { // If there isn't a valid logger from the platform, then logs would go nowhere. this.logger = options.logger; - const { errors, validatedOptions } = validateTypesAndNames(options); + const { errors, validatedOptions: topLevelResult } = validateTypesAndNames( + options, + defaultValues, + ); + const validatedOptions = topLevelResult as ValidatedOptions; errors.forEach((error) => { this.logger?.warn(error); }); validateEndpoints(options, validatedOptions); + if (options.dataSystem) { + // validate the data system options, this will also apply reasonable defaults + const { errors: dsErrors, validatedOptions: dsResult } = validateDataSystemOptions( + options.dataSystem, + ); + const validatedDSOptions = dsResult as LDDataSystemOptions; + this.dataSystem = { + dataSource: validatedDSOptions.dataSource, + useLdd: validatedDSOptions.useLdd, + // TODO: Discuss typing error with Rlamb. This was existing before it seems. + // @ts-ignore + featureStoreFactory: (clientContext) => { + if (validatedDSOptions.persistentStore === undefined) { + // the persistent store provided was either undefined or invalid, default to memory store + return new InMemoryFeatureStore(); + } + if (TypeValidators.Function.is(validatedDSOptions.persistentStore)) { + return validatedDSOptions.persistentStore(clientContext); + } + return validatedDSOptions.persistentStore; + }, + // TODO: Discuss typing error with Rlamb. This was existing before it seems. + // @ts-ignore + updateProcessorFactory: TypeValidators.Function.is(validatedOptions.updateProcessor) + ? validatedOptions.updateProcessor + : () => validatedOptions.updateProcessor, + }; + dsErrors.forEach((error) => { + this.logger?.warn(error); + }); + } else { + // if data system is not specified, we will use the top level options + // that have been deprecated to make the data system configuration. + this.dataSystem = { + // pick data source based on the stream option + dataSource: + (options.stream ?? true) + ? { + // default to standard which has streaming support + type: 'standard', + streamInitialReconnectDelay: validatedOptions.streamInitialReconnectDelay, + pollInterval: validatedOptions.pollInterval, + } + : { + type: 'pollingOnly', + pollInterval: validatedOptions.pollInterval, + }, + useLdd: validatedOptions.useLdd, + /** + * TODO: Discuss typing error with Rlamb. This was existing before it seems. +Type '((LDFeatureStore | ((options: LDOptions) => LDFeatureStore)) & ((...args: any[]) => void)) | (() => LDFeatureStore | ((options: LDOptions) => LDFeatureStore))' is not assignable to type '((clientContext: LDClientContext) => LDFeatureStore) | undefined'. + Type 'LDFeatureStore & ((...args: any[]) => void)' is not assignable to type '((clientContext: LDClientContext) => LDFeatureStore) | undefined'. + Type 'LDFeatureStore & ((...args: any[]) => void)' is not assignable to type '(clientContext: LDClientContext) => LDFeatureStore'. + Type 'void' is not assignable to type 'LDFeatureStore'. + */ + // @ts-ignore + featureStoreFactory: TypeValidators.Function.is(validatedOptions.featureStore) + ? validatedOptions.featureStore + : () => validatedOptions.featureStore, + // @ts-ignore + updateProcessorFactory: TypeValidators.Function.is(validatedOptions.updateProcessor) + ? validatedOptions.updateProcessor + : () => validatedOptions.updateProcessor, + }; + } + this.serviceEndpoints = new ServiceEndpoints( validatedOptions.streamUri, validatedOptions.baseUri, @@ -244,7 +428,6 @@ export default class Configuration { this.bigSegments = validatedOptions.bigSegments; this.flushInterval = validatedOptions.flushInterval; - this.pollInterval = validatedOptions.pollInterval; this.proxyOptions = validatedOptions.proxyOptions; this.sendEvents = validatedOptions.sendEvents; @@ -259,30 +442,8 @@ export default class Configuration { this.wrapperVersion = validatedOptions.wrapperVersion; this.tags = new ApplicationTags(validatedOptions); this.diagnosticRecordingInterval = validatedOptions.diagnosticRecordingInterval; + this.hooks = validatedOptions.hooks; this.offline = validatedOptions.offline; - this.stream = validatedOptions.stream; - this.streamInitialReconnectDelay = validatedOptions.streamInitialReconnectDelay; - this.useLdd = validatedOptions.useLdd; - - if (TypeValidators.Function.is(validatedOptions.updateProcessor)) { - // @ts-ignore - this.updateProcessorFactory = validatedOptions.updateProcessor; - } else { - // The processor is already created, just have the method return it. - // @ts-ignore - this.updateProcessorFactory = () => validatedOptions.updateProcessor; - } - - if (TypeValidators.Function.is(validatedOptions.featureStore)) { - // @ts-ignore - this.featureStoreFactory = validatedOptions.featureStore; - } else { - // The store is already created, just have the method return it. - // @ts-ignore - this.featureStoreFactory = () => validatedOptions.featureStore; - } - - this.hooks = validatedOptions.hooks; } } diff --git a/packages/shared/sdk-server/src/options/ValidatedOptions.ts b/packages/shared/sdk-server/src/options/ValidatedOptions.ts index ce9a58de9b..1e707954c8 100644 --- a/packages/shared/sdk-server/src/options/ValidatedOptions.ts +++ b/packages/shared/sdk-server/src/options/ValidatedOptions.ts @@ -2,6 +2,7 @@ import { LDLogger, subsystem } from '@launchdarkly/js-sdk-common'; import { LDBigSegmentsOptions, LDOptions, LDProxyOptions, LDTLSOptions } from '../api'; import { Hook } from '../api/integrations'; +import { LDDataSystemOptions } from '../api/options/LDDataSystemOptions'; import { LDFeatureStore } from '../api/subsystems'; /** @@ -22,7 +23,6 @@ export interface ValidatedOptions { flushInterval: number; pollInterval: number; offline: boolean; - useLdd: boolean; allAttributesPrivate: false; privateAttributes: string[]; contextKeysCapacity: number; @@ -30,6 +30,7 @@ export interface ValidatedOptions { diagnosticOptOut: boolean; diagnosticRecordingInterval: number; featureStore: LDFeatureStore | ((options: LDOptions) => LDFeatureStore); + dataSystem: LDDataSystemOptions; tlsParams?: LDTLSOptions; updateProcessor?: subsystem.LDStreamProcessor; wrapperName?: string;