From 80b81f864706b6cecedc6a567d7dd108463163a4 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Tue, 7 Oct 2025 15:33:23 -0400 Subject: [PATCH 01/22] feat: add debounce hook Signed-off-by: Todd Baert --- .release-please-manifest.json | 3 +- libs/hooks/debounce/.eslintrc.json | 30 +++ libs/hooks/debounce/README.md | 48 +++++ libs/hooks/debounce/babel.config.json | 3 + libs/hooks/debounce/jest.config.ts | 9 + libs/hooks/debounce/package.json | 17 ++ libs/hooks/debounce/project.json | 77 +++++++ libs/hooks/debounce/src/index.ts | 1 + .../debounce/src/lib/debounce-hook.spec.ts | 195 ++++++++++++++++++ libs/hooks/debounce/src/lib/debounce-hook.ts | 181 ++++++++++++++++ .../utils/fixed-size-expiring-cache.spec.ts | 72 +++++++ .../lib/utils/fixed-size-expiring-cache.ts | 89 ++++++++ libs/hooks/debounce/tsconfig.json | 22 ++ libs/hooks/debounce/tsconfig.lib.json | 10 + libs/hooks/debounce/tsconfig.spec.json | 10 + release-please-config.json | 7 + tsconfig.base.json | 1 + 17 files changed, 774 insertions(+), 1 deletion(-) create mode 100644 libs/hooks/debounce/.eslintrc.json create mode 100644 libs/hooks/debounce/README.md create mode 100644 libs/hooks/debounce/babel.config.json create mode 100644 libs/hooks/debounce/jest.config.ts create mode 100644 libs/hooks/debounce/package.json create mode 100644 libs/hooks/debounce/project.json create mode 100644 libs/hooks/debounce/src/index.ts create mode 100644 libs/hooks/debounce/src/lib/debounce-hook.spec.ts create mode 100644 libs/hooks/debounce/src/lib/debounce-hook.ts create mode 100644 libs/hooks/debounce/src/lib/utils/fixed-size-expiring-cache.spec.ts create mode 100644 libs/hooks/debounce/src/lib/utils/fixed-size-expiring-cache.ts create mode 100644 libs/hooks/debounce/tsconfig.json create mode 100644 libs/hooks/debounce/tsconfig.lib.json create mode 100644 libs/hooks/debounce/tsconfig.spec.json diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 2d49f3e53..356ddf1cd 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -22,5 +22,6 @@ "libs/providers/unleash-web": "0.1.1", "libs/providers/growthbook": "0.1.2", "libs/providers/aws-ssm": "0.1.3", - "libs/providers/flagsmith": "0.1.2" + "libs/providers/flagsmith": "0.1.2", + "libs/hooks/debounce": "0.1.0" } diff --git a/libs/hooks/debounce/.eslintrc.json b/libs/hooks/debounce/.eslintrc.json new file mode 100644 index 000000000..356462f02 --- /dev/null +++ b/libs/hooks/debounce/.eslintrc.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../.eslintrc.json", + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.json"], + "parser": "jsonc-eslint-parser", + "rules": { + "@nx/dependency-checks": [ + "error", + { + "ignoredFiles": ["{projectRoot}/eslint.config.{js,cjs,mjs}"] + } + ] + } + } + ] +} diff --git a/libs/hooks/debounce/README.md b/libs/hooks/debounce/README.md new file mode 100644 index 000000000..c28727563 --- /dev/null +++ b/libs/hooks/debounce/README.md @@ -0,0 +1,48 @@ +# Debounce Hook + +This is a utility "meta" hook, which can be used to effectively debounce or rate limit other hooks based on various parameters. +This can be especially useful for certain UI frameworks and SDKs that frequently re-render and re-evaluate flags (React, Angular, etc). + +## Installation + +``` +$ npm install @openfeature/debounce-hook +``` + +### Peer dependencies + +Confirm that the following peer dependencies are installed: + +``` +$ npm install @openfeature/web-sdk +``` + +NOTE: if you're using the React or Angular SDKs, you don't need to directly install this web SDK. + +## Usage + +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). +Simply wrap your hook with the debounce hook by passing it a constructor arg, and then configure the remaining options. +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. + +```ts +// a function defining the key for the hook stage +const supplier = (flagKey: string, context: EvaluationContext, details: EvaluationDetails) => flagKey; + +const hook = new DebounceHook(loggingHook, { + afterCacheKeySupplier: supplier, // if the key calculated by the supplier exists in the cache, the wrapped hook's stage will not run + ttlMs: 60_000, // how long to cache keys for + maxCacheItems: 100, // max amount of items to keep in the LRU cache + cacheErrors: false // whether or not to cache the errors thrown by hook stages +}); +``` + +## Development + +### Building + +Run `nx package hooks-debounce` to build the library. + +### Running unit tests + +Run `nx test hooks-debounce` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/libs/hooks/debounce/babel.config.json b/libs/hooks/debounce/babel.config.json new file mode 100644 index 000000000..d7bf474d1 --- /dev/null +++ b/libs/hooks/debounce/babel.config.json @@ -0,0 +1,3 @@ +{ + "presets": [["minify", { "builtIns": false }]] +} diff --git a/libs/hooks/debounce/jest.config.ts b/libs/hooks/debounce/jest.config.ts new file mode 100644 index 000000000..bc7424074 --- /dev/null +++ b/libs/hooks/debounce/jest.config.ts @@ -0,0 +1,9 @@ +export default { + displayName: 'debounce', + preset: '../../../jest.preset.js', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../coverage/hooks', +}; diff --git a/libs/hooks/debounce/package.json b/libs/hooks/debounce/package.json new file mode 100644 index 000000000..88eeb7291 --- /dev/null +++ b/libs/hooks/debounce/package.json @@ -0,0 +1,17 @@ +{ + "name": "@openfeature/debounce-hook", + "version": "0.0.1", + "dependencies": { + "tslib": "^2.3.0" + }, + "main": "./src/index.js", + "typings": "./src/index.d.ts", + "scripts": { + "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", + "current-version": "echo $npm_package_version" + }, + "license": "Apache-2.0", + "peerDependencies": { + "@openfeature/web-sdk": "^1.6.0" + } +} diff --git a/libs/hooks/debounce/project.json b/libs/hooks/debounce/project.json new file mode 100644 index 000000000..6231d584b --- /dev/null +++ b/libs/hooks/debounce/project.json @@ -0,0 +1,77 @@ +{ + "name": "debounce", + "$schema": "../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "hooks/src", + "projectType": "library", + "release": { + "version": { + "generatorOptions": { + "packageRoot": "dist/{projectRoot}", + "currentVersionResolver": "git-tag" + } + } + }, + "tags": [], + "targets": { + "nx-release-publish": { + "options": { + "packageRoot": "dist/{projectRoot}" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "{projectRoot}/jest.config.ts" + } + }, + "package": { + "executor": "@nx/rollup:rollup", + "outputs": ["{options.outputPath}"], + "options": { + "project": "libs/hooks/debounce/package.json", + "outputPath": "dist/libs/hooks/debounce", + "entryFile": "libs/hooks/debounce/src/index.ts", + "tsConfig": "libs/hooks/debounce/tsconfig.lib.json", + "compiler": "tsc", + "generateExportsField": true, + "umdName": "debounce", + "external": "all", + "format": ["cjs", "esm"], + "assets": [ + { + "glob": "package.json", + "input": "./assets", + "output": "./src/" + }, + { + "glob": "LICENSE", + "input": "./", + "output": "./" + }, + { + "glob": "README.md", + "input": "./libs/hooks/debounce", + "output": "./" + } + ] + } + }, + "publish": { + "executor": "nx:run-commands", + "options": { + "command": "npm run publish-if-not-exists", + "cwd": "dist/libs/hooks/debounce" + }, + "dependsOn": [ + { + "projects": "self", + "target": "package" + } + ] + } + } +} diff --git a/libs/hooks/debounce/src/index.ts b/libs/hooks/debounce/src/index.ts new file mode 100644 index 000000000..71155b938 --- /dev/null +++ b/libs/hooks/debounce/src/index.ts @@ -0,0 +1 @@ +export * from './lib/debounce-hook'; diff --git a/libs/hooks/debounce/src/lib/debounce-hook.spec.ts b/libs/hooks/debounce/src/lib/debounce-hook.spec.ts new file mode 100644 index 000000000..6ab892e43 --- /dev/null +++ b/libs/hooks/debounce/src/lib/debounce-hook.spec.ts @@ -0,0 +1,195 @@ +import type { EvaluationDetails, Hook, HookContext } from '@openfeature/web-sdk'; +import { DebounceHook } from './debounce-hook'; + +describe('DebounceHook', () => { + describe('caching', () => { + afterAll(() => { + jest.resetAllMocks(); + }); + + const innerHook: Hook = { + before: jest.fn(), + after: jest.fn(), + error: jest.fn(), + finally: jest.fn(), + }; + + const supplier = (flagKey: string) => flagKey; + + const hook = new DebounceHook(innerHook, { + beforeCacheKeySupplier: supplier, + afterCacheKeySupplier: supplier, + errorCacheKeySupplier: supplier, + finallyCacheKeySupplier: supplier, + ttlMs: 60_000, + maxCacheItems: 100, + }); + + const evaluationDetails: EvaluationDetails = { + value: 'testValue', + } as EvaluationDetails; + const err: Error = new Error('fake error!'); + const context = {}; + const hints = {}; + + it.each([ + { + flagKey: 'flag1', + calledTimesTotal: 1, + }, + { + flagKey: 'flag2', + calledTimesTotal: 2, + }, + { + flagKey: 'flag1', + calledTimesTotal: 2, // should not have been incremented, same cache key + }, + ])('should cache each stage based on supplier', ({ flagKey, calledTimesTotal }) => { + hook.before({ flagKey, context } as HookContext, hints); + hook.after({ flagKey, context } as HookContext, evaluationDetails, hints); + hook.error({ flagKey, context } as HookContext, err, hints); + hook.finally({ flagKey, context } as HookContext, evaluationDetails, hints); + + expect(innerHook.before).toHaveBeenNthCalledWith(calledTimesTotal, expect.objectContaining({ context }), hints); + expect(innerHook.after).toHaveBeenNthCalledWith( + calledTimesTotal, + expect.objectContaining({ context }), + evaluationDetails, + hints, + ); + expect(innerHook.error).toHaveBeenNthCalledWith( + calledTimesTotal, + expect.objectContaining({ context }), + err, + hints, + ); + expect(innerHook.finally).toHaveBeenNthCalledWith( + calledTimesTotal, + expect.objectContaining({ context }), + evaluationDetails, + hints, + ); + }); + }); + + describe('options', () => { + afterAll(() => { + jest.resetAllMocks(); + }); + + it('maxCacheItems should limit size', () => { + const innerHook: Hook = { + before: jest.fn(), + }; + + const hook = new DebounceHook(innerHook, { + beforeCacheKeySupplier: (flagKey: string) => flagKey, + ttlMs: 60_000, + maxCacheItems: 1, + }); + + hook.before({ flagKey: 'flag1' } as HookContext, {}); + hook.before({ flagKey: 'flag2' } as HookContext, {}); + hook.before({ flagKey: 'flag1' } as HookContext, {}); + + // every invocation should have run since we have only maxCacheItems: 1 + expect(innerHook.before).toHaveBeenCalledTimes(3); + }); + + it('should rerun inner hook stage only after ttl', async () => { + const innerHook: Hook = { + before: jest.fn(), + }; + + const flagKey = 'some-flag'; + + const hook = new DebounceHook(innerHook, { + beforeCacheKeySupplier: (flagKey: string) => flagKey, + ttlMs: 500, + maxCacheItems: 1, + }); + + hook.before({ flagKey } as HookContext, {}); + hook.before({ flagKey } as HookContext, {}); + hook.before({ flagKey } as HookContext, {}); + + await new Promise((r) => setTimeout(r, 1000)); + + hook.before({ flagKey } as HookContext, {}); + + // only the first and last should have invoked the inner hook + expect(innerHook.before).toHaveBeenCalledTimes(2); + }); + + it('noop if supplier not defined', () => { + const innerHook: Hook = { + before: jest.fn(), + after: jest.fn(), + error: jest.fn(), + finally: jest.fn(), + }; + + const flagKey = 'some-flag'; + const context = {}; + const hints = {}; + + // no suppliers defined, so we no-op (do no caching) + const hook = new DebounceHook(innerHook, { + ttlMs: 60_000, + maxCacheItems: 100, + }); + + const evaluationDetails: EvaluationDetails = { + value: 'testValue', + } as EvaluationDetails; + + for (let i = 0; i < 3; i++) { + hook.before({ flagKey, context } as HookContext, hints); + hook.after({ flagKey, context } as HookContext, evaluationDetails, hints); + hook.error({ flagKey, context } as HookContext, hints); + hook.finally({ flagKey, context } as HookContext, evaluationDetails, hints); + } + + // every invocation should have run since we have only maxCacheItems: 1 + expect(innerHook.before).toHaveBeenCalledTimes(3); + expect(innerHook.after).toHaveBeenCalledTimes(3); + expect(innerHook.error).toHaveBeenCalledTimes(3); + expect(innerHook.finally).toHaveBeenCalledTimes(3); + }); + + it.each([ + { + cacheErrors: false, + timesCalled: 2, // should be called each time since the hook always errors + }, + { + cacheErrors: true, + timesCalled: 1, // should be called once since we cached the error + }, + ])('should cache errors if cacheErrors set', ({ cacheErrors, timesCalled }) => { + const innerErrorHook: Hook = { + before: jest.fn(() => { + // throw an error + throw new Error('fake!'); + }), + }; + + const flagKey = 'some-flag'; + const context = {}; + + // this hook caches error invocations + const hook = new DebounceHook(innerErrorHook, { + beforeCacheKeySupplier: (flagKey: string) => flagKey, + maxCacheItems: 100, + ttlMs: 60_000, + cacheErrors, + }); + + expect(() => hook.before({ flagKey, context } as HookContext)).toThrow(); + expect(() => hook.before({ flagKey, context } as HookContext)).toThrow(); + + expect(innerErrorHook.before).toHaveBeenCalledTimes(timesCalled); + }); + }); +}); diff --git a/libs/hooks/debounce/src/lib/debounce-hook.ts b/libs/hooks/debounce/src/lib/debounce-hook.ts new file mode 100644 index 000000000..456f792b6 --- /dev/null +++ b/libs/hooks/debounce/src/lib/debounce-hook.ts @@ -0,0 +1,181 @@ +import type { + EvaluationContext, + EvaluationDetails, + FlagValue, + Hook, + HookContext, + HookHints, +} from '@openfeature/web-sdk'; +import { FixedSizeExpiringCache } from './utils/fixed-size-expiring-cache'; + +/** + * An error cached from a previous hook invocation. + */ +export class CachedError extends Error { + private _innerError: unknown; + + constructor(innerError: unknown) { + super(); + Object.setPrototypeOf(this, CachedError.prototype); + this.name = 'CachedError'; + this._innerError = innerError; + } + + /** + * The original error. + */ + get innerError() { + return this._innerError; + } +} + +export type Options = { + /** + * Function to generate the cache key for the before stage of the wrapped hook. + * If the cache key is found in the cache, the hook stage will not run. + * If not defined, the DebounceHook will no-op for this stage (inner hook will always run for this stage). + * + * @param flagKey the flag key + * @param context the evaluation context + * @returns cache key for this stage + */ + beforeCacheKeySupplier?: (flagKey: string, context: EvaluationContext) => string | null | undefined; + /** + * Function to generate the cache key for the after stage of the wrapped hook. + * If the cache key is found in the cache, the hook stage will not run. + * If not defined, the DebounceHook will no-op for this stage (inner hook will always run for this stage). + * + * @param flagKey the flag key + * @param context the evaluation context + * @param details the evaluation details + * @returns cache key for this stage + */ + afterCacheKeySupplier?: ( + flagKey: string, + context: EvaluationContext, + details: EvaluationDetails, + ) => string | null | undefined; + /** + * Function to generate the cache key for the error stage of the wrapped hook. + * If the cache key is found in the cache, the hook stage will not run. + * If not defined, the DebounceHook will no-op for this stage (inner hook will always run for this stage). + * + * @param flagKey the flag key + * @param context the evaluation context + * @param err the Error + * @returns cache key for this stage + */ + errorCacheKeySupplier?: (flagKey: string, context: EvaluationContext, err: unknown) => string | null | undefined; + /** + * Function to generate the cache key for the error stage of the wrapped hook. + * If the cache key is found in the cache, the hook stage will not run. + * If not defined, the DebounceHook will no-op for this stage (inner hook will always run for this stage). + * + * @param flagKey the flag key + * @param context the evaluation context + * @param details the evaluation details + * @returns cache key for this stage + */ + finallyCacheKeySupplier?: ( + flagKey: string, + context: EvaluationContext, + details: EvaluationDetails, + ) => string | null | undefined; + /** + * Whether or not to debounce and cache the errors thrown by hook stages. + * If false (default) stages that throw will not be debounced and their errors not cached. + */ + cacheErrors?: boolean; + /** + * Time to live for items in cache in milliseconds. + */ + ttlMs: number; + /** + * Max number of items to be kept in cache before the oldest entry falls out. + */ + maxCacheItems: number; +}; + +export class DebounceHook implements Hook { + private readonly cache: FixedSizeExpiringCache; + private readonly cacheErrors: boolean; + + public constructor( + private readonly innerHook: Hook, + private readonly options: Options, + ) { + this.cacheErrors = options.cacheErrors || false; + this.cache = new FixedSizeExpiringCache({ + maxItems: options.maxCacheItems, + ttlMs: options.ttlMs, + }); + } + + before(hookContext: HookContext, hookHints?: HookHints) { + this.maybeSkipAndCache( + 'before', + () => this.options?.beforeCacheKeySupplier?.(hookContext.flagKey, hookContext.context), + () => this.innerHook?.before?.(hookContext, hookHints), + ); + } + + after(hookContext: HookContext, evaluationDetails: EvaluationDetails, hookHints?: HookHints) { + this.maybeSkipAndCache( + 'after', + () => this.options?.afterCacheKeySupplier?.(hookContext.flagKey, hookContext.context, evaluationDetails), + () => this.innerHook?.after?.(hookContext, evaluationDetails, hookHints), + ); + } + + error(hookContext: HookContext, err: unknown, hookHints?: HookHints) { + this.maybeSkipAndCache( + 'error', + () => this.options?.errorCacheKeySupplier?.(hookContext.flagKey, hookContext.context, err), + () => this.innerHook?.error?.(hookContext, err, hookHints), + ); + } + + finally(hookContext: HookContext, evaluationDetails: EvaluationDetails, hookHints?: HookHints) { + this.maybeSkipAndCache( + 'finally', + () => this.options?.finallyCacheKeySupplier?.(hookContext.flagKey, hookContext.context, evaluationDetails), + () => this.innerHook?.finally?.(hookContext, evaluationDetails, hookHints), + ); + } + + private maybeSkipAndCache( + stage: 'before' | 'after' | 'error' | 'finally', + keyGenCallback: () => string | null | undefined, + hookCallback: () => void, + ) { + // the cache key is a concatenation of the result of calling keyGenCallback and the stage + const dynamicKey = keyGenCallback(); + const cacheKeySuffix = stage; + const cacheKey = `${dynamicKey}::${cacheKeySuffix}`; + + // if the keyGenCallback returns nothing, we don't do any caching + if (dynamicKey) { + const cached = this.cache.get(cacheKey); + if (cached) { + // throw cached errors + if (cached instanceof CachedError) { + throw cached; + } + return; + } + try { + hookCallback(); + this.cache.set(cacheKey, true); + } catch (error: unknown) { + if (this.cacheErrors) { + // cache error + this.cache.set(cacheKey, new CachedError(error)); + } + throw error; + } + return; + } else { + hookCallback(); + } + } +} diff --git a/libs/hooks/debounce/src/lib/utils/fixed-size-expiring-cache.spec.ts b/libs/hooks/debounce/src/lib/utils/fixed-size-expiring-cache.spec.ts new file mode 100644 index 000000000..1a1f7edb0 --- /dev/null +++ b/libs/hooks/debounce/src/lib/utils/fixed-size-expiring-cache.spec.ts @@ -0,0 +1,72 @@ +import { FixedSizeExpiringCache } from './fixed-size-expiring-cache'; + +describe('FixedSizeExpiringCache', () => { + it('should expire', async () => { + const cache = new FixedSizeExpiringCache({ maxItems: 1, ttlMs: 500 }); + + const key1 = 'key1'; + const value1 = 'value1'; + + cache.set(key1, value1); + + // should be present + expect(cache.get(key1)).toEqual(value1); + + // wait for expiry + await new Promise((r) => setTimeout(r, 1000)); + + // should be expired + expect(cache.get(key1)).toBeUndefined(); + }); + + it('should remove oldest when over full', async () => { + const cache = new FixedSizeExpiringCache({ maxItems: 2, ttlMs: 60000 }); + + const key1 = 'key1'; + const value1 = 'value1'; + const key2 = 'key2'; + const value2 = 'value2'; + const key3 = 'key3'; + const value3 = 'value3'; + + cache.set(key1, value1); + cache.set(key2, value2); + cache.set(key3, value3); + + // recent 2 should be found + expect(cache.get(key2)).toEqual(value2); + expect(cache.get(key3)).toEqual(value3); + + // oldest should be gone + expect(cache.get(key1)).toBeUndefined(); + }); + + it('should no-op for falsy key', async () => { + const cache = new FixedSizeExpiringCache({ maxItems: 100, ttlMs: 60000 }); + + const key1 = undefined; + const value1 = 'value1'; + const key2 = null; + const value2 = 'value2'; + const key3 = ''; + const value3 = 'value3'; + + cache.set(key1 as unknown as string, value1); + cache.set(key2 as unknown as string, value2); + cache.set(key3 as unknown as string, value3); + + // should all be undefined + expect(cache.get(key1 as unknown as string)).toBeUndefined(); + expect(cache.get(key2 as unknown as string)).toBeUndefined(); + expect(cache.get(key3 as unknown as string)).toBeUndefined(); + }); + + describe('options', () => { + it('should validate options', () => { + expect(() => new FixedSizeExpiringCache({ maxItems: 0, ttlMs: 60000 })).toThrow(); + expect(() => new FixedSizeExpiringCache({ maxItems: -1, ttlMs: 60000 })).toThrow(); + expect(() => new FixedSizeExpiringCache({ maxItems: 100, ttlMs: 0 })).toThrow(); + expect(() => new FixedSizeExpiringCache({ maxItems: 100, ttlMs: -1 })).toThrow(); + }); + }); +}); diff --git a/libs/hooks/debounce/src/lib/utils/fixed-size-expiring-cache.ts b/libs/hooks/debounce/src/lib/utils/fixed-size-expiring-cache.ts new file mode 100644 index 000000000..587bcca39 --- /dev/null +++ b/libs/hooks/debounce/src/lib/utils/fixed-size-expiring-cache.ts @@ -0,0 +1,89 @@ +/** + * Wrapper object so we can easily handle falsy/undefined values. + */ +type Entry = { + value: T; + expiry: number; +}; + +type Options = { + /** + * A positive integer setting the max number of items in the cache before the oldest entry is removed. + */ + maxItems: number; + /** + * Time to live for items in cache in milliseconds. + */ + ttlMs: number; +}; + +/** + * A very simple cache which lazily evicts and expires keys. + */ +export class FixedSizeExpiringCache { + private cacheMap = new Map>(); + private readonly maxItems: number; + private readonly ttl: number; + + constructor(options: Options) { + if (options.maxItems < 1) { + throw new Error('maxItems must be a positive integer'); + } + this.maxItems = options.maxItems; + if (options.ttlMs < 1) { + throw new Error('ttlMs must be a positive integer'); + } + this.ttl = options.ttlMs; + } + + /** + * Gets a key from the cache, updating its recency. + * + * @param key key for the entry + * @returns value or key or undefined + */ + get(key: string): T | void { + if (key) { + const entry = this.cacheMap.get(key); + if (entry) { + if (entry.expiry > Date.now()) { + return entry.value; + } else { + this.cacheMap.delete(key); // expired + } + } + } + } + + /** + * Sets a key in the cache. + * If the cache is already at it's maxItems, the oldest key is evicted. + * + * @param key key for the entry; if falsy, the function will no-op + * @param value value for the entry + */ + set(key: string, value: T) { + if (key) { + if (this.cacheMap.size >= this.maxItems) { + this.evictOldest(); + } + // delete first so that the order is updated when we re-set (Map keeps insertion order) + this.cacheMap.delete(key); + this.cacheMap.set(key, { + value, + expiry: Date.now() + this.ttl, + }); + } + } + + /** + * Removes the oldest key + */ + private evictOldest() { + // Map keeps insertion order, so the first key is the oldest + const oldestKey = this.cacheMap.keys().next(); + if (!oldestKey.done) { + this.cacheMap.delete(oldestKey.value); + } + } +} diff --git a/libs/hooks/debounce/tsconfig.json b/libs/hooks/debounce/tsconfig.json new file mode 100644 index 000000000..b2db732c1 --- /dev/null +++ b/libs/hooks/debounce/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "ES6", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/hooks/debounce/tsconfig.lib.json b/libs/hooks/debounce/tsconfig.lib.json new file mode 100644 index 000000000..6f3c503a2 --- /dev/null +++ b/libs/hooks/debounce/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libs/hooks/debounce/tsconfig.spec.json b/libs/hooks/debounce/tsconfig.spec.json new file mode 100644 index 000000000..a5992c7ff --- /dev/null +++ b/libs/hooks/debounce/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../dist/out-tsc", + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"] + }, + "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/release-please-config.json b/release-please-config.json index 0e7872d10..0babf64df 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -171,6 +171,13 @@ "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, "versioning": "default" + }, + "libs/hooks/debounce": { + "release-type": "node", + "prerelease": false, + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "versioning": "default" } }, "changelog-sections": [ diff --git a/tsconfig.base.json b/tsconfig.base.json index 97270c5ff..f3a45617f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -22,6 +22,7 @@ "@openfeature/config-cat-core": ["libs/shared/config-cat-core/src/index.ts"], "@openfeature/config-cat-provider": ["libs/providers/config-cat/src/index.ts"], "@openfeature/config-cat-web-provider": ["libs/providers/config-cat-web/src/index.ts"], + "@openfeature/debounce-hook": ["hooks/src/index.ts"], "@openfeature/env-var-provider": ["libs/providers/env-var/src/index.ts"], "@openfeature/flagd-core": ["libs/shared/flagd-core/src/index.ts"], "@openfeature/flagd-provider": ["libs/providers/flagd/src/index.ts"], From ef5caa68a0d461f5a1b28dd3cb28a7e0d2a9f37a Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Tue, 7 Oct 2025 21:56:51 -0400 Subject: [PATCH 02/22] fixup: improve docs, param names Signed-off-by: Todd Baert --- libs/hooks/debounce/README.md | 10 +++---- .../debounce/src/lib/debounce-hook.spec.ts | 12 ++++----- libs/hooks/debounce/src/lib/debounce-hook.ts | 27 ++++++++++--------- 3 files changed, 25 insertions(+), 24 deletions(-) diff --git a/libs/hooks/debounce/README.md b/libs/hooks/debounce/README.md index c28727563..164b61ca0 100644 --- a/libs/hooks/debounce/README.md +++ b/libs/hooks/debounce/README.md @@ -23,17 +23,17 @@ NOTE: if you're using the React or Angular SDKs, you don't need to directly inst 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). Simply wrap your hook with the debounce hook by passing it a constructor arg, and then configure the remaining options. -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. +In the example below, we wrap the "after" stage of 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. ```ts -// a function defining the key for the hook stage +// a function defining the key for the hook stage; if this matches a recent key, the hook execution for this stage will be bypassed const supplier = (flagKey: string, context: EvaluationContext, details: EvaluationDetails) => flagKey; const hook = new DebounceHook(loggingHook, { + debounceTime: 60_000, // how long to wait before the hook can fire again (applied to each stage independently) in milliseconds afterCacheKeySupplier: supplier, // if the key calculated by the supplier exists in the cache, the wrapped hook's stage will not run - ttlMs: 60_000, // how long to cache keys for - maxCacheItems: 100, // max amount of items to keep in the LRU cache - cacheErrors: false // whether or not to cache the errors thrown by hook stages + maxCacheItems: 100, // max amount of items to keep in the cache; if exceeded, the oldest item is dropped + cacheErrors: false // whether or not to debounce errors thrown by hook stages }); ``` diff --git a/libs/hooks/debounce/src/lib/debounce-hook.spec.ts b/libs/hooks/debounce/src/lib/debounce-hook.spec.ts index 6ab892e43..b1af7737f 100644 --- a/libs/hooks/debounce/src/lib/debounce-hook.spec.ts +++ b/libs/hooks/debounce/src/lib/debounce-hook.spec.ts @@ -21,7 +21,7 @@ describe('DebounceHook', () => { afterCacheKeySupplier: supplier, errorCacheKeySupplier: supplier, finallyCacheKeySupplier: supplier, - ttlMs: 60_000, + debounceTime: 60_000, maxCacheItems: 100, }); @@ -85,7 +85,7 @@ describe('DebounceHook', () => { const hook = new DebounceHook(innerHook, { beforeCacheKeySupplier: (flagKey: string) => flagKey, - ttlMs: 60_000, + debounceTime: 60_000, maxCacheItems: 1, }); @@ -97,7 +97,7 @@ describe('DebounceHook', () => { expect(innerHook.before).toHaveBeenCalledTimes(3); }); - it('should rerun inner hook stage only after ttl', async () => { + it('should rerun inner hook only after debounce time', async () => { const innerHook: Hook = { before: jest.fn(), }; @@ -106,7 +106,7 @@ describe('DebounceHook', () => { const hook = new DebounceHook(innerHook, { beforeCacheKeySupplier: (flagKey: string) => flagKey, - ttlMs: 500, + debounceTime: 500, maxCacheItems: 1, }); @@ -136,7 +136,7 @@ describe('DebounceHook', () => { // no suppliers defined, so we no-op (do no caching) const hook = new DebounceHook(innerHook, { - ttlMs: 60_000, + debounceTime: 60_000, maxCacheItems: 100, }); @@ -182,7 +182,7 @@ describe('DebounceHook', () => { const hook = new DebounceHook(innerErrorHook, { beforeCacheKeySupplier: (flagKey: string) => flagKey, maxCacheItems: 100, - ttlMs: 60_000, + debounceTime: 60_000, cacheErrors, }); diff --git a/libs/hooks/debounce/src/lib/debounce-hook.ts b/libs/hooks/debounce/src/lib/debounce-hook.ts index 456f792b6..f500a736b 100644 --- a/libs/hooks/debounce/src/lib/debounce-hook.ts +++ b/libs/hooks/debounce/src/lib/debounce-hook.ts @@ -87,9 +87,9 @@ export type Options = { */ cacheErrors?: boolean; /** - * Time to live for items in cache in milliseconds. + * Debounce timeout - how long to wait before the hook can fire again (applied to each stage independently) in milliseconds. */ - ttlMs: number; + debounceTime: number; /** * Max number of items to be kept in cache before the oldest entry falls out. */ @@ -107,7 +107,7 @@ export class DebounceHook implements Hook { this.cacheErrors = options.cacheErrors || false; this.cache = new FixedSizeExpiringCache({ maxItems: options.maxCacheItems, - ttlMs: options.ttlMs, + ttlMs: options.debounceTime, }); } @@ -162,18 +162,19 @@ export class DebounceHook implements Hook { throw cached; } return; - } - try { - hookCallback(); - this.cache.set(cacheKey, true); - } catch (error: unknown) { - if (this.cacheErrors) { - // cache error - this.cache.set(cacheKey, new CachedError(error)); + } else { + try { + hookCallback(); + this.cache.set(cacheKey, true); + } catch (error: unknown) { + if (this.cacheErrors) { + // cache error + this.cache.set(cacheKey, new CachedError(error)); + } + throw error; } - throw error; + return; } - return; } else { hookCallback(); } From 1aaa99afa15ddedc6d65e5203f80c5ad0fba04b5 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Wed, 8 Oct 2025 08:54:27 -0400 Subject: [PATCH 03/22] Update libs/hooks/debounce/README.md Co-authored-by: Michael Beemer Signed-off-by: Todd Baert --- libs/hooks/debounce/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/hooks/debounce/README.md b/libs/hooks/debounce/README.md index 164b61ca0..9b6f073f2 100644 --- a/libs/hooks/debounce/README.md +++ b/libs/hooks/debounce/README.md @@ -17,7 +17,7 @@ Confirm that the following peer dependencies are installed: $ npm install @openfeature/web-sdk ``` -NOTE: if you're using the React or Angular SDKs, you don't need to directly install this web SDK. +NOTE: if you're using the React or Angular OpenFeature SDKs, you don't need to directly install this web SDK. ## Usage From 169f522f6489ad59bae1feed37fbc426442afd40 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Wed, 8 Oct 2025 09:07:05 -0400 Subject: [PATCH 04/22] fixup: review feedback and comments Signed-off-by: Todd Baert --- libs/hooks/debounce/README.md | 4 ++++ libs/hooks/debounce/src/lib/debounce-hook.ts | 14 ++++++++------ .../src/lib/utils/fixed-size-expiring-cache.ts | 5 +++++ 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/libs/hooks/debounce/README.md b/libs/hooks/debounce/README.md index 9b6f073f2..0bc368273 100644 --- a/libs/hooks/debounce/README.md +++ b/libs/hooks/debounce/README.md @@ -17,7 +17,11 @@ Confirm that the following peer dependencies are installed: $ npm install @openfeature/web-sdk ``` +<<<<<<< Updated upstream NOTE: if you're using the React or Angular OpenFeature SDKs, you don't need to directly install this web SDK. +======= +NOTE: if you're using the React or Angular SDKs, you don't need to directly install the web SDK. +>>>>>>> Stashed changes ## Usage diff --git a/libs/hooks/debounce/src/lib/debounce-hook.ts b/libs/hooks/debounce/src/lib/debounce-hook.ts index f500a736b..f32c59ad2 100644 --- a/libs/hooks/debounce/src/lib/debounce-hook.ts +++ b/libs/hooks/debounce/src/lib/debounce-hook.ts @@ -84,6 +84,7 @@ export type Options = { /** * Whether or not to debounce and cache the errors thrown by hook stages. * If false (default) stages that throw will not be debounced and their errors not cached. + * If true, stages that throw will be debounced and their errors cached and re-thrown for the debounced period. */ cacheErrors?: boolean; /** @@ -150,16 +151,17 @@ export class DebounceHook implements Hook { ) { // the cache key is a concatenation of the result of calling keyGenCallback and the stage const dynamicKey = keyGenCallback(); - const cacheKeySuffix = stage; - const cacheKey = `${dynamicKey}::${cacheKeySuffix}`; // if the keyGenCallback returns nothing, we don't do any caching if (dynamicKey) { - const cached = this.cache.get(cacheKey); - if (cached) { + const cacheKeySuffix = stage; + const cacheKey = `${dynamicKey}::${cacheKeySuffix}`; + const got = this.cache.get(cacheKey); + + if (got) { // throw cached errors - if (cached instanceof CachedError) { - throw cached; + if (got instanceof CachedError) { + throw got; } return; } else { diff --git a/libs/hooks/debounce/src/lib/utils/fixed-size-expiring-cache.ts b/libs/hooks/debounce/src/lib/utils/fixed-size-expiring-cache.ts index 587bcca39..cca43040b 100644 --- a/libs/hooks/debounce/src/lib/utils/fixed-size-expiring-cache.ts +++ b/libs/hooks/debounce/src/lib/utils/fixed-size-expiring-cache.ts @@ -19,6 +19,9 @@ type Options = { /** * A very simple cache which lazily evicts and expires keys. + * The cache has a fixed max size, and when that size is exceeded, the oldest key is evicted based on insertion order. + * When a key is retrieved, if it has expired, it is removed from the cache and undefined is returned. + * If a key is set that already exists, it is updated and its recency (but not it's TTL) is updated. */ export class FixedSizeExpiringCache { private cacheMap = new Map>(); @@ -68,6 +71,7 @@ export class FixedSizeExpiringCache { this.evictOldest(); } // delete first so that the order is updated when we re-set (Map keeps insertion order) + // this is only relevant for eviction when at max size this.cacheMap.delete(key); this.cacheMap.set(key, { value, @@ -81,6 +85,7 @@ export class FixedSizeExpiringCache { */ private evictOldest() { // Map keeps insertion order, so the first key is the oldest + // this is only relevant when at max size const oldestKey = this.cacheMap.keys().next(); if (!oldestKey.done) { this.cacheMap.delete(oldestKey.value); From a61ce3c984efa779dbbeea25a8aa8bff6c6cb40c Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Wed, 8 Oct 2025 09:10:10 -0400 Subject: [PATCH 05/22] fixup: conflict Signed-off-by: Todd Baert --- libs/hooks/debounce/README.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/libs/hooks/debounce/README.md b/libs/hooks/debounce/README.md index 0bc368273..977e81e1a 100644 --- a/libs/hooks/debounce/README.md +++ b/libs/hooks/debounce/README.md @@ -17,11 +17,7 @@ Confirm that the following peer dependencies are installed: $ npm install @openfeature/web-sdk ``` -<<<<<<< Updated upstream -NOTE: if you're using the React or Angular OpenFeature SDKs, you don't need to directly install this web SDK. -======= -NOTE: if you're using the React or Angular SDKs, you don't need to directly install the web SDK. ->>>>>>> Stashed changes +NOTE: if you're using the React or Angular OpenFeature SDKs, you don't need to directly install the web SDK. ## Usage From 30c7aedefa6267995fd66014cda1731784eee820 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Wed, 8 Oct 2025 09:18:39 -0400 Subject: [PATCH 06/22] fixup: more comments Signed-off-by: Todd Baert --- libs/hooks/debounce/src/lib/debounce-hook.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/libs/hooks/debounce/src/lib/debounce-hook.ts b/libs/hooks/debounce/src/lib/debounce-hook.ts index f32c59ad2..b411773e4 100644 --- a/libs/hooks/debounce/src/lib/debounce-hook.ts +++ b/libs/hooks/debounce/src/lib/debounce-hook.ts @@ -97,6 +97,12 @@ export type Options = { maxCacheItems: number; }; +/** + * A hook that wraps another hook and debounces its execution based on the provided options. + * Each stage of the hook (before, after, error, finally) is debounced independently. + * 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. + * If no cache key supplier is provided for a stage, that stage will always run. + */ export class DebounceHook implements Hook { private readonly cache: FixedSizeExpiringCache; private readonly cacheErrors: boolean; From d88cf36181383dc1951e83340a54b04c9111c122 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Wed, 8 Oct 2025 09:23:42 -0400 Subject: [PATCH 07/22] fixup: thisarg Signed-off-by: Todd Baert --- libs/hooks/debounce/src/lib/debounce-hook.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/hooks/debounce/src/lib/debounce-hook.ts b/libs/hooks/debounce/src/lib/debounce-hook.ts index b411773e4..5f65c624b 100644 --- a/libs/hooks/debounce/src/lib/debounce-hook.ts +++ b/libs/hooks/debounce/src/lib/debounce-hook.ts @@ -172,7 +172,7 @@ export class DebounceHook implements Hook { return; } else { try { - hookCallback(); + hookCallback.call(this.innerHook); this.cache.set(cacheKey, true); } catch (error: unknown) { if (this.cacheErrors) { @@ -184,7 +184,7 @@ export class DebounceHook implements Hook { return; } } else { - hookCallback(); + hookCallback.call(this.innerHook); } } } From e521624a38f21698e3fae444c6393cfc5c53afdf Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Wed, 8 Oct 2025 11:58:03 -0400 Subject: [PATCH 08/22] Apply suggestion from @gemini-code-assist[bot] Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Signed-off-by: Todd Baert --- libs/hooks/debounce/src/lib/utils/fixed-size-expiring-cache.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/hooks/debounce/src/lib/utils/fixed-size-expiring-cache.ts b/libs/hooks/debounce/src/lib/utils/fixed-size-expiring-cache.ts index cca43040b..a8fc0c231 100644 --- a/libs/hooks/debounce/src/lib/utils/fixed-size-expiring-cache.ts +++ b/libs/hooks/debounce/src/lib/utils/fixed-size-expiring-cache.ts @@ -40,7 +40,7 @@ export class FixedSizeExpiringCache { } /** - * Gets a key from the cache, updating its recency. + * Gets a key from the cache. * * @param key key for the entry * @returns value or key or undefined From d29aec0e32d73735cb84b88dc153fd4144677832 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Wed, 8 Oct 2025 11:59:02 -0400 Subject: [PATCH 09/22] Apply suggestion from @lukas-reining Co-authored-by: Lukas Reining Signed-off-by: Todd Baert --- libs/hooks/debounce/src/lib/debounce-hook.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/hooks/debounce/src/lib/debounce-hook.ts b/libs/hooks/debounce/src/lib/debounce-hook.ts index 5f65c624b..879dee146 100644 --- a/libs/hooks/debounce/src/lib/debounce-hook.ts +++ b/libs/hooks/debounce/src/lib/debounce-hook.ts @@ -111,7 +111,7 @@ export class DebounceHook implements Hook { private readonly innerHook: Hook, private readonly options: Options, ) { - this.cacheErrors = options.cacheErrors || false; + this.cacheErrors = options.cacheErrors ?? false; this.cache = new FixedSizeExpiringCache({ maxItems: options.maxCacheItems, ttlMs: options.debounceTime, From 7f838b9f4396c887e795c43388cd462e5bedd486 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Wed, 8 Oct 2025 14:08:11 -0400 Subject: [PATCH 10/22] Update libs/hooks/debounce/src/lib/debounce-hook.ts Co-authored-by: Lukas Reining Signed-off-by: Todd Baert --- libs/hooks/debounce/src/lib/debounce-hook.ts | 49 ++++++++++---------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/libs/hooks/debounce/src/lib/debounce-hook.ts b/libs/hooks/debounce/src/lib/debounce-hook.ts index 879dee146..c7070da4d 100644 --- a/libs/hooks/debounce/src/lib/debounce-hook.ts +++ b/libs/hooks/debounce/src/lib/debounce-hook.ts @@ -158,33 +158,32 @@ export class DebounceHook implements Hook { // the cache key is a concatenation of the result of calling keyGenCallback and the stage const dynamicKey = keyGenCallback(); - // if the keyGenCallback returns nothing, we don't do any caching - if (dynamicKey) { - const cacheKeySuffix = stage; - const cacheKey = `${dynamicKey}::${cacheKeySuffix}`; - const got = this.cache.get(cacheKey); + // if the keyGenCallback returns nothing, we don't do any caching + if (!dynamicKey) { + hookCallback.call(this.innerHook); + } + + const cacheKeySuffix = stage; + const cacheKey = `${dynamicKey}::${cacheKeySuffix}`; + const got = this.cache.get(cacheKey); - if (got) { - // throw cached errors - if (got instanceof CachedError) { - throw got; - } - return; - } else { - try { - hookCallback.call(this.innerHook); - this.cache.set(cacheKey, true); - } catch (error: unknown) { - if (this.cacheErrors) { - // cache error - this.cache.set(cacheKey, new CachedError(error)); - } - throw error; - } - return; + if (got) { + // throw cached errors + if (got instanceof CachedError) { + throw got; } - } else { + return; + } + + try { hookCallback.call(this.innerHook); - } + this.cache.set(cacheKey, true); + } catch (error: unknown) { + if (this.cacheErrors) { + // cache error + this.cache.set(cacheKey, new CachedError(error)); + } + throw error; + } } } From 16f63c27ee0bf0bd77cdb31a180cca75c7c344cc Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Wed, 8 Oct 2025 16:46:02 -0400 Subject: [PATCH 11/22] use single supplier so hook is debounced in entirety Signed-off-by: Todd Baert --- .../debounce/src/lib/debounce-hook.spec.ts | 58 ++++---- libs/hooks/debounce/src/lib/debounce-hook.ts | 138 +++++++++--------- .../lib/utils/fixed-size-expiring-cache.ts | 3 +- 3 files changed, 99 insertions(+), 100 deletions(-) diff --git a/libs/hooks/debounce/src/lib/debounce-hook.spec.ts b/libs/hooks/debounce/src/lib/debounce-hook.spec.ts index b1af7737f..bb9da88a6 100644 --- a/libs/hooks/debounce/src/lib/debounce-hook.spec.ts +++ b/libs/hooks/debounce/src/lib/debounce-hook.spec.ts @@ -14,13 +14,7 @@ describe('DebounceHook', () => { finally: jest.fn(), }; - const supplier = (flagKey: string) => flagKey; - const hook = new DebounceHook(innerHook, { - beforeCacheKeySupplier: supplier, - afterCacheKeySupplier: supplier, - errorCacheKeySupplier: supplier, - finallyCacheKeySupplier: supplier, debounceTime: 60_000, maxCacheItems: 100, }); @@ -71,6 +65,31 @@ describe('DebounceHook', () => { hints, ); }); + + it('stages should be cached independently', () => { + const innerHook: Hook = { + before: jest.fn(), + after: jest.fn(), + }; + + const hook = new DebounceHook(innerHook, { + debounceTime: 60_000, + maxCacheItems: 100, + }); + + const flagKey = 'my-flag'; + + hook.before({ flagKey } as HookContext, {}); + hook.after({ flagKey } as HookContext, { + flagKey, + flagMetadata: {}, + value: true, + }); + + // both should run + expect(innerHook.before).toHaveBeenCalledTimes(1); + expect(innerHook.after).toHaveBeenCalledTimes(1); + }); }); describe('options', () => { @@ -84,7 +103,6 @@ describe('DebounceHook', () => { }; const hook = new DebounceHook(innerHook, { - beforeCacheKeySupplier: (flagKey: string) => flagKey, debounceTime: 60_000, maxCacheItems: 1, }); @@ -105,7 +123,6 @@ describe('DebounceHook', () => { const flagKey = 'some-flag'; const hook = new DebounceHook(innerHook, { - beforeCacheKeySupplier: (flagKey: string) => flagKey, debounceTime: 500, maxCacheItems: 1, }); @@ -122,7 +139,7 @@ describe('DebounceHook', () => { expect(innerHook.before).toHaveBeenCalledTimes(2); }); - it('noop if supplier not defined', () => { + it('use custom supplier', () => { const innerHook: Hook = { before: jest.fn(), after: jest.fn(), @@ -130,32 +147,20 @@ describe('DebounceHook', () => { finally: jest.fn(), }; - const flagKey = 'some-flag'; const context = {}; const hints = {}; - // no suppliers defined, so we no-op (do no caching) const hook = new DebounceHook(innerHook, { + cacheKeySupplier: () => 'a-silly-const-key', // a constant key means all invocations are cached; just to test that the custom supplier is used debounceTime: 60_000, maxCacheItems: 100, }); - const evaluationDetails: EvaluationDetails = { - value: 'testValue', - } as EvaluationDetails; - - for (let i = 0; i < 3; i++) { - hook.before({ flagKey, context } as HookContext, hints); - hook.after({ flagKey, context } as HookContext, evaluationDetails, hints); - hook.error({ flagKey, context } as HookContext, hints); - hook.finally({ flagKey, context } as HookContext, evaluationDetails, hints); - } + hook.before({ flagKey: 'flag1', context } as HookContext, hints); + hook.before({ flagKey: 'flag2', context } as HookContext, hints); - // every invocation should have run since we have only maxCacheItems: 1 - expect(innerHook.before).toHaveBeenCalledTimes(3); - expect(innerHook.after).toHaveBeenCalledTimes(3); - expect(innerHook.error).toHaveBeenCalledTimes(3); - expect(innerHook.finally).toHaveBeenCalledTimes(3); + // since we used a constant key, the second invocation should have been cached even though the flagKey was different + expect(innerHook.before).toHaveBeenCalledTimes(1); }); it.each([ @@ -180,7 +185,6 @@ describe('DebounceHook', () => { // this hook caches error invocations const hook = new DebounceHook(innerErrorHook, { - beforeCacheKeySupplier: (flagKey: string) => flagKey, maxCacheItems: 100, debounceTime: 60_000, cacheErrors, diff --git a/libs/hooks/debounce/src/lib/debounce-hook.ts b/libs/hooks/debounce/src/lib/debounce-hook.ts index c7070da4d..6b923f7a6 100644 --- a/libs/hooks/debounce/src/lib/debounce-hook.ts +++ b/libs/hooks/debounce/src/lib/debounce-hook.ts @@ -1,17 +1,24 @@ -import type { - EvaluationContext, - EvaluationDetails, - FlagValue, - Hook, - HookContext, - HookHints, +import type { Logger } from '@openfeature/web-sdk'; +import { + ErrorCode, + OpenFeatureError, + type EvaluationContext, + type EvaluationDetails, + type FlagValue, + type Hook, + type HookContext, + type HookHints, } from '@openfeature/web-sdk'; import { FixedSizeExpiringCache } from './utils/fixed-size-expiring-cache'; +const DEFAULT_CACHE_KEY_SUPPLIER = (flagKey: string) => flagKey; +type StageResult = true | CachedError; +type HookStagesEntry = { before?: StageResult; after?: StageResult; error?: StageResult; finally?: StageResult }; + /** * An error cached from a previous hook invocation. */ -export class CachedError extends Error { +export class CachedError extends OpenFeatureError { private _innerError: unknown; constructor(innerError: unknown) { @@ -27,60 +34,27 @@ export class CachedError extends Error { get innerError() { return this._innerError; } + + get code() { + if (this._innerError instanceof OpenFeatureError) { + return this._innerError.code; + } + return ErrorCode.GENERAL; + } } -export type Options = { +export type Options = { /** - * Function to generate the cache key for the before stage of the wrapped hook. + * Function to generate the cache key for the wrapped hook. * If the cache key is found in the cache, the hook stage will not run. - * If not defined, the DebounceHook will no-op for this stage (inner hook will always run for this stage). + * By default, the flag key is used as the cache key. * * @param flagKey the flag key * @param context the evaluation context * @returns cache key for this stage + * @default (flagKey) => flagKey */ - beforeCacheKeySupplier?: (flagKey: string, context: EvaluationContext) => string | null | undefined; - /** - * Function to generate the cache key for the after stage of the wrapped hook. - * If the cache key is found in the cache, the hook stage will not run. - * If not defined, the DebounceHook will no-op for this stage (inner hook will always run for this stage). - * - * @param flagKey the flag key - * @param context the evaluation context - * @param details the evaluation details - * @returns cache key for this stage - */ - afterCacheKeySupplier?: ( - flagKey: string, - context: EvaluationContext, - details: EvaluationDetails, - ) => string | null | undefined; - /** - * Function to generate the cache key for the error stage of the wrapped hook. - * If the cache key is found in the cache, the hook stage will not run. - * If not defined, the DebounceHook will no-op for this stage (inner hook will always run for this stage). - * - * @param flagKey the flag key - * @param context the evaluation context - * @param err the Error - * @returns cache key for this stage - */ - errorCacheKeySupplier?: (flagKey: string, context: EvaluationContext, err: unknown) => string | null | undefined; - /** - * Function to generate the cache key for the error stage of the wrapped hook. - * If the cache key is found in the cache, the hook stage will not run. - * If not defined, the DebounceHook will no-op for this stage (inner hook will always run for this stage). - * - * @param flagKey the flag key - * @param context the evaluation context - * @param details the evaluation details - * @returns cache key for this stage - */ - finallyCacheKeySupplier?: ( - flagKey: string, - context: EvaluationContext, - details: EvaluationDetails, - ) => string | null | undefined; + cacheKeySupplier?: (flagKey: string, context: EvaluationContext) => string | null | undefined; /** * Whether or not to debounce and cache the errors thrown by hook stages. * If false (default) stages that throw will not be debounced and their errors not cached. @@ -95,24 +69,29 @@ export type Options = { * Max number of items to be kept in cache before the oldest entry falls out. */ maxCacheItems: number; + /** + * Optional logger. + */ + logger?: Logger; }; /** * A hook that wraps another hook and debounces its execution based on the provided options. - * Each stage of the hook (before, after, error, finally) is debounced independently. - * 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. + * 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. * If no cache key supplier is provided for a stage, that stage will always run. */ export class DebounceHook implements Hook { - private readonly cache: FixedSizeExpiringCache; + private readonly cache: FixedSizeExpiringCache; private readonly cacheErrors: boolean; + private readonly cacheKeySupplier: Options['cacheKeySupplier']; public constructor( private readonly innerHook: Hook, - private readonly options: Options, + private readonly options: Options, ) { this.cacheErrors = options.cacheErrors ?? false; - this.cache = new FixedSizeExpiringCache({ + this.cacheKeySupplier = options.cacheKeySupplier ?? DEFAULT_CACHE_KEY_SUPPLIER; + this.cache = new FixedSizeExpiringCache({ maxItems: options.maxCacheItems, ttlMs: options.debounceTime, }); @@ -121,7 +100,7 @@ export class DebounceHook implements Hook { before(hookContext: HookContext, hookHints?: HookHints) { this.maybeSkipAndCache( 'before', - () => this.options?.beforeCacheKeySupplier?.(hookContext.flagKey, hookContext.context), + () => this.cacheKeySupplier?.(hookContext.flagKey, hookContext.context), () => this.innerHook?.before?.(hookContext, hookHints), ); } @@ -129,7 +108,7 @@ export class DebounceHook implements Hook { after(hookContext: HookContext, evaluationDetails: EvaluationDetails, hookHints?: HookHints) { this.maybeSkipAndCache( 'after', - () => this.options?.afterCacheKeySupplier?.(hookContext.flagKey, hookContext.context, evaluationDetails), + () => this.cacheKeySupplier?.(hookContext.flagKey, hookContext.context), () => this.innerHook?.after?.(hookContext, evaluationDetails, hookHints), ); } @@ -137,7 +116,7 @@ export class DebounceHook implements Hook { error(hookContext: HookContext, err: unknown, hookHints?: HookHints) { this.maybeSkipAndCache( 'error', - () => this.options?.errorCacheKeySupplier?.(hookContext.flagKey, hookContext.context, err), + () => this.cacheKeySupplier?.(hookContext.flagKey, hookContext.context), () => this.innerHook?.error?.(hookContext, err, hookHints), ); } @@ -145,7 +124,7 @@ export class DebounceHook implements Hook { finally(hookContext: HookContext, evaluationDetails: EvaluationDetails, hookHints?: HookHints) { this.maybeSkipAndCache( 'finally', - () => this.options?.finallyCacheKeySupplier?.(hookContext.flagKey, hookContext.context, evaluationDetails), + () => this.cacheKeySupplier?.(hookContext.flagKey, hookContext.context), () => this.innerHook?.finally?.(hookContext, evaluationDetails, hookHints), ); } @@ -156,34 +135,49 @@ export class DebounceHook implements Hook { hookCallback: () => void, ) { // the cache key is a concatenation of the result of calling keyGenCallback and the stage - const dynamicKey = keyGenCallback(); + let dynamicKey: string | null | undefined; + + try { + dynamicKey = keyGenCallback(); + } catch (e) { + // if the keyGenCallback throws, we log and run the hook stage + this.options.logger?.error( + `DebounceHook: cacheKeySupplier threw an error, running inner hook stage "${stage}" without debouncing.`, + e, + ); + } - // if the keyGenCallback returns nothing, we don't do any caching + // if the keyGenCallback returns nothing, we don't do any caching if (!dynamicKey) { hookCallback.call(this.innerHook); + return; } - + const cacheKeySuffix = stage; const cacheKey = `${dynamicKey}::${cacheKeySuffix}`; const got = this.cache.get(cacheKey); if (got) { + const cachedStageResult = got[stage]; // throw cached errors - if (got instanceof CachedError) { + if (cachedStageResult instanceof CachedError) { throw got; } - return; - } - + if (cachedStageResult === true) { + // already ran this stage for this key and is still in the debounce period + return; + } + } + try { hookCallback.call(this.innerHook); - this.cache.set(cacheKey, true); + this.cache.set(cacheKey, { ...got, [stage]: true }); } catch (error: unknown) { if (this.cacheErrors) { // cache error - this.cache.set(cacheKey, new CachedError(error)); + this.cache.set(cacheKey, { ...got, [stage]: new CachedError(error) }); } throw error; - } + } } } diff --git a/libs/hooks/debounce/src/lib/utils/fixed-size-expiring-cache.ts b/libs/hooks/debounce/src/lib/utils/fixed-size-expiring-cache.ts index a8fc0c231..502ecc215 100644 --- a/libs/hooks/debounce/src/lib/utils/fixed-size-expiring-cache.ts +++ b/libs/hooks/debounce/src/lib/utils/fixed-size-expiring-cache.ts @@ -45,7 +45,7 @@ export class FixedSizeExpiringCache { * @param key key for the entry * @returns value or key or undefined */ - get(key: string): T | void { + get(key: string): T | undefined { if (key) { const entry = this.cacheMap.get(key); if (entry) { @@ -56,6 +56,7 @@ export class FixedSizeExpiringCache { } } } + return undefined; } /** From e94812a8d1d2f1107900f2b42e7dcaf9ecb886c5 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Wed, 8 Oct 2025 16:50:12 -0400 Subject: [PATCH 12/22] fixup: readme Signed-off-by: Todd Baert --- libs/hooks/debounce/README.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/libs/hooks/debounce/README.md b/libs/hooks/debounce/README.md index 977e81e1a..d41c9b866 100644 --- a/libs/hooks/debounce/README.md +++ b/libs/hooks/debounce/README.md @@ -26,15 +26,16 @@ Simply wrap your hook with the debounce hook by passing it a constructor arg, an In the example below, we wrap the "after" stage of 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. ```ts -// a function defining the key for the hook stage; if this matches a recent key, the hook execution for this stage will be bypassed -const supplier = (flagKey: string, context: EvaluationContext, details: EvaluationDetails) => flagKey; - -const hook = new DebounceHook(loggingHook, { - debounceTime: 60_000, // how long to wait before the hook can fire again (applied to each stage independently) in milliseconds - afterCacheKeySupplier: supplier, // if the key calculated by the supplier exists in the cache, the wrapped hook's stage will not run +const debounceHook = new DebounceHook(loggingHook, { + debounceTime: 60_000, // how long to wait before the hook can fire again maxCacheItems: 100, // max amount of items to keep in the cache; if exceeded, the oldest item is dropped - cacheErrors: false // whether or not to debounce errors thrown by hook stages }); + +// add the hook globally +OpenFeature.addHooks(debounceHook); + +// or at a specific client +client.addHooks(debounceHook); ``` ## Development From 4ef61562b835be65f157fe45a5d717643a59d595 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Wed, 8 Oct 2025 16:56:50 -0400 Subject: [PATCH 13/22] Update libs/hooks/debounce/README.md Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Signed-off-by: Todd Baert --- libs/hooks/debounce/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/hooks/debounce/README.md b/libs/hooks/debounce/README.md index d41c9b866..e78b21939 100644 --- a/libs/hooks/debounce/README.md +++ b/libs/hooks/debounce/README.md @@ -23,7 +23,7 @@ NOTE: if you're using the React or Angular OpenFeature SDKs, you don't need to d 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). Simply wrap your hook with the debounce hook by passing it a constructor arg, and then configure the remaining options. -In the example below, we wrap the "after" stage of 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. +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. ```ts const debounceHook = new DebounceHook(loggingHook, { From ce32d2f2783eda7dddf981b48a51960e2604a85c Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Wed, 8 Oct 2025 17:01:02 -0400 Subject: [PATCH 14/22] Update libs/hooks/debounce/src/lib/utils/fixed-size-expiring-cache.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Signed-off-by: Todd Baert --- libs/hooks/debounce/src/lib/utils/fixed-size-expiring-cache.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/hooks/debounce/src/lib/utils/fixed-size-expiring-cache.ts b/libs/hooks/debounce/src/lib/utils/fixed-size-expiring-cache.ts index 502ecc215..b0edd74d6 100644 --- a/libs/hooks/debounce/src/lib/utils/fixed-size-expiring-cache.ts +++ b/libs/hooks/debounce/src/lib/utils/fixed-size-expiring-cache.ts @@ -68,7 +68,7 @@ export class FixedSizeExpiringCache { */ set(key: string, value: T) { if (key) { - if (this.cacheMap.size >= this.maxItems) { + if (!this.cacheMap.has(key) && this.cacheMap.size >= this.maxItems) { this.evictOldest(); } // delete first so that the order is updated when we re-set (Map keeps insertion order) From 69e462b0e85b25db1d6f6435c6fdbbdba577cb29 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Wed, 8 Oct 2025 17:01:24 -0400 Subject: [PATCH 15/22] Update libs/hooks/debounce/src/lib/debounce-hook.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Signed-off-by: Todd Baert --- libs/hooks/debounce/src/lib/debounce-hook.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/hooks/debounce/src/lib/debounce-hook.ts b/libs/hooks/debounce/src/lib/debounce-hook.ts index 6b923f7a6..0a1f36a16 100644 --- a/libs/hooks/debounce/src/lib/debounce-hook.ts +++ b/libs/hooks/debounce/src/lib/debounce-hook.ts @@ -161,7 +161,7 @@ export class DebounceHook implements Hook { const cachedStageResult = got[stage]; // throw cached errors if (cachedStageResult instanceof CachedError) { - throw got; + throw cachedStageResult; } if (cachedStageResult === true) { // already ran this stage for this key and is still in the debounce period From 477c601e6f85cc4a773b584f5d247c6d1b93a50e Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Thu, 9 Oct 2025 16:17:25 -0400 Subject: [PATCH 16/22] fixup: support web and server Signed-off-by: Todd Baert --- libs/hooks/debounce/README.md | 26 ++- libs/hooks/debounce/package.json | 2 +- .../debounce/src/lib/debounce-hook.spec.ts | 187 +++++++++++++++--- libs/hooks/debounce/src/lib/debounce-hook.ts | 90 ++++++--- 4 files changed, 238 insertions(+), 67 deletions(-) diff --git a/libs/hooks/debounce/README.md b/libs/hooks/debounce/README.md index e78b21939..3975de555 100644 --- a/libs/hooks/debounce/README.md +++ b/libs/hooks/debounce/README.md @@ -11,19 +11,13 @@ $ npm install @openfeature/debounce-hook ### Peer dependencies -Confirm that the following peer dependencies are installed: - -``` -$ npm install @openfeature/web-sdk -``` - -NOTE: if you're using the React or Angular OpenFeature SDKs, you don't need to directly install the web SDK. +This package only requires the `@openfeature/core` dependency, which is installed automatically no matter which OpenFeature JavaScript SDK you are using. ## Usage -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). -Simply wrap your hook with the debounce hook by passing it a constructor arg, and then configure the remaining options. -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. +Simply wrap your hook with the debounce hook by passing it as a constructor arg, and then configure the remaining options. +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. ```ts const debounceHook = new DebounceHook(loggingHook, { @@ -38,6 +32,18 @@ OpenFeature.addHooks(debounceHook); client.addHooks(debounceHook); ``` +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). +Be default, the key-generation function is purely based on the flag key. +Particularly in server use-cases, you may want to take the targetingKey or other contextual information into account in your debouncing: + +```ts +const debounceHook = new DebounceHook(loggingHook, { + cacheKeySupplier: (flagKey, context) => flagKey + context.targetingKey, // cache on a combination of user and flag key + debounceTime: 60_000, + maxCacheItems: 1000, +}); +``` + ## Development ### Building diff --git a/libs/hooks/debounce/package.json b/libs/hooks/debounce/package.json index 88eeb7291..e166c6f12 100644 --- a/libs/hooks/debounce/package.json +++ b/libs/hooks/debounce/package.json @@ -12,6 +12,6 @@ }, "license": "Apache-2.0", "peerDependencies": { - "@openfeature/web-sdk": "^1.6.0" + "@openfeature/core": "^1.9.1" } } diff --git a/libs/hooks/debounce/src/lib/debounce-hook.spec.ts b/libs/hooks/debounce/src/lib/debounce-hook.spec.ts index bb9da88a6..9e802ad5a 100644 --- a/libs/hooks/debounce/src/lib/debounce-hook.spec.ts +++ b/libs/hooks/debounce/src/lib/debounce-hook.spec.ts @@ -1,5 +1,7 @@ -import type { EvaluationDetails, Hook, HookContext } from '@openfeature/web-sdk'; +import type { EvaluationDetails, BaseHook, HookContext } from '@openfeature/core'; import { DebounceHook } from './debounce-hook'; +import type { Hook as WebSdkHook } from '@openfeature/web-sdk'; +import type { Hook as ServerSdkHook } from '@openfeature/server-sdk'; describe('DebounceHook', () => { describe('caching', () => { @@ -7,7 +9,7 @@ describe('DebounceHook', () => { jest.resetAllMocks(); }); - const innerHook: Hook = { + const innerHook: BaseHook = { before: jest.fn(), after: jest.fn(), error: jest.fn(), @@ -40,10 +42,10 @@ describe('DebounceHook', () => { calledTimesTotal: 2, // should not have been incremented, same cache key }, ])('should cache each stage based on supplier', ({ flagKey, calledTimesTotal }) => { - hook.before({ flagKey, context } as HookContext, hints); - hook.after({ flagKey, context } as HookContext, evaluationDetails, hints); - hook.error({ flagKey, context } as HookContext, err, hints); - hook.finally({ flagKey, context } as HookContext, evaluationDetails, hints); + hook.before({ flagKey, context } as HookContext, hints); + hook.after({ flagKey, context } as HookContext, evaluationDetails, hints); + hook.error({ flagKey, context } as HookContext, err, hints); + hook.finally({ flagKey, context } as HookContext, evaluationDetails, hints); expect(innerHook.before).toHaveBeenNthCalledWith(calledTimesTotal, expect.objectContaining({ context }), hints); expect(innerHook.after).toHaveBeenNthCalledWith( @@ -67,7 +69,7 @@ describe('DebounceHook', () => { }); it('stages should be cached independently', () => { - const innerHook: Hook = { + const innerHook: BaseHook = { before: jest.fn(), after: jest.fn(), }; @@ -79,8 +81,8 @@ describe('DebounceHook', () => { const flagKey = 'my-flag'; - hook.before({ flagKey } as HookContext, {}); - hook.after({ flagKey } as HookContext, { + hook.before({ flagKey } as HookContext, {}); + hook.after({ flagKey } as HookContext, { flagKey, flagMetadata: {}, value: true, @@ -98,7 +100,7 @@ describe('DebounceHook', () => { }); it('maxCacheItems should limit size', () => { - const innerHook: Hook = { + const innerHook: BaseHook = { before: jest.fn(), }; @@ -107,57 +109,59 @@ describe('DebounceHook', () => { maxCacheItems: 1, }); - hook.before({ flagKey: 'flag1' } as HookContext, {}); - hook.before({ flagKey: 'flag2' } as HookContext, {}); - hook.before({ flagKey: 'flag1' } as HookContext, {}); + hook.before({ flagKey: 'flag1' } as HookContext, {}); + hook.before({ flagKey: 'flag2' } as HookContext, {}); + hook.before({ flagKey: 'flag1' } as HookContext, {}); // every invocation should have run since we have only maxCacheItems: 1 expect(innerHook.before).toHaveBeenCalledTimes(3); }); it('should rerun inner hook only after debounce time', async () => { - const innerHook: Hook = { + const innerHook: BaseHook = { before: jest.fn(), }; const flagKey = 'some-flag'; - const hook = new DebounceHook(innerHook, { + const hook = new DebounceHook(innerHook, { debounceTime: 500, maxCacheItems: 1, }); - hook.before({ flagKey } as HookContext, {}); - hook.before({ flagKey } as HookContext, {}); - hook.before({ flagKey } as HookContext, {}); + hook.before({ flagKey } as HookContext, {}); + hook.before({ flagKey } as HookContext, {}); + hook.before({ flagKey } as HookContext, {}); await new Promise((r) => setTimeout(r, 1000)); - hook.before({ flagKey } as HookContext, {}); + hook.before({ flagKey } as HookContext, {}); // only the first and last should have invoked the inner hook expect(innerHook.before).toHaveBeenCalledTimes(2); }); it('use custom supplier', () => { - const innerHook: Hook = { + const innerHook: BaseHook = { before: jest.fn(), after: jest.fn(), error: jest.fn(), finally: jest.fn(), }; - const context = {}; + const context = { + targetingKey: 'user123', + }; const hints = {}; - const hook = new DebounceHook(innerHook, { - cacheKeySupplier: () => 'a-silly-const-key', // a constant key means all invocations are cached; just to test that the custom supplier is used + const hook = new DebounceHook(innerHook, { + cacheKeySupplier: (_, context) => context.targetingKey, // we are caching purely based on the targetingKey in the context, so we will only ever cache one entry debounceTime: 60_000, maxCacheItems: 100, }); - hook.before({ flagKey: 'flag1', context } as HookContext, hints); - hook.before({ flagKey: 'flag2', context } as HookContext, hints); + hook.before({ flagKey: 'flag1', context } as HookContext, hints); + hook.before({ flagKey: 'flag2', context } as HookContext, hints); // since we used a constant key, the second invocation should have been cached even though the flagKey was different expect(innerHook.before).toHaveBeenCalledTimes(1); @@ -173,7 +177,7 @@ describe('DebounceHook', () => { timesCalled: 1, // should be called once since we cached the error }, ])('should cache errors if cacheErrors set', ({ cacheErrors, timesCalled }) => { - const innerErrorHook: Hook = { + const innerErrorHook: BaseHook = { before: jest.fn(() => { // throw an error throw new Error('fake!'); @@ -184,16 +188,141 @@ describe('DebounceHook', () => { const context = {}; // this hook caches error invocations - const hook = new DebounceHook(innerErrorHook, { + const hook = new DebounceHook(innerErrorHook, { maxCacheItems: 100, debounceTime: 60_000, cacheErrors, }); - expect(() => hook.before({ flagKey, context } as HookContext)).toThrow(); - expect(() => hook.before({ flagKey, context } as HookContext)).toThrow(); + expect(() => hook.before({ flagKey, context } as HookContext)).toThrow(); + expect(() => hook.before({ flagKey, context } as HookContext)).toThrow(); expect(innerErrorHook.before).toHaveBeenCalledTimes(timesCalled); }); }); + + describe('SDK compatibility', () => { + describe('web-sdk hooks', () => { + it('should debounce synchronous hooks', () => { + const innerWebSdkHook: WebSdkHook = { + before: jest.fn(), + after: jest.fn(), + error: jest.fn(), + finally: jest.fn(), + }; + + const hook = new DebounceHook(innerWebSdkHook, { + debounceTime: 60_000, + maxCacheItems: 100, + }); + + const evaluationDetails: EvaluationDetails = { + value: 'testValue', + } as EvaluationDetails; + const err: Error = new Error('fake error!'); + const context = {}; + const hints = {}; + const flagKey = 'flag1'; + + for (let i = 0; i < 2; i++) { + hook.before({ flagKey, context } as HookContext, hints); + hook.after({ flagKey, context } as HookContext, evaluationDetails, hints); + hook.error({ flagKey, context } as HookContext, err, hints); + hook.finally({ flagKey, context } as HookContext, evaluationDetails, hints); + } + + expect(innerWebSdkHook.before).toHaveBeenCalledTimes(1); + }); + }); + + describe('server-sdk hooks', () => { + const contextKey = 'key'; + const contextValue = 'value'; + const evaluationContext = { [contextKey]: contextValue }; + it('should debounce synchronous hooks', () => { + const innerServerSdkHook: ServerSdkHook = { + before: jest.fn(() => { + return evaluationContext; + }), + after: jest.fn(), + error: jest.fn(), + finally: jest.fn(), + }; + + const hook = new DebounceHook(innerServerSdkHook, { + debounceTime: 60_000, + maxCacheItems: 100, + }); + + const evaluationDetails: EvaluationDetails = { + value: 1337, + } as EvaluationDetails; + const err: Error = new Error('fake error!'); + const context = {}; + const hints = {}; + const flagKey = 'flag1'; + + for (let i = 0; i < 2; i++) { + const returnedContext = hook.before({ flagKey, context } as HookContext, hints); + // make sure we return the expected context each time + expect(returnedContext).toEqual(expect.objectContaining(evaluationContext)); + hook.after({ flagKey, context } as HookContext, evaluationDetails, hints); + hook.error({ flagKey, context } as HookContext, err, hints); + hook.finally({ flagKey, context } as HookContext, evaluationDetails, hints); + } + + // all stages should have been called only once + expect(innerServerSdkHook.before).toHaveBeenCalledTimes(1); + expect(innerServerSdkHook.after).toHaveBeenCalledTimes(1); + expect(innerServerSdkHook.error).toHaveBeenCalledTimes(1); + expect(innerServerSdkHook.finally).toHaveBeenCalledTimes(1); + }); + + it('should debounce asynchronous hooks', async () => { + const delayMs = 100; + const innerServerSdkHook: ServerSdkHook = { + before: jest.fn(() => { + return new Promise((resolve) => setTimeout(() => resolve(evaluationContext), delayMs)); + }), + after: jest.fn(() => { + return new Promise((resolve) => setTimeout(() => resolve(), delayMs)); + }), + error: jest.fn(() => { + return new Promise((resolve) => setTimeout(() => resolve(), delayMs)); + }), + finally: jest.fn(() => { + return new Promise((resolve) => setTimeout(() => resolve(), delayMs)); + }), + }; + + const hook = new DebounceHook(innerServerSdkHook, { + debounceTime: 60_000, + maxCacheItems: 100, + }); + + const evaluationDetails: EvaluationDetails = { + value: 1337, + } as EvaluationDetails; + const err: Error = new Error('fake error!'); + const context = {}; + const hints = {}; + const flagKey = 'flag1'; + + for (let i = 0; i < 2; i++) { + const returnedContext = await hook.before({ flagKey, context } as HookContext, hints); + // make sure we return the expected context each time + expect(returnedContext).toEqual(expect.objectContaining(evaluationContext)); + await hook.after({ flagKey, context } as HookContext, evaluationDetails, hints); + await hook.error({ flagKey, context } as HookContext, err, hints); + await hook.finally({ flagKey, context } as HookContext, evaluationDetails, hints); + } + + // each stage should have been called only once + expect(innerServerSdkHook.before).toHaveBeenCalledTimes(1); + expect(innerServerSdkHook.after).toHaveBeenCalledTimes(1); + expect(innerServerSdkHook.error).toHaveBeenCalledTimes(1); + expect(innerServerSdkHook.finally).toHaveBeenCalledTimes(1); + }); + }); + }); }); diff --git a/libs/hooks/debounce/src/lib/debounce-hook.ts b/libs/hooks/debounce/src/lib/debounce-hook.ts index 0a1f36a16..f0bc24bbb 100644 --- a/libs/hooks/debounce/src/lib/debounce-hook.ts +++ b/libs/hooks/debounce/src/lib/debounce-hook.ts @@ -1,19 +1,20 @@ -import type { Logger } from '@openfeature/web-sdk'; +import type { Logger } from '@openfeature/core'; import { ErrorCode, OpenFeatureError, type EvaluationContext, type EvaluationDetails, type FlagValue, - type Hook, + type BaseHook, type HookContext, type HookHints, -} from '@openfeature/web-sdk'; +} from '@openfeature/core'; import { FixedSizeExpiringCache } from './utils/fixed-size-expiring-cache'; const DEFAULT_CACHE_KEY_SUPPLIER = (flagKey: string) => flagKey; -type StageResult = true | CachedError; +type StageResult = EvaluationContext | true | CachedError; type HookStagesEntry = { before?: StageResult; after?: StageResult; error?: StageResult; finally?: StageResult }; +type Stage = 'before' | 'after' | 'error' | 'finally'; /** * An error cached from a previous hook invocation. @@ -80,13 +81,17 @@ export type Options = { * 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. * If no cache key supplier is provided for a stage, that stage will always run. */ -export class DebounceHook implements Hook { +export class DebounceHook implements BaseHook { private readonly cache: FixedSizeExpiringCache; private readonly cacheErrors: boolean; private readonly cacheKeySupplier: Options['cacheKeySupplier']; public constructor( - private readonly innerHook: Hook, + private readonly innerHook: BaseHook< + T, + Promise | EvaluationContext | void, + Promise | void + >, private readonly options: Options, ) { this.cacheErrors = options.cacheErrors ?? false; @@ -97,32 +102,32 @@ export class DebounceHook implements Hook { }); } - before(hookContext: HookContext, hookHints?: HookHints) { - this.maybeSkipAndCache( + before(hookContext: HookContext, hookHints?: HookHints) { + return this.maybeSkipAndCache( 'before', () => this.cacheKeySupplier?.(hookContext.flagKey, hookContext.context), () => this.innerHook?.before?.(hookContext, hookHints), ); } - after(hookContext: HookContext, evaluationDetails: EvaluationDetails, hookHints?: HookHints) { - this.maybeSkipAndCache( + after(hookContext: HookContext, evaluationDetails: EvaluationDetails, hookHints?: HookHints) { + return this.maybeSkipAndCache( 'after', () => this.cacheKeySupplier?.(hookContext.flagKey, hookContext.context), () => this.innerHook?.after?.(hookContext, evaluationDetails, hookHints), ); } - error(hookContext: HookContext, err: unknown, hookHints?: HookHints) { - this.maybeSkipAndCache( + error(hookContext: HookContext, err: unknown, hookHints?: HookHints) { + return this.maybeSkipAndCache( 'error', () => this.cacheKeySupplier?.(hookContext.flagKey, hookContext.context), () => this.innerHook?.error?.(hookContext, err, hookHints), ); } - finally(hookContext: HookContext, evaluationDetails: EvaluationDetails, hookHints?: HookHints) { - this.maybeSkipAndCache( + finally(hookContext: HookContext, evaluationDetails: EvaluationDetails, hookHints?: HookHints) { + return this.maybeSkipAndCache( 'finally', () => this.cacheKeySupplier?.(hookContext.flagKey, hookContext.context), () => this.innerHook?.finally?.(hookContext, evaluationDetails, hookHints), @@ -130,9 +135,9 @@ export class DebounceHook implements Hook { } private maybeSkipAndCache( - stage: 'before' | 'after' | 'error' | 'finally', + stage: Stage, keyGenCallback: () => string | null | undefined, - hookCallback: () => void, + hookCallback: () => Promise | EvaluationContext | void, ) { // the cache key is a concatenation of the result of calling keyGenCallback and the stage let dynamicKey: string | null | undefined; @@ -149,12 +154,10 @@ export class DebounceHook implements Hook { // if the keyGenCallback returns nothing, we don't do any caching if (!dynamicKey) { - hookCallback.call(this.innerHook); - return; + return hookCallback.call(this.innerHook); } - const cacheKeySuffix = stage; - const cacheKey = `${dynamicKey}::${cacheKeySuffix}`; + const cacheKey = `${dynamicKey}::cache-key`; const got = this.cache.get(cacheKey); if (got) { @@ -163,21 +166,54 @@ export class DebounceHook implements Hook { if (cachedStageResult instanceof CachedError) { throw cachedStageResult; } - if (cachedStageResult === true) { + if (cachedStageResult) { // already ran this stage for this key and is still in the debounce period + if (typeof cachedStageResult === 'object') { + // we have a cached context to return + return cachedStageResult; + } return; } } + // we have to be pretty careful here to support both web and server hooks; + // server hooks can be async, web hooks can't, we have to handle both cases. try { - hookCallback.call(this.innerHook); - this.cache.set(cacheKey, { ...got, [stage]: true }); - } catch (error: unknown) { - if (this.cacheErrors) { - // cache error - this.cache.set(cacheKey, { ...got, [stage]: new CachedError(error) }); + const maybePromiseOrContext = hookCallback.call(this.innerHook); + if (maybePromiseOrContext && typeof maybePromiseOrContext.then === 'function') { + // async hook result; cache after promise resolves + maybePromiseOrContext + .then((maybeContext) => { + this.cacheSuccess(cacheKey, stage, got, maybeContext); + return maybeContext; + }) + .catch((error) => { + this.cacheError(cacheKey, stage, got, error); + throw error; + }); + } else { + // sync hook result; cache now + this.cacheSuccess(cacheKey, stage, got, maybePromiseOrContext as void | EvaluationContext); } + return maybePromiseOrContext; + } catch (error: unknown) { + this.cacheError(cacheKey, stage, got, error); throw error; } } + + private cacheSuccess( + key: string, + stage: Stage, + cached: HookStagesEntry | undefined, + maybeContext: EvaluationContext | void, + ): void { + this.cache.set(key, { ...cached, [stage]: maybeContext || true }); + } + + private cacheError(key: string, stage: Stage, cached: HookStagesEntry | undefined, error: unknown): void { + if (this.cacheErrors) { + this.cache.set(key, { ...cached, [stage]: new CachedError(error) }); + } + } } From 0733ac7713ac622377139f895c5988e0155184a2 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Thu, 9 Oct 2025 16:45:54 -0400 Subject: [PATCH 17/22] fixup: typechecks Signed-off-by: Todd Baert --- libs/hooks/debounce/src/lib/debounce-hook.spec.ts | 12 ++++++------ libs/hooks/debounce/src/lib/debounce-hook.ts | 4 +++- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/libs/hooks/debounce/src/lib/debounce-hook.spec.ts b/libs/hooks/debounce/src/lib/debounce-hook.spec.ts index 9e802ad5a..6e6e1bd80 100644 --- a/libs/hooks/debounce/src/lib/debounce-hook.spec.ts +++ b/libs/hooks/debounce/src/lib/debounce-hook.spec.ts @@ -9,7 +9,7 @@ describe('DebounceHook', () => { jest.resetAllMocks(); }); - const innerHook: BaseHook = { + const innerHook: BaseHook, void, void> = { before: jest.fn(), after: jest.fn(), error: jest.fn(), @@ -69,7 +69,7 @@ describe('DebounceHook', () => { }); it('stages should be cached independently', () => { - const innerHook: BaseHook = { + const innerHook: BaseHook, void, void> = { before: jest.fn(), after: jest.fn(), }; @@ -100,7 +100,7 @@ describe('DebounceHook', () => { }); it('maxCacheItems should limit size', () => { - const innerHook: BaseHook = { + const innerHook: BaseHook, void, void> = { before: jest.fn(), }; @@ -118,7 +118,7 @@ describe('DebounceHook', () => { }); it('should rerun inner hook only after debounce time', async () => { - const innerHook: BaseHook = { + const innerHook: BaseHook, void, void> = { before: jest.fn(), }; @@ -142,7 +142,7 @@ describe('DebounceHook', () => { }); it('use custom supplier', () => { - const innerHook: BaseHook = { + const innerHook: BaseHook, void, void> = { before: jest.fn(), after: jest.fn(), error: jest.fn(), @@ -177,7 +177,7 @@ describe('DebounceHook', () => { timesCalled: 1, // should be called once since we cached the error }, ])('should cache errors if cacheErrors set', ({ cacheErrors, timesCalled }) => { - const innerErrorHook: BaseHook = { + const innerErrorHook: BaseHook, void, void> = { before: jest.fn(() => { // throw an error throw new Error('fake!'); diff --git a/libs/hooks/debounce/src/lib/debounce-hook.ts b/libs/hooks/debounce/src/lib/debounce-hook.ts index f0bc24bbb..15fc241dc 100644 --- a/libs/hooks/debounce/src/lib/debounce-hook.ts +++ b/libs/hooks/debounce/src/lib/debounce-hook.ts @@ -87,8 +87,10 @@ export class DebounceHook implements BaseHook { private readonly cacheKeySupplier: Options['cacheKeySupplier']; public constructor( + // this is a superset of web and server hook forms; validated by the test suite private readonly innerHook: BaseHook< - T, + FlagValue, + Record, Promise | EvaluationContext | void, Promise | void >, From 91ef84dfe9e5e78b9f4eebede0ca7d90373174a3 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Thu, 9 Oct 2025 16:46:27 -0400 Subject: [PATCH 18/22] Update libs/hooks/debounce/README.md Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Signed-off-by: Todd Baert --- libs/hooks/debounce/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/hooks/debounce/README.md b/libs/hooks/debounce/README.md index 3975de555..56d513648 100644 --- a/libs/hooks/debounce/README.md +++ b/libs/hooks/debounce/README.md @@ -32,7 +32,7 @@ OpenFeature.addHooks(debounceHook); client.addHooks(debounceHook); ``` -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). +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 (cacheKeySupplier). Be default, the key-generation function is purely based on the flag key. Particularly in server use-cases, you may want to take the targetingKey or other contextual information into account in your debouncing: From 216d22088af66f47437d5152fa931d6d47ec695e Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Mon, 13 Oct 2025 07:59:17 -0400 Subject: [PATCH 19/22] Update libs/hooks/debounce/src/lib/debounce-hook.ts Co-authored-by: Lukas Reining Signed-off-by: Todd Baert --- libs/hooks/debounce/src/lib/debounce-hook.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/libs/hooks/debounce/src/lib/debounce-hook.ts b/libs/hooks/debounce/src/lib/debounce-hook.ts index 15fc241dc..3746ab7eb 100644 --- a/libs/hooks/debounce/src/lib/debounce-hook.ts +++ b/libs/hooks/debounce/src/lib/debounce-hook.ts @@ -182,7 +182,7 @@ export class DebounceHook implements BaseHook { // server hooks can be async, web hooks can't, we have to handle both cases. try { const maybePromiseOrContext = hookCallback.call(this.innerHook); - if (maybePromiseOrContext && typeof maybePromiseOrContext.then === 'function') { + if (this.isPromise(maybePromiseOrContext)) { // async hook result; cache after promise resolves maybePromiseOrContext .then((maybeContext) => { @@ -195,7 +195,7 @@ export class DebounceHook implements BaseHook { }); } else { // sync hook result; cache now - this.cacheSuccess(cacheKey, stage, got, maybePromiseOrContext as void | EvaluationContext); + this.cacheSuccess(cacheKey, stage, got, maybePromiseOrContext); } return maybePromiseOrContext; } catch (error: unknown) { @@ -204,6 +204,10 @@ export class DebounceHook implements BaseHook { } } + private isPromise(value: T | Promise): value is Promise { + return !!value && typeof (value as Promise).then === 'function'; + } + private cacheSuccess( key: string, stage: Stage, From 5076b504002e39760025b4f9bd7ac152732f66a5 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Mon, 13 Oct 2025 08:48:26 -0400 Subject: [PATCH 20/22] fixup: compilation issue with server Signed-off-by: Todd Baert --- .../debounce/src/lib/debounce-hook.spec.ts | 36 +++++++++++++++++++ libs/hooks/debounce/src/lib/debounce-hook.ts | 20 +++++++---- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/libs/hooks/debounce/src/lib/debounce-hook.spec.ts b/libs/hooks/debounce/src/lib/debounce-hook.spec.ts index 6e6e1bd80..d368cce21 100644 --- a/libs/hooks/debounce/src/lib/debounce-hook.spec.ts +++ b/libs/hooks/debounce/src/lib/debounce-hook.spec.ts @@ -1,7 +1,9 @@ import type { EvaluationDetails, BaseHook, HookContext } from '@openfeature/core'; import { DebounceHook } from './debounce-hook'; import type { Hook as WebSdkHook } from '@openfeature/web-sdk'; +import { OpenFeature as OpenFeatureWeb } from '@openfeature/web-sdk'; import type { Hook as ServerSdkHook } from '@openfeature/server-sdk'; +import { OpenFeature as OpenFeaturServer } from '@openfeature/server-sdk'; describe('DebounceHook', () => { describe('caching', () => { @@ -203,6 +205,23 @@ describe('DebounceHook', () => { describe('SDK compatibility', () => { describe('web-sdk hooks', () => { + it('should have type compatibility with API, client, evaluation', () => { + const webSdkHook = {} as WebSdkHook; + + const hook = new DebounceHook(webSdkHook, { + debounceTime: 60_000, + maxCacheItems: 100, + }); + + OpenFeatureWeb.addHooks(hook); + const client = OpenFeatureWeb.getClient().addHooks(hook); + client.getBooleanValue('flag', false, { hooks: [hook] }); + + // these expectations are silly, the real test here is making sure the above compiles + expect(OpenFeatureWeb.getHooks().length).toEqual(1); + expect(client.getHooks().length).toEqual(1); + }); + it('should debounce synchronous hooks', () => { const innerWebSdkHook: WebSdkHook = { before: jest.fn(), @@ -239,6 +258,23 @@ describe('DebounceHook', () => { const contextKey = 'key'; const contextValue = 'value'; const evaluationContext = { [contextKey]: contextValue }; + it('should have type compatibility with API, client, evaluation', () => { + const serverHook = {} as ServerSdkHook; + + const hook = new DebounceHook(serverHook, { + debounceTime: 60_000, + maxCacheItems: 100, + }); + + OpenFeaturServer.addHooks(hook); + const client = OpenFeaturServer.getClient().addHooks(hook); + client.getBooleanValue('flag', false, {}, { hooks: [hook] }); + + // these expectations are silly, the real test here is making sure the above compiles + expect(OpenFeaturServer.getHooks().length).toEqual(1); + expect(client.getHooks().length).toEqual(1); + }); + it('should debounce synchronous hooks', () => { const innerServerSdkHook: ServerSdkHook = { before: jest.fn(() => { diff --git a/libs/hooks/debounce/src/lib/debounce-hook.ts b/libs/hooks/debounce/src/lib/debounce-hook.ts index 3746ab7eb..17cea4c70 100644 --- a/libs/hooks/debounce/src/lib/debounce-hook.ts +++ b/libs/hooks/debounce/src/lib/debounce-hook.ts @@ -81,7 +81,15 @@ export type Options = { * 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. * If no cache key supplier is provided for a stage, that stage will always run. */ -export class DebounceHook implements BaseHook { +export class DebounceHook + implements + BaseHook< + T, + Record, + Promise | EvaluationContext | void, + Promise | void + > +{ private readonly cache: FixedSizeExpiringCache; private readonly cacheErrors: boolean; private readonly cacheKeySupplier: Options['cacheKeySupplier']; @@ -136,11 +144,11 @@ export class DebounceHook implements BaseHook { ); } - private maybeSkipAndCache( + private maybeSkipAndCache | EvaluationContext | void>( stage: Stage, keyGenCallback: () => string | null | undefined, - hookCallback: () => Promise | EvaluationContext | void, - ) { + hookCallback: () => T, + ): T | void { // the cache key is a concatenation of the result of calling keyGenCallback and the stage let dynamicKey: string | null | undefined; @@ -172,9 +180,9 @@ export class DebounceHook implements BaseHook { // already ran this stage for this key and is still in the debounce period if (typeof cachedStageResult === 'object') { // we have a cached context to return - return cachedStageResult; + return cachedStageResult as T; } - return; + return; // cached run with void return } } From 28a20cdbe4ef2175c631a3e676be788dd1d4bb68 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Mon, 13 Oct 2025 11:19:36 -0400 Subject: [PATCH 21/22] fixup: pr feedback Signed-off-by: Todd Baert --- libs/hooks/debounce/README.md | 11 +++++--- .../debounce/src/lib/debounce-hook.spec.ts | 22 +++++++++------- libs/hooks/debounce/src/lib/debounce-hook.ts | 26 +++++++------------ 3 files changed, 28 insertions(+), 31 deletions(-) diff --git a/libs/hooks/debounce/README.md b/libs/hooks/debounce/README.md index 56d513648..e736091fb 100644 --- a/libs/hooks/debounce/README.md +++ b/libs/hooks/debounce/README.md @@ -20,7 +20,7 @@ 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. ```ts -const debounceHook = new DebounceHook(loggingHook, { +const debounceHook = new DebounceHook(loggingHook, { debounceTime: 60_000, // how long to wait before the hook can fire again maxCacheItems: 100, // max amount of items to keep in the cache; if exceeded, the oldest item is dropped }); @@ -34,13 +34,16 @@ client.addHooks(debounceHook); 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 (cacheKeySupplier). Be default, the key-generation function is purely based on the flag key. -Particularly in server use-cases, you may want to take the targetingKey or other contextual information into account in your debouncing: +Particularly in server use-cases, you may want to take the targetingKey or other contextual information into account in your debouncing. +Below we see an example using this, as well as other advanced options: ```ts const debounceHook = new DebounceHook(loggingHook, { cacheKeySupplier: (flagKey, context) => flagKey + context.targetingKey, // cache on a combination of user and flag key - debounceTime: 60_000, - maxCacheItems: 1000, + debounceTime: 60_000, // debounce for 60 seconds (per user due to our cacheKeySupplier above) + maxCacheItems: 1000, // cache a maximum of 1000 records + cacheErrors: false, // don't debounce errors; always re-run if the last run threw + logger: console, // optional logger }); ``` diff --git a/libs/hooks/debounce/src/lib/debounce-hook.spec.ts b/libs/hooks/debounce/src/lib/debounce-hook.spec.ts index d368cce21..0747a9afc 100644 --- a/libs/hooks/debounce/src/lib/debounce-hook.spec.ts +++ b/libs/hooks/debounce/src/lib/debounce-hook.spec.ts @@ -18,7 +18,7 @@ describe('DebounceHook', () => { finally: jest.fn(), }; - const hook = new DebounceHook(innerHook, { + const hook = new DebounceHook(innerHook, { debounceTime: 60_000, maxCacheItems: 100, }); @@ -76,7 +76,7 @@ describe('DebounceHook', () => { after: jest.fn(), }; - const hook = new DebounceHook(innerHook, { + const hook = new DebounceHook(innerHook, { debounceTime: 60_000, maxCacheItems: 100, }); @@ -106,7 +106,7 @@ describe('DebounceHook', () => { before: jest.fn(), }; - const hook = new DebounceHook(innerHook, { + const hook = new DebounceHook(innerHook, { debounceTime: 60_000, maxCacheItems: 1, }); @@ -156,7 +156,7 @@ describe('DebounceHook', () => { }; const hints = {}; - const hook = new DebounceHook(innerHook, { + const hook = new DebounceHook(innerHook, { cacheKeySupplier: (_, context) => context.targetingKey, // we are caching purely based on the targetingKey in the context, so we will only ever cache one entry debounceTime: 60_000, maxCacheItems: 100, @@ -190,7 +190,7 @@ describe('DebounceHook', () => { const context = {}; // this hook caches error invocations - const hook = new DebounceHook(innerErrorHook, { + const hook = new DebounceHook(innerErrorHook, { maxCacheItems: 100, debounceTime: 60_000, cacheErrors, @@ -208,7 +208,7 @@ describe('DebounceHook', () => { it('should have type compatibility with API, client, evaluation', () => { const webSdkHook = {} as WebSdkHook; - const hook = new DebounceHook(webSdkHook, { + const hook = new DebounceHook(webSdkHook, { debounceTime: 60_000, maxCacheItems: 100, }); @@ -230,7 +230,7 @@ describe('DebounceHook', () => { finally: jest.fn(), }; - const hook = new DebounceHook(innerWebSdkHook, { + const hook = new DebounceHook(innerWebSdkHook, { debounceTime: 60_000, maxCacheItems: 100, }); @@ -261,7 +261,7 @@ describe('DebounceHook', () => { it('should have type compatibility with API, client, evaluation', () => { const serverHook = {} as ServerSdkHook; - const hook = new DebounceHook(serverHook, { + const hook = new DebounceHook(serverHook, { debounceTime: 60_000, maxCacheItems: 100, }); @@ -285,7 +285,7 @@ describe('DebounceHook', () => { finally: jest.fn(), }; - const hook = new DebounceHook(innerServerSdkHook, { + const hook = new DebounceHook(innerServerSdkHook, { debounceTime: 60_000, maxCacheItems: 100, }); @@ -331,9 +331,11 @@ describe('DebounceHook', () => { }), }; - const hook = new DebounceHook(innerServerSdkHook, { + const hook = new DebounceHook(innerServerSdkHook, { debounceTime: 60_000, maxCacheItems: 100, + cacheErrors: false, + logger: console, }); const evaluationDetails: EvaluationDetails = { diff --git a/libs/hooks/debounce/src/lib/debounce-hook.ts b/libs/hooks/debounce/src/lib/debounce-hook.ts index 17cea4c70..b306f53be 100644 --- a/libs/hooks/debounce/src/lib/debounce-hook.ts +++ b/libs/hooks/debounce/src/lib/debounce-hook.ts @@ -81,15 +81,7 @@ export type Options = { * 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. * If no cache key supplier is provided for a stage, that stage will always run. */ -export class DebounceHook - implements - BaseHook< - T, - Record, - Promise | EvaluationContext | void, - Promise | void - > -{ +export class DebounceHook implements BaseHook { private readonly cache: FixedSizeExpiringCache; private readonly cacheErrors: boolean; private readonly cacheKeySupplier: Options['cacheKeySupplier']; @@ -112,7 +104,7 @@ export class DebounceHook }); } - before(hookContext: HookContext, hookHints?: HookHints) { + before(hookContext: HookContext, hookHints?: HookHints) { return this.maybeSkipAndCache( 'before', () => this.cacheKeySupplier?.(hookContext.flagKey, hookContext.context), @@ -120,7 +112,7 @@ export class DebounceHook ); } - after(hookContext: HookContext, evaluationDetails: EvaluationDetails, hookHints?: HookHints) { + after(hookContext: HookContext, evaluationDetails: EvaluationDetails, hookHints?: HookHints) { return this.maybeSkipAndCache( 'after', () => this.cacheKeySupplier?.(hookContext.flagKey, hookContext.context), @@ -128,7 +120,7 @@ export class DebounceHook ); } - error(hookContext: HookContext, err: unknown, hookHints?: HookHints) { + error(hookContext: HookContext, err: unknown, hookHints?: HookHints) { return this.maybeSkipAndCache( 'error', () => this.cacheKeySupplier?.(hookContext.flagKey, hookContext.context), @@ -136,7 +128,7 @@ export class DebounceHook ); } - finally(hookContext: HookContext, evaluationDetails: EvaluationDetails, hookHints?: HookHints) { + finally(hookContext: HookContext, evaluationDetails: EvaluationDetails, hookHints?: HookHints) { return this.maybeSkipAndCache( 'finally', () => this.cacheKeySupplier?.(hookContext.flagKey, hookContext.context), @@ -150,10 +142,10 @@ export class DebounceHook hookCallback: () => T, ): T | void { // the cache key is a concatenation of the result of calling keyGenCallback and the stage - let dynamicKey: string | null | undefined; + let cacheKey: string | null | undefined; try { - dynamicKey = keyGenCallback(); + cacheKey = keyGenCallback(); } catch (e) { // if the keyGenCallback throws, we log and run the hook stage this.options.logger?.error( @@ -163,11 +155,10 @@ export class DebounceHook } // if the keyGenCallback returns nothing, we don't do any caching - if (!dynamicKey) { + if (!cacheKey) { return hookCallback.call(this.innerHook); } - const cacheKey = `${dynamicKey}::cache-key`; const got = this.cache.get(cacheKey); if (got) { @@ -222,6 +213,7 @@ export class DebounceHook cached: HookStagesEntry | undefined, maybeContext: EvaluationContext | void, ): void { + // cache the context if we have one, otherwise just a true to indicate we ran this stage this.cache.set(key, { ...cached, [stage]: maybeContext || true }); } From 44818f3e83716507db3711385928228905bbab60 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Mon, 13 Oct 2025 12:36:45 -0400 Subject: [PATCH 22/22] Update libs/hooks/debounce/src/lib/debounce-hook.ts Co-authored-by: Lukas Reining Signed-off-by: Todd Baert --- libs/hooks/debounce/src/lib/debounce-hook.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/hooks/debounce/src/lib/debounce-hook.ts b/libs/hooks/debounce/src/lib/debounce-hook.ts index b306f53be..31877492c 100644 --- a/libs/hooks/debounce/src/lib/debounce-hook.ts +++ b/libs/hooks/debounce/src/lib/debounce-hook.ts @@ -214,7 +214,7 @@ export class DebounceHook implements BaseHook { maybeContext: EvaluationContext | void, ): void { // cache the context if we have one, otherwise just a true to indicate we ran this stage - this.cache.set(key, { ...cached, [stage]: maybeContext || true }); + this.cache.set(key, { ...cached, [stage]: maybeContext ?? true }); } private cacheError(key: string, stage: Stage, cached: HookStagesEntry | undefined, error: unknown): void {