Compare commits
62 Commits
core-v1.8.
...
main
Author | SHA1 | Date |
---|---|---|
|
ee236396cd | |
|
9aab3d053b | |
|
3105595926 | |
|
1dbbd5161b | |
|
2287083de7 | |
|
04139affcb | |
|
1e330a2e13 | |
|
7df8a8eedc | |
|
3b3515b601 | |
|
63a1feb213 | |
|
845d24c5fe | |
|
dc1970e717 | |
|
e6e5ff3edf | |
|
40a512e212 | |
|
28850b7f6d | |
|
aa232a9d6a | |
|
07af3a9eda | |
|
d8bd93b6d5 | |
|
1b3ac12a35 | |
|
d3bca54979 | |
|
baf0dfbd5b | |
|
2fd944d317 | |
|
3598b5e822 | |
|
52ed31ad96 | |
|
3e6bcaef0b | |
|
66a3ce05af | |
|
1cb4a506aa | |
|
1f33453c23 | |
|
2838d6afce | |
|
aadc7a6636 | |
|
dae36bba1f | |
|
b6ea5884f2 | |
|
affdecb619 | |
|
f2121671fa | |
|
9f887a965c | |
|
b2784f53b8 | |
|
14761998bf | |
|
43b14b4869 | |
|
cd8017d537 | |
|
273af9e20b | |
|
8f9b1ae34f | |
|
cb14827639 | |
|
c00675c9bd | |
|
1e58d2b65e | |
|
59c2f5df76 | |
|
59b8fe904f | |
|
4fe8d87a2e | |
|
a259b9097b | |
|
d13adca9cd | |
|
42a3b39c24 | |
|
2f55a36dca | |
|
21a32ec92e | |
|
07a3d85742 | |
|
37bcf3cf48 | |
|
aff60f6776 | |
|
7864bd704c | |
|
191433e705 | |
|
24f1b230bf | |
|
5f85a56362 | |
|
9218e987a0 | |
|
7f81917226 | |
|
aafdb4382f |
|
@ -29,6 +29,6 @@ jobs:
|
|||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 20
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
cache: 'npm'
|
||||
|
|
|
@ -16,7 +16,7 @@ jobs:
|
|||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
node-version: 18
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install
|
||||
|
|
|
@ -16,9 +16,9 @@ jobs:
|
|||
strategy:
|
||||
matrix:
|
||||
node-version:
|
||||
- 18.x
|
||||
- 20.x
|
||||
- 22.x
|
||||
- 24.x
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
@ -37,8 +37,11 @@ jobs:
|
|||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Test
|
||||
run: npm run test
|
||||
- name: Test Jest Projects
|
||||
run: npm run test:jest
|
||||
|
||||
- name: Test Angular SDK
|
||||
run: npm run test:angular
|
||||
|
||||
codecov-and-docs:
|
||||
runs-on: ubuntu-latest
|
||||
|
@ -47,7 +50,7 @@ jobs:
|
|||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install
|
||||
|
@ -69,8 +72,7 @@ jobs:
|
|||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
# we need 'fetch' for this test, which is only in 18
|
||||
node-version: 18
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install
|
||||
|
|
|
@ -38,7 +38,7 @@ jobs:
|
|||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 20
|
||||
- name: Generate SBOM
|
||||
run: |
|
||||
npm install -g npm@^10.2.0
|
||||
|
@ -54,6 +54,7 @@ jobs:
|
|||
needs: release-please
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ needs.release-please.outputs.release_created }}
|
||||
environment: publish
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
|
@ -64,7 +65,7 @@ jobs:
|
|||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 20
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
cache: 'npm'
|
||||
- name: Build Packages
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
{
|
||||
"packages/nest": "0.2.2",
|
||||
"packages/react": "0.4.11",
|
||||
"packages/angular": "0.0.1-experimental",
|
||||
"packages/web": "1.4.1",
|
||||
"packages/server": "1.17.1",
|
||||
"packages/shared": "1.8.0",
|
||||
"packages/angular/projects/angular-sdk": "0.0.10"
|
||||
"packages/nest": "0.2.5",
|
||||
"packages/react": "1.0.1",
|
||||
"packages/web": "1.6.1",
|
||||
"packages/server": "1.19.0",
|
||||
"packages/shared": "1.9.0",
|
||||
"packages/angular/projects/angular-sdk": "0.0.16"
|
||||
}
|
||||
|
|
|
@ -190,26 +190,6 @@ export default {
|
|||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'angular',
|
||||
testEnvironment: 'jsdom',
|
||||
preset: 'jest-preset-angular',
|
||||
testMatch: ['<rootDir>/packages/angular/projects/angular-sdk/src/**/*.spec.{ts,tsx}'],
|
||||
setupFilesAfterEnv: ['<rootDir>/packages/angular/setup-jest.ts'],
|
||||
moduleNameMapper: {
|
||||
'@openfeature/core': '<rootDir>/packages/shared/src',
|
||||
'@openfeature/web-sdk': '<rootDir>/packages/web/src',
|
||||
},
|
||||
transform: {
|
||||
'^.+\\.(ts|js|html|svg)$': [
|
||||
'jest-preset-angular',
|
||||
{
|
||||
tsconfig: '<rootDir>/packages/angular/tsconfig.json',
|
||||
isolatedModules: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
],
|
||||
|
||||
// Use this configuration option to add custom reporters to Jest
|
||||
|
|
File diff suppressed because it is too large
Load Diff
23
package.json
23
package.json
|
@ -1,10 +1,15 @@
|
|||
{
|
||||
"name": "@openfeature/js",
|
||||
"engines": {
|
||||
"npm": "^10.0.0"
|
||||
},
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "OpenFeature SDK for JavaScript",
|
||||
"scripts": {
|
||||
"test": "jest --selectProjects=shared --selectProjects=server --selectProjects=web --selectProjects=react --selectProjects=angular --selectProjects=nest --silent",
|
||||
"test": "npm run test:jest && npm run test:angular",
|
||||
"test:jest": "jest --selectProjects=shared --selectProjects=server --selectProjects=web --selectProjects=react --selectProjects=nest --silent",
|
||||
"test:angular": "npm run test:coverage --workspace=packages/angular",
|
||||
"e2e-server": "git submodule update --init --recursive && shx cp test-harness/features/evaluation.feature packages/server/e2e/features && jest --selectProjects=server-e2e --verbose",
|
||||
"e2e-web": "git submodule update --init --recursive && shx cp test-harness/features/evaluation.feature packages/web/e2e/features && jest --selectProjects=web-e2e --verbose",
|
||||
"e2e": "npm run e2e-server && npm run e2e-web",
|
||||
|
@ -31,18 +36,15 @@
|
|||
"url": "https://github.com/open-feature/js-sdk/issues"
|
||||
},
|
||||
"homepage": "https://github.com/open-feature/js-sdk#readme",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-typescript": "^11.1.6",
|
||||
"@rollup/plugin-typescript": "^12.0.0",
|
||||
"@testing-library/jest-dom": "^6.4.2",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "^20.11.16",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^18.2.55",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"esbuild": "^0.24.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-import-resolver-alias": "^1.1.2",
|
||||
|
@ -57,14 +59,12 @@
|
|||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-environment-node": "^29.7.0",
|
||||
"jest-junit": "^16.0.0",
|
||||
"jest-preset-angular": "^14.2.4",
|
||||
"ng-packagr": "^18.2.1",
|
||||
"prettier": "^3.2.5",
|
||||
"react": "^18.2.0",
|
||||
"rollup": "^4.0.0",
|
||||
"rollup-plugin-dts": "^6.1.1",
|
||||
"rxjs": "~7.8.0",
|
||||
"shx": "^0.3.4",
|
||||
"shx": "^0.4.0",
|
||||
"ts-jest": "^29.1.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tslib": "^2.3.0",
|
||||
|
@ -72,9 +72,6 @@
|
|||
"typescript": "^4.7.4",
|
||||
"uuid": "^11.0.0"
|
||||
},
|
||||
"overrides": {
|
||||
"typescript": "^4.7.4"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/shared",
|
||||
"packages/server",
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
"prefix": "lib",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:ng-packagr",
|
||||
"builder": "@angular/build:ng-packagr",
|
||||
"options": {
|
||||
"project": "projects/angular-sdk/ng-package.json"
|
||||
},
|
||||
|
@ -32,6 +32,15 @@
|
|||
"projects/angular-sdk/**/*.html"
|
||||
]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular/build:unit-test",
|
||||
"options": {
|
||||
"tsConfig": "projects/angular-sdk/tsconfig.spec.json",
|
||||
"providersFile": "projects/angular-sdk/src/test-provider.ts",
|
||||
"runner": "vitest",
|
||||
"buildTarget": "::development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,37 +7,42 @@
|
|||
"lint": "ng lint",
|
||||
"lint:fix": "ng lint --fix",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "jest",
|
||||
"test": "ng test",
|
||||
"test:coverage": "ng test --no-watch --code-coverage",
|
||||
"build": "ng build && npm run postbuild",
|
||||
"postbuild": "shx cp ./../../LICENSE ./dist/angular/LICENSE",
|
||||
"publish-if-not-exists": "cp $NPM_CONFIG_USERCONFIG .npmrc && if [ \"$(npm --prefix dist/angular run current-published-version -s)\" = \"$(npm --prefix dist/angular run current-version -s)\" ]; then echo 'already published, skipping'; else cd dist/angular && npm publish --access public; fi"
|
||||
},
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^19.0.0",
|
||||
"@angular-eslint/builder": "18.4.3",
|
||||
"@angular-eslint/eslint-plugin": "18.4.3",
|
||||
"@angular-eslint/eslint-plugin-template": "18.4.3",
|
||||
"@angular-eslint/schematics": "18.4.3",
|
||||
"@angular-eslint/template-parser": "18.4.3",
|
||||
"@angular/animations": "^19.0.0",
|
||||
"@angular/cli": "^19.0.0",
|
||||
"@angular/common": "^19.0.0",
|
||||
"@angular/compiler": "^19.0.0",
|
||||
"@angular/compiler-cli": "^19.0.0",
|
||||
"@angular/core": "^19.0.0",
|
||||
"@angular/forms": "^19.0.0",
|
||||
"@angular/platform-browser": "^19.0.0",
|
||||
"@angular/platform-browser-dynamic": "^19.0.0",
|
||||
"@angular/router": "^19.0.0",
|
||||
"@angular-eslint/builder": "^20.1.1",
|
||||
"@angular-eslint/eslint-plugin": "^20.1.1",
|
||||
"@angular-eslint/eslint-plugin-template": "^20.1.1",
|
||||
"@angular-eslint/schematics": "^20.1.1",
|
||||
"@angular-eslint/template-parser": "^20.1.1",
|
||||
"@angular/animations": "^20.1.1",
|
||||
"@angular/build": "^20.1.1",
|
||||
"@angular/cli": "^20.1.1",
|
||||
"@angular/common": "^20.1.1",
|
||||
"@angular/compiler": "^20.1.1",
|
||||
"@angular/compiler-cli": "^20.1.1",
|
||||
"@angular/core": "^20.1.1",
|
||||
"@angular/forms": "^20.1.1",
|
||||
"@angular/platform-browser": "^20.1.1",
|
||||
"@angular/platform-browser-dynamic": "^20.1.1",
|
||||
"@angular/router": "^20.1.1",
|
||||
"@typescript-eslint/eslint-plugin": "7.18.0",
|
||||
"@typescript-eslint/parser": "7.18.0",
|
||||
"@vitest/browser": "^3.2.4",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"eslint": "^8.57.0",
|
||||
"jest-preset-angular": "^14.2.4",
|
||||
"ng-packagr": "^19.0.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"ng-packagr": "^20.1.0",
|
||||
"playwright": "^1.53.2",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"typescript": "^5.5.4",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^3.2.4",
|
||||
"zone.js": "~0.15.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
# This file is used by the build system to adjust CSS and JS output to support the specified browsers below.
|
||||
# For additional information regarding the format and rule options, please see:
|
||||
# https://github.com/browserslist/browserslist#queries
|
||||
|
||||
# For Angular's browser support policy, please see:
|
||||
# https://angular.dev/reference/versions#browser-support
|
||||
|
||||
# You can see what browsers were selected by your queries by running:
|
||||
# npx browserslist
|
||||
|
||||
Chrome >= 107
|
||||
ChromeAndroid >= 107
|
||||
Edge >= 107
|
||||
Firefox >= 104
|
||||
FirefoxAndroid >= 104
|
||||
Safari >= 16
|
||||
iOS >= 16
|
||||
|
|
@ -1,6 +1,53 @@
|
|||
# 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)
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
* **angular:** update docs ([#1200](https://github.com/open-feature/js-sdk/issues/1200)) ([b6ea588](https://github.com/open-feature/js-sdk/commit/b6ea5884f2ab9f4f94c8b258c4cf7268ea6dbeb8))
|
||||
|
||||
|
||||
## [0.0.14](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.13...angular-sdk-v0.0.14) (2025-05-25)
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
* **angular:** add license and url field to package.json ([b2784f5](https://github.com/open-feature/js-sdk/commit/b2784f53b85a11c58abb8e2a0f87a31890885c54))
|
||||
|
||||
|
||||
## [0.0.13](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.12...angular-sdk-v0.0.13) (2025-04-20)
|
||||
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
* fix readme typo ([#1174](https://github.com/open-feature/js-sdk/issues/1174)) ([21a32ec](https://github.com/open-feature/js-sdk/commit/21a32ec92ecde9ec43c9d72b5921035af13448d1))
|
||||
|
||||
## [0.0.12](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.11...angular-sdk-v0.0.12) (2025-04-11)
|
||||
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
* **angular:** add docs for setting evaluation context in angular ([#1170](https://github.com/open-feature/js-sdk/issues/1170)) ([24f1b23](https://github.com/open-feature/js-sdk/commit/24f1b230bf1d57971a336ac21b9ee46e8baf0cab))
|
||||
|
||||
|
||||
## [0.0.11](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.10...angular-sdk-v0.0.11) (2025-04-11)
|
||||
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
* **angular:** add option for initial context injection ([aafdb43](https://github.com/open-feature/js-sdk/commit/aafdb4382f113f96a649f5fc0cecadb4178ada67))
|
||||
|
||||
|
||||
## [0.0.10](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.9-experimental...angular-sdk-v0.0.10) (2025-02-13)
|
||||
|
||||
|
||||
|
|
|
@ -16,8 +16,8 @@
|
|||
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
|
||||
</a>
|
||||
<!-- x-release-please-start-version -->
|
||||
<a href="https://github.com/open-feature/js-sdk/releases/tag/angular-sdk-v0.0.10">
|
||||
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.0.10&color=blue&style=for-the-badge" />
|
||||
<a href="https://github.com/open-feature/js-sdk/releases/tag/angular-sdk-v0.0.16">
|
||||
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.0.16&color=blue&style=for-the-badge" />
|
||||
</a>
|
||||
<!-- x-release-please-end -->
|
||||
<br/>
|
||||
|
@ -44,21 +44,22 @@ In addition to the features provided by the [web sdk](https://openfeature.dev/do
|
|||
|
||||
- [Overview](#overview)
|
||||
- [Quick start](#quick-start)
|
||||
- [Requirements](#requirements)
|
||||
- [Install](#install)
|
||||
- [npm](#npm)
|
||||
- [yarn](#yarn)
|
||||
- [Required peer dependencies](#required-peer-dependencies)
|
||||
- [Usage](#usage)
|
||||
- [Module](#module)
|
||||
- [Minimal Example](#minimal-example)
|
||||
- [How to use](#how-to-use)
|
||||
- [Boolean Feature Flag](#boolean-feature-flag)
|
||||
- [Number Feature Flag](#number-feature-flag)
|
||||
- [String Feature Flag](#string-feature-flag)
|
||||
- [Object Feature Flag](#object-feature-flag)
|
||||
- [Opting-out of automatic re-rendering](#opting-out-of-automatic-re-rendering)
|
||||
- [Consuming the evaluation details](#consuming-the-evaluation-details)
|
||||
- [Requirements](#requirements)
|
||||
- [Install](#install)
|
||||
- [npm](#npm)
|
||||
- [yarn](#yarn)
|
||||
- [Required peer dependencies](#required-peer-dependencies)
|
||||
- [Usage](#usage)
|
||||
- [Module](#module)
|
||||
- [Minimal Example](#minimal-example)
|
||||
- [How to use](#how-to-use)
|
||||
- [Boolean Feature Flag](#boolean-feature-flag)
|
||||
- [Number Feature Flag](#number-feature-flag)
|
||||
- [String Feature Flag](#string-feature-flag)
|
||||
- [Object Feature Flag](#object-feature-flag)
|
||||
- [Opting-out of automatic re-rendering](#opting-out-of-automatic-re-rendering)
|
||||
- [Consuming the evaluation details](#consuming-the-evaluation-details)
|
||||
- [Setting Evaluation Context](#setting-evaluation-context)
|
||||
- [FAQ and troubleshooting](#faq-and-troubleshooting)
|
||||
- [Resources](#resources)
|
||||
|
||||
|
@ -113,7 +114,7 @@ import { OpenFeatureModule } from '@openfeature/angular-sdk';
|
|||
CommonModule,
|
||||
OpenFeatureModule.forRoot({
|
||||
provider: yourFeatureProvider,
|
||||
// domainBoundProviders are optional, mostly needed if more than one provider is needed
|
||||
// domainBoundProviders are optional, mostly needed if more than one provider is used in the application.
|
||||
domainBoundProviders: {
|
||||
domain1: new YourOpenFeatureProvider(),
|
||||
domain2: new YourOtherOpenFeatureProvider(),
|
||||
|
@ -281,6 +282,63 @@ This can be used to just render the flag value or details without conditional re
|
|||
</div>
|
||||
```
|
||||
|
||||
##### Setting evaluation context
|
||||
|
||||
To set the initial evaluation context, you can add the `context` parameter to the `OpenFeatureModule` configuration.
|
||||
This context can be either an object or a factory function that returns an `EvaluationContext`.
|
||||
|
||||
> [!TIP]
|
||||
> Updating the context can be done directly via the global OpenFeature API using `OpenFeature.setContext()`
|
||||
|
||||
Here’s how you can define and use the initial client evaluation context:
|
||||
|
||||
###### Using a static object
|
||||
|
||||
```typescript
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { OpenFeatureModule } from '@openfeature/angular-sdk';
|
||||
|
||||
const initialContext = {
|
||||
user: {
|
||||
id: 'user123',
|
||||
role: 'admin',
|
||||
}
|
||||
};
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
OpenFeatureModule.forRoot({
|
||||
provider: yourFeatureProvider,
|
||||
context: initialContext
|
||||
})
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
```
|
||||
|
||||
###### Using a factory function
|
||||
|
||||
```typescript
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { OpenFeatureModule, EvaluationContext } from '@openfeature/angular-sdk';
|
||||
|
||||
const contextFactory = (): EvaluationContext => loadContextFromLocalStorage();
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
OpenFeatureModule.forRoot({
|
||||
provider: yourFeatureProvider,
|
||||
context: contextFactory
|
||||
})
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
```
|
||||
|
||||
## FAQ and troubleshooting
|
||||
|
||||
> I can import things form the `@openfeature/angular-sdk`, `@openfeature/web-sdk`, and `@openfeature/core`; which should I use?
|
||||
|
@ -291,4 +349,4 @@ Avoid importing anything from `@openfeature/web-sdk` or `@openfeature/core`.
|
|||
|
||||
## Resources
|
||||
|
||||
- [Example repo](https://github.com/open-feature/angular-test-app)
|
||||
- [Example repo](https://github.com/open-feature/angular-test-app)
|
||||
|
|
|
@ -1,29 +1,34 @@
|
|||
{
|
||||
"name": "@openfeature/angular-sdk",
|
||||
"version": "0.0.10",
|
||||
"version": "0.0.16",
|
||||
"description": "OpenFeature Angular SDK",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/open-feature/js-sdk.git"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"bugs": {
|
||||
"url": "https://github.com/open-feature/js-sdk/issues"
|
||||
},
|
||||
"homepage": "https://github.com/open-feature/js-sdk#readme",
|
||||
"scripts": {
|
||||
"current-published-version": "npm show $npm_package_name@$npm_package_version version",
|
||||
"current-version": "echo $npm_package_version",
|
||||
"prepack": "shx cp ./../../../../LICENSE ./LICENSE"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "^16.2.12 || ^17.3.0 || ^18.0.0 || ^19.0.0",
|
||||
"@angular/core": "^16.2.12 || ^17.3.0 || ^18.0.0 || ^19.0.0",
|
||||
"@angular/common": "^16.2.12 || ^17.3.0 || ^18.0.0 || ^19.0.0 || ^20.0.0",
|
||||
"@angular/core": "^16.2.12 || ^17.3.0 || ^18.0.0 || ^19.0.0 || ^20.0.0",
|
||||
"@openfeature/web-sdk": "^1.4.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openfeature/core": "*",
|
||||
"@openfeature/web-sdk": "*",
|
||||
"@angular/common": "^19.0.0",
|
||||
"@angular/core": "^19.0.0"
|
||||
"@openfeature/core": "^1.8.1",
|
||||
"@openfeature/web-sdk": "^1.5.0",
|
||||
"@angular/common": "^20.1.2",
|
||||
"@angular/core": "^20.1.2"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"keywords": [
|
||||
|
|
|
@ -253,6 +253,7 @@ describe('FeatureFlagDirective', () => {
|
|||
await expectRenderedText(fixture, 'case-2', 'Flag On');
|
||||
|
||||
await updateFlagValue(provider, false);
|
||||
fixture.detectChanges(); // Ensure change detection after flag update
|
||||
await expectRenderedText(fixture, 'case-2', 'Flag Off');
|
||||
});
|
||||
});
|
||||
|
@ -393,7 +394,9 @@ describe('FeatureFlagDirective', () => {
|
|||
await waitForClientReady(client);
|
||||
await expectRenderedText(fixture, 'case-6', 'Flag On');
|
||||
|
||||
fixture.componentInstance.specialFlagKey = 'new-test-flag';
|
||||
fixture.componentRef.setInput('specialFlagKey', 'new-test-flag');
|
||||
await fixture.whenStable();
|
||||
|
||||
await expectRenderedText(fixture, 'case-6', 'Flag Off');
|
||||
});
|
||||
|
||||
|
@ -445,7 +448,9 @@ describe('FeatureFlagDirective', () => {
|
|||
);
|
||||
await OpenFeature.setProviderAndWait(newDomain, newProvider);
|
||||
|
||||
fixture.componentInstance.domain = newDomain;
|
||||
fixture.componentRef.setInput('domain', newDomain);
|
||||
await fixture.whenStable();
|
||||
|
||||
await expectRenderedText(fixture, 'case-6', 'Flag Off');
|
||||
});
|
||||
});
|
||||
|
@ -560,8 +565,7 @@ async function createTestingModule(config?: {
|
|||
],
|
||||
}).createComponent(TestComponent);
|
||||
|
||||
fixture.componentInstance.domain = domain;
|
||||
fixture.detectChanges();
|
||||
fixture.componentRef.setInput('domain', domain);
|
||||
await fixture.whenStable();
|
||||
|
||||
const client = OpenFeature.getClient(domain);
|
||||
|
|
|
@ -8,6 +8,7 @@ import {
|
|||
OnInit,
|
||||
TemplateRef,
|
||||
ViewContainerRef,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
Client,
|
||||
|
@ -35,6 +36,9 @@ class FeatureFlagDirectiveContext<T extends FlagValue> {
|
|||
selector: '[featureFlag]',
|
||||
})
|
||||
export abstract class FeatureFlagDirective<T extends FlagValue> implements OnInit, OnDestroy, OnChanges {
|
||||
protected _changeDetectorRef: ChangeDetectorRef;
|
||||
protected _viewContainerRef: ViewContainerRef;
|
||||
|
||||
protected _featureFlagDefault: T;
|
||||
protected _featureFlagDomain: string | undefined;
|
||||
|
||||
|
@ -64,13 +68,7 @@ export abstract class FeatureFlagDirective<T extends FlagValue> implements OnIni
|
|||
protected _reconcilingTemplateRef: TemplateRef<FeatureFlagDirectiveContext<T>> | null;
|
||||
protected _reconcilingViewRef: EmbeddedViewRef<unknown> | null;
|
||||
|
||||
protected constructor(
|
||||
protected _changeDetectorRef: ChangeDetectorRef,
|
||||
protected _viewContainerRef: ViewContainerRef,
|
||||
templateRef: TemplateRef<FeatureFlagDirectiveContext<T>>,
|
||||
) {
|
||||
this._thenTemplateRef = templateRef;
|
||||
}
|
||||
protected constructor() {}
|
||||
|
||||
set featureFlagDomain(domain: string | undefined) {
|
||||
/**
|
||||
|
@ -232,6 +230,10 @@ export abstract class FeatureFlagDirective<T extends FlagValue> implements OnIni
|
|||
selector: '[booleanFeatureFlag]',
|
||||
})
|
||||
export class BooleanFeatureFlagDirective extends FeatureFlagDirective<boolean> implements OnChanges {
|
||||
override _changeDetectorRef = inject(ChangeDetectorRef);
|
||||
override _viewContainerRef = inject(ViewContainerRef);
|
||||
override _thenTemplateRef = inject<TemplateRef<FeatureFlagDirectiveContext<boolean>>>(TemplateRef);
|
||||
|
||||
/**
|
||||
* The key of the boolean feature flag.
|
||||
*/
|
||||
|
@ -242,12 +244,8 @@ export class BooleanFeatureFlagDirective extends FeatureFlagDirective<boolean> i
|
|||
*/
|
||||
@Input({ required: true }) booleanFeatureFlagDefault: boolean;
|
||||
|
||||
constructor(
|
||||
_changeDetectorRef: ChangeDetectorRef,
|
||||
_viewContainerRef: ViewContainerRef,
|
||||
templateRef: TemplateRef<FeatureFlagDirectiveContext<boolean>>,
|
||||
) {
|
||||
super(_changeDetectorRef, _viewContainerRef, templateRef);
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
override ngOnChanges() {
|
||||
|
@ -347,6 +345,10 @@ export class BooleanFeatureFlagDirective extends FeatureFlagDirective<boolean> i
|
|||
selector: '[numberFeatureFlag]',
|
||||
})
|
||||
export class NumberFeatureFlagDirective extends FeatureFlagDirective<number> implements OnChanges {
|
||||
override _changeDetectorRef = inject(ChangeDetectorRef);
|
||||
override _viewContainerRef = inject(ViewContainerRef);
|
||||
override _thenTemplateRef = inject<TemplateRef<FeatureFlagDirectiveContext<number>>>(TemplateRef);
|
||||
|
||||
/**
|
||||
* The key of the number feature flag.
|
||||
*/
|
||||
|
@ -362,12 +364,8 @@ export class NumberFeatureFlagDirective extends FeatureFlagDirective<number> imp
|
|||
*/
|
||||
@Input({ required: false }) numberFeatureFlagValue?: number;
|
||||
|
||||
constructor(
|
||||
_changeDetectorRef: ChangeDetectorRef,
|
||||
_viewContainerRef: ViewContainerRef,
|
||||
templateRef: TemplateRef<FeatureFlagDirectiveContext<number>>,
|
||||
) {
|
||||
super(_changeDetectorRef, _viewContainerRef, templateRef);
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
override ngOnChanges() {
|
||||
|
@ -467,6 +465,10 @@ export class NumberFeatureFlagDirective extends FeatureFlagDirective<number> imp
|
|||
selector: '[stringFeatureFlag]',
|
||||
})
|
||||
export class StringFeatureFlagDirective extends FeatureFlagDirective<string> implements OnChanges {
|
||||
override _changeDetectorRef = inject(ChangeDetectorRef);
|
||||
override _viewContainerRef = inject(ViewContainerRef);
|
||||
override _thenTemplateRef = inject<TemplateRef<FeatureFlagDirectiveContext<string>>>(TemplateRef);
|
||||
|
||||
/**
|
||||
* The key of the string feature flag.
|
||||
*/
|
||||
|
@ -482,12 +484,8 @@ export class StringFeatureFlagDirective extends FeatureFlagDirective<string> imp
|
|||
*/
|
||||
@Input({ required: false }) stringFeatureFlagValue?: string;
|
||||
|
||||
constructor(
|
||||
_changeDetectorRef: ChangeDetectorRef,
|
||||
_viewContainerRef: ViewContainerRef,
|
||||
templateRef: TemplateRef<FeatureFlagDirectiveContext<string>>,
|
||||
) {
|
||||
super(_changeDetectorRef, _viewContainerRef, templateRef);
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
override ngOnChanges() {
|
||||
|
@ -587,6 +585,10 @@ export class StringFeatureFlagDirective extends FeatureFlagDirective<string> imp
|
|||
selector: '[objectFeatureFlag]',
|
||||
})
|
||||
export class ObjectFeatureFlagDirective<T extends JsonValue> extends FeatureFlagDirective<T> implements OnChanges {
|
||||
override _changeDetectorRef = inject(ChangeDetectorRef);
|
||||
override _viewContainerRef = inject(ViewContainerRef);
|
||||
override _thenTemplateRef = inject<TemplateRef<FeatureFlagDirectiveContext<T>>>(TemplateRef);
|
||||
|
||||
/**
|
||||
* The key of the object feature flag.
|
||||
*/
|
||||
|
@ -602,12 +604,8 @@ export class ObjectFeatureFlagDirective<T extends JsonValue> extends FeatureFlag
|
|||
*/
|
||||
@Input({ required: false }) objectFeatureFlagValue?: T;
|
||||
|
||||
constructor(
|
||||
_changeDetectorRef: ChangeDetectorRef,
|
||||
_viewContainerRef: ViewContainerRef,
|
||||
templateRef: TemplateRef<FeatureFlagDirectiveContext<T>>,
|
||||
) {
|
||||
super(_changeDetectorRef, _viewContainerRef, templateRef);
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
override ngOnChanges() {
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
import { InjectionToken, ModuleWithProviders, NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { OpenFeature, Provider } from '@openfeature/web-sdk';
|
||||
import { EvaluationContext, OpenFeature, Provider } from '@openfeature/web-sdk';
|
||||
|
||||
export type EvaluationContextFactory = () => EvaluationContext;
|
||||
|
||||
export interface OpenFeatureConfig {
|
||||
provider: Provider;
|
||||
domainBoundProviders?: Record<string, Provider>;
|
||||
context?: EvaluationContext | EvaluationContextFactory;
|
||||
}
|
||||
|
||||
export const OPEN_FEATURE_CONFIG_TOKEN = new InjectionToken<OpenFeatureConfig>('OPEN_FEATURE_CONFIG_TOKEN');
|
||||
|
@ -16,7 +19,9 @@ export const OPEN_FEATURE_CONFIG_TOKEN = new InjectionToken<OpenFeatureConfig>('
|
|||
})
|
||||
export class OpenFeatureModule {
|
||||
static forRoot(config: OpenFeatureConfig): ModuleWithProviders<OpenFeatureModule> {
|
||||
OpenFeature.setProvider(config.provider);
|
||||
const context = typeof config.context === 'function' ? config.context() : config.context;
|
||||
OpenFeature.setProvider(config.provider, context);
|
||||
|
||||
if (config.domainBoundProviders) {
|
||||
Object.entries(config.domainBoundProviders).map(([domain, provider]) =>
|
||||
OpenFeature.setProvider(domain, provider),
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
import { provideZonelessChangeDetection } from '@angular/core';
|
||||
|
||||
export default [provideZonelessChangeDetection()];
|
|
@ -3,15 +3,20 @@
|
|||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/spec",
|
||||
"types": [
|
||||
"jest",
|
||||
"vitest/globals",
|
||||
"node"
|
||||
],
|
||||
"paths": {
|
||||
"angular": [
|
||||
"./dist/angular"
|
||||
]
|
||||
},
|
||||
"esModuleInterop": true,
|
||||
"emitDecoratorMetadata": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts",
|
||||
"setup-jest.ts"
|
||||
"src/test-provider.ts"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
import 'jest-preset-angular/setup-jest';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing';
|
||||
|
||||
TestBed.initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting());
|
|
@ -23,8 +23,8 @@
|
|||
"experimentalDecorators": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"target": "ES2015",
|
||||
"module": "ES2015",
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"useDefineForClassFields": false,
|
||||
"strictNullChecks": false,
|
||||
"lib": [
|
||||
|
|
|
@ -3,13 +3,14 @@
|
|||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/spec",
|
||||
"types": [
|
||||
"jest"
|
||||
"vitest/globals",
|
||||
"node"
|
||||
],
|
||||
"esModuleInterop": true,
|
||||
"emitDecoratorMetadata": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
"projects/angular-sdk/src/**/*.spec.ts",
|
||||
"projects/angular-sdk/src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
},
|
||||
},
|
||||
});
|
|
@ -1,5 +1,34 @@
|
|||
# Changelog
|
||||
|
||||
## [0.2.5](https://github.com/open-feature/js-sdk/compare/nestjs-sdk-v0.2.4...nestjs-sdk-v0.2.5) (2025-05-27)
|
||||
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
* adds RequireFlagsEnabled decorator ([#1159](https://github.com/open-feature/js-sdk/issues/1159)) ([59b8fe9](https://github.com/open-feature/js-sdk/commit/59b8fe904f053e4aa3d0c72631af34183ff54dc7))
|
||||
|
||||
|
||||
## [0.2.4](https://github.com/open-feature/js-sdk/compare/nestjs-sdk-v0.2.3...nestjs-sdk-v0.2.4) (2025-04-20)
|
||||
|
||||
|
||||
### 🧹 Chore
|
||||
|
||||
* **nest:** allow nestjs version 11 ([#1176](https://github.com/open-feature/js-sdk/issues/1176)) ([42a3b39](https://github.com/open-feature/js-sdk/commit/42a3b39c2488002f249b37ce86794ef2f77eb31c))
|
||||
|
||||
## [0.2.3](https://github.com/open-feature/js-sdk/compare/nestjs-sdk-v0.2.2...nestjs-sdk-v0.2.3) (2025-04-11)
|
||||
|
||||
|
||||
### 🧹 Chore
|
||||
|
||||
* update sdk peer ([#1142](https://github.com/open-feature/js-sdk/issues/1142)) ([8bb6206](https://github.com/open-feature/js-sdk/commit/8bb620601e2b8dc7b62d717169b585bd1c886996))
|
||||
|
||||
|
||||
### Dependencies
|
||||
|
||||
* The following workspace dependencies were updated
|
||||
* devDependencies
|
||||
* @openfeature/server-sdk bumped from * to 1.18.0
|
||||
|
||||
## [0.2.2](https://github.com/open-feature/js-sdk/compare/nestjs-sdk-v0.2.1-experimental...nestjs-sdk-v0.2.2) (2024-10-29)
|
||||
|
||||
|
||||
|
|
|
@ -16,8 +16,8 @@
|
|||
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
|
||||
</a>
|
||||
<!-- x-release-please-start-version -->
|
||||
<a href="https://github.com/open-feature/js-sdk/releases/tag/nestjs-sdk-v0.2.2">
|
||||
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.2.2&color=blue&style=for-the-badge" />
|
||||
<a href="https://github.com/open-feature/js-sdk/releases/tag/nestjs-sdk-v0.2.5">
|
||||
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.2.5&color=blue&style=for-the-badge" />
|
||||
</a>
|
||||
<!-- x-release-please-end -->
|
||||
<br/>
|
||||
|
@ -50,7 +50,7 @@ Capabilities include:
|
|||
|
||||
### Requirements
|
||||
|
||||
- Node.js version 18+
|
||||
- Node.js version 20+
|
||||
- NestJS version 8+
|
||||
|
||||
### Install
|
||||
|
@ -72,10 +72,10 @@ yarn add @openfeature/nestjs-sdk @openfeature/server-sdk @openfeature/core
|
|||
|
||||
The following list contains the peer dependencies of `@openfeature/nestjs-sdk` with its expected and compatible versions:
|
||||
|
||||
* `@openfeature/server-sdk`: >=1.7.5
|
||||
* `@nestjs/common`: ^8.0.0 || ^9.0.0 || ^10.0.0
|
||||
* `@nestjs/core`: ^8.0.0 || ^9.0.0 || ^10.0.0
|
||||
* `rxjs`: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
- `@openfeature/server-sdk`: >=1.7.5
|
||||
- `@nestjs/common`: ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
|
||||
- `@nestjs/core`: ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
|
||||
- `rxjs`: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
|
||||
The minimum required version of `@openfeature/server-sdk` currently is `1.7.5`.
|
||||
|
||||
|
@ -152,6 +152,24 @@ export class OpenFeatureTestService {
|
|||
}
|
||||
```
|
||||
|
||||
#### Managing Controller or Route Access via Feature Flags
|
||||
|
||||
The `RequireFlagsEnabled` decorator can be used to manage access to a controller or route based on the enabled state of a feature flag. The decorator will throw an exception if the required feature flag(s) are not enabled.
|
||||
|
||||
```ts
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { RequireFlagsEnabled } from '@openfeature/nestjs-sdk';
|
||||
|
||||
@Controller()
|
||||
export class OpenFeatureController {
|
||||
@RequireFlagsEnabled({ flags: [{ flagKey: 'testBooleanFlag' }] })
|
||||
@Get('/welcome')
|
||||
public async welcome() {
|
||||
return 'Welcome to this OpenFeature-enabled NestJS app!';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Module additional information
|
||||
|
||||
### Flag evaluation context injection
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openfeature/nestjs-sdk",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.5",
|
||||
"description": "OpenFeature Nest.js SDK",
|
||||
"main": "./dist/cjs/index.js",
|
||||
"files": [
|
||||
|
@ -46,18 +46,18 @@
|
|||
},
|
||||
"homepage": "https://github.com/open-feature/js-sdk#readme",
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0",
|
||||
"@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0",
|
||||
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
|
||||
"@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
|
||||
"rxjs": "^6.0.0 || ^7.0.0 || 8.0.0",
|
||||
"@openfeature/server-sdk": "^1.17.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/common": "^10.3.6",
|
||||
"@nestjs/core": "^10.3.6",
|
||||
"@nestjs/platform-express": "^10.3.6",
|
||||
"@nestjs/testing": "^10.3.6",
|
||||
"@nestjs/common": "^11.0.20",
|
||||
"@nestjs/core": "^11.0.20",
|
||||
"@nestjs/platform-express": "^11.0.20",
|
||||
"@nestjs/testing": "^11.0.20",
|
||||
"@openfeature/core": "*",
|
||||
"@openfeature/server-sdk": "*",
|
||||
"@openfeature/server-sdk": "1.18.0",
|
||||
"@types/supertest": "^6.0.0",
|
||||
"supertest": "^7.0.0"
|
||||
}
|
||||
|
|
|
@ -1,16 +1,10 @@
|
|||
import { createParamDecorator, Inject } from '@nestjs/common';
|
||||
import type {
|
||||
EvaluationContext,
|
||||
EvaluationDetails,
|
||||
FlagValue,
|
||||
JsonValue} from '@openfeature/server-sdk';
|
||||
import {
|
||||
OpenFeature,
|
||||
Client,
|
||||
} from '@openfeature/server-sdk';
|
||||
import type { EvaluationContext, EvaluationDetails, FlagValue, JsonValue } from '@openfeature/server-sdk';
|
||||
import { Client } from '@openfeature/server-sdk';
|
||||
import { getOpenFeatureClientToken } from './open-feature.module';
|
||||
import type { Observable } from 'rxjs';
|
||||
import { from } from 'rxjs';
|
||||
import { getClientForEvaluation } from './utils';
|
||||
|
||||
/**
|
||||
* Options for injecting an OpenFeature client into a constructor.
|
||||
|
@ -56,16 +50,6 @@ interface FeatureProps<T extends FlagValue> {
|
|||
context?: EvaluationContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a domain scoped or the default OpenFeature client with the given context.
|
||||
* @param {string} domain The domain of the OpenFeature client.
|
||||
* @param {EvaluationContext} context The evaluation context of the client.
|
||||
* @returns {Client} The OpenFeature client.
|
||||
*/
|
||||
function getClientForEvaluation(domain?: string, context?: EvaluationContext) {
|
||||
return domain ? OpenFeature.getClient(domain, context) : OpenFeature.getClient(context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Route handler parameter decorator.
|
||||
*
|
||||
|
|
|
@ -2,5 +2,6 @@ export * from './open-feature.module';
|
|||
export * from './feature.decorator';
|
||||
export * from './evaluation-context-interceptor';
|
||||
export * from './context-factory';
|
||||
export * from './require-flags-enabled.decorator';
|
||||
// re-export the server-sdk so consumers can access that API from the nestjs-sdk
|
||||
export * from '@openfeature/server-sdk';
|
||||
|
|
|
@ -0,0 +1,104 @@
|
|||
import type { CallHandler, ExecutionContext, HttpException, NestInterceptor } from '@nestjs/common';
|
||||
import { applyDecorators, mixin, NotFoundException, UseInterceptors } from '@nestjs/common';
|
||||
import { getClientForEvaluation } from './utils';
|
||||
import type { EvaluationContext } from '@openfeature/server-sdk';
|
||||
import type { ContextFactory } from './context-factory';
|
||||
|
||||
type RequiredFlag = {
|
||||
flagKey: string;
|
||||
defaultValue?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Options for using one or more Boolean feature flags to control access to a Controller or Route.
|
||||
*/
|
||||
interface RequireFlagsEnabledProps {
|
||||
/**
|
||||
* The key and default value of the feature flag.
|
||||
* @see {@link Client#getBooleanValue}
|
||||
*/
|
||||
flags: RequiredFlag[];
|
||||
|
||||
/**
|
||||
* The exception to throw if any of the required feature flags are not enabled.
|
||||
* Defaults to a 404 Not Found exception.
|
||||
* @see {@link HttpException}
|
||||
* @default new NotFoundException(`Cannot ${req.method} ${req.url}`)
|
||||
*/
|
||||
exception?: HttpException;
|
||||
|
||||
/**
|
||||
* The domain of the OpenFeature client, if a domain scoped client should be used.
|
||||
* @see {@link OpenFeature#getClient}
|
||||
*/
|
||||
domain?: string;
|
||||
|
||||
/**
|
||||
* The {@link EvaluationContext} for evaluating the feature flag.
|
||||
* @see {@link OpenFeature#setContext}
|
||||
*/
|
||||
context?: EvaluationContext;
|
||||
|
||||
/**
|
||||
* A factory function for creating an OpenFeature {@link EvaluationContext} from Nest {@link ExecutionContext}.
|
||||
* For example, this can be used to get header info from an HTTP request or information from a gRPC call to be used in the {@link EvaluationContext}.
|
||||
* @see {@link ContextFactory}
|
||||
*/
|
||||
contextFactory?: ContextFactory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Controller or Route permissions handler decorator.
|
||||
*
|
||||
* Requires that the given feature flags are enabled for the request to be processed, else throws an exception.
|
||||
*
|
||||
* For example:
|
||||
* ```typescript
|
||||
* @RequireFlagsEnabled({
|
||||
* flags: [ // Required, an array of Boolean flags to check, with optional default values (defaults to false)
|
||||
* { flagKey: 'flagName' },
|
||||
* { flagKey: 'flagName2', defaultValue: true },
|
||||
* ],
|
||||
* exception: new ForbiddenException(), // Optional, defaults to a 404 Not Found Exception
|
||||
* domain: 'my-domain', // Optional, defaults to the default OpenFeature Client
|
||||
* context: { // Optional, defaults to the global OpenFeature Context
|
||||
* targetingKey: 'user-id',
|
||||
* },
|
||||
* contextFactory: (context: ExecutionContext) => { // Optional, defaults to the global OpenFeature Context. Takes precedence over the context option.
|
||||
* return {
|
||||
* targetingKey: context.switchToHttp().getRequest().headers['x-user-id'],
|
||||
* };
|
||||
* },
|
||||
* })
|
||||
* @Get('/')
|
||||
* public async handleGetRequest()
|
||||
* ```
|
||||
* @param {RequireFlagsEnabledProps} props The options for injecting the feature flag.
|
||||
* @returns {ClassDecorator & MethodDecorator} The decorator that can be used to require Boolean Feature Flags to be enabled for a controller or a specific route.
|
||||
*/
|
||||
export const RequireFlagsEnabled = (props: RequireFlagsEnabledProps): ClassDecorator & MethodDecorator =>
|
||||
applyDecorators(UseInterceptors(FlagsEnabledInterceptor(props)));
|
||||
|
||||
const FlagsEnabledInterceptor = (props: RequireFlagsEnabledProps) => {
|
||||
class FlagsEnabledInterceptor implements NestInterceptor {
|
||||
constructor() {}
|
||||
|
||||
async intercept(context: ExecutionContext, next: CallHandler) {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const evaluationContext = props.contextFactory ? await props.contextFactory(context) : props.context;
|
||||
const client = getClientForEvaluation(props.domain, evaluationContext);
|
||||
|
||||
for (const flag of props.flags) {
|
||||
const endpointAccessible = await client.getBooleanValue(flag.flagKey, flag.defaultValue ?? false);
|
||||
|
||||
if (!endpointAccessible) {
|
||||
throw props.exception || new NotFoundException(`Cannot ${req.method} ${req.url}`);
|
||||
}
|
||||
}
|
||||
|
||||
return next.handle();
|
||||
}
|
||||
}
|
||||
|
||||
return mixin(FlagsEnabledInterceptor);
|
||||
};
|
|
@ -0,0 +1,12 @@
|
|||
import type { Client, EvaluationContext } from '@openfeature/server-sdk';
|
||||
import { OpenFeature } from '@openfeature/server-sdk';
|
||||
|
||||
/**
|
||||
* Returns a domain scoped or the default OpenFeature client with the given context.
|
||||
* @param {string} domain The domain of the OpenFeature client.
|
||||
* @param {EvaluationContext} context The evaluation context of the client.
|
||||
* @returns {Client} The OpenFeature client.
|
||||
*/
|
||||
export function getClientForEvaluation(domain?: string, context?: EvaluationContext) {
|
||||
return domain ? OpenFeature.getClient(domain, context) : OpenFeature.getClient(context);
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
import { InMemoryProvider } from '@openfeature/server-sdk';
|
||||
import type { EvaluationContext } from '@openfeature/server-sdk';
|
||||
import type { ExecutionContext } from '@nestjs/common';
|
||||
import { OpenFeatureModule } from '../src';
|
||||
|
||||
|
@ -23,6 +24,17 @@ export const defaultProvider = new InMemoryProvider({
|
|||
variants: { default: { client: 'default' } },
|
||||
disabled: false,
|
||||
},
|
||||
testBooleanFlag2: {
|
||||
defaultVariant: 'default',
|
||||
variants: { default: false, enabled: true },
|
||||
disabled: false,
|
||||
contextEvaluator: (ctx: EvaluationContext) => {
|
||||
if (ctx.targetingKey === '123') {
|
||||
return 'enabled';
|
||||
}
|
||||
return 'default';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const providers = {
|
||||
|
|
|
@ -2,7 +2,12 @@ import type { TestingModule } from '@nestjs/testing';
|
|||
import { Test } from '@nestjs/testing';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import supertest from 'supertest';
|
||||
import { OpenFeatureController, OpenFeatureControllerContextScopedController, OpenFeatureTestService } from './test-app';
|
||||
import {
|
||||
OpenFeatureController,
|
||||
OpenFeatureContextScopedController,
|
||||
OpenFeatureRequireFlagsEnabledController,
|
||||
OpenFeatureTestService,
|
||||
} from './test-app';
|
||||
import { exampleContextFactory, getOpenFeatureDefaultTestModule } from './fixtures';
|
||||
import { OpenFeatureModule } from '../src';
|
||||
import { defaultProvider, providers } from './fixtures';
|
||||
|
@ -14,11 +19,9 @@ describe('OpenFeature SDK', () => {
|
|||
|
||||
beforeAll(async () => {
|
||||
moduleRef = await Test.createTestingModule({
|
||||
imports: [
|
||||
getOpenFeatureDefaultTestModule()
|
||||
],
|
||||
imports: [getOpenFeatureDefaultTestModule()],
|
||||
providers: [OpenFeatureTestService],
|
||||
controllers: [OpenFeatureController],
|
||||
controllers: [OpenFeatureController, OpenFeatureRequireFlagsEnabledController],
|
||||
}).compile();
|
||||
app = moduleRef.createNestApplication();
|
||||
app = await app.init();
|
||||
|
@ -112,7 +115,7 @@ describe('OpenFeature SDK', () => {
|
|||
});
|
||||
|
||||
describe('evaluation context service should', () => {
|
||||
it('inject the evaluation context from contex factory', async function() {
|
||||
it('inject the evaluation context from contex factory', async function () {
|
||||
const evaluationSpy = jest.spyOn(defaultProvider, 'resolveBooleanEvaluation');
|
||||
await supertest(app.getHttpServer())
|
||||
.get('/dynamic-context-in-service')
|
||||
|
@ -122,26 +125,77 @@ describe('OpenFeature SDK', () => {
|
|||
expect(evaluationSpy).toHaveBeenCalledWith('testBooleanFlag', false, { targetingKey: 'dynamic-user' }, {});
|
||||
});
|
||||
});
|
||||
|
||||
describe('require flags enabled decorator', () => {
|
||||
describe('OpenFeatureController', () => {
|
||||
it('should sucessfully return the response if the flag is enabled', async () => {
|
||||
await supertest(app.getHttpServer()).get('/flags-enabled').expect(200).expect('Get Boolean Flag Success!');
|
||||
});
|
||||
|
||||
it('should throw an exception if the flag is disabled', async () => {
|
||||
jest.spyOn(defaultProvider, 'resolveBooleanEvaluation').mockResolvedValueOnce({
|
||||
value: false,
|
||||
reason: 'DISABLED',
|
||||
});
|
||||
await supertest(app.getHttpServer()).get('/flags-enabled').expect(404);
|
||||
});
|
||||
|
||||
it('should throw a custom exception if the flag is disabled', async () => {
|
||||
jest.spyOn(defaultProvider, 'resolveBooleanEvaluation').mockResolvedValueOnce({
|
||||
value: false,
|
||||
reason: 'DISABLED',
|
||||
});
|
||||
await supertest(app.getHttpServer()).get('/flags-enabled-custom-exception').expect(403);
|
||||
});
|
||||
|
||||
it('should throw a custom exception if the flag is disabled with context', async () => {
|
||||
await supertest(app.getHttpServer())
|
||||
.get('/flags-enabled-custom-exception-with-context')
|
||||
.set('x-user-id', '123')
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenFeatureControllerRequireFlagsEnabled', () => {
|
||||
it('should allow access to the RequireFlagsEnabled controller with global context interceptor', async () => {
|
||||
await supertest(app.getHttpServer())
|
||||
.get('/require-flags-enabled')
|
||||
.set('x-user-id', '123')
|
||||
.expect(200)
|
||||
.expect('Hello, world!');
|
||||
});
|
||||
|
||||
it('should throw a 403 - Forbidden exception if user does not match targeting requirements', async () => {
|
||||
await supertest(app.getHttpServer()).get('/require-flags-enabled').set('x-user-id', 'not-123').expect(403);
|
||||
});
|
||||
|
||||
it('should throw a 403 - Forbidden exception if one of the flags is disabled', async () => {
|
||||
jest.spyOn(defaultProvider, 'resolveBooleanEvaluation').mockResolvedValueOnce({
|
||||
value: false,
|
||||
reason: 'DISABLED',
|
||||
});
|
||||
await supertest(app.getHttpServer()).get('/require-flags-enabled').set('x-user-id', '123').expect(403);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Without global context interceptor', () => {
|
||||
|
||||
let moduleRef: TestingModule;
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
|
||||
moduleRef = await Test.createTestingModule({
|
||||
imports: [
|
||||
OpenFeatureModule.forRoot({
|
||||
contextFactory: exampleContextFactory,
|
||||
defaultProvider,
|
||||
providers,
|
||||
useGlobalInterceptor: false
|
||||
useGlobalInterceptor: false,
|
||||
}),
|
||||
],
|
||||
providers: [OpenFeatureTestService],
|
||||
controllers: [OpenFeatureController, OpenFeatureControllerContextScopedController],
|
||||
controllers: [OpenFeatureController, OpenFeatureContextScopedController],
|
||||
}).compile();
|
||||
app = moduleRef.createNestApplication();
|
||||
app = await app.init();
|
||||
|
@ -158,7 +212,7 @@ describe('OpenFeature SDK', () => {
|
|||
});
|
||||
|
||||
describe('evaluation context service should', () => {
|
||||
it('inject empty context if no context interceptor is configured', async function() {
|
||||
it('inject empty context if no context interceptor is configured', async function () {
|
||||
const evaluationSpy = jest.spyOn(defaultProvider, 'resolveBooleanEvaluation');
|
||||
await supertest(app.getHttpServer())
|
||||
.get('/dynamic-context-in-service')
|
||||
|
@ -172,9 +226,26 @@ describe('OpenFeature SDK', () => {
|
|||
describe('With Controller bound Context interceptor', () => {
|
||||
it('should not use context if global context interceptor is not configured', async () => {
|
||||
const evaluationSpy = jest.spyOn(defaultProvider, 'resolveBooleanEvaluation');
|
||||
await supertest(app.getHttpServer()).get('/controller-context').set('x-user-id', '123').expect(200).expect('true');
|
||||
await supertest(app.getHttpServer())
|
||||
.get('/controller-context')
|
||||
.set('x-user-id', '123')
|
||||
.expect(200)
|
||||
.expect('true');
|
||||
expect(evaluationSpy).toHaveBeenCalledWith('testBooleanFlag', false, { targetingKey: '123' }, {});
|
||||
});
|
||||
});
|
||||
|
||||
describe('require flags enabled decorator', () => {
|
||||
it('should return a 404 - Not Found exception if the flag is disabled', async () => {
|
||||
jest.spyOn(providers.domainScopedClient, 'resolveBooleanEvaluation').mockResolvedValueOnce({
|
||||
value: false,
|
||||
reason: 'DISABLED',
|
||||
});
|
||||
await supertest(app.getHttpServer())
|
||||
.get('/controller-context/flags-enabled')
|
||||
.set('x-user-id', '123')
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,7 +1,14 @@
|
|||
import { Controller, Get, Injectable, UseInterceptors } from '@nestjs/common';
|
||||
import type { Observable} from 'rxjs';
|
||||
import { Controller, ForbiddenException, Get, Injectable, UseInterceptors } from '@nestjs/common';
|
||||
import type { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs';
|
||||
import { BooleanFeatureFlag, ObjectFeatureFlag, NumberFeatureFlag, OpenFeatureClient, StringFeatureFlag } from '../src';
|
||||
import {
|
||||
BooleanFeatureFlag,
|
||||
ObjectFeatureFlag,
|
||||
NumberFeatureFlag,
|
||||
OpenFeatureClient,
|
||||
StringFeatureFlag,
|
||||
RequireFlagsEnabled,
|
||||
} from '../src';
|
||||
import type { Client, EvaluationDetails, FlagValue } from '@openfeature/server-sdk';
|
||||
import { EvaluationContextInterceptor } from '../src';
|
||||
|
||||
|
@ -84,11 +91,40 @@ export class OpenFeatureController {
|
|||
public async handleDynamicContextInServiceRequest() {
|
||||
return this.testService.serviceMethodWithDynamicContext('testBooleanFlag');
|
||||
}
|
||||
|
||||
@RequireFlagsEnabled({
|
||||
flags: [{ flagKey: 'testBooleanFlag' }],
|
||||
})
|
||||
@Get('/flags-enabled')
|
||||
public async handleGuardedBooleanRequest() {
|
||||
return 'Get Boolean Flag Success!';
|
||||
}
|
||||
|
||||
@RequireFlagsEnabled({
|
||||
flags: [{ flagKey: 'testBooleanFlag' }],
|
||||
exception: new ForbiddenException(),
|
||||
})
|
||||
@Get('/flags-enabled-custom-exception')
|
||||
public async handleBooleanRequestWithCustomException() {
|
||||
return 'Get Boolean Flag Success!';
|
||||
}
|
||||
|
||||
@RequireFlagsEnabled({
|
||||
flags: [{ flagKey: 'testBooleanFlag2' }],
|
||||
exception: new ForbiddenException(),
|
||||
context: {
|
||||
targetingKey: 'user-id',
|
||||
},
|
||||
})
|
||||
@Get('/flags-enabled-custom-exception-with-context')
|
||||
public async handleBooleanRequestWithCustomExceptionAndContext() {
|
||||
return 'Get Boolean Flag Success!';
|
||||
}
|
||||
}
|
||||
|
||||
@Controller()
|
||||
@UseInterceptors(EvaluationContextInterceptor)
|
||||
export class OpenFeatureControllerContextScopedController {
|
||||
export class OpenFeatureContextScopedController {
|
||||
constructor(private testService: OpenFeatureTestService) {}
|
||||
|
||||
@Get('/controller-context')
|
||||
|
@ -101,4 +137,27 @@ export class OpenFeatureControllerContextScopedController {
|
|||
) {
|
||||
return feature.pipe(map((details) => this.testService.serviceMethod(details)));
|
||||
}
|
||||
|
||||
@RequireFlagsEnabled({
|
||||
flags: [{ flagKey: 'testBooleanFlag' }],
|
||||
domain: 'domainScopedClient',
|
||||
})
|
||||
@Get('/controller-context/flags-enabled')
|
||||
public async handleBooleanRequest() {
|
||||
return 'Get Boolean Flag Success!';
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('require-flags-enabled')
|
||||
@RequireFlagsEnabled({
|
||||
flags: [{ flagKey: 'testBooleanFlag', defaultValue: false }, { flagKey: 'testBooleanFlag2' }],
|
||||
exception: new ForbiddenException(),
|
||||
})
|
||||
export class OpenFeatureRequireFlagsEnabledController {
|
||||
constructor() {}
|
||||
|
||||
@Get('/')
|
||||
public async handleGetRequest() {
|
||||
return 'Hello, world!';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,20 @@
|
|||
# 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)
|
||||
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
* add polyfill for react use hook ([#1157](https://github.com/open-feature/js-sdk/issues/1157)) ([5afe61f](https://github.com/open-feature/js-sdk/commit/5afe61f9e351b037b04c93a1d81aee8016756748))
|
||||
* add support for abort controllers to event handlers ([#1151](https://github.com/open-feature/js-sdk/issues/1151)) ([6a22483](https://github.com/open-feature/js-sdk/commit/6a224830fa4e62fc30a7802536f6f6fc3f772038))
|
||||
|
||||
## [0.4.11](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.4.10...react-sdk-v0.4.11) (2025-02-07)
|
||||
|
||||
|
||||
|
|
|
@ -16,8 +16,8 @@
|
|||
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
|
||||
</a>
|
||||
<!-- x-release-please-start-version -->
|
||||
<a href="https://github.com/open-feature/js-sdk/releases/tag/react-sdk-v0.4.11">
|
||||
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.4.11&color=blue&style=for-the-badge" />
|
||||
<a href="https://github.com/open-feature/js-sdk/releases/tag/react-sdk-v1.0.1">
|
||||
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v1.0.1&color=blue&style=for-the-badge" />
|
||||
</a>
|
||||
<!-- x-release-please-end -->
|
||||
<br/>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openfeature/react-sdk",
|
||||
"version": "0.4.11",
|
||||
"version": "1.0.1",
|
||||
"description": "OpenFeature React SDK",
|
||||
"main": "./dist/cjs/index.js",
|
||||
"files": [
|
||||
|
@ -47,7 +47,7 @@
|
|||
},
|
||||
"homepage": "https://github.com/open-feature/js-sdk#readme",
|
||||
"peerDependencies": {
|
||||
"@openfeature/web-sdk": "^1.4.1",
|
||||
"@openfeature/web-sdk": "^1.5.0",
|
||||
"react": ">=16.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
@ -8,7 +8,7 @@ import type {
|
|||
JsonValue,
|
||||
} from '@openfeature/web-sdk';
|
||||
import { ProviderEvents, ProviderStatus } from '@openfeature/web-sdk';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
DEFAULT_OPTIONS,
|
||||
isEqual,
|
||||
|
@ -287,8 +287,7 @@ function attachHandlersAndResolve<T extends FlagValue>(
|
|||
const client = useOpenFeatureClient();
|
||||
const status = useOpenFeatureClientStatus();
|
||||
const provider = useOpenFeatureProvider();
|
||||
|
||||
const controller = new AbortController();
|
||||
const isFirstRender = useRef(true);
|
||||
|
||||
if (defaultedOptions.suspendUntilReady && status === ProviderStatus.NOT_READY) {
|
||||
suspendUntilInitialized(provider, client);
|
||||
|
@ -298,9 +297,22 @@ function attachHandlersAndResolve<T extends FlagValue>(
|
|||
suspendUntilReconciled(client);
|
||||
}
|
||||
|
||||
const [evaluationDetails, setEvaluationDetails] = useState<EvaluationDetails<T>>(
|
||||
const [evaluationDetails, setEvaluationDetails] = useState<EvaluationDetails<T>>(() =>
|
||||
resolver(client).call(client, flagKey, defaultValue, options),
|
||||
);
|
||||
|
||||
// Re-evaluate when dependencies change (handles prop changes like flagKey), or if during a re-render, we have detected a change in the evaluated value
|
||||
useEffect(() => {
|
||||
if (isFirstRender.current) {
|
||||
isFirstRender.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const newDetails = resolver(client).call(client, flagKey, defaultValue, options);
|
||||
if (!isEqual(newDetails.value, evaluationDetails.value)) {
|
||||
setEvaluationDetails(newDetails);
|
||||
}
|
||||
}, [client, flagKey, defaultValue, options, resolver, evaluationDetails]);
|
||||
|
||||
// Maintain a mutable reference to the evaluation details to have a up-to-date reference in the handlers.
|
||||
const evaluationDetailsRef = useRef<EvaluationDetails<T>>(evaluationDetails);
|
||||
|
@ -308,7 +320,7 @@ function attachHandlersAndResolve<T extends FlagValue>(
|
|||
evaluationDetailsRef.current = evaluationDetails;
|
||||
}, [evaluationDetails]);
|
||||
|
||||
const updateEvaluationDetailsCallback = () => {
|
||||
const updateEvaluationDetailsCallback = useCallback(() => {
|
||||
const updatedEvaluationDetails = resolver(client).call(client, flagKey, defaultValue, options);
|
||||
|
||||
/**
|
||||
|
@ -319,15 +331,19 @@ function attachHandlersAndResolve<T extends FlagValue>(
|
|||
if (!isEqual(updatedEvaluationDetails.value, evaluationDetailsRef.current.value)) {
|
||||
setEvaluationDetails(updatedEvaluationDetails);
|
||||
}
|
||||
};
|
||||
}, [client, flagKey, defaultValue, options, resolver]);
|
||||
|
||||
const configurationChangeCallback: EventHandler<ClientProviderEvents.ConfigurationChanged> = (eventDetails) => {
|
||||
if (shouldEvaluateFlag(flagKey, eventDetails?.flagsChanged)) {
|
||||
updateEvaluationDetailsCallback();
|
||||
}
|
||||
};
|
||||
const configurationChangeCallback = useCallback<EventHandler<ClientProviderEvents.ConfigurationChanged>>(
|
||||
(eventDetails) => {
|
||||
if (shouldEvaluateFlag(flagKey, eventDetails?.flagsChanged)) {
|
||||
updateEvaluationDetailsCallback();
|
||||
}
|
||||
},
|
||||
[flagKey, updateEvaluationDetailsCallback],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
if (status === ProviderStatus.NOT_READY) {
|
||||
// update when the provider is ready
|
||||
client.addHandler(ProviderEvents.Ready, updateEvaluationDetailsCallback, { signal: controller.signal });
|
||||
|
@ -348,7 +364,14 @@ function attachHandlersAndResolve<T extends FlagValue>(
|
|||
// cleanup the handlers
|
||||
controller.abort();
|
||||
};
|
||||
}, []);
|
||||
}, [
|
||||
client,
|
||||
status,
|
||||
defaultedOptions.updateOnContextChanged,
|
||||
defaultedOptions.updateOnConfigurationChanged,
|
||||
updateEvaluationDetailsCallback,
|
||||
configurationChangeCallback,
|
||||
]);
|
||||
|
||||
return evaluationDetails;
|
||||
}
|
||||
|
|
|
@ -924,17 +924,124 @@ describe('evaluation', () => {
|
|||
OpenFeature.setContext(SUSPEND_OFF, { user: TARGETED_USER });
|
||||
});
|
||||
|
||||
// expect to see static value until we reconcile
|
||||
await waitFor(() => expect(screen.queryByText(STATIC_FLAG_VALUE_A)).toBeInTheDocument(), {
|
||||
timeout: DELAY / 2,
|
||||
});
|
||||
|
||||
// make sure we updated after reconciling
|
||||
// With the fix for useState initialization, the hook now immediately
|
||||
// reflects provider state changes. This is intentional to handle cases
|
||||
// where providers don't emit proper events.
|
||||
// The value updates immediately to the targeted value.
|
||||
await waitFor(() => expect(screen.queryByText(TARGETED_FLAG_VALUE)).toBeInTheDocument(), {
|
||||
timeout: DELAY * 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('re-render behavior when flag values change without provider events', ()=> {
|
||||
it('should reflect provider state changes on re-render even without provider events', async () => {
|
||||
let providerValue = 'initial-value';
|
||||
|
||||
class SilentUpdateProvider extends InMemoryProvider {
|
||||
resolveBooleanEvaluation() {
|
||||
return {
|
||||
value: true,
|
||||
variant: 'on',
|
||||
reason: StandardResolutionReasons.STATIC,
|
||||
};
|
||||
}
|
||||
|
||||
resolveStringEvaluation() {
|
||||
return {
|
||||
value: providerValue,
|
||||
variant: providerValue,
|
||||
reason: StandardResolutionReasons.STATIC,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const provider = new SilentUpdateProvider({});
|
||||
await OpenFeature.setProviderAndWait('test', provider);
|
||||
|
||||
// The triggerRender prop forces a re-render
|
||||
const TestComponent = ({ triggerRender }: { triggerRender: number }) => {
|
||||
const { value } = useFlag('test-flag', 'default');
|
||||
return <div data-testid="flag-value" data-render-count={triggerRender}>{value}</div>;
|
||||
};
|
||||
|
||||
const WrapperComponent = () => {
|
||||
const [renderCount, setRenderCount] = useState(0);
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setRenderCount(c => c + 1)}>Force Re-render</button>
|
||||
<TestComponent triggerRender={renderCount} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const { getByText } = render(
|
||||
<OpenFeatureProvider client={OpenFeature.getClient('test')}>
|
||||
<WrapperComponent />
|
||||
</OpenFeatureProvider>
|
||||
);
|
||||
|
||||
// Initial value should be rendered
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('flag-value')).toHaveTextContent('initial-value');
|
||||
});
|
||||
|
||||
// Change the provider's internal state (without emitting events)
|
||||
providerValue = 'updated-value';
|
||||
|
||||
// Force a re-render of the component
|
||||
act(() => {
|
||||
getByText('Force Re-render').click();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('flag-value')).toHaveTextContent('updated-value');
|
||||
});
|
||||
});
|
||||
|
||||
it('should update flag value when flag key prop changes without provider events', async () => {
|
||||
const provider = new InMemoryProvider({
|
||||
'flag-a': {
|
||||
disabled: false,
|
||||
variants: { on: 'value-a' },
|
||||
defaultVariant: 'on',
|
||||
},
|
||||
'flag-b': {
|
||||
disabled: false,
|
||||
variants: { on: 'value-b' },
|
||||
defaultVariant: 'on',
|
||||
},
|
||||
});
|
||||
|
||||
await OpenFeature.setProviderAndWait(EVALUATION, provider);
|
||||
|
||||
const TestComponent = ({ flagKey }: { flagKey: string }) => {
|
||||
const { value } = useFlag(flagKey, 'default');
|
||||
return <div data-testid="flag-value">{value}</div>;
|
||||
};
|
||||
|
||||
const { rerender } = render(
|
||||
<OpenFeatureProvider client={OpenFeature.getClient(EVALUATION)}>
|
||||
<TestComponent flagKey="flag-a" />
|
||||
</OpenFeatureProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('flag-value')).toHaveTextContent('value-a');
|
||||
});
|
||||
|
||||
// Change to flag-b (without any provider events)
|
||||
rerender(
|
||||
<OpenFeatureProvider client={OpenFeature.getClient(EVALUATION)}>
|
||||
<TestComponent flagKey="flag-b" />
|
||||
</OpenFeatureProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('flag-value')).toHaveTextContent('value-b');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('context, hooks and options', () => {
|
||||
|
|
|
@ -288,7 +288,7 @@ describe('OpenFeatureProvider', () => {
|
|||
{ timeout: DELAY * 4 },
|
||||
);
|
||||
|
||||
expect(screen.getByText('Will says hi')).toBeInTheDocument();
|
||||
expect(screen.getByText('Will says aloha')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,5 +1,35 @@
|
|||
# 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)
|
||||
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
* add a top-level method for accessing providers ([#1152](https://github.com/open-feature/js-sdk/issues/1152)) ([ae8fce8](https://github.com/open-feature/js-sdk/commit/ae8fce87530005ed20f7e68dc696ce67053fca31))
|
||||
* add support for abort controllers to event handlers ([#1151](https://github.com/open-feature/js-sdk/issues/1151)) ([6a22483](https://github.com/open-feature/js-sdk/commit/6a224830fa4e62fc30a7802536f6f6fc3f772038))
|
||||
|
||||
## [1.17.1](https://github.com/open-feature/js-sdk/compare/server-sdk-v1.17.0...server-sdk-v1.17.1) (2025-02-07)
|
||||
|
||||
|
||||
|
|
|
@ -16,8 +16,8 @@
|
|||
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
|
||||
</a>
|
||||
<!-- x-release-please-start-version -->
|
||||
<a href="https://github.com/open-feature/js-sdk/releases/tag/server-sdk-v1.17.1">
|
||||
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v1.17.1&color=blue&style=for-the-badge" />
|
||||
<a href="https://github.com/open-feature/js-sdk/releases/tag/server-sdk-v1.19.0">
|
||||
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v1.19.0&color=blue&style=for-the-badge" />
|
||||
</a>
|
||||
<!-- x-release-please-end -->
|
||||
<br/>
|
||||
|
@ -75,7 +75,11 @@ yarn add @openfeature/server-sdk @openfeature/core
|
|||
import { OpenFeature } from '@openfeature/server-sdk';
|
||||
|
||||
// Register your feature flag provider
|
||||
await OpenFeature.setProviderAndWait(new YourProviderOfChoice());
|
||||
try {
|
||||
await OpenFeature.setProviderAndWait(new YourProviderOfChoice());
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize provider:', error);
|
||||
}
|
||||
|
||||
// create a new client
|
||||
const client = OpenFeature.getClient();
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openfeature/server-sdk",
|
||||
"version": "1.17.1",
|
||||
"version": "1.19.0",
|
||||
"description": "OpenFeature SDK for JavaScript",
|
||||
"main": "./dist/cjs/index.js",
|
||||
"files": [
|
||||
|
@ -45,12 +45,12 @@
|
|||
},
|
||||
"homepage": "https://github.com/open-feature/js-sdk#readme",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@openfeature/core": "^1.7.0"
|
||||
"@openfeature/core": "^1.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openfeature/core": "^1.7.0"
|
||||
"@openfeature/core": "^1.9.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,6 +22,7 @@ import {
|
|||
StandardResolutionReasons,
|
||||
instantiateErrorByErrorCode,
|
||||
statusMatchesEvent,
|
||||
MapHookData,
|
||||
} from '@openfeature/core';
|
||||
import type { FlagEvaluationOptions } from '../../evaluation';
|
||||
import type { ProviderEvents } from '../../events';
|
||||
|
@ -276,22 +277,26 @@ export class OpenFeatureClient implements Client {
|
|||
|
||||
const mergedContext = this.mergeContexts(invocationContext);
|
||||
|
||||
// this reference cannot change during the course of evaluation
|
||||
// it may be used as a key in WeakMaps
|
||||
const hookContext: Readonly<HookContext> = {
|
||||
flagKey,
|
||||
defaultValue,
|
||||
flagValueType: flagType,
|
||||
clientMetadata: this.metadata,
|
||||
providerMetadata: this._provider.metadata,
|
||||
context: mergedContext,
|
||||
logger: this._logger,
|
||||
};
|
||||
// Create hook context instances for each hook (stable object references for the entire evaluation)
|
||||
// This ensures hooks can use WeakMaps with hookContext as keys across lifecycle methods
|
||||
// NOTE: Uses the reversed order to reduce the number of times we have to calculate the index.
|
||||
const hookContexts = allHooksReversed.map<HookContext>(() =>
|
||||
Object.freeze({
|
||||
flagKey,
|
||||
defaultValue,
|
||||
flagValueType: flagType,
|
||||
clientMetadata: this.metadata,
|
||||
providerMetadata: this._provider.metadata,
|
||||
context: mergedContext,
|
||||
logger: this._logger,
|
||||
hookData: new MapHookData(),
|
||||
}),
|
||||
);
|
||||
|
||||
let evaluationDetails: EvaluationDetails<T>;
|
||||
|
||||
try {
|
||||
const frozenContext = await this.beforeHooks(allHooks, hookContext, options);
|
||||
const frozenContext = await this.beforeHooks(allHooks, hookContexts, mergedContext, options);
|
||||
|
||||
this.shortCircuitIfNotReady();
|
||||
|
||||
|
@ -306,53 +311,71 @@ export class OpenFeatureClient implements Client {
|
|||
|
||||
if (resolutionDetails.errorCode) {
|
||||
const err = instantiateErrorByErrorCode(resolutionDetails.errorCode, resolutionDetails.errorMessage);
|
||||
await this.errorHooks(allHooksReversed, hookContext, err, options);
|
||||
await this.errorHooks(allHooksReversed, hookContexts, err, options);
|
||||
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err, resolutionDetails.flagMetadata);
|
||||
} else {
|
||||
await this.afterHooks(allHooksReversed, hookContext, resolutionDetails, options);
|
||||
await this.afterHooks(allHooksReversed, hookContexts, resolutionDetails, options);
|
||||
evaluationDetails = resolutionDetails;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
await this.errorHooks(allHooksReversed, hookContext, err, options);
|
||||
await this.errorHooks(allHooksReversed, hookContexts, err, options);
|
||||
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err);
|
||||
}
|
||||
|
||||
await this.finallyHooks(allHooksReversed, hookContext, evaluationDetails, options);
|
||||
await this.finallyHooks(allHooksReversed, hookContexts, evaluationDetails, options);
|
||||
return evaluationDetails;
|
||||
}
|
||||
|
||||
private async beforeHooks(hooks: Hook[], hookContext: HookContext, options: FlagEvaluationOptions) {
|
||||
for (const hook of hooks) {
|
||||
// freeze the hookContext
|
||||
Object.freeze(hookContext);
|
||||
private async beforeHooks(
|
||||
hooks: Hook[],
|
||||
hookContexts: HookContext[],
|
||||
mergedContext: EvaluationContext,
|
||||
options: FlagEvaluationOptions,
|
||||
) {
|
||||
let accumulatedContext = mergedContext;
|
||||
|
||||
// use Object.assign to avoid modification of frozen hookContext
|
||||
Object.assign(hookContext.context, {
|
||||
...hookContext.context,
|
||||
...(await hook?.before?.(hookContext, Object.freeze(options.hookHints))),
|
||||
});
|
||||
for (const [index, hook] of hooks.entries()) {
|
||||
const hookContextIndex = hooks.length - 1 - index; // reverse index for before hooks
|
||||
const hookContext = hookContexts[hookContextIndex];
|
||||
|
||||
// Update the context on the stable hook context object
|
||||
Object.assign(hookContext.context, accumulatedContext);
|
||||
|
||||
const hookResult = await hook?.before?.(hookContext, Object.freeze(options.hookHints));
|
||||
if (hookResult) {
|
||||
accumulatedContext = {
|
||||
...accumulatedContext,
|
||||
...hookResult,
|
||||
};
|
||||
|
||||
for (let i = 0; i < hooks.length; i++) {
|
||||
Object.assign(hookContexts[hookContextIndex].context, accumulatedContext);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// after before hooks, freeze the EvaluationContext.
|
||||
return Object.freeze(hookContext.context);
|
||||
return Object.freeze(accumulatedContext);
|
||||
}
|
||||
|
||||
private async afterHooks(
|
||||
hooks: Hook[],
|
||||
hookContext: HookContext,
|
||||
hookContexts: HookContext[],
|
||||
evaluationDetails: EvaluationDetails<FlagValue>,
|
||||
options: FlagEvaluationOptions,
|
||||
) {
|
||||
// run "after" hooks sequentially
|
||||
for (const hook of hooks) {
|
||||
for (const [index, hook] of hooks.entries()) {
|
||||
const hookContext = hookContexts[index];
|
||||
await hook?.after?.(hookContext, evaluationDetails, options.hookHints);
|
||||
}
|
||||
}
|
||||
|
||||
private async errorHooks(hooks: Hook[], hookContext: HookContext, err: unknown, options: FlagEvaluationOptions) {
|
||||
private async errorHooks(hooks: Hook[], hookContexts: HookContext[], err: unknown, options: FlagEvaluationOptions) {
|
||||
// run "error" hooks sequentially
|
||||
for (const hook of hooks) {
|
||||
for (const [index, hook] of hooks.entries()) {
|
||||
try {
|
||||
const hookContext = hookContexts[index];
|
||||
await hook?.error?.(hookContext, err, options.hookHints);
|
||||
} catch (err) {
|
||||
this._logger.error(`Unhandled error during 'error' hook: ${err}`);
|
||||
|
@ -366,13 +389,14 @@ export class OpenFeatureClient implements Client {
|
|||
|
||||
private async finallyHooks(
|
||||
hooks: Hook[],
|
||||
hookContext: HookContext,
|
||||
hookContexts: HookContext[],
|
||||
evaluationDetails: EvaluationDetails<FlagValue>,
|
||||
options: FlagEvaluationOptions,
|
||||
) {
|
||||
// run "finally" hooks sequentially
|
||||
for (const hook of hooks) {
|
||||
for (const [index, hook] of hooks.entries()) {
|
||||
try {
|
||||
const hookContext = hookContexts[index];
|
||||
await hook?.finally?.(hookContext, evaluationDetails, options.hookHints);
|
||||
} catch (err) {
|
||||
this._logger.error(`Unhandled error during 'finally' hook: ${err}`);
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import type { BaseHook, EvaluationContext, FlagValue } from '@openfeature/core';
|
||||
|
||||
export type Hook = BaseHook<
|
||||
export type Hook<TData = Record<string, unknown>> = BaseHook<
|
||||
FlagValue,
|
||||
TData,
|
||||
Promise<EvaluationContext | void> | EvaluationContext | void,
|
||||
Promise<void> | void
|
||||
>;
|
||||
|
|
|
@ -82,7 +82,7 @@ export class OpenFeatureAPI
|
|||
* Setting a provider supersedes the current provider used in new and existing unbound clients.
|
||||
* @param {Provider} provider The provider responsible for flag evaluations.
|
||||
* @returns {Promise<void>}
|
||||
* @throws Uncaught exceptions thrown by the provider during initialization.
|
||||
* @throws {Error} If the provider throws an exception during initialization.
|
||||
*/
|
||||
setProviderAndWait(provider: Provider): Promise<void>;
|
||||
/**
|
||||
|
@ -92,7 +92,7 @@ export class OpenFeatureAPI
|
|||
* @param {string} domain The name to identify the client
|
||||
* @param {Provider} provider The provider responsible for flag evaluations.
|
||||
* @returns {Promise<void>}
|
||||
* @throws Uncaught exceptions thrown by the provider during initialization.
|
||||
* @throws {Error} If the provider throws an exception during initialization.
|
||||
*/
|
||||
setProviderAndWait(domain: string, provider: Provider): Promise<void>;
|
||||
async setProviderAndWait(domainOrProvider?: string | Provider, providerOrUndefined?: Provider): Promise<void> {
|
||||
|
|
|
@ -0,0 +1,508 @@
|
|||
import { OpenFeature } from '../src';
|
||||
import type { Client } from '../src/client';
|
||||
import type {
|
||||
JsonValue,
|
||||
ResolutionDetails,
|
||||
HookContext,
|
||||
BeforeHookContext,
|
||||
HookData} from '@openfeature/core';
|
||||
import {
|
||||
StandardResolutionReasons
|
||||
} from '@openfeature/core';
|
||||
import type { Provider } from '../src/provider';
|
||||
import type { Hook } from '../src/hooks';
|
||||
|
||||
const BOOLEAN_VALUE = true;
|
||||
const STRING_VALUE = 'val';
|
||||
const NUMBER_VALUE = 1;
|
||||
const OBJECT_VALUE = { key: 'value' };
|
||||
|
||||
// A test hook that stores data in the before stage and retrieves it in after/error/finally
|
||||
class TestHookWithData implements Hook {
|
||||
beforeData: unknown;
|
||||
afterData: unknown;
|
||||
errorData: unknown;
|
||||
finallyData: unknown;
|
||||
|
||||
async before(hookContext: BeforeHookContext) {
|
||||
// Store some data
|
||||
hookContext.hookData.set('testKey', 'testValue');
|
||||
hookContext.hookData.set('timestamp', Date.now());
|
||||
hookContext.hookData.set('object', { nested: 'value' });
|
||||
this.beforeData = hookContext.hookData.get('testKey');
|
||||
}
|
||||
|
||||
async after(hookContext: HookContext) {
|
||||
// Retrieve data stored in before
|
||||
this.afterData = hookContext.hookData.get('testKey');
|
||||
}
|
||||
|
||||
async error(hookContext: HookContext) {
|
||||
// Retrieve data stored in before
|
||||
this.errorData = hookContext.hookData.get('testKey');
|
||||
}
|
||||
|
||||
async finally(hookContext: HookContext) {
|
||||
// Retrieve data stored in before
|
||||
this.finallyData = hookContext.hookData.get('testKey');
|
||||
}
|
||||
}
|
||||
|
||||
// Typed hook example demonstrating improved type safety
|
||||
interface OpenTelemetryData {
|
||||
spanId: string;
|
||||
traceId: string;
|
||||
startTime: number;
|
||||
attributes: Record<string, string | number | boolean>;
|
||||
}
|
||||
|
||||
class TypedOpenTelemetryHook implements Hook {
|
||||
spanId?: string;
|
||||
duration?: number;
|
||||
|
||||
async before(hookContext: BeforeHookContext) {
|
||||
const spanId = `span-${Math.random().toString(36).substring(2, 11)}`;
|
||||
const traceId = `trace-${Math.random().toString(36).substring(2, 11)}`;
|
||||
|
||||
// Demonstrate that we can cast for type safety while maintaining compatibility
|
||||
const typedHookData = hookContext.hookData as unknown as HookData<OpenTelemetryData>;
|
||||
|
||||
// Type-safe setting with proper intellisense
|
||||
typedHookData.set('spanId', spanId);
|
||||
typedHookData.set('traceId', traceId);
|
||||
typedHookData.set('startTime', Date.now());
|
||||
typedHookData.set('attributes', {
|
||||
flagKey: hookContext.flagKey,
|
||||
clientName: hookContext.clientMetadata.name || 'unknown',
|
||||
providerName: hookContext.providerMetadata.name,
|
||||
});
|
||||
|
||||
this.spanId = spanId;
|
||||
}
|
||||
|
||||
async after(hookContext: HookContext) {
|
||||
// Type-safe getting with proper return types
|
||||
const typedHookData = hookContext.hookData as unknown as HookData<OpenTelemetryData>;
|
||||
const startTime: number | undefined = typedHookData.get('startTime');
|
||||
const spanId: string | undefined = typedHookData.get('spanId');
|
||||
|
||||
if (startTime && spanId) {
|
||||
this.duration = Date.now() - startTime;
|
||||
// Simulate span completion
|
||||
}
|
||||
}
|
||||
|
||||
async error(hookContext: HookContext) {
|
||||
const typedHookData = hookContext.hookData as unknown as HookData<OpenTelemetryData>;
|
||||
const spanId: string | undefined = typedHookData.get('spanId');
|
||||
if (spanId) {
|
||||
// Mark span as error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A timing hook that measures evaluation duration
|
||||
class TimingHook implements Hook {
|
||||
duration?: number;
|
||||
|
||||
async before(hookContext: BeforeHookContext) {
|
||||
hookContext.hookData.set('startTime', Date.now());
|
||||
}
|
||||
|
||||
async after(hookContext: HookContext) {
|
||||
const startTime = hookContext.hookData.get('startTime') as number;
|
||||
if (startTime) {
|
||||
this.duration = Date.now() - startTime;
|
||||
}
|
||||
}
|
||||
|
||||
async error(hookContext: HookContext) {
|
||||
const startTime = hookContext.hookData.get('startTime') as number;
|
||||
if (startTime) {
|
||||
this.duration = Date.now() - startTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hook that tests hook data isolation
|
||||
class IsolationTestHook implements Hook {
|
||||
hookId: string;
|
||||
|
||||
constructor(id: string) {
|
||||
this.hookId = id;
|
||||
}
|
||||
|
||||
before(hookContext: BeforeHookContext) {
|
||||
const storedId = hookContext.hookData.get('hookId');
|
||||
if (storedId) {
|
||||
throw new Error('Hook data isolation violated! Data is set in before hook.');
|
||||
}
|
||||
|
||||
// Each hook instance should have its own data
|
||||
hookContext.hookData.set('hookId', this.hookId);
|
||||
hookContext.hookData.set(`data_${this.hookId}`, `value_${this.hookId}`);
|
||||
}
|
||||
|
||||
after(hookContext: HookContext) {
|
||||
// Verify we can only see our own data
|
||||
const storedId = hookContext.hookData.get('hookId');
|
||||
if (storedId !== this.hookId) {
|
||||
throw new Error(`Hook data isolation violated! Expected ${this.hookId}, got ${storedId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mock provider for testing
|
||||
const MOCK_PROVIDER: Provider = {
|
||||
metadata: { name: 'mock-provider' },
|
||||
async resolveBooleanEvaluation(): Promise<ResolutionDetails<boolean>> {
|
||||
return {
|
||||
value: BOOLEAN_VALUE,
|
||||
variant: 'default',
|
||||
reason: StandardResolutionReasons.DEFAULT,
|
||||
};
|
||||
},
|
||||
async resolveStringEvaluation(): Promise<ResolutionDetails<string>> {
|
||||
return {
|
||||
value: STRING_VALUE,
|
||||
variant: 'default',
|
||||
reason: StandardResolutionReasons.DEFAULT,
|
||||
};
|
||||
},
|
||||
async resolveNumberEvaluation(): Promise<ResolutionDetails<number>> {
|
||||
return {
|
||||
value: NUMBER_VALUE,
|
||||
variant: 'default',
|
||||
reason: StandardResolutionReasons.DEFAULT,
|
||||
};
|
||||
},
|
||||
async resolveObjectEvaluation<T extends JsonValue>(): Promise<ResolutionDetails<T>> {
|
||||
return {
|
||||
value: OBJECT_VALUE as unknown as T,
|
||||
variant: 'default',
|
||||
reason: StandardResolutionReasons.DEFAULT,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// Mock provider that throws an error
|
||||
const ERROR_PROVIDER: Provider = {
|
||||
metadata: { name: 'error-provider' },
|
||||
async resolveBooleanEvaluation(): Promise<ResolutionDetails<boolean>> {
|
||||
throw new Error('Provider error');
|
||||
},
|
||||
async resolveStringEvaluation(): Promise<ResolutionDetails<string>> {
|
||||
throw new Error('Provider error');
|
||||
},
|
||||
async resolveNumberEvaluation(): Promise<ResolutionDetails<number>> {
|
||||
throw new Error('Provider error');
|
||||
},
|
||||
async resolveObjectEvaluation<T extends JsonValue>(): Promise<ResolutionDetails<T>> {
|
||||
throw new Error('Provider error');
|
||||
},
|
||||
};
|
||||
|
||||
describe('Hook Data', () => {
|
||||
let client: Client;
|
||||
|
||||
beforeEach(async () => {
|
||||
OpenFeature.clearHooks();
|
||||
await OpenFeature.setProviderAndWait(MOCK_PROVIDER);
|
||||
client = OpenFeature.getClient();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await OpenFeature.clearProviders();
|
||||
});
|
||||
|
||||
describe('Basic Hook Data Functionality', () => {
|
||||
it('should allow hooks to store and retrieve data across stages', async () => {
|
||||
const hook = new TestHookWithData();
|
||||
client.addHooks(hook);
|
||||
|
||||
await client.getBooleanValue('test-flag', false);
|
||||
|
||||
// Verify data was stored in before and retrieved in all other stages
|
||||
expect(hook.beforeData).toBe('testValue');
|
||||
expect(hook.afterData).toBe('testValue');
|
||||
expect(hook.finallyData).toBe('testValue');
|
||||
});
|
||||
|
||||
it('should support storing different data types', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const storedValues: any = {};
|
||||
|
||||
const hook: Hook = {
|
||||
async before(hookContext: BeforeHookContext) {
|
||||
// Store various types
|
||||
hookContext.hookData.set('string', 'test');
|
||||
hookContext.hookData.set('number', 42);
|
||||
hookContext.hookData.set('boolean', true);
|
||||
hookContext.hookData.set('object', { key: 'value' });
|
||||
hookContext.hookData.set('array', [1, 2, 3]);
|
||||
hookContext.hookData.set('null', null);
|
||||
hookContext.hookData.set('undefined', undefined);
|
||||
},
|
||||
|
||||
async after(hookContext: HookContext) {
|
||||
storedValues.string = hookContext.hookData.get('string');
|
||||
storedValues.number = hookContext.hookData.get('number');
|
||||
storedValues.boolean = hookContext.hookData.get('boolean');
|
||||
storedValues.object = hookContext.hookData.get('object');
|
||||
storedValues.array = hookContext.hookData.get('array');
|
||||
storedValues.null = hookContext.hookData.get('null');
|
||||
storedValues.undefined = hookContext.hookData.get('undefined');
|
||||
},
|
||||
};
|
||||
|
||||
client.addHooks(hook);
|
||||
await client.getBooleanValue('test-flag', false);
|
||||
|
||||
expect(storedValues.string).toBe('test');
|
||||
expect(storedValues.number).toBe(42);
|
||||
expect(storedValues.boolean).toBe(true);
|
||||
expect(storedValues.object).toEqual({ key: 'value' });
|
||||
expect(storedValues.array).toEqual([1, 2, 3]);
|
||||
expect(storedValues.null).toBeNull();
|
||||
expect(storedValues.undefined).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle hook data in error scenarios', async () => {
|
||||
await OpenFeature.setProviderAndWait(ERROR_PROVIDER);
|
||||
const hook = new TestHookWithData();
|
||||
client.addHooks(hook);
|
||||
|
||||
await client.getBooleanValue('test-flag', false);
|
||||
|
||||
// Verify data was accessible in error and finally stages
|
||||
expect(hook.beforeData).toBe('testValue');
|
||||
expect(hook.errorData).toBe('testValue');
|
||||
expect(hook.finallyData).toBe('testValue');
|
||||
expect(hook.afterData).toBeUndefined(); // after should not run on error
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hook Data API', () => {
|
||||
it('should support has() method', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const hasResults: any = {};
|
||||
|
||||
const hook: Hook = {
|
||||
async before(hookContext: BeforeHookContext) {
|
||||
hookContext.hookData.set('exists', 'value');
|
||||
hasResults.beforeExists = hookContext.hookData.has('exists');
|
||||
hasResults.beforeNotExists = hookContext.hookData.has('notExists');
|
||||
},
|
||||
|
||||
async after(hookContext: HookContext) {
|
||||
hasResults.afterExists = hookContext.hookData.has('exists');
|
||||
hasResults.afterNotExists = hookContext.hookData.has('notExists');
|
||||
},
|
||||
};
|
||||
|
||||
client.addHooks(hook);
|
||||
await client.getBooleanValue('test-flag', false);
|
||||
|
||||
expect(hasResults.beforeExists).toBe(true);
|
||||
expect(hasResults.beforeNotExists).toBe(false);
|
||||
expect(hasResults.afterExists).toBe(true);
|
||||
expect(hasResults.afterNotExists).toBe(false);
|
||||
});
|
||||
|
||||
it('should support delete() method', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const deleteResults: any = {};
|
||||
|
||||
const hook: Hook = {
|
||||
async before(hookContext: BeforeHookContext) {
|
||||
hookContext.hookData.set('toDelete', 'value');
|
||||
deleteResults.hasBeforeDelete = hookContext.hookData.has('toDelete');
|
||||
deleteResults.deleteResult = hookContext.hookData.delete('toDelete');
|
||||
deleteResults.hasAfterDelete = hookContext.hookData.has('toDelete');
|
||||
deleteResults.deleteAgainResult = hookContext.hookData.delete('toDelete');
|
||||
},
|
||||
};
|
||||
|
||||
client.addHooks(hook);
|
||||
await client.getBooleanValue('test-flag', false);
|
||||
|
||||
expect(deleteResults.hasBeforeDelete).toBe(true);
|
||||
expect(deleteResults.deleteResult).toBe(true);
|
||||
expect(deleteResults.hasAfterDelete).toBe(false);
|
||||
expect(deleteResults.deleteAgainResult).toBe(false);
|
||||
});
|
||||
|
||||
it('should support clear() method', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const clearResults: any = {};
|
||||
|
||||
const hook: Hook = {
|
||||
async before(hookContext: BeforeHookContext) {
|
||||
hookContext.hookData.set('key1', 'value1');
|
||||
hookContext.hookData.set('key2', 'value2');
|
||||
hookContext.hookData.set('key3', 'value3');
|
||||
clearResults.hasBeforeClear = hookContext.hookData.has('key1');
|
||||
hookContext.hookData.clear();
|
||||
clearResults.hasAfterClear = hookContext.hookData.has('key1');
|
||||
},
|
||||
|
||||
async after(hookContext: HookContext) {
|
||||
// Verify all data was cleared
|
||||
clearResults.afterHasKey1 = hookContext.hookData.has('key1');
|
||||
clearResults.afterHasKey2 = hookContext.hookData.has('key2');
|
||||
clearResults.afterHasKey3 = hookContext.hookData.has('key3');
|
||||
},
|
||||
};
|
||||
|
||||
client.addHooks(hook);
|
||||
await client.getBooleanValue('test-flag', false);
|
||||
|
||||
expect(clearResults.hasBeforeClear).toBe(true);
|
||||
expect(clearResults.hasAfterClear).toBe(false);
|
||||
expect(clearResults.afterHasKey1).toBe(false);
|
||||
expect(clearResults.afterHasKey2).toBe(false);
|
||||
expect(clearResults.afterHasKey3).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hook Data Isolation', () => {
|
||||
it('should isolate data between different hook instances', async () => {
|
||||
const hook1 = new IsolationTestHook('hook1');
|
||||
const hook2 = new IsolationTestHook('hook2');
|
||||
const hook3 = new IsolationTestHook('hook3');
|
||||
|
||||
client.addHooks(hook1, hook2, hook3);
|
||||
|
||||
expect(await client.getBooleanValue('test-flag', false)).toBe(true);
|
||||
});
|
||||
|
||||
it('should isolate data between the same hook instance', async () => {
|
||||
const hook = new IsolationTestHook('hook');
|
||||
|
||||
client.addHooks(hook, hook);
|
||||
|
||||
expect(await client.getBooleanValue('test-flag', false)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not share data between different evaluations', async () => {
|
||||
let firstEvalData: unknown;
|
||||
let secondEvalData: unknown;
|
||||
|
||||
const hook: Hook = {
|
||||
async before(hookContext: BeforeHookContext) {
|
||||
// Check if data exists from previous evaluation
|
||||
const existingData = hookContext.hookData.get('evalData');
|
||||
if (existingData) {
|
||||
throw new Error('Hook data leaked between evaluations!');
|
||||
}
|
||||
hookContext.hookData.set('evalData', 'evaluation-specific');
|
||||
},
|
||||
|
||||
async after(hookContext: HookContext) {
|
||||
if (!firstEvalData) {
|
||||
firstEvalData = hookContext.hookData.get('evalData');
|
||||
} else {
|
||||
secondEvalData = hookContext.hookData.get('evalData');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
client.addHooks(hook);
|
||||
|
||||
// First evaluation
|
||||
await client.getBooleanValue('test-flag', false);
|
||||
// Second evaluation
|
||||
await client.getBooleanValue('test-flag', false);
|
||||
|
||||
expect(firstEvalData).toBe('evaluation-specific');
|
||||
expect(secondEvalData).toBe('evaluation-specific');
|
||||
});
|
||||
|
||||
it('should isolate data between global, client, and invocation hooks', async () => {
|
||||
const globalHook = new IsolationTestHook('global');
|
||||
const clientHook = new IsolationTestHook('client');
|
||||
const invocationHook = new IsolationTestHook('invocation');
|
||||
|
||||
OpenFeature.addHooks(globalHook);
|
||||
client.addHooks(clientHook);
|
||||
|
||||
expect(await client.getBooleanValue('test-flag', false, {}, { hooks: [invocationHook] })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Use Cases', () => {
|
||||
it('should support timing measurements', async () => {
|
||||
const timingHook = new TimingHook();
|
||||
client.addHooks(timingHook);
|
||||
|
||||
await client.getBooleanValue('test-flag', false);
|
||||
|
||||
expect(timingHook.duration).toBeDefined();
|
||||
expect(timingHook.duration).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should support multi-stage validation accumulation', async () => {
|
||||
let finalErrors: string[] = [];
|
||||
|
||||
const validationHook: Hook = {
|
||||
async before(hookContext: BeforeHookContext) {
|
||||
hookContext.hookData.set('errors', []);
|
||||
|
||||
// Simulate validation
|
||||
const errors = hookContext.hookData.get('errors') as string[];
|
||||
if (!hookContext.context.userId) {
|
||||
errors.push('Missing userId');
|
||||
}
|
||||
if (!hookContext.context.region) {
|
||||
errors.push('Missing region');
|
||||
}
|
||||
},
|
||||
|
||||
async finally(hookContext: HookContext) {
|
||||
finalErrors = (hookContext.hookData.get('errors') as string[]) || [];
|
||||
},
|
||||
};
|
||||
|
||||
client.addHooks(validationHook);
|
||||
await client.getBooleanValue('test-flag', false, {});
|
||||
|
||||
expect(finalErrors).toContain('Missing userId');
|
||||
expect(finalErrors).toContain('Missing region');
|
||||
});
|
||||
|
||||
it('should support request correlation', async () => {
|
||||
let correlationId: string | undefined;
|
||||
|
||||
const correlationHook: Hook = {
|
||||
async before(hookContext: BeforeHookContext) {
|
||||
const id = `req-${Date.now()}-${Math.random()}`;
|
||||
hookContext.hookData.set('correlationId', id);
|
||||
},
|
||||
|
||||
async after(hookContext: HookContext) {
|
||||
correlationId = hookContext.hookData.get('correlationId') as string;
|
||||
},
|
||||
};
|
||||
|
||||
client.addHooks(correlationHook);
|
||||
await client.getBooleanValue('test-flag', false);
|
||||
|
||||
expect(correlationId).toBeDefined();
|
||||
expect(correlationId).toMatch(/^req-\d+-[\d.]+$/);
|
||||
});
|
||||
|
||||
it('should support typed hook data for better type safety', async () => {
|
||||
const typedHook = new TypedOpenTelemetryHook();
|
||||
client.addHooks(typedHook);
|
||||
|
||||
await client.getBooleanValue('test-flag', false);
|
||||
|
||||
// Verify the typed hook worked correctly
|
||||
expect(typedHook.spanId).toBeDefined();
|
||||
expect(typedHook.spanId).toMatch(/^span-[a-z0-9]+$/);
|
||||
expect(typedHook.duration).toBeDefined();
|
||||
expect(typeof typedHook.duration).toBe('number');
|
||||
expect(typedHook.duration).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,5 +1,20 @@
|
|||
# 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)
|
||||
|
||||
|
||||
### 🔄 Refactoring
|
||||
|
||||
* **telemetry:** update telemetry attributes and remove unused evaluation data ([#1189](https://github.com/open-feature/js-sdk/issues/1189)) ([3e6bcae](https://github.com/open-feature/js-sdk/commit/3e6bcaef0bb5c05e914a0078f0cb884b7f74f068))
|
||||
|
||||
## [1.8.0](https://github.com/open-feature/js-sdk/compare/core-v1.7.2...core-v1.8.0) (2025-04-10)
|
||||
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openfeature/core",
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.0",
|
||||
"description": "Shared OpenFeature JS components (server and web)",
|
||||
"main": "./dist/cjs/index.js",
|
||||
"files": [
|
||||
|
|
|
@ -18,3 +18,4 @@ export abstract class OpenFeatureError extends Error {
|
|||
this.cause = options?.cause;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ import type { JsonValue } from '../types/structure';
|
|||
export type FlagValueType = 'boolean' | 'string' | 'number' | 'object';
|
||||
|
||||
/**
|
||||
* Represents a JSON node value, or Date.
|
||||
* Represents a JSON node value.
|
||||
*/
|
||||
export type FlagValue = boolean | string | number | JsonValue;
|
||||
|
||||
|
|
|
@ -0,0 +1,72 @@
|
|||
/**
|
||||
* A mutable data structure for hooks to maintain state across their lifecycle.
|
||||
* Each hook instance gets its own isolated data store that persists for the
|
||||
* duration of a single flag evaluation.
|
||||
* @template TData - A record type that defines the shape of the stored data
|
||||
*/
|
||||
export interface HookData<TData = Record<string, unknown>> {
|
||||
/**
|
||||
* Sets a value in the hook data store.
|
||||
* @param key The key to store the value under
|
||||
* @param value The value to store
|
||||
*/
|
||||
set<K extends keyof TData>(key: K, value: TData[K]): void;
|
||||
set(key: string, value: unknown): void;
|
||||
|
||||
/**
|
||||
* Gets a value from the hook data store.
|
||||
* @param key The key to retrieve the value for
|
||||
* @returns The stored value, or undefined if not found
|
||||
*/
|
||||
get<K extends keyof TData>(key: K): TData[K] | undefined;
|
||||
get(key: string): unknown;
|
||||
|
||||
/**
|
||||
* Checks if a key exists in the hook data store.
|
||||
* @param key The key to check
|
||||
* @returns True if the key exists, false otherwise
|
||||
*/
|
||||
has<K extends keyof TData>(key: K): boolean;
|
||||
has(key: string): boolean;
|
||||
|
||||
/**
|
||||
* Deletes a value from the hook data store.
|
||||
* @param key The key to delete
|
||||
* @returns True if the key was deleted, false if it didn't exist
|
||||
*/
|
||||
delete<K extends keyof TData>(key: K): boolean;
|
||||
delete(key: string): boolean;
|
||||
|
||||
/**
|
||||
* Clears all values from the hook data store.
|
||||
*/
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default implementation of HookData using a Map.
|
||||
* @template TData - A record type that defines the shape of the stored data
|
||||
*/
|
||||
export class MapHookData<TData = Record<string, unknown>> implements HookData<TData> {
|
||||
private readonly data = new Map<keyof TData, TData[keyof TData]>();
|
||||
|
||||
set<K extends keyof TData>(key: K, value: TData[K]): void {
|
||||
this.data.set(key, value);
|
||||
}
|
||||
|
||||
get<K extends keyof TData>(key: K): TData[K] | undefined {
|
||||
return this.data.get(key) as TData[K] | undefined;
|
||||
}
|
||||
|
||||
has<K extends keyof TData>(key: K): boolean {
|
||||
return this.data.has(key);
|
||||
}
|
||||
|
||||
delete<K extends keyof TData>(key: K): boolean {
|
||||
return this.data.delete(key);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.data.clear();
|
||||
}
|
||||
}
|
|
@ -1,14 +1,19 @@
|
|||
import type { BeforeHookContext, HookContext, HookHints } from './hooks';
|
||||
import type { EvaluationDetails, FlagValue } from '../evaluation';
|
||||
|
||||
export interface BaseHook<T extends FlagValue = FlagValue, BeforeHookReturn = unknown, HooksReturn = unknown> {
|
||||
export interface BaseHook<
|
||||
T extends FlagValue = FlagValue,
|
||||
TData = Record<string, unknown>,
|
||||
BeforeHookReturn = unknown,
|
||||
HooksReturn = unknown
|
||||
> {
|
||||
/**
|
||||
* Runs before flag values are resolved from the provider.
|
||||
* If an EvaluationContext is returned, it will be merged with the pre-existing EvaluationContext.
|
||||
* @param hookContext
|
||||
* @param hookHints
|
||||
*/
|
||||
before?(hookContext: BeforeHookContext, hookHints?: HookHints): BeforeHookReturn;
|
||||
before?(hookContext: BeforeHookContext<T, TData>, hookHints?: HookHints): BeforeHookReturn;
|
||||
|
||||
/**
|
||||
* Runs after flag values are successfully resolved from the provider.
|
||||
|
@ -17,7 +22,7 @@ export interface BaseHook<T extends FlagValue = FlagValue, BeforeHookReturn = un
|
|||
* @param hookHints
|
||||
*/
|
||||
after?(
|
||||
hookContext: Readonly<HookContext<T>>,
|
||||
hookContext: Readonly<HookContext<T, TData>>,
|
||||
evaluationDetails: EvaluationDetails<T>,
|
||||
hookHints?: HookHints,
|
||||
): HooksReturn;
|
||||
|
@ -28,7 +33,7 @@ export interface BaseHook<T extends FlagValue = FlagValue, BeforeHookReturn = un
|
|||
* @param error
|
||||
* @param hookHints
|
||||
*/
|
||||
error?(hookContext: Readonly<HookContext<T>>, error: unknown, hookHints?: HookHints): HooksReturn;
|
||||
error?(hookContext: Readonly<HookContext<T, TData>>, error: unknown, hookHints?: HookHints): HooksReturn;
|
||||
|
||||
/**
|
||||
* Runs after all other hook stages, regardless of success or error.
|
||||
|
@ -37,8 +42,9 @@ export interface BaseHook<T extends FlagValue = FlagValue, BeforeHookReturn = un
|
|||
* @param hookHints
|
||||
*/
|
||||
finally?(
|
||||
hookContext: Readonly<HookContext<T>>,
|
||||
hookContext: Readonly<HookContext<T, TData>>,
|
||||
evaluationDetails: EvaluationDetails<T>,
|
||||
hookHints?: HookHints,
|
||||
): HooksReturn;
|
||||
}
|
||||
|
||||
|
|
|
@ -2,10 +2,11 @@ import type { ProviderMetadata } from '../provider';
|
|||
import type { ClientMetadata } from '../client';
|
||||
import type { EvaluationContext, FlagValue, FlagValueType } from '../evaluation';
|
||||
import type { Logger } from '../logger';
|
||||
import type { HookData } from './hook-data';
|
||||
|
||||
export type HookHints = Readonly<Record<string, unknown>>;
|
||||
|
||||
export interface HookContext<T extends FlagValue = FlagValue> {
|
||||
export interface HookContext<T extends FlagValue = FlagValue, TData = Record<string, unknown>> {
|
||||
readonly flagKey: string;
|
||||
readonly defaultValue: T;
|
||||
readonly flagValueType: FlagValueType;
|
||||
|
@ -13,8 +14,9 @@ export interface HookContext<T extends FlagValue = FlagValue> {
|
|||
readonly clientMetadata: ClientMetadata;
|
||||
readonly providerMetadata: ProviderMetadata;
|
||||
readonly logger: Logger;
|
||||
readonly hookData: HookData<TData>;
|
||||
}
|
||||
|
||||
export interface BeforeHookContext extends HookContext {
|
||||
export interface BeforeHookContext<T extends FlagValue = FlagValue, TData = Record<string, unknown>> extends HookContext<T, TData> {
|
||||
context: EvaluationContext;
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
export * from './hook';
|
||||
export * from './hooks';
|
||||
export * from './evaluation-lifecycle';
|
||||
export * from './hook-data';
|
||||
|
|
|
@ -20,6 +20,14 @@ export const TelemetryAttribute = {
|
|||
* - example: `flag_not_found`
|
||||
*/
|
||||
ERROR_CODE: 'error.type',
|
||||
/**
|
||||
* A message explaining the nature of an error occurring during flag evaluation.
|
||||
*
|
||||
* - type: `string`
|
||||
* - requirement level: `recommended`
|
||||
* - example: `Flag not found`
|
||||
*/
|
||||
ERROR_MESSAGE: 'error.message',
|
||||
/**
|
||||
* A semantic identifier for an evaluated flag value.
|
||||
*
|
||||
|
@ -28,7 +36,16 @@ export const TelemetryAttribute = {
|
|||
* - condition: variant is defined on the evaluation details
|
||||
* - example: `blue`; `on`; `true`
|
||||
*/
|
||||
VARIANT: 'feature_flag.variant',
|
||||
VARIANT: 'feature_flag.result.variant',
|
||||
/**
|
||||
* The evaluated value of the feature flag.
|
||||
*
|
||||
* - type: `undefined`
|
||||
* - requirement level: `conditionally required`
|
||||
* - condition: variant is not defined on the evaluation details
|
||||
* - example: `#ff0000`; `1`; `true`
|
||||
*/
|
||||
VALUE: 'feature_flag.result.value',
|
||||
/**
|
||||
* The unique identifier for the flag evaluation context. For example, the targeting key.
|
||||
*
|
||||
|
@ -37,14 +54,6 @@ export const TelemetryAttribute = {
|
|||
* - example: `5157782b-2203-4c80-a857-dbbd5e7761db`
|
||||
*/
|
||||
CONTEXT_ID: 'feature_flag.context.id',
|
||||
/**
|
||||
* A message explaining the nature of an error occurring during flag evaluation.
|
||||
*
|
||||
* - type: `string`
|
||||
* - requirement level: `recommended`
|
||||
* - example: `Flag not found`
|
||||
*/
|
||||
ERROR_MESSAGE: 'feature_flag.evaluation.error.message',
|
||||
/**
|
||||
* The reason code which shows how a feature flag value was determined.
|
||||
*
|
||||
|
@ -52,7 +61,7 @@ export const TelemetryAttribute = {
|
|||
* - requirement level: `recommended`
|
||||
* - example: `targeting_match`
|
||||
*/
|
||||
REASON: 'feature_flag.evaluation.reason',
|
||||
REASON: 'feature_flag.result.reason',
|
||||
/**
|
||||
* Describes a class of error the operation ended with.
|
||||
*
|
||||
|
@ -60,7 +69,7 @@ export const TelemetryAttribute = {
|
|||
* - requirement level: `recommended`
|
||||
* - example: `flag_not_found`
|
||||
*/
|
||||
PROVIDER: 'feature_flag.provider_name',
|
||||
PROVIDER: 'feature_flag.provider.name',
|
||||
/**
|
||||
* The identifier of the flag set to which the feature flag belongs.
|
||||
*
|
||||
|
|
|
@ -1,17 +0,0 @@
|
|||
/**
|
||||
* Event data, sometimes referred as "body", is specific to a specific event.
|
||||
* In this case, the event is `feature_flag.evaluation`. That's why the prefix
|
||||
* is omitted from the values.
|
||||
* @see https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-logs/
|
||||
*/
|
||||
export const TelemetryEvaluationData = {
|
||||
/**
|
||||
* The evaluated value of the feature flag.
|
||||
*
|
||||
* - type: `undefined`
|
||||
* - requirement level: `conditionally required`
|
||||
* - condition: variant is not defined on the evaluation details
|
||||
* - example: `#ff0000`; `1`; `true`
|
||||
*/
|
||||
VALUE: 'value',
|
||||
} as const;
|
|
@ -1,14 +1,19 @@
|
|||
import { ErrorCode, StandardResolutionReasons, type EvaluationDetails, type FlagValue } from '../evaluation/evaluation';
|
||||
import type { HookContext } from '../hooks/hooks';
|
||||
import type { JsonValue } from '../types';
|
||||
import { TelemetryAttribute } from './attributes';
|
||||
import { TelemetryEvaluationData } from './evaluation-data';
|
||||
import { TelemetryFlagMetadata } from './flag-metadata';
|
||||
|
||||
type EvaluationEvent = {
|
||||
/**
|
||||
* The name of the feature flag evaluation event.
|
||||
*/
|
||||
name: string;
|
||||
attributes: Record<string, string | number | boolean>;
|
||||
body: Record<string, JsonValue>;
|
||||
/**
|
||||
* The attributes of an OpenTelemetry compliant event for flag evaluation.
|
||||
* @experimental The attributes are subject to change.
|
||||
* @see https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-logs/
|
||||
*/
|
||||
attributes: Record<string, string | number | boolean | FlagValue>;
|
||||
};
|
||||
|
||||
const FLAG_EVALUATION_EVENT_NAME = 'feature_flag.evaluation';
|
||||
|
@ -28,12 +33,11 @@ export function createEvaluationEvent(
|
|||
[TelemetryAttribute.PROVIDER]: hookContext.providerMetadata.name,
|
||||
[TelemetryAttribute.REASON]: (evaluationDetails.reason ?? StandardResolutionReasons.UNKNOWN).toLowerCase(),
|
||||
};
|
||||
const body: EvaluationEvent['body'] = {};
|
||||
|
||||
if (evaluationDetails.variant) {
|
||||
attributes[TelemetryAttribute.VARIANT] = evaluationDetails.variant;
|
||||
} else {
|
||||
body[TelemetryEvaluationData.VALUE] = evaluationDetails.value;
|
||||
attributes[TelemetryAttribute.VALUE] = evaluationDetails.value;
|
||||
}
|
||||
|
||||
const contextId =
|
||||
|
@ -62,6 +66,5 @@ export function createEvaluationEvent(
|
|||
return {
|
||||
name: FLAG_EVALUATION_EVENT_NAME,
|
||||
attributes,
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
export * from './attributes';
|
||||
export * from './evaluation-data';
|
||||
export * from './flag-metadata';
|
||||
export * from './evaluation-event';
|
||||
|
|
|
@ -0,0 +1,214 @@
|
|||
import type { HookData, BaseHook, BeforeHookContext, HookContext } from '../src/hooks';
|
||||
import { MapHookData } from '../src/hooks';
|
||||
import type { FlagValue } from '../src/evaluation';
|
||||
|
||||
describe('Hook Data Type Safety', () => {
|
||||
it('should provide type safety with typed hook data', () => {
|
||||
// Define a strict type for hook data
|
||||
interface MyHookData {
|
||||
startTime: number;
|
||||
userId: string;
|
||||
metadata: { version: string; feature: boolean };
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
const hookData = new MapHookData<MyHookData>();
|
||||
|
||||
// Type-safe setting and getting
|
||||
hookData.set('startTime', 123456);
|
||||
hookData.set('userId', 'user-123');
|
||||
hookData.set('metadata', { version: '1.0.0', feature: true });
|
||||
hookData.set('tags', ['tag1', 'tag2']);
|
||||
|
||||
// TypeScript should infer the correct return types
|
||||
const startTime: number | undefined = hookData.get('startTime');
|
||||
const userId: string | undefined = hookData.get('userId');
|
||||
const metadata: { version: string; feature: boolean } | undefined = hookData.get('metadata');
|
||||
const tags: string[] | undefined = hookData.get('tags');
|
||||
|
||||
// Verify the values
|
||||
expect(startTime).toBe(123456);
|
||||
expect(userId).toBe('user-123');
|
||||
expect(metadata).toEqual({ version: '1.0.0', feature: true });
|
||||
expect(tags).toEqual(['tag1', 'tag2']);
|
||||
|
||||
// Type-safe existence checks
|
||||
expect(hookData.has('startTime')).toBe(true);
|
||||
expect(hookData.has('userId')).toBe(true);
|
||||
expect(hookData.has('metadata')).toBe(true);
|
||||
expect(hookData.has('tags')).toBe(true);
|
||||
|
||||
// Type-safe deletion
|
||||
expect(hookData.delete('tags')).toBe(true);
|
||||
expect(hookData.has('tags')).toBe(false);
|
||||
});
|
||||
|
||||
it('should support untyped usage for backward compatibility', () => {
|
||||
const hookData: HookData = new MapHookData();
|
||||
|
||||
// Untyped usage still works
|
||||
hookData.set('anyKey', 'anyValue');
|
||||
hookData.set('numberKey', 42);
|
||||
hookData.set('objectKey', { nested: true });
|
||||
|
||||
const value: unknown = hookData.get('anyKey');
|
||||
const numberValue: unknown = hookData.get('numberKey');
|
||||
const objectValue: unknown = hookData.get('objectKey');
|
||||
|
||||
expect(value).toBe('anyValue');
|
||||
expect(numberValue).toBe(42);
|
||||
expect(objectValue).toEqual({ nested: true });
|
||||
});
|
||||
|
||||
it('should support mixed usage with typed and untyped keys', () => {
|
||||
interface PartiallyTypedData {
|
||||
correlationId: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const hookData: HookData<PartiallyTypedData> = new MapHookData<PartiallyTypedData>();
|
||||
|
||||
// Typed usage
|
||||
hookData.set('correlationId', 'abc-123');
|
||||
hookData.set('timestamp', Date.now());
|
||||
|
||||
// Untyped usage for additional keys
|
||||
hookData.set('dynamicKey', 'dynamicValue');
|
||||
|
||||
// Type-safe retrieval for typed keys
|
||||
const correlationId: string | undefined = hookData.get('correlationId');
|
||||
const timestamp: number | undefined = hookData.get('timestamp');
|
||||
|
||||
// Untyped retrieval for dynamic keys
|
||||
const dynamicValue: unknown = hookData.get('dynamicKey');
|
||||
|
||||
expect(correlationId).toBe('abc-123');
|
||||
expect(typeof timestamp).toBe('number');
|
||||
expect(dynamicValue).toBe('dynamicValue');
|
||||
});
|
||||
|
||||
it('should work with complex nested types', () => {
|
||||
interface ComplexHookData {
|
||||
request: {
|
||||
id: string;
|
||||
headers: Record<string, string>;
|
||||
body?: { [key: string]: unknown };
|
||||
};
|
||||
response: {
|
||||
status: number;
|
||||
data: unknown;
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
metrics: {
|
||||
startTime: number;
|
||||
endTime?: number;
|
||||
duration?: number;
|
||||
};
|
||||
}
|
||||
|
||||
const hookData: HookData<ComplexHookData> = new MapHookData<ComplexHookData>();
|
||||
|
||||
const requestData = {
|
||||
id: 'req-123',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: { flag: 'test-flag' },
|
||||
};
|
||||
|
||||
hookData.set('request', requestData);
|
||||
hookData.set('metrics', { startTime: Date.now() });
|
||||
|
||||
const retrievedRequest = hookData.get('request');
|
||||
const retrievedMetrics = hookData.get('metrics');
|
||||
|
||||
expect(retrievedRequest).toEqual(requestData);
|
||||
expect(retrievedMetrics?.startTime).toBeDefined();
|
||||
expect(typeof retrievedMetrics?.startTime).toBe('number');
|
||||
});
|
||||
|
||||
it('should support generic type inference', () => {
|
||||
// This function demonstrates how the generic types work in practice
|
||||
function createTypedHookData<T>(): HookData<T> {
|
||||
return new MapHookData<T>();
|
||||
}
|
||||
|
||||
interface TimingData {
|
||||
start: number;
|
||||
checkpoint: number;
|
||||
}
|
||||
|
||||
const timingHookData = createTypedHookData<TimingData>();
|
||||
|
||||
timingHookData.set('start', performance.now());
|
||||
timingHookData.set('checkpoint', performance.now());
|
||||
|
||||
const start: number | undefined = timingHookData.get('start');
|
||||
const checkpoint: number | undefined = timingHookData.get('checkpoint');
|
||||
|
||||
expect(typeof start).toBe('number');
|
||||
expect(typeof checkpoint).toBe('number');
|
||||
});
|
||||
|
||||
it('should work with BaseHook interface without casting', () => {
|
||||
interface TestHookData {
|
||||
testId: string;
|
||||
startTime: number;
|
||||
metadata: { version: string };
|
||||
}
|
||||
|
||||
class TestTypedHook implements BaseHook<FlagValue, TestHookData> {
|
||||
capturedData: { testId?: string; duration?: number } = {};
|
||||
|
||||
before(hookContext: BeforeHookContext<FlagValue, TestHookData>) {
|
||||
// No casting needed - TypeScript knows the types
|
||||
hookContext.hookData.set('testId', 'test-123');
|
||||
hookContext.hookData.set('startTime', Date.now());
|
||||
hookContext.hookData.set('metadata', { version: '1.0.0' });
|
||||
}
|
||||
|
||||
after(hookContext: HookContext<FlagValue, TestHookData>) {
|
||||
// Type-safe getting with proper return types
|
||||
const testId: string | undefined = hookContext.hookData.get('testId');
|
||||
const startTime: number | undefined = hookContext.hookData.get('startTime');
|
||||
|
||||
if (testId && startTime) {
|
||||
this.capturedData = {
|
||||
testId,
|
||||
duration: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hook = new TestTypedHook();
|
||||
|
||||
// Create mock contexts that satisfy the BaseHook interface
|
||||
const mockBeforeContext: BeforeHookContext<FlagValue, TestHookData> = {
|
||||
flagKey: 'test-flag',
|
||||
defaultValue: true,
|
||||
flagValueType: 'boolean',
|
||||
context: {},
|
||||
clientMetadata: {
|
||||
name: 'test-client',
|
||||
domain: 'test-domain',
|
||||
providerMetadata: { name: 'test-provider' },
|
||||
},
|
||||
providerMetadata: { name: 'test-provider' },
|
||||
logger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() },
|
||||
hookData: new MapHookData<TestHookData>(),
|
||||
};
|
||||
|
||||
const mockAfterContext: HookContext<FlagValue, TestHookData> = {
|
||||
...mockBeforeContext,
|
||||
context: Object.freeze({}),
|
||||
};
|
||||
|
||||
// Execute the hook methods
|
||||
hook.before!(mockBeforeContext);
|
||||
hook.after!(mockAfterContext);
|
||||
|
||||
// Verify the typed hook worked correctly
|
||||
expect(hook.capturedData.testId).toBe('test-123');
|
||||
expect(hook.capturedData.duration).toBeDefined();
|
||||
expect(typeof hook.capturedData.duration).toBe('number');
|
||||
});
|
||||
});
|
|
@ -1,7 +1,8 @@
|
|||
import { createEvaluationEvent } from '../src/telemetry/evaluation-event';
|
||||
import { ErrorCode, StandardResolutionReasons, type EvaluationDetails } from '../src/evaluation/evaluation';
|
||||
import type { HookContext } from '../src/hooks/hooks';
|
||||
import { TelemetryAttribute, TelemetryFlagMetadata, TelemetryEvaluationData } from '../src/telemetry';
|
||||
import { TelemetryAttribute, TelemetryFlagMetadata } from '../src/telemetry';
|
||||
import { MapHookData } from '../src/hooks/hook-data';
|
||||
|
||||
describe('evaluationEvent', () => {
|
||||
const flagKey = 'test-flag';
|
||||
|
@ -25,6 +26,7 @@ describe('evaluationEvent', () => {
|
|||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
},
|
||||
hookData: new MapHookData(),
|
||||
};
|
||||
|
||||
it('should return basic event body with mandatory fields', () => {
|
||||
|
@ -43,9 +45,7 @@ describe('evaluationEvent', () => {
|
|||
[TelemetryAttribute.PROVIDER]: 'test-provider',
|
||||
[TelemetryAttribute.REASON]: StandardResolutionReasons.STATIC.toLowerCase(),
|
||||
[TelemetryAttribute.CONTEXT_ID]: 'test-target',
|
||||
});
|
||||
expect(result.body).toEqual({
|
||||
[TelemetryEvaluationData.VALUE]: true,
|
||||
[TelemetryAttribute.VALUE]: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -61,7 +61,7 @@ describe('evaluationEvent', () => {
|
|||
const result = createEvaluationEvent(mockHookContext, details);
|
||||
|
||||
expect(result.attributes[TelemetryAttribute.VARIANT]).toBe('test-variant');
|
||||
expect(result.attributes[TelemetryEvaluationData.VALUE]).toBeUndefined();
|
||||
expect(result.attributes[TelemetryAttribute.VALUE]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should include flag metadata when provided', () => {
|
||||
|
|
|
@ -1,6 +1,39 @@
|
|||
# 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)
|
||||
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
* add a top-level method for accessing providers ([#1152](https://github.com/open-feature/js-sdk/issues/1152)) ([ae8fce8](https://github.com/open-feature/js-sdk/commit/ae8fce87530005ed20f7e68dc696ce67053fca31))
|
||||
* add support for abort controllers to event handlers ([#1151](https://github.com/open-feature/js-sdk/issues/1151)) ([6a22483](https://github.com/open-feature/js-sdk/commit/6a224830fa4e62fc30a7802536f6f6fc3f772038))
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
* Typo in name of the function ([2c5b37c](https://github.com/open-feature/js-sdk/commit/2c5b37c79d72d60864c27b9e67d96e99ef4ae241))
|
||||
|
||||
## [1.4.1](https://github.com/open-feature/js-sdk/compare/web-sdk-v1.4.0...web-sdk-v1.4.1) (2025-02-07)
|
||||
|
||||
|
||||
|
|
|
@ -16,8 +16,8 @@
|
|||
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
|
||||
</a>
|
||||
<!-- x-release-please-start-version -->
|
||||
<a href="https://github.com/open-feature/js-sdk/releases/tag/web-sdk-v1.4.1">
|
||||
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v1.4.1&color=blue&style=for-the-badge" />
|
||||
<a href="https://github.com/open-feature/js-sdk/releases/tag/web-sdk-v1.6.1">
|
||||
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v1.6.1&color=blue&style=for-the-badge" />
|
||||
</a>
|
||||
<!-- x-release-please-end -->
|
||||
<br/>
|
||||
|
@ -75,7 +75,11 @@ yarn add @openfeature/web-sdk @openfeature/core
|
|||
import { OpenFeature } from '@openfeature/web-sdk';
|
||||
|
||||
// Register your feature flag provider
|
||||
await OpenFeature.setProviderAndWait(new YourProviderOfChoice());
|
||||
try {
|
||||
await OpenFeature.setProviderAndWait(new YourProviderOfChoice());
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize provider:', error);
|
||||
}
|
||||
|
||||
// create a new client
|
||||
const client = OpenFeature.getClient();
|
||||
|
@ -121,7 +125,11 @@ Once you've added a provider as a dependency, it can be registered with OpenFeat
|
|||
To register a provider and ensure it is ready before further actions are taken, you can use the `setProviderAndWait` method as shown below:
|
||||
|
||||
```ts
|
||||
await OpenFeature.setProviderAndWait(new MyProvider());
|
||||
try {
|
||||
await OpenFeature.setProviderAndWait(new MyProvider());
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize provider:', error);
|
||||
}
|
||||
```
|
||||
|
||||
#### Synchronous
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
{
|
||||
"name": "@openfeature/web-sdk",
|
||||
"version": "1.4.1",
|
||||
"version": "1.6.1",
|
||||
"description": "OpenFeature SDK for Web",
|
||||
"main": "./dist/cjs/index.js",
|
||||
"unpkg": "dist/global/index.min.js",
|
||||
"jsdelivr": "dist/global/index.min.js",
|
||||
"files": [
|
||||
"dist/"
|
||||
],
|
||||
|
@ -20,8 +22,10 @@
|
|||
"clean": "shx rm -rf ./dist",
|
||||
"build:web-esm": "esbuild src/index.ts --bundle --external:@openfeature/core --sourcemap --target=es2015 --platform=browser --format=esm --outfile=./dist/esm/index.js --analyze",
|
||||
"build:web-cjs": "esbuild src/index.ts --bundle --external:@openfeature/core --sourcemap --target=es2015 --platform=browser --format=cjs --outfile=./dist/cjs/index.js --analyze",
|
||||
"build:web-global": "esbuild src/index.ts --bundle --sourcemap --target=es2015 --platform=browser --format=iife --outfile=./dist/global/index.js --global-name=OpenFeature --analyze",
|
||||
"build:web-global:min": "esbuild src/index.ts --bundle --sourcemap --target=es2015 --platform=browser --format=iife --outfile=./dist/global/index.min.js --global-name=OpenFeature --minify --analyze",
|
||||
"build:rollup-types": "rollup -c ../../rollup.config.mjs",
|
||||
"build": "npm run clean && npm run build:web-esm && npm run build:web-cjs && npm run build:rollup-types",
|
||||
"build": "npm run clean && npm run build:web-esm && npm run build:web-cjs && npm run build:web-global && npm run build:web-global:min && npm run build:rollup-types",
|
||||
"postbuild": "shx cp ./../../package.esm.json ./dist/esm/package.json",
|
||||
"current-version": "echo $npm_package_version",
|
||||
"prepack": "shx cp ./../../LICENSE ./LICENSE",
|
||||
|
@ -46,9 +50,9 @@
|
|||
},
|
||||
"homepage": "https://github.com/open-feature/js-sdk#readme",
|
||||
"peerDependencies": {
|
||||
"@openfeature/core": "^1.7.0"
|
||||
"@openfeature/core": "^1.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openfeature/core": "^1.7.0"
|
||||
"@openfeature/core": "^1.9.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,6 +22,7 @@ import {
|
|||
StandardResolutionReasons,
|
||||
instantiateErrorByErrorCode,
|
||||
statusMatchesEvent,
|
||||
MapHookData,
|
||||
} from '@openfeature/core';
|
||||
import type { FlagEvaluationOptions } from '../../evaluation';
|
||||
import type { ProviderEvents } from '../../events';
|
||||
|
@ -231,22 +232,26 @@ export class OpenFeatureClient implements Client {
|
|||
...this.apiContextAccessor(this?.options?.domain),
|
||||
};
|
||||
|
||||
// this reference cannot change during the course of evaluation
|
||||
// it may be used as a key in WeakMaps
|
||||
const hookContext: Readonly<HookContext> = {
|
||||
flagKey,
|
||||
defaultValue,
|
||||
flagValueType: flagType,
|
||||
clientMetadata: this.metadata,
|
||||
providerMetadata: this._provider.metadata,
|
||||
context,
|
||||
logger: this._logger,
|
||||
};
|
||||
// Create hook context instances for each hook (stable object references for the entire evaluation)
|
||||
// This ensures hooks can use WeakMaps with hookContext as keys across lifecycle methods
|
||||
// NOTE: Uses the reversed order to reduce the number of times we have to calculate the index.
|
||||
const hookContexts = allHooksReversed.map<HookContext>(() =>
|
||||
Object.freeze({
|
||||
flagKey,
|
||||
defaultValue,
|
||||
flagValueType: flagType,
|
||||
clientMetadata: this.metadata,
|
||||
providerMetadata: this._provider.metadata,
|
||||
context,
|
||||
logger: this._logger,
|
||||
hookData: new MapHookData(),
|
||||
}),
|
||||
);
|
||||
|
||||
let evaluationDetails: EvaluationDetails<T>;
|
||||
|
||||
try {
|
||||
this.beforeHooks(allHooks, hookContext, options);
|
||||
this.beforeHooks(allHooks, hookContexts, options);
|
||||
|
||||
this.shortCircuitIfNotReady();
|
||||
|
||||
|
@ -261,45 +266,48 @@ export class OpenFeatureClient implements Client {
|
|||
|
||||
if (resolutionDetails.errorCode) {
|
||||
const err = instantiateErrorByErrorCode(resolutionDetails.errorCode, resolutionDetails.errorMessage);
|
||||
this.errorHooks(allHooksReversed, hookContext, err, options);
|
||||
this.errorHooks(allHooksReversed, hookContexts, err, options);
|
||||
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err, resolutionDetails.flagMetadata);
|
||||
} else {
|
||||
this.afterHooks(allHooksReversed, hookContext, resolutionDetails, options);
|
||||
this.afterHooks(allHooksReversed, hookContexts, resolutionDetails, options);
|
||||
evaluationDetails = resolutionDetails;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
this.errorHooks(allHooksReversed, hookContext, err, options);
|
||||
this.errorHooks(allHooksReversed, hookContexts, err, options);
|
||||
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err);
|
||||
}
|
||||
this.finallyHooks(allHooksReversed, hookContext, evaluationDetails, options);
|
||||
this.finallyHooks(allHooksReversed, hookContexts, evaluationDetails, options);
|
||||
return evaluationDetails;
|
||||
}
|
||||
|
||||
private beforeHooks(hooks: Hook[], hookContext: HookContext, options: FlagEvaluationOptions) {
|
||||
Object.freeze(hookContext);
|
||||
Object.freeze(hookContext.context);
|
||||
|
||||
for (const hook of hooks) {
|
||||
private beforeHooks(hooks: Hook[], hookContexts: HookContext[], options: FlagEvaluationOptions) {
|
||||
for (const [index, hook] of hooks.entries()) {
|
||||
const hookContextIndex = hooks.length - 1 - index; // reverse index for before hooks
|
||||
const hookContext = hookContexts[hookContextIndex];
|
||||
Object.freeze(hookContext);
|
||||
Object.freeze(hookContext.context);
|
||||
hook?.before?.(hookContext, Object.freeze(options.hookHints));
|
||||
}
|
||||
}
|
||||
|
||||
private afterHooks(
|
||||
hooks: Hook[],
|
||||
hookContext: HookContext,
|
||||
hookContexts: HookContext[],
|
||||
evaluationDetails: EvaluationDetails<FlagValue>,
|
||||
options: FlagEvaluationOptions,
|
||||
) {
|
||||
// run "after" hooks sequentially
|
||||
for (const hook of hooks) {
|
||||
for (const [index, hook] of hooks.entries()) {
|
||||
const hookContext = hookContexts[index];
|
||||
hook?.after?.(hookContext, evaluationDetails, options.hookHints);
|
||||
}
|
||||
}
|
||||
|
||||
private errorHooks(hooks: Hook[], hookContext: HookContext, err: unknown, options: FlagEvaluationOptions) {
|
||||
private errorHooks(hooks: Hook[], hookContexts: HookContext[], err: unknown, options: FlagEvaluationOptions) {
|
||||
// run "error" hooks sequentially
|
||||
for (const hook of hooks) {
|
||||
for (const [index, hook] of hooks.entries()) {
|
||||
try {
|
||||
const hookContext = hookContexts[index];
|
||||
hook?.error?.(hookContext, err, options.hookHints);
|
||||
} catch (err) {
|
||||
this._logger.error(`Unhandled error during 'error' hook: ${err}`);
|
||||
|
@ -313,13 +321,14 @@ export class OpenFeatureClient implements Client {
|
|||
|
||||
private finallyHooks(
|
||||
hooks: Hook[],
|
||||
hookContext: HookContext,
|
||||
hookContexts: HookContext[],
|
||||
evaluationDetails: EvaluationDetails<FlagValue>,
|
||||
options: FlagEvaluationOptions,
|
||||
) {
|
||||
// run "finally" hooks sequentially
|
||||
for (const hook of hooks) {
|
||||
for (const [index, hook] of hooks.entries()) {
|
||||
try {
|
||||
const hookContext = hookContexts[index];
|
||||
hook?.finally?.(hookContext, evaluationDetails, options.hookHints);
|
||||
} catch (err) {
|
||||
this._logger.error(`Unhandled error during 'finally' hook: ${err}`);
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
import type { BaseHook, FlagValue } from '@openfeature/core';
|
||||
|
||||
export type Hook = BaseHook<FlagValue, void, void>;
|
||||
export type Hook<TData = Record<string, unknown>> = BaseHook<FlagValue, TData, void, void>;
|
||||
|
|
|
@ -77,7 +77,7 @@ export class OpenFeatureAPI
|
|||
* Setting a provider supersedes the current provider used in new and existing unbound clients.
|
||||
* @param {Provider} provider The provider responsible for flag evaluations.
|
||||
* @returns {Promise<void>}
|
||||
* @throws Uncaught exceptions thrown by the provider during initialization.
|
||||
* @throws {Error} If the provider throws an exception during initialization.
|
||||
*/
|
||||
setProviderAndWait(provider: Provider): Promise<void>;
|
||||
/**
|
||||
|
@ -87,7 +87,7 @@ export class OpenFeatureAPI
|
|||
* @param {Provider} provider The provider responsible for flag evaluations.
|
||||
* @param {EvaluationContext} context The evaluation context to use for flag evaluations.
|
||||
* @returns {Promise<void>}
|
||||
* @throws Uncaught exceptions thrown by the provider during initialization.
|
||||
* @throws {Error} If the provider throws an exception during initialization.
|
||||
*/
|
||||
setProviderAndWait(provider: Provider, context: EvaluationContext): Promise<void>;
|
||||
/**
|
||||
|
@ -97,7 +97,7 @@ export class OpenFeatureAPI
|
|||
* @param {string} domain The name to identify the client
|
||||
* @param {Provider} provider The provider responsible for flag evaluations.
|
||||
* @returns {Promise<void>}
|
||||
* @throws Uncaught exceptions thrown by the provider during initialization.
|
||||
* @throws {Error} If the provider throws an exception during initialization.
|
||||
*/
|
||||
setProviderAndWait(domain: string, provider: Provider): Promise<void>;
|
||||
/**
|
||||
|
@ -108,7 +108,7 @@ export class OpenFeatureAPI
|
|||
* @param {Provider} provider The provider responsible for flag evaluations.
|
||||
* @param {EvaluationContext} context The evaluation context to use for flag evaluations.
|
||||
* @returns {Promise<void>}
|
||||
* @throws Uncaught exceptions thrown by the provider during initialization.
|
||||
* @throws {Error} If the provider throws an exception during initialization.
|
||||
*/
|
||||
setProviderAndWait(domain: string, provider: Provider, context: EvaluationContext): Promise<void>;
|
||||
async setProviderAndWait(
|
||||
|
|
|
@ -0,0 +1,436 @@
|
|||
import { OpenFeatureAPI } from '../src/open-feature';
|
||||
import type { Client } from '../src/client';
|
||||
import type { JsonValue, ResolutionDetails, HookContext, BeforeHookContext } from '@openfeature/core';
|
||||
import { StandardResolutionReasons } from '@openfeature/core';
|
||||
import type { Provider } from '../src/provider';
|
||||
import type { Hook } from '../src/hooks';
|
||||
|
||||
const BOOLEAN_VALUE = true;
|
||||
const STRING_VALUE = 'val';
|
||||
const NUMBER_VALUE = 1;
|
||||
const OBJECT_VALUE = { key: 'value' };
|
||||
|
||||
// A test hook that stores data in the before stage and retrieves it in after/error/finally
|
||||
class TestHookWithData implements Hook {
|
||||
beforeData: unknown;
|
||||
afterData: unknown;
|
||||
errorData: unknown;
|
||||
finallyData: unknown;
|
||||
|
||||
before(hookContext: BeforeHookContext) {
|
||||
// Store some data
|
||||
hookContext.hookData.set('testKey', 'testValue');
|
||||
hookContext.hookData.set('timestamp', Date.now());
|
||||
hookContext.hookData.set('object', { nested: 'value' });
|
||||
this.beforeData = hookContext.hookData.get('testKey');
|
||||
}
|
||||
|
||||
after(hookContext: HookContext) {
|
||||
// Retrieve data stored in before
|
||||
this.afterData = hookContext.hookData.get('testKey');
|
||||
}
|
||||
|
||||
error(hookContext: HookContext) {
|
||||
// Retrieve data stored in before
|
||||
this.errorData = hookContext.hookData.get('testKey');
|
||||
}
|
||||
|
||||
finally(hookContext: HookContext) {
|
||||
// Retrieve data stored in before
|
||||
this.finallyData = hookContext.hookData.get('testKey');
|
||||
}
|
||||
}
|
||||
|
||||
// A timing hook that measures evaluation duration
|
||||
class TimingHook implements Hook {
|
||||
duration?: number;
|
||||
|
||||
before(hookContext: BeforeHookContext) {
|
||||
hookContext.hookData.set('startTime', performance.now());
|
||||
}
|
||||
|
||||
after(hookContext: HookContext) {
|
||||
const startTime = hookContext.hookData.get('startTime') as number;
|
||||
if (startTime) {
|
||||
this.duration = performance.now() - startTime;
|
||||
}
|
||||
}
|
||||
|
||||
error(hookContext: HookContext) {
|
||||
const startTime = hookContext.hookData.get('startTime') as number;
|
||||
if (startTime) {
|
||||
this.duration = performance.now() - startTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hook that tests hook data isolation
|
||||
class IsolationTestHook implements Hook {
|
||||
hookId: string;
|
||||
|
||||
constructor(id: string) {
|
||||
this.hookId = id;
|
||||
}
|
||||
|
||||
before(hookContext: BeforeHookContext) {
|
||||
const storedId = hookContext.hookData.get('hookId');
|
||||
if (storedId) {
|
||||
throw new Error('Hook data isolation violated! Data is set in before hook.');
|
||||
}
|
||||
|
||||
// Each hook instance should have its own data
|
||||
hookContext.hookData.set('hookId', this.hookId);
|
||||
hookContext.hookData.set(`data_${this.hookId}`, `value_${this.hookId}`);
|
||||
}
|
||||
|
||||
after(hookContext: HookContext) {
|
||||
// Verify we can only see our own data
|
||||
const storedId = hookContext.hookData.get('hookId');
|
||||
if (storedId !== this.hookId) {
|
||||
throw new Error(`Hook data isolation violated! Expected ${this.hookId}, got ${storedId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mock provider for testing
|
||||
const MOCK_PROVIDER: Provider = {
|
||||
metadata: { name: 'mock-provider' },
|
||||
resolveBooleanEvaluation(): ResolutionDetails<boolean> {
|
||||
return {
|
||||
value: BOOLEAN_VALUE,
|
||||
variant: 'default',
|
||||
reason: StandardResolutionReasons.DEFAULT,
|
||||
};
|
||||
},
|
||||
resolveStringEvaluation(): ResolutionDetails<string> {
|
||||
return {
|
||||
value: STRING_VALUE,
|
||||
variant: 'default',
|
||||
reason: StandardResolutionReasons.DEFAULT,
|
||||
};
|
||||
},
|
||||
resolveNumberEvaluation(): ResolutionDetails<number> {
|
||||
return {
|
||||
value: NUMBER_VALUE,
|
||||
variant: 'default',
|
||||
reason: StandardResolutionReasons.DEFAULT,
|
||||
};
|
||||
},
|
||||
resolveObjectEvaluation<T extends JsonValue>(): ResolutionDetails<T> {
|
||||
return {
|
||||
value: OBJECT_VALUE as unknown as T,
|
||||
variant: 'default',
|
||||
reason: StandardResolutionReasons.DEFAULT,
|
||||
};
|
||||
},
|
||||
} as Provider;
|
||||
|
||||
// Mock provider that throws an error
|
||||
const ERROR_PROVIDER: Provider = {
|
||||
metadata: { name: 'error-provider' },
|
||||
resolveBooleanEvaluation(): ResolutionDetails<boolean> {
|
||||
throw new Error('Provider error');
|
||||
},
|
||||
resolveStringEvaluation(): ResolutionDetails<string> {
|
||||
throw new Error('Provider error');
|
||||
},
|
||||
resolveNumberEvaluation(): ResolutionDetails<number> {
|
||||
throw new Error('Provider error');
|
||||
},
|
||||
resolveObjectEvaluation<T extends JsonValue>(): ResolutionDetails<T> {
|
||||
throw new Error('Provider error');
|
||||
},
|
||||
};
|
||||
|
||||
describe('Hook Data (Web SDK)', () => {
|
||||
let client: Client;
|
||||
let api: OpenFeatureAPI;
|
||||
|
||||
beforeEach(() => {
|
||||
api = OpenFeatureAPI.getInstance();
|
||||
api.clearHooks();
|
||||
api.setProvider(MOCK_PROVIDER);
|
||||
client = api.getClient();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
api.clearProviders();
|
||||
});
|
||||
|
||||
describe('Basic Hook Data Functionality', () => {
|
||||
it('should allow hooks to store and retrieve data across stages', () => {
|
||||
const hook = new TestHookWithData();
|
||||
client.addHooks(hook);
|
||||
|
||||
client.getBooleanValue('test-flag', false);
|
||||
|
||||
// Verify data was stored in before and retrieved in all other stages
|
||||
expect(hook.beforeData).toBe('testValue');
|
||||
expect(hook.afterData).toBe('testValue');
|
||||
expect(hook.finallyData).toBe('testValue');
|
||||
});
|
||||
|
||||
it('should support storing different data types', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const storedValues: any = {};
|
||||
|
||||
const hook: Hook = {
|
||||
before(hookContext: BeforeHookContext) {
|
||||
// Store various types
|
||||
hookContext.hookData.set('string', 'test');
|
||||
hookContext.hookData.set('number', 42);
|
||||
hookContext.hookData.set('boolean', true);
|
||||
hookContext.hookData.set('object', { key: 'value' });
|
||||
hookContext.hookData.set('array', [1, 2, 3]);
|
||||
hookContext.hookData.set('null', null);
|
||||
hookContext.hookData.set('undefined', undefined);
|
||||
},
|
||||
|
||||
after(hookContext: HookContext) {
|
||||
storedValues.string = hookContext.hookData.get('string');
|
||||
storedValues.number = hookContext.hookData.get('number');
|
||||
storedValues.boolean = hookContext.hookData.get('boolean');
|
||||
storedValues.object = hookContext.hookData.get('object');
|
||||
storedValues.array = hookContext.hookData.get('array');
|
||||
storedValues.null = hookContext.hookData.get('null');
|
||||
storedValues.undefined = hookContext.hookData.get('undefined');
|
||||
},
|
||||
};
|
||||
|
||||
client.addHooks(hook);
|
||||
client.getBooleanValue('test-flag', false);
|
||||
|
||||
expect(storedValues.string).toBe('test');
|
||||
expect(storedValues.number).toBe(42);
|
||||
expect(storedValues.boolean).toBe(true);
|
||||
expect(storedValues.object).toEqual({ key: 'value' });
|
||||
expect(storedValues.array).toEqual([1, 2, 3]);
|
||||
expect(storedValues.null).toBeNull();
|
||||
expect(storedValues.undefined).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle hook data in error scenarios', () => {
|
||||
api.setProvider(ERROR_PROVIDER);
|
||||
const hook = new TestHookWithData();
|
||||
client.addHooks(hook);
|
||||
|
||||
client.getBooleanValue('test-flag', false);
|
||||
|
||||
// Verify data was accessible in error and finally stages
|
||||
expect(hook.beforeData).toBe('testValue');
|
||||
expect(hook.errorData).toBe('testValue');
|
||||
expect(hook.finallyData).toBe('testValue');
|
||||
expect(hook.afterData).toBeUndefined(); // after should not run on error
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hook Data API', () => {
|
||||
it('should support has() method', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const hasResults: any = {};
|
||||
|
||||
const hook: Hook = {
|
||||
before(hookContext: BeforeHookContext) {
|
||||
hookContext.hookData.set('exists', 'value');
|
||||
hasResults.beforeExists = hookContext.hookData.has('exists');
|
||||
hasResults.beforeNotExists = hookContext.hookData.has('notExists');
|
||||
},
|
||||
|
||||
after(hookContext: HookContext) {
|
||||
hasResults.afterExists = hookContext.hookData.has('exists');
|
||||
hasResults.afterNotExists = hookContext.hookData.has('notExists');
|
||||
},
|
||||
};
|
||||
|
||||
client.addHooks(hook);
|
||||
client.getBooleanValue('test-flag', false);
|
||||
|
||||
expect(hasResults.beforeExists).toBe(true);
|
||||
expect(hasResults.beforeNotExists).toBe(false);
|
||||
expect(hasResults.afterExists).toBe(true);
|
||||
expect(hasResults.afterNotExists).toBe(false);
|
||||
});
|
||||
|
||||
it('should support delete() method', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const deleteResults: any = {};
|
||||
|
||||
const hook: Hook = {
|
||||
before(hookContext: BeforeHookContext) {
|
||||
hookContext.hookData.set('toDelete', 'value');
|
||||
deleteResults.hasBeforeDelete = hookContext.hookData.has('toDelete');
|
||||
deleteResults.deleteResult = hookContext.hookData.delete('toDelete');
|
||||
deleteResults.hasAfterDelete = hookContext.hookData.has('toDelete');
|
||||
deleteResults.deleteAgainResult = hookContext.hookData.delete('toDelete');
|
||||
},
|
||||
};
|
||||
|
||||
client.addHooks(hook);
|
||||
client.getBooleanValue('test-flag', false);
|
||||
|
||||
expect(deleteResults.hasBeforeDelete).toBe(true);
|
||||
expect(deleteResults.deleteResult).toBe(true);
|
||||
expect(deleteResults.hasAfterDelete).toBe(false);
|
||||
expect(deleteResults.deleteAgainResult).toBe(false);
|
||||
});
|
||||
|
||||
it('should support clear() method', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const clearResults: any = {};
|
||||
|
||||
const hook: Hook = {
|
||||
before(hookContext: BeforeHookContext) {
|
||||
hookContext.hookData.set('key1', 'value1');
|
||||
hookContext.hookData.set('key2', 'value2');
|
||||
hookContext.hookData.set('key3', 'value3');
|
||||
clearResults.hasBeforeClear = hookContext.hookData.has('key1');
|
||||
hookContext.hookData.clear();
|
||||
clearResults.hasAfterClear = hookContext.hookData.has('key1');
|
||||
},
|
||||
|
||||
after(hookContext: HookContext) {
|
||||
// Verify all data was cleared
|
||||
clearResults.afterHasKey1 = hookContext.hookData.has('key1');
|
||||
clearResults.afterHasKey2 = hookContext.hookData.has('key2');
|
||||
clearResults.afterHasKey3 = hookContext.hookData.has('key3');
|
||||
},
|
||||
};
|
||||
|
||||
client.addHooks(hook);
|
||||
client.getBooleanValue('test-flag', false);
|
||||
|
||||
expect(clearResults.hasBeforeClear).toBe(true);
|
||||
expect(clearResults.hasAfterClear).toBe(false);
|
||||
expect(clearResults.afterHasKey1).toBe(false);
|
||||
expect(clearResults.afterHasKey2).toBe(false);
|
||||
expect(clearResults.afterHasKey3).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hook Data Isolation', () => {
|
||||
it('should isolate data between different hook instances', () => {
|
||||
const hook1 = new IsolationTestHook('hook1');
|
||||
const hook2 = new IsolationTestHook('hook2');
|
||||
const hook3 = new IsolationTestHook('hook3');
|
||||
|
||||
client.addHooks(hook1, hook2, hook3);
|
||||
|
||||
expect(client.getBooleanValue('test-flag', false)).toBe(true);
|
||||
});
|
||||
|
||||
it('should isolate data between the same hook instance', () => {
|
||||
const hook = new IsolationTestHook('hook');
|
||||
|
||||
client.addHooks(hook, hook);
|
||||
|
||||
expect(client.getBooleanValue('test-flag', false)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not share data between different evaluations', () => {
|
||||
let firstEvalData: unknown;
|
||||
let secondEvalData: unknown;
|
||||
|
||||
const hook: Hook = {
|
||||
before(hookContext: BeforeHookContext) {
|
||||
// Check if data exists from previous evaluation
|
||||
const existingData = hookContext.hookData.get('evalData');
|
||||
if (existingData) {
|
||||
throw new Error('Hook data leaked between evaluations!');
|
||||
}
|
||||
hookContext.hookData.set('evalData', 'evaluation-specific');
|
||||
},
|
||||
|
||||
after(hookContext: HookContext) {
|
||||
if (!firstEvalData) {
|
||||
firstEvalData = hookContext.hookData.get('evalData');
|
||||
} else {
|
||||
secondEvalData = hookContext.hookData.get('evalData');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
client.addHooks(hook);
|
||||
|
||||
// First evaluation
|
||||
client.getBooleanValue('test-flag', false);
|
||||
// Second evaluation
|
||||
client.getBooleanValue('test-flag', false);
|
||||
|
||||
expect(firstEvalData).toBe('evaluation-specific');
|
||||
expect(secondEvalData).toBe('evaluation-specific');
|
||||
});
|
||||
|
||||
it('should isolate data between global, client, and invocation hooks', () => {
|
||||
const globalHook = new IsolationTestHook('global');
|
||||
const clientHook = new IsolationTestHook('client');
|
||||
const invocationHook = new IsolationTestHook('invocation');
|
||||
|
||||
api.addHooks(globalHook);
|
||||
client.addHooks(clientHook);
|
||||
|
||||
expect(client.getBooleanValue('test-flag', false, { hooks: [invocationHook] })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Use Cases', () => {
|
||||
it('should support timing measurements', () => {
|
||||
const timingHook = new TimingHook();
|
||||
client.addHooks(timingHook);
|
||||
|
||||
client.getBooleanValue('test-flag', false);
|
||||
|
||||
expect(timingHook.duration).toBeDefined();
|
||||
expect(timingHook.duration).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should support multi-stage validation accumulation', () => {
|
||||
let finalErrors: string[] = [];
|
||||
|
||||
const validationHook: Hook = {
|
||||
before(hookContext: BeforeHookContext) {
|
||||
hookContext.hookData.set('errors', []);
|
||||
|
||||
// Simulate validation
|
||||
const errors = hookContext.hookData.get('errors') as string[];
|
||||
if (!hookContext.context.userId) {
|
||||
errors.push('Missing userId');
|
||||
}
|
||||
if (!hookContext.context.region) {
|
||||
errors.push('Missing region');
|
||||
}
|
||||
},
|
||||
|
||||
finally(hookContext: HookContext) {
|
||||
finalErrors = (hookContext.hookData.get('errors') as string[]) || [];
|
||||
},
|
||||
};
|
||||
|
||||
client.addHooks(validationHook);
|
||||
client.getBooleanValue('test-flag', false, {});
|
||||
|
||||
expect(finalErrors).toContain('Missing userId');
|
||||
expect(finalErrors).toContain('Missing region');
|
||||
});
|
||||
|
||||
it('should support request correlation', () => {
|
||||
let correlationId: string | undefined;
|
||||
|
||||
const correlationHook: Hook = {
|
||||
before(hookContext: BeforeHookContext) {
|
||||
const id = `req-${Date.now()}-${Math.random()}`;
|
||||
hookContext.hookData.set('correlationId', id);
|
||||
},
|
||||
|
||||
after(hookContext: HookContext) {
|
||||
correlationId = hookContext.hookData.get('correlationId') as string;
|
||||
},
|
||||
};
|
||||
|
||||
client.addHooks(correlationHook);
|
||||
client.getBooleanValue('test-flag', false);
|
||||
|
||||
expect(correlationId).toBeDefined();
|
||||
expect(correlationId).toMatch(/^req-\d+-[\d.]+$/);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -20,7 +20,6 @@
|
|||
"versioning": "default"
|
||||
},
|
||||
"packages/react": {
|
||||
"release-as": "1.0.0",
|
||||
"release-type": "node",
|
||||
"prerelease": false,
|
||||
"bump-minor-pre-major": true,
|
||||
|
|
Loading…
Reference in New Issue