Skip to content

Commit 5aa11ce

Browse files
committed
feat: add debounce hook
Signed-off-by: Todd Baert <[email protected]>
1 parent 15ae73b commit 5aa11ce

17 files changed

+774
-1
lines changed

.release-please-manifest.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,6 @@
2222
"libs/providers/unleash-web": "0.1.1",
2323
"libs/providers/growthbook": "0.1.2",
2424
"libs/providers/aws-ssm": "0.1.3",
25-
"libs/providers/flagsmith": "0.1.1"
25+
"libs/providers/flagsmith": "0.1.1",
26+
"libs/hooks/debounce": "0.1.0"
2627
}

libs/hooks/debounce/.eslintrc.json

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"extends": "../../../.eslintrc.json",
3+
"ignorePatterns": ["!**/*"],
4+
"overrides": [
5+
{
6+
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
7+
"rules": {}
8+
},
9+
{
10+
"files": ["*.ts", "*.tsx"],
11+
"rules": {}
12+
},
13+
{
14+
"files": ["*.js", "*.jsx"],
15+
"rules": {}
16+
},
17+
{
18+
"files": ["*.json"],
19+
"parser": "jsonc-eslint-parser",
20+
"rules": {
21+
"@nx/dependency-checks": [
22+
"error",
23+
{
24+
"ignoredFiles": ["{projectRoot}/eslint.config.{js,cjs,mjs}"]
25+
}
26+
]
27+
}
28+
}
29+
]
30+
}

libs/hooks/debounce/README.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Debounce Hook
2+
3+
This is a utility "meta" hook, which can be used to effectively debounce or rate limit other hooks based on various parameters.
4+
This can be especially useful for certain UI frameworks and SDKs that frequently re-render and re-evaluate flags (React, Angular, etc).
5+
6+
## Installation
7+
8+
```
9+
$ npm install @openfeature/debounce-hook
10+
```
11+
12+
### Peer dependencies
13+
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 SDKs, you don't need to directly install this web SDK.
21+
22+
## Usage
23+
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 so that it only logs a maximum of once a minute for each flag key, no matter how many times that flag is evaluated.
27+
28+
```ts
29+
// a function defining the key for the hook stage
30+
const supplier = (flagKey: string, context: EvaluationContext, details: EvaluationDetails<T>) => flagKey;
31+
32+
const hook = new DebounceHook<string>(loggingHook, {
33+
afterCacheKeySupplier: supplier, // if the key calculated by the supplier exists in the cache, the wrapped hook's stage will not run
34+
ttlMs: 60_000, // how long to cache keys for
35+
maxCacheItems: 100, // max amount of items to keep in the LRU cache
36+
cacheErrors: false // whether or not to cache the errors thrown by hook stages
37+
});
38+
```
39+
40+
## Development
41+
42+
### Building
43+
44+
Run `nx package hooks-debounce` to build the library.
45+
46+
### Running unit tests
47+
48+
Run `nx test hooks-debounce` to execute the unit tests via [Jest](https://jestjs.io).
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"presets": [["minify", { "builtIns": false }]]
3+
}

libs/hooks/debounce/jest.config.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export default {
2+
displayName: 'debounce',
3+
preset: '../../../jest.preset.js',
4+
transform: {
5+
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
6+
},
7+
moduleFileExtensions: ['ts', 'js', 'html'],
8+
coverageDirectory: '../coverage/hooks',
9+
};

libs/hooks/debounce/package.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"name": "@openfeature/debounce-hook",
3+
"version": "0.0.1",
4+
"dependencies": {
5+
"tslib": "^2.3.0"
6+
},
7+
"main": "./src/index.js",
8+
"typings": "./src/index.d.ts",
9+
"scripts": {
10+
"publish-if-not-exists": "cp $NPM_CONFIG_USERCONFIG .npmrc && if [ \"$(npm show $npm_package_name@$npm_package_version version)\" = \"$(npm run current-version -s)\" ]; then echo 'already published, skipping'; else npm publish --access public; fi",
11+
"current-version": "echo $npm_package_version"
12+
},
13+
"license": "Apache-2.0",
14+
"peerDependencies": {
15+
"@openfeature/web-sdk": "^1.6.0"
16+
}
17+
}

