launchdarkly-js-sdk-common 5.5.0 → 5.6.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +7 -0
- package/package.json +1 -1
- package/src/HookRunner.js +37 -0
- package/src/__tests__/HookRunner-test.js +118 -0
- package/src/__tests__/LDClient-hooks-test.js +26 -0
- package/src/__tests__/LDClient-plugins-test.js +214 -0
- package/src/configuration.js +2 -0
- package/src/index.js +11 -2
- package/src/plugins.js +109 -0
- package/test-types.ts +37 -1
- package/typings.d.ts +154 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the `launchdarkly-js-sdk-common` package will be documented in this file. Changes that affect the dependent SDKs such as `launchdarkly-js-client-sdk` should also be logged in those projects, in the next release that uses the updated version of this package. This project adheres to [Semantic Versioning](http://semver.org).
|
|
4
4
|
|
|
5
|
+
## [5.5.1](https://github.com/launchdarkly/js-sdk-common/compare/5.5.0...5.5.1) (2025-04-25)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* Add hooks to default options to prevent warning. ([#121](https://github.com/launchdarkly/js-sdk-common/issues/121)) ([9c04081](https://github.com/launchdarkly/js-sdk-common/commit/9c04081e828d2dde84f800c139480e960b537cfe))
|
|
11
|
+
|
|
5
12
|
## [5.5.0](https://github.com/launchdarkly/js-sdk-common/compare/5.4.0...5.5.0) (2025-04-21)
|
|
6
13
|
|
|
7
14
|
|
package/package.json
CHANGED
package/src/HookRunner.js
CHANGED
|
@@ -3,6 +3,7 @@ const BEFORE_EVALUATION_STAGE_NAME = 'beforeEvaluation';
|
|
|
3
3
|
const AFTER_EVALUATION_STAGE_NAME = 'afterEvaluation';
|
|
4
4
|
const BEFORE_IDENTIFY_STAGE_NAME = 'beforeIdentify';
|
|
5
5
|
const AFTER_IDENTIFY_STAGE_NAME = 'afterIdentify';
|
|
6
|
+
const AFTER_TRACK_STAGE_NAME = 'afterTrack';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Safely executes a hook stage function, logging any errors.
|
|
@@ -125,6 +126,28 @@ function executeAfterIdentify(logger, hooks, hookContext, updatedData, result) {
|
|
|
125
126
|
}
|
|
126
127
|
}
|
|
127
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Executes the 'afterTrack' stage for all registered hooks in reverse order.
|
|
131
|
+
* @param {{ error: (message: string) => void }} logger The logger instance.
|
|
132
|
+
* @param {Array<{ afterTrack?: (hookContext: { context: object, data: object, metricValue: number }) => void }>} hooks The array of hook instances.
|
|
133
|
+
* @param {{ context: object, data: object, metricValue: number }} hookContext The context for the track operation.
|
|
134
|
+
* @returns {void}
|
|
135
|
+
*/
|
|
136
|
+
function executeAfterTrack(logger, hooks, hookContext) {
|
|
137
|
+
// This iterates in reverse, versus reversing a shallow copy of the hooks,
|
|
138
|
+
// for efficiency.
|
|
139
|
+
for (let hookIndex = hooks.length - 1; hookIndex >= 0; hookIndex -= 1) {
|
|
140
|
+
const hook = hooks[hookIndex];
|
|
141
|
+
tryExecuteStage(
|
|
142
|
+
logger,
|
|
143
|
+
AFTER_TRACK_STAGE_NAME,
|
|
144
|
+
getHookName(logger, hook),
|
|
145
|
+
() => hook?.afterTrack?.(hookContext),
|
|
146
|
+
undefined
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
128
151
|
/**
|
|
129
152
|
* Factory function to create a HookRunner instance.
|
|
130
153
|
* Manages the execution of hooks for flag evaluations and identify operations.
|
|
@@ -203,10 +226,24 @@ function createHookRunner(logger, initialHooks) {
|
|
|
203
226
|
hooksInternal.push(hook);
|
|
204
227
|
}
|
|
205
228
|
|
|
229
|
+
/**
|
|
230
|
+
* Executes the 'afterTrack' stage for all registered hooks in reverse order.
|
|
231
|
+
* @param {{ context: object, data: object, metricValue: number }} hookContext The context for the track operation.
|
|
232
|
+
* @returns {void}
|
|
233
|
+
*/
|
|
234
|
+
function afterTrack(hookContext) {
|
|
235
|
+
if (hooksInternal.length === 0) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const hooks = [...hooksInternal];
|
|
239
|
+
executeAfterTrack(logger, hooks, hookContext);
|
|
240
|
+
}
|
|
241
|
+
|
|
206
242
|
return {
|
|
207
243
|
withEvaluation,
|
|
208
244
|
identify,
|
|
209
245
|
addHook,
|
|
246
|
+
afterTrack,
|
|
210
247
|
};
|
|
211
248
|
}
|
|
212
249
|
|
|
@@ -16,6 +16,7 @@ const createTestHook = (name = 'Test Hook') => ({
|
|
|
16
16
|
afterEvaluation: jest.fn(),
|
|
17
17
|
beforeIdentify: jest.fn(),
|
|
18
18
|
afterIdentify: jest.fn(),
|
|
19
|
+
afterTrack: jest.fn(),
|
|
19
20
|
});
|
|
20
21
|
|
|
21
22
|
describe('Given a logger, runner, and hook', () => {
|
|
@@ -328,4 +329,121 @@ describe('Given a logger, runner, and hook', () => {
|
|
|
328
329
|
`An error was encountered in "beforeEvaluation" of the "${hookName}" hook: Error: Specific test error`
|
|
329
330
|
);
|
|
330
331
|
});
|
|
332
|
+
|
|
333
|
+
it('should execute afterTrack hooks', () => {
|
|
334
|
+
const context = { kind: 'user', key: 'user-123' };
|
|
335
|
+
const key = 'test';
|
|
336
|
+
const data = { test: 'data' };
|
|
337
|
+
const metricValue = 42;
|
|
338
|
+
|
|
339
|
+
const trackContext = {
|
|
340
|
+
key,
|
|
341
|
+
context,
|
|
342
|
+
data,
|
|
343
|
+
metricValue,
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
hookRunner.afterTrack(trackContext);
|
|
347
|
+
|
|
348
|
+
expect(testHook.afterTrack).toHaveBeenCalledWith(trackContext);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('should handle errors in afterTrack hooks', () => {
|
|
352
|
+
const errorHook = {
|
|
353
|
+
getMetadata: jest.fn().mockReturnValue({ name: 'Error Hook' }),
|
|
354
|
+
afterTrack: jest.fn().mockImplementation(() => {
|
|
355
|
+
throw new Error('Hook error');
|
|
356
|
+
}),
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
const errorHookRunner = createHookRunner(logger, [errorHook]);
|
|
360
|
+
|
|
361
|
+
errorHookRunner.afterTrack({
|
|
362
|
+
key: 'test',
|
|
363
|
+
context: { kind: 'user', key: 'user-123' },
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
367
|
+
expect.stringContaining('An error was encountered in "afterTrack" of the "Error Hook" hook: Error: Hook error')
|
|
368
|
+
);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('should skip afterTrack execution if there are no hooks', () => {
|
|
372
|
+
const emptyHookRunner = createHookRunner(logger, []);
|
|
373
|
+
|
|
374
|
+
emptyHookRunner.afterTrack({
|
|
375
|
+
key: 'test',
|
|
376
|
+
context: { kind: 'user', key: 'user-123' },
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
expect(logger.error).not.toHaveBeenCalled();
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('executes hook stages in the specified order', () => {
|
|
383
|
+
const beforeEvalOrder = [];
|
|
384
|
+
const afterEvalOrder = [];
|
|
385
|
+
const beforeIdentifyOrder = [];
|
|
386
|
+
const afterIdentifyOrder = [];
|
|
387
|
+
const afterTrackOrder = [];
|
|
388
|
+
|
|
389
|
+
const createMockHook = id => ({
|
|
390
|
+
getMetadata: jest.fn().mockReturnValue({ name: `Hook ${id}` }),
|
|
391
|
+
beforeEvaluation: jest.fn().mockImplementation((_context, data) => {
|
|
392
|
+
beforeEvalOrder.push(id);
|
|
393
|
+
return data;
|
|
394
|
+
}),
|
|
395
|
+
afterEvaluation: jest.fn().mockImplementation((_context, data) => {
|
|
396
|
+
afterEvalOrder.push(id);
|
|
397
|
+
return data;
|
|
398
|
+
}),
|
|
399
|
+
beforeIdentify: jest.fn().mockImplementation((_context, data) => {
|
|
400
|
+
beforeIdentifyOrder.push(id);
|
|
401
|
+
return data;
|
|
402
|
+
}),
|
|
403
|
+
afterIdentify: jest.fn().mockImplementation((_context, data) => {
|
|
404
|
+
afterIdentifyOrder.push(id);
|
|
405
|
+
return data;
|
|
406
|
+
}),
|
|
407
|
+
afterTrack: jest.fn().mockImplementation(() => {
|
|
408
|
+
afterTrackOrder.push(id);
|
|
409
|
+
}),
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
const hookA = createMockHook('a');
|
|
413
|
+
const hookB = createMockHook('b');
|
|
414
|
+
const hookC = createMockHook('c');
|
|
415
|
+
|
|
416
|
+
const runner = createHookRunner(logger, [hookA, hookB]);
|
|
417
|
+
runner.addHook(hookC);
|
|
418
|
+
|
|
419
|
+
// Test evaluation order
|
|
420
|
+
runner.withEvaluation('flagKey', { kind: 'user', key: 'bob' }, 'default', () => ({
|
|
421
|
+
value: false,
|
|
422
|
+
reason: { kind: 'ERROR', errorKind: 'FLAG_NOT_FOUND' },
|
|
423
|
+
variationIndex: null,
|
|
424
|
+
}));
|
|
425
|
+
|
|
426
|
+
// Test identify order
|
|
427
|
+
const identifyCallback = runner.identify({ kind: 'user', key: 'bob' }, 1000);
|
|
428
|
+
identifyCallback({ status: 'completed' });
|
|
429
|
+
|
|
430
|
+
// Test track order
|
|
431
|
+
runner.afterTrack({
|
|
432
|
+
key: 'test',
|
|
433
|
+
context: { kind: 'user', key: 'bob' },
|
|
434
|
+
data: { test: 'data' },
|
|
435
|
+
metricValue: 42,
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
// Verify evaluation hooks order
|
|
439
|
+
expect(beforeEvalOrder).toEqual(['a', 'b', 'c']);
|
|
440
|
+
expect(afterEvalOrder).toEqual(['c', 'b', 'a']);
|
|
441
|
+
|
|
442
|
+
// Verify identify hooks order
|
|
443
|
+
expect(beforeIdentifyOrder).toEqual(['a', 'b', 'c']);
|
|
444
|
+
expect(afterIdentifyOrder).toEqual(['c', 'b', 'a']);
|
|
445
|
+
|
|
446
|
+
// Verify track hooks order
|
|
447
|
+
expect(afterTrackOrder).toEqual(['c', 'b', 'a']);
|
|
448
|
+
});
|
|
331
449
|
});
|
|
@@ -225,4 +225,30 @@ describe('LDClient Hooks Integration', () => {
|
|
|
225
225
|
});
|
|
226
226
|
});
|
|
227
227
|
});
|
|
228
|
+
|
|
229
|
+
it('should execute afterTrack hooks when tracking events', async () => {
|
|
230
|
+
const testHook = {
|
|
231
|
+
beforeEvaluation: jest.fn(),
|
|
232
|
+
afterEvaluation: jest.fn(),
|
|
233
|
+
beforeIdentify: jest.fn(),
|
|
234
|
+
afterIdentify: jest.fn(),
|
|
235
|
+
afterTrack: jest.fn(),
|
|
236
|
+
getMetadata() {
|
|
237
|
+
return {
|
|
238
|
+
name: 'test hook',
|
|
239
|
+
};
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
await withClient(initialContext, {}, [testHook], async client => {
|
|
244
|
+
client.track('test', { test: 'data' }, 42);
|
|
245
|
+
|
|
246
|
+
expect(testHook.afterTrack).toHaveBeenCalledWith({
|
|
247
|
+
key: 'test',
|
|
248
|
+
context: { kind: 'user', key: 'user-key-initial' },
|
|
249
|
+
data: { test: 'data' },
|
|
250
|
+
metricValue: 42,
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
});
|
|
228
254
|
});
|
|
@@ -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
|
+
});
|
package/src/configuration.js
CHANGED
|
@@ -37,6 +37,8 @@ const baseOptionDefs = {
|
|
|
37
37
|
stateProvider: { type: 'object' }, // not a public option, used internally
|
|
38
38
|
application: { validator: applicationConfigValidator },
|
|
39
39
|
inspectors: { default: [] },
|
|
40
|
+
hooks: { default: [] },
|
|
41
|
+
plugins: { default: [] },
|
|
40
42
|
};
|
|
41
43
|
|
|
42
44
|
/**
|
package/src/index.js
CHANGED
|
@@ -18,7 +18,7 @@ const { checkContext, getContextKeys } = require('./context');
|
|
|
18
18
|
const { InspectorTypes, InspectorManager } = require('./InspectorManager');
|
|
19
19
|
const timedPromise = require('./timedPromise');
|
|
20
20
|
const createHookRunner = require('./HookRunner');
|
|
21
|
-
|
|
21
|
+
const { getPluginHooks, registerPlugins, createPluginEnvironment } = require('./plugins');
|
|
22
22
|
const changeEvent = 'change';
|
|
23
23
|
const internalChangeEvent = 'internal-change';
|
|
24
24
|
const highTimeoutThreshold = 5;
|
|
@@ -41,7 +41,13 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
|
|
|
41
41
|
const sendEvents = options.sendEvents;
|
|
42
42
|
let environment = env;
|
|
43
43
|
let hash = options.hash;
|
|
44
|
-
const
|
|
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]);
|
|
45
51
|
|
|
46
52
|
const persistentStorage = PersistentStorage(platform.localStorage, logger);
|
|
47
53
|
|
|
@@ -428,6 +434,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
|
|
|
428
434
|
e.metricValue = metricValue;
|
|
429
435
|
}
|
|
430
436
|
enqueueEvent(e);
|
|
437
|
+
hookRunner.afterTrack({ context, key, data, metricValue });
|
|
431
438
|
}
|
|
432
439
|
|
|
433
440
|
function connectStream() {
|
|
@@ -871,6 +878,8 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
|
|
|
871
878
|
addHook: addHook,
|
|
872
879
|
};
|
|
873
880
|
|
|
881
|
+
registerPlugins(logger, pluginEnvironment, client, plugins);
|
|
882
|
+
|
|
874
883
|
return {
|
|
875
884
|
client: client, // The client object containing all public methods.
|
|
876
885
|
options: options, // The validated configuration object, including all defaults.
|
package/src/plugins.js
ADDED
|
@@ -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
|
+
};
|
package/test-types.ts
CHANGED
|
@@ -27,6 +27,40 @@ var user: ld.LDContext = {
|
|
|
27
27
|
},
|
|
28
28
|
privateAttributeNames: [ 'name', 'email' ]
|
|
29
29
|
};
|
|
30
|
+
const hook: ld.Hook = {
|
|
31
|
+
getMetadata: () => ({
|
|
32
|
+
name: 'hook',
|
|
33
|
+
}),
|
|
34
|
+
|
|
35
|
+
beforeEvaluation(hookContext: ld.EvaluationSeriesContext, data: ld.EvaluationSeriesData): ld.EvaluationSeriesData {
|
|
36
|
+
return data;
|
|
37
|
+
},
|
|
38
|
+
afterEvaluation(hookContext: ld.EvaluationSeriesContext, data: ld.EvaluationSeriesData, detail: ld.LDEvaluationDetail): ld.EvaluationSeriesData {
|
|
39
|
+
return data;
|
|
40
|
+
},
|
|
41
|
+
beforeIdentify(hookContext: ld.IdentifySeriesContext, data: ld.IdentifySeriesData): ld.IdentifySeriesData {
|
|
42
|
+
return data;
|
|
43
|
+
},
|
|
44
|
+
afterIdentify(hookContext: ld.IdentifySeriesContext, data: ld.IdentifySeriesData, result: ld.IdentifySeriesResult): ld.IdentifySeriesData {
|
|
45
|
+
return data;
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
afterTrack(hookContext: ld.TrackSeriesContext): void {
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const plugin: ld.LDPlugin = {
|
|
53
|
+
getMetadata: () => ({
|
|
54
|
+
name: 'plugin',
|
|
55
|
+
}),
|
|
56
|
+
register(client: ld.LDClientBase, environmentMetadata: ld.LDPluginEnvironmentMetadata): void {
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
getHooks(metadata: ld.LDPluginEnvironmentMetadata): ld.Hook[] {
|
|
60
|
+
return [];
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
30
64
|
var logger: ld.LDLogger = ld.commonBasicLogger({ level: 'info' });
|
|
31
65
|
var allBaseOptions: ld.LDOptionsBase = {
|
|
32
66
|
bootstrap: { },
|
|
@@ -48,7 +82,9 @@ var allBaseOptions: ld.LDOptionsBase = {
|
|
|
48
82
|
application: {
|
|
49
83
|
version: 'version',
|
|
50
84
|
id: 'id'
|
|
51
|
-
}
|
|
85
|
+
},
|
|
86
|
+
hooks: [ hook ],
|
|
87
|
+
plugins: [ plugin ]
|
|
52
88
|
};
|
|
53
89
|
|
|
54
90
|
var client: ld.LDClientBase = {} as ld.LDClientBase; // wouldn't do this in real life, it's just so the following statements will compile
|
package/typings.d.ts
CHANGED
|
@@ -127,6 +127,29 @@ declare module 'launchdarkly-js-sdk-common' {
|
|
|
127
127
|
status: IdentifySeriesStatus;
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Contextual information provided to track stages.
|
|
132
|
+
*/
|
|
133
|
+
export interface TrackSeriesContext {
|
|
134
|
+
/**
|
|
135
|
+
* The key for the event being tracked.
|
|
136
|
+
*/
|
|
137
|
+
readonly key: string;
|
|
138
|
+
/**
|
|
139
|
+
* The context associated with the track operation.
|
|
140
|
+
*/
|
|
141
|
+
readonly context: LDContext;
|
|
142
|
+
/**
|
|
143
|
+
* The data associated with the track operation.
|
|
144
|
+
*/
|
|
145
|
+
readonly data?: unknown;
|
|
146
|
+
/**
|
|
147
|
+
* The metric value associated with the track operation.
|
|
148
|
+
*/
|
|
149
|
+
readonly metricValue?: number;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
130
153
|
/**
|
|
131
154
|
* Interface for extending SDK functionality via hooks.
|
|
132
155
|
*/
|
|
@@ -216,8 +239,132 @@ declare module 'launchdarkly-js-sdk-common' {
|
|
|
216
239
|
data: IdentifySeriesData,
|
|
217
240
|
result: IdentifySeriesResult,
|
|
218
241
|
): IdentifySeriesData;
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* This method is called during the execution of the track process after the event
|
|
245
|
+
* has been enqueued.
|
|
246
|
+
*
|
|
247
|
+
* @param hookContext Contains information about the track operation being performed. This is not
|
|
248
|
+
* mutable.
|
|
249
|
+
*/
|
|
250
|
+
afterTrack?(hookContext: TrackSeriesContext): void;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Meta-data about a plugin implementation.
|
|
255
|
+
*
|
|
256
|
+
* May be used in logs and analytics to identify the plugin.
|
|
257
|
+
*/
|
|
258
|
+
export interface LDPluginMetadata {
|
|
259
|
+
/**
|
|
260
|
+
* The name of the plugin.
|
|
261
|
+
*/
|
|
262
|
+
readonly name: string;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Metadata about the SDK that is running the plugin.
|
|
267
|
+
*/
|
|
268
|
+
export interface LDPluginSdkMetadata {
|
|
269
|
+
/**
|
|
270
|
+
* The name of the SDK.
|
|
271
|
+
*/
|
|
272
|
+
readonly name: string;
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The version of the SDK.
|
|
276
|
+
*/
|
|
277
|
+
readonly version: string;
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* If this is a wrapper SDK, then this is the name of the wrapper.
|
|
281
|
+
*/
|
|
282
|
+
readonly wrapperName?: string;
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* If this is a wrapper SDK, then this is the version of the wrapper.
|
|
286
|
+
*/
|
|
287
|
+
readonly wrapperVersion?: string;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Metadata about the application where the LaunchDarkly SDK is running.
|
|
292
|
+
*/
|
|
293
|
+
export interface LDPluginApplicationMetadata {
|
|
294
|
+
/**
|
|
295
|
+
* A unique identifier representing the application where the LaunchDarkly SDK is running.
|
|
296
|
+
*
|
|
297
|
+
* This can be specified as any string value as long as it only uses the following characters: ASCII letters,
|
|
298
|
+
* ASCII digits, period, hyphen, underscore. A string containing any other characters will be ignored.
|
|
299
|
+
*
|
|
300
|
+
* Example: `authentication-service`
|
|
301
|
+
*/
|
|
302
|
+
readonly id?: string;
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* A unique identifier representing the version of the application where the LaunchDarkly SDK is running.
|
|
306
|
+
*
|
|
307
|
+
* This can be specified as any string value as long as it only uses the following characters: ASCII letters,
|
|
308
|
+
* ASCII digits, period, hyphen, underscore. A string containing any other characters will be ignored.
|
|
309
|
+
*
|
|
310
|
+
* Example: `1.0.0` (standard version string) or `abcdef` (sha prefix)
|
|
311
|
+
*/
|
|
312
|
+
readonly version?: string;
|
|
219
313
|
}
|
|
220
314
|
|
|
315
|
+
/**
|
|
316
|
+
* Metadata about the environment where the plugin is running.
|
|
317
|
+
*/
|
|
318
|
+
export interface LDPluginEnvironmentMetadata {
|
|
319
|
+
/**
|
|
320
|
+
* Metadata about the SDK that is running the plugin.
|
|
321
|
+
*/
|
|
322
|
+
readonly sdk: LDPluginSdkMetadata;
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Metadata about the application where the LaunchDarkly SDK is running.
|
|
326
|
+
*
|
|
327
|
+
* Only present if any application information is available.
|
|
328
|
+
*/
|
|
329
|
+
readonly application?: LDPluginApplicationMetadata;
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The client-side ID used to initialize the SDK.
|
|
333
|
+
*/
|
|
334
|
+
readonly clientSideId: string;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Interface for plugins to the LaunchDarkly SDK.
|
|
339
|
+
*/
|
|
340
|
+
export interface LDPlugin {
|
|
341
|
+
/**
|
|
342
|
+
* Get metadata about the plugin.
|
|
343
|
+
*/
|
|
344
|
+
getMetadata(): LDPluginMetadata;
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Registers the plugin with the SDK. Called once during SDK initialization.
|
|
348
|
+
*
|
|
349
|
+
* The SDK initialization will typically not have been completed at this point, so the plugin should take appropriate
|
|
350
|
+
* actions to ensure the SDK is ready before sending track events or evaluating flags.
|
|
351
|
+
*
|
|
352
|
+
* @param client The SDK client instance.
|
|
353
|
+
* @param environmentMetadata Information about the environment where the plugin is running.
|
|
354
|
+
*/
|
|
355
|
+
register(client: LDClientBase, environmentMetadata: LDPluginEnvironmentMetadata): void;
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Gets a list of hooks that the plugin wants to register.
|
|
359
|
+
*
|
|
360
|
+
* This method will be called once during SDK initialization before the register method is called.
|
|
361
|
+
*
|
|
362
|
+
* If the plugin does not need to register any hooks, this method doesn't need to be implemented.
|
|
363
|
+
* @param metadata
|
|
364
|
+
*/
|
|
365
|
+
getHooks?(metadata: LDPluginEnvironmentMetadata): Hook[];
|
|
366
|
+
}
|
|
367
|
+
|
|
221
368
|
/**
|
|
222
369
|
* LaunchDarkly initialization options that are supported by all variants of the JS client.
|
|
223
370
|
* The browser SDK and Electron SDK may support additional options.
|
|
@@ -474,6 +621,13 @@ declare module 'launchdarkly-js-sdk-common' {
|
|
|
474
621
|
* ```
|
|
475
622
|
*/
|
|
476
623
|
hooks?: Hook[];
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* A list of plugins to be used with the SDK.
|
|
627
|
+
*
|
|
628
|
+
* Plugin support is currently experimental and subject to change.
|
|
629
|
+
*/
|
|
630
|
+
plugins?: LDPlugin[];
|
|
477
631
|
}
|
|
478
632
|
|
|
479
633
|
/**
|