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 ,
8
11
} from '@openfeature/web-sdk' ;
9
12
import { FixedSizeExpiringCache } from './utils/fixed-size-expiring-cache' ;
10
13
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
+
11
18
/**
12
19
* An error cached from a previous hook invocation.
13
20
*/
14
- export class CachedError extends Error {
21
+ export class CachedError extends OpenFeatureError {
15
22
private _innerError : unknown ;
16
23
17
24
constructor ( innerError : unknown ) {
@@ -27,60 +34,27 @@ export class CachedError extends Error {
27
34
get innerError ( ) {
28
35
return this . _innerError ;
29
36
}
37
+
38
+ get code ( ) {
39
+ if ( this . _innerError instanceof OpenFeatureError ) {
40
+ return this . _innerError . code ;
41
+ }
42
+ return ErrorCode . GENERAL ;
43
+ }
30
44
}
31
45
32
- export type Options < T extends FlagValue = FlagValue > = {
46
+ export type Options = {
33
47
/**
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.
35
49
* 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 .
37
51
*
38
52
* @param flagKey the flag key
39
53
* @param context the evaluation context
40
54
* @returns cache key for this stage
55
+ * @default (flagKey) => flagKey
41
56
*/
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 ;
84
58
/**
85
59
* Whether or not to debounce and cache the errors thrown by hook stages.
86
60
* 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> = {
95
69
* Max number of items to be kept in cache before the oldest entry falls out.
96
70
*/
97
71
maxCacheItems : number ;
72
+ /**
73
+ * Optional logger.
74
+ */
75
+ logger ?: Logger ;
98
76
} ;
99
77
100
78
/**
101
79
* 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.
104
81
* If no cache key supplier is provided for a stage, that stage will always run.
105
82
*/
106
83
export class DebounceHook < T extends FlagValue = FlagValue > implements Hook {
107
- private readonly cache : FixedSizeExpiringCache < true | CachedError > ;
84
+ private readonly cache : FixedSizeExpiringCache < HookStagesEntry > ;
108
85
private readonly cacheErrors : boolean ;
86
+ private readonly cacheKeySupplier : Options [ 'cacheKeySupplier' ] ;
109
87
110
88
public constructor (
111
89
private readonly innerHook : Hook ,
112
- private readonly options : Options < T > ,
90
+ private readonly options : Options ,
113
91
) {
114
92
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 > ( {
116
95
maxItems : options . maxCacheItems ,
117
96
ttlMs : options . debounceTime ,
118
97
} ) ;
@@ -121,31 +100,31 @@ export class DebounceHook<T extends FlagValue = FlagValue> implements Hook {
121
100
before ( hookContext : HookContext , hookHints ?: HookHints ) {
122
101
this . maybeSkipAndCache (
123
102
'before' ,
124
- ( ) => this . options ?. beforeCacheKeySupplier ?.( hookContext . flagKey , hookContext . context ) ,
103
+ ( ) => this . cacheKeySupplier ?.( hookContext . flagKey , hookContext . context ) ,
125
104
( ) => this . innerHook ?. before ?.( hookContext , hookHints ) ,
126
105
) ;
127
106
}
128
107
129
108
after ( hookContext : HookContext , evaluationDetails : EvaluationDetails < T > , hookHints ?: HookHints ) {
130
109
this . maybeSkipAndCache (
131
110
'after' ,
132
- ( ) => this . options ?. afterCacheKeySupplier ?. ( hookContext . flagKey , hookContext . context , evaluationDetails ) ,
111
+ ( ) => this . cacheKeySupplier ?. ( hookContext . flagKey , hookContext . context ) ,
133
112
( ) => this . innerHook ?. after ?.( hookContext , evaluationDetails , hookHints ) ,
134
113
) ;
135
114
}
136
115
137
116
error ( hookContext : HookContext , err : unknown , hookHints ?: HookHints ) {
138
117
this . maybeSkipAndCache (
139
118
'error' ,
140
- ( ) => this . options ?. errorCacheKeySupplier ?. ( hookContext . flagKey , hookContext . context , err ) ,
119
+ ( ) => this . cacheKeySupplier ?. ( hookContext . flagKey , hookContext . context ) ,
141
120
( ) => this . innerHook ?. error ?.( hookContext , err , hookHints ) ,
142
121
) ;
143
122
}
144
123
145
124
finally ( hookContext : HookContext , evaluationDetails : EvaluationDetails < T > , hookHints ?: HookHints ) {
146
125
this . maybeSkipAndCache (
147
126
'finally' ,
148
- ( ) => this . options ?. finallyCacheKeySupplier ?. ( hookContext . flagKey , hookContext . context , evaluationDetails ) ,
127
+ ( ) => this . cacheKeySupplier ?. ( hookContext . flagKey , hookContext . context ) ,
149
128
( ) => this . innerHook ?. finally ?.( hookContext , evaluationDetails , hookHints ) ,
150
129
) ;
151
130
}
@@ -156,34 +135,49 @@ export class DebounceHook<T extends FlagValue = FlagValue> implements Hook {
156
135
hookCallback : ( ) => void ,
157
136
) {
158
137
// 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
+ }
160
149
161
- // if the keyGenCallback returns nothing, we don't do any caching
150
+ // if the keyGenCallback returns nothing, we don't do any caching
162
151
if ( ! dynamicKey ) {
163
152
hookCallback . call ( this . innerHook ) ;
153
+ return ;
164
154
}
165
-
155
+
166
156
const cacheKeySuffix = stage ;
167
157
const cacheKey = `${ dynamicKey } ::${ cacheKeySuffix } ` ;
168
158
const got = this . cache . get ( cacheKey ) ;
169
159
170
160
if ( got ) {
161
+ const cachedStageResult = got [ stage ] ;
171
162
// throw cached errors
172
- if ( got instanceof CachedError ) {
163
+ if ( cachedStageResult instanceof CachedError ) {
173
164
throw got ;
174
165
}
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
+
178
172
try {
179
173
hookCallback . call ( this . innerHook ) ;
180
- this . cache . set ( cacheKey , true ) ;
174
+ this . cache . set ( cacheKey , { ... got , [ stage ] : true } ) ;
181
175
} catch ( error : unknown ) {
182
176
if ( this . cacheErrors ) {
183
177
// cache error
184
- this . cache . set ( cacheKey , new CachedError ( error ) ) ;
178
+ this . cache . set ( cacheKey , { ... got , [ stage ] : new CachedError ( error ) } ) ;
185
179
}
186
180
throw error ;
187
- }
181
+ }
188
182
}
189
183
}
0 commit comments