diff --git a/src/__tests__/LDClient-plugins-test.js b/src/__tests__/LDClient-plugins-test.js new file mode 100644 index 0000000..fcc690b --- /dev/null +++ b/src/__tests__/LDClient-plugins-test.js @@ -0,0 +1,214 @@ +const { initialize } = require('../index'); +const stubPlatform = require('./stubPlatform'); +const { respondJson } = require('./mockHttp'); + +// Mock the logger functions +const mockLogger = () => ({ + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), +}); + +// Define a basic Hook structure for tests +const createTestHook = (name = 'Test Hook') => ({ + getMetadata: jest.fn().mockReturnValue({ name }), + beforeEvaluation: jest.fn().mockImplementation((_ctx, data) => data), + afterEvaluation: jest.fn().mockImplementation((_ctx, data) => data), + beforeIdentify: jest.fn().mockImplementation((_ctx, data) => data), + afterIdentify: jest.fn().mockImplementation((_ctx, data) => data), + afterTrack: jest.fn().mockImplementation((_ctx, data) => data), +}); + +// Define a basic Plugin structure for tests +const createTestPlugin = (name = 'Test Plugin', hooks = []) => ({ + getMetadata: jest.fn().mockReturnValue({ name }), + register: jest.fn(), + getHooks: jest.fn().mockReturnValue(hooks), +}); + +// Helper to initialize the client for tests +async function withClient(initialContext, configOverrides = {}, plugins = [], testFn) { + const platform = stubPlatform.defaults(); + const server = platform.testing.http.newServer(); + const logger = mockLogger(); + + // Disable streaming and event sending unless overridden + const defaults = { + baseUrl: server.url, + streaming: false, + sendEvents: false, + useLdd: false, + logger: logger, + plugins: plugins, + }; + const config = { ...defaults, ...configOverrides }; + const { client, start } = initialize('env', initialContext, config, platform); + + server.byDefault(respondJson({})); + start(); + + try { + await client.waitForInitialization(10); + await testFn(client, logger, platform); + } finally { + await client.close(); + server.close(); + } +} + +it('registers plugins and executes hooks during initialization', async () => { + const mockHook = createTestHook('test-hook'); + const mockPlugin = createTestPlugin('test-plugin', [mockHook]); + + await withClient({ key: 'user-key', kind: 'user' }, {}, [mockPlugin], async client => { + // Verify the plugin was registered + expect(mockPlugin.register).toHaveBeenCalled(); + + // Test identify hook + await client.identify({ key: 'user-key', kind: 'user' }); + expect(mockHook.beforeIdentify).toHaveBeenCalledWith( + { context: { key: 'user-key', kind: 'user' }, timeout: undefined }, + {} + ); + expect(mockHook.afterIdentify).toHaveBeenCalledWith( + { context: { key: 'user-key', kind: 'user' }, timeout: undefined }, + {}, + { status: 'completed' } + ); + + // Test variation hook + client.variation('flag-key', false); + expect(mockHook.beforeEvaluation).toHaveBeenCalledWith( + { + context: { key: 'user-key', kind: 'user' }, + defaultValue: false, + flagKey: 'flag-key', + }, + {} + ); + expect(mockHook.afterEvaluation).toHaveBeenCalled(); + + // Test track hook + client.track('event-key', { data: true }, 42); + expect(mockHook.afterTrack).toHaveBeenCalledWith({ + context: { key: 'user-key', kind: 'user' }, + key: 'event-key', + data: { data: true }, + metricValue: 42, + }); + }); +}); + +it('registers multiple plugins and executes all hooks', async () => { + const mockHook1 = createTestHook('test-hook-1'); + const mockHook2 = createTestHook('test-hook-2'); + const mockPlugin1 = createTestPlugin('test-plugin-1', [mockHook1]); + const mockPlugin2 = createTestPlugin('test-plugin-2', [mockHook2]); + + await withClient({ key: 'user-key', kind: 'user' }, {}, [mockPlugin1, mockPlugin2], async client => { + // Verify plugins were registered + expect(mockPlugin1.register).toHaveBeenCalled(); + expect(mockPlugin2.register).toHaveBeenCalled(); + + // Test that both hooks work + await client.identify({ key: 'user-key', kind: 'user' }); + client.variation('flag-key', false); + client.track('event-key', { data: true }, 42); + + expect(mockHook1.beforeEvaluation).toHaveBeenCalled(); + expect(mockHook1.afterEvaluation).toHaveBeenCalled(); + expect(mockHook2.beforeEvaluation).toHaveBeenCalled(); + expect(mockHook2.afterEvaluation).toHaveBeenCalled(); + expect(mockHook1.afterTrack).toHaveBeenCalled(); + expect(mockHook2.afterTrack).toHaveBeenCalled(); + }); +}); + +it('passes correct environmentMetadata to plugin getHooks and register functions', async () => { + const mockPlugin = createTestPlugin('test-plugin'); + const options = { + wrapperName: 'test-wrapper', + wrapperVersion: '2.0.0', + application: { + name: 'test-app', + version: '3.0.0', + }, + }; + + await withClient( + { key: 'user-key', kind: 'user' }, + { ...options, plugins: [mockPlugin] }, + [mockPlugin], + async (client, logger, testPlatform) => { + expect(testPlatform.userAgent).toBeDefined(); + expect(testPlatform.version).toBeDefined(); + // Verify getHooks was called with correct environmentMetadata + expect(mockPlugin.getHooks).toHaveBeenCalledWith({ + sdk: { + name: testPlatform.userAgent, + version: testPlatform.version, + wrapperName: options.wrapperName, + wrapperVersion: options.wrapperVersion, + }, + application: { + id: options.application.id, + version: options.application.version, + }, + clientSideId: 'env', + }); + + // Verify register was called with correct environmentMetadata + expect(mockPlugin.register).toHaveBeenCalledWith( + expect.any(Object), // client + { + sdk: { + name: testPlatform.userAgent, + version: testPlatform.version, + wrapperName: options.wrapperName, + wrapperVersion: options.wrapperVersion, + }, + application: { + id: options.application.id, + version: options.application.version, + }, + clientSideId: 'env', + } + ); + } + ); +}); + +it('passes correct environmentMetadata without optional fields', async () => { + const mockPlugin = createTestPlugin('test-plugin'); + + await withClient( + { key: 'user-key', kind: 'user' }, + { plugins: [mockPlugin] }, + [mockPlugin], + async (client, logger, testPlatform) => { + expect(testPlatform.userAgent).toBeDefined(); + expect(testPlatform.version).toBeDefined(); + // Verify getHooks was called with correct environmentMetadata + expect(mockPlugin.getHooks).toHaveBeenCalledWith({ + sdk: { + name: testPlatform.userAgent, + version: testPlatform.version, + }, + clientSideId: 'env', + }); + + // Verify register was called with correct environmentMetadata + expect(mockPlugin.register).toHaveBeenCalledWith( + expect.any(Object), // client + { + sdk: { + name: testPlatform.userAgent, + version: testPlatform.version, + }, + clientSideId: 'env', + } + ); + } + ); +}); diff --git a/src/configuration.js b/src/configuration.js index 7907ddf..617f528 100644 --- a/src/configuration.js +++ b/src/configuration.js @@ -38,6 +38,7 @@ const baseOptionDefs = { application: { validator: applicationConfigValidator }, inspectors: { default: [] }, hooks: { default: [] }, + plugins: { default: [] }, }; /** diff --git a/src/index.js b/src/index.js index e02c7ee..86d6752 100644 --- a/src/index.js +++ b/src/index.js @@ -18,7 +18,7 @@ const { checkContext, getContextKeys } = require('./context'); const { InspectorTypes, InspectorManager } = require('./InspectorManager'); const timedPromise = require('./timedPromise'); const createHookRunner = require('./HookRunner'); - +const { getPluginHooks, registerPlugins, createPluginEnvironment } = require('./plugins'); const changeEvent = 'change'; const internalChangeEvent = 'internal-change'; const highTimeoutThreshold = 5; @@ -41,7 +41,13 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) { const sendEvents = options.sendEvents; let environment = env; let hash = options.hash; - const hookRunner = createHookRunner(logger, options.hooks); + const plugins = [...options.plugins]; + + const pluginEnvironment = createPluginEnvironment(platform, env, options); + + const pluginHooks = getPluginHooks(logger, pluginEnvironment, plugins); + + const hookRunner = createHookRunner(logger, [...options.hooks, ...pluginHooks]); const persistentStorage = PersistentStorage(platform.localStorage, logger); @@ -872,6 +878,8 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) { addHook: addHook, }; + registerPlugins(logger, pluginEnvironment, client, plugins); + return { client: client, // The client object containing all public methods. options: options, // The validated configuration object, including all defaults. diff --git a/src/plugins.js b/src/plugins.js new file mode 100644 index 0000000..561635b --- /dev/null +++ b/src/plugins.js @@ -0,0 +1,109 @@ +const UNKNOWN_PLUGIN_NAME = 'unknown plugin'; + +/** + * Safely gets the name of a plugin with error handling + * @param {{ error: (message: string) => void }} logger - The logger instance + * @param {{getMetadata: () => {name: string}}} plugin - Plugin object that may have a name property + * @returns {string} The plugin name or 'unknown' if not available + */ +function getPluginName(logger, plugin) { + try { + return plugin.getMetadata().name || UNKNOWN_PLUGIN_NAME; + } catch (error) { + logger.error(`Exception thrown getting metadata for plugin. Unable to get plugin name.`); + return UNKNOWN_PLUGIN_NAME; + } +} + +/** + * Safely retrieves hooks from plugins with error handling + * @param {Object} logger - The logger instance + * @param {Object} environmentMetadata - Metadata about the environment for plugin initialization + * @param {Array<{getHooks: (environmentMetadata: object) => Hook[]}>} plugins - Array of plugin objects that may implement getHooks + * @returns {Array} Array of hook objects collected from all plugins + */ +function getPluginHooks(logger, environmentMetadata, plugins) { + const hooks = []; + plugins.forEach(plugin => { + try { + const pluginHooks = plugin.getHooks?.(environmentMetadata); + if (pluginHooks === undefined) { + logger.error(`Plugin ${getPluginName(logger, plugin)} returned undefined from getHooks.`); + } else if (pluginHooks && pluginHooks.length > 0) { + hooks.push(...pluginHooks); + } + } catch (error) { + logger.error(`Exception thrown getting hooks for plugin ${getPluginName(logger, plugin)}. Unable to get hooks.`); + } + }); + return hooks; +} + +/** + * Registers plugins with the SDK + * @param {{ error: (message: string) => void }} logger - The logger instance + * @param {Object} environmentMetadata - Metadata about the environment for plugin initialization + * @param {Object} client - The SDK client instance + * @param {Array<{register: (client: object, environmentMetadata: object) => void}>} plugins - Array of plugin objects that implement register + */ +function registerPlugins(logger, environmentMetadata, client, plugins) { + plugins.forEach(plugin => { + try { + plugin.register(client, environmentMetadata); + } catch (error) { + logger.error(`Exception thrown registering plugin ${getPluginName(logger, plugin)}.`); + } + }); +} + +/** + * Creates a plugin environment object + * @param {{userAgent: string, version: string}} platform - The platform object + * @param {string} env - The environment + * @param {{application: {name: string, version: string}, wrapperName: string, wrapperVersion: string}} options - The options + * @returns {{sdk: {name: string, version: string, wrapperName: string, wrapperVersion: string}, application: {name: string, version: string}, clientSideId: string}} The plugin environment + */ +function createPluginEnvironment(platform, env, options) { + const pluginSdkMetadata = {}; + + if (platform.userAgent) { + pluginSdkMetadata.name = platform.userAgent; + } + if (platform.version) { + pluginSdkMetadata.version = platform.version; + } + if (options.wrapperName) { + pluginSdkMetadata.wrapperName = options.wrapperName; + } + if (options.wrapperVersion) { + pluginSdkMetadata.wrapperVersion = options.wrapperVersion; + } + + const pluginApplicationMetadata = {}; + + if (options.application) { + if (options.application.name) { + pluginApplicationMetadata.name = options.application.name; + } + if (options.application.version) { + pluginApplicationMetadata.version = options.application.version; + } + } + + const pluginEnvironment = { + sdk: pluginSdkMetadata, + clientSideId: env, + }; + + if (Object.keys(pluginApplicationMetadata).length > 0) { + pluginEnvironment.application = pluginApplicationMetadata; + } + + return pluginEnvironment; +} + +module.exports = { + getPluginHooks, + registerPlugins, + createPluginEnvironment, +}; diff --git a/test-types.ts b/test-types.ts index 956fb52..b27906f 100644 --- a/test-types.ts +++ b/test-types.ts @@ -27,6 +27,40 @@ var user: ld.LDContext = { }, privateAttributeNames: [ 'name', 'email' ] }; +const hook: ld.Hook = { + getMetadata: () => ({ + name: 'hook', + }), + + beforeEvaluation(hookContext: ld.EvaluationSeriesContext, data: ld.EvaluationSeriesData): ld.EvaluationSeriesData { + return data; + }, + afterEvaluation(hookContext: ld.EvaluationSeriesContext, data: ld.EvaluationSeriesData, detail: ld.LDEvaluationDetail): ld.EvaluationSeriesData { + return data; + }, + beforeIdentify(hookContext: ld.IdentifySeriesContext, data: ld.IdentifySeriesData): ld.IdentifySeriesData { + return data; + }, + afterIdentify(hookContext: ld.IdentifySeriesContext, data: ld.IdentifySeriesData, result: ld.IdentifySeriesResult): ld.IdentifySeriesData { + return data; + }, + + afterTrack(hookContext: ld.TrackSeriesContext): void { + } +}; + +const plugin: ld.LDPlugin = { + getMetadata: () => ({ + name: 'plugin', + }), + register(client: ld.LDClientBase, environmentMetadata: ld.LDPluginEnvironmentMetadata): void { + }, + + getHooks(metadata: ld.LDPluginEnvironmentMetadata): ld.Hook[] { + return []; + }, +}; + var logger: ld.LDLogger = ld.commonBasicLogger({ level: 'info' }); var allBaseOptions: ld.LDOptionsBase = { bootstrap: { }, @@ -48,7 +82,9 @@ var allBaseOptions: ld.LDOptionsBase = { application: { version: 'version', id: 'id' - } + }, + hooks: [ hook ], + plugins: [ plugin ] }; var client: ld.LDClientBase = {} as ld.LDClientBase; // wouldn't do this in real life, it's just so the following statements will compile diff --git a/typings.d.ts b/typings.d.ts index 536fe5a..ba882f1 100644 --- a/typings.d.ts +++ b/typings.d.ts @@ -250,6 +250,121 @@ declare module 'launchdarkly-js-sdk-common' { afterTrack?(hookContext: TrackSeriesContext): void; } + /** + * Meta-data about a plugin implementation. + * + * May be used in logs and analytics to identify the plugin. + */ + export interface LDPluginMetadata { + /** + * The name of the plugin. + */ + readonly name: string; + } + + /** + * Metadata about the SDK that is running the plugin. + */ + export interface LDPluginSdkMetadata { + /** + * The name of the SDK. + */ + readonly name: string; + + /** + * The version of the SDK. + */ + readonly version: string; + + /** + * If this is a wrapper SDK, then this is the name of the wrapper. + */ + readonly wrapperName?: string; + + /** + * If this is a wrapper SDK, then this is the version of the wrapper. + */ + readonly wrapperVersion?: string; + } + + /** + * Metadata about the application where the LaunchDarkly SDK is running. + */ + export interface LDPluginApplicationMetadata { + /** + * A unique identifier representing the application where the LaunchDarkly SDK is running. + * + * This can be specified as any string value as long as it only uses the following characters: ASCII letters, + * ASCII digits, period, hyphen, underscore. A string containing any other characters will be ignored. + * + * Example: `authentication-service` + */ + readonly id?: string; + + /** + * A unique identifier representing the version of the application where the LaunchDarkly SDK is running. + * + * This can be specified as any string value as long as it only uses the following characters: ASCII letters, + * ASCII digits, period, hyphen, underscore. A string containing any other characters will be ignored. + * + * Example: `1.0.0` (standard version string) or `abcdef` (sha prefix) + */ + readonly version?: string; + } + + /** + * Metadata about the environment where the plugin is running. + */ + export interface LDPluginEnvironmentMetadata { + /** + * Metadata about the SDK that is running the plugin. + */ + readonly sdk: LDPluginSdkMetadata; + + /** + * Metadata about the application where the LaunchDarkly SDK is running. + * + * Only present if any application information is available. + */ + readonly application?: LDPluginApplicationMetadata; + + /** + * The client-side ID used to initialize the SDK. + */ + readonly clientSideId: string; + } + +/** + * Interface for plugins to the LaunchDarkly SDK. + */ +export interface LDPlugin { + /** + * Get metadata about the plugin. + */ + getMetadata(): LDPluginMetadata; + + /** + * Registers the plugin with the SDK. Called once during SDK initialization. + * + * The SDK initialization will typically not have been completed at this point, so the plugin should take appropriate + * actions to ensure the SDK is ready before sending track events or evaluating flags. + * + * @param client The SDK client instance. + * @param environmentMetadata Information about the environment where the plugin is running. + */ + register(client: LDClientBase, environmentMetadata: LDPluginEnvironmentMetadata): void; + + /** + * Gets a list of hooks that the plugin wants to register. + * + * This method will be called once during SDK initialization before the register method is called. + * + * If the plugin does not need to register any hooks, this method doesn't need to be implemented. + * @param metadata + */ + getHooks?(metadata: LDPluginEnvironmentMetadata): Hook[]; +} + /** * LaunchDarkly initialization options that are supported by all variants of the JS client. * The browser SDK and Electron SDK may support additional options. @@ -506,6 +621,13 @@ declare module 'launchdarkly-js-sdk-common' { * ``` */ hooks?: Hook[]; + + /** + * A list of plugins to be used with the SDK. + * + * Plugin support is currently experimental and subject to change. + */ + plugins?: LDPlugin[]; } /**