Skip to content

Commit 07af3a9

Browse files
authored
feat: add evaluation-scoped hook data (#1216)
Signed-off-by: Michael Beemer <[email protected]>
1 parent d8bd93b commit 07af3a9

File tree

12 files changed

+1343
-68
lines changed

12 files changed

+1343
-68
lines changed

packages/server/src/client/internal/open-feature-client.ts

Lines changed: 56 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
StandardResolutionReasons,
2323
instantiateErrorByErrorCode,
2424
statusMatchesEvent,
25+
MapHookData,
2526
} from '@openfeature/core';
2627
import type { FlagEvaluationOptions } from '../../evaluation';
2728
import type { ProviderEvents } from '../../events';
@@ -276,22 +277,26 @@ export class OpenFeatureClient implements Client {
276277

277278
const mergedContext = this.mergeContexts(invocationContext);
278279

279-
// this reference cannot change during the course of evaluation
280-
// it may be used as a key in WeakMaps
281-
const hookContext: Readonly<HookContext> = {
282-
flagKey,
283-
defaultValue,
284-
flagValueType: flagType,
285-
clientMetadata: this.metadata,
286-
providerMetadata: this._provider.metadata,
287-
context: mergedContext,
288-
logger: this._logger,
289-
};
280+
// Create hook context instances for each hook (stable object references for the entire evaluation)
281+
// This ensures hooks can use WeakMaps with hookContext as keys across lifecycle methods
282+
// NOTE: Uses the reversed order to reduce the number of times we have to calculate the index.
283+
const hookContexts = allHooksReversed.map<HookContext>(() =>
284+
Object.freeze({
285+
flagKey,
286+
defaultValue,
287+
flagValueType: flagType,
288+
clientMetadata: this.metadata,
289+
providerMetadata: this._provider.metadata,
290+
context: mergedContext,
291+
logger: this._logger,
292+
hookData: new MapHookData(),
293+
}),
294+
);
290295

291296
let evaluationDetails: EvaluationDetails<T>;
292297

293298
try {
294-
const frozenContext = await this.beforeHooks(allHooks, hookContext, options);
299+
const frozenContext = await this.beforeHooks(allHooks, hookContexts, mergedContext, options);
295300

296301
this.shortCircuitIfNotReady();
297302

@@ -306,53 +311,71 @@ export class OpenFeatureClient implements Client {
306311

307312
if (resolutionDetails.errorCode) {
308313
const err = instantiateErrorByErrorCode(resolutionDetails.errorCode, resolutionDetails.errorMessage);
309-
await this.errorHooks(allHooksReversed, hookContext, err, options);
314+
await this.errorHooks(allHooksReversed, hookContexts, err, options);
310315
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err, resolutionDetails.flagMetadata);
311316
} else {
312-
await this.afterHooks(allHooksReversed, hookContext, resolutionDetails, options);
317+
await this.afterHooks(allHooksReversed, hookContexts, resolutionDetails, options);
313318
evaluationDetails = resolutionDetails;
314319
}
315320
} catch (err: unknown) {
316-
await this.errorHooks(allHooksReversed, hookContext, err, options);
321+
await this.errorHooks(allHooksReversed, hookContexts, err, options);
317322
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err);
318323
}
319324

320-
await this.finallyHooks(allHooksReversed, hookContext, evaluationDetails, options);
325+
await this.finallyHooks(allHooksReversed, hookContexts, evaluationDetails, options);
321326
return evaluationDetails;
322327
}
323328

324-
private async beforeHooks(hooks: Hook[], hookContext: HookContext, options: FlagEvaluationOptions) {
325-
for (const hook of hooks) {
326-
// freeze the hookContext
327-
Object.freeze(hookContext);
329+
private async beforeHooks(
330+
hooks: Hook[],
331+
hookContexts: HookContext[],
332+
mergedContext: EvaluationContext,
333+
options: FlagEvaluationOptions,
334+
) {
335+
let accumulatedContext = mergedContext;
336+
337+
for (const [index, hook] of hooks.entries()) {
338+
const hookContextIndex = hooks.length - 1 - index; // reverse index for before hooks
339+
const hookContext = hookContexts[hookContextIndex];
328340

329-
// use Object.assign to avoid modification of frozen hookContext
330-
Object.assign(hookContext.context, {
331-
...hookContext.context,
332-
...(await hook?.before?.(hookContext, Object.freeze(options.hookHints))),
333-
});
341+
// Update the context on the stable hook context object
342+
Object.assign(hookContext.context, accumulatedContext);
343+
344+
const hookResult = await hook?.before?.(hookContext, Object.freeze(options.hookHints));
345+
if (hookResult) {
346+
accumulatedContext = {
347+
...accumulatedContext,
348+
...hookResult,
349+
};
350+
351+
for (let i = 0; i < hooks.length; i++) {
352+
Object.assign(hookContexts[hookContextIndex].context, accumulatedContext);
353+
}
354+
}
334355
}
335356

336357
// after before hooks, freeze the EvaluationContext.
337-
return Object.freeze(hookContext.context);
358+
return Object.freeze(accumulatedContext);
338359
}
339360

340361
private async afterHooks(
341362
hooks: Hook[],
342-
hookContext: HookContext,
363+
hookContexts: HookContext[],
343364
evaluationDetails: EvaluationDetails<FlagValue>,
344365
options: FlagEvaluationOptions,
345366
) {
346367
// run "after" hooks sequentially
347-
for (const hook of hooks) {
368+
for (const [index, hook] of hooks.entries()) {
369+
const hookContext = hookContexts[index];
348370
await hook?.after?.(hookContext, evaluationDetails, options.hookHints);
349371
}
350372
}
351373

352-
private async errorHooks(hooks: Hook[], hookContext: HookContext, err: unknown, options: FlagEvaluationOptions) {
374+
private async errorHooks(hooks: Hook[], hookContexts: HookContext[], err: unknown, options: FlagEvaluationOptions) {
353375
// run "error" hooks sequentially
354-
for (const hook of hooks) {
376+
for (const [index, hook] of hooks.entries()) {
355377
try {
378+
const hookContext = hookContexts[index];
356379
await hook?.error?.(hookContext, err, options.hookHints);
357380
} catch (err) {
358381
this._logger.error(`Unhandled error during 'error' hook: ${err}`);
@@ -366,13 +389,14 @@ export class OpenFeatureClient implements Client {
366389

367390
private async finallyHooks(
368391
hooks: Hook[],
369-
hookContext: HookContext,
392+
hookContexts: HookContext[],
370393
evaluationDetails: EvaluationDetails<FlagValue>,
371394
options: FlagEvaluationOptions,
372395
) {
373396
// run "finally" hooks sequentially
374-
for (const hook of hooks) {
397+
for (const [index, hook] of hooks.entries()) {
375398
try {
399+
const hookContext = hookContexts[index];
376400
await hook?.finally?.(hookContext, evaluationDetails, options.hookHints);
377401
} catch (err) {
378402
this._logger.error(`Unhandled error during 'finally' hook: ${err}`);

packages/server/src/hooks/hook.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type { BaseHook, EvaluationContext, FlagValue } from '@openfeature/core';
22

3-
export type Hook = BaseHook<
3+
export type Hook<TData = Record<string, unknown>> = BaseHook<
44
FlagValue,
5+
TData,
56
Promise<EvaluationContext | void> | EvaluationContext | void,
67
Promise<void> | void
78
>;

0 commit comments

Comments
 (0)