libs/hooks/debounce/project.json

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
{
2+
"name": "debounce",
3+
"$schema": "../node_modules/nx/schemas/project-schema.json",
4+
"sourceRoot": "hooks/src",
5+
"projectType": "library",
6+
"release": {
7+
"version": {
8+
"generatorOptions": {
9+
"packageRoot": "dist/{projectRoot}",
10+
"currentVersionResolver": "git-tag"
11+
}
12+
}
13+
},
14+
"tags": [],
15+
"targets": {
16+
"nx-release-publish": {
17+
"options": {
18+
"packageRoot": "dist/{projectRoot}"
19+
}
20+
},
21+
"lint": {
22+
"executor": "@nx/eslint:lint"
23+
},
24+
"test": {
25+
"executor": "@nx/jest:jest",
26+
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
27+
"options": {
28+
"jestConfig": "{projectRoot}/jest.config.ts"
29+
}
30+
},
31+
"package": {
32+
"executor": "@nx/rollup:rollup",
33+
"outputs": ["{options.outputPath}"],
34+
"options": {
35+
"project": "libs/hooks/debounce/package.json",
36+
"outputPath": "dist/libs/hooks/debounce",
37+
"entryFile": "libs/hooks/debounce/src/index.ts",
38+
"tsConfig": "libs/hooks/debounce/tsconfig.lib.json",
39+
"compiler": "tsc",
40+
"generateExportsField": true,
41+
"umdName": "debounce",
42+
"external": "all",
43+
"format": ["cjs", "esm"],
44+
"assets": [
45+
{
46+
"glob": "package.json",
47+
"input": "./assets",
48+
"output": "./src/"
49+
},
50+
{
51+
"glob": "LICENSE",
52+
"input": "./",
53+
"output": "./"
54+
},
55+
{
56+
"glob": "README.md",
57+
"input": "./libs/hooks/debounce",
58+
"output": "./"
59+
}
60+
]
61+
}
62+
},
63+
"publish": {
64+
"executor": "nx:run-commands",
65+
"options": {
66+
"command": "npm run publish-if-not-exists",
67+
"cwd": "dist/libs/hooks/debounce"
68+
},
69+
"dependsOn": [
70+
{
71+
"projects": "self",
72+
"target": "package"
73+
}
74+
]
75+
}
76+
}
77+
}

