Skip to content

Commit 4d53cea

Browse files
committed
fixup: support web and server
Signed-off-by: Todd Baert <[email protected]>
1 parent 69e462b commit 4d53cea

File tree

4 files changed

+237
-65
lines changed

4 files changed

+237
-65
lines changed

libs/hooks/debounce/README.md

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,13 @@ $ npm install @openfeature/debounce-hook
1111

1212
### Peer dependencies
1313

14-
Confirm that the following peer dependencies are installed:
15-
16-
```
17-
$ npm install @openfeature/web-sdk
18-
```
19-
20-
NOTE: if you're using the React or Angular OpenFeature SDKs, you don't need to directly install the web SDK.
14+
This package only requires the `@openfeature/core` dependency, which is installed automatically no matter which OpenFeature JavaScript SDK you are using.
2115

2216
## Usage
2317

24-
The hook maintains a simple expiring cache with a fixed max size and keeps a record of recent evaluations based on a user-defined key-generation function (keySupplier).
25-
Simply wrap your hook with the debounce hook by passing it a constructor arg, and then configure the remaining options.
26-
In the example below, we wrap a logging hook. This debounces all its stages, so it only logs a maximum of once a minute for each flag key, no matter how many times that flag is evaluated.
18+
Simply wrap your hook with the debounce hook by passing it as a constructor arg, and then configure the remaining options.
19+
In the example below, we wrap a logging hook.
20+
This debounces all its stages, so it only logs a maximum of once a minute for each flag key, no matter how many times that flag is evaluated.
2721

2822
```ts
2923
const debounceHook = new DebounceHook<string>(loggingHook, {
@@ -38,6 +32,18 @@ OpenFeature.addHooks(debounceHook);
3832
client.addHooks(debounceHook);
3933
```
4034

35+
The hook maintains a simple expiring cache with a fixed max size and keeps a record of recent evaluations based on an optional key-generation function (keySupplier).
36+
Be default, the key-generation function is purely based on the flag key.
37+
Particularly in server use-cases, you may want to take the targetingKey or other contextual information into account in your debouncing:
38+
39+
```ts
40+
const debounceHook = new DebounceHook<string>(loggingHook, {
41+
cacheKeySupplier: (flagKey, context) => flagKey + context.targetingKey, // cache on a combination of user and flag key
42+
debounceTime: 60_000,
43+
maxCacheItems: 1000,
44+
});
45+
```
46+
4147
## Development
4248

4349
### Building

libs/hooks/debounce/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,6 @@
1212
},
1313
"license": "Apache-2.0",
1414
"peerDependencies": {
15-
"@openfeature/web-sdk": "^1.6.0"
15+
"@openfeature/core": "^1.9.1"
1616
}
1717
}

libs/hooks/debounce/src/lib/debounce-hook.spec.ts

Lines changed: 158 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
1-
import type { EvaluationDetails, Hook, HookContext } from '@openfeature/web-sdk';
1+
import type { EvaluationDetails, BaseHook, HookContext } from '@openfeature/core';
22
import { DebounceHook } from './debounce-hook';
3+
import type { Hook as WebSdkHook } from '@openfeature/web-sdk';
4+
import type { Hook as ServerSdkHook } from '@openfeature/server-sdk';
35

