Compare commits
17 Commits
core-v1.8.
...
main
Author | SHA1 | Date |
---|---|---|
|
ee236396cd | |
|
9aab3d053b | |
|
3105595926 | |
|
1dbbd5161b | |
|
2287083de7 | |
|
04139affcb | |
|
1e330a2e13 | |
|
7df8a8eedc | |
|
3b3515b601 | |
|
63a1feb213 | |
|
845d24c5fe | |
|
dc1970e717 | |
|
e6e5ff3edf | |
|
40a512e212 | |
|
28850b7f6d | |
|
aa232a9d6a | |
|
07af3a9eda |
|
@ -37,8 +37,11 @@ jobs:
|
|||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Test
|
||||
run: npm run test
|
||||
- name: Test Jest Projects
|
||||
run: npm run test:jest
|
||||
|
||||
- name: Test Angular SDK
|
||||
run: npm run test:angular
|
||||
|
||||
codecov-and-docs:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"packages/nest": "0.2.5",
|
||||
"packages/react": "1.0.0",
|
||||
"packages/web": "1.5.0",
|
||||
"packages/server": "1.18.0",
|
||||
"packages/shared": "1.8.1",
|
||||
"packages/angular/projects/angular-sdk": "0.0.15"
|
||||
"packages/react": "1.0.1",
|
||||
"packages/web": "1.6.1",
|
||||
"packages/server": "1.19.0",
|
||||
"packages/shared": "1.9.0",
|
||||
"packages/angular/projects/angular-sdk": "0.0.16"
|
||||
}
|
||||
|
|
|
@ -190,26 +190,6 @@ export default {
|
|||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'angular',
|
||||
testEnvironment: 'jsdom',
|
||||
preset: 'jest-preset-angular',
|
||||
testMatch: ['<rootDir>/packages/angular/projects/angular-sdk/src/**/*.spec.{ts,tsx}'],
|
||||
setupFilesAfterEnv: ['<rootDir>/packages/angular/setup-jest.ts'],
|
||||
moduleNameMapper: {
|
||||
'@openfeature/core': '<rootDir>/packages/shared/src',
|
||||
'@openfeature/web-sdk': '<rootDir>/packages/web/src',
|
||||
},
|
||||
transform: {
|
||||
'^.+\\.(ts|js|html|svg)$': [
|
||||
'jest-preset-angular',
|
||||
{
|
||||
tsconfig: '<rootDir>/packages/angular/tsconfig.json',
|
||||
isolatedModules: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
],
|
||||
|
||||
// Use this configuration option to add custom reporters to Jest
|
||||
|
|
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
|
@ -1,10 +1,15 @@
|
|||
{
|
||||
"name": "@openfeature/js",
|
||||
"engines": {
|
||||
"npm": "^10.0.0"
|
||||
},
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "OpenFeature SDK for JavaScript",
|
||||
"scripts": {
|
||||
"test": "jest --selectProjects=shared --selectProjects=server --selectProjects=web --selectProjects=react --selectProjects=angular --selectProjects=nest --silent",
|
||||
"test": "npm run test:jest && npm run test:angular",
|
||||
"test:jest": "jest --selectProjects=shared --selectProjects=server --selectProjects=web --selectProjects=react --selectProjects=nest --silent",
|
||||
"test:angular": "npm run test:coverage --workspace=packages/angular",
|
||||
"e2e-server": "git submodule update --init --recursive && shx cp test-harness/features/evaluation.feature packages/server/e2e/features && jest --selectProjects=server-e2e --verbose",
|
||||
"e2e-web": "git submodule update --init --recursive && shx cp test-harness/features/evaluation.feature packages/web/e2e/features && jest --selectProjects=web-e2e --verbose",
|
||||
"e2e": "npm run e2e-server && npm run e2e-web",
|
||||
|
@ -54,14 +59,12 @@
|
|||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-environment-node": "^29.7.0",
|
||||
"jest-junit": "^16.0.0",
|
||||
"jest-preset-angular": "^14.2.4",
|
||||
"ng-packagr": "^18.2.1",
|
||||
"prettier": "^3.2.5",
|
||||
"react": "^18.2.0",
|
||||
"rollup": "^4.0.0",
|
||||
"rollup-plugin-dts": "^6.1.1",
|
||||
"rxjs": "~7.8.0",
|
||||
"shx": "^0.3.4",
|
||||
"shx": "^0.4.0",
|
||||
"ts-jest": "^29.1.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tslib": "^2.3.0",
|
||||
|
@ -69,9 +72,6 @@
|
|||
"typescript": "^4.7.4",
|
||||
"uuid": "^11.0.0"
|
||||
},
|
||||
"overrides": {
|
||||
"typescript": "^4.7.4"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/shared",
|
||||
"packages/server",
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
"prefix": "lib",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:ng-packagr",
|
||||
"builder": "@angular/build:ng-packagr",
|
||||
"options": {
|
||||
"project": "projects/angular-sdk/ng-package.json"
|
||||
},
|
||||
|
@ -32,6 +32,15 @@
|
|||
"projects/angular-sdk/**/*.html"
|
||||
]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular/build:unit-test",
|
||||
"options": {
|
||||
"tsConfig": "projects/angular-sdk/tsconfig.spec.json",
|
||||
"providersFile": "projects/angular-sdk/src/test-provider.ts",
|
||||
"runner": "vitest",
|
||||
"buildTarget": "::development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,37 +7,42 @@
|
|||
"lint": "ng lint",
|
||||
"lint:fix": "ng lint --fix",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "jest",
|
||||
"test": "ng test",
|
||||
"test:coverage": "ng test --no-watch --code-coverage",
|
||||
"build": "ng build && npm run postbuild",
|
||||
"postbuild": "shx cp ./../../LICENSE ./dist/angular/LICENSE",
|
||||
"publish-if-not-exists": "cp $NPM_CONFIG_USERCONFIG .npmrc && if [ \"$(npm --prefix dist/angular run current-published-version -s)\" = \"$(npm --prefix dist/angular run current-version -s)\" ]; then echo 'already published, skipping'; else cd dist/angular && npm publish --access public; fi"
|
||||
},
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^19.0.0",
|
||||
"@angular-eslint/builder": "19.3.0",
|
||||
"@angular-eslint/eslint-plugin": "19.3.0",
|
||||
"@angular-eslint/eslint-plugin-template": "19.3.0",
|
||||
"@angular-eslint/schematics": "19.3.0",
|
||||
"@angular-eslint/template-parser": "19.3.0",
|
||||
"@angular/animations": "^19.0.0",
|
||||
"@angular/cli": "^19.0.0",
|
||||
"@angular/common": "^19.0.0",
|
||||
"@angular/compiler": "^19.0.0",
|
||||
"@angular/compiler-cli": "^19.0.0",
|
||||
"@angular/core": "^19.0.0",
|
||||
"@angular/forms": "^19.0.0",
|
||||
"@angular/platform-browser": "^19.0.0",
|
||||
"@angular/platform-browser-dynamic": "^19.0.0",
|
||||
"@angular/router": "^19.0.0",
|
||||
"@angular-eslint/builder": "^20.1.1",
|
||||
"@angular-eslint/eslint-plugin": "^20.1.1",
|
||||
"@angular-eslint/eslint-plugin-template": "^20.1.1",
|
||||
"@angular-eslint/schematics": "^20.1.1",
|
||||
"@angular-eslint/template-parser": "^20.1.1",
|
||||
"@angular/animations": "^20.1.1",
|
||||
"@angular/build": "^20.1.1",
|
||||
"@angular/cli": "^20.1.1",
|
||||
"@angular/common": "^20.1.1",
|
||||
"@angular/compiler": "^20.1.1",
|
||||
"@angular/compiler-cli": "^20.1.1",
|
||||
"@angular/core": "^20.1.1",
|
||||
"@angular/forms": "^20.1.1",
|
||||
"@angular/platform-browser": "^20.1.1",
|
||||
"@angular/platform-browser-dynamic": "^20.1.1",
|
||||
"@angular/router": "^20.1.1",
|
||||
"@typescript-eslint/eslint-plugin": "7.18.0",
|
||||
"@typescript-eslint/parser": "7.18.0",
|
||||
"@vitest/browser": "^3.2.4",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"eslint": "^8.57.0",
|
||||
"jest-preset-angular": "^14.2.4",
|
||||
"ng-packagr": "^19.0.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"ng-packagr": "^20.1.0",
|
||||
"playwright": "^1.53.2",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"typescript": "^5.5.4",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^3.2.4",
|
||||
"zone.js": "~0.15.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
# This file is used by the build system to adjust CSS and JS output to support the specified browsers below.
|
||||
# For additional information regarding the format and rule options, please see:
|
||||
# https://github.com/browserslist/browserslist#queries
|
||||
|
||||
# For Angular's browser support policy, please see:
|
||||
# https://angular.dev/reference/versions#browser-support
|
||||
|
||||
# You can see what browsers were selected by your queries by running:
|
||||
# npx browserslist
|
||||
|
||||
Chrome >= 107
|
||||
ChromeAndroid >= 107
|
||||
Edge >= 107
|
||||
Firefox >= 104
|
||||
FirefoxAndroid >= 104
|
||||
Safari >= 16
|
||||
iOS >= 16
|
||||
|
|
@ -1,6 +1,14 @@
|
|||
# Changelog
|
||||
|
||||
|
||||
## [0.0.16](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.15...angular-sdk-v0.0.16) (2025-07-25)
|
||||
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
* support Angular 20 ([#1220](https://github.com/open-feature/js-sdk/issues/1220)) ([aa232a9](https://github.com/open-feature/js-sdk/commit/aa232a9d6a8dfa416380ccdecd71843d3e361048))
|
||||
|
||||
|
||||
## [0.0.15](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.14...angular-sdk-v0.0.15) (2025-05-27)
|
||||
|
||||
|
||||
|
|
|
@ -16,8 +16,8 @@
|
|||
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
|
||||
</a>
|
||||
<!-- x-release-please-start-version -->
|
||||
<a href="https://github.com/open-feature/js-sdk/releases/tag/angular-sdk-v0.0.15">
|
||||
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.0.15&color=blue&style=for-the-badge" />
|
||||
<a href="https://github.com/open-feature/js-sdk/releases/tag/angular-sdk-v0.0.16">
|
||||
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.0.16&color=blue&style=for-the-badge" />
|
||||
</a>
|
||||
<!-- x-release-please-end -->
|
||||
<br/>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openfeature/angular-sdk",
|
||||
"version": "0.0.15",
|
||||
"version": "0.0.16",
|
||||
"description": "OpenFeature Angular SDK",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
@ -17,18 +17,18 @@
|
|||
"prepack": "shx cp ./../../../../LICENSE ./LICENSE"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "^16.2.12 || ^17.3.0 || ^18.0.0 || ^19.0.0",
|
||||
"@angular/core": "^16.2.12 || ^17.3.0 || ^18.0.0 || ^19.0.0",
|
||||
"@angular/common": "^16.2.12 || ^17.3.0 || ^18.0.0 || ^19.0.0 || ^20.0.0",
|
||||
"@angular/core": "^16.2.12 || ^17.3.0 || ^18.0.0 || ^19.0.0 || ^20.0.0",
|
||||
"@openfeature/web-sdk": "^1.4.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openfeature/core": "*",
|
||||
"@openfeature/web-sdk": "*",
|
||||
"@angular/common": "^19.0.0",
|
||||
"@angular/core": "^19.0.0"
|
||||
"@openfeature/core": "^1.8.1",
|
||||
"@openfeature/web-sdk": "^1.5.0",
|
||||
"@angular/common": "^20.1.2",
|
||||
"@angular/core": "^20.1.2"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"keywords": [
|
||||
|
|
|
@ -253,6 +253,7 @@ describe('FeatureFlagDirective', () => {
|
|||
await expectRenderedText(fixture, 'case-2', 'Flag On');
|
||||
|
||||
await updateFlagValue(provider, false);
|
||||
fixture.detectChanges(); // Ensure change detection after flag update
|
||||
await expectRenderedText(fixture, 'case-2', 'Flag Off');
|
||||
});
|
||||
});
|
||||
|
@ -393,7 +394,9 @@ describe('FeatureFlagDirective', () => {
|
|||
await waitForClientReady(client);
|
||||
await expectRenderedText(fixture, 'case-6', 'Flag On');
|
||||
|
||||
fixture.componentInstance.specialFlagKey = 'new-test-flag';
|
||||
fixture.componentRef.setInput('specialFlagKey', 'new-test-flag');
|
||||
await fixture.whenStable();
|
||||
|
||||
await expectRenderedText(fixture, 'case-6', 'Flag Off');
|
||||
});
|
||||
|
||||
|
@ -445,7 +448,9 @@ describe('FeatureFlagDirective', () => {
|
|||
);
|
||||
await OpenFeature.setProviderAndWait(newDomain, newProvider);
|
||||
|
||||
fixture.componentInstance.domain = newDomain;
|
||||
fixture.componentRef.setInput('domain', newDomain);
|
||||
await fixture.whenStable();
|
||||
|
||||
await expectRenderedText(fixture, 'case-6', 'Flag Off');
|
||||
});
|
||||
});
|
||||
|
@ -560,8 +565,7 @@ async function createTestingModule(config?: {
|
|||
],
|
||||
}).createComponent(TestComponent);
|
||||
|
||||
fixture.componentInstance.domain = domain;
|
||||
fixture.detectChanges();
|
||||
fixture.componentRef.setInput('domain', domain);
|
||||
await fixture.whenStable();
|
||||
|
||||
const client = OpenFeature.getClient(domain);
|
||||
|
|
|
@ -8,6 +8,7 @@ import {
|
|||
OnInit,
|
||||
TemplateRef,
|
||||
ViewContainerRef,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
Client,
|
||||
|
@ -35,6 +36,9 @@ class FeatureFlagDirectiveContext<T extends FlagValue> {
|
|||
selector: '[featureFlag]',
|
||||
})
|
||||
export abstract class FeatureFlagDirective<T extends FlagValue> implements OnInit, OnDestroy, OnChanges {
|
||||
protected _changeDetectorRef: ChangeDetectorRef;
|
||||
protected _viewContainerRef: ViewContainerRef;
|
||||
|
||||
protected _featureFlagDefault: T;
|
||||
protected _featureFlagDomain: string | undefined;
|
||||
|
||||
|
@ -64,13 +68,7 @@ export abstract class FeatureFlagDirective<T extends FlagValue> implements OnIni
|
|||
protected _reconcilingTemplateRef: TemplateRef<FeatureFlagDirectiveContext<T>> | null;
|
||||
protected _reconcilingViewRef: EmbeddedViewRef<unknown> | null;
|
||||
|
||||
protected constructor(
|
||||
protected _changeDetectorRef: ChangeDetectorRef,
|
||||
protected _viewContainerRef: ViewContainerRef,
|
||||
templateRef: TemplateRef<FeatureFlagDirectiveContext<T>>,
|
||||
) {
|
||||
this._thenTemplateRef = templateRef;
|
||||
}
|
||||
protected constructor() {}
|
||||
|
||||
set featureFlagDomain(domain: string | undefined) {
|
||||
/**
|
||||
|
@ -232,6 +230,10 @@ export abstract class FeatureFlagDirective<T extends FlagValue> implements OnIni
|
|||
selector: '[booleanFeatureFlag]',
|
||||
})
|
||||
export class BooleanFeatureFlagDirective extends FeatureFlagDirective<boolean> implements OnChanges {
|
||||
override _changeDetectorRef = inject(ChangeDetectorRef);
|
||||
override _viewContainerRef = inject(ViewContainerRef);
|
||||
override _thenTemplateRef = inject<TemplateRef<FeatureFlagDirectiveContext<boolean>>>(TemplateRef);
|
||||
|
||||
/**
|
||||
* The key of the boolean feature flag.
|
||||
*/
|
||||
|
@ -242,12 +244,8 @@ export class BooleanFeatureFlagDirective extends FeatureFlagDirective<boolean> i
|
|||
*/
|
||||
@Input({ required: true }) booleanFeatureFlagDefault: boolean;
|
||||
|
||||
constructor(
|
||||
_changeDetectorRef: ChangeDetectorRef,
|
||||
_viewContainerRef: ViewContainerRef,
|
||||
templateRef: TemplateRef<FeatureFlagDirectiveContext<boolean>>,
|
||||
) {
|
||||
super(_changeDetectorRef, _viewContainerRef, templateRef);
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
override ngOnChanges() {
|
||||
|
@ -347,6 +345,10 @@ export class BooleanFeatureFlagDirective extends FeatureFlagDirective<boolean> i
|
|||
selector: '[numberFeatureFlag]',
|
||||
})
|
||||
export class NumberFeatureFlagDirective extends FeatureFlagDirective<number> implements OnChanges {
|
||||
override _changeDetectorRef = inject(ChangeDetectorRef);
|
||||
override _viewContainerRef = inject(ViewContainerRef);
|
||||
override _thenTemplateRef = inject<TemplateRef<FeatureFlagDirectiveContext<number>>>(TemplateRef);
|
||||
|
||||
/**
|
||||
* The key of the number feature flag.
|
||||
*/
|
||||
|
@ -362,12 +364,8 @@ export class NumberFeatureFlagDirective extends FeatureFlagDirective<number> imp
|
|||
*/
|
||||
@Input({ required: false }) numberFeatureFlagValue?: number;
|
||||
|
||||
constructor(
|
||||
_changeDetectorRef: ChangeDetectorRef,
|
||||
_viewContainerRef: ViewContainerRef,
|
||||
templateRef: TemplateRef<FeatureFlagDirectiveContext<number>>,
|
||||
) {
|
||||
super(_changeDetectorRef, _viewContainerRef, templateRef);
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
override ngOnChanges() {
|
||||
|
@ -467,6 +465,10 @@ export class NumberFeatureFlagDirective extends FeatureFlagDirective<number> imp
|
|||
selector: '[stringFeatureFlag]',
|
||||
})
|
||||
export class StringFeatureFlagDirective extends FeatureFlagDirective<string> implements OnChanges {
|
||||
override _changeDetectorRef = inject(ChangeDetectorRef);
|
||||
override _viewContainerRef = inject(ViewContainerRef);
|
||||
override _thenTemplateRef = inject<TemplateRef<FeatureFlagDirectiveContext<string>>>(TemplateRef);
|
||||
|
||||
/**
|
||||
* The key of the string feature flag.
|
||||
*/
|
||||
|
@ -482,12 +484,8 @@ export class StringFeatureFlagDirective extends FeatureFlagDirective<string> imp
|
|||
*/
|
||||
@Input({ required: false }) stringFeatureFlagValue?: string;
|
||||
|
||||
constructor(
|
||||
_changeDetectorRef: ChangeDetectorRef,
|
||||
_viewContainerRef: ViewContainerRef,
|
||||
templateRef: TemplateRef<FeatureFlagDirectiveContext<string>>,
|
||||
) {
|
||||
super(_changeDetectorRef, _viewContainerRef, templateRef);
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
override ngOnChanges() {
|
||||
|
@ -587,6 +585,10 @@ export class StringFeatureFlagDirective extends FeatureFlagDirective<string> imp
|
|||
selector: '[objectFeatureFlag]',
|
||||
})
|
||||
export class ObjectFeatureFlagDirective<T extends JsonValue> extends FeatureFlagDirective<T> implements OnChanges {
|
||||
override _changeDetectorRef = inject(ChangeDetectorRef);
|
||||
override _viewContainerRef = inject(ViewContainerRef);
|
||||
override _thenTemplateRef = inject<TemplateRef<FeatureFlagDirectiveContext<T>>>(TemplateRef);
|
||||
|
||||
/**
|
||||
* The key of the object feature flag.
|
||||
*/
|
||||
|
@ -602,12 +604,8 @@ export class ObjectFeatureFlagDirective<T extends JsonValue> extends FeatureFlag
|
|||
*/
|
||||
@Input({ required: false }) objectFeatureFlagValue?: T;
|
||||
|
||||
constructor(
|
||||
_changeDetectorRef: ChangeDetectorRef,
|
||||
_viewContainerRef: ViewContainerRef,
|
||||
templateRef: TemplateRef<FeatureFlagDirectiveContext<T>>,
|
||||
) {
|
||||
super(_changeDetectorRef, _viewContainerRef, templateRef);
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
override ngOnChanges() {
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
import { provideZonelessChangeDetection } from '@angular/core';
|
||||
|
||||
export default [provideZonelessChangeDetection()];
|
|
@ -3,15 +3,20 @@
|
|||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/spec",
|
||||
"types": [
|
||||
"jest",
|
||||
"vitest/globals",
|
||||
"node"
|
||||
],
|
||||
"paths": {
|
||||
"angular": [
|
||||
"./dist/angular"
|
||||
]
|
||||
},
|
||||
"esModuleInterop": true,
|
||||
"emitDecoratorMetadata": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts",
|
||||
"setup-jest.ts"
|
||||
"src/test-provider.ts"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
import 'jest-preset-angular/setup-jest';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing';
|
||||
|
||||
TestBed.initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting());
|
|
@ -23,8 +23,8 @@
|
|||
"experimentalDecorators": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"target": "ES2015",
|
||||
"module": "ES2015",
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"useDefineForClassFields": false,
|
||||
"strictNullChecks": false,
|
||||
"lib": [
|
||||
|
|
|
@ -3,13 +3,14 @@
|
|||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/spec",
|
||||
"types": [
|
||||
"jest"
|
||||
"vitest/globals",
|
||||
"node"
|
||||
],
|
||||
"esModuleInterop": true,
|
||||
"emitDecoratorMetadata": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
"projects/angular-sdk/src/**/*.spec.ts",
|
||||
"projects/angular-sdk/src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
},
|
||||
},
|
||||
});
|
|
@ -1,5 +1,12 @@
|
|||
# Changelog
|
||||
|
||||
## [1.0.1](https://github.com/open-feature/js-sdk/compare/react-sdk-v1.0.0...react-sdk-v1.0.1) (2025-08-18)
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
* **react:** re-evaluate flags on re-render to detect silent provider … ([#1226](https://github.com/open-feature/js-sdk/issues/1226)) ([3105595](https://github.com/open-feature/js-sdk/commit/31055959265a53f52102590f54fa3168811ec678))
|
||||
|
||||
## [1.0.0](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.4.11...react-sdk-v1.0.0) (2025-04-14)
|
||||
|
||||
|
||||
|
|
|
@ -16,8 +16,8 @@
|
|||
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
|
||||
</a>
|
||||
<!-- x-release-please-start-version -->
|
||||
<a href="https://github.com/open-feature/js-sdk/releases/tag/react-sdk-v1.0.0">
|
||||
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v1.0.0&color=blue&style=for-the-badge" />
|
||||
<a href="https://github.com/open-feature/js-sdk/releases/tag/react-sdk-v1.0.1">
|
||||
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v1.0.1&color=blue&style=for-the-badge" />
|
||||
</a>
|
||||
<!-- x-release-please-end -->
|
||||
<br/>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openfeature/react-sdk",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"description": "OpenFeature React SDK",
|
||||
"main": "./dist/cjs/index.js",
|
||||
"files": [
|
||||
|
|
|
@ -8,7 +8,7 @@ import type {
|
|||
JsonValue,
|
||||
} from '@openfeature/web-sdk';
|
||||
import { ProviderEvents, ProviderStatus } from '@openfeature/web-sdk';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
DEFAULT_OPTIONS,
|
||||
isEqual,
|
||||
|
@ -287,8 +287,7 @@ function attachHandlersAndResolve<T extends FlagValue>(
|
|||
const client = useOpenFeatureClient();
|
||||
const status = useOpenFeatureClientStatus();
|
||||
const provider = useOpenFeatureProvider();
|
||||
|
||||
const controller = new AbortController();
|
||||
const isFirstRender = useRef(true);
|
||||
|
||||
if (defaultedOptions.suspendUntilReady && status === ProviderStatus.NOT_READY) {
|
||||
suspendUntilInitialized(provider, client);
|
||||
|
@ -298,9 +297,22 @@ function attachHandlersAndResolve<T extends FlagValue>(
|
|||
suspendUntilReconciled(client);
|
||||
}
|
||||
|
||||
const [evaluationDetails, setEvaluationDetails] = useState<EvaluationDetails<T>>(
|
||||
const [evaluationDetails, setEvaluationDetails] = useState<EvaluationDetails<T>>(() =>
|
||||
resolver(client).call(client, flagKey, defaultValue, options),
|
||||
);
|
||||
|
||||
// Re-evaluate when dependencies change (handles prop changes like flagKey), or if during a re-render, we have detected a change in the evaluated value
|
||||
useEffect(() => {
|
||||
if (isFirstRender.current) {
|
||||
isFirstRender.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const newDetails = resolver(client).call(client, flagKey, defaultValue, options);
|
||||
if (!isEqual(newDetails.value, evaluationDetails.value)) {
|
||||
setEvaluationDetails(newDetails);
|
||||
}
|
||||
}, [client, flagKey, defaultValue, options, resolver, evaluationDetails]);
|
||||
|
||||
// Maintain a mutable reference to the evaluation details to have a up-to-date reference in the handlers.
|
||||
const evaluationDetailsRef = useRef<EvaluationDetails<T>>(evaluationDetails);
|
||||
|
@ -308,7 +320,7 @@ function attachHandlersAndResolve<T extends FlagValue>(
|
|||
evaluationDetailsRef.current = evaluationDetails;
|
||||
}, [evaluationDetails]);
|
||||
|
||||
const updateEvaluationDetailsCallback = () => {
|
||||
const updateEvaluationDetailsCallback = useCallback(() => {
|
||||
const updatedEvaluationDetails = resolver(client).call(client, flagKey, defaultValue, options);
|
||||
|
||||
/**
|
||||
|
@ -319,15 +331,19 @@ function attachHandlersAndResolve<T extends FlagValue>(
|
|||
if (!isEqual(updatedEvaluationDetails.value, evaluationDetailsRef.current.value)) {
|
||||
setEvaluationDetails(updatedEvaluationDetails);
|
||||
}
|
||||
};
|
||||
}, [client, flagKey, defaultValue, options, resolver]);
|
||||
|
||||
const configurationChangeCallback: EventHandler<ClientProviderEvents.ConfigurationChanged> = (eventDetails) => {
|
||||
if (shouldEvaluateFlag(flagKey, eventDetails?.flagsChanged)) {
|
||||
updateEvaluationDetailsCallback();
|
||||
}
|
||||
};
|
||||
const configurationChangeCallback = useCallback<EventHandler<ClientProviderEvents.ConfigurationChanged>>(
|
||||
(eventDetails) => {
|
||||
if (shouldEvaluateFlag(flagKey, eventDetails?.flagsChanged)) {
|
||||
updateEvaluationDetailsCallback();
|
||||
}
|
||||
},
|
||||
[flagKey, updateEvaluationDetailsCallback],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
if (status === ProviderStatus.NOT_READY) {
|
||||
// update when the provider is ready
|
||||
client.addHandler(ProviderEvents.Ready, updateEvaluationDetailsCallback, { signal: controller.signal });
|
||||
|
@ -348,7 +364,14 @@ function attachHandlersAndResolve<T extends FlagValue>(
|
|||
// cleanup the handlers
|
||||
controller.abort();
|
||||
};
|
||||
}, []);
|
||||
}, [
|
||||
client,
|
||||
status,
|
||||
defaultedOptions.updateOnContextChanged,
|
||||
defaultedOptions.updateOnConfigurationChanged,
|
||||
updateEvaluationDetailsCallback,
|
||||
configurationChangeCallback,
|
||||
]);
|
||||
|
||||
return evaluationDetails;
|
||||
}
|
||||
|
|
|
@ -924,17 +924,124 @@ describe('evaluation', () => {
|
|||
OpenFeature.setContext(SUSPEND_OFF, { user: TARGETED_USER });
|
||||
});
|
||||
|
||||
// expect to see static value until we reconcile
|
||||
await waitFor(() => expect(screen.queryByText(STATIC_FLAG_VALUE_A)).toBeInTheDocument(), {
|
||||
timeout: DELAY / 2,
|
||||
});
|
||||
|
||||
// make sure we updated after reconciling
|
||||
// With the fix for useState initialization, the hook now immediately
|
||||
// reflects provider state changes. This is intentional to handle cases
|
||||
// where providers don't emit proper events.
|
||||
// The value updates immediately to the targeted value.
|
||||
await waitFor(() => expect(screen.queryByText(TARGETED_FLAG_VALUE)).toBeInTheDocument(), {
|
||||
timeout: DELAY * 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('re-render behavior when flag values change without provider events', ()=> {
|
||||
it('should reflect provider state changes on re-render even without provider events', async () => {
|
||||
let providerValue = 'initial-value';
|
||||
|
||||
class SilentUpdateProvider extends InMemoryProvider {
|
||||
resolveBooleanEvaluation() {
|
||||
return {
|
||||
value: true,
|
||||
variant: 'on',
|
||||
reason: StandardResolutionReasons.STATIC,
|
||||
};
|
||||
}
|
||||
|
||||
resolveStringEvaluation() {
|
||||
return {
|
||||
value: providerValue,
|
||||
variant: providerValue,
|
||||
reason: StandardResolutionReasons.STATIC,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const provider = new SilentUpdateProvider({});
|
||||
await OpenFeature.setProviderAndWait('test', provider);
|
||||
|
||||
// The triggerRender prop forces a re-render
|
||||
const TestComponent = ({ triggerRender }: { triggerRender: number }) => {
|
||||
const { value } = useFlag('test-flag', 'default');
|
||||
return <div data-testid="flag-value" data-render-count={triggerRender}>{value}</div>;
|
||||
};
|
||||
|
||||
const WrapperComponent = () => {
|
||||
const [renderCount, setRenderCount] = useState(0);
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setRenderCount(c => c + 1)}>Force Re-render</button>
|
||||
<TestComponent triggerRender={renderCount} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const { getByText } = render(
|
||||
<OpenFeatureProvider client={OpenFeature.getClient('test')}>
|
||||
<WrapperComponent />
|
||||
</OpenFeatureProvider>
|
||||
);
|
||||
|
||||
// Initial value should be rendered
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('flag-value')).toHaveTextContent('initial-value');
|
||||
});
|
||||
|
||||
// Change the provider's internal state (without emitting events)
|
||||
providerValue = 'updated-value';
|
||||
|
||||
// Force a re-render of the component
|
||||
act(() => {
|
||||
getByText('Force Re-render').click();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('flag-value')).toHaveTextContent('updated-value');
|
||||
});
|
||||
});
|
||||
|
||||
it('should update flag value when flag key prop changes without provider events', async () => {
|
||||
const provider = new InMemoryProvider({
|
||||
'flag-a': {
|
||||
disabled: false,
|
||||
variants: { on: 'value-a' },
|
||||
defaultVariant: 'on',
|
||||
},
|
||||
'flag-b': {
|
||||
disabled: false,
|
||||
variants: { on: 'value-b' },
|
||||
defaultVariant: 'on',
|
||||
},
|
||||
});
|
||||
|
||||
await OpenFeature.setProviderAndWait(EVALUATION, provider);
|
||||
|
||||
const TestComponent = ({ flagKey }: { flagKey: string }) => {
|
||||
const { value } = useFlag(flagKey, 'default');
|
||||
return <div data-testid="flag-value">{value}</div>;
|
||||
};
|
||||
|
||||
const { rerender } = render(
|
||||
<OpenFeatureProvider client={OpenFeature.getClient(EVALUATION)}>
|
||||
<TestComponent flagKey="flag-a" />
|
||||
</OpenFeatureProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('flag-value')).toHaveTextContent('value-a');
|
||||
});
|
||||
|
||||
// Change to flag-b (without any provider events)
|
||||
rerender(
|
||||
<OpenFeatureProvider client={OpenFeature.getClient(EVALUATION)}>
|
||||
<TestComponent flagKey="flag-b" />
|
||||
</OpenFeatureProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('flag-value')).toHaveTextContent('value-b');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('context, hooks and options', () => {
|
||||
|
|
|
@ -288,7 +288,7 @@ describe('OpenFeatureProvider', () => {
|
|||
{ timeout: DELAY * 4 },
|
||||
);
|
||||
|
||||
expect(screen.getByText('Will says hi')).toBeInTheDocument();
|
||||
expect(screen.getByText('Will says aloha')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,5 +1,27 @@
|
|||
# Changelog
|
||||
|
||||
## [1.19.0](https://github.com/open-feature/js-sdk/compare/server-sdk-v1.18.0...server-sdk-v1.19.0) (2025-08-14)
|
||||
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
* add evaluation-scoped hook data ([#1216](https://github.com/open-feature/js-sdk/issues/1216)) ([07af3a9](https://github.com/open-feature/js-sdk/commit/07af3a9eda895e9edb24c7ee1e3c1c4f16e17431))
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
* update core dep ([#1228](https://github.com/open-feature/js-sdk/issues/1228)) ([845d24c](https://github.com/open-feature/js-sdk/commit/845d24c5fecc80de3080e49fde839f08ecac6b33))
|
||||
|
||||
|
||||
### 🧹 Chore
|
||||
|
||||
* update node to v20+ ([#1203](https://github.com/open-feature/js-sdk/issues/1203)) ([1f33453](https://github.com/open-feature/js-sdk/commit/1f33453c23df0763cbf0d0b44db8d91216377009))
|
||||
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
* Clarify the behavior of setProviderAndWait ([#1180](https://github.com/open-feature/js-sdk/issues/1180)) ([4fe8d87](https://github.com/open-feature/js-sdk/commit/4fe8d87a2e5df2cbd4086cc4f4a380e8857ed8ba))
|
||||
|
||||
## [1.18.0](https://github.com/open-feature/js-sdk/compare/server-sdk-v1.17.1...server-sdk-v1.18.0) (2025-04-11)
|
||||
|
||||
|
||||
|
|
|
@ -16,8 +16,8 @@
|
|||
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
|
||||
</a>
|
||||
<!-- x-release-please-start-version -->
|
||||
<a href="https://github.com/open-feature/js-sdk/releases/tag/server-sdk-v1.18.0">
|
||||
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v1.18.0&color=blue&style=for-the-badge" />
|
||||
<a href="https://github.com/open-feature/js-sdk/releases/tag/server-sdk-v1.19.0">
|
||||
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v1.19.0&color=blue&style=for-the-badge" />
|
||||
</a>
|
||||
<!-- x-release-please-end -->
|
||||
<br/>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openfeature/server-sdk",
|
||||
"version": "1.18.0",
|
||||
"version": "1.19.0",
|
||||
"description": "OpenFeature SDK for JavaScript",
|
||||
"main": "./dist/cjs/index.js",
|
||||
"files": [
|
||||
|
@ -48,9 +48,9 @@
|
|||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@openfeature/core": "^1.7.0"
|
||||
"@openfeature/core": "^1.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openfeature/core": "^1.7.0"
|
||||
"@openfeature/core": "^1.9.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,6 +22,7 @@ import {
|
|||
StandardResolutionReasons,
|
||||
instantiateErrorByErrorCode,
|
||||
statusMatchesEvent,
|
||||
MapHookData,
|
||||
} from '@openfeature/core';
|
||||
import type { FlagEvaluationOptions } from '../../evaluation';
|
||||
import type { ProviderEvents } from '../../events';
|
||||
|
@ -276,22 +277,26 @@ export class OpenFeatureClient implements Client {
|
|||
|
||||
const mergedContext = this.mergeContexts(invocationContext);
|
||||
|
||||
// this reference cannot change during the course of evaluation
|
||||
// it may be used as a key in WeakMaps
|
||||
const hookContext: Readonly<HookContext> = {
|
||||
flagKey,
|
||||
defaultValue,
|
||||
flagValueType: flagType,
|
||||
clientMetadata: this.metadata,
|
||||
providerMetadata: this._provider.metadata,
|
||||
context: mergedContext,
|
||||
logger: this._logger,
|
||||
};
|
||||
// Create hook context instances for each hook (stable object references for the entire evaluation)
|
||||
// This ensures hooks can use WeakMaps with hookContext as keys across lifecycle methods
|
||||
// NOTE: Uses the reversed order to reduce the number of times we have to calculate the index.
|
||||
const hookContexts = allHooksReversed.map<HookContext>(() =>
|
||||
Object.freeze({
|
||||
flagKey,
|
||||
defaultValue,
|
||||
flagValueType: flagType,
|
||||
clientMetadata: this.metadata,
|
||||
providerMetadata: this._provider.metadata,
|
||||
context: mergedContext,
|
||||
logger: this._logger,
|
||||
hookData: new MapHookData(),
|
||||
}),
|
||||
);
|
||||
|
||||
let evaluationDetails: EvaluationDetails<T>;
|
||||
|
||||
try {
|
||||
const frozenContext = await this.beforeHooks(allHooks, hookContext, options);
|
||||
const frozenContext = await this.beforeHooks(allHooks, hookContexts, mergedContext, options);
|
||||
|
||||
this.shortCircuitIfNotReady();
|
||||
|
||||
|
@ -306,53 +311,71 @@ export class OpenFeatureClient implements Client {
|
|||
|
||||
if (resolutionDetails.errorCode) {
|
||||
const err = instantiateErrorByErrorCode(resolutionDetails.errorCode, resolutionDetails.errorMessage);
|
||||
await this.errorHooks(allHooksReversed, hookContext, err, options);
|
||||
await this.errorHooks(allHooksReversed, hookContexts, err, options);
|
||||
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err, resolutionDetails.flagMetadata);
|
||||
} else {
|
||||
await this.afterHooks(allHooksReversed, hookContext, resolutionDetails, options);
|
||||
await this.afterHooks(allHooksReversed, hookContexts, resolutionDetails, options);
|
||||
evaluationDetails = resolutionDetails;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
await this.errorHooks(allHooksReversed, hookContext, err, options);
|
||||
await this.errorHooks(allHooksReversed, hookContexts, err, options);
|
||||
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err);
|
||||
}
|
||||
|
||||
await this.finallyHooks(allHooksReversed, hookContext, evaluationDetails, options);
|
||||
await this.finallyHooks(allHooksReversed, hookContexts, evaluationDetails, options);
|
||||
return evaluationDetails;
|
||||
}
|
||||
|
||||
private async beforeHooks(hooks: Hook[], hookContext: HookContext, options: FlagEvaluationOptions) {
|
||||
for (const hook of hooks) {
|
||||
// freeze the hookContext
|
||||
Object.freeze(hookContext);
|
||||
private async beforeHooks(
|
||||
hooks: Hook[],
|
||||
hookContexts: HookContext[],
|
||||
mergedContext: EvaluationContext,
|
||||
options: FlagEvaluationOptions,
|
||||
) {
|
||||
let accumulatedContext = mergedContext;
|
||||
|
||||
// use Object.assign to avoid modification of frozen hookContext
|
||||
Object.assign(hookContext.context, {
|
||||
...hookContext.context,
|
||||
...(await hook?.before?.(hookContext, Object.freeze(options.hookHints))),
|
||||
});
|
||||
for (const [index, hook] of hooks.entries()) {
|
||||
const hookContextIndex = hooks.length - 1 - index; // reverse index for before hooks
|
||||
const hookContext = hookContexts[hookContextIndex];
|
||||
|
||||
// Update the context on the stable hook context object
|
||||
Object.assign(hookContext.context, accumulatedContext);
|
||||
|
||||
const hookResult = await hook?.before?.(hookContext, Object.freeze(options.hookHints));
|
||||
if (hookResult) {
|
||||
accumulatedContext = {
|
||||
...accumulatedContext,
|
||||
...hookResult,
|
||||
};
|
||||
|
||||
for (let i = 0; i < hooks.length; i++) {
|
||||
Object.assign(hookContexts[hookContextIndex].context, accumulatedContext);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// after before hooks, freeze the EvaluationContext.
|
||||
return Object.freeze(hookContext.context);
|
||||
return Object.freeze(accumulatedContext);
|
||||
}
|
||||
|
||||
private async afterHooks(
|
||||
hooks: Hook[],
|
||||
hookContext: HookContext,
|
||||
hookContexts: HookContext[],
|
||||
evaluationDetails: EvaluationDetails<FlagValue>,
|
||||
options: FlagEvaluationOptions,
|
||||
) {
|
||||
// run "after" hooks sequentially
|
||||
for (const hook of hooks) {
|
||||
for (const [index, hook] of hooks.entries()) {
|
||||
const hookContext = hookContexts[index];
|
||||
await hook?.after?.(hookContext, evaluationDetails, options.hookHints);
|
||||
}
|
||||
}
|
||||
|
||||
private async errorHooks(hooks: Hook[], hookContext: HookContext, err: unknown, options: FlagEvaluationOptions) {
|
||||
private async errorHooks(hooks: Hook[], hookContexts: HookContext[], err: unknown, options: FlagEvaluationOptions) {
|
||||
// run "error" hooks sequentially
|
||||
for (const hook of hooks) {
|
||||
for (const [index, hook] of hooks.entries()) {
|
||||
try {
|
||||
const hookContext = hookContexts[index];
|
||||
await hook?.error?.(hookContext, err, options.hookHints);
|
||||
} catch (err) {
|
||||
this._logger.error(`Unhandled error during 'error' hook: ${err}`);
|
||||
|
@ -366,13 +389,14 @@ export class OpenFeatureClient implements Client {
|
|||
|
||||
private async finallyHooks(
|
||||
hooks: Hook[],
|
||||
hookContext: HookContext,
|
||||
hookContexts: HookContext[],
|
||||
evaluationDetails: EvaluationDetails<FlagValue>,
|
||||
options: FlagEvaluationOptions,
|
||||
) {
|
||||
// run "finally" hooks sequentially
|
||||
for (const hook of hooks) {
|
||||
for (const [index, hook] of hooks.entries()) {
|
||||
try {
|
||||
const hookContext = hookContexts[index];
|
||||
await hook?.finally?.(hookContext, evaluationDetails, options.hookHints);
|
||||
} catch (err) {
|
||||
this._logger.error(`Unhandled error during 'finally' hook: ${err}`);
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import type { BaseHook, EvaluationContext, FlagValue } from '@openfeature/core';
|
||||
|
||||
export type Hook = BaseHook<
|
||||
export type Hook<TData = Record<string, unknown>> = BaseHook<
|
||||
FlagValue,
|
||||
TData,
|
||||
Promise<EvaluationContext | void> | EvaluationContext | void,
|
||||
Promise<void> | void
|
||||
>;
|
||||
|
|
|
@ -0,0 +1,508 @@
|
|||
import { OpenFeature } from '../src';
|
||||
import type { Client } from '../src/client';
|
||||
import type {
|
||||
JsonValue,
|
||||
ResolutionDetails,
|
||||
HookContext,
|
||||
BeforeHookContext,
|
||||
HookData} from '@openfeature/core';
|
||||
import {
|
||||
StandardResolutionReasons
|
||||
} from '@openfeature/core';
|
||||
import type { Provider } from '../src/provider';
|
||||
import type { Hook } from '../src/hooks';
|
||||
|
||||
const BOOLEAN_VALUE = true;
|
||||
const STRING_VALUE = 'val';
|
||||
const NUMBER_VALUE = 1;
|
||||
const OBJECT_VALUE = { key: 'value' };
|
||||
|
||||
// A test hook that stores data in the before stage and retrieves it in after/error/finally
|
||||
class TestHookWithData implements Hook {
|
||||
beforeData: unknown;
|
||||
afterData: unknown;
|
||||
errorData: unknown;
|
||||
finallyData: unknown;
|
||||
|
||||
async before(hookContext: BeforeHookContext) {
|
||||
// Store some data
|
||||
hookContext.hookData.set('testKey', 'testValue');
|
||||
hookContext.hookData.set('timestamp', Date.now());
|
||||
hookContext.hookData.set('object', { nested: 'value' });
|
||||
this.beforeData = hookContext.hookData.get('testKey');
|
||||
}
|
||||
|
||||
async after(hookContext: HookContext) {
|
||||
// Retrieve data stored in before
|
||||
this.afterData = hookContext.hookData.get('testKey');
|
||||
}
|
||||
|
||||
async error(hookContext: HookContext) {
|
||||
// Retrieve data stored in before
|
||||
this.errorData = hookContext.hookData.get('testKey');
|
||||
}
|
||||
|
||||
async finally(hookContext: HookContext) {
|
||||
// Retrieve data stored in before
|
||||
this.finallyData = hookContext.hookData.get('testKey');
|
||||
}
|
||||
}
|
||||
|
||||
// Typed hook example demonstrating improved type safety
|
||||
interface OpenTelemetryData {
|
||||
spanId: string;
|
||||
traceId: string;
|
||||
startTime: number;
|
||||
attributes: Record<string, string | number | boolean>;
|
||||
}
|
||||
|
||||
class TypedOpenTelemetryHook implements Hook {
|
||||
spanId?: string;
|
||||
duration?: number;
|
||||
|
||||
async before(hookContext: BeforeHookContext) {
|
||||
const spanId = `span-${Math.random().toString(36).substring(2, 11)}`;
|
||||
const traceId = `trace-${Math.random().toString(36).substring(2, 11)}`;
|
||||
|
||||
// Demonstrate that we can cast for type safety while maintaining compatibility
|
||||
const typedHookData = hookContext.hookData as unknown as HookData<OpenTelemetryData>;
|
||||
|
||||
// Type-safe setting with proper intellisense
|
||||
typedHookData.set('spanId', spanId);
|
||||
typedHookData.set('traceId', traceId);
|
||||
typedHookData.set('startTime', Date.now());
|
||||
typedHookData.set('attributes', {
|
||||
flagKey: hookContext.flagKey,
|
||||
clientName: hookContext.clientMetadata.name || 'unknown',
|
||||
providerName: hookContext.providerMetadata.name,
|
||||
});
|
||||
|
||||
this.spanId = spanId;
|
||||
}
|
||||
|
||||
async after(hookContext: HookContext) {
|
||||
// Type-safe getting with proper return types
|
||||
const typedHookData = hookContext.hookData as unknown as HookData<OpenTelemetryData>;
|
||||
const startTime: number | undefined = typedHookData.get('startTime');
|
||||
const spanId: string | undefined = typedHookData.get('spanId');
|
||||
|
||||
if (startTime && spanId) {
|
||||
this.duration = Date.now() - startTime;
|
||||
// Simulate span completion
|
||||
}
|
||||
}
|
||||
|
||||
async error(hookContext: HookContext) {
|
||||
const typedHookData = hookContext.hookData as unknown as HookData<OpenTelemetryData>;
|
||||
const spanId: string | undefined = typedHookData.get('spanId');
|
||||
if (spanId) {
|
||||
// Mark span as error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A timing hook that measures evaluation duration
|
||||
class TimingHook implements Hook {
|
||||
duration?: number;
|
||||
|
||||
async before(hookContext: BeforeHookContext) {
|
||||
hookContext.hookData.set('startTime', Date.now());
|
||||
}
|
||||
|
||||
async after(hookContext: HookContext) {
|
||||
const startTime = hookContext.hookData.get('startTime') as number;
|
||||
if (startTime) {
|
||||
this.duration = Date.now() - startTime;
|
||||
}
|
||||
}
|
||||
|
||||
async error(hookContext: HookContext) {
|
||||
const startTime = hookContext.hookData.get('startTime') as number;
|
||||
if (startTime) {
|
||||
this.duration = Date.now() - startTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hook that tests hook data isolation
|
||||
class IsolationTestHook implements Hook {
|
||||
hookId: string;
|
||||
|
||||
constructor(id: string) {
|
||||
this.hookId = id;
|
||||
}
|
||||
|
||||
before(hookContext: BeforeHookContext) {
|
||||
const storedId = hookContext.hookData.get('hookId');
|
||||
if (storedId) {
|
||||
throw new Error('Hook data isolation violated! Data is set in before hook.');
|
||||
}
|
||||
|
||||
// Each hook instance should have its own data
|
||||
hookContext.hookData.set('hookId', this.hookId);
|
||||
hookContext.hookData.set(`data_${this.hookId}`, `value_${this.hookId}`);
|
||||
}
|
||||
|
||||
after(hookContext: HookContext) {
|
||||
// Verify we can only see our own data
|
||||
const storedId = hookContext.hookData.get('hookId');
|
||||
if (storedId !== this.hookId) {
|
||||
throw new Error(`Hook data isolation violated! Expected ${this.hookId}, got ${storedId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mock provider for testing
|
||||
const MOCK_PROVIDER: Provider = {
|
||||
metadata: { name: 'mock-provider' },
|
||||
async resolveBooleanEvaluation(): Promise<ResolutionDetails<boolean>> {
|
||||
return {
|
||||
value: BOOLEAN_VALUE,
|
||||
variant: 'default',
|
||||
reason: StandardResolutionReasons.DEFAULT,
|
||||
};
|
||||
},
|
||||
async resolveStringEvaluation(): Promise<ResolutionDetails<string>> {
|
||||
return {
|
||||
value: STRING_VALUE,
|
||||
variant: 'default',
|
||||
reason: StandardResolutionReasons.DEFAULT,
|
||||
};
|
||||
},
|
||||
async resolveNumberEvaluation(): Promise<ResolutionDetails<number>> {
|
||||
return {
|
||||
value: NUMBER_VALUE,
|
||||
variant: 'default',
|
||||
reason: StandardResolutionReasons.DEFAULT,
|
||||
};
|
||||
},
|
||||
async resolveObjectEvaluation<T extends JsonValue>(): Promise<ResolutionDetails<T>> {
|
||||
return {
|
||||
value: OBJECT_VALUE as unknown as T,
|
||||
variant: 'default',
|
||||
reason: StandardResolutionReasons.DEFAULT,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// Mock provider that throws an error
|
||||
const ERROR_PROVIDER: Provider = {
|
||||
metadata: { name: 'error-provider' },
|
||||
async resolveBooleanEvaluation(): Promise<ResolutionDetails<boolean>> {
|
||||
throw new Error('Provider error');
|
||||
},
|
||||
async resolveStringEvaluation(): Promise<ResolutionDetails<string>> {
|
||||
throw new Error('Provider error');
|
||||
},
|
||||
async resolveNumberEvaluation(): Promise<ResolutionDetails<number>> {
|
||||
throw new Error('Provider error');
|
||||
},
|
||||
async resolveObjectEvaluation<T extends JsonValue>(): Promise<ResolutionDetails<T>> {
|
||||
throw new Error('Provider error');
|
||||
},
|
||||
};
|
||||
|
||||
describe('Hook Data', () => {
|
||||
let client: Client;
|
||||
|
||||
beforeEach(async () => {
|
||||
OpenFeature.clearHooks();
|
||||
await OpenFeature.setProviderAndWait(MOCK_PROVIDER);
|
||||
client = OpenFeature.getClient();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await OpenFeature.clearProviders();
|
||||
});
|
||||
|
||||
describe('Basic Hook Data Functionality', () => {
|
||||
it('should allow hooks to store and retrieve data across stages', async () => {
|
||||
const hook = new TestHookWithData();
|
||||
client.addHooks(hook);
|
||||
|
||||
await client.getBooleanValue('test-flag', false);
|
||||
|
||||
// Verify data was stored in before and retrieved in all other stages
|
||||
expect(hook.beforeData).toBe('testValue');
|
||||
expect(hook.afterData).toBe('testValue');
|
||||
expect(hook.finallyData).toBe('testValue');
|
||||
});
|
||||
|
||||
it('should support storing different data types', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const storedValues: any = {};
|
||||
|
||||
const hook: Hook = {
|
||||
async before(hookContext: BeforeHookContext) {
|
||||
// Store various types
|
||||
hookContext.hookData.set('string', 'test');
|
||||
hookContext.hookData.set('number', 42);
|
||||
hookContext.hookData.set('boolean', true);
|
||||
hookContext.hookData.set('object', { key: 'value' });
|
||||
hookContext.hookData.set('array', [1, 2, 3]);
|
||||
hookContext.hookData.set('null', null);
|
||||
hookContext.hookData.set('undefined', undefined);
|
||||
},
|
||||
|
||||
async after(hookContext: HookContext) {
|
||||
storedValues.string = hookContext.hookData.get('string');
|
||||
storedValues.number = hookContext.hookData.get('number');
|
||||
storedValues.boolean = hookContext.hookData.get('boolean');
|
||||
storedValues.object = hookContext.hookData.get('object');
|
||||
storedValues.array = hookContext.hookData.get('array');
|
||||
storedValues.null = hookContext.hookData.get('null');
|
||||
storedValues.undefined = hookContext.hookData.get('undefined');
|
||||
},
|
||||
};
|
||||
|
||||
client.addHooks(hook);
|
||||
await client.getBooleanValue('test-flag', false);
|
||||
|
||||
expect(storedValues.string).toBe('test');
|
||||
expect(storedValues.number).toBe(42);
|
||||
expect(storedValues.boolean).toBe(true);
|
||||
expect(storedValues.object).toEqual({ key: 'value' });
|
||||
expect(storedValues.array).toEqual([1, 2, 3]);
|
||||
expect(storedValues.null).toBeNull();
|
||||
expect(storedValues.undefined).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle hook data in error scenarios', async () => {
|
||||
await OpenFeature.setProviderAndWait(ERROR_PROVIDER);
|
||||
const hook = new TestHookWithData();
|
||||
client.addHooks(hook);
|
||||
|
||||
await client.getBooleanValue('test-flag', false);
|
||||
|
||||
// Verify data was accessible in error and finally stages
|
||||
expect(hook.beforeData).toBe('testValue');
|
||||
expect(hook.errorData).toBe('testValue');
|
||||
expect(hook.finallyData).toBe('testValue');
|
||||
expect(hook.afterData).toBeUndefined(); // after should not run on error
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hook Data API', () => {
|
||||
it('should support has() method', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const hasResults: any = {};
|
||||
|
||||
const hook: Hook = {
|
||||
async before(hookContext: BeforeHookContext) {
|
||||
hookContext.hookData.set('exists', 'value');
|
||||
hasResults.beforeExists = hookContext.hookData.has('exists');
|
||||
hasResults.beforeNotExists = hookContext.hookData.has('notExists');
|
||||
},
|
||||
|
||||
async after(hookContext: HookContext) {
|
||||
hasResults.afterExists = hookContext.hookData.has('exists');
|
||||
hasResults.afterNotExists = hookContext.hookData.has('notExists');
|
||||
},
|
||||
};
|
||||
|
||||
client.addHooks(hook);
|
||||
await client.getBooleanValue('test-flag', false);
|
||||
|
||||
expect(hasResults.beforeExists).toBe(true);
|
||||
expect(hasResults.beforeNotExists).toBe(false);
|
||||
expect(hasResults.afterExists).toBe(true);
|
||||
expect(hasResults.afterNotExists).toBe(false);
|
||||
});
|
||||
|
||||
it('should support delete() method', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const deleteResults: any = {};
|
||||
|
||||
const hook: Hook = {
|
||||
async before(hookContext: BeforeHookContext) {
|
||||
hookContext.hookData.set('toDelete', 'value');
|
||||
deleteResults.hasBeforeDelete = hookContext.hookData.has('toDelete');
|
||||
deleteResults.deleteResult = hookContext.hookData.delete('toDelete');
|
||||
deleteResults.hasAfterDelete = hookContext.hookData.has('toDelete');
|
||||
deleteResults.deleteAgainResult = hookContext.hookData.delete('toDelete');
|
||||
},
|
||||
};
|
||||
|
||||
client.addHooks(hook);
|
||||
await client.getBooleanValue('test-flag', false);
|
||||
|
||||
expect(deleteResults.hasBeforeDelete).toBe(true);
|
||||
expect(deleteResults.deleteResult).toBe(true);
|
||||
expect(deleteResults.hasAfterDelete).toBe(false);
|
||||
expect(deleteResults.deleteAgainResult).toBe(false);
|
||||
});
|
||||
|
||||
it('should support clear() method', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const clearResults: any = {};
|
||||
|
||||
const hook: Hook = {
|
||||
async before(hookContext: BeforeHookContext) {
|
||||
hookContext.hookData.set('key1', 'value1');
|
||||
hookContext.hookData.set('key2', 'value2');
|
||||
hookContext.hookData.set('key3', 'value3');
|
||||
clearResults.hasBeforeClear = hookContext.hookData.has('key1');
|
||||
hookContext.hookData.clear();
|
||||
clearResults.hasAfterClear = hookContext.hookData.has('key1');
|
||||
},
|
||||
|
||||
async after(hookContext: HookContext) {
|
||||
// Verify all data was cleared
|
||||
clearResults.afterHasKey1 = hookContext.hookData.has('key1');
|
||||
clearResults.afterHasKey2 = hookContext.hookData.has('key2');
|
||||
clearResults.afterHasKey3 = hookContext.hookData.has('key3');
|
||||
},
|
||||
};
|
||||
|
||||
client.addHooks(hook);
|
||||
await client.getBooleanValue('test-flag', false);
|
||||
|
||||
expect(clearResults.hasBeforeClear).toBe(true);
|
||||
expect(clearResults.hasAfterClear).toBe(false);
|
||||
expect(clearResults.afterHasKey1).toBe(false);
|
||||
expect(clearResults.afterHasKey2).toBe(false);
|
||||
expect(clearResults.afterHasKey3).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hook Data Isolation', () => {
|
||||
it('should isolate data between different hook instances', async () => {
|
||||
const hook1 = new IsolationTestHook('hook1');
|
||||
const hook2 = new IsolationTestHook('hook2');
|
||||
const hook3 = new IsolationTestHook('hook3');
|
||||
|
||||
client.addHooks(hook1, hook2, hook3);
|
||||
|
||||
expect(await client.getBooleanValue('test-flag', false)).toBe(true);
|
||||
});
|
||||
|
||||
it('should isolate data between the same hook instance', async () => {
|
||||
const hook = new IsolationTestHook('hook');
|
||||
|
||||
client.addHooks(hook, hook);
|
||||
|
||||
expect(await client.getBooleanValue('test-flag', false)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not share data between different evaluations', async () => {
|
||||
let firstEvalData: unknown;
|
||||
let secondEvalData: unknown;
|
||||
|
||||
const hook: Hook = {
|
||||
async before(hookContext: BeforeHookContext) {
|
||||
// Check if data exists from previous evaluation
|
||||
const existingData = hookContext.hookData.get('evalData');
|
||||
if (existingData) {
|
||||
throw new Error('Hook data leaked between evaluations!');
|
||||
}
|
||||
hookContext.hookData.set('evalData', 'evaluation-specific');
|
||||
},
|
||||
|
||||
async after(hookContext: HookContext) {
|
||||
if (!firstEvalData) {
|
||||
firstEvalData = hookContext.hookData.get('evalData');
|
||||
} else {
|
||||
secondEvalData = hookContext.hookData.get('evalData');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
client.addHooks(hook);
|
||||
|
||||
// First evaluation
|
||||
await client.getBooleanValue('test-flag', false);
|
||||
// Second evaluation
|
||||
await client.getBooleanValue('test-flag', false);
|
||||
|
||||
expect(firstEvalData).toBe('evaluation-specific');
|
||||
expect(secondEvalData).toBe('evaluation-specific');
|
||||
});
|
||||
|
||||
it('should isolate data between global, client, and invocation hooks', async () => {
|
||||
const globalHook = new IsolationTestHook('global');
|
||||
const clientHook = new IsolationTestHook('client');
|
||||
const invocationHook = new IsolationTestHook('invocation');
|
||||
|
||||
OpenFeature.addHooks(globalHook);
|
||||
client.addHooks(clientHook);
|
||||
|
||||
expect(await client.getBooleanValue('test-flag', false, {}, { hooks: [invocationHook] })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Use Cases', () => {
|
||||
it('should support timing measurements', async () => {
|
||||
const timingHook = new TimingHook();
|
||||
client.addHooks(timingHook);
|
||||
|
||||
await client.getBooleanValue('test-flag', false);
|
||||
|
||||
expect(timingHook.duration).toBeDefined();
|
||||
expect(timingHook.duration).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should support multi-stage validation accumulation', async () => {
|
||||
let finalErrors: string[] = [];
|
||||
|
||||
const validationHook: Hook = {
|
||||
async before(hookContext: BeforeHookContext) {
|
||||
hookContext.hookData.set('errors', []);
|
||||
|
||||
// Simulate validation
|
||||
const errors = hookContext.hookData.get('errors') as string[];
|
||||
if (!hookContext.context.userId) {
|
||||
errors.push('Missing userId');
|
||||
}
|
||||
if (!hookContext.context.region) {
|
||||
errors.push('Missing region');
|
||||
}
|
||||
},
|
||||
|
||||
async finally(hookContext: HookContext) {
|
||||
finalErrors = (hookContext.hookData.get('errors') as string[]) || [];
|
||||
},
|
||||
};
|
||||
|
||||
client.addHooks(validationHook);
|
||||
await client.getBooleanValue('test-flag', false, {});
|
||||
|
||||
expect(finalErrors).toContain('Missing userId');
|
||||
expect(finalErrors).toContain('Missing region');
|
||||
});
|
||||
|
||||
it('should support request correlation', async () => {
|
||||
let correlationId: string | undefined;
|
||||
|
||||
const correlationHook: Hook = {
|
||||
async before(hookContext: BeforeHookContext) {
|
||||
const id = `req-${Date.now()}-${Math.random()}`;
|
||||
hookContext.hookData.set('correlationId', id);
|
||||
},
|
||||
|
||||
async after(hookContext: HookContext) {
|
||||
correlationId = hookContext.hookData.get('correlationId') as string;
|
||||
},
|
||||
};
|
||||
|
||||
client.addHooks(correlationHook);
|
||||
await client.getBooleanValue('test-flag', false);
|
||||
|
||||
expect(correlationId).toBeDefined();
|
||||
expect(correlationId).toMatch(/^req-\d+-[\d.]+$/);
|
||||
});
|
||||
|
||||
it('should support typed hook data for better type safety', async () => {
|
||||
const typedHook = new TypedOpenTelemetryHook();
|
||||
client.addHooks(typedHook);
|
||||
|
||||
await client.getBooleanValue('test-flag', false);
|
||||
|
||||
// Verify the typed hook worked correctly
|
||||
expect(typedHook.spanId).toBeDefined();
|
||||
expect(typedHook.spanId).toMatch(/^span-[a-z0-9]+$/);
|
||||
expect(typedHook.duration).toBeDefined();
|
||||
expect(typeof typedHook.duration).toBe('number');
|
||||
expect(typedHook.duration).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,5 +1,13 @@
|
|||
# Changelog
|
||||
|
||||
## [1.9.0](https://github.com/open-feature/js-sdk/compare/core-v1.8.1...core-v1.9.0) (2025-08-10)
|
||||
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
* add evaluation-scoped hook data ([#1216](https://github.com/open-feature/js-sdk/issues/1216)) ([07af3a9](https://github.com/open-feature/js-sdk/commit/07af3a9eda895e9edb24c7ee1e3c1c4f16e17431))
|
||||
* support Angular 20 ([#1220](https://github.com/open-feature/js-sdk/issues/1220)) ([aa232a9](https://github.com/open-feature/js-sdk/commit/aa232a9d6a8dfa416380ccdecd71843d3e361048))
|
||||
|
||||
## [1.8.1](https://github.com/open-feature/js-sdk/compare/core-v1.8.0...core-v1.8.1) (2025-06-04)
|
||||
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openfeature/core",
|
||||
"version": "1.8.1",
|
||||
"version": "1.9.0",
|
||||
"description": "Shared OpenFeature JS components (server and web)",
|
||||
"main": "./dist/cjs/index.js",
|
||||
"files": [
|
||||
|
|
|
@ -18,3 +18,4 @@ export abstract class OpenFeatureError extends Error {
|
|||
this.cause = options?.cause;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,72 @@
|
|||
/**
|
||||
* A mutable data structure for hooks to maintain state across their lifecycle.
|
||||
* Each hook instance gets its own isolated data store that persists for the
|
||||
* duration of a single flag evaluation.
|
||||
* @template TData - A record type that defines the shape of the stored data
|
||||
*/
|
||||
export interface HookData<TData = Record<string, unknown>> {
|
||||
/**
|
||||
* Sets a value in the hook data store.
|
||||
* @param key The key to store the value under
|
||||
* @param value The value to store
|
||||
*/
|
||||
set<K extends keyof TData>(key: K, value: TData[K]): void;
|
||||
set(key: string, value: unknown): void;
|
||||
|
||||
/**
|
||||
* Gets a value from the hook data store.
|
||||
* @param key The key to retrieve the value for
|
||||
* @returns The stored value, or undefined if not found
|
||||
*/
|
||||
get<K extends keyof TData>(key: K): TData[K] | undefined;
|
||||
get(key: string): unknown;
|
||||
|
||||
/**
|
||||
* Checks if a key exists in the hook data store.
|
||||
* @param key The key to check
|
||||
* @returns True if the key exists, false otherwise
|
||||
*/
|
||||
has<K extends keyof TData>(key: K): boolean;
|
||||
has(key: string): boolean;
|
||||
|
||||
/**
|
||||
* Deletes a value from the hook data store.
|
||||
* @param key The key to delete
|
||||
* @returns True if the key was deleted, false if it didn't exist
|
||||
*/
|
||||
delete<K extends keyof TData>(key: K): boolean;
|
||||
delete(key: string): boolean;
|
||||
|
||||
/**
|
||||
* Clears all values from the hook data store.
|
||||
*/
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default implementation of HookData using a Map.
|
||||
* @template TData - A record type that defines the shape of the stored data
|
||||
*/
|
||||
export class MapHookData<TData = Record<string, unknown>> implements HookData<TData> {
|
||||
private readonly data = new Map<keyof TData, TData[keyof TData]>();
|
||||
|
||||
set<K extends keyof TData>(key: K, value: TData[K]): void {
|
||||
this.data.set(key, value);
|
||||
}
|
||||
|
||||
get<K extends keyof TData>(key: K): TData[K] | undefined {
|
||||
return this.data.get(key) as TData[K] | undefined;
|
||||
}
|
||||
|
||||
has<K extends keyof TData>(key: K): boolean {
|
||||
return this.data.has(key);
|
||||
}
|
||||
|
||||
delete<K extends keyof TData>(key: K): boolean {
|
||||
return this.data.delete(key);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.data.clear();
|
||||
}
|
||||
}
|
|
@ -1,14 +1,19 @@
|
|||
import type { BeforeHookContext, HookContext, HookHints } from './hooks';
|
||||
import type { EvaluationDetails, FlagValue } from '../evaluation';
|
||||
|
||||
export interface BaseHook<T extends FlagValue = FlagValue, BeforeHookReturn = unknown, HooksReturn = unknown> {
|
||||
export interface BaseHook<
|
||||
T extends FlagValue = FlagValue,
|
||||
TData = Record<string, unknown>,
|
||||
BeforeHookReturn = unknown,
|
||||
HooksReturn = unknown
|
||||
> {
|
||||
/**
|
||||
* Runs before flag values are resolved from the provider.
|
||||
* If an EvaluationContext is returned, it will be merged with the pre-existing EvaluationContext.
|
||||
* @param hookContext
|
||||
* @param hookHints
|
||||
*/
|
||||
before?(hookContext: BeforeHookContext, hookHints?: HookHints): BeforeHookReturn;
|
||||
before?(hookContext: BeforeHookContext<T, TData>, hookHints?: HookHints): BeforeHookReturn;
|
||||
|
||||
/**
|
||||
* Runs after flag values are successfully resolved from the provider.
|
||||
|
@ -17,7 +22,7 @@ export interface BaseHook<T extends FlagValue = FlagValue, BeforeHookReturn = un
|
|||
* @param hookHints
|
||||
*/
|
||||
after?(
|
||||
hookContext: Readonly<HookContext<T>>,
|
||||
hookContext: Readonly<HookContext<T, TData>>,
|
||||
evaluationDetails: EvaluationDetails<T>,
|
||||
hookHints?: HookHints,
|
||||
): HooksReturn;
|
||||
|
@ -28,7 +33,7 @@ export interface BaseHook<T extends FlagValue = FlagValue, BeforeHookReturn = un
|
|||
* @param error
|
||||
* @param hookHints
|
||||
*/
|
||||
error?(hookContext: Readonly<HookContext<T>>, error: unknown, hookHints?: HookHints): HooksReturn;
|
||||
error?(hookContext: Readonly<HookContext<T, TData>>, error: unknown, hookHints?: HookHints): HooksReturn;
|
||||
|
||||
/**
|
||||
* Runs after all other hook stages, regardless of success or error.
|
||||
|
@ -37,8 +42,9 @@ export interface BaseHook<T extends FlagValue = FlagValue, BeforeHookReturn = un
|
|||
* @param hookHints
|
||||
*/
|
||||
finally?(
|
||||
hookContext: Readonly<HookContext<T>>,
|
||||
hookContext: Readonly<HookContext<T, TData>>,
|
||||
evaluationDetails: EvaluationDetails<T>,
|
||||
hookHints?: HookHints,
|
||||
): HooksReturn;
|
||||
}
|
||||
|
||||
|
|
|
@ -2,10 +2,11 @@ import type { ProviderMetadata } from '../provider';
|
|||
import type { ClientMetadata } from '../client';
|
||||
import type { EvaluationContext, FlagValue, FlagValueType } from '../evaluation';
|
||||
import type { Logger } from '../logger';
|
||||
import type { HookData } from './hook-data';
|
||||
|
||||
export type HookHints = Readonly<Record<string, unknown>>;
|
||||
|
||||
export interface HookContext<T extends FlagValue = FlagValue> {
|
||||
export interface HookContext<T extends FlagValue = FlagValue, TData = Record<string, unknown>> {
|
||||
readonly flagKey: string;
|
||||
readonly defaultValue: T;
|
||||
readonly flagValueType: FlagValueType;
|
||||
|
@ -13,8 +14,9 @@ export interface HookContext<T extends FlagValue = FlagValue> {
|
|||
readonly clientMetadata: ClientMetadata;
|
||||
readonly providerMetadata: ProviderMetadata;
|
||||
readonly logger: Logger;
|
||||
readonly hookData: HookData<TData>;
|
||||
}
|
||||
|
||||
export interface BeforeHookContext extends HookContext {
|
||||
export interface BeforeHookContext<T extends FlagValue = FlagValue, TData = Record<string, unknown>> extends HookContext<T, TData> {
|
||||
context: EvaluationContext;
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
export * from './hook';
|
||||
export * from './hooks';
|
||||
export * from './evaluation-lifecycle';
|
||||
export * from './hook-data';
|
||||
|
|
|
@ -0,0 +1,214 @@
|
|||
import type { HookData, BaseHook, BeforeHookContext, HookContext } from '../src/hooks';
|
||||
import { MapHookData } from '../src/hooks';
|
||||
import type { FlagValue } from '../src/evaluation';
|
||||
|
||||
describe('Hook Data Type Safety', () => {
|
||||
it('should provide type safety with typed hook data', () => {
|
||||
// Define a strict type for hook data
|
||||
interface MyHookData {
|
||||
startTime: number;
|
||||
userId: string;
|
||||
metadata: { version: string; feature: boolean };
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
const hookData = new MapHookData<MyHookData>();
|
||||
|
||||
// Type-safe setting and getting
|
||||
hookData.set('startTime', 123456);
|
||||
hookData.set('userId', 'user-123');
|
||||
hookData.set('metadata', { version: '1.0.0', feature: true });
|
||||
hookData.set('tags', ['tag1', 'tag2']);
|
||||
|
||||
// TypeScript should infer the correct return types
|
||||
const startTime: number | undefined = hookData.get('startTime');
|
||||
const userId: string | undefined = hookData.get('userId');
|
||||
const metadata: { version: string; feature: boolean } | undefined = hookData.get('metadata');
|
||||
const tags: string[] | undefined = hookData.get('tags');
|
||||
|
||||
// Verify the values
|
||||
expect(startTime).toBe(123456);
|
||||
expect(userId).toBe('user-123');
|
||||
expect(metadata).toEqual({ version: '1.0.0', feature: true });
|
||||
expect(tags).toEqual(['tag1', 'tag2']);
|
||||
|
||||
// Type-safe existence checks
|
||||
expect(hookData.has('startTime')).toBe(true);
|
||||
expect(hookData.has('userId')).toBe(true);
|
||||
expect(hookData.has('metadata')).toBe(true);
|
||||
expect(hookData.has('tags')).toBe(true);
|
||||
|
||||
// Type-safe deletion
|
||||
expect(hookData.delete('tags')).toBe(true);
|
||||
expect(hookData.has('tags')).toBe(false);
|
||||
});
|
||||
|
||||
it('should support untyped usage for backward compatibility', () => {
|
||||
const hookData: HookData = new MapHookData();
|
||||
|
||||
// Untyped usage still works
|
||||
hookData.set('anyKey', 'anyValue');
|
||||
hookData.set('numberKey', 42);
|
||||
hookData.set('objectKey', { nested: true });
|
||||
|
||||
const value: unknown = hookData.get('anyKey');
|
||||
const numberValue: unknown = hookData.get('numberKey');
|
||||
const objectValue: unknown = hookData.get('objectKey');
|
||||
|
||||
expect(value).toBe('anyValue');
|
||||
expect(numberValue).toBe(42);
|
||||
expect(objectValue).toEqual({ nested: true });
|
||||
});
|
||||
|
||||
it('should support mixed usage with typed and untyped keys', () => {
|
||||
interface PartiallyTypedData {
|
||||
correlationId: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const hookData: HookData<PartiallyTypedData> = new MapHookData<PartiallyTypedData>();
|
||||
|
||||
// Typed usage
|
||||
hookData.set('correlationId', 'abc-123');
|
||||
hookData.set('timestamp', Date.now());
|
||||
|
||||
// Untyped usage for additional keys
|
||||
hookData.set('dynamicKey', 'dynamicValue');
|
||||
|
||||
// Type-safe retrieval for typed keys
|
||||
const correlationId: string | undefined = hookData.get('correlationId');
|
||||
const timestamp: number | undefined = hookData.get('timestamp');
|
||||
|
||||
// Untyped retrieval for dynamic keys
|
||||
const dynamicValue: unknown = hookData.get('dynamicKey');
|
||||
|
||||
expect(correlationId).toBe('abc-123');
|
||||
expect(typeof timestamp).toBe('number');
|
||||
expect(dynamicValue).toBe('dynamicValue');
|
||||
});
|
||||
|
||||
it('should work with complex nested types', () => {
|
||||
interface ComplexHookData {
|
||||
request: {
|
||||
id: string;
|
||||
headers: Record<string, string>;
|
||||
body?: { [key: string]: unknown };
|
||||
};
|
||||
response: {
|
||||
status: number;
|
||||
data: unknown;
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
metrics: {
|
||||
startTime: number;
|
||||
endTime?: number;
|
||||
duration?: number;
|
||||
};
|
||||
}
|
||||
|
||||
const hookData: HookData<ComplexHookData> = new MapHookData<ComplexHookData>();
|
||||
|
||||
const requestData = {
|
||||
id: 'req-123',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: { flag: 'test-flag' },
|
||||
};
|
||||
|
||||
hookData.set('request', requestData);
|
||||
hookData.set('metrics', { startTime: Date.now() });
|
||||
|
||||
const retrievedRequest = hookData.get('request');
|
||||
const retrievedMetrics = hookData.get('metrics');
|
||||
|
||||
expect(retrievedRequest).toEqual(requestData);
|
||||
expect(retrievedMetrics?.startTime).toBeDefined();
|
||||
expect(typeof retrievedMetrics?.startTime).toBe('number');
|
||||
});
|
||||
|
||||
it('should support generic type inference', () => {
|
||||
// This function demonstrates how the generic types work in practice
|
||||
function createTypedHookData<T>(): HookData<T> {
|
||||
return new MapHookData<T>();
|
||||
}
|
||||
|
||||
interface TimingData {
|
||||
start: number;
|
||||
checkpoint: number;
|
||||
}
|
||||
|
||||
const timingHookData = createTypedHookData<TimingData>();
|
||||
|
||||
timingHookData.set('start', performance.now());
|
||||
timingHookData.set('checkpoint', performance.now());
|
||||
|
||||
const start: number | undefined = timingHookData.get('start');
|
||||
const checkpoint: number | undefined = timingHookData.get('checkpoint');
|
||||
|
||||
expect(typeof start).toBe('number');
|
||||
expect(typeof checkpoint).toBe('number');
|
||||
});
|
||||
|
||||
it('should work with BaseHook interface without casting', () => {
|
||||
interface TestHookData {
|
||||
testId: string;
|
||||
startTime: number;
|
||||
metadata: { version: string };
|
||||
}
|
||||
|
||||
class TestTypedHook implements BaseHook<FlagValue, TestHookData> {
|
||||
capturedData: { testId?: string; duration?: number } = {};
|
||||
|
||||
before(hookContext: BeforeHookContext<FlagValue, TestHookData>) {
|
||||
// No casting needed - TypeScript knows the types
|
||||
hookContext.hookData.set('testId', 'test-123');
|
||||
hookContext.hookData.set('startTime', Date.now());
|
||||
hookContext.hookData.set('metadata', { version: '1.0.0' });
|
||||
}
|
||||
|
||||
after(hookContext: HookContext<FlagValue, TestHookData>) {
|
||||
// Type-safe getting with proper return types
|
||||
const testId: string | undefined = hookContext.hookData.get('testId');
|
||||
const startTime: number | undefined = hookContext.hookData.get('startTime');
|
||||
|
||||
if (testId && startTime) {
|
||||
this.capturedData = {
|
||||
testId,
|
||||
duration: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hook = new TestTypedHook();
|
||||
|
||||
// Create mock contexts that satisfy the BaseHook interface
|
||||
const mockBeforeContext: BeforeHookContext<FlagValue, TestHookData> = {
|
||||
flagKey: 'test-flag',
|
||||
defaultValue: true,
|
||||
flagValueType: 'boolean',
|
||||
context: {},
|
||||
clientMetadata: {
|
||||
name: 'test-client',
|
||||
domain: 'test-domain',
|
||||
providerMetadata: { name: 'test-provider' },
|
||||
},
|
||||
providerMetadata: { name: 'test-provider' },
|
||||
logger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() },
|
||||
hookData: new MapHookData<TestHookData>(),
|
||||
};
|
||||
|
||||
const mockAfterContext: HookContext<FlagValue, TestHookData> = {
|
||||
...mockBeforeContext,
|
||||
context: Object.freeze({}),
|
||||
};
|
||||
|
||||
// Execute the hook methods
|
||||
hook.before!(mockBeforeContext);
|
||||
hook.after!(mockAfterContext);
|
||||
|
||||
// Verify the typed hook worked correctly
|
||||
expect(hook.capturedData.testId).toBe('test-123');
|
||||
expect(hook.capturedData.duration).toBeDefined();
|
||||
expect(typeof hook.capturedData.duration).toBe('number');
|
||||
});
|
||||
});
|
|
@ -2,6 +2,7 @@ import { createEvaluationEvent } from '../src/telemetry/evaluation-event';
|
|||
import { ErrorCode, StandardResolutionReasons, type EvaluationDetails } from '../src/evaluation/evaluation';
|
||||
import type { HookContext } from '../src/hooks/hooks';
|
||||
import { TelemetryAttribute, TelemetryFlagMetadata } from '../src/telemetry';
|
||||
import { MapHookData } from '../src/hooks/hook-data';
|
||||
|
||||
describe('evaluationEvent', () => {
|
||||
const flagKey = 'test-flag';
|
||||
|
@ -25,6 +26,7 @@ describe('evaluationEvent', () => {
|
|||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
},
|
||||
hookData: new MapHookData(),
|
||||
};
|
||||
|
||||
it('should return basic event body with mandatory fields', () => {
|
||||
|
|
|
@ -1,6 +1,26 @@
|
|||
# Changelog
|
||||
|
||||
|
||||
## [1.6.1](https://github.com/open-feature/js-sdk/compare/web-sdk-v1.6.0...web-sdk-v1.6.1) (2025-08-14)
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
* update core dep ([#1228](https://github.com/open-feature/js-sdk/issues/1228)) ([845d24c](https://github.com/open-feature/js-sdk/commit/845d24c5fecc80de3080e49fde839f08ecac6b33))
|
||||
|
||||
## [1.6.0](https://github.com/open-feature/js-sdk/compare/web-sdk-v1.5.0...web-sdk-v1.6.0) (2025-08-12)
|
||||
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
* add evaluation-scoped hook data ([#1216](https://github.com/open-feature/js-sdk/issues/1216)) ([07af3a9](https://github.com/open-feature/js-sdk/commit/07af3a9eda895e9edb24c7ee1e3c1c4f16e17431))
|
||||
* **web-global-build:** impl ([#1225](https://github.com/open-feature/js-sdk/issues/1225)) ([40a512e](https://github.com/open-feature/js-sdk/commit/40a512e21204eb92dc3ef4161b383f9c1fd74da7))
|
||||
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
* Clarify the behavior of setProviderAndWait ([#1180](https://github.com/open-feature/js-sdk/issues/1180)) ([4fe8d87](https://github.com/open-feature/js-sdk/commit/4fe8d87a2e5df2cbd4086cc4f4a380e8857ed8ba))
|
||||
|
||||
## [1.5.0](https://github.com/open-feature/js-sdk/compare/web-sdk-v1.4.1...web-sdk-v1.5.0) (2025-04-11)
|
||||
|
||||
|
||||
|
|
|
@ -16,8 +16,8 @@
|
|||
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
|
||||
</a>
|
||||
<!-- x-release-please-start-version -->
|
||||
<a href="https://github.com/open-feature/js-sdk/releases/tag/web-sdk-v1.5.0">
|
||||
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v1.5.0&color=blue&style=for-the-badge" />
|
||||
<a href="https://github.com/open-feature/js-sdk/releases/tag/web-sdk-v1.6.1">
|
||||
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v1.6.1&color=blue&style=for-the-badge" />
|
||||
</a>
|
||||
<!-- x-release-please-end -->
|
||||
<br/>
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
{
|
||||
"name": "@openfeature/web-sdk",
|
||||
"version": "1.5.0",
|
||||
"version": "1.6.1",
|
||||
"description": "OpenFeature SDK for Web",
|
||||
"main": "./dist/cjs/index.js",
|
||||
"unpkg": "dist/global/index.min.js",
|
||||
"jsdelivr": "dist/global/index.min.js",
|
||||
"files": [
|
||||
"dist/"
|
||||
],
|
||||
|
@ -20,8 +22,10 @@
|
|||
"clean": "shx rm -rf ./dist",
|
||||
"build:web-esm": "esbuild src/index.ts --bundle --external:@openfeature/core --sourcemap --target=es2015 --platform=browser --format=esm --outfile=./dist/esm/index.js --analyze",
|
||||
"build:web-cjs": "esbuild src/index.ts --bundle --external:@openfeature/core --sourcemap --target=es2015 --platform=browser --format=cjs --outfile=./dist/cjs/index.js --analyze",
|
||||
"build:web-global": "esbuild src/index.ts --bundle --sourcemap --target=es2015 --platform=browser --format=iife --outfile=./dist/global/index.js --global-name=OpenFeature --analyze",
|
||||
"build:web-global:min": "esbuild src/index.ts --bundle --sourcemap --target=es2015 --platform=browser --format=iife --outfile=./dist/global/index.min.js --global-name=OpenFeature --minify --analyze",
|
||||
"build:rollup-types": "rollup -c ../../rollup.config.mjs",
|
||||
"build": "npm run clean && npm run build:web-esm && npm run build:web-cjs && npm run build:rollup-types",
|
||||
"build": "npm run clean && npm run build:web-esm && npm run build:web-cjs && npm run build:web-global && npm run build:web-global:min && npm run build:rollup-types",
|
||||
"postbuild": "shx cp ./../../package.esm.json ./dist/esm/package.json",
|
||||
"current-version": "echo $npm_package_version",
|
||||
"prepack": "shx cp ./../../LICENSE ./LICENSE",
|
||||
|
@ -46,9 +50,9 @@
|
|||
},
|
||||
"homepage": "https://github.com/open-feature/js-sdk#readme",
|
||||
"peerDependencies": {
|
||||
"@openfeature/core": "^1.8.0"
|
||||
"@openfeature/core": "^1.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openfeature/core": "^1.8.0"
|
||||
"@openfeature/core": "^1.9.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,6 +22,7 @@ import {
|
|||
StandardResolutionReasons,
|
||||
instantiateErrorByErrorCode,
|
||||
statusMatchesEvent,
|
||||
MapHookData,
|
||||
} from '@openfeature/core';
|
||||
import type { FlagEvaluationOptions } from '../../evaluation';
|
||||
import type { ProviderEvents } from '../../events';
|
||||
|
@ -231,22 +232,26 @@ export class OpenFeatureClient implements Client {
|
|||
...this.apiContextAccessor(this?.options?.domain),
|
||||
};
|
||||
|
||||
// this reference cannot change during the course of evaluation
|
||||
// it may be used as a key in WeakMaps
|
||||
const hookContext: Readonly<HookContext> = {
|
||||
flagKey,
|
||||
defaultValue,
|
||||
flagValueType: flagType,
|
||||
clientMetadata: this.metadata,
|
||||
providerMetadata: this._provider.metadata,
|
||||
context,
|
||||
logger: this._logger,
|
||||
};
|
||||
// Create hook context instances for each hook (stable object references for the entire evaluation)
|
||||
// This ensures hooks can use WeakMaps with hookContext as keys across lifecycle methods
|
||||
// NOTE: Uses the reversed order to reduce the number of times we have to calculate the index.
|
||||
const hookContexts = allHooksReversed.map<HookContext>(() =>
|
||||
Object.freeze({
|
||||
flagKey,
|
||||
defaultValue,
|
||||
flagValueType: flagType,
|
||||
clientMetadata: this.metadata,
|
||||
providerMetadata: this._provider.metadata,
|
||||
context,
|
||||
logger: this._logger,
|
||||
hookData: new MapHookData(),
|
||||
}),
|
||||
);
|
||||
|
||||
let evaluationDetails: EvaluationDetails<T>;
|
||||
|
||||
try {
|
||||
this.beforeHooks(allHooks, hookContext, options);
|
||||
this.beforeHooks(allHooks, hookContexts, options);
|
||||
|
||||
this.shortCircuitIfNotReady();
|
||||
|
||||
|
@ -261,45 +266,48 @@ export class OpenFeatureClient implements Client {
|
|||
|
||||
if (resolutionDetails.errorCode) {
|
||||
const err = instantiateErrorByErrorCode(resolutionDetails.errorCode, resolutionDetails.errorMessage);
|
||||
this.errorHooks(allHooksReversed, hookContext, err, options);
|
||||
this.errorHooks(allHooksReversed, hookContexts, err, options);
|
||||
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err, resolutionDetails.flagMetadata);
|
||||
} else {
|
||||
this.afterHooks(allHooksReversed, hookContext, resolutionDetails, options);
|
||||
this.afterHooks(allHooksReversed, hookContexts, resolutionDetails, options);
|
||||
evaluationDetails = resolutionDetails;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
this.errorHooks(allHooksReversed, hookContext, err, options);
|
||||
this.errorHooks(allHooksReversed, hookContexts, err, options);
|
||||
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err);
|
||||
}
|
||||
this.finallyHooks(allHooksReversed, hookContext, evaluationDetails, options);
|
||||
this.finallyHooks(allHooksReversed, hookContexts, evaluationDetails, options);
|
||||
return evaluationDetails;
|
||||
}
|
||||
|
||||
private beforeHooks(hooks: Hook[], hookContext: HookContext, options: FlagEvaluationOptions) {
|
||||
Object.freeze(hookContext);
|
||||
Object.freeze(hookContext.context);
|
||||
|
||||
for (const hook of hooks) {
|
||||
private beforeHooks(hooks: Hook[], hookContexts: HookContext[], options: FlagEvaluationOptions) {
|
||||
for (const [index, hook] of hooks.entries()) {
|
||||
const hookContextIndex = hooks.length - 1 - index; // reverse index for before hooks
|
||||
const hookContext = hookContexts[hookContextIndex];
|
||||
Object.freeze(hookContext);
|
||||
Object.freeze(hookContext.context);
|
||||
hook?.before?.(hookContext, Object.freeze(options.hookHints));
|
||||
}
|
||||
}
|
||||
|
||||
private afterHooks(
|
||||
hooks: Hook[],
|
||||
hookContext: HookContext,
|
||||
hookContexts: HookContext[],
|
||||
evaluationDetails: EvaluationDetails<FlagValue>,
|
||||
options: FlagEvaluationOptions,
|
||||
) {
|
||||
// run "after" hooks sequentially
|
||||
for (const hook of hooks) {
|
||||
for (const [index, hook] of hooks.entries()) {
|
||||
const hookContext = hookContexts[index];
|
||||
hook?.after?.(hookContext, evaluationDetails, options.hookHints);
|
||||
}
|
||||
}
|
||||
|
||||
private errorHooks(hooks: Hook[], hookContext: HookContext, err: unknown, options: FlagEvaluationOptions) {
|
||||
private errorHooks(hooks: Hook[], hookContexts: HookContext[], err: unknown, options: FlagEvaluationOptions) {
|
||||
// run "error" hooks sequentially
|
||||
for (const hook of hooks) {
|
||||
for (const [index, hook] of hooks.entries()) {
|
||||
try {
|
||||
const hookContext = hookContexts[index];
|
||||
hook?.error?.(hookContext, err, options.hookHints);
|
||||
} catch (err) {
|
||||
this._logger.error(`Unhandled error during 'error' hook: ${err}`);
|
||||
|
@ -313,13 +321,14 @@ export class OpenFeatureClient implements Client {
|
|||
|
||||
private finallyHooks(
|
||||
hooks: Hook[],
|
||||
hookContext: HookContext,
|
||||
hookContexts: HookContext[],
|
||||
evaluationDetails: EvaluationDetails<FlagValue>,
|
||||
options: FlagEvaluationOptions,
|
||||
) {
|
||||
// run "finally" hooks sequentially
|
||||
for (const hook of hooks) {
|
||||
for (const [index, hook] of hooks.entries()) {
|
||||
try {
|
||||
const hookContext = hookContexts[index];
|
||||
hook?.finally?.(hookContext, evaluationDetails, options.hookHints);
|
||||
} catch (err) {
|
||||
this._logger.error(`Unhandled error during 'finally' hook: ${err}`);
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
import type { BaseHook, FlagValue } from '@openfeature/core';
|
||||
|
||||
export type Hook = BaseHook<FlagValue, void, void>;
|
||||
export type Hook<TData = Record<string, unknown>> = BaseHook<FlagValue, TData, void, void>;
|
||||
|
|
|
@ -0,0 +1,436 @@
|
|||
import { OpenFeatureAPI } from '../src/open-feature';
|
||||
import type { Client } from '../src/client';
|
||||
import type { JsonValue, ResolutionDetails, HookContext, BeforeHookContext } from '@openfeature/core';
|
||||
import { StandardResolutionReasons } from '@openfeature/core';
|
||||
import type { Provider } from '../src/provider';
|
||||
import type { Hook } from '../src/hooks';
|
||||
|
||||
const BOOLEAN_VALUE = true;
|
||||
const STRING_VALUE = 'val';
|
||||
const NUMBER_VALUE = 1;
|
||||
const OBJECT_VALUE = { key: 'value' };
|
||||
|
||||
// A test hook that stores data in the before stage and retrieves it in after/error/finally
|
||||
class TestHookWithData implements Hook {
|
||||
beforeData: unknown;
|
||||
afterData: unknown;
|
||||
errorData: unknown;
|
||||
finallyData: unknown;
|
||||
|
||||
before(hookContext: BeforeHookContext) {
|
||||
// Store some data
|
||||
hookContext.hookData.set('testKey', 'testValue');
|
||||
hookContext.hookData.set('timestamp', Date.now());
|
||||
hookContext.hookData.set('object', { nested: 'value' });
|
||||
this.beforeData = hookContext.hookData.get('testKey');
|
||||
}
|
||||
|
||||
after(hookContext: HookContext) {
|
||||
// Retrieve data stored in before
|
||||
this.afterData = hookContext.hookData.get('testKey');
|
||||
}
|
||||
|
||||
error(hookContext: HookContext) {
|
||||
// Retrieve data stored in before
|
||||
this.errorData = hookContext.hookData.get('testKey');
|
||||
}
|
||||
|
||||
finally(hookContext: HookContext) {
|
||||
// Retrieve data stored in before
|
||||
this.finallyData = hookContext.hookData.get('testKey');
|
||||
}
|
||||
}
|
||||
|
||||
// A timing hook that measures evaluation duration
|
||||
class TimingHook implements Hook {
|
||||
duration?: number;
|
||||
|
||||
before(hookContext: BeforeHookContext) {
|
||||
hookContext.hookData.set('startTime', performance.now());
|
||||
}
|
||||
|
||||
after(hookContext: HookContext) {
|
||||
const startTime = hookContext.hookData.get('startTime') as number;
|
||||
if (startTime) {
|
||||
this.duration = performance.now() - startTime;
|
||||
}
|
||||
}
|
||||
|
||||
error(hookContext: HookContext) {
|
||||
const startTime = hookContext.hookData.get('startTime') as number;
|
||||
if (startTime) {
|
||||
this.duration = performance.now() - startTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hook that tests hook data isolation
|
||||
class IsolationTestHook implements Hook {
|
||||
hookId: string;
|
||||
|
||||
constructor(id: string) {
|
||||
this.hookId = id;
|
||||
}
|
||||
|
||||
before(hookContext: BeforeHookContext) {
|
||||
const storedId = hookContext.hookData.get('hookId');
|
||||
if (storedId) {
|
||||
throw new Error('Hook data isolation violated! Data is set in before hook.');
|
||||
}
|
||||
|
||||
// Each hook instance should have its own data
|
||||
hookContext.hookData.set('hookId', this.hookId);
|
||||
hookContext.hookData.set(`data_${this.hookId}`, `value_${this.hookId}`);
|
||||
}
|
||||
|
||||
after(hookContext: HookContext) {
|
||||
// Verify we can only see our own data
|
||||
const storedId = hookContext.hookData.get('hookId');
|
||||
if (storedId !== this.hookId) {
|
||||
throw new Error(`Hook data isolation violated! Expected ${this.hookId}, got ${storedId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mock provider for testing
|
||||
const MOCK_PROVIDER: Provider = {
|
||||
metadata: { name: 'mock-provider' },
|
||||
resolveBooleanEvaluation(): ResolutionDetails<boolean> {
|
||||
return {
|
||||
value: BOOLEAN_VALUE,
|
||||
variant: 'default',
|
||||
reason: StandardResolutionReasons.DEFAULT,
|
||||
};
|
||||
},
|
||||
resolveStringEvaluation(): ResolutionDetails<string> {
|
||||
return {
|
||||
value: STRING_VALUE,
|
||||
variant: 'default',
|
||||
reason: StandardResolutionReasons.DEFAULT,
|
||||
};
|
||||
},
|
||||
resolveNumberEvaluation(): ResolutionDetails<number> {
|
||||
return {
|
||||
value: NUMBER_VALUE,
|
||||
variant: 'default',
|
||||
reason: StandardResolutionReasons.DEFAULT,
|
||||
};
|
||||
},
|
||||
resolveObjectEvaluation<T extends JsonValue>(): ResolutionDetails<T> {
|
||||
return {
|
||||
value: OBJECT_VALUE as unknown as T,
|
||||
variant: 'default',
|
||||
reason: StandardResolutionReasons.DEFAULT,
|
||||
};
|
||||
},
|
||||
} as Provider;
|
||||
|
||||
// Mock provider that throws an error
|
||||
const ERROR_PROVIDER: Provider = {
|
||||
metadata: { name: 'error-provider' },
|
||||
resolveBooleanEvaluation(): ResolutionDetails<boolean> {
|
||||
throw new Error('Provider error');
|
||||
},
|
||||
resolveStringEvaluation(): ResolutionDetails<string> {
|
||||
throw new Error('Provider error');
|
||||
},
|
||||
resolveNumberEvaluation(): ResolutionDetails<number> {
|
||||
throw new Error('Provider error');
|
||||
},
|
||||
resolveObjectEvaluation<T extends JsonValue>(): ResolutionDetails<T> {
|
||||
throw new Error('Provider error');
|
||||
},
|
||||
};
|
||||
|
||||
describe('Hook Data (Web SDK)', () => {
|
||||
let client: Client;
|
||||
let api: OpenFeatureAPI;
|
||||
|
||||
beforeEach(() => {
|
||||
api = OpenFeatureAPI.getInstance();
|
||||
api.clearHooks();
|
||||
api.setProvider(MOCK_PROVIDER);
|
||||
client = api.getClient();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
api.clearProviders();
|
||||
});
|
||||
|
||||
describe('Basic Hook Data Functionality', () => {
|
||||
it('should allow hooks to store and retrieve data across stages', () => {
|
||||
const hook = new TestHookWithData();
|
||||
client.addHooks(hook);
|
||||
|
||||
client.getBooleanValue('test-flag', false);
|
||||
|
||||
// Verify data was stored in before and retrieved in all other stages
|
||||
expect(hook.beforeData).toBe('testValue');
|
||||
expect(hook.afterData).toBe('testValue');
|
||||
expect(hook.finallyData).toBe('testValue');
|
||||
});
|
||||
|
||||
it('should support storing different data types', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const storedValues: any = {};
|
||||
|
||||
const hook: Hook = {
|
||||
before(hookContext: BeforeHookContext) {
|
||||
// Store various types
|
||||
hookContext.hookData.set('string', 'test');
|
||||
hookContext.hookData.set('number', 42);
|
||||
hookContext.hookData.set('boolean', true);
|
||||
hookContext.hookData.set('object', { key: 'value' });
|
||||
hookContext.hookData.set('array', [1, 2, 3]);
|
||||
hookContext.hookData.set('null', null);
|
||||
hookContext.hookData.set('undefined', undefined);
|
||||
},
|
||||
|
||||
after(hookContext: HookContext) {
|
||||
storedValues.string = hookContext.hookData.get('string');
|
||||
storedValues.number = hookContext.hookData.get('number');
|
||||
storedValues.boolean = hookContext.hookData.get('boolean');
|
||||
storedValues.object = hookContext.hookData.get('object');
|
||||
storedValues.array = hookContext.hookData.get('array');
|
||||
storedValues.null = hookContext.hookData.get('null');
|
||||
storedValues.undefined = hookContext.hookData.get('undefined');
|
||||
},
|
||||
};
|
||||
|
||||
client.addHooks(hook);
|
||||
client.getBooleanValue('test-flag', false);
|
||||
|
||||
expect(storedValues.string).toBe('test');
|
||||
expect(storedValues.number).toBe(42);
|
||||
expect(storedValues.boolean).toBe(true);
|
||||
expect(storedValues.object).toEqual({ key: 'value' });
|
||||
expect(storedValues.array).toEqual([1, 2, 3]);
|
||||
expect(storedValues.null).toBeNull();
|
||||
expect(storedValues.undefined).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle hook data in error scenarios', () => {
|
||||
api.setProvider(ERROR_PROVIDER);
|
||||
const hook = new TestHookWithData();
|
||||
client.addHooks(hook);
|
||||
|
||||
client.getBooleanValue('test-flag', false);
|
||||
|
||||
// Verify data was accessible in error and finally stages
|
||||
expect(hook.beforeData).toBe('testValue');
|
||||
expect(hook.errorData).toBe('testValue');
|
||||
expect(hook.finallyData).toBe('testValue');
|
||||
expect(hook.afterData).toBeUndefined(); // after should not run on error
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hook Data API', () => {
|
||||
it('should support has() method', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const hasResults: any = {};
|
||||
|
||||
const hook: Hook = {
|
||||
before(hookContext: BeforeHookContext) {
|
||||
hookContext.hookData.set('exists', 'value');
|
||||
hasResults.beforeExists = hookContext.hookData.has('exists');
|
||||
hasResults.beforeNotExists = hookContext.hookData.has('notExists');
|
||||
},
|
||||
|
||||
after(hookContext: HookContext) {
|
||||
hasResults.afterExists = hookContext.hookData.has('exists');
|
||||
hasResults.afterNotExists = hookContext.hookData.has('notExists');
|
||||
},
|
||||
};
|
||||
|
||||
client.addHooks(hook);
|
||||
client.getBooleanValue('test-flag', false);
|
||||
|
||||
expect(hasResults.beforeExists).toBe(true);
|
||||
expect(hasResults.beforeNotExists).toBe(false);
|
||||
expect(hasResults.afterExists).toBe(true);
|
||||
expect(hasResults.afterNotExists).toBe(false);
|
||||
});
|
||||
|
||||
it('should support delete() method', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const deleteResults: any = {};
|
||||
|
||||
const hook: Hook = {
|
||||
before(hookContext: BeforeHookContext) {
|
||||
hookContext.hookData.set('toDelete', 'value');
|
||||
deleteResults.hasBeforeDelete = hookContext.hookData.has('toDelete');
|
||||
deleteResults.deleteResult = hookContext.hookData.delete('toDelete');
|
||||
deleteResults.hasAfterDelete = hookContext.hookData.has('toDelete');
|
||||
deleteResults.deleteAgainResult = hookContext.hookData.delete('toDelete');
|
||||
},
|
||||
};
|
||||
|
||||
client.addHooks(hook);
|
||||
client.getBooleanValue('test-flag', false);
|
||||
|
||||
expect(deleteResults.hasBeforeDelete).toBe(true);
|
||||
expect(deleteResults.deleteResult).toBe(true);
|
||||
expect(deleteResults.hasAfterDelete).toBe(false);
|
||||
expect(deleteResults.deleteAgainResult).toBe(false);
|
||||
});
|
||||
|
||||
it('should support clear() method', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const clearResults: any = {};
|
||||
|
||||
const hook: Hook = {
|
||||
before(hookContext: BeforeHookContext) {
|
||||
hookContext.hookData.set('key1', 'value1');
|
||||
hookContext.hookData.set('key2', 'value2');
|
||||
hookContext.hookData.set('key3', 'value3');
|
||||
clearResults.hasBeforeClear = hookContext.hookData.has('key1');
|
||||
hookContext.hookData.clear();
|
||||
clearResults.hasAfterClear = hookContext.hookData.has('key1');
|
||||
},
|
||||
|
||||
after(hookContext: HookContext) {
|
||||
// Verify all data was cleared
|
||||
clearResults.afterHasKey1 = hookContext.hookData.has('key1');
|
||||
clearResults.afterHasKey2 = hookContext.hookData.has('key2');
|
||||
clearResults.afterHasKey3 = hookContext.hookData.has('key3');
|
||||
},
|
||||
};
|
||||
|
||||
client.addHooks(hook);
|
||||
client.getBooleanValue('test-flag', false);
|
||||
|
||||
expect(clearResults.hasBeforeClear).toBe(true);
|
||||
expect(clearResults.hasAfterClear).toBe(false);
|
||||
expect(clearResults.afterHasKey1).toBe(false);
|
||||
expect(clearResults.afterHasKey2).toBe(false);
|
||||
expect(clearResults.afterHasKey3).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hook Data Isolation', () => {
|
||||
it('should isolate data between different hook instances', () => {
|
||||
const hook1 = new IsolationTestHook('hook1');
|
||||
const hook2 = new IsolationTestHook('hook2');
|
||||
const hook3 = new IsolationTestHook('hook3');
|
||||
|
||||
client.addHooks(hook1, hook2, hook3);
|
||||
|
||||
expect(client.getBooleanValue('test-flag', false)).toBe(true);
|
||||
});
|
||||
|
||||
it('should isolate data between the same hook instance', () => {
|
||||
const hook = new IsolationTestHook('hook');
|
||||
|
||||
client.addHooks(hook, hook);
|
||||
|
||||
expect(client.getBooleanValue('test-flag', false)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not share data between different evaluations', () => {
|
||||
let firstEvalData: unknown;
|
||||
let secondEvalData: unknown;
|
||||
|
||||
const hook: Hook = {
|
||||
before(hookContext: BeforeHookContext) {
|
||||
// Check if data exists from previous evaluation
|
||||
const existingData = hookContext.hookData.get('evalData');
|
||||
if (existingData) {
|
||||
throw new Error('Hook data leaked between evaluations!');
|
||||
}
|
||||
hookContext.hookData.set('evalData', 'evaluation-specific');
|
||||
},
|
||||
|
||||
after(hookContext: HookContext) {
|
||||
if (!firstEvalData) {
|
||||
firstEvalData = hookContext.hookData.get('evalData');
|
||||
} else {
|
||||
secondEvalData = hookContext.hookData.get('evalData');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
client.addHooks(hook);
|
||||
|
||||
// First evaluation
|
||||
client.getBooleanValue('test-flag', false);
|
||||
// Second evaluation
|
||||
client.getBooleanValue('test-flag', false);
|
||||
|
||||
expect(firstEvalData).toBe('evaluation-specific');
|
||||
expect(secondEvalData).toBe('evaluation-specific');
|
||||
});
|
||||
|
||||
it('should isolate data between global, client, and invocation hooks', () => {
|
||||
const globalHook = new IsolationTestHook('global');
|
||||
const clientHook = new IsolationTestHook('client');
|
||||
const invocationHook = new IsolationTestHook('invocation');
|
||||
|
||||
api.addHooks(globalHook);
|
||||
client.addHooks(clientHook);
|
||||
|
||||
expect(client.getBooleanValue('test-flag', false, { hooks: [invocationHook] })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Use Cases', () => {
|
||||
it('should support timing measurements', () => {
|
||||
const timingHook = new TimingHook();
|
||||
client.addHooks(timingHook);
|
||||
|
||||
client.getBooleanValue('test-flag', false);
|
||||
|
||||
expect(timingHook.duration).toBeDefined();
|
||||
expect(timingHook.duration).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should support multi-stage validation accumulation', () => {
|
||||
let finalErrors: string[] = [];
|
||||
|
||||
const validationHook: Hook = {
|
||||
before(hookContext: BeforeHookContext) {
|
||||
hookContext.hookData.set('errors', []);
|
||||
|
||||
// Simulate validation
|
||||
const errors = hookContext.hookData.get('errors') as string[];
|
||||
if (!hookContext.context.userId) {
|
||||
errors.push('Missing userId');
|
||||
}
|
||||
if (!hookContext.context.region) {
|
||||
errors.push('Missing region');
|
||||
}
|
||||
},
|
||||
|
||||
finally(hookContext: HookContext) {
|
||||
finalErrors = (hookContext.hookData.get('errors') as string[]) || [];
|
||||
},
|
||||
};
|
||||
|
||||
client.addHooks(validationHook);
|
||||
client.getBooleanValue('test-flag', false, {});
|
||||
|
||||
expect(finalErrors).toContain('Missing userId');
|
||||
expect(finalErrors).toContain('Missing region');
|
||||
});
|
||||
|
||||
it('should support request correlation', () => {
|
||||
let correlationId: string | undefined;
|
||||
|
||||
const correlationHook: Hook = {
|
||||
before(hookContext: BeforeHookContext) {
|
||||
const id = `req-${Date.now()}-${Math.random()}`;
|
||||
hookContext.hookData.set('correlationId', id);
|
||||
},
|
||||
|
||||
after(hookContext: HookContext) {
|
||||
correlationId = hookContext.hookData.get('correlationId') as string;
|
||||
},
|
||||
};
|
||||
|
||||
client.addHooks(correlationHook);
|
||||
client.getBooleanValue('test-flag', false);
|
||||
|
||||
expect(correlationId).toBeDefined();
|
||||
expect(correlationId).toMatch(/^req-\d+-[\d.]+$/);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -20,7 +20,6 @@
|
|||
"versioning": "default"
|
||||
},
|
||||
"packages/react": {
|
||||
"release-as": "1.0.0",
|
||||
"release-type": "node",
|
||||
"prerelease": false,
|
||||
"bump-minor-pre-major": true,
|
||||
|
|
Loading…
Reference in New Issue