launchdarkly-js-sdk-common 5.5.0-beta.3 → 5.5.0-beta.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "launchdarkly-js-sdk-common",
3
- "version": "5.5.0-beta.3",
3
+ "version": "5.5.0-beta.5",
4
4
  "description": "LaunchDarkly SDK for JavaScript - common code",
5
5
  "author": "LaunchDarkly <team@launchdarkly.com>",
6
6
  "license": "Apache-2.0",
@@ -47,16 +47,16 @@ function EventProcessor(
47
47
  // Transform an event from its internal format to the format we use when sending a payload.
48
48
  function makeOutputEvent(e) {
49
49
  const ret = utils.extend({}, e);
50
- if (e.kind === 'identify') {
51
- // identify events always have an inline context
50
+
51
+ // Identify, feature, and custom events should all include the full context.
52
+ // Debug events do as well, but are not handled by this code path.
53
+ if (e.kind === 'identify' || e.kind === 'feature' || e.kind === 'custom') {
52
54
  ret.context = contextFilter.filter(e.context);
53
- } else if (e.kind === 'feature') {
54
- // feature events always have an inline context
55
- ret.context = contextFilter.filter(e.context, true);
56
55
  } else {
57
56
  ret.contextKeys = getContextKeysFromEvent(e);
58
57
  delete ret['context'];
59
58
  }
59
+
60
60
  if (e.kind === 'feature') {
61
61
  delete ret['trackEvents'];
62
62
  delete ret['debugEventsUntilDate'];
package/src/HookRunner.js CHANGED
@@ -1,7 +1,18 @@
1
1
  const UNKNOWN_HOOK_NAME = 'unknown hook';
2
2
  const BEFORE_EVALUATION_STAGE_NAME = 'beforeEvaluation';
3
3
  const AFTER_EVALUATION_STAGE_NAME = 'afterEvaluation';
4
+ const BEFORE_IDENTIFY_STAGE_NAME = 'beforeIdentify';
5
+ const AFTER_IDENTIFY_STAGE_NAME = 'afterIdentify';
4
6
 
7
+ /**
8
+ * Safely executes a hook stage function, logging any errors.
9
+ * @param {{ error: (message: string) => void } | undefined} logger The logger instance.
10
+ * @param {string} method The name of the hook stage being executed (e.g., 'beforeEvaluation').
11
+ * @param {string} hookName The name of the hook.
12
+ * @param {() => any} stage The function representing the hook stage to execute.
13
+ * @param {any} def The default value to return if the stage function throws an error.
14
+ * @returns {any} The result of the stage function, or the default value if an error occurred.
15
+ */
5
16
  function tryExecuteStage(logger, method, hookName, stage, def) {
6
17
  try {
7
18
  return stage();
@@ -11,6 +22,12 @@ function tryExecuteStage(logger, method, hookName, stage, def) {
11
22
  }
12
23
  }
13
24
 
25
+ /**
26
+ * Safely gets the name of a hook from its metadata.
27
+ * @param {{ error: (message: string) => void }} logger The logger instance.
28
+ * @param {{ getMetadata: () => { name?: string } }} hook The hook instance.
29
+ * @returns {string} The name of the hook, or 'unknown hook' if unable to retrieve it.
30
+ */
14
31
  function getHookName(logger, hook) {
15
32
  try {
16
33
  return hook.getMetadata().name || UNKNOWN_HOOK_NAME;
@@ -20,6 +37,13 @@ function getHookName(logger, hook) {
20
37
  }
21
38
  }
22
39
 
40
+ /**
41
+ * Executes the 'beforeEvaluation' stage for all registered hooks.
42
+ * @param {{ error: (message: string) => void }} logger The logger instance.
43
+ * @param {Array<{ beforeEvaluation?: (hookContext: object, data: object) => object }>} hooks The array of hook instances.
44
+ * @param {{ flagKey: string, context: object, defaultValue: any }} hookContext The context for the evaluation series.
45
+ * @returns {Array<object>} An array containing the data returned by each hook's 'beforeEvaluation' stage.
46
+ */
23
47
  function executeBeforeEvaluation(logger, hooks, hookContext) {
24
48
  return hooks.map(hook =>
25
49
  tryExecuteStage(
@@ -32,6 +56,15 @@ function executeBeforeEvaluation(logger, hooks, hookContext) {
32
56
  );
33
57
  }
34
58
 
59
+ /**
60
+ * Executes the 'afterEvaluation' stage for all registered hooks in reverse order.
61
+ * @param {{ error: (message: string) => void }} logger The logger instance.
62
+ * @param {Array<{ afterEvaluation?: (hookContext: object, data: object, result: object) => object }>} hooks The array of hook instances.
63
+ * @param {{ flagKey: string, context: object, defaultValue: any }} hookContext The context for the evaluation series.
64
+ * @param {Array<object>} updatedData The data collected from the 'beforeEvaluation' stages.
65
+ * @param {{ value: any, variationIndex?: number, reason?: object }} result The result of the flag evaluation.
66
+ * @returns {void}
67
+ */
35
68
  function executeAfterEvaluation(logger, hooks, hookContext, updatedData, result) {
36
69
  // This iterates in reverse, versus reversing a shallow copy of the hooks,
37
70
  // for efficiency.
@@ -48,11 +81,18 @@ function executeAfterEvaluation(logger, hooks, hookContext, updatedData, result)
48
81
  }
49
82
  }
50
83
 
84
+ /**
85
+ * Executes the 'beforeIdentify' stage for all registered hooks.
86
+ * @param {{ error: (message: string) => void }} logger The logger instance.
87
+ * @param {Array<{ beforeIdentify?: (hookContext: object, data: object) => object }>} hooks The array of hook instances.
88
+ * @param {{ context: object, timeout?: number }} hookContext The context for the identify series.
89
+ * @returns {Array<object>} An array containing the data returned by each hook's 'beforeIdentify' stage.
90
+ */
51
91
  function executeBeforeIdentify(logger, hooks, hookContext) {
52
92
  return hooks.map(hook =>
53
93
  tryExecuteStage(
54
94
  logger,
55
- BEFORE_EVALUATION_STAGE_NAME,
95
+ BEFORE_IDENTIFY_STAGE_NAME,
56
96
  getHookName(logger, hook),
57
97
  () => hook?.beforeIdentify?.(hookContext, {}) ?? {},
58
98
  {}
@@ -60,6 +100,15 @@ function executeBeforeIdentify(logger, hooks, hookContext) {
60
100
  );
61
101
  }
62
102
 
103
+ /**
104
+ * Executes the 'afterIdentify' stage for all registered hooks in reverse order.
105
+ * @param {{ error: (message: string) => void }} logger The logger instance.
106
+ * @param {Array<{ afterIdentify?: (hookContext: object, data: object, result: object) => object }>} hooks The array of hook instances.
107
+ * @param {{ context: object, timeout?: number }} hookContext The context for the identify series.
108
+ * @param {Array<object>} updatedData The data collected from the 'beforeIdentify' stages.
109
+ * @param {{ status: string }} result The result of the identify operation.
110
+ * @returns {void}
111
+ */
63
112
  function executeAfterIdentify(logger, hooks, hookContext, updatedData, result) {
64
113
  // This iterates in reverse, versus reversing a shallow copy of the hooks,
65
114
  // for efficiency.
@@ -68,7 +117,7 @@ function executeAfterIdentify(logger, hooks, hookContext, updatedData, result) {
68
117
  const data = updatedData[hookIndex];
69
118
  tryExecuteStage(
70
119
  logger,
71
- AFTER_EVALUATION_STAGE_NAME,
120
+ AFTER_IDENTIFY_STAGE_NAME,
72
121
  getHookName(logger, hook),
73
122
  () => hook?.afterIdentify?.(hookContext, data, result) ?? {},
74
123
  {}
@@ -76,44 +125,89 @@ function executeAfterIdentify(logger, hooks, hookContext, updatedData, result) {
76
125
  }
77
126
  }
78
127
 
79
- class HookRunner {
80
- constructor(logger, initialHooks) {
81
- this._logger = logger;
82
- this._hooks = initialHooks ? [...initialHooks] : [];
83
- }
128
+ /**
129
+ * Factory function to create a HookRunner instance.
130
+ * Manages the execution of hooks for flag evaluations and identify operations.
131
+ * @param {{ error: (message: string) => void }} logger The logger instance.
132
+ * @param {Array<object> | undefined} initialHooks An optional array of hooks to initialize with.
133
+ * @returns {{
134
+ * withEvaluation: (key: string, context: object, defaultValue: any, method: () => { value: any, variationIndex?: number, reason?: object }) => { value: any, variationIndex?: number, reason?: object },
135
+ * identify: (context: object, timeout?: number) => (result: { status: string }) => void,
136
+ * addHook: (hook: object) => void
137
+ * }} The hook runner object with methods to manage and execute hooks.
138
+ */
139
+ function createHookRunner(logger, initialHooks) {
140
+ // Use local variable instead of instance property
141
+ const hooksInternal = initialHooks ? [...initialHooks] : [];
84
142
 
85
- withEvaluation(key, context, defaultValue, method) {
86
- if (this._hooks.length === 0) {
143
+ /**
144
+ * Wraps a flag evaluation method with before/after hook stages.
145
+ * @param {string} key The flag key.
146
+ * @param {object} context The evaluation context.
147
+ * @param {any} defaultValue The default value for the flag.
148
+ * @param {() => { value: any, variationIndex?: number, reason?: object }} method The function that performs the actual flag evaluation.
149
+ * @returns {{ value: any, variationIndex?: number, reason?: object }} The result of the flag evaluation.
150
+ */
151
+ function withEvaluation(key, context, defaultValue, method) {
152
+ if (hooksInternal.length === 0) {
87
153
  return method();
88
154
  }
89
- const hooks = [...this._hooks];
155
+ const hooks = [...hooksInternal];
156
+ /** @type {{ flagKey: string, context: object, defaultValue: any }} */
90
157
  const hookContext = {
91
158
  flagKey: key,
92
159
  context,
93
160
  defaultValue,
94
161
  };
95
162
 
96
- const hookData = executeBeforeEvaluation(this._logger, hooks, hookContext);
163
+ // Use the logger passed into the factory
164
+ const hookData = executeBeforeEvaluation(logger, hooks, hookContext);
97
165
  const result = method();
98
- executeAfterEvaluation(this._logger, hooks, hookContext, hookData, result);
166
+ executeAfterEvaluation(logger, hooks, hookContext, hookData, result);
99
167
  return result;
100
168
  }
101
169
 
102
- identify(context, timeout) {
103
- const hooks = [...this._hooks];
170
+ /**
171
+ * Wraps the identify operation with before/after hook stages.
172
+ * Executes the 'beforeIdentify' stage immediately and returns a function
173
+ * to execute the 'afterIdentify' stage later.
174
+ * @param {object} context The context being identified.
175
+ * @param {number | undefined} timeout Optional timeout for the identify operation.
176
+ * @returns {(result: { status: string }) => void} A function to call after the identify operation completes.
177
+ */
178
+ function identify(context, timeout) {
179
+ const hooks = [...hooksInternal];
180
+ /** @type {{ context: object, timeout?: number }} */
104
181
  const hookContext = {
105
182
  context,
106
183
  timeout,
107
184
  };
108
- const hookData = executeBeforeIdentify(this._logger, hooks, hookContext);
185
+ // Use the logger passed into the factory
186
+ const hookData = executeBeforeIdentify(logger, hooks, hookContext);
187
+ /**
188
+ * Executes the 'afterIdentify' hook stage.
189
+ * @param {{ status: string }} result The result of the identify operation.
190
+ */
109
191
  return result => {
110
- executeAfterIdentify(this._logger, hooks, hookContext, hookData, result);
192
+ executeAfterIdentify(logger, hooks, hookContext, hookData, result);
111
193
  };
112
194
  }
113
195
 
114
- addHook(hook) {
115
- this._hooks.push(hook);
196
+ /**
197
+ * Adds a new hook to the runner.
198
+ * @param {object} hook The hook instance to add.
199
+ * @returns {void}
200
+ */
201
+ function addHook(hook) {
202
+ // Mutate the internal hooks array
203
+ hooksInternal.push(hook);
116
204
  }
205
+
206
+ return {
207
+ withEvaluation,
208
+ identify,
209
+ addHook,
210
+ };
117
211
  }
118
212
 
119
- module.exports = HookRunner;
213
+ module.exports = createHookRunner;
@@ -59,16 +59,6 @@ describe.each([
59
59
  }
60
60
  }
61
61
 
62
- function checkUserInline(e, source, inlineUser) {
63
- if (inlineUser) {
64
- expect(e.context).toEqual(inlineUser);
65
- expect(e.contextKeys).toBeUndefined();
66
- } else {
67
- expect(e.contextKeys).toEqual({ user: source.context.key || source.context.user.key });
68
- expect(e.context).toBeUndefined();
69
- }
70
- }
71
-
72
62
  function checkFeatureEvent(e, source, debug, inlineUser) {
73
63
  expect(e.kind).toEqual(debug ? 'debug' : 'feature');
74
64
  expect(e.creationDate).toEqual(source.creationDate);
@@ -87,7 +77,7 @@ describe.each([
87
77
  expect(e.key).toEqual(source.key);
88
78
  expect(e.data).toEqual(source.data);
89
79
  expect(e.metricValue).toEqual(source.metricValue);
90
- checkUserInline(e, source);
80
+ expect(e.context).toEqual(source.context);
91
81
  }
92
82
 
93
83
  function checkSummaryEvent(e) {
@@ -219,6 +209,31 @@ describe.each([
219
209
  });
220
210
  });
221
211
 
212
+ it('filters context in feature event', async () => {
213
+ const config = { ...defaultConfig, allAttributesPrivate: true };
214
+ await withProcessorAndSender(config, async (ep, mockEventSender) => {
215
+ const e = {
216
+ kind: 'feature',
217
+ creationDate: 1000,
218
+ context: eventContext,
219
+ key: 'flagkey',
220
+ version: 11,
221
+ variation: 1,
222
+ value: 'value',
223
+ default: 'default',
224
+ trackEvents: true,
225
+ };
226
+ ep.enqueue(e);
227
+ await ep.flush();
228
+
229
+ expect(mockEventSender.calls.length()).toEqual(1);
230
+ const output = (await mockEventSender.calls.take()).events;
231
+ expect(output.length).toEqual(2);
232
+ checkFeatureEvent(output[0], e, false, filteredContext);
233
+ checkSummaryEvent(output[1]);
234
+ });
235
+ });
236
+
222
237
  it('can both track and debug an event', async () => {
223
238
  await withProcessorAndSender(defaultConfig, async (ep, mockEventSender) => {
224
239
  const futureTime = new Date().getTime() + 1000000;
@@ -380,6 +395,27 @@ describe.each([
380
395
  });
381
396
  });
382
397
 
398
+ it('filters context in custom event', async () => {
399
+ const config = { ...defaultConfig, allAttributesPrivate: true };
400
+ await withProcessorAndSender(config, async (ep, mockEventSender) => {
401
+ const e = {
402
+ kind: 'custom',
403
+ creationDate: 1000,
404
+ context: eventContext,
405
+ key: 'eventkey',
406
+ data: { thing: 'stuff' },
407
+ metricValue: 1.5,
408
+ };
409
+ ep.enqueue(e);
410
+ await ep.flush();
411
+
412
+ expect(mockEventSender.calls.length()).toEqual(1);
413
+ const output = (await mockEventSender.calls.take()).events;
414
+ expect(output.length).toEqual(1);
415
+ checkCustomEvent(output[0], { ...e, context: filteredContext });
416
+ });
417
+ });
418
+
383
419
  it('enforces event capacity', async () => {
384
420
  const config = { ...defaultConfig, eventCapacity: 1, logger: stubPlatform.logger() };
385
421
  const e0 = { kind: 'custom', creationDate: 1000, context: eventContext, key: 'key0' };
@@ -0,0 +1,331 @@
1
+ // The HookRunner factory function under test
2
+ const createHookRunner = require('../HookRunner');
3
+
4
+ // Mock the logger functions we expect to be called
5
+ const mockLogger = () => ({
6
+ error: jest.fn(),
7
+ warn: jest.fn(),
8
+ info: jest.fn(),
9
+ debug: jest.fn(),
10
+ });
11
+
12
+ // Define a basic Hook structure for tests
13
+ const createTestHook = (name = 'Test Hook') => ({
14
+ getMetadata: jest.fn().mockReturnValue({ name }),
15
+ beforeEvaluation: jest.fn(),
16
+ afterEvaluation: jest.fn(),
17
+ beforeIdentify: jest.fn(),
18
+ afterIdentify: jest.fn(),
19
+ });
20
+
21
+ describe('Given a logger, runner, and hook', () => {
22
+ let logger;
23
+ let testHook;
24
+ let hookRunner;
25
+
26
+ beforeEach(() => {
27
+ // Reset mocks and create fresh instances for each test
28
+ logger = mockLogger();
29
+ testHook = createTestHook();
30
+ // Initialize the runner with the test hook
31
+ hookRunner = createHookRunner(logger, [testHook]);
32
+ });
33
+
34
+ it('evaluation: should execute hooks and return the evaluation result', () => {
35
+ const key = 'test-flag';
36
+ const context = { kind: 'user', key: 'user-123' };
37
+ const defaultValue = false;
38
+ const evaluationResult = {
39
+ value: true,
40
+ variationIndex: 1,
41
+ reason: { kind: 'OFF' },
42
+ };
43
+ // Mock the core evaluation method
44
+ const method = jest.fn().mockReturnValue(evaluationResult);
45
+
46
+ // The data expected to be passed between stages initially is empty
47
+ const initialData = {};
48
+
49
+ const result = hookRunner.withEvaluation(key, context, defaultValue, method);
50
+
51
+ // Check if beforeEvaluation was called correctly
52
+ expect(testHook.beforeEvaluation).toHaveBeenCalledWith(
53
+ expect.objectContaining({
54
+ flagKey: key,
55
+ context,
56
+ defaultValue,
57
+ }),
58
+ initialData // Initial data passed to beforeEvaluation
59
+ );
60
+
61
+ // Check if the original evaluation method was called
62
+ expect(method).toHaveBeenCalled();
63
+
64
+ // Check if afterEvaluation was called correctly
65
+ expect(testHook.afterEvaluation).toHaveBeenCalledWith(
66
+ expect.objectContaining({
67
+ flagKey: key,
68
+ context,
69
+ defaultValue,
70
+ }),
71
+ initialData, // Data returned from (mocked) beforeEvaluation
72
+ evaluationResult
73
+ );
74
+
75
+ // Verify the final result matches the evaluation result
76
+ expect(result).toEqual(evaluationResult);
77
+ });
78
+
79
+ it('evaluation: should handle errors in beforeEvaluation hook', () => {
80
+ const errorHook = createTestHook('Error Hook');
81
+ const testError = new Error('Hook error in before');
82
+ errorHook.beforeEvaluation.mockImplementation(() => {
83
+ throw testError;
84
+ });
85
+
86
+ const errorHookRunner = createHookRunner(logger, [errorHook]);
87
+ const method = jest.fn().mockReturnValue({ value: 'default', reason: { kind: 'ERROR' } });
88
+ const initialData = {}; // Data returned by the failing hook (default)
89
+
90
+ errorHookRunner.withEvaluation('test-flag', { kind: 'user', key: 'user-123' }, false, method);
91
+
92
+ // Error should be logged
93
+ expect(logger.error).toHaveBeenCalledWith(
94
+ 'An error was encountered in "beforeEvaluation" of the "Error Hook" hook: Error: Hook error in before'
95
+ );
96
+ // Method should still be called
97
+ expect(method).toHaveBeenCalled();
98
+ // After evaluation should still be called, passing the default data ({}) because before failed
99
+ expect(errorHook.afterEvaluation).toHaveBeenCalledWith(expect.anything(), initialData, expect.anything());
100
+ });
101
+
102
+ it('evaluation: should handle errors in afterEvaluation hook', () => {
103
+ const errorHook = createTestHook('Error Hook');
104
+ const testError = new Error('Hook error in after');
105
+ errorHook.afterEvaluation.mockImplementation(() => {
106
+ throw testError;
107
+ });
108
+
109
+ const errorHookRunner = createHookRunner(logger, [errorHook]);
110
+ const method = jest.fn().mockReturnValue({ value: 'default', reason: { kind: 'FALLTHROUGH' } });
111
+
112
+ errorHookRunner.withEvaluation('test-flag', { kind: 'user', key: 'user-123' }, false, method);
113
+
114
+ // Before evaluation should be called normally
115
+ expect(errorHook.beforeEvaluation).toHaveBeenCalled();
116
+ // Method should be called normally
117
+ expect(method).toHaveBeenCalled();
118
+ // Error should be logged for afterEvaluation
119
+ expect(logger.error).toHaveBeenCalledWith(
120
+ 'An error was encountered in "afterEvaluation" of the "Error Hook" hook: Error: Hook error in after'
121
+ );
122
+ });
123
+
124
+ it('evaluation: should skip hook execution if no hooks are provided', () => {
125
+ const emptyHookRunner = createHookRunner(logger, []); // No initial hooks
126
+ const method = jest.fn().mockReturnValue({ value: true });
127
+
128
+ emptyHookRunner.withEvaluation('test-flag', { kind: 'user', key: 'user-123' }, false, method);
129
+
130
+ // Only the method should be called
131
+ expect(method).toHaveBeenCalled();
132
+ expect(logger.error).not.toHaveBeenCalled();
133
+ expect(logger.warn).not.toHaveBeenCalled();
134
+ });
135
+
136
+ it('evaluation: should pass data from beforeEvaluation to afterEvaluation', () => {
137
+ const key = 'test-flag';
138
+ const context = { kind: 'user', key: 'user-123' };
139
+ const defaultValue = false;
140
+ const evaluationResult = { value: true };
141
+ const seriesData = { testData: 'before data' };
142
+
143
+ // Mock beforeEvaluation to return specific data
144
+ testHook.beforeEvaluation.mockReturnValue(seriesData);
145
+ const method = jest.fn().mockReturnValue(evaluationResult);
146
+
147
+ hookRunner.withEvaluation(key, context, defaultValue, method);
148
+
149
+ expect(testHook.beforeEvaluation).toHaveBeenCalled();
150
+ // afterEvaluation should receive the data returned by beforeEvaluation
151
+ expect(testHook.afterEvaluation).toHaveBeenCalledWith(
152
+ expect.anything(),
153
+ expect.objectContaining(seriesData), // Check if the passed data includes seriesData
154
+ evaluationResult
155
+ );
156
+ });
157
+
158
+ it('identify: should execute identify hooks', () => {
159
+ const context = { kind: 'user', key: 'user-123' };
160
+ const timeout = 10;
161
+ const identifyResult = { status: 'completed' }; // Example result structure
162
+ const initialData = {};
163
+
164
+ // Call identify to get the callback
165
+ const identifyCallback = hookRunner.identify(context, timeout);
166
+
167
+ // Check if beforeIdentify was called immediately
168
+ expect(testHook.beforeIdentify).toHaveBeenCalledWith(
169
+ expect.objectContaining({
170
+ context,
171
+ timeout,
172
+ }),
173
+ initialData // Initial data passed to beforeIdentify
174
+ );
175
+
176
+ // Now invoke the callback returned by identify
177
+ identifyCallback(identifyResult);
178
+
179
+ // Check if afterIdentify was called with the correct arguments
180
+ expect(testHook.afterIdentify).toHaveBeenCalledWith(
181
+ expect.objectContaining({
182
+ context,
183
+ timeout,
184
+ }),
185
+ initialData, // Data returned from (mocked) beforeIdentify
186
+ identifyResult
187
+ );
188
+ });
189
+
190
+ it('identify: should handle errors in beforeIdentify hook', () => {
191
+ const errorHook = createTestHook('Error Hook');
192
+ const testError = new Error('Hook error in before identify');
193
+ errorHook.beforeIdentify.mockImplementation(() => {
194
+ throw testError;
195
+ });
196
+
197
+ const errorHookRunner = createHookRunner(logger, [errorHook]);
198
+ const identifyCallback = errorHookRunner.identify({ kind: 'user', key: 'user-456' }, 1000);
199
+
200
+ // Error should be logged immediately from beforeIdentify
201
+ expect(logger.error).toHaveBeenCalledWith(
202
+ 'An error was encountered in "beforeIdentify" of the "Error Hook" hook: Error: Hook error in before identify'
203
+ );
204
+
205
+ // Execute the callback - afterIdentify should still be called
206
+ identifyCallback({ status: 'error' }); // Example result
207
+
208
+ // Check afterIdentify was called, receiving default data {}
209
+ expect(errorHook.afterIdentify).toHaveBeenCalledWith(expect.anything(), {}, expect.anything());
210
+ });
211
+
212
+ it('identify: should handle errors in afterIdentify hook', () => {
213
+ const errorHook = createTestHook('Error Hook');
214
+ const testError = new Error('Hook error in after identify');
215
+ errorHook.afterIdentify.mockImplementation(() => {
216
+ throw testError;
217
+ });
218
+
219
+ const errorHookRunner = createHookRunner(logger, [errorHook]);
220
+ const identifyCallback = errorHookRunner.identify({ kind: 'user', key: 'user-456' }, 1000);
221
+
222
+ // Before should run fine
223
+ expect(errorHook.beforeIdentify).toHaveBeenCalled();
224
+ expect(logger.error).not.toHaveBeenCalled();
225
+
226
+ // Execute the callback - this should trigger the error in afterIdentify
227
+ identifyCallback({ status: 'completed' }); // Example result
228
+
229
+ // Error should be logged from afterIdentify
230
+ expect(logger.error).toHaveBeenCalledWith(
231
+ 'An error was encountered in "afterIdentify" of the "Error Hook" hook: Error: Hook error in after identify'
232
+ );
233
+ });
234
+
235
+ it('identify: should pass data from beforeIdentify to afterIdentify', () => {
236
+ const context = { kind: 'user', key: 'user-789' };
237
+ const timeout = 50;
238
+ const identifyResult = { status: 'completed' };
239
+ const seriesData = { testData: 'before identify data' };
240
+
241
+ // Mock beforeIdentify to return specific data
242
+ testHook.beforeIdentify.mockReturnValue(seriesData);
243
+
244
+ const identifyCallback = hookRunner.identify(context, timeout);
245
+ identifyCallback(identifyResult);
246
+
247
+ expect(testHook.beforeIdentify).toHaveBeenCalled();
248
+ // afterIdentify should receive the data returned by beforeIdentify
249
+ expect(testHook.afterIdentify).toHaveBeenCalledWith(
250
+ expect.anything(),
251
+ expect.objectContaining(seriesData), // Check if the passed data includes seriesData
252
+ identifyResult
253
+ );
254
+ });
255
+
256
+ it('addHook: should use the added hook in future invocations', () => {
257
+ const newHook = createTestHook('New Hook');
258
+ hookRunner.addHook(newHook);
259
+
260
+ const method = jest.fn().mockReturnValue({ value: true });
261
+ hookRunner.withEvaluation('test-flag', { kind: 'user', key: 'user-123' }, false, method);
262
+
263
+ // Both the original and the new hook should have been called
264
+ expect(testHook.beforeEvaluation).toHaveBeenCalled();
265
+ expect(testHook.afterEvaluation).toHaveBeenCalled();
266
+ expect(newHook.beforeEvaluation).toHaveBeenCalled();
267
+ expect(newHook.afterEvaluation).toHaveBeenCalled();
268
+ });
269
+
270
+ it('error handling: should log "unknown hook" when getMetadata throws', () => {
271
+ const errorMetadataHook = {
272
+ getMetadata: jest.fn().mockImplementation(() => {
273
+ throw new Error('Metadata error');
274
+ }),
275
+ beforeEvaluation: jest.fn().mockImplementation(() => {
276
+ throw new Error('Eval error'); // Add an error here to trigger logging
277
+ }),
278
+ afterEvaluation: jest.fn(),
279
+ // Add other methods if needed for completeness, mocked simply
280
+ beforeIdentify: jest.fn(),
281
+ afterIdentify: jest.fn(),
282
+ };
283
+
284
+ const errorRunner = createHookRunner(logger, [errorMetadataHook]);
285
+ errorRunner.withEvaluation('flag', {}, false, () => ({ value: null }));
286
+
287
+ // First error: getting metadata
288
+ expect(logger.error).toHaveBeenCalledWith('Exception thrown getting metadata for hook. Unable to get hook name.');
289
+ // Second error: executing the stage with 'unknown hook' name
290
+ expect(logger.error).toHaveBeenCalledWith(
291
+ 'An error was encountered in "beforeEvaluation" of the "unknown hook" hook: Error: Eval error'
292
+ );
293
+ });
294
+
295
+ it('error handling: should log "unknown hook" when getMetadata returns empty name', () => {
296
+ const emptyNameHook = createTestHook(''); // Create hook with empty name
297
+ emptyNameHook.beforeEvaluation.mockImplementation(() => {
298
+ throw new Error('Eval error'); // Add an error here to trigger logging
299
+ });
300
+
301
+ const errorRunner = createHookRunner(logger, [emptyNameHook]);
302
+ errorRunner.withEvaluation('flag', {}, false, () => ({ value: null }));
303
+
304
+ // Verify getMetadata was called (even though name is empty)
305
+ expect(emptyNameHook.getMetadata).toHaveBeenCalled();
306
+
307
+ // Verify the error uses 'unknown hook'
308
+ expect(logger.error).toHaveBeenCalledWith(
309
+ 'An error was encountered in "beforeEvaluation" of the "unknown hook" hook: Error: Eval error'
310
+ );
311
+ });
312
+
313
+ it('error handling: should log the correct hook name when an error occurs', () => {
314
+ const hookName = 'Specific Error Hook';
315
+ const errorHook = createTestHook(hookName);
316
+ const testError = new Error('Specific test error');
317
+ errorHook.beforeEvaluation.mockImplementation(() => {
318
+ throw testError;
319
+ });
320
+
321
+ const specificRunner = createHookRunner(logger, [errorHook]);
322
+ specificRunner.withEvaluation('flag', {}, false, () => ({ value: null }));
323
+
324
+ // Verify getMetadata was called
325
+ expect(errorHook.getMetadata).toHaveBeenCalled();
326
+ // Verify the error message includes the correct hook name
327
+ expect(logger.error).toHaveBeenCalledWith(
328
+ `An error was encountered in "beforeEvaluation" of the "${hookName}" hook: Error: Specific test error`
329
+ );
330
+ });
331
+ });
@@ -0,0 +1,228 @@
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(),
17
+ afterEvaluation: jest.fn(),
18
+ beforeIdentify: jest.fn().mockImplementation((_ctx, data) => data), // Pass data through
19
+ afterIdentify: jest.fn(),
20
+ });
21
+
22
+ // Helper to initialize the client for tests
23
+ // Disables network requests and event sending by default
24
+ async function withClient(initialContext, configOverrides = {}, hooks = [], testFn) {
25
+ const platform = stubPlatform.defaults();
26
+ const server = platform.testing.http.newServer();
27
+
28
+ const logger = mockLogger();
29
+
30
+ // Disable streaming and event sending unless overridden
31
+ // Configure client to use the mock server's URL
32
+ const defaults = {
33
+ baseUrl: server.url, // Use mock server URL
34
+ streaming: false,
35
+ sendEvents: false,
36
+ useLdd: false,
37
+ logger: logger,
38
+ hooks: hooks, // Pass initial hooks here
39
+ };
40
+ const config = { ...defaults, ...configOverrides };
41
+ const { client, start } = initialize('env', initialContext, config, platform);
42
+
43
+ // Set the mock server to return an empty flag set by default
44
+ server.byDefault(respondJson({})); // Correct way to provide initial flags
45
+
46
+ start(); // Start the client components
47
+
48
+ try {
49
+ // Wait briefly for initialization (client will hit the mock server)
50
+ await client.waitForInitialization(10); // Use a short timeout
51
+ await testFn(client, logger, platform); // Pass client, logger, platform to the test
52
+ } finally {
53
+ await client.close();
54
+ server.close(); // Close the mock server
55
+ }
56
+ }
57
+
58
+ describe('LDClient Hooks Integration', () => {
59
+ const initialContext = { kind: 'user', key: 'user-key-initial' };
60
+ const flagKey = 'test-flag';
61
+ const flagDefaultValue = false;
62
+ // Expected result when flag is not found (as it will be with empty flags)
63
+ const flagNotFoundDetail = {
64
+ value: flagDefaultValue,
65
+ variationIndex: null,
66
+ reason: { kind: 'ERROR', errorKind: 'FLAG_NOT_FOUND' },
67
+ };
68
+
69
+ it('should use hooks registered during configuration', async () => {
70
+ const testHook = createTestHook('Initial Hook');
71
+ const initialData = {}; // Hooks start with empty data
72
+
73
+ await withClient(initialContext, {}, [testHook], async client => {
74
+ // Call variation
75
+ await client.variation(flagKey, flagDefaultValue);
76
+
77
+ // Check identify hooks
78
+ expect(testHook.beforeIdentify).toHaveBeenCalledTimes(1);
79
+ expect(testHook.beforeIdentify).toHaveBeenCalledWith(
80
+ expect.objectContaining({
81
+ context: initialContext,
82
+ // timeout will be undefined unless explicitly passed to identify
83
+ }),
84
+ initialData
85
+ );
86
+ expect(testHook.afterIdentify).toHaveBeenCalledTimes(1);
87
+ expect(testHook.afterIdentify).toHaveBeenCalledWith(
88
+ expect.objectContaining({
89
+ context: initialContext,
90
+ }),
91
+ initialData, // Assumes beforeIdentify just returned the initial data
92
+ { status: 'completed' }
93
+ );
94
+
95
+ // Check evaluation hooks (context from identify is now current)
96
+ expect(testHook.beforeEvaluation).toHaveBeenCalledTimes(1);
97
+ expect(testHook.beforeEvaluation).toHaveBeenCalledWith(
98
+ expect.objectContaining({
99
+ flagKey: flagKey,
100
+ context: initialContext,
101
+ defaultValue: flagDefaultValue,
102
+ }),
103
+ initialData
104
+ );
105
+ expect(testHook.afterEvaluation).toHaveBeenCalledTimes(1);
106
+ expect(testHook.afterEvaluation).toHaveBeenCalledWith(
107
+ expect.objectContaining({
108
+ flagKey: flagKey,
109
+ context: initialContext,
110
+ defaultValue: flagDefaultValue,
111
+ }),
112
+ initialData, // Assumes beforeEvaluation just returned the initial data
113
+ flagNotFoundDetail // Using the default flag not found result
114
+ );
115
+ });
116
+ });
117
+
118
+ it('should execute hooks that are added using addHook', async () => {
119
+ const addedHook = createTestHook('Added Hook');
120
+ const identifyContext = { kind: 'user', key: 'user-key-added' };
121
+ const initialData = {};
122
+
123
+ // Initialize client *without* the hook initially
124
+ await withClient(initialContext, {}, [], async client => {
125
+ // Add the hook dynamically
126
+ client.addHook(addedHook);
127
+
128
+ // Call identify and variation
129
+ await client.identify(identifyContext);
130
+ await client.variation(flagKey, flagDefaultValue);
131
+
132
+ // Check identify hooks
133
+ expect(addedHook.beforeIdentify).toHaveBeenCalledTimes(1);
134
+ expect(addedHook.beforeIdentify).toHaveBeenCalledWith(
135
+ expect.objectContaining({ context: identifyContext }),
136
+ initialData
137
+ );
138
+ expect(addedHook.afterIdentify).toHaveBeenCalledTimes(1);
139
+ expect(addedHook.afterIdentify).toHaveBeenCalledWith(
140
+ expect.objectContaining({ context: identifyContext }),
141
+ initialData,
142
+ { status: 'completed' }
143
+ );
144
+
145
+ // Check evaluation hooks
146
+ expect(addedHook.beforeEvaluation).toHaveBeenCalledTimes(1);
147
+ expect(addedHook.beforeEvaluation).toHaveBeenCalledWith(
148
+ expect.objectContaining({ flagKey, context: identifyContext, defaultValue: flagDefaultValue }),
149
+ initialData
150
+ );
151
+ expect(addedHook.afterEvaluation).toHaveBeenCalledTimes(1);
152
+ expect(addedHook.afterEvaluation).toHaveBeenCalledWith(
153
+ expect.objectContaining({ flagKey, context: identifyContext, defaultValue: flagDefaultValue }),
154
+ initialData,
155
+ flagNotFoundDetail
156
+ );
157
+ });
158
+ });
159
+
160
+ it('should execute both initial hooks and hooks added using addHook', async () => {
161
+ const initialHook = createTestHook('Initial Hook For Both');
162
+ const addedHook = createTestHook('Added Hook For Both');
163
+ const identifyContext = { kind: 'user', key: 'user-key-both' };
164
+ const initialData = {};
165
+
166
+ // Initialize client *with* the initial hook
167
+ await withClient(initialContext, {}, [initialHook], async client => {
168
+ // Add the second hook dynamically
169
+ client.addHook(addedHook);
170
+
171
+ await client.identify(identifyContext);
172
+ await client.variation(flagKey, flagDefaultValue);
173
+
174
+ expect(initialHook.beforeIdentify).toHaveBeenCalledTimes(2);
175
+ expect(initialHook.beforeIdentify).toHaveBeenNthCalledWith(
176
+ 1,
177
+ expect.objectContaining({ context: initialContext }),
178
+ initialData
179
+ );
180
+ expect(initialHook.beforeIdentify).toHaveBeenNthCalledWith(
181
+ 2,
182
+ expect.objectContaining({ context: identifyContext }),
183
+ initialData
184
+ );
185
+
186
+ expect(initialHook.afterIdentify).toHaveBeenCalledTimes(2);
187
+ expect(initialHook.afterIdentify).toHaveBeenNthCalledWith(
188
+ 1,
189
+ expect.objectContaining({ context: initialContext }),
190
+ initialData, // Assuming pass-through
191
+ { status: 'completed' }
192
+ );
193
+ expect(initialHook.afterIdentify).toHaveBeenNthCalledWith(
194
+ 2,
195
+ expect.objectContaining({ context: identifyContext }),
196
+ initialData, // Assuming pass-through
197
+ { status: 'completed' }
198
+ );
199
+
200
+ expect(addedHook.beforeIdentify).toHaveBeenCalledTimes(1);
201
+ expect(addedHook.beforeIdentify).toHaveBeenCalledWith(
202
+ expect.objectContaining({ context: identifyContext }),
203
+ initialData
204
+ );
205
+ expect(addedHook.afterIdentify).toHaveBeenCalledTimes(1);
206
+ expect(addedHook.afterIdentify).toHaveBeenCalledWith(
207
+ expect.objectContaining({ context: identifyContext }),
208
+ initialData, // Assuming pass-through
209
+ { status: 'completed' }
210
+ );
211
+
212
+ // Check evaluation hooks for BOTH hooks
213
+ [initialHook, addedHook].forEach(hook => {
214
+ expect(hook.beforeEvaluation).toHaveBeenCalledTimes(1);
215
+ expect(hook.beforeEvaluation).toHaveBeenCalledWith(
216
+ expect.objectContaining({ flagKey, context: identifyContext, defaultValue: flagDefaultValue }),
217
+ initialData
218
+ );
219
+ expect(hook.afterEvaluation).toHaveBeenCalledTimes(1);
220
+ expect(hook.afterEvaluation).toHaveBeenCalledWith(
221
+ expect.objectContaining({ flagKey, context: identifyContext, defaultValue: flagDefaultValue }),
222
+ initialData, // Assuming pass-through
223
+ flagNotFoundDetail
224
+ );
225
+ });
226
+ });
227
+ });
228
+ });
@@ -1,4 +1,4 @@
1
- import { appendUrlPath, getLDUserAgentString, wrapPromiseCallback } from '../utils';
1
+ import { appendUrlPath, getLDUserAgentString, wrapPromiseCallback, once } from '../utils';
2
2
 
3
3
  import * as stubPlatform from './stubPlatform';
4
4
 
@@ -63,4 +63,34 @@ describe('utils', () => {
63
63
  expect(ua).toEqual('stubClient/7.8.9');
64
64
  });
65
65
  });
66
+
67
+ it('when using once the original function is only called once', () => {
68
+ let count = 0;
69
+ const fn = once(() => {
70
+ count++;
71
+ return count;
72
+ });
73
+
74
+ expect(fn()).toBe(1);
75
+ expect(fn()).toBe(1);
76
+ expect(fn()).toBe(1);
77
+ expect(count).toBe(1);
78
+ });
79
+
80
+ it('once works with async functions', async () => {
81
+ let count = 0;
82
+ const fn = once(async () => {
83
+ count++;
84
+ return count;
85
+ });
86
+
87
+ const result1 = await fn();
88
+ const result2 = await fn();
89
+ const result3 = await fn();
90
+
91
+ expect(result1).toBe(1);
92
+ expect(result2).toBe(1);
93
+ expect(result3).toBe(1);
94
+ expect(count).toBe(1);
95
+ });
66
96
  });
package/src/index.js CHANGED
@@ -17,7 +17,7 @@ const messages = require('./messages');
17
17
  const { checkContext, getContextKeys } = require('./context');
18
18
  const { InspectorTypes, InspectorManager } = require('./InspectorManager');
19
19
  const timedPromise = require('./timedPromise');
20
- const HookRunner = require('./HookRunner');
20
+ const createHookRunner = require('./HookRunner');
21
21
 
22
22
  const changeEvent = 'change';
23
23
  const internalChangeEvent = 'internal-change';
@@ -41,7 +41,7 @@ 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 hookRunner = new HookRunner(logger, options.hooks);
44
+ const hookRunner = createHookRunner(logger, options.hooks);
45
45
 
46
46
  const persistentStorage = PersistentStorage(platform.localStorage, logger);
47
47
 
@@ -265,7 +265,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
265
265
  .then(() => anonymousContextProcessor.processContext(context))
266
266
  .then(verifyContext)
267
267
  .then(context => {
268
- afterIdentify = hookRunner.identify(context, undefined);
268
+ afterIdentify = utils.once(hookRunner.identify(context, undefined));
269
269
  return context;
270
270
  })
271
271
  .then(validatedContext =>
@@ -687,7 +687,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
687
687
  .processContext(context)
688
688
  .then(verifyContext)
689
689
  .then(context => {
690
- afterIdentify = hookRunner.identify(context, undefined);
690
+ afterIdentify = utils.once(hookRunner.identify(context, undefined));
691
691
  return context;
692
692
  })
693
693
  .then(validatedContext => {
package/src/utils.js CHANGED
@@ -144,6 +144,26 @@ function sanitizeContext(context) {
144
144
  return newContext || context;
145
145
  }
146
146
 
147
+ /**
148
+ * Creates a function that will invoke the provided function only once.
149
+ *
150
+ * If the function returns a value, then that returned value will be re-used for subsequent invocations.
151
+ *
152
+ * @param {Function} func The function to restrict.
153
+ * @returns {Function} Returns the new restricted function.
154
+ */
155
+ function once(func) {
156
+ let called = false;
157
+ let result;
158
+ return function(...args) {
159
+ if (!called) {
160
+ called = true;
161
+ result = func.apply(this, args);
162
+ }
163
+ return result;
164
+ };
165
+ }
166
+
147
167
  module.exports = {
148
168
  appendUrlPath,
149
169
  base64URLEncode,
@@ -158,4 +178,5 @@ module.exports = {
158
178
  transformValuesToVersionedValues,
159
179
  transformVersionedValuesToValues,
160
180
  wrapPromiseCallback,
181
+ once,
161
182
  };
package/typings.d.ts CHANGED
@@ -183,7 +183,7 @@ declare module 'launchdarkly-js-sdk-common' {
183
183
  * This method is called during the execution of the identify process before the operation
184
184
  * completes, but after any context modifications are performed.
185
185
  *
186
- * @param hookContext Contains information about the evaluation being performed. This is not
186
+ * @param hookContext Contains information about the identify operation being performed. This is not
187
187
  * mutable.
188
188
  * @param data A record associated with each stage of hook invocations. Each stage is called with
189
189
  * the data of the previous stage for a series. The input record should not be modified.
@@ -200,7 +200,7 @@ declare module 'launchdarkly-js-sdk-common' {
200
200
  * This method is called during the execution of the identify process before the operation
201
201
  * completes, but after any context modifications are performed.
202
202
  *
203
- * @param hookContext Contains information about the evaluation being performed. This is not
203
+ * @param hookContext Contains information about the identify operation being performed. This is not
204
204
  * mutable.
205
205
  * @param data A record associated with each stage of hook invocations. Each stage is called with
206
206
  * the data of the previous stage for a series. The input record should not be modified.
@@ -467,7 +467,7 @@ declare module 'launchdarkly-js-sdk-common' {
467
467
  *
468
468
  * Example:
469
469
  * ```typescript
470
- * import { init } from '@launchdarkly/node-server-sdk';
470
+ * import { initialize } from 'launchdarkly-js-client-sdk';
471
471
  * import { TheHook } from '@launchdarkly/some-hook';
472
472
  *
473
473
  * const client = init('my-sdk-key', { hooks: [new TheHook()] });