46
describe('DebounceHook', () => {
57
describe('caching', () => {
68
afterAll(() => {
79
jest.resetAllMocks();
810
});
911

10-
const innerHook: Hook = {
12+
const innerHook: BaseHook<string, void, void> = {
1113
before: jest.fn(),
1214
after: jest.fn(),
1315
error: jest.fn(),
@@ -40,10 +42,10 @@ describe('DebounceHook', () => {
4042
calledTimesTotal: 2, // should not have been incremented, same cache key
4143
},
4244
])('should cache each stage based on supplier', ({ flagKey, calledTimesTotal }) => {
43-
hook.before({ flagKey, context } as HookContext, hints);
44-
hook.after({ flagKey, context } as HookContext, evaluationDetails, hints);
45-
hook.error({ flagKey, context } as HookContext, err, hints);
46-
hook.finally({ flagKey, context } as HookContext, evaluationDetails, hints);
45+
hook.before({ flagKey, context } as HookContext<string>, hints);
46+
hook.after({ flagKey, context } as HookContext<string>, evaluationDetails, hints);
47+
hook.error({ flagKey, context } as HookContext<string>, err, hints);
48+
hook.finally({ flagKey, context } as HookContext<string>, evaluationDetails, hints);
4749

4850
expect(innerHook.before).toHaveBeenNthCalledWith(calledTimesTotal, expect.objectContaining({ context }), hints);
4951
expect(innerHook.after).toHaveBeenNthCalledWith(
@@ -67,7 +69,7 @@ describe('DebounceHook', () => {
6769
});
6870

6971
it('stages should be cached independently', () => {
70-
const innerHook: Hook = {
72+
const innerHook: BaseHook<boolean, void, void> = {
7173
before: jest.fn(),
7274
after: jest.fn(),
7375
};
@@ -79,8 +81,8 @@ describe('DebounceHook', () => {
7981

8082
const flagKey = 'my-flag';
8183

82-
hook.before({ flagKey } as HookContext, {});
83-
hook.after({ flagKey } as HookContext, {
84+
hook.before({ flagKey } as HookContext<boolean>, {});
85+
hook.after({ flagKey } as HookContext<boolean>, {
8486
flagKey,
8587
flagMetadata: {},
8688
value: true,
@@ -98,7 +100,7 @@ describe('DebounceHook', () => {
98100
});
99101

100102
it('maxCacheItems should limit size', () => {
101-
const innerHook: Hook = {
103+
const innerHook: BaseHook<string, void, void> = {
102104
before: jest.fn(),
103105
};
104106

@@ -107,57 +109,59 @@ describe('DebounceHook', () => {
107109
maxCacheItems: 1,
108110
});
109111

110-
hook.before({ flagKey: 'flag1' } as HookContext, {});
111-
hook.before({ flagKey: 'flag2' } as HookContext, {});
112-
hook.before({ flagKey: 'flag1' } as HookContext, {});
112+
hook.before({ flagKey: 'flag1' } as HookContext<string>, {});
113+
hook.before({ flagKey: 'flag2' } as HookContext<string>, {});
114+
hook.before({ flagKey: 'flag1' } as HookContext<string>, {});
113115

114116
// every invocation should have run since we have only maxCacheItems: 1
115117
expect(innerHook.before).toHaveBeenCalledTimes(3);
116118
});
117119

118120
it('should rerun inner hook only after debounce time', async () => {
119-
const innerHook: Hook = {
121+
const innerHook: BaseHook<string, void, void> = {
120122
before: jest.fn(),
121123
};
122124

123125
const flagKey = 'some-flag';
124126

125-
const hook = new DebounceHook<string>(innerHook, {
127+
const hook = new DebounceHook(innerHook, {
126128
debounceTime: 500,
127129
maxCacheItems: 1,
128130
});
129131

130-
hook.before({ flagKey } as HookContext, {});
131-
hook.before({ flagKey } as HookContext, {});
132-
hook.before({ flagKey } as HookContext, {});
132+
hook.before({ flagKey } as HookContext<string>, {});
133+
hook.before({ flagKey } as HookContext<string>, {});
134+
hook.before({ flagKey } as HookContext<string>, {});
133135

134136
await new Promise((r) => setTimeout(r, 1000));
135137

136-
hook.before({ flagKey } as HookContext, {});
138+
hook.before({ flagKey } as HookContext<string>, {});
137139

138140
// only the first and last should have invoked the inner hook
139141
expect(innerHook.before).toHaveBeenCalledTimes(2);
140142
});
141143

142144
it('use custom supplier', () => {
143-
const innerHook: Hook = {
145+
const innerHook: BaseHook<number, void, void> = {
144146
before: jest.fn(),
145147
after: jest.fn(),
146148
error: jest.fn(),
147149
finally: jest.fn(),
148150
};
149151

150-
const context = {};
152+
const context = {
153+
targetingKey: 'user123',
154+
};
151155
const hints = {};
152156

153-
const hook = new DebounceHook<string>(innerHook, {
154-
cacheKeySupplier: () => 'a-silly-const-key', // a constant key means all invocations are cached; just to test that the custom supplier is used
157+
const hook = new DebounceHook<number>(innerHook, {
158+
cacheKeySupplier: (_, context) => context.targetingKey, // we are caching purely based on the targetingKey in the context, so we will only ever cache one entry
155159
debounceTime: 60_000,
156160
maxCacheItems: 100,
157161
});
158162

159-
hook.before({ flagKey: 'flag1', context } as HookContext, hints);
160-
hook.before({ flagKey: 'flag2', context } as HookContext, hints);
163+
hook.before({ flagKey: 'flag1', context } as HookContext<number>, hints);
164+
hook.before({ flagKey: 'flag2', context } as HookContext<number>, hints);
161165

162166
// since we used a constant key, the second invocation should have been cached even though the flagKey was different
163167
expect(innerHook.before).toHaveBeenCalledTimes(1);
@@ -173,7 +177,7 @@ describe('DebounceHook', () => {
173177
timesCalled: 1, // should be called once since we cached the error
174178
},
175179
])('should cache errors if cacheErrors set', ({ cacheErrors, timesCalled }) => {
176-
const innerErrorHook: Hook = {
180+
const innerErrorHook: BaseHook<string[], void, void> = {
177181
before: jest.fn(() => {
178182
// throw an error
179183
throw new Error('fake!');
@@ -184,16 +188,141 @@ describe('DebounceHook', () => {
184188
const context = {};
185189

186190
// this hook caches error invocations
187-
const hook = new DebounceHook<string>(innerErrorHook, {
191+
const hook = new DebounceHook<string[]>(innerErrorHook, {
188192
maxCacheItems: 100,
189193
debounceTime: 60_000,
190194
cacheErrors,
191195
});
192196

193-
expect(() => hook.before({ flagKey, context } as HookContext)).toThrow();
194-
expect(() => hook.before({ flagKey, context } as HookContext)).toThrow();
197+
expect(() => hook.before({ flagKey, context } as HookContext<string[]>)).toThrow();
198+
expect(() => hook.before({ flagKey, context } as HookContext<string[]>)).toThrow();
195199

196200
expect(innerErrorHook.before).toHaveBeenCalledTimes(timesCalled);
197201
});
198202
});
203+
204+
describe('SDK compatibility', () => {
205+
describe('web-sdk hooks', () => {
206+
it('should debounce synchronous hooks', () => {
207+
const innerWebSdkHook: WebSdkHook = {
208+
before: jest.fn(),
209+
after: jest.fn(),
210+
error: jest.fn(),
211+
finally: jest.fn(),
212+
};
213+
214+
const hook = new DebounceHook<string>(innerWebSdkHook, {
215+
debounceTime: 60_000,
216+
maxCacheItems: 100,
217+
});
218+
219+
const evaluationDetails: EvaluationDetails<string> = {
220+
value: 'testValue',
221+
} as EvaluationDetails<string>;
222+
const err: Error = new Error('fake error!');
223+
const context = {};
224+
const hints = {};
225+
const flagKey = 'flag1';
226+
227+
for (let i = 0; i < 2; i++) {
228+
hook.before({ flagKey, context } as HookContext<string>, hints);
229+
hook.after({ flagKey, context } as HookContext<string>, evaluationDetails, hints);
230+
hook.error({ flagKey, context } as HookContext<string>, err, hints);
231+
hook.finally({ flagKey, context } as HookContext<string>, evaluationDetails, hints);
232+
}
233+
234+
expect(innerWebSdkHook.before).toHaveBeenCalledTimes(1);
235+
});
236+
});
237+
238+
describe('server-sdk hooks', () => {
239+
const contextKey = 'key';
240+
const contextValue = 'value';
241+
const evaluationContext = { [contextKey]: contextValue };
242+
it('should debounce synchronous hooks', () => {
243+
const innerServerSdkHook: ServerSdkHook = {
244+
before: jest.fn(() => {
245+
return evaluationContext;
246+
}),
247+
after: jest.fn(),
248+
error: jest.fn(),
249+
finally: jest.fn(),
250+
};
251+
252+
const hook = new DebounceHook<number>(innerServerSdkHook, {
253+
debounceTime: 60_000,
254+
maxCacheItems: 100,
255+
});
256+
257+
const evaluationDetails: EvaluationDetails<number> = {
258+
value: 1337,
259+
} as EvaluationDetails<number>;
260+
const err: Error = new Error('fake error!');
261+
const context = {};
262+
const hints = {};
263+
const flagKey = 'flag1';
264+
265+
for (let i = 0; i < 2; i++) {
266+
const returnedContext = hook.before({ flagKey, context } as HookContext<number>, hints);
267+
// make sure we return the expected context each time
268+
expect(returnedContext).toEqual(expect.objectContaining(evaluationContext));
269+
hook.after({ flagKey, context } as HookContext<number>, evaluationDetails, hints);
270+
hook.error({ flagKey, context } as HookContext<number>, err, hints);
271+
hook.finally({ flagKey, context } as HookContext<number>, evaluationDetails, hints);
272+
}
273+
274+
// all stages should have been called only once
275+
expect(innerServerSdkHook.before).toHaveBeenCalledTimes(1);
276+
expect(innerServerSdkHook.after).toHaveBeenCalledTimes(1);
277+
expect(innerServerSdkHook.error).toHaveBeenCalledTimes(1);
278+
expect(innerServerSdkHook.finally).toHaveBeenCalledTimes(1);
279+
});
280+
281+
it('should debounce asynchronous hooks', async () => {
282+
const delayMs = 100;
283+
const innerServerSdkHook: ServerSdkHook = {
284+
before: jest.fn(() => {
285+
return new Promise((resolve) => setTimeout(() => resolve(evaluationContext), delayMs));
286+
}),
287+
after: jest.fn(() => {
288+
return new Promise((resolve) => setTimeout(() => resolve(), delayMs));
289+
}),
290+
error: jest.fn(() => {
291+
return new Promise((resolve) => setTimeout(() => resolve(), delayMs));
292+
}),
293+
finally: jest.fn(() => {
294+
return new Promise((resolve) => setTimeout(() => resolve(), delayMs));
295+
}),
296+
};
297+
298+
const hook = new DebounceHook<number>(innerServerSdkHook, {
299+
debounceTime: 60_000,
300+
maxCacheItems: 100,
301+
});
302+
303+
const evaluationDetails: EvaluationDetails<number> = {
304+
value: 1337,
305+
} as EvaluationDetails<number>;
306+
const err: Error = new Error('fake error!');
307+
const context = {};
308+
const hints = {};
309+
const flagKey = 'flag1';
310+
311+
for (let i = 0; i < 2; i++) {
312+
const returnedContext = await hook.before({ flagKey, context } as HookContext<number>, hints);
313+
// make sure we return the expected context each time
314+
expect(returnedContext).toEqual(expect.objectContaining(evaluationContext));
315+
await hook.after({ flagKey, context } as HookContext<number>, evaluationDetails, hints);
316+
await hook.error({ flagKey, context } as HookContext<number>, err, hints);
317+
await hook.finally({ flagKey, context } as HookContext<number>, evaluationDetails, hints);
318+
}
319+
320+
// each stage should have been called only once
321+
expect(innerServerSdkHook.before).toHaveBeenCalledTimes(1);
322+
expect(innerServerSdkHook.after).toHaveBeenCalledTimes(1);
323+
expect(innerServerSdkHook.error).toHaveBeenCalledTimes(1);
324+
expect(innerServerSdkHook.finally).toHaveBeenCalledTimes(1);
325+
});
326+
});
327+
});
199328
});

0 commit comments

Comments
 (0)