1- import type {
2- EvaluationContext ,
3- EvaluationDetails ,
4- FlagValue ,
5- Hook ,
6- HookContext ,
7- HookHints ,
1+ import type { Logger } from '@openfeature/web-sdk' ;
2+ import {
3+ ErrorCode ,
4+ OpenFeatureError ,
5+ type EvaluationContext ,
6+ type EvaluationDetails ,
7+ type FlagValue ,
8+ type Hook ,
9+ type HookContext ,
10+ type HookHints ,
811} from '@openfeature/web-sdk' ;
912import { FixedSizeExpiringCache } from './utils/fixed-size-expiring-cache' ;
1013
14+ const DEFAULT_CACHE_KEY_SUPPLIER = ( flagKey : string ) => flagKey ;
15+ type StageResult = true | CachedError ;
16+ type HookStagesEntry = { before ?: StageResult ; after ?: StageResult ; error ?: StageResult ; finally ?: StageResult } ;
17+
1118/**
1219 * An error cached from a previous hook invocation.
1320 */
14- export class CachedError extends Error {
21+ export class CachedError extends OpenFeatureError {
1522 private _innerError : unknown ;
1623
1724 constructor ( innerError : unknown ) {
@@ -27,60 +34,27 @@ export class CachedError extends Error {
2734 get innerError ( ) {
2835 return this . _innerError ;
2936 }
37+
38+ get code ( ) {
39+ if ( this . _innerError instanceof OpenFeatureError ) {
40+ return this . _innerError . code ;
41+ }
42+ return ErrorCode . GENERAL ;
43+ }
3044}
3145
32- export type Options < T extends FlagValue = FlagValue > = {
46+ export type Options = {
3347 /**
34- * Function to generate the cache key for the before stage of the wrapped hook.
48+ * Function to generate the cache key for the wrapped hook.
3549 * If the cache key is found in the cache, the hook stage will not run.
36- * If not defined , the DebounceHook will no-op for this stage (inner hook will always run for this stage) .
50+ * By default , the flag key is used as the cache key .
3751 *
3852 * @param flagKey the flag key
3953 * @param context the evaluation context
4054 * @returns cache key for this stage
55+ * @default (flagKey) => flagKey
4156 */
42- beforeCacheKeySupplier ?: ( flagKey : string , context : EvaluationContext ) => string | null | undefined ;
43- /**
44- * Function to generate the cache key for the after stage of the wrapped hook.
45- * If the cache key is found in the cache, the hook stage will not run.
46- * If not defined, the DebounceHook will no-op for this stage (inner hook will always run for this stage).
47- *
48- * @param flagKey the flag key
49- * @param context the evaluation context
50- * @param details the evaluation details
51- * @returns cache key for this stage
52- */
53- afterCacheKeySupplier ?: (
54- flagKey : string ,
55- context : EvaluationContext ,
56- details : EvaluationDetails < T > ,
57- ) => string | null | undefined ;
58- /**
59- * Function to generate the cache key for the error stage of the wrapped hook.
60- * If the cache key is found in the cache, the hook stage will not run.
61- * If not defined, the DebounceHook will no-op for this stage (inner hook will always run for this stage).
62- *
63- * @param flagKey the flag key
64- * @param context the evaluation context
65- * @param err the Error
66- * @returns cache key for this stage
67- */
68- errorCacheKeySupplier ?: ( flagKey : string , context : EvaluationContext , err : unknown ) => string | null | undefined ;
69- /**
70- * Function to generate the cache key for the error stage of the wrapped hook.
71- * If the cache key is found in the cache, the hook stage will not run.
72- * If not defined, the DebounceHook will no-op for this stage (inner hook will always run for this stage).
73- *
74- * @param flagKey the flag key
75- * @param context the evaluation context
76- * @param details the evaluation details
77- * @returns cache key for this stage
78- */
79- finallyCacheKeySupplier ?: (
80- flagKey : string ,
81- context : EvaluationContext ,
82- details : EvaluationDetails < T > ,
83- ) => string | null | undefined ;
57+ cacheKeySupplier ?: ( flagKey : string , context : EvaluationContext ) => string | null | undefined ;
8458 /**
8559 * Whether or not to debounce and cache the errors thrown by hook stages.
8660 * If false (default) stages that throw will not be debounced and their errors not cached.
@@ -95,24 +69,29 @@ export type Options<T extends FlagValue = FlagValue> = {
9569 * Max number of items to be kept in cache before the oldest entry falls out.
9670 */
9771 maxCacheItems : number ;
72+ /**
73+ * Optional logger.
74+ */
75+ logger ?: Logger ;
9876} ;
9977
10078/**
10179 * A hook that wraps another hook and debounces its execution based on the provided options.
102- * Each stage of the hook (before, after, error, finally) is debounced independently.
103- * If a stage is called with a cache key that has been seen within the debounce time, the inner hook's stage will not run.
80+ * The cacheKeySupplier is used to generate a cache key for the hook, which is used to determine if the hook should be executed or skipped.
10481 * If no cache key supplier is provided for a stage, that stage will always run.
10582 */
10683export class DebounceHook < T extends FlagValue = FlagValue > implements Hook {
107- private readonly cache : FixedSizeExpiringCache < true | CachedError > ;
84+ private readonly cache : FixedSizeExpiringCache < HookStagesEntry > ;
10885 private readonly cacheErrors : boolean ;
86+ private readonly cacheKeySupplier : Options [ 'cacheKeySupplier' ] ;
10987
11088 public constructor (
11189 private readonly innerHook : Hook ,
112- private readonly options : Options < T > ,
90+ private readonly options : Options ,
11391 ) {
11492 this . cacheErrors = options . cacheErrors ?? false ;
115- this . cache = new FixedSizeExpiringCache < true | CachedError > ( {
93+ this . cacheKeySupplier = options . cacheKeySupplier ?? DEFAULT_CACHE_KEY_SUPPLIER ;
94+ this . cache = new FixedSizeExpiringCache < HookStagesEntry > ( {
11695 maxItems : options . maxCacheItems ,
11796 ttlMs : options . debounceTime ,
11897 } ) ;
@@ -121,31 +100,31 @@ export class DebounceHook<T extends FlagValue = FlagValue> implements Hook {
121100 before ( hookContext : HookContext , hookHints ?: HookHints ) {
122101 this . maybeSkipAndCache (
123102 'before' ,
124- ( ) => this . options ?. beforeCacheKeySupplier ?.( hookContext . flagKey , hookContext . context ) ,
103+ ( ) => this . cacheKeySupplier ?.( hookContext . flagKey , hookContext . context ) ,
125104 ( ) => this . innerHook ?. before ?.( hookContext , hookHints ) ,
126105 ) ;
127106 }
128107
129108 after ( hookContext : HookContext , evaluationDetails : EvaluationDetails < T > , hookHints ?: HookHints ) {
130109 this . maybeSkipAndCache (
131110 'after' ,
132- ( ) => this . options ?. afterCacheKeySupplier ?. ( hookContext . flagKey , hookContext . context , evaluationDetails ) ,
111+ ( ) => this . cacheKeySupplier ?. ( hookContext . flagKey , hookContext . context ) ,
133112 ( ) => this . innerHook ?. after ?.( hookContext , evaluationDetails , hookHints ) ,
134113 ) ;
135114 }
136115
137116 error ( hookContext : HookContext , err : unknown , hookHints ?: HookHints ) {
138117 this . maybeSkipAndCache (
139118 'error' ,
140- ( ) => this . options ?. errorCacheKeySupplier ?. ( hookContext . flagKey , hookContext . context , err ) ,
119+ ( ) => this . cacheKeySupplier ?. ( hookContext . flagKey , hookContext . context ) ,
141120 ( ) => this . innerHook ?. error ?.( hookContext , err , hookHints ) ,
142121 ) ;
143122 }
144123
145124 finally ( hookContext : HookContext , evaluationDetails : EvaluationDetails < T > , hookHints ?: HookHints ) {
146125 this . maybeSkipAndCache (
147126 'finally' ,
148- ( ) => this . options ?. finallyCacheKeySupplier ?. ( hookContext . flagKey , hookContext . context , evaluationDetails ) ,
127+ ( ) => this . cacheKeySupplier ?. ( hookContext . flagKey , hookContext . context ) ,
149128 ( ) => this . innerHook ?. finally ?.( hookContext , evaluationDetails , hookHints ) ,
150129 ) ;
151130 }
@@ -156,34 +135,49 @@ export class DebounceHook<T extends FlagValue = FlagValue> implements Hook {
156135 hookCallback : ( ) => void ,
157136 ) {
158137 // the cache key is a concatenation of the result of calling keyGenCallback and the stage
159- const dynamicKey = keyGenCallback ( ) ;
138+ let dynamicKey : string | null | undefined ;
139+
140+ try {
141+ dynamicKey = keyGenCallback ( ) ;
142+ } catch ( e ) {
143+ // if the keyGenCallback throws, we log and run the hook stage
144+ this . options . logger ?. error (
145+ `DebounceHook: cacheKeySupplier threw an error, running inner hook stage "${ stage } " without debouncing.` ,
146+ e ,
147+ ) ;
148+ }
160149
161- // if the keyGenCallback returns nothing, we don't do any caching
150+ // if the keyGenCallback returns nothing, we don't do any caching
162151 if ( ! dynamicKey ) {
163152 hookCallback . call ( this . innerHook ) ;
153+ return ;
164154 }
165-
155+
166156 const cacheKeySuffix = stage ;
167157 const cacheKey = `${ dynamicKey } ::${ cacheKeySuffix } ` ;
168158 const got = this . cache . get ( cacheKey ) ;
169159
170160 if ( got ) {
161+ const cachedStageResult = got [ stage ] ;
171162 // throw cached errors
172- if ( got instanceof CachedError ) {
163+ if ( cachedStageResult instanceof CachedError ) {
173164 throw got ;
174165 }
175- return ;
176- }
177-
166+ if ( cachedStageResult === true ) {
167+ // already ran this stage for this key and is still in the debounce period
168+ return ;
169+ }
170+ }
171+
178172 try {
179173 hookCallback . call ( this . innerHook ) ;
180- this . cache . set ( cacheKey , true ) ;
174+ this . cache . set ( cacheKey , { ... got , [ stage ] : true } ) ;
181175 } catch ( error : unknown ) {
182176 if ( this . cacheErrors ) {
183177 // cache error
184- this . cache . set ( cacheKey , new CachedError ( error ) ) ;
178+ this . cache . set ( cacheKey , { ... got , [ stage ] : new CachedError ( error ) } ) ;
185179 }
186180 throw error ;
187- }
181+ }
188182 }
189183}
0 commit comments