Compare commits
No commits in common. "main" and "core-v1.8.1" have entirely different histories.
main
...
core-v1.8.
|
@ -37,11 +37,8 @@ jobs:
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: npm run lint
|
run: npm run lint
|
||||||
|
|
||||||
- name: Test Jest Projects
|
- name: Test
|
||||||
run: npm run test:jest
|
run: npm run test
|
||||||
|
|
||||||
- name: Test Angular SDK
|
|
||||||
run: npm run test:angular
|
|
||||||
|
|
||||||
codecov-and-docs:
|
codecov-and-docs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
{
|
{
|
||||||
"packages/nest": "0.2.5",
|
"packages/nest": "0.2.5",
|
||||||
"packages/react": "1.0.1",
|
"packages/react": "1.0.0",
|
||||||
"packages/web": "1.6.1",
|
"packages/web": "1.5.0",
|
||||||
"packages/server": "1.19.0",
|
"packages/server": "1.18.0",
|
||||||
"packages/shared": "1.9.0",
|
"packages/shared": "1.8.1",
|
||||||
"packages/angular/projects/angular-sdk": "0.0.16"
|
"packages/angular/projects/angular-sdk": "0.0.15"
|
||||||
}
|
}
|
||||||
|
|
|
@ -190,6 +190,26 @@ 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
|
// 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,15 +1,10 @@
|
||||||
{
|
{
|
||||||
"name": "@openfeature/js",
|
"name": "@openfeature/js",
|
||||||
"engines": {
|
|
||||||
"npm": "^10.0.0"
|
|
||||||
},
|
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "OpenFeature SDK for JavaScript",
|
"description": "OpenFeature SDK for JavaScript",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "npm run test:jest && npm run test:angular",
|
"test": "jest --selectProjects=shared --selectProjects=server --selectProjects=web --selectProjects=react --selectProjects=angular --selectProjects=nest --silent",
|
||||||
"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-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-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",
|
"e2e": "npm run e2e-server && npm run e2e-web",
|
||||||
|
@ -59,12 +54,14 @@
|
||||||
"jest-environment-jsdom": "^29.7.0",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
"jest-environment-node": "^29.7.0",
|
"jest-environment-node": "^29.7.0",
|
||||||
"jest-junit": "^16.0.0",
|
"jest-junit": "^16.0.0",
|
||||||
|
"jest-preset-angular": "^14.2.4",
|
||||||
|
"ng-packagr": "^18.2.1",
|
||||||
"prettier": "^3.2.5",
|
"prettier": "^3.2.5",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"rollup": "^4.0.0",
|
"rollup": "^4.0.0",
|
||||||
"rollup-plugin-dts": "^6.1.1",
|
"rollup-plugin-dts": "^6.1.1",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"shx": "^0.4.0",
|
"shx": "^0.3.4",
|
||||||
"ts-jest": "^29.1.2",
|
"ts-jest": "^29.1.2",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
|
@ -72,6 +69,9 @@
|
||||||
"typescript": "^4.7.4",
|
"typescript": "^4.7.4",
|
||||||
"uuid": "^11.0.0"
|
"uuid": "^11.0.0"
|
||||||
},
|
},
|
||||||
|
"overrides": {
|
||||||
|
"typescript": "^4.7.4"
|
||||||
|
},
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/shared",
|
"packages/shared",
|
||||||
"packages/server",
|
"packages/server",
|
||||||
|
|
|
@ -10,7 +10,7 @@
|
||||||
"prefix": "lib",
|
"prefix": "lib",
|
||||||
"architect": {
|
"architect": {
|
||||||
"build": {
|
"build": {
|
||||||
"builder": "@angular/build:ng-packagr",
|
"builder": "@angular-devkit/build-angular:ng-packagr",
|
||||||
"options": {
|
"options": {
|
||||||
"project": "projects/angular-sdk/ng-package.json"
|
"project": "projects/angular-sdk/ng-package.json"
|
||||||
},
|
},
|
||||||
|
@ -32,15 +32,6 @@
|
||||||
"projects/angular-sdk/**/*.html"
|
"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,42 +7,37 @@
|
||||||
"lint": "ng lint",
|
"lint": "ng lint",
|
||||||
"lint:fix": "ng lint --fix",
|
"lint:fix": "ng lint --fix",
|
||||||
"watch": "ng build --watch --configuration development",
|
"watch": "ng build --watch --configuration development",
|
||||||
"test": "ng test",
|
"test": "jest",
|
||||||
"test:coverage": "ng test --no-watch --code-coverage",
|
|
||||||
"build": "ng build && npm run postbuild",
|
"build": "ng build && npm run postbuild",
|
||||||
"postbuild": "shx cp ./../../LICENSE ./dist/angular/LICENSE",
|
"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"
|
"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,
|
"private": true,
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-eslint/builder": "^20.1.1",
|
"@angular-devkit/build-angular": "^19.0.0",
|
||||||
"@angular-eslint/eslint-plugin": "^20.1.1",
|
"@angular-eslint/builder": "19.3.0",
|
||||||
"@angular-eslint/eslint-plugin-template": "^20.1.1",
|
"@angular-eslint/eslint-plugin": "19.3.0",
|
||||||
"@angular-eslint/schematics": "^20.1.1",
|
"@angular-eslint/eslint-plugin-template": "19.3.0",
|
||||||
"@angular-eslint/template-parser": "^20.1.1",
|
"@angular-eslint/schematics": "19.3.0",
|
||||||
"@angular/animations": "^20.1.1",
|
"@angular-eslint/template-parser": "19.3.0",
|
||||||
"@angular/build": "^20.1.1",
|
"@angular/animations": "^19.0.0",
|
||||||
"@angular/cli": "^20.1.1",
|
"@angular/cli": "^19.0.0",
|
||||||
"@angular/common": "^20.1.1",
|
"@angular/common": "^19.0.0",
|
||||||
"@angular/compiler": "^20.1.1",
|
"@angular/compiler": "^19.0.0",
|
||||||
"@angular/compiler-cli": "^20.1.1",
|
"@angular/compiler-cli": "^19.0.0",
|
||||||
"@angular/core": "^20.1.1",
|
"@angular/core": "^19.0.0",
|
||||||
"@angular/forms": "^20.1.1",
|
"@angular/forms": "^19.0.0",
|
||||||
"@angular/platform-browser": "^20.1.1",
|
"@angular/platform-browser": "^19.0.0",
|
||||||
"@angular/platform-browser-dynamic": "^20.1.1",
|
"@angular/platform-browser-dynamic": "^19.0.0",
|
||||||
"@angular/router": "^20.1.1",
|
"@angular/router": "^19.0.0",
|
||||||
"@typescript-eslint/eslint-plugin": "7.18.0",
|
"@typescript-eslint/eslint-plugin": "7.18.0",
|
||||||
"@typescript-eslint/parser": "7.18.0",
|
"@typescript-eslint/parser": "7.18.0",
|
||||||
"@vitest/browser": "^3.2.4",
|
|
||||||
"@vitest/coverage-v8": "^3.2.4",
|
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.0",
|
||||||
"jsdom": "^26.1.0",
|
"jest-preset-angular": "^14.2.4",
|
||||||
"ng-packagr": "^20.1.0",
|
"ng-packagr": "^19.0.0",
|
||||||
"playwright": "^1.53.2",
|
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.5.4",
|
||||||
"vitest": "^3.2.4",
|
|
||||||
"zone.js": "~0.15.0"
|
"zone.js": "~0.15.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,18 +0,0 @@
|
||||||
# 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,14 +1,6 @@
|
||||||
# Changelog
|
# 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)
|
## [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" />
|
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
|
||||||
</a>
|
</a>
|
||||||
<!-- x-release-please-start-version -->
|
<!-- x-release-please-start-version -->
|
||||||
<a href="https://github.com/open-feature/js-sdk/releases/tag/angular-sdk-v0.0.16">
|
<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.16&color=blue&style=for-the-badge" />
|
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.0.15&color=blue&style=for-the-badge" />
|
||||||
</a>
|
</a>
|
||||||
<!-- x-release-please-end -->
|
<!-- x-release-please-end -->
|
||||||
<br/>
|
<br/>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@openfeature/angular-sdk",
|
"name": "@openfeature/angular-sdk",
|
||||||
"version": "0.0.16",
|
"version": "0.0.15",
|
||||||
"description": "OpenFeature Angular SDK",
|
"description": "OpenFeature Angular SDK",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
@ -17,18 +17,18 @@
|
||||||
"prepack": "shx cp ./../../../../LICENSE ./LICENSE"
|
"prepack": "shx cp ./../../../../LICENSE ./LICENSE"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@angular/common": "^16.2.12 || ^17.3.0 || ^18.0.0 || ^19.0.0 || ^20.0.0",
|
"@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 || ^20.0.0",
|
"@angular/core": "^16.2.12 || ^17.3.0 || ^18.0.0 || ^19.0.0",
|
||||||
"@openfeature/web-sdk": "^1.4.1"
|
"@openfeature/web-sdk": "^1.4.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@openfeature/core": "^1.8.1",
|
"@openfeature/core": "*",
|
||||||
"@openfeature/web-sdk": "^1.5.0",
|
"@openfeature/web-sdk": "*",
|
||||||
"@angular/common": "^20.1.2",
|
"@angular/common": "^19.0.0",
|
||||||
"@angular/core": "^20.1.2"
|
"@angular/core": "^19.0.0"
|
||||||
},
|
},
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|
|
@ -253,7 +253,6 @@ describe('FeatureFlagDirective', () => {
|
||||||
await expectRenderedText(fixture, 'case-2', 'Flag On');
|
await expectRenderedText(fixture, 'case-2', 'Flag On');
|
||||||
|
|
||||||
await updateFlagValue(provider, false);
|
await updateFlagValue(provider, false);
|
||||||
fixture.detectChanges(); // Ensure change detection after flag update
|
|
||||||
await expectRenderedText(fixture, 'case-2', 'Flag Off');
|
await expectRenderedText(fixture, 'case-2', 'Flag Off');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -394,9 +393,7 @@ describe('FeatureFlagDirective', () => {
|
||||||
await waitForClientReady(client);
|
await waitForClientReady(client);
|
||||||
await expectRenderedText(fixture, 'case-6', 'Flag On');
|
await expectRenderedText(fixture, 'case-6', 'Flag On');
|
||||||
|
|
||||||
fixture.componentRef.setInput('specialFlagKey', 'new-test-flag');
|
fixture.componentInstance.specialFlagKey = 'new-test-flag';
|
||||||
await fixture.whenStable();
|
|
||||||
|
|
||||||
await expectRenderedText(fixture, 'case-6', 'Flag Off');
|
await expectRenderedText(fixture, 'case-6', 'Flag Off');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -448,9 +445,7 @@ describe('FeatureFlagDirective', () => {
|
||||||
);
|
);
|
||||||
await OpenFeature.setProviderAndWait(newDomain, newProvider);
|
await OpenFeature.setProviderAndWait(newDomain, newProvider);
|
||||||
|
|
||||||
fixture.componentRef.setInput('domain', newDomain);
|
fixture.componentInstance.domain = newDomain;
|
||||||
await fixture.whenStable();
|
|
||||||
|
|
||||||
await expectRenderedText(fixture, 'case-6', 'Flag Off');
|
await expectRenderedText(fixture, 'case-6', 'Flag Off');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -565,7 +560,8 @@ async function createTestingModule(config?: {
|
||||||
],
|
],
|
||||||
}).createComponent(TestComponent);
|
}).createComponent(TestComponent);
|
||||||
|
|
||||||
fixture.componentRef.setInput('domain', domain);
|
fixture.componentInstance.domain = domain;
|
||||||
|
fixture.detectChanges();
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
|
|
||||||
const client = OpenFeature.getClient(domain);
|
const client = OpenFeature.getClient(domain);
|
||||||
|
|
|
@ -8,7 +8,6 @@ import {
|
||||||
OnInit,
|
OnInit,
|
||||||
TemplateRef,
|
TemplateRef,
|
||||||
ViewContainerRef,
|
ViewContainerRef,
|
||||||
inject,
|
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import {
|
import {
|
||||||
Client,
|
Client,
|
||||||
|
@ -36,9 +35,6 @@ class FeatureFlagDirectiveContext<T extends FlagValue> {
|
||||||
selector: '[featureFlag]',
|
selector: '[featureFlag]',
|
||||||
})
|
})
|
||||||
export abstract class FeatureFlagDirective<T extends FlagValue> implements OnInit, OnDestroy, OnChanges {
|
export abstract class FeatureFlagDirective<T extends FlagValue> implements OnInit, OnDestroy, OnChanges {
|
||||||
protected _changeDetectorRef: ChangeDetectorRef;
|
|
||||||
protected _viewContainerRef: ViewContainerRef;
|
|
||||||
|
|
||||||
protected _featureFlagDefault: T;
|
protected _featureFlagDefault: T;
|
||||||
protected _featureFlagDomain: string | undefined;
|
protected _featureFlagDomain: string | undefined;
|
||||||
|
|
||||||
|
@ -68,7 +64,13 @@ export abstract class FeatureFlagDirective<T extends FlagValue> implements OnIni
|
||||||
protected _reconcilingTemplateRef: TemplateRef<FeatureFlagDirectiveContext<T>> | null;
|
protected _reconcilingTemplateRef: TemplateRef<FeatureFlagDirectiveContext<T>> | null;
|
||||||
protected _reconcilingViewRef: EmbeddedViewRef<unknown> | null;
|
protected _reconcilingViewRef: EmbeddedViewRef<unknown> | null;
|
||||||
|
|
||||||
protected constructor() {}
|
protected constructor(
|
||||||
|
protected _changeDetectorRef: ChangeDetectorRef,
|
||||||
|
protected _viewContainerRef: ViewContainerRef,
|
||||||
|
templateRef: TemplateRef<FeatureFlagDirectiveContext<T>>,
|
||||||
|
) {
|
||||||
|
this._thenTemplateRef = templateRef;
|
||||||
|
}
|
||||||
|
|
||||||
set featureFlagDomain(domain: string | undefined) {
|
set featureFlagDomain(domain: string | undefined) {
|
||||||
/**
|
/**
|
||||||
|
@ -230,10 +232,6 @@ export abstract class FeatureFlagDirective<T extends FlagValue> implements OnIni
|
||||||
selector: '[booleanFeatureFlag]',
|
selector: '[booleanFeatureFlag]',
|
||||||
})
|
})
|
||||||
export class BooleanFeatureFlagDirective extends FeatureFlagDirective<boolean> implements OnChanges {
|
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.
|
* The key of the boolean feature flag.
|
||||||
*/
|
*/
|
||||||
|
@ -244,8 +242,12 @@ export class BooleanFeatureFlagDirective extends FeatureFlagDirective<boolean> i
|
||||||
*/
|
*/
|
||||||
@Input({ required: true }) booleanFeatureFlagDefault: boolean;
|
@Input({ required: true }) booleanFeatureFlagDefault: boolean;
|
||||||
|
|
||||||
constructor() {
|
constructor(
|
||||||
super();
|
_changeDetectorRef: ChangeDetectorRef,
|
||||||
|
_viewContainerRef: ViewContainerRef,
|
||||||
|
templateRef: TemplateRef<FeatureFlagDirectiveContext<boolean>>,
|
||||||
|
) {
|
||||||
|
super(_changeDetectorRef, _viewContainerRef, templateRef);
|
||||||
}
|
}
|
||||||
|
|
||||||
override ngOnChanges() {
|
override ngOnChanges() {
|
||||||
|
@ -345,10 +347,6 @@ export class BooleanFeatureFlagDirective extends FeatureFlagDirective<boolean> i
|
||||||
selector: '[numberFeatureFlag]',
|
selector: '[numberFeatureFlag]',
|
||||||
})
|
})
|
||||||
export class NumberFeatureFlagDirective extends FeatureFlagDirective<number> implements OnChanges {
|
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.
|
* The key of the number feature flag.
|
||||||
*/
|
*/
|
||||||
|
@ -364,8 +362,12 @@ export class NumberFeatureFlagDirective extends FeatureFlagDirective<number> imp
|
||||||
*/
|
*/
|
||||||
@Input({ required: false }) numberFeatureFlagValue?: number;
|
@Input({ required: false }) numberFeatureFlagValue?: number;
|
||||||
|
|
||||||
constructor() {
|
constructor(
|
||||||
super();
|
_changeDetectorRef: ChangeDetectorRef,
|
||||||
|
_viewContainerRef: ViewContainerRef,
|
||||||
|
templateRef: TemplateRef<FeatureFlagDirectiveContext<number>>,
|
||||||
|
) {
|
||||||
|
super(_changeDetectorRef, _viewContainerRef, templateRef);
|
||||||
}
|
}
|
||||||
|
|
||||||
override ngOnChanges() {
|
override ngOnChanges() {
|
||||||
|
@ -465,10 +467,6 @@ export class NumberFeatureFlagDirective extends FeatureFlagDirective<number> imp
|
||||||
selector: '[stringFeatureFlag]',
|
selector: '[stringFeatureFlag]',
|
||||||
})
|
})
|
||||||
export class StringFeatureFlagDirective extends FeatureFlagDirective<string> implements OnChanges {
|
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.
|
* The key of the string feature flag.
|
||||||
*/
|
*/
|
||||||
|
@ -484,8 +482,12 @@ export class StringFeatureFlagDirective extends FeatureFlagDirective<string> imp
|
||||||
*/
|
*/
|
||||||
@Input({ required: false }) stringFeatureFlagValue?: string;
|
@Input({ required: false }) stringFeatureFlagValue?: string;
|
||||||
|
|
||||||
constructor() {
|
constructor(
|
||||||
super();
|
_changeDetectorRef: ChangeDetectorRef,
|
||||||
|
_viewContainerRef: ViewContainerRef,
|
||||||
|
templateRef: TemplateRef<FeatureFlagDirectiveContext<string>>,
|
||||||
|
) {
|
||||||
|
super(_changeDetectorRef, _viewContainerRef, templateRef);
|
||||||
}
|
}
|
||||||
|
|
||||||
override ngOnChanges() {
|
override ngOnChanges() {
|
||||||
|
@ -585,10 +587,6 @@ export class StringFeatureFlagDirective extends FeatureFlagDirective<string> imp
|
||||||
selector: '[objectFeatureFlag]',
|
selector: '[objectFeatureFlag]',
|
||||||
})
|
})
|
||||||
export class ObjectFeatureFlagDirective<T extends JsonValue> extends FeatureFlagDirective<T> implements OnChanges {
|
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.
|
* The key of the object feature flag.
|
||||||
*/
|
*/
|
||||||
|
@ -604,8 +602,12 @@ export class ObjectFeatureFlagDirective<T extends JsonValue> extends FeatureFlag
|
||||||
*/
|
*/
|
||||||
@Input({ required: false }) objectFeatureFlagValue?: T;
|
@Input({ required: false }) objectFeatureFlagValue?: T;
|
||||||
|
|
||||||
constructor() {
|
constructor(
|
||||||
super();
|
_changeDetectorRef: ChangeDetectorRef,
|
||||||
|
_viewContainerRef: ViewContainerRef,
|
||||||
|
templateRef: TemplateRef<FeatureFlagDirectiveContext<T>>,
|
||||||
|
) {
|
||||||
|
super(_changeDetectorRef, _viewContainerRef, templateRef);
|
||||||
}
|
}
|
||||||
|
|
||||||
override ngOnChanges() {
|
override ngOnChanges() {
|
||||||
|
|
|
@ -1,3 +0,0 @@
|
||||||
import { provideZonelessChangeDetection } from '@angular/core';
|
|
||||||
|
|
||||||
export default [provideZonelessChangeDetection()];
|
|
|
@ -3,20 +3,15 @@
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./out-tsc/spec",
|
"outDir": "./out-tsc/spec",
|
||||||
"types": [
|
"types": [
|
||||||
"vitest/globals",
|
"jest",
|
||||||
"node"
|
"node"
|
||||||
],
|
],
|
||||||
"paths": {
|
|
||||||
"angular": [
|
|
||||||
"./dist/angular"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"emitDecoratorMetadata": true
|
"emitDecoratorMetadata": true
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src/**/*.spec.ts",
|
"src/**/*.spec.ts",
|
||||||
"src/**/*.d.ts",
|
"src/**/*.d.ts",
|
||||||
"src/test-provider.ts"
|
"setup-jest.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
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,
|
"experimentalDecorators": true,
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"importHelpers": true,
|
"importHelpers": true,
|
||||||
"target": "ES2022",
|
"target": "ES2015",
|
||||||
"module": "ES2022",
|
"module": "ES2015",
|
||||||
"useDefineForClassFields": false,
|
"useDefineForClassFields": false,
|
||||||
"strictNullChecks": false,
|
"strictNullChecks": false,
|
||||||
"lib": [
|
"lib": [
|
||||||
|
|
|
@ -3,14 +3,13 @@
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./out-tsc/spec",
|
"outDir": "./out-tsc/spec",
|
||||||
"types": [
|
"types": [
|
||||||
"vitest/globals",
|
"jest"
|
||||||
"node"
|
|
||||||
],
|
],
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"emitDecoratorMetadata": true
|
"emitDecoratorMetadata": true
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"projects/angular-sdk/src/**/*.spec.ts",
|
"src/**/*.spec.ts",
|
||||||
"projects/angular-sdk/src/**/*.d.ts"
|
"src/**/*.d.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +0,0 @@
|
||||||
import { defineConfig } from 'vitest/config';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
test: {
|
|
||||||
coverage: {
|
|
||||||
provider: 'v8',
|
|
||||||
reporter: ['text', 'json', 'html'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
|
@ -1,12 +1,5 @@
|
||||||
# Changelog
|
# 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)
|
## [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" />
|
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
|
||||||
</a>
|
</a>
|
||||||
<!-- x-release-please-start-version -->
|
<!-- x-release-please-start-version -->
|
||||||
<a href="https://github.com/open-feature/js-sdk/releases/tag/react-sdk-v1.0.1">
|
<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.1&color=blue&style=for-the-badge" />
|
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v1.0.0&color=blue&style=for-the-badge" />
|
||||||
</a>
|
</a>
|
||||||
<!-- x-release-please-end -->
|
<!-- x-release-please-end -->
|
||||||
<br/>
|
<br/>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@openfeature/react-sdk",
|
"name": "@openfeature/react-sdk",
|
||||||
"version": "1.0.1",
|
"version": "1.0.0",
|
||||||
"description": "OpenFeature React SDK",
|
"description": "OpenFeature React SDK",
|
||||||
"main": "./dist/cjs/index.js",
|
"main": "./dist/cjs/index.js",
|
||||||
"files": [
|
"files": [
|
||||||
|
|
|
@ -8,7 +8,7 @@ import type {
|
||||||
JsonValue,
|
JsonValue,
|
||||||
} from '@openfeature/web-sdk';
|
} from '@openfeature/web-sdk';
|
||||||
import { ProviderEvents, ProviderStatus } from '@openfeature/web-sdk';
|
import { ProviderEvents, ProviderStatus } from '@openfeature/web-sdk';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
DEFAULT_OPTIONS,
|
DEFAULT_OPTIONS,
|
||||||
isEqual,
|
isEqual,
|
||||||
|
@ -287,7 +287,8 @@ function attachHandlersAndResolve<T extends FlagValue>(
|
||||||
const client = useOpenFeatureClient();
|
const client = useOpenFeatureClient();
|
||||||
const status = useOpenFeatureClientStatus();
|
const status = useOpenFeatureClientStatus();
|
||||||
const provider = useOpenFeatureProvider();
|
const provider = useOpenFeatureProvider();
|
||||||
const isFirstRender = useRef(true);
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
if (defaultedOptions.suspendUntilReady && status === ProviderStatus.NOT_READY) {
|
if (defaultedOptions.suspendUntilReady && status === ProviderStatus.NOT_READY) {
|
||||||
suspendUntilInitialized(provider, client);
|
suspendUntilInitialized(provider, client);
|
||||||
|
@ -297,30 +298,17 @@ function attachHandlersAndResolve<T extends FlagValue>(
|
||||||
suspendUntilReconciled(client);
|
suspendUntilReconciled(client);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [evaluationDetails, setEvaluationDetails] = useState<EvaluationDetails<T>>(() =>
|
const [evaluationDetails, setEvaluationDetails] = useState<EvaluationDetails<T>>(
|
||||||
resolver(client).call(client, flagKey, defaultValue, options),
|
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.
|
// Maintain a mutable reference to the evaluation details to have a up-to-date reference in the handlers.
|
||||||
const evaluationDetailsRef = useRef<EvaluationDetails<T>>(evaluationDetails);
|
const evaluationDetailsRef = useRef<EvaluationDetails<T>>(evaluationDetails);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
evaluationDetailsRef.current = evaluationDetails;
|
evaluationDetailsRef.current = evaluationDetails;
|
||||||
}, [evaluationDetails]);
|
}, [evaluationDetails]);
|
||||||
|
|
||||||
const updateEvaluationDetailsCallback = useCallback(() => {
|
const updateEvaluationDetailsCallback = () => {
|
||||||
const updatedEvaluationDetails = resolver(client).call(client, flagKey, defaultValue, options);
|
const updatedEvaluationDetails = resolver(client).call(client, flagKey, defaultValue, options);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -331,19 +319,15 @@ function attachHandlersAndResolve<T extends FlagValue>(
|
||||||
if (!isEqual(updatedEvaluationDetails.value, evaluationDetailsRef.current.value)) {
|
if (!isEqual(updatedEvaluationDetails.value, evaluationDetailsRef.current.value)) {
|
||||||
setEvaluationDetails(updatedEvaluationDetails);
|
setEvaluationDetails(updatedEvaluationDetails);
|
||||||
}
|
}
|
||||||
}, [client, flagKey, defaultValue, options, resolver]);
|
};
|
||||||
|
|
||||||
const configurationChangeCallback = useCallback<EventHandler<ClientProviderEvents.ConfigurationChanged>>(
|
const configurationChangeCallback: EventHandler<ClientProviderEvents.ConfigurationChanged> = (eventDetails) => {
|
||||||
(eventDetails) => {
|
|
||||||
if (shouldEvaluateFlag(flagKey, eventDetails?.flagsChanged)) {
|
if (shouldEvaluateFlag(flagKey, eventDetails?.flagsChanged)) {
|
||||||
updateEvaluationDetailsCallback();
|
updateEvaluationDetailsCallback();
|
||||||
}
|
}
|
||||||
},
|
};
|
||||||
[flagKey, updateEvaluationDetailsCallback],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
|
||||||
if (status === ProviderStatus.NOT_READY) {
|
if (status === ProviderStatus.NOT_READY) {
|
||||||
// update when the provider is ready
|
// update when the provider is ready
|
||||||
client.addHandler(ProviderEvents.Ready, updateEvaluationDetailsCallback, { signal: controller.signal });
|
client.addHandler(ProviderEvents.Ready, updateEvaluationDetailsCallback, { signal: controller.signal });
|
||||||
|
@ -364,14 +348,7 @@ function attachHandlersAndResolve<T extends FlagValue>(
|
||||||
// cleanup the handlers
|
// cleanup the handlers
|
||||||
controller.abort();
|
controller.abort();
|
||||||
};
|
};
|
||||||
}, [
|
}, []);
|
||||||
client,
|
|
||||||
status,
|
|
||||||
defaultedOptions.updateOnContextChanged,
|
|
||||||
defaultedOptions.updateOnConfigurationChanged,
|
|
||||||
updateEvaluationDetailsCallback,
|
|
||||||
configurationChangeCallback,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return evaluationDetails;
|
return evaluationDetails;
|
||||||
}
|
}
|
||||||
|
|
|
@ -924,124 +924,17 @@ describe('evaluation', () => {
|
||||||
OpenFeature.setContext(SUSPEND_OFF, { user: TARGETED_USER });
|
OpenFeature.setContext(SUSPEND_OFF, { user: TARGETED_USER });
|
||||||
});
|
});
|
||||||
|
|
||||||
// With the fix for useState initialization, the hook now immediately
|
// expect to see static value until we reconcile
|
||||||
// reflects provider state changes. This is intentional to handle cases
|
await waitFor(() => expect(screen.queryByText(STATIC_FLAG_VALUE_A)).toBeInTheDocument(), {
|
||||||
// where providers don't emit proper events.
|
timeout: DELAY / 2,
|
||||||
// The value updates immediately to the targeted value.
|
});
|
||||||
|
|
||||||
|
// make sure we updated after reconciling
|
||||||
await waitFor(() => expect(screen.queryByText(TARGETED_FLAG_VALUE)).toBeInTheDocument(), {
|
await waitFor(() => expect(screen.queryByText(TARGETED_FLAG_VALUE)).toBeInTheDocument(), {
|
||||||
timeout: DELAY * 2,
|
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', () => {
|
describe('context, hooks and options', () => {
|
||||||
|
|
|
@ -288,7 +288,7 @@ describe('OpenFeatureProvider', () => {
|
||||||
{ timeout: DELAY * 4 },
|
{ timeout: DELAY * 4 },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText('Will says aloha')).toBeInTheDocument();
|
expect(screen.getByText('Will says hi')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,27 +1,5 @@
|
||||||
# Changelog
|
# 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)
|
## [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" />
|
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
|
||||||
</a>
|
</a>
|
||||||
<!-- x-release-please-start-version -->
|
<!-- x-release-please-start-version -->
|
||||||
<a href="https://github.com/open-feature/js-sdk/releases/tag/server-sdk-v1.19.0">
|
<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.19.0&color=blue&style=for-the-badge" />
|
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v1.18.0&color=blue&style=for-the-badge" />
|
||||||
</a>
|
</a>
|
||||||
<!-- x-release-please-end -->
|
<!-- x-release-please-end -->
|
||||||
<br/>
|
<br/>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@openfeature/server-sdk",
|
"name": "@openfeature/server-sdk",
|
||||||
"version": "1.19.0",
|
"version": "1.18.0",
|
||||||
"description": "OpenFeature SDK for JavaScript",
|
"description": "OpenFeature SDK for JavaScript",
|
||||||
"main": "./dist/cjs/index.js",
|
"main": "./dist/cjs/index.js",
|
||||||
"files": [
|
"files": [
|
||||||
|
@ -48,9 +48,9 @@
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@openfeature/core": "^1.9.0"
|
"@openfeature/core": "^1.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@openfeature/core": "^1.9.0"
|
"@openfeature/core": "^1.7.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,7 +22,6 @@ import {
|
||||||
StandardResolutionReasons,
|
StandardResolutionReasons,
|
||||||
instantiateErrorByErrorCode,
|
instantiateErrorByErrorCode,
|
||||||
statusMatchesEvent,
|
statusMatchesEvent,
|
||||||
MapHookData,
|
|
||||||
} from '@openfeature/core';
|
} from '@openfeature/core';
|
||||||
import type { FlagEvaluationOptions } from '../../evaluation';
|
import type { FlagEvaluationOptions } from '../../evaluation';
|
||||||
import type { ProviderEvents } from '../../events';
|
import type { ProviderEvents } from '../../events';
|
||||||
|
@ -277,11 +276,9 @@ export class OpenFeatureClient implements Client {
|
||||||
|
|
||||||
const mergedContext = this.mergeContexts(invocationContext);
|
const mergedContext = this.mergeContexts(invocationContext);
|
||||||
|
|
||||||
// Create hook context instances for each hook (stable object references for the entire evaluation)
|
// this reference cannot change during the course of evaluation
|
||||||
// This ensures hooks can use WeakMaps with hookContext as keys across lifecycle methods
|
// it may be used as a key in WeakMaps
|
||||||
// NOTE: Uses the reversed order to reduce the number of times we have to calculate the index.
|
const hookContext: Readonly<HookContext> = {
|
||||||
const hookContexts = allHooksReversed.map<HookContext>(() =>
|
|
||||||
Object.freeze({
|
|
||||||
flagKey,
|
flagKey,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
flagValueType: flagType,
|
flagValueType: flagType,
|
||||||
|
@ -289,14 +286,12 @@ export class OpenFeatureClient implements Client {
|
||||||
providerMetadata: this._provider.metadata,
|
providerMetadata: this._provider.metadata,
|
||||||
context: mergedContext,
|
context: mergedContext,
|
||||||
logger: this._logger,
|
logger: this._logger,
|
||||||
hookData: new MapHookData(),
|
};
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
let evaluationDetails: EvaluationDetails<T>;
|
let evaluationDetails: EvaluationDetails<T>;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const frozenContext = await this.beforeHooks(allHooks, hookContexts, mergedContext, options);
|
const frozenContext = await this.beforeHooks(allHooks, hookContext, options);
|
||||||
|
|
||||||
this.shortCircuitIfNotReady();
|
this.shortCircuitIfNotReady();
|
||||||
|
|
||||||
|
@ -311,71 +306,53 @@ export class OpenFeatureClient implements Client {
|
||||||
|
|
||||||
if (resolutionDetails.errorCode) {
|
if (resolutionDetails.errorCode) {
|
||||||
const err = instantiateErrorByErrorCode(resolutionDetails.errorCode, resolutionDetails.errorMessage);
|
const err = instantiateErrorByErrorCode(resolutionDetails.errorCode, resolutionDetails.errorMessage);
|
||||||
await this.errorHooks(allHooksReversed, hookContexts, err, options);
|
await this.errorHooks(allHooksReversed, hookContext, err, options);
|
||||||
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err, resolutionDetails.flagMetadata);
|
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err, resolutionDetails.flagMetadata);
|
||||||
} else {
|
} else {
|
||||||
await this.afterHooks(allHooksReversed, hookContexts, resolutionDetails, options);
|
await this.afterHooks(allHooksReversed, hookContext, resolutionDetails, options);
|
||||||
evaluationDetails = resolutionDetails;
|
evaluationDetails = resolutionDetails;
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
await this.errorHooks(allHooksReversed, hookContexts, err, options);
|
await this.errorHooks(allHooksReversed, hookContext, err, options);
|
||||||
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err);
|
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.finallyHooks(allHooksReversed, hookContexts, evaluationDetails, options);
|
await this.finallyHooks(allHooksReversed, hookContext, evaluationDetails, options);
|
||||||
return evaluationDetails;
|
return evaluationDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async beforeHooks(
|
private async beforeHooks(hooks: Hook[], hookContext: HookContext, options: FlagEvaluationOptions) {
|
||||||
hooks: Hook[],
|
for (const hook of hooks) {
|
||||||
hookContexts: HookContext[],
|
// freeze the hookContext
|
||||||
mergedContext: EvaluationContext,
|
Object.freeze(hookContext);
|
||||||
options: FlagEvaluationOptions,
|
|
||||||
) {
|
|
||||||
let accumulatedContext = mergedContext;
|
|
||||||
|
|
||||||
for (const [index, hook] of hooks.entries()) {
|
// use Object.assign to avoid modification of frozen hookContext
|
||||||
const hookContextIndex = hooks.length - 1 - index; // reverse index for before hooks
|
Object.assign(hookContext.context, {
|
||||||
const hookContext = hookContexts[hookContextIndex];
|
...hookContext.context,
|
||||||
|
...(await hook?.before?.(hookContext, Object.freeze(options.hookHints))),
|
||||||
// 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.
|
// after before hooks, freeze the EvaluationContext.
|
||||||
return Object.freeze(accumulatedContext);
|
return Object.freeze(hookContext.context);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async afterHooks(
|
private async afterHooks(
|
||||||
hooks: Hook[],
|
hooks: Hook[],
|
||||||
hookContexts: HookContext[],
|
hookContext: HookContext,
|
||||||
evaluationDetails: EvaluationDetails<FlagValue>,
|
evaluationDetails: EvaluationDetails<FlagValue>,
|
||||||
options: FlagEvaluationOptions,
|
options: FlagEvaluationOptions,
|
||||||
) {
|
) {
|
||||||
// run "after" hooks sequentially
|
// run "after" hooks sequentially
|
||||||
for (const [index, hook] of hooks.entries()) {
|
for (const hook of hooks) {
|
||||||
const hookContext = hookContexts[index];
|
|
||||||
await hook?.after?.(hookContext, evaluationDetails, options.hookHints);
|
await hook?.after?.(hookContext, evaluationDetails, options.hookHints);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async errorHooks(hooks: Hook[], hookContexts: HookContext[], err: unknown, options: FlagEvaluationOptions) {
|
private async errorHooks(hooks: Hook[], hookContext: HookContext, err: unknown, options: FlagEvaluationOptions) {
|
||||||
// run "error" hooks sequentially
|
// run "error" hooks sequentially
|
||||||
for (const [index, hook] of hooks.entries()) {
|
for (const hook of hooks) {
|
||||||
try {
|
try {
|
||||||
const hookContext = hookContexts[index];
|
|
||||||
await hook?.error?.(hookContext, err, options.hookHints);
|
await hook?.error?.(hookContext, err, options.hookHints);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this._logger.error(`Unhandled error during 'error' hook: ${err}`);
|
this._logger.error(`Unhandled error during 'error' hook: ${err}`);
|
||||||
|
@ -389,14 +366,13 @@ export class OpenFeatureClient implements Client {
|
||||||
|
|
||||||
private async finallyHooks(
|
private async finallyHooks(
|
||||||
hooks: Hook[],
|
hooks: Hook[],
|
||||||
hookContexts: HookContext[],
|
hookContext: HookContext,
|
||||||
evaluationDetails: EvaluationDetails<FlagValue>,
|
evaluationDetails: EvaluationDetails<FlagValue>,
|
||||||
options: FlagEvaluationOptions,
|
options: FlagEvaluationOptions,
|
||||||
) {
|
) {
|
||||||
// run "finally" hooks sequentially
|
// run "finally" hooks sequentially
|
||||||
for (const [index, hook] of hooks.entries()) {
|
for (const hook of hooks) {
|
||||||
try {
|
try {
|
||||||
const hookContext = hookContexts[index];
|
|
||||||
await hook?.finally?.(hookContext, evaluationDetails, options.hookHints);
|
await hook?.finally?.(hookContext, evaluationDetails, options.hookHints);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this._logger.error(`Unhandled error during 'finally' hook: ${err}`);
|
this._logger.error(`Unhandled error during 'finally' hook: ${err}`);
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
import type { BaseHook, EvaluationContext, FlagValue } from '@openfeature/core';
|
import type { BaseHook, EvaluationContext, FlagValue } from '@openfeature/core';
|
||||||
|
|
||||||
export type Hook<TData = Record<string, unknown>> = BaseHook<
|
export type Hook = BaseHook<
|
||||||
FlagValue,
|
FlagValue,
|
||||||
TData,
|
|
||||||
Promise<EvaluationContext | void> | EvaluationContext | void,
|
Promise<EvaluationContext | void> | EvaluationContext | void,
|
||||||
Promise<void> | void
|
Promise<void> | void
|
||||||
>;
|
>;
|
||||||
|
|
|
@ -1,508 +0,0 @@
|
||||||
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,13 +1,5 @@
|
||||||
# Changelog
|
# 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.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",
|
"name": "@openfeature/core",
|
||||||
"version": "1.9.0",
|
"version": "1.8.1",
|
||||||
"description": "Shared OpenFeature JS components (server and web)",
|
"description": "Shared OpenFeature JS components (server and web)",
|
||||||
"main": "./dist/cjs/index.js",
|
"main": "./dist/cjs/index.js",
|
||||||
"files": [
|
"files": [
|
||||||
|
|
|
@ -18,4 +18,3 @@ export abstract class OpenFeatureError extends Error {
|
||||||
this.cause = options?.cause;
|
this.cause = options?.cause;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,72 +0,0 @@
|
||||||
/**
|
|
||||||
* 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,19 +1,14 @@
|
||||||
import type { BeforeHookContext, HookContext, HookHints } from './hooks';
|
import type { BeforeHookContext, HookContext, HookHints } from './hooks';
|
||||||
import type { EvaluationDetails, FlagValue } from '../evaluation';
|
import type { EvaluationDetails, FlagValue } from '../evaluation';
|
||||||
|
|
||||||
export interface BaseHook<
|
export interface BaseHook<T extends FlagValue = FlagValue, BeforeHookReturn = unknown, HooksReturn = unknown> {
|
||||||
T extends FlagValue = FlagValue,
|
|
||||||
TData = Record<string, unknown>,
|
|
||||||
BeforeHookReturn = unknown,
|
|
||||||
HooksReturn = unknown
|
|
||||||
> {
|
|
||||||
/**
|
/**
|
||||||
* Runs before flag values are resolved from the provider.
|
* Runs before flag values are resolved from the provider.
|
||||||
* If an EvaluationContext is returned, it will be merged with the pre-existing EvaluationContext.
|
* If an EvaluationContext is returned, it will be merged with the pre-existing EvaluationContext.
|
||||||
* @param hookContext
|
* @param hookContext
|
||||||
* @param hookHints
|
* @param hookHints
|
||||||
*/
|
*/
|
||||||
before?(hookContext: BeforeHookContext<T, TData>, hookHints?: HookHints): BeforeHookReturn;
|
before?(hookContext: BeforeHookContext, hookHints?: HookHints): BeforeHookReturn;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs after flag values are successfully resolved from the provider.
|
* Runs after flag values are successfully resolved from the provider.
|
||||||
|
@ -22,7 +17,7 @@ export interface BaseHook<
|
||||||
* @param hookHints
|
* @param hookHints
|
||||||
*/
|
*/
|
||||||
after?(
|
after?(
|
||||||
hookContext: Readonly<HookContext<T, TData>>,
|
hookContext: Readonly<HookContext<T>>,
|
||||||
evaluationDetails: EvaluationDetails<T>,
|
evaluationDetails: EvaluationDetails<T>,
|
||||||
hookHints?: HookHints,
|
hookHints?: HookHints,
|
||||||
): HooksReturn;
|
): HooksReturn;
|
||||||
|
@ -33,7 +28,7 @@ export interface BaseHook<
|
||||||
* @param error
|
* @param error
|
||||||
* @param hookHints
|
* @param hookHints
|
||||||
*/
|
*/
|
||||||
error?(hookContext: Readonly<HookContext<T, TData>>, error: unknown, hookHints?: HookHints): HooksReturn;
|
error?(hookContext: Readonly<HookContext<T>>, error: unknown, hookHints?: HookHints): HooksReturn;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs after all other hook stages, regardless of success or error.
|
* Runs after all other hook stages, regardless of success or error.
|
||||||
|
@ -42,9 +37,8 @@ export interface BaseHook<
|
||||||
* @param hookHints
|
* @param hookHints
|
||||||
*/
|
*/
|
||||||
finally?(
|
finally?(
|
||||||
hookContext: Readonly<HookContext<T, TData>>,
|
hookContext: Readonly<HookContext<T>>,
|
||||||
evaluationDetails: EvaluationDetails<T>,
|
evaluationDetails: EvaluationDetails<T>,
|
||||||
hookHints?: HookHints,
|
hookHints?: HookHints,
|
||||||
): HooksReturn;
|
): HooksReturn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,11 +2,10 @@ import type { ProviderMetadata } from '../provider';
|
||||||
import type { ClientMetadata } from '../client';
|
import type { ClientMetadata } from '../client';
|
||||||
import type { EvaluationContext, FlagValue, FlagValueType } from '../evaluation';
|
import type { EvaluationContext, FlagValue, FlagValueType } from '../evaluation';
|
||||||
import type { Logger } from '../logger';
|
import type { Logger } from '../logger';
|
||||||
import type { HookData } from './hook-data';
|
|
||||||
|
|
||||||
export type HookHints = Readonly<Record<string, unknown>>;
|
export type HookHints = Readonly<Record<string, unknown>>;
|
||||||
|
|
||||||
export interface HookContext<T extends FlagValue = FlagValue, TData = Record<string, unknown>> {
|
export interface HookContext<T extends FlagValue = FlagValue> {
|
||||||
readonly flagKey: string;
|
readonly flagKey: string;
|
||||||
readonly defaultValue: T;
|
readonly defaultValue: T;
|
||||||
readonly flagValueType: FlagValueType;
|
readonly flagValueType: FlagValueType;
|
||||||
|
@ -14,9 +13,8 @@ export interface HookContext<T extends FlagValue = FlagValue, TData = Record<str
|
||||||
readonly clientMetadata: ClientMetadata;
|
readonly clientMetadata: ClientMetadata;
|
||||||
readonly providerMetadata: ProviderMetadata;
|
readonly providerMetadata: ProviderMetadata;
|
||||||
readonly logger: Logger;
|
readonly logger: Logger;
|
||||||
readonly hookData: HookData<TData>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BeforeHookContext<T extends FlagValue = FlagValue, TData = Record<string, unknown>> extends HookContext<T, TData> {
|
export interface BeforeHookContext extends HookContext {
|
||||||
context: EvaluationContext;
|
context: EvaluationContext;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
export * from './hook';
|
export * from './hook';
|
||||||
export * from './hooks';
|
export * from './hooks';
|
||||||
export * from './evaluation-lifecycle';
|
export * from './evaluation-lifecycle';
|
||||||
export * from './hook-data';
|
|
||||||
|
|
|
@ -1,214 +0,0 @@
|
||||||
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,7 +2,6 @@ import { createEvaluationEvent } from '../src/telemetry/evaluation-event';
|
||||||
import { ErrorCode, StandardResolutionReasons, type EvaluationDetails } from '../src/evaluation/evaluation';
|
import { ErrorCode, StandardResolutionReasons, type EvaluationDetails } from '../src/evaluation/evaluation';
|
||||||
import type { HookContext } from '../src/hooks/hooks';
|
import type { HookContext } from '../src/hooks/hooks';
|
||||||
import { TelemetryAttribute, TelemetryFlagMetadata } from '../src/telemetry';
|
import { TelemetryAttribute, TelemetryFlagMetadata } from '../src/telemetry';
|
||||||
import { MapHookData } from '../src/hooks/hook-data';
|
|
||||||
|
|
||||||
describe('evaluationEvent', () => {
|
describe('evaluationEvent', () => {
|
||||||
const flagKey = 'test-flag';
|
const flagKey = 'test-flag';
|
||||||
|
@ -26,7 +25,6 @@ describe('evaluationEvent', () => {
|
||||||
error: jest.fn(),
|
error: jest.fn(),
|
||||||
warn: jest.fn(),
|
warn: jest.fn(),
|
||||||
},
|
},
|
||||||
hookData: new MapHookData(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
it('should return basic event body with mandatory fields', () => {
|
it('should return basic event body with mandatory fields', () => {
|
||||||
|
|
|
@ -1,26 +1,6 @@
|
||||||
# Changelog
|
# 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)
|
## [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" />
|
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
|
||||||
</a>
|
</a>
|
||||||
<!-- x-release-please-start-version -->
|
<!-- x-release-please-start-version -->
|
||||||
<a href="https://github.com/open-feature/js-sdk/releases/tag/web-sdk-v1.6.1">
|
<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.6.1&color=blue&style=for-the-badge" />
|
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v1.5.0&color=blue&style=for-the-badge" />
|
||||||
</a>
|
</a>
|
||||||
<!-- x-release-please-end -->
|
<!-- x-release-please-end -->
|
||||||
<br/>
|
<br/>
|
||||||
|
|
|
@ -1,10 +1,8 @@
|
||||||
{
|
{
|
||||||
"name": "@openfeature/web-sdk",
|
"name": "@openfeature/web-sdk",
|
||||||
"version": "1.6.1",
|
"version": "1.5.0",
|
||||||
"description": "OpenFeature SDK for Web",
|
"description": "OpenFeature SDK for Web",
|
||||||
"main": "./dist/cjs/index.js",
|
"main": "./dist/cjs/index.js",
|
||||||
"unpkg": "dist/global/index.min.js",
|
|
||||||
"jsdelivr": "dist/global/index.min.js",
|
|
||||||
"files": [
|
"files": [
|
||||||
"dist/"
|
"dist/"
|
||||||
],
|
],
|
||||||
|
@ -22,10 +20,8 @@
|
||||||
"clean": "shx rm -rf ./dist",
|
"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-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-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:rollup-types": "rollup -c ../../rollup.config.mjs",
|
||||||
"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",
|
"build": "npm run clean && npm run build:web-esm && npm run build:web-cjs && npm run build:rollup-types",
|
||||||
"postbuild": "shx cp ./../../package.esm.json ./dist/esm/package.json",
|
"postbuild": "shx cp ./../../package.esm.json ./dist/esm/package.json",
|
||||||
"current-version": "echo $npm_package_version",
|
"current-version": "echo $npm_package_version",
|
||||||
"prepack": "shx cp ./../../LICENSE ./LICENSE",
|
"prepack": "shx cp ./../../LICENSE ./LICENSE",
|
||||||
|
@ -50,9 +46,9 @@
|
||||||
},
|
},
|
||||||
"homepage": "https://github.com/open-feature/js-sdk#readme",
|
"homepage": "https://github.com/open-feature/js-sdk#readme",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@openfeature/core": "^1.9.0"
|
"@openfeature/core": "^1.8.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@openfeature/core": "^1.9.0"
|
"@openfeature/core": "^1.8.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,7 +22,6 @@ import {
|
||||||
StandardResolutionReasons,
|
StandardResolutionReasons,
|
||||||
instantiateErrorByErrorCode,
|
instantiateErrorByErrorCode,
|
||||||
statusMatchesEvent,
|
statusMatchesEvent,
|
||||||
MapHookData,
|
|
||||||
} from '@openfeature/core';
|
} from '@openfeature/core';
|
||||||
import type { FlagEvaluationOptions } from '../../evaluation';
|
import type { FlagEvaluationOptions } from '../../evaluation';
|
||||||
import type { ProviderEvents } from '../../events';
|
import type { ProviderEvents } from '../../events';
|
||||||
|
@ -232,11 +231,9 @@ export class OpenFeatureClient implements Client {
|
||||||
...this.apiContextAccessor(this?.options?.domain),
|
...this.apiContextAccessor(this?.options?.domain),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create hook context instances for each hook (stable object references for the entire evaluation)
|
// this reference cannot change during the course of evaluation
|
||||||
// This ensures hooks can use WeakMaps with hookContext as keys across lifecycle methods
|
// it may be used as a key in WeakMaps
|
||||||
// NOTE: Uses the reversed order to reduce the number of times we have to calculate the index.
|
const hookContext: Readonly<HookContext> = {
|
||||||
const hookContexts = allHooksReversed.map<HookContext>(() =>
|
|
||||||
Object.freeze({
|
|
||||||
flagKey,
|
flagKey,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
flagValueType: flagType,
|
flagValueType: flagType,
|
||||||
|
@ -244,14 +241,12 @@ export class OpenFeatureClient implements Client {
|
||||||
providerMetadata: this._provider.metadata,
|
providerMetadata: this._provider.metadata,
|
||||||
context,
|
context,
|
||||||
logger: this._logger,
|
logger: this._logger,
|
||||||
hookData: new MapHookData(),
|
};
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
let evaluationDetails: EvaluationDetails<T>;
|
let evaluationDetails: EvaluationDetails<T>;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.beforeHooks(allHooks, hookContexts, options);
|
this.beforeHooks(allHooks, hookContext, options);
|
||||||
|
|
||||||
this.shortCircuitIfNotReady();
|
this.shortCircuitIfNotReady();
|
||||||
|
|
||||||
|
@ -266,48 +261,45 @@ export class OpenFeatureClient implements Client {
|
||||||
|
|
||||||
if (resolutionDetails.errorCode) {
|
if (resolutionDetails.errorCode) {
|
||||||
const err = instantiateErrorByErrorCode(resolutionDetails.errorCode, resolutionDetails.errorMessage);
|
const err = instantiateErrorByErrorCode(resolutionDetails.errorCode, resolutionDetails.errorMessage);
|
||||||
this.errorHooks(allHooksReversed, hookContexts, err, options);
|
this.errorHooks(allHooksReversed, hookContext, err, options);
|
||||||
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err, resolutionDetails.flagMetadata);
|
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err, resolutionDetails.flagMetadata);
|
||||||
} else {
|
} else {
|
||||||
this.afterHooks(allHooksReversed, hookContexts, resolutionDetails, options);
|
this.afterHooks(allHooksReversed, hookContext, resolutionDetails, options);
|
||||||
evaluationDetails = resolutionDetails;
|
evaluationDetails = resolutionDetails;
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
this.errorHooks(allHooksReversed, hookContexts, err, options);
|
this.errorHooks(allHooksReversed, hookContext, err, options);
|
||||||
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err);
|
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err);
|
||||||
}
|
}
|
||||||
this.finallyHooks(allHooksReversed, hookContexts, evaluationDetails, options);
|
this.finallyHooks(allHooksReversed, hookContext, evaluationDetails, options);
|
||||||
return evaluationDetails;
|
return evaluationDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
private beforeHooks(hooks: Hook[], hookContexts: HookContext[], options: FlagEvaluationOptions) {
|
private beforeHooks(hooks: Hook[], hookContext: 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);
|
||||||
Object.freeze(hookContext.context);
|
Object.freeze(hookContext.context);
|
||||||
|
|
||||||
|
for (const hook of hooks) {
|
||||||
hook?.before?.(hookContext, Object.freeze(options.hookHints));
|
hook?.before?.(hookContext, Object.freeze(options.hookHints));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private afterHooks(
|
private afterHooks(
|
||||||
hooks: Hook[],
|
hooks: Hook[],
|
||||||
hookContexts: HookContext[],
|
hookContext: HookContext,
|
||||||
evaluationDetails: EvaluationDetails<FlagValue>,
|
evaluationDetails: EvaluationDetails<FlagValue>,
|
||||||
options: FlagEvaluationOptions,
|
options: FlagEvaluationOptions,
|
||||||
) {
|
) {
|
||||||
// run "after" hooks sequentially
|
// run "after" hooks sequentially
|
||||||
for (const [index, hook] of hooks.entries()) {
|
for (const hook of hooks) {
|
||||||
const hookContext = hookContexts[index];
|
|
||||||
hook?.after?.(hookContext, evaluationDetails, options.hookHints);
|
hook?.after?.(hookContext, evaluationDetails, options.hookHints);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private errorHooks(hooks: Hook[], hookContexts: HookContext[], err: unknown, options: FlagEvaluationOptions) {
|
private errorHooks(hooks: Hook[], hookContext: HookContext, err: unknown, options: FlagEvaluationOptions) {
|
||||||
// run "error" hooks sequentially
|
// run "error" hooks sequentially
|
||||||
for (const [index, hook] of hooks.entries()) {
|
for (const hook of hooks) {
|
||||||
try {
|
try {
|
||||||
const hookContext = hookContexts[index];
|
|
||||||
hook?.error?.(hookContext, err, options.hookHints);
|
hook?.error?.(hookContext, err, options.hookHints);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this._logger.error(`Unhandled error during 'error' hook: ${err}`);
|
this._logger.error(`Unhandled error during 'error' hook: ${err}`);
|
||||||
|
@ -321,14 +313,13 @@ export class OpenFeatureClient implements Client {
|
||||||
|
|
||||||
private finallyHooks(
|
private finallyHooks(
|
||||||
hooks: Hook[],
|
hooks: Hook[],
|
||||||
hookContexts: HookContext[],
|
hookContext: HookContext,
|
||||||
evaluationDetails: EvaluationDetails<FlagValue>,
|
evaluationDetails: EvaluationDetails<FlagValue>,
|
||||||
options: FlagEvaluationOptions,
|
options: FlagEvaluationOptions,
|
||||||
) {
|
) {
|
||||||
// run "finally" hooks sequentially
|
// run "finally" hooks sequentially
|
||||||
for (const [index, hook] of hooks.entries()) {
|
for (const hook of hooks) {
|
||||||
try {
|
try {
|
||||||
const hookContext = hookContexts[index];
|
|
||||||
hook?.finally?.(hookContext, evaluationDetails, options.hookHints);
|
hook?.finally?.(hookContext, evaluationDetails, options.hookHints);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this._logger.error(`Unhandled error during 'finally' hook: ${err}`);
|
this._logger.error(`Unhandled error during 'finally' hook: ${err}`);
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
import type { BaseHook, FlagValue } from '@openfeature/core';
|
import type { BaseHook, FlagValue } from '@openfeature/core';
|
||||||
|
|
||||||
export type Hook<TData = Record<string, unknown>> = BaseHook<FlagValue, TData, void, void>;
|
export type Hook = BaseHook<FlagValue, void, void>;
|
||||||
|
|
|
@ -1,436 +0,0 @@
|
||||||
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,6 +20,7 @@
|
||||||
"versioning": "default"
|
"versioning": "default"
|
||||||
},
|
},
|
||||||
"packages/react": {
|
"packages/react": {
|
||||||
|
"release-as": "1.0.0",
|
||||||
"release-type": "node",
|
"release-type": "node",
|
||||||
"prerelease": false,
|
"prerelease": false,
|
||||||
"bump-minor-pre-major": true,
|
"bump-minor-pre-major": true,
|
||||||
|
|
Loading…
Reference in New Issue