Skip to content

Commit 5178ec1

Browse files
author
Developer
committed
feat: add evaluation-scoped hook data
1 parent 1b3ac12 commit 5178ec1

File tree

12 files changed

+1366
-60
lines changed

12 files changed

+1366
-60
lines changed

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

Lines changed: 79 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
StandardResolutionReasons,
2323
instantiateErrorByErrorCode,
2424
statusMatchesEvent,
25+
DefaultHookData,
2526
} from '@openfeature/core';
2627
import type { FlagEvaluationOptions } from '../../evaluation';
2728
import type { ProviderEvents } from '../../events';
@@ -276,22 +277,47 @@ 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 data instances for each hook
281+
const hookDataMap = new WeakMap<Hook, DefaultHookData>();
282+
for (const hook of allHooks) {
283+
hookDataMap.set(hook, new DefaultHookData());
284+
}
285+
286+
// Create hook context instances for each hook (stable object references for the entire evaluation)
287+
// This ensures hooks can use WeakMaps with hookContext as keys across lifecycle methods
288+
const hookContextMap = new WeakMap<Hook, HookContext>();
289+
for (const hook of allHooks) {
290+
const hookContext: HookContext = {
291+
flagKey,
292+
defaultValue,
293+
flagValueType: flagType,
294+
clientMetadata: this.metadata,
295+
providerMetadata: this._provider.metadata,
296+
context: mergedContext,
297+
logger: this._logger,
298+
hookData: hookDataMap.get(hook)!,
299+
};
300+
301+
// Make the core properties immutable while allowing context updates
302+
Object.defineProperty(hookContext, 'flagKey', { writable: false, configurable: false });
303+
Object.defineProperty(hookContext, 'defaultValue', { writable: false, configurable: false });
304+
Object.defineProperty(hookContext, 'flagValueType', { writable: false, configurable: false });
305+
Object.defineProperty(hookContext, 'clientMetadata', { writable: false, configurable: false });
306+
Object.defineProperty(hookContext, 'providerMetadata', { writable: false, configurable: false });
307+
Object.defineProperty(hookContext, 'logger', { writable: false, configurable: false });
308+
Object.defineProperty(hookContext, 'hookData', { writable: false, configurable: false });
309+
310+
hookContextMap.set(hook, hookContext);
311+
}
312+
313+
// Function to get the stable hook context for a given hook
314+
const getHookContext = (hook: Hook) => hookContextMap.get(hook)!;
290315

291316
let evaluationDetails: EvaluationDetails<T>;
317+
let frozenContext = mergedContext;
292318

293319
try {
294-
const frozenContext = await this.beforeHooks(allHooks, hookContext, options);
320+
frozenContext = await this.beforeHooks(allHooks, getHookContext, mergedContext, options);
295321

296322
this.shortCircuitIfNotReady();
297323

@@ -306,53 +332,77 @@ export class OpenFeatureClient implements Client {
306332

307333
if (resolutionDetails.errorCode) {
308334
const err = instantiateErrorByErrorCode(resolutionDetails.errorCode, resolutionDetails.errorMessage);
309-
await this.errorHooks(allHooksReversed, hookContext, err, options);
335+
await this.errorHooks(allHooksReversed, getHookContext, err, options);
310336
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err, resolutionDetails.flagMetadata);
311337
} else {
312-
await this.afterHooks(allHooksReversed, hookContext, resolutionDetails, options);
338+
await this.afterHooks(allHooksReversed, getHookContext, resolutionDetails, options);
313339
evaluationDetails = resolutionDetails;
314340
}
315341
} catch (err: unknown) {
316-
await this.errorHooks(allHooksReversed, hookContext, err, options);
342+
await this.errorHooks(allHooksReversed, getHookContext, err, options);
317343
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err);
318344
}
319345

320-
await this.finallyHooks(allHooksReversed, hookContext, evaluationDetails, options);
346+
await this.finallyHooks(allHooksReversed, getHookContext, evaluationDetails, options);
321347
return evaluationDetails;
322348
}
323349

324-
private async beforeHooks(hooks: Hook[], hookContext: HookContext, options: FlagEvaluationOptions) {
350+
private async beforeHooks(
351+
hooks: Hook[],
352+
getHookContext: (hook: Hook) => HookContext,
353+
mergedContext: EvaluationContext,
354+
options: FlagEvaluationOptions
355+
) {
356+
let accumulatedContext = mergedContext;
357+
325358
for (const hook of hooks) {
326-
// freeze the hookContext
327-
Object.freeze(hookContext);
359+
const hookContext = getHookContext(hook);
360+
361+
// Update the context on the stable hook context object
362+
(hookContext as any).context = accumulatedContext;
363+
364+
const hookResult = await hook?.before?.(hookContext, Object.freeze(options.hookHints));
365+
if (hookResult) {
366+
accumulatedContext = {
367+
...accumulatedContext,
368+
...hookResult,
369+
};
370+
}
371+
}
328372

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-
});
373+
// Update all hook contexts with the final accumulated context and freeze it
374+
for (const hook of hooks) {
375+
const hookContext = getHookContext(hook);
376+
(hookContext as any).context = Object.freeze(accumulatedContext);
334377
}
335378

336379
// after before hooks, freeze the EvaluationContext.
337-
return Object.freeze(hookContext.context);
380+
return Object.freeze(accumulatedContext);
338381
}
339382

340383
private async afterHooks(
341384
hooks: Hook[],
342-
hookContext: HookContext,
385+
getHookContext: (hook: Hook) => HookContext,
343386
evaluationDetails: EvaluationDetails<FlagValue>,
344387
options: FlagEvaluationOptions,
345388
) {
346389
// run "after" hooks sequentially
347390
for (const hook of hooks) {
391+
const hookContext = getHookContext(hook);
348392
await hook?.after?.(hookContext, evaluationDetails, options.hookHints);
349393
}
350394
}
351395

352-
private async errorHooks(hooks: Hook[], hookContext: HookContext, err: unknown, options: FlagEvaluationOptions) {
396+
private async errorHooks(
397+
hooks: Hook[],
398+
getHookContext: (hook: Hook) => HookContext,
399+
err: unknown,
400+
options: FlagEvaluationOptions
401+
) {
353402
// run "error" hooks sequentially
354403
for (const hook of hooks) {
355404
try {
405+
const hookContext = getHookContext(hook);
356406
await hook?.error?.(hookContext, err, options.hookHints);
357407
} catch (err) {
358408
this._logger.error(`Unhandled error during 'error' hook: ${err}`);
@@ -366,13 +416,14 @@ export class OpenFeatureClient implements Client {
366416

367417
private async finallyHooks(
368418
hooks: Hook[],
369-
hookContext: HookContext,
419+
getHookContext: (hook: Hook) => HookContext,
370420
evaluationDetails: EvaluationDetails<FlagValue>,
371421
options: FlagEvaluationOptions,
372422
) {
373423
// run "finally" hooks sequentially
374424
for (const hook of hooks) {
375425
try {
426+
const hookContext = getHookContext(hook);
376427
await hook?.finally?.(hookContext, evaluationDetails, options.hookHints);
377428
} catch (err) {
378429
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)