Skip to content

Commit 85c227c

Browse files
authored
feat: Add hook support. (#116)
Draft as tests need added first.
1 parent 450f8f7 commit 85c227c

File tree

7 files changed

+1062
-3
lines changed

7 files changed

+1062
-3
lines changed

src/HookRunner.js

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
const UNKNOWN_HOOK_NAME = 'unknown hook';
2+
const BEFORE_EVALUATION_STAGE_NAME = 'beforeEvaluation';
3+
const AFTER_EVALUATION_STAGE_NAME = 'afterEvaluation';
4+
const BEFORE_IDENTIFY_STAGE_NAME = 'beforeIdentify';
5+
const AFTER_IDENTIFY_STAGE_NAME = 'afterIdentify';
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+
*/
16+
function tryExecuteStage(logger, method, hookName, stage, def) {
17+
try {
18+
return stage();
19+
} catch (err) {
20+
logger?.error(`An error was encountered in "${method}" of the "${hookName}" hook: ${err}`);
21+
return def;
22+
}
23+
}
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+
*/
31+
function getHookName(logger, hook) {
32+
try {
33+
return hook.getMetadata().name || UNKNOWN_HOOK_NAME;
34+
} catch {
35+
logger.error(`Exception thrown getting metadata for hook. Unable to get hook name.`);
36+
return UNKNOWN_HOOK_NAME;
37+
}
38+
}
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+
*/
47+
function executeBeforeEvaluation(logger, hooks, hookContext) {
48+
return hooks.map(hook =>
49+
tryExecuteStage(
50+
logger,
51+
BEFORE_EVALUATION_STAGE_NAME,
52+
getHookName(logger, hook),
53+
() => hook?.beforeEvaluation?.(hookContext, {}) ?? {},
54+
{}
55+
)
56+
);
57+
}
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+
*/
68+
function executeAfterEvaluation(logger, hooks, hookContext, updatedData, result) {
69+
// This iterates in reverse, versus reversing a shallow copy of the hooks,
70+
// for efficiency.
71+
for (let hookIndex = hooks.length - 1; hookIndex >= 0; hookIndex -= 1) {
72+
const hook = hooks[hookIndex];
73+
const data = updatedData[hookIndex];
74+
tryExecuteStage(
75+
logger,
76+
AFTER_EVALUATION_STAGE_NAME,
77+
getHookName(logger, hook),
78+
() => hook?.afterEvaluation?.(hookContext, data, result) ?? {},
79+
{}
80+
);
81+
}
82+
}
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+
*/
91+
function executeBeforeIdentify(logger, hooks, hookContext) {
92+
return hooks.map(hook =>
93+
tryExecuteStage(
94+
logger,
95+
BEFORE_IDENTIFY_STAGE_NAME,
96+
getHookName(logger, hook),
97+
() => hook?.beforeIdentify?.(hookContext, {}) ?? {},
98+
{}
99+
)
100+
);
101+
}
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+
*/
112+
function executeAfterIdentify(logger, hooks, hookContext, updatedData, result) {
113+
// This iterates in reverse, versus reversing a shallow copy of the hooks,
114+
// for efficiency.
115+
for (let hookIndex = hooks.length - 1; hookIndex >= 0; hookIndex -= 1) {
116+
const hook = hooks[hookIndex];
117+
const data = updatedData[hookIndex];
118+
tryExecuteStage(
119+
logger,
120+
AFTER_IDENTIFY_STAGE_NAME,
121+
getHookName(logger, hook),
122+
() => hook?.afterIdentify?.(hookContext, data, result) ?? {},
123+
{}
124+
);
125+
}
126+
}
127+
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] : [];
142+
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) {
153+
return method();
154+
}
155+
const hooks = [...hooksInternal];
156+
/** @type {{ flagKey: string, context: object, defaultValue: any }} */
157+
const hookContext = {
158+
flagKey: key,
159+
context,
160+
defaultValue,
161+
};
162+
163+
// Use the logger passed into the factory
164+
const hookData = executeBeforeEvaluation(logger, hooks, hookContext);
165+
const result = method();
166+
executeAfterEvaluation(logger, hooks, hookContext, hookData, result);
167+
return result;
168+
}
169+
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 }} */
181+
const hookContext = {
182+
context,
183+
timeout,
184+
};
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+
*/
191+
return result => {
192+
executeAfterIdentify(logger, hooks, hookContext, hookData, result);
193+
};
194+
}
195+
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);
204+
}
205+
206+
return {
207+
withEvaluation,
208+
identify,
209+
addHook,
210+
};
211+
}
212+
213+
module.exports = createHookRunner;

0 commit comments

Comments
 (0)