diff --git a/packages/sdk/server-node/__tests__/LDClientNode.plugin.test.ts b/packages/sdk/server-node/__tests__/LDClientNode.plugin.test.ts new file mode 100644 index 0000000000..6801fd4424 --- /dev/null +++ b/packages/sdk/server-node/__tests__/LDClientNode.plugin.test.ts @@ -0,0 +1,259 @@ +import { integrations, LDContext, LDLogger } from '@launchdarkly/js-server-sdk-common'; + +import { LDOptions } from '../src/api/LDOptions'; +import { LDPlugin } from '../src/api/LDPlugin'; +import LDClientNode from '../src/LDClientNode'; +import NodeInfo from '../src/platform/NodeInfo'; + +// Test for plugin registration +it('registers plugins and executes hooks during initialization', async () => { + const logger: LDLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + const mockHook: integrations.Hook = { + getMetadata(): integrations.HookMetadata { + return { + name: 'test-hook', + }; + }, + beforeEvaluation: jest.fn(() => ({})), + afterEvaluation: jest.fn(() => ({})), + }; + + const mockPlugin: LDPlugin = { + getMetadata: () => ({ name: 'test-plugin' }), + register: jest.fn(), + getHooks: () => [mockHook], + }; + + const client = new LDClientNode('test', { offline: true, logger, plugins: [mockPlugin] }); + + // Verify the plugin was registered + expect(mockPlugin.register).toHaveBeenCalled(); + + // Now test that hooks work by calling identify and variation + const context: LDContext = { key: 'user-key', kind: 'user' }; + + await client.variation('flag-key', context, false); + + expect(mockHook.beforeEvaluation).toHaveBeenCalledWith( + { + context, + defaultValue: false, + flagKey: 'flag-key', + method: 'LDClient.variation', + environmentId: undefined, + }, + {}, + ); + + expect(mockHook.afterEvaluation).toHaveBeenCalled(); +}); + +// Test for multiple plugins with hooks +it('registers multiple plugins and executes all hooks', async () => { + const logger: LDLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + const mockHook1: integrations.Hook = { + getMetadata(): integrations.HookMetadata { + return { + name: 'test-hook-1', + }; + }, + beforeEvaluation: jest.fn(() => ({})), + afterEvaluation: jest.fn(() => ({})), + }; + + const mockHook2: integrations.Hook = { + getMetadata(): integrations.HookMetadata { + return { + name: 'test-hook-2', + }; + }, + beforeEvaluation: jest.fn(() => ({})), + afterEvaluation: jest.fn(() => ({})), + }; + + const mockPlugin1: LDPlugin = { + getMetadata: () => ({ name: 'test-plugin-1' }), + register: jest.fn(), + getHooks: () => [mockHook1], + }; + + const mockPlugin2: LDPlugin = { + getMetadata: () => ({ name: 'test-plugin-2' }), + register: jest.fn(), + getHooks: () => [mockHook2], + }; + + const client = new LDClientNode('test', { + offline: true, + logger, + plugins: [mockPlugin1, mockPlugin2], + }); + + // Verify plugins were registered + expect(mockPlugin1.register).toHaveBeenCalled(); + expect(mockPlugin2.register).toHaveBeenCalled(); + + // Test that both hooks work + const context: LDContext = { key: 'user-key', kind: 'user' }; + await client.variation('flag-key', context, false); + + expect(mockHook1.beforeEvaluation).toHaveBeenCalled(); + expect(mockHook1.afterEvaluation).toHaveBeenCalled(); + expect(mockHook2.beforeEvaluation).toHaveBeenCalled(); + expect(mockHook2.afterEvaluation).toHaveBeenCalled(); +}); + +it('passes correct environmentMetadata to plugin getHooks and register functions', async () => { + const logger: LDLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + const mockHook: integrations.Hook = { + getMetadata(): integrations.HookMetadata { + return { + name: 'test-hook', + }; + }, + beforeEvaluation: jest.fn(() => ({})), + afterEvaluation: jest.fn(() => ({})), + }; + + const mockPlugin: LDPlugin = { + getMetadata: () => ({ name: 'test-plugin' }), + register: jest.fn(), + getHooks: jest.fn(() => [mockHook]), + }; + + const options: LDOptions = { + wrapperName: 'test-wrapper', + wrapperVersion: '2.0.0', + application: { + id: 'test-app', + name: 'TestApp', + version: '3.0.0', + versionName: '3', + }, + offline: true, + logger, + plugins: [mockPlugin], + }; + + // eslint-disable-next-line no-new + new LDClientNode('test', options); + const platformInfo = new NodeInfo(options); + const sdkData = platformInfo.sdkData(); + expect(sdkData.name).toBeDefined(); + expect(sdkData.version).toBeDefined(); + + // Verify getHooks was called with correct environmentMetadata + expect(mockPlugin.getHooks).toHaveBeenCalledWith({ + sdk: { + name: sdkData.userAgentBase, + version: sdkData.version, + wrapperName: options.wrapperName, + wrapperVersion: options.wrapperVersion, + }, + application: { + id: options.application?.id, + name: options.application?.name, + version: options.application?.version, + versionName: options.application?.versionName, + }, + sdkKey: 'test', + }); + + // Verify register was called with correct environmentMetadata + expect(mockPlugin.register).toHaveBeenCalledWith( + expect.any(Object), // client + { + sdk: { + name: sdkData.userAgentBase, + version: sdkData.version, + wrapperName: options.wrapperName, + wrapperVersion: options.wrapperVersion, + }, + application: { + id: options.application?.id, + version: options.application?.version, + name: options.application?.name, + versionName: options.application?.versionName, + }, + sdkKey: 'test', + }, + ); +}); + +it('passes correct environmentMetadata without optional fields', async () => { + const logger: LDLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + const mockHook: integrations.Hook = { + getMetadata(): integrations.HookMetadata { + return { + name: 'test-hook', + }; + }, + beforeEvaluation: jest.fn(() => ({})), + afterEvaluation: jest.fn(() => ({})), + }; + + const mockPlugin: LDPlugin = { + getMetadata: () => ({ name: 'test-plugin' }), + register: jest.fn(), + getHooks: jest.fn(() => [mockHook]), + }; + + const options: LDOptions = { + offline: true, + logger, + plugins: [mockPlugin], + }; + + // eslint-disable-next-line no-new + new LDClientNode('test', options); + + const platformInfo = new NodeInfo(options); + const sdkData = platformInfo.sdkData(); + expect(sdkData.name).toBeDefined(); + expect(sdkData.version).toBeDefined(); + + // Verify getHooks was called with correct environmentMetadata + expect(mockPlugin.getHooks).toHaveBeenCalledWith({ + sdk: { + name: sdkData.userAgentBase, + version: sdkData.version, + }, + sdkKey: 'test', + }); + + // Verify register was called with correct environmentMetadata + expect(mockPlugin.register).toHaveBeenCalledWith( + expect.any(Object), // client + { + sdk: { + name: sdkData.userAgentBase, + version: sdkData.version, + }, + sdkKey: 'test', + }, + ); +}); diff --git a/packages/sdk/server-node/src/LDClientNode.ts b/packages/sdk/server-node/src/LDClientNode.ts index d6e322d8cc..0b8cbc3a0e 100644 --- a/packages/sdk/server-node/src/LDClientNode.ts +++ b/packages/sdk/server-node/src/LDClientNode.ts @@ -4,25 +4,25 @@ import { format } from 'util'; import { BasicLogger, + internal, LDClientImpl, - LDOptions, + LDPluginEnvironmentMetadata, SafeLogger, + TypeValidators, } from '@launchdarkly/js-server-sdk-common'; -import { BigSegmentStoreStatusProvider } from './api'; +import { BigSegmentStoreStatusProvider, LDClient } from './api'; +import { LDOptions } from './api/LDOptions'; +import { LDPlugin } from './api/LDPlugin'; import BigSegmentStoreStatusProviderNode from './BigSegmentsStoreStatusProviderNode'; -import { Emits } from './Emits'; import NodePlatform from './platform/NodePlatform'; -class ClientEmitter extends EventEmitter {} - /** * @ignore */ -class LDClientNode extends LDClientImpl { - emitter: EventEmitter; - +class LDClientNode extends LDClientImpl implements LDClient { bigSegmentStoreStatusProvider: BigSegmentStoreStatusProvider; + emitter: EventEmitter; constructor(sdkKey: string, options: LDOptions) { const fallbackLogger = new BasicLogger({ @@ -32,13 +32,26 @@ class LDClientNode extends LDClientImpl { formatter: format, }); - const emitter = new ClientEmitter(); - const logger = options.logger ? new SafeLogger(options.logger, fallbackLogger) : fallbackLogger; + const emitter = new EventEmitter(); + + const pluginValidator = TypeValidators.createTypeArray('LDPlugin', {}); + const plugins: LDPlugin[] = []; + if (options.plugins) { + if (pluginValidator.is(options.plugins)) { + plugins.push(...options.plugins); + } else { + logger.warn('Could not validate plugins.'); + } + } + + const baseOptions = { ...options, logger }; + delete baseOptions.plugins; + super( sdkKey, new NodePlatform({ ...options, logger }), - { ...options, logger }, + baseOptions, { onError: (err: Error) => { if (emitter.listenerCount('error')) { @@ -65,13 +78,92 @@ class LDClientNode extends LDClientImpl { name === 'update' || (typeof name === 'string' && name.startsWith('update:')), ), }, + { + getImplementationHooks: (environmentMetadata: LDPluginEnvironmentMetadata) => + internal.safeGetHooks(logger, environmentMetadata, plugins), + }, ); - this.emitter = emitter; + this.emitter = emitter; this.bigSegmentStoreStatusProvider = new BigSegmentStoreStatusProviderNode( this.bigSegmentStatusProviderInternal, ) as BigSegmentStoreStatusProvider; + + internal.safeRegisterPlugins(logger, this.environmentMetadata, this, plugins); + } + + // #region: EventEmitter + + on(eventName: string | symbol, listener: (...args: any[]) => void): this { + this.emitter.on(eventName, listener); + return this; + } + + addListener(eventName: string | symbol, listener: (...args: any[]) => void): this { + this.emitter.addListener(eventName, listener); + return this; } + + once(eventName: string | symbol, listener: (...args: any[]) => void): this { + this.emitter.once(eventName, listener); + return this; + } + + removeListener(eventName: string | symbol, listener: (...args: any[]) => void): this { + this.emitter.removeListener(eventName, listener); + return this; + } + + off(eventName: string | symbol, listener: (...args: any) => void): this { + this.emitter.off(eventName, listener); + return this; + } + + removeAllListeners(event?: string | symbol): this { + this.emitter.removeAllListeners(event); + return this; + } + + setMaxListeners(n: number): this { + this.emitter.setMaxListeners(n); + return this; + } + + getMaxListeners(): number { + return this.emitter.getMaxListeners(); + } + + listeners(eventName: string | symbol): Function[] { + return this.emitter.listeners(eventName); + } + + rawListeners(eventName: string | symbol): Function[] { + return this.emitter.rawListeners(eventName); + } + + emit(eventName: string | symbol, ...args: any[]): boolean { + return this.emitter.emit(eventName, args); + } + + listenerCount(eventName: string | symbol): number { + return this.emitter.listenerCount(eventName); + } + + prependListener(eventName: string | symbol, listener: (...args: any[]) => void): this { + this.emitter.prependListener(eventName, listener); + return this; + } + + prependOnceListener(eventName: string | symbol, listener: (...args: any[]) => void): this { + this.emitter.prependOnceListener(eventName, listener); + return this; + } + + eventNames(): (string | symbol)[] { + return this.emitter.eventNames(); + } + + // #endregion } -export default Emits(LDClientNode); +export default LDClientNode; diff --git a/packages/sdk/server-node/src/api/LDOptions.ts b/packages/sdk/server-node/src/api/LDOptions.ts new file mode 100644 index 0000000000..2a5decf164 --- /dev/null +++ b/packages/sdk/server-node/src/api/LDOptions.ts @@ -0,0 +1,19 @@ +import { LDOptions as LDOptionsCommon } from '@launchdarkly/js-server-sdk-common'; + +import { LDPlugin } from './LDPlugin'; + +/** + * LaunchDarkly initialization options. + * + * @privateRemarks + * The plugins implementation is SDK specific, so these options exist to extend the base options + * with the node specific plugin configuration. + */ +export interface LDOptions extends LDOptionsCommon { + /** + * A list of plugins to be used with the SDK. + * + * Plugin support is currently experimental and subject to change. + */ + plugins?: LDPlugin[]; +} diff --git a/packages/sdk/server-node/src/api/LDPlugin.ts b/packages/sdk/server-node/src/api/LDPlugin.ts new file mode 100644 index 0000000000..cc074943b6 --- /dev/null +++ b/packages/sdk/server-node/src/api/LDPlugin.ts @@ -0,0 +1,12 @@ +import { integrations, LDPluginBase } from '@launchdarkly/js-server-sdk-common'; + +import { LDClient } from './LDClient'; + +/** + * Interface for plugins to the LaunchDarkly SDK. + * + * @privateRemarks + * The plugin interface must be in the leaf-sdk implementations to ensure it uses the correct LDClient intrface. + * The LDClient in the shared server code doesn't match the LDClient interface of the individual SDKs. + */ +export interface LDPlugin extends LDPluginBase {} diff --git a/packages/sdk/server-node/src/index.ts b/packages/sdk/server-node/src/index.ts index d6e713d3d6..db9ef00199 100644 --- a/packages/sdk/server-node/src/index.ts +++ b/packages/sdk/server-node/src/index.ts @@ -8,17 +8,15 @@ * * @packageDocumentation */ -import { - BasicLogger, - BasicLoggerOptions, - LDLogger, - LDOptions, -} from '@launchdarkly/js-server-sdk-common'; +import { BasicLogger, BasicLoggerOptions, LDLogger } from '@launchdarkly/js-server-sdk-common'; import { LDClient } from './api/LDClient'; +import { LDOptions } from './api/LDOptions'; import LDClientImpl from './LDClientNode'; export * from '@launchdarkly/js-server-sdk-common'; +// Override common options with node specific options. +export { LDOptions }; // To replace the exports from `export *` we need to name them. // So the below exports replace them with the Node specific variants. diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index c785297da5..c7655564f8 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -11,6 +11,7 @@ import { LDEvaluationDetail, LDEvaluationDetailTyped, LDLogger, + LDPluginEnvironmentMetadata, LDTimeoutError, Platform, subsystem, @@ -39,6 +40,7 @@ import { } from './api/options/LDDataSystemOptions'; import BigSegmentsManager from './BigSegmentsManager'; import BigSegmentStoreStatusProvider from './BigSegmentStatusProviderImpl'; +import { createPluginEnvironmentMetadata } from './createPluginEnvironmentMetadata'; import { createPayloadListener } from './data_sources/createPayloadListenerFDv2'; import { createStreamListeners } from './data_sources/createStreamListeners'; import DataSourceUpdates from './data_sources/DataSourceUpdates'; @@ -64,6 +66,7 @@ import HookRunner from './hooks/HookRunner'; import MigrationOpEventToInputEvent from './MigrationOpEventConversion'; import MigrationOpTracker from './MigrationOpTracker'; import Configuration, { DEFAULT_POLL_INTERVAL } from './options/Configuration'; +import { ServerInternalOptions } from './options/ServerInternalOptions'; import VersionedDataKinds from './store/VersionedDataKinds'; const { ClientMessages, ErrorKinds, NullEventProcessor } = internal; @@ -106,6 +109,7 @@ function constructFDv1( callbacks: LDClientCallbacks, initSuccess: () => void, dataSourceErrorHandler: (e: any) => void, + hooks: Hook[], ): { config: Configuration; logger: LDLogger | undefined; @@ -121,7 +125,7 @@ function constructFDv1( } { const { onUpdate, hasEventListeners } = callbacks; - const hookRunner = new HookRunner(config.logger, config.hooks || []); + const hookRunner = new HookRunner(config.logger, hooks); if (!sdkKey && !config.offline) { throw new Error('You must configure the client with an SDK key'); @@ -234,6 +238,7 @@ function constructFDv2( config: Configuration, callbacks: LDClientCallbacks, initSuccess: () => void, + hooks: Hook[], ): { config: Configuration; logger: LDLogger | undefined; @@ -250,7 +255,7 @@ function constructFDv2( } { const { onUpdate, hasEventListeners } = callbacks; - const hookRunner = new HookRunner(config.logger, config.hooks || []); + const hookRunner = new HookRunner(config.logger, hooks); if (!sdkKey && !config.offline) { throw new Error('You must configure the client with an SDK key'); @@ -437,6 +442,11 @@ export default class LDClientImpl implements LDClient { private _hookRunner: HookRunner; + /** + * Derived classes need to use this data to register plugins. + */ + protected environmentMetadata: LDPluginEnvironmentMetadata; + public get logger(): LDLogger | undefined { return this._logger; } @@ -455,10 +465,20 @@ export default class LDClientImpl implements LDClient { private _platform: Platform, options: LDOptions, callbacks: LDClientCallbacks, - internalOptions?: internal.LDInternalOptions, + internalOptions?: ServerInternalOptions, ) { const config = new Configuration(options, internalOptions); + this.environmentMetadata = createPluginEnvironmentMetadata(_platform, _sdkKey, config); + + const hooks: Hook[] = []; + if (config.hooks) { + hooks.push(...config.hooks); + } + config.getImplementationHooks(this.environmentMetadata).forEach((hook) => { + hooks.push(hook); + }); + if (!config.dataSystem) { // setup for FDv1 ({ @@ -480,6 +500,7 @@ export default class LDClientImpl implements LDClient { callbacks, () => this._initSuccess(), (e) => this._dataSourceErrorHandler(e), + hooks, )); this.bigSegmentStatusProviderInternal = this._bigSegmentsManager @@ -509,7 +530,7 @@ export default class LDClientImpl implements LDClient { onError: this._onError, onFailed: this._onFailed, onReady: this._onReady, - } = constructFDv2(_sdkKey, _platform, config, callbacks, () => this._initSuccess())); + } = constructFDv2(_sdkKey, _platform, config, callbacks, () => this._initSuccess(), hooks)); this._featureStore = transactionalStore; this.bigSegmentStatusProviderInternal = this._bigSegmentsManager .statusProvider as BigSegmentStoreStatusProvider; diff --git a/packages/shared/sdk-server/src/createPluginEnvironmentMetadata.ts b/packages/shared/sdk-server/src/createPluginEnvironmentMetadata.ts new file mode 100644 index 0000000000..8b6005c0f6 --- /dev/null +++ b/packages/shared/sdk-server/src/createPluginEnvironmentMetadata.ts @@ -0,0 +1,35 @@ +import { LDPluginEnvironmentMetadata, Platform } from '@launchdarkly/js-sdk-common'; + +import Configuration from './options/Configuration'; + +/** + * Mutable utility type to allow building up a readonly object from a mutable one. + */ +type Mutable = { + -readonly [P in keyof T]: Mutable; +}; + +export function createPluginEnvironmentMetadata( + _platform: Platform, + _sdkKey: string, + config: Configuration, +) { + const environmentMetadata: Mutable = { + sdk: { + name: _platform.info.sdkData().userAgentBase!, + version: _platform.info.sdkData().version!, + }, + sdkKey: _sdkKey, + }; + + if (_platform.info.sdkData().wrapperName) { + environmentMetadata.sdk.wrapperName = _platform.info.sdkData().wrapperName; + } + if (_platform.info.sdkData().wrapperVersion) { + environmentMetadata.sdk.wrapperVersion = _platform.info.sdkData().wrapperVersion; + } + if (config.applicationInfo) { + environmentMetadata.application = config.applicationInfo; + } + return environmentMetadata; +} diff --git a/packages/shared/sdk-server/src/options/Configuration.ts b/packages/shared/sdk-server/src/options/Configuration.ts index 1b12ff6d10..5eb7bbc5a3 100644 --- a/packages/shared/sdk-server/src/options/Configuration.ts +++ b/packages/shared/sdk-server/src/options/Configuration.ts @@ -1,8 +1,8 @@ import { ApplicationTags, - internal, LDClientContext, LDLogger, + LDPluginEnvironmentMetadata, NumberWithMinimum, OptionMessages, ServiceEndpoints, @@ -30,6 +30,7 @@ import { LDTransactionalFeatureStore, } from '../api/subsystems'; import InMemoryFeatureStore from '../store/InMemoryFeatureStore'; +import { ServerInternalOptions } from './ServerInternalOptions'; import { ValidatedOptions } from './ValidatedOptions'; // Once things are internal to the implementation of the SDK we can depend on @@ -337,7 +338,18 @@ export default class Configuration { public readonly enableEventCompression: boolean; - constructor(options: LDOptions = {}, internalOptions: internal.LDInternalOptions = {}) { + public readonly getImplementationHooks: ( + environmentMetadata: LDPluginEnvironmentMetadata, + ) => Hook[]; + + public readonly applicationInfo?: { + id?: string; + version?: string; + name?: string; + versionName?: string; + }; + + constructor(options: LDOptions = {}, internalOptions: ServerInternalOptions = {}) { // The default will handle undefined, but not null. // Because we can be called from JS we need to be extra defensive. // eslint-disable-next-line no-param-reassign @@ -436,5 +448,7 @@ export default class Configuration { this.hooks = validatedOptions.hooks; this.enableEventCompression = validatedOptions.enableEventCompression; + this.getImplementationHooks = internalOptions.getImplementationHooks ?? (() => []); + this.applicationInfo = validatedOptions.application; } } diff --git a/packages/shared/sdk-server/src/options/ServerInternalOptions.ts b/packages/shared/sdk-server/src/options/ServerInternalOptions.ts new file mode 100644 index 0000000000..dd1de40357 --- /dev/null +++ b/packages/shared/sdk-server/src/options/ServerInternalOptions.ts @@ -0,0 +1,7 @@ +import { internal, LDPluginEnvironmentMetadata } from '@launchdarkly/js-sdk-common'; + +import { Hook } from '../integrations'; + +export interface ServerInternalOptions extends internal.LDInternalOptions { + getImplementationHooks?: (environmentMetadata: LDPluginEnvironmentMetadata) => Hook[]; +}