libs/hooks/debounce/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './lib/debounce-hook';
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import type { EvaluationDetails, Hook, HookContext } from '@openfeature/web-sdk';
2+
import { DebounceHook } from './debounce-hook';
3+
4+
describe('DebounceHook', () => {
5+
describe('caching', () => {
6+
afterAll(() => {
7+
jest.resetAllMocks();
8+
});
9+
10+
const innerHook: Hook = {
11+
before: jest.fn(),
12+
after: jest.fn(),
13+
error: jest.fn(),
14+
finally: jest.fn(),
15+
};
16+
17+
const supplier = (flagKey: string) => flagKey;
18+
19+
const hook = new DebounceHook<string>(innerHook, {
20+
beforeCacheKeySupplier: supplier,
21+
afterCacheKeySupplier: supplier,
22+
errorCacheKeySupplier: supplier,
23+
finallyCacheKeySupplier: supplier,
24+
ttlMs: 60_000,
25+
maxCacheItems: 100,
26+
});
27+
28+
const evaluationDetails: EvaluationDetails<string> = {
29+
value: 'testValue',
30+
} as EvaluationDetails<string>;
31+
const err: Error = new Error('fake error!');
32+
const context = {};
33+
const hints = {};
34+
35+
it.each([
36+
{
37+
flagKey: 'flag1',
38+
calledTimesTotal: 1,
39+
},
40+
{
41+
flagKey: 'flag2',
42+
calledTimesTotal: 2,
43+
},
44+
{
45+
flagKey: 'flag1',
46+
calledTimesTotal: 2, // should not have been incremented, same cache key
47+
},
48+
])('should cache each stage based on supplier', ({ flagKey, calledTimesTotal }) => {
49+
hook.before({ flagKey, context } as HookContext, hints);
50+
hook.after({ flagKey, context } as HookContext, evaluationDetails, hints);
51+
hook.error({ flagKey, context } as HookContext, err, hints);
52+
hook.finally({ flagKey, context } as HookContext, evaluationDetails, hints);
53+
54+
expect(innerHook.before).toHaveBeenNthCalledWith(calledTimesTotal, expect.objectContaining({ context }), hints);
55+
expect(innerHook.after).toHaveBeenNthCalledWith(
56+
calledTimesTotal,
57+
expect.objectContaining({ context }),
58+
evaluationDetails,
59+
hints,
60+
);
61+
expect(innerHook.error).toHaveBeenNthCalledWith(
62+
calledTimesTotal,
63+
expect.objectContaining({ context }),
64+
err,
65+
hints,
66+
);
67+
expect(innerHook.finally).toHaveBeenNthCalledWith(
68+
calledTimesTotal,
69+
expect.objectContaining({ context }),
70+
evaluationDetails,
71+
hints,
72+
);
73+
});
74+
});
75+
76+
describe('options', () => {
77+
afterAll(() => {
78+
jest.resetAllMocks();
79+
});
80+
81+
it('maxCacheItems should limit size', () => {
82+
const innerHook: Hook = {
83+
before: jest.fn(),
84+
};
85+
86+
const hook = new DebounceHook<string>(innerHook, {
87+
beforeCacheKeySupplier: (flagKey: string) => flagKey,
88+
ttlMs: 60_000,
89+
maxCacheItems: 1,
90+
});
91+
92+
hook.before({ flagKey: 'flag1' } as HookContext, {});
93+
hook.before({ flagKey: 'flag2' } as HookContext, {});
94+
hook.before({ flagKey: 'flag1' } as HookContext, {});
95+
96+
// every invocation should have run since we have only maxCacheItems: 1
97+
expect(innerHook.before).toHaveBeenCalledTimes(3);
98+
});
99+
100+
it('should rerun inner hook stage only after ttl', async () => {
101+
const innerHook: Hook = {
102+
before: jest.fn(),
103+
};
104+
105+
const flagKey = 'some-flag';
106+
107+
const hook = new DebounceHook<string>(innerHook, {
108+
beforeCacheKeySupplier: (flagKey: string) => flagKey,
109+
ttlMs: 500,
110+
maxCacheItems: 1,
111+
});
112+
113+
hook.before({ flagKey } as HookContext, {});
114+
hook.before({ flagKey } as HookContext, {});
115+
hook.before({ flagKey } as HookContext, {});
116+
117+
await new Promise((r) => setTimeout(r, 1000));
118+
119+
hook.before({ flagKey } as HookContext, {});
120+
121+
// only the first and last should have invoked the inner hook
122+
expect(innerHook.before).toHaveBeenCalledTimes(2);
123+
});
124+
125+
it('noop if supplier not defined', () => {
126+
const innerHook: Hook = {
127+
before: jest.fn(),
128+
after: jest.fn(),
129+
error: jest.fn(),
130+
finally: jest.fn(),
131+
};
132+
133+
const flagKey = 'some-flag';
134+
const context = {};
135+
const hints = {};
136+
137+
// no suppliers defined, so we no-op (do no caching)
138+
const hook = new DebounceHook<string>(innerHook, {
139+
ttlMs: 60_000,
140+
maxCacheItems: 100,
141+
});
142+
143+
const evaluationDetails: EvaluationDetails<string> = {
144+
value: 'testValue',
145+
} as EvaluationDetails<string>;
146+
147+
for (let i = 0; i < 3; i++) {
148+
hook.before({ flagKey, context } as HookContext, hints);
149+
hook.after({ flagKey, context } as HookContext, evaluationDetails, hints);
150+
hook.error({ flagKey, context } as HookContext, hints);
151+
hook.finally({ flagKey, context } as HookContext, evaluationDetails, hints);
152+
}
153+
154+
// every invocation should have run since we have only maxCacheItems: 1
155+
expect(innerHook.before).toHaveBeenCalledTimes(3);
156+
expect(innerHook.after).toHaveBeenCalledTimes(3);
157+
expect(innerHook.error).toHaveBeenCalledTimes(3);
158+
expect(innerHook.finally).toHaveBeenCalledTimes(3);
159+
});
160+
161+
it.each([
162+
{
163+
cacheErrors: false,
164+
timesCalled: 2, // should be called each time since the hook always errors
165+
},
166+
{
167+
cacheErrors: true,
168+
timesCalled: 1, // should be called once since we cached the error
169+
},
170+
])('should cache errors if cacheErrors set', ({ cacheErrors, timesCalled }) => {
171+
const innerErrorHook: Hook = {
172+
before: jest.fn(() => {
173+
// throw an error
174+
throw new Error('fake!');
175+
}),
176+
};
177+
178+
const flagKey = 'some-flag';
179+
const context = {};
180+
181+
// this hook caches error invocations
182+
const hook = new DebounceHook<string>(innerErrorHook, {
183+
beforeCacheKeySupplier: (flagKey: string) => flagKey,
184+
maxCacheItems: 100,
185+
ttlMs: 60_000,
186+
cacheErrors,
187+
});
188+
189+
expect(() => hook.before({ flagKey, context } as HookContext)).toThrow();
190+
expect(() => hook.before({ flagKey, context } as HookContext)).toThrow();
191+
192+
expect(innerErrorHook.before).toHaveBeenCalledTimes(timesCalled);
193+
});
194+
});
195+
});

0 commit comments

Comments
 (0)