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..56d513648 --- /dev/null +++ b/libs/hooks/debounce/README.md @@ -0,0 +1,55 @@ +# 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 + +This package only requires the `@openfeature/core` dependency, which is installed automatically no matter which OpenFeature JavaScript SDK you are using. + +## Usage + +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, { + 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 +}); + +// add the hook globally +OpenFeature.addHooks(debounceHook); + +// or at a specific client +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: + +```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 + +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..e166c6f12 --- /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/core": "^1.9.1" + } +} 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..6e6e1bd80 --- /dev/null +++ b/libs/hooks/debounce/src/lib/debounce-hook.spec.ts @@ -0,0 +1,328 @@ +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', () => { + afterAll(() => { + jest.resetAllMocks(); + }); + + const innerHook: BaseHook, void, void> = { + before: jest.fn(), + after: jest.fn(), + error: jest.fn(), + finally: jest.fn(), + }; + + const hook = new DebounceHook(innerHook, { + debounceTime: 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, + ); + }); + + it('stages should be cached independently', () => { + const innerHook: BaseHook, void, void> = { + 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', () => { + afterAll(() => { + jest.resetAllMocks(); + }); + + it('maxCacheItems should limit size', () => { + const innerHook: BaseHook, void, void> = { + before: jest.fn(), + }; + + const hook = new DebounceHook(innerHook, { + debounceTime: 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 only after debounce time', async () => { + const innerHook: BaseHook, void, void> = { + before: jest.fn(), + }; + + const flagKey = 'some-flag'; + + const hook = new DebounceHook(innerHook, { + debounceTime: 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('use custom supplier', () => { + const innerHook: BaseHook, void, void> = { + before: jest.fn(), + after: jest.fn(), + error: jest.fn(), + finally: jest.fn(), + }; + + const context = { + targetingKey: 'user123', + }; + const hints = {}; + + 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); + + // 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([ + { + 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: BaseHook, void, void> = { + 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, { + maxCacheItems: 100, + debounceTime: 60_000, + cacheErrors, + }); + + 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 new file mode 100644 index 000000000..15fc241dc --- /dev/null +++ b/libs/hooks/debounce/src/lib/debounce-hook.ts @@ -0,0 +1,221 @@ +import type { Logger } from '@openfeature/core'; +import { + ErrorCode, + OpenFeatureError, + type EvaluationContext, + type EvaluationDetails, + type FlagValue, + type BaseHook, + type HookContext, + type HookHints, +} from '@openfeature/core'; +import { FixedSizeExpiringCache } from './utils/fixed-size-expiring-cache'; + +const DEFAULT_CACHE_KEY_SUPPLIER = (flagKey: string) => flagKey; +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. + */ +export class CachedError extends OpenFeatureError { + 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; + } + + get code() { + if (this._innerError instanceof OpenFeatureError) { + return this._innerError.code; + } + return ErrorCode.GENERAL; + } +} + +export type Options = { + /** + * 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. + * 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 + */ + 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. + * If true, stages that throw will be debounced and their errors cached and re-thrown for the debounced period. + */ + cacheErrors?: boolean; + /** + * Debounce timeout - how long to wait before the hook can fire again (applied to each stage independently) in milliseconds. + */ + debounceTime: number; + /** + * 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. + * 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 { + private readonly cache: FixedSizeExpiringCache; + private readonly cacheErrors: boolean; + 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< + FlagValue, + Record, + Promise | EvaluationContext | void, + Promise | void + >, + private readonly options: Options, + ) { + this.cacheErrors = options.cacheErrors ?? false; + this.cacheKeySupplier = options.cacheKeySupplier ?? DEFAULT_CACHE_KEY_SUPPLIER; + this.cache = new FixedSizeExpiringCache({ + maxItems: options.maxCacheItems, + ttlMs: options.debounceTime, + }); + } + + 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) { + return this.maybeSkipAndCache( + 'after', + () => this.cacheKeySupplier?.(hookContext.flagKey, hookContext.context), + () => this.innerHook?.after?.(hookContext, evaluationDetails, hookHints), + ); + } + + 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) { + return this.maybeSkipAndCache( + 'finally', + () => this.cacheKeySupplier?.(hookContext.flagKey, hookContext.context), + () => this.innerHook?.finally?.(hookContext, evaluationDetails, hookHints), + ); + } + + private maybeSkipAndCache( + stage: Stage, + keyGenCallback: () => string | null | undefined, + hookCallback: () => Promise | EvaluationContext | void, + ) { + // the cache key is a concatenation of the result of calling keyGenCallback and the stage + 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 (!dynamicKey) { + return hookCallback.call(this.innerHook); + } + + const cacheKey = `${dynamicKey}::cache-key`; + const got = this.cache.get(cacheKey); + + if (got) { + const cachedStageResult = got[stage]; + // throw cached errors + if (cachedStageResult instanceof CachedError) { + throw cachedStageResult; + } + 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 { + 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) }); + } + } +} 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..b0edd74d6 --- /dev/null +++ b/libs/hooks/debounce/src/lib/utils/fixed-size-expiring-cache.ts @@ -0,0 +1,95 @@ +/** + * 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. + * 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>(); + 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. + * + * @param key key for the entry + * @returns value or key or undefined + */ + get(key: string): T | undefined { + if (key) { + const entry = this.cacheMap.get(key); + if (entry) { + if (entry.expiry > Date.now()) { + return entry.value; + } else { + this.cacheMap.delete(key); // expired + } + } + } + return undefined; + } + + /** + * 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.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) + // this is only relevant for eviction when at max size + 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 + // this is only relevant when at max size + 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"],