Skip to content

Commit e0544c1

Browse files
authored
feat: Add support for plugins. (#124)
1 parent f7bebeb commit e0544c1

File tree

6 files changed

+493
-3
lines changed

6 files changed

+493
-3
lines changed
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
const { initialize } = require('../index');
2+
const stubPlatform = require('./stubPlatform');
3+
const { respondJson } = require('./mockHttp');
4+
5+
// Mock the logger functions
6+
const mockLogger = () => ({
7+
error: jest.fn(),
8+
warn: jest.fn(),
9+
info: jest.fn(),
10+
debug: jest.fn(),
11+
});
12+
13+
// Define a basic Hook structure for tests
14+
const createTestHook = (name = 'Test Hook') => ({
15+
getMetadata: jest.fn().mockReturnValue({ name }),
16+
beforeEvaluation: jest.fn().mockImplementation((_ctx, data) => data),
17+
afterEvaluation: jest.fn().mockImplementation((_ctx, data) => data),
18+
beforeIdentify: jest.fn().mockImplementation((_ctx, data) => data),
19+
afterIdentify: jest.fn().mockImplementation((_ctx, data) => data),
20+
afterTrack: jest.fn().mockImplementation((_ctx, data) => data),
21+
});
22+
23+
// Define a basic Plugin structure for tests
24+
const createTestPlugin = (name = 'Test Plugin', hooks = []) => ({
25+
getMetadata: jest.fn().mockReturnValue({ name }),
26+
register: jest.fn(),
27+
getHooks: jest.fn().mockReturnValue(hooks),
28+
});
29+
30+
// Helper to initialize the client for tests
31+
async function withClient(initialContext, configOverrides = {}, plugins = [], testFn) {
32+
const platform = stubPlatform.defaults();
33+
const server = platform.testing.http.newServer();
34+
const logger = mockLogger();
35+
36+
// Disable streaming and event sending unless overridden
37+
const defaults = {
38+
baseUrl: server.url,
39+
streaming: false,
40+
sendEvents: false,
41+
useLdd: false,
42+
logger: logger,
43+
plugins: plugins,
44+
};
45+
const config = { ...defaults, ...configOverrides };
46+
const { client, start } = initialize('env', initialContext, config, platform);
47+
48+
server.byDefault(respondJson({}));
49+
start();
50+
51+
try {
52+
await client.waitForInitialization(10);
53+
await testFn(client, logger, platform);
54+
} finally {
55+
await client.close();
56+
server.close();
57+
}
58+
}
59+
60+
it('registers plugins and executes hooks during initialization', async () => {
61+
const mockHook = createTestHook('test-hook');
62+
const mockPlugin = createTestPlugin('test-plugin', [mockHook]);
63+
64+
await withClient({ key: 'user-key', kind: 'user' }, {}, [mockPlugin], async client => {
65+
// Verify the plugin was registered
66+
expect(mockPlugin.register).toHaveBeenCalled();
67+
68+
// Test identify hook
69+
await client.identify({ key: 'user-key', kind: 'user' });
70+
expect(mockHook.beforeIdentify).toHaveBeenCalledWith(
71+
{ context: { key: 'user-key', kind: 'user' }, timeout: undefined },
72+
{}
73+
);
74+
expect(mockHook.afterIdentify).toHaveBeenCalledWith(
75+
{ context: { key: 'user-key', kind: 'user' }, timeout: undefined },
76+
{},
77+
{ status: 'completed' }
78+
);
79+
80+
// Test variation hook
81+
client.variation('flag-key', false);
82+
expect(mockHook.beforeEvaluation).toHaveBeenCalledWith(
83+
{
84+
context: { key: 'user-key', kind: 'user' },
85+
defaultValue: false,
86+
flagKey: 'flag-key',
87+
},
88+
{}
89+
);
90+
expect(mockHook.afterEvaluation).toHaveBeenCalled();
91+
92+
// Test track hook
93+
client.track('event-key', { data: true }, 42);
94+
expect(mockHook.afterTrack).toHaveBeenCalledWith({
95+
context: { key: 'user-key', kind: 'user' },
96+
key: 'event-key',
97+
data: { data: true },
98+
metricValue: 42,
99+
});
100+
});
101+
});
102+
103+
it('registers multiple plugins and executes all hooks', async () => {
104+
const mockHook1 = createTestHook('test-hook-1');
105+
const mockHook2 = createTestHook('test-hook-2');
106+
const mockPlugin1 = createTestPlugin('test-plugin-1', [mockHook1]);
107+
const mockPlugin2 = createTestPlugin('test-plugin-2', [mockHook2]);
108+
109+
await withClient({ key: 'user-key', kind: 'user' }, {}, [mockPlugin1, mockPlugin2], async client => {
110+
// Verify plugins were registered
111+
expect(mockPlugin1.register).toHaveBeenCalled();
112+
expect(mockPlugin2.register).toHaveBeenCalled();
113+
114+
// Test that both hooks work
115+
await client.identify({ key: 'user-key', kind: 'user' });
116+
client.variation('flag-key', false);
117+
client.track('event-key', { data: true }, 42);
118+
119+
expect(mockHook1.beforeEvaluation).toHaveBeenCalled();
120+
expect(mockHook1.afterEvaluation).toHaveBeenCalled();
121+
expect(mockHook2.beforeEvaluation).toHaveBeenCalled();
122+
expect(mockHook2.afterEvaluation).toHaveBeenCalled();
123+
expect(mockHook1.afterTrack).toHaveBeenCalled();
124+
expect(mockHook2.afterTrack).toHaveBeenCalled();
125+
});
126+
});
127+
128+
it('passes correct environmentMetadata to plugin getHooks and register functions', async () => {
129+
const mockPlugin = createTestPlugin('test-plugin');
130+
const options = {
131+
wrapperName: 'test-wrapper',
132+
wrapperVersion: '2.0.0',
133+
application: {
134+
name: 'test-app',
135+
version: '3.0.0',
136+
},
137+
};
138+
139+
await withClient(
140+
{ key: 'user-key', kind: 'user' },
141+
{ ...options, plugins: [mockPlugin] },
142+
[mockPlugin],
143+
async (client, logger, testPlatform) => {
144+
expect(testPlatform.userAgent).toBeDefined();
145+
expect(testPlatform.version).toBeDefined();
146+
// Verify getHooks was called with correct environmentMetadata
147+
expect(mockPlugin.getHooks).toHaveBeenCalledWith({
148+
sdk: {
149+
name: testPlatform.userAgent,
150+
version: testPlatform.version,
151+
wrapperName: options.wrapperName,
152+
wrapperVersion: options.wrapperVersion,
153+
},
154+
application: {
155+
id: options.application.id,
156+
version: options.application.version,
157+
},
158+
clientSideId: 'env',
159+
});
160+
161+
// Verify register was called with correct environmentMetadata
162+
expect(mockPlugin.register).toHaveBeenCalledWith(
163+
expect.any(Object), // client
164+
{
165+
sdk: {
166+
name: testPlatform.userAgent,
167+
version: testPlatform.version,
168+
wrapperName: options.wrapperName,
169+
wrapperVersion: options.wrapperVersion,
170+
},
171+
application: {
172+
id: options.application.id,
173+
version: options.application.version,
174+
},
175+
clientSideId: 'env',
176+
}
177+
);
178+
}
179+
);
180+
});
181+
182+
it('passes correct environmentMetadata without optional fields', async () => {
183+
const mockPlugin = createTestPlugin('test-plugin');
184+
185+
await withClient(
186+
{ key: 'user-key', kind: 'user' },
187+
{ plugins: [mockPlugin] },
188+
[mockPlugin],
189+
async (client, logger, testPlatform) => {
190+
expect(testPlatform.userAgent).toBeDefined();
191+
expect(testPlatform.version).toBeDefined();
192+
// Verify getHooks was called with correct environmentMetadata
193+
expect(mockPlugin.getHooks).toHaveBeenCalledWith({
194+
sdk: {
195+
name: testPlatform.userAgent,
196+
version: testPlatform.version,
197+
},
198+
clientSideId: 'env',
199+
});
200+
201+
// Verify register was called with correct environmentMetadata
202+
expect(mockPlugin.register).toHaveBeenCalledWith(
203+
expect.any(Object), // client
204+
{
205+
sdk: {
206+
name: testPlatform.userAgent,
207+
version: testPlatform.version,
208+
},
209+
clientSideId: 'env',
210+
}
211+
);
212+
}
213+
);
214+
});

src/configuration.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ const baseOptionDefs = {
3838
application: { validator: applicationConfigValidator },
3939
inspectors: { default: [] },
4040
hooks: { default: [] },
41+
plugins: { default: [] },
4142
};
4243

4344
/**

src/index.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const { checkContext, getContextKeys } = require('./context');
1818
const { InspectorTypes, InspectorManager } = require('./InspectorManager');
1919
const timedPromise = require('./timedPromise');
2020
const createHookRunner = require('./HookRunner');
21-
21+
const { getPluginHooks, registerPlugins, createPluginEnvironment } = require('./plugins');
2222
const changeEvent = 'change';
2323
const internalChangeEvent = 'internal-change';
2424
const highTimeoutThreshold = 5;
@@ -41,7 +41,13 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
4141
const sendEvents = options.sendEvents;
4242
let environment = env;
4343
let hash = options.hash;
44-
const hookRunner = createHookRunner(logger, options.hooks);
44+
const plugins = [...options.plugins];
45+
46+
const pluginEnvironment = createPluginEnvironment(platform, env, options);
47+
48+
const pluginHooks = getPluginHooks(logger, pluginEnvironment, plugins);
49+
50+
const hookRunner = createHookRunner(logger, [...options.hooks, ...pluginHooks]);
4551

4652
const persistentStorage = PersistentStorage(platform.localStorage, logger);
4753

@@ -872,6 +878,8 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
872878
addHook: addHook,
873879
};
874880

881+
registerPlugins(logger, pluginEnvironment, client, plugins);
882+
875883
return {
876884
client: client, // The client object containing all public methods.
877885
options: options, // The validated configuration object, including all defaults.

src/plugins.js

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
const UNKNOWN_PLUGIN_NAME = 'unknown plugin';
2+
3+
/**
4+
* Safely gets the name of a plugin with error handling
5+
* @param {{ error: (message: string) => void }} logger - The logger instance
6+
* @param {{getMetadata: () => {name: string}}} plugin - Plugin object that may have a name property
7+
* @returns {string} The plugin name or 'unknown' if not available
8+
*/
9+
function getPluginName(logger, plugin) {
10+
try {
11+
return plugin.getMetadata().name || UNKNOWN_PLUGIN_NAME;
12+
} catch (error) {
13+
logger.error(`Exception thrown getting metadata for plugin. Unable to get plugin name.`);
14+
return UNKNOWN_PLUGIN_NAME;
15+
}
16+
}
17+
18+
/**
19+
* Safely retrieves hooks from plugins with error handling
20+
* @param {Object} logger - The logger instance
21+
* @param {Object} environmentMetadata - Metadata about the environment for plugin initialization
22+
* @param {Array<{getHooks: (environmentMetadata: object) => Hook[]}>} plugins - Array of plugin objects that may implement getHooks
23+
* @returns {Array<Hook>} Array of hook objects collected from all plugins
24+
*/
25+
function getPluginHooks(logger, environmentMetadata, plugins) {
26+
const hooks = [];
27+
plugins.forEach(plugin => {
28+
try {
29+
const pluginHooks = plugin.getHooks?.(environmentMetadata);
30+
if (pluginHooks === undefined) {
31+
logger.error(`Plugin ${getPluginName(logger, plugin)} returned undefined from getHooks.`);
32+
} else if (pluginHooks && pluginHooks.length > 0) {
33+
hooks.push(...pluginHooks);
34+
}
35+
} catch (error) {
36+
logger.error(`Exception thrown getting hooks for plugin ${getPluginName(logger, plugin)}. Unable to get hooks.`);
37+
}
38+
});
39+
return hooks;
40+
}
41+
42+
/**
43+
* Registers plugins with the SDK
44+
* @param {{ error: (message: string) => void }} logger - The logger instance
45+
* @param {Object} environmentMetadata - Metadata about the environment for plugin initialization
46+
* @param {Object} client - The SDK client instance
47+
* @param {Array<{register: (client: object, environmentMetadata: object) => void}>} plugins - Array of plugin objects that implement register
48+
*/
49+
function registerPlugins(logger, environmentMetadata, client, plugins) {
50+
plugins.forEach(plugin => {
51+
try {
52+
plugin.register(client, environmentMetadata);
53+
} catch (error) {
54+
logger.error(`Exception thrown registering plugin ${getPluginName(logger, plugin)}.`);
55+
}
56+
});
57+
}
58+
59+
/**
60+
* Creates a plugin environment object
61+
* @param {{userAgent: string, version: string}} platform - The platform object
62+
* @param {string} env - The environment
63+
* @param {{application: {name: string, version: string}, wrapperName: string, wrapperVersion: string}} options - The options
64+
* @returns {{sdk: {name: string, version: string, wrapperName: string, wrapperVersion: string}, application: {name: string, version: string}, clientSideId: string}} The plugin environment
65+
*/
66+
function createPluginEnvironment(platform, env, options) {
67+
const pluginSdkMetadata = {};
68+
69+
if (platform.userAgent) {
70+
pluginSdkMetadata.name = platform.userAgent;
71+
}
72+
if (platform.version) {
73+
pluginSdkMetadata.version = platform.version;
74+
}
75+
if (options.wrapperName) {
76+
pluginSdkMetadata.wrapperName = options.wrapperName;
77+
}
78+
if (options.wrapperVersion) {
79+
pluginSdkMetadata.wrapperVersion = options.wrapperVersion;
80+
}
81+
82+
const pluginApplicationMetadata = {};
83+
84+
if (options.application) {
85+
if (options.application.name) {
86+
pluginApplicationMetadata.name = options.application.name;
87+
}
88+
if (options.application.version) {
89+
pluginApplicationMetadata.version = options.application.version;
90+
}
91+
}
92+
93+
const pluginEnvironment = {
94+
sdk: pluginSdkMetadata,
95+
clientSideId: env,
96+
};
97+
98+
if (Object.keys(pluginApplicationMetadata).length > 0) {
99+
pluginEnvironment.application = pluginApplicationMetadata;
100+
}
101+
102+
return pluginEnvironment;
103+
}
104+
105+
module.exports = {
106+
getPluginHooks,
107+
registerPlugins,
108+
createPluginEnvironment,
109+
};

0 commit comments

Comments
 (0)