Compare commits
81 Commits
server-sdk
...
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 | |
|
b60c3df372 | |
|
5b19eb035a | |
|
5afe61f9e3 | |
|
60401b6cec | |
|
4482c2b33a | |
|
2c5b37c79d | |
|
cf89e7da24 | |
|
ae8fce8753 | |
|
eec21dda82 | |
|
6a224830fa | |
|
a703ee7a6f | |
|
4c2b01e367 | |
|
40deec0414 | |
|
5272f76c40 | |
|
30004ea88f | |
|
dcbc30090e | |
|
23ba0b3d8c | |
|
8bb620601e | |
|
ffceec91bc |
|
@ -29,6 +29,6 @@ jobs:
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: 20
|
||||||
registry-url: "https://registry.npmjs.org"
|
registry-url: "https://registry.npmjs.org"
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
|
@ -16,7 +16,7 @@ jobs:
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
registry-url: 'https://registry.npmjs.org'
|
registry-url: 'https://registry.npmjs.org'
|
||||||
node-version: 18
|
node-version: 20
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install
|
- name: Install
|
||||||
|
|
|
@ -16,9 +16,9 @@ jobs:
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
node-version:
|
node-version:
|
||||||
- 18.x
|
|
||||||
- 20.x
|
- 20.x
|
||||||
- 22.x
|
- 22.x
|
||||||
|
- 24.x
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
@ -37,8 +37,11 @@ jobs:
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: npm run lint
|
run: npm run lint
|
||||||
|
|
||||||
- name: Test
|
- name: Test Jest Projects
|
||||||
run: npm run test
|
run: npm run test:jest
|
||||||
|
|
||||||
|
- name: Test Angular SDK
|
||||||
|
run: npm run test:angular
|
||||||
|
|
||||||
codecov-and-docs:
|
codecov-and-docs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
@ -47,7 +50,7 @@ jobs:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: 20
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install
|
- name: Install
|
||||||
|
@ -69,8 +72,7 @@ jobs:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
# we need 'fetch' for this test, which is only in 18
|
node-version: 20
|
||||||
node-version: 18
|
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install
|
- name: Install
|
||||||
|
|
|
@ -38,7 +38,7 @@ jobs:
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: 20
|
||||||
- name: Generate SBOM
|
- name: Generate SBOM
|
||||||
run: |
|
run: |
|
||||||
npm install -g npm@^10.2.0
|
npm install -g npm@^10.2.0
|
||||||
|
@ -54,6 +54,7 @@ jobs:
|
||||||
needs: release-please
|
needs: release-please
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: ${{ needs.release-please.outputs.release_created }}
|
if: ${{ needs.release-please.outputs.release_created }}
|
||||||
|
environment: publish
|
||||||
permissions:
|
permissions:
|
||||||
id-token: write
|
id-token: write
|
||||||
contents: write
|
contents: write
|
||||||
|
@ -64,7 +65,7 @@ jobs:
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: 20
|
||||||
registry-url: "https://registry.npmjs.org"
|
registry-url: "https://registry.npmjs.org"
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
- name: Build Packages
|
- name: Build Packages
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
{
|
{
|
||||||
"packages/nest": "0.2.2",
|
"packages/nest": "0.2.5",
|
||||||
"packages/react": "0.4.10",
|
"packages/react": "1.0.1",
|
||||||
"packages/angular": "0.0.1-experimental",
|
"packages/web": "1.6.1",
|
||||||
"packages/web": "1.4.0",
|
"packages/server": "1.19.0",
|
||||||
"packages/server": "1.17.1",
|
"packages/shared": "1.9.0",
|
||||||
"packages/shared": "1.7.0",
|
"packages/angular/projects/angular-sdk": "0.0.16"
|
||||||
"packages/angular/projects/angular-sdk": "0.0.9-experimental"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,4 +3,4 @@
|
||||||
#
|
#
|
||||||
# Managed by Peribolos: https://github.com/open-feature/community/blob/main/config/open-feature/sdk-javascript/workgroup.yaml
|
# Managed by Peribolos: https://github.com/open-feature/community/blob/main/config/open-feature/sdk-javascript/workgroup.yaml
|
||||||
#
|
#
|
||||||
* @open-feature/sdk-javascript-maintainers
|
* @open-feature/sdk-javascript-maintainers @open-feature/maintainers
|
||||||
|
|
|
@ -161,6 +161,7 @@ export default {
|
||||||
testMatch: ['<rootDir>/packages/nest/test/**/*.spec.ts'],
|
testMatch: ['<rootDir>/packages/nest/test/**/*.spec.ts'],
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'@openfeature/core': '<rootDir>/packages/shared/src',
|
'@openfeature/core': '<rootDir>/packages/shared/src',
|
||||||
|
'@openfeature/server-sdk': '<rootDir>/packages/server/src',
|
||||||
},
|
},
|
||||||
transform: {
|
transform: {
|
||||||
'^.+\\.ts$': [
|
'^.+\\.ts$': [
|
||||||
|
@ -189,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
|
// Use this configuration option to add custom reporters to Jest
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
25
package.json
25
package.json
|
@ -1,10 +1,15 @@
|
||||||
{
|
{
|
||||||
"name": "@openfeature/js",
|
"name": "@openfeature/js",
|
||||||
|
"engines": {
|
||||||
|
"npm": "^10.0.0"
|
||||||
|
},
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "OpenFeature SDK for JavaScript",
|
"description": "OpenFeature SDK for JavaScript",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "jest --selectProjects=shared --selectProjects=server --selectProjects=web --selectProjects=react --selectProjects=angular --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-server": "git submodule update --init --recursive && shx cp test-harness/features/evaluation.feature packages/server/e2e/features && jest --selectProjects=server-e2e --verbose",
|
||||||
"e2e-web": "git submodule update --init --recursive && shx cp test-harness/features/evaluation.feature packages/web/e2e/features && jest --selectProjects=web-e2e --verbose",
|
"e2e-web": "git submodule update --init --recursive && shx cp test-harness/features/evaluation.feature packages/web/e2e/features && jest --selectProjects=web-e2e --verbose",
|
||||||
"e2e": "npm run e2e-server && npm run e2e-web",
|
"e2e": "npm run e2e-server && npm run e2e-web",
|
||||||
|
@ -31,18 +36,15 @@
|
||||||
"url": "https://github.com/open-feature/js-sdk/issues"
|
"url": "https://github.com/open-feature/js-sdk/issues"
|
||||||
},
|
},
|
||||||
"homepage": "https://github.com/open-feature/js-sdk#readme",
|
"homepage": "https://github.com/open-feature/js-sdk#readme",
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rollup/plugin-typescript": "^11.1.6",
|
"@rollup/plugin-typescript": "^12.0.0",
|
||||||
"@testing-library/jest-dom": "^6.4.2",
|
"@testing-library/jest-dom": "^6.4.2",
|
||||||
"@testing-library/react": "^16.0.0",
|
"@testing-library/react": "^16.0.0",
|
||||||
"@types/jest": "^29.5.12",
|
"@types/jest": "^29.5.12",
|
||||||
"@types/node": "^20.11.16",
|
"@types/node": "^22.0.0",
|
||||||
"@types/react": "^18.2.55",
|
"@types/react": "^18.2.55",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"esbuild": "^0.24.0",
|
"esbuild": "^0.25.0",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-import-resolver-alias": "^1.1.2",
|
"eslint-import-resolver-alias": "^1.1.2",
|
||||||
|
@ -57,23 +59,18 @@
|
||||||
"jest-environment-jsdom": "^29.7.0",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
"jest-environment-node": "^29.7.0",
|
"jest-environment-node": "^29.7.0",
|
||||||
"jest-junit": "^16.0.0",
|
"jest-junit": "^16.0.0",
|
||||||
"jest-preset-angular": "^14.2.4",
|
|
||||||
"ng-packagr": "^18.2.1",
|
|
||||||
"prettier": "^3.2.5",
|
"prettier": "^3.2.5",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"rollup": "^4.0.0",
|
"rollup": "^4.0.0",
|
||||||
"rollup-plugin-dts": "^6.1.1",
|
"rollup-plugin-dts": "^6.1.1",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"shx": "^0.3.4",
|
"shx": "^0.4.0",
|
||||||
"ts-jest": "^29.1.2",
|
"ts-jest": "^29.1.2",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
"typedoc": "^0.26.0",
|
"typedoc": "^0.26.0",
|
||||||
"typescript": "^4.7.4",
|
"typescript": "^4.7.4",
|
||||||
"uuid": "^9.0.1"
|
"uuid": "^11.0.0"
|
||||||
},
|
|
||||||
"overrides": {
|
|
||||||
"typescript": "^4.7.4"
|
|
||||||
},
|
},
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/shared",
|
"packages/shared",
|
||||||
|
|
|
@ -10,7 +10,7 @@
|
||||||
"prefix": "lib",
|
"prefix": "lib",
|
||||||
"architect": {
|
"architect": {
|
||||||
"build": {
|
"build": {
|
||||||
"builder": "@angular-devkit/build-angular:ng-packagr",
|
"builder": "@angular/build:ng-packagr",
|
||||||
"options": {
|
"options": {
|
||||||
"project": "projects/angular-sdk/ng-package.json"
|
"project": "projects/angular-sdk/ng-package.json"
|
||||||
},
|
},
|
||||||
|
@ -32,6 +32,15 @@
|
||||||
"projects/angular-sdk/**/*.html"
|
"projects/angular-sdk/**/*.html"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"builder": "@angular/build:unit-test",
|
||||||
|
"options": {
|
||||||
|
"tsConfig": "projects/angular-sdk/tsconfig.spec.json",
|
||||||
|
"providersFile": "projects/angular-sdk/src/test-provider.ts",
|
||||||
|
"runner": "vitest",
|
||||||
|
"buildTarget": "::development"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,37 +7,42 @@
|
||||||
"lint": "ng lint",
|
"lint": "ng lint",
|
||||||
"lint:fix": "ng lint --fix",
|
"lint:fix": "ng lint --fix",
|
||||||
"watch": "ng build --watch --configuration development",
|
"watch": "ng build --watch --configuration development",
|
||||||
"test": "jest",
|
"test": "ng test",
|
||||||
|
"test:coverage": "ng test --no-watch --code-coverage",
|
||||||
"build": "ng build && npm run postbuild",
|
"build": "ng build && npm run postbuild",
|
||||||
"postbuild": "shx cp ./../../LICENSE ./dist/angular/LICENSE",
|
"postbuild": "shx cp ./../../LICENSE ./dist/angular/LICENSE",
|
||||||
"publish-if-not-exists": "cp $NPM_CONFIG_USERCONFIG .npmrc && if [ \"$(npm --prefix dist/angular run current-published-version -s)\" = \"$(npm --prefix dist/angular run current-version -s)\" ]; then echo 'already published, skipping'; else cd dist/angular && npm publish --access public; fi"
|
"publish-if-not-exists": "cp $NPM_CONFIG_USERCONFIG .npmrc && if [ \"$(npm --prefix dist/angular run current-published-version -s)\" = \"$(npm --prefix dist/angular run current-version -s)\" ]; then echo 'already published, skipping'; else cd dist/angular && npm publish --access public; fi"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-devkit/build-angular": "^19.0.0",
|
"@angular-eslint/builder": "^20.1.1",
|
||||||
"@angular-eslint/builder": "18.4.3",
|
"@angular-eslint/eslint-plugin": "^20.1.1",
|
||||||
"@angular-eslint/eslint-plugin": "18.4.3",
|
"@angular-eslint/eslint-plugin-template": "^20.1.1",
|
||||||
"@angular-eslint/eslint-plugin-template": "18.4.3",
|
"@angular-eslint/schematics": "^20.1.1",
|
||||||
"@angular-eslint/schematics": "18.4.3",
|
"@angular-eslint/template-parser": "^20.1.1",
|
||||||
"@angular-eslint/template-parser": "18.4.3",
|
"@angular/animations": "^20.1.1",
|
||||||
"@angular/animations": "^19.0.0",
|
"@angular/build": "^20.1.1",
|
||||||
"@angular/cli": "^19.0.0",
|
"@angular/cli": "^20.1.1",
|
||||||
"@angular/common": "^19.0.0",
|
"@angular/common": "^20.1.1",
|
||||||
"@angular/compiler": "^19.0.0",
|
"@angular/compiler": "^20.1.1",
|
||||||
"@angular/compiler-cli": "^19.0.0",
|
"@angular/compiler-cli": "^20.1.1",
|
||||||
"@angular/core": "^19.0.0",
|
"@angular/core": "^20.1.1",
|
||||||
"@angular/forms": "^19.0.0",
|
"@angular/forms": "^20.1.1",
|
||||||
"@angular/platform-browser": "^19.0.0",
|
"@angular/platform-browser": "^20.1.1",
|
||||||
"@angular/platform-browser-dynamic": "^19.0.0",
|
"@angular/platform-browser-dynamic": "^20.1.1",
|
||||||
"@angular/router": "^19.0.0",
|
"@angular/router": "^20.1.1",
|
||||||
"@typescript-eslint/eslint-plugin": "7.18.0",
|
"@typescript-eslint/eslint-plugin": "7.18.0",
|
||||||
"@typescript-eslint/parser": "7.18.0",
|
"@typescript-eslint/parser": "7.18.0",
|
||||||
|
"@vitest/browser": "^3.2.4",
|
||||||
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.0",
|
||||||
"jest-preset-angular": "^14.2.4",
|
"jsdom": "^26.1.0",
|
||||||
"ng-packagr": "^19.0.0",
|
"ng-packagr": "^20.1.0",
|
||||||
|
"playwright": "^1.53.2",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.8.3",
|
||||||
|
"vitest": "^3.2.4",
|
||||||
"zone.js": "~0.15.0"
|
"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,61 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
|
||||||
|
## [0.0.16](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.15...angular-sdk-v0.0.16) (2025-07-25)
|
||||||
|
|
||||||
|
|
||||||
|
### ✨ New Features
|
||||||
|
|
||||||
|
* support Angular 20 ([#1220](https://github.com/open-feature/js-sdk/issues/1220)) ([aa232a9](https://github.com/open-feature/js-sdk/commit/aa232a9d6a8dfa416380ccdecd71843d3e361048))
|
||||||
|
|
||||||
|
|
||||||
|
## [0.0.15](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.14...angular-sdk-v0.0.15) (2025-05-27)
|
||||||
|
|
||||||
|
|
||||||
|
### 🐛 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)
|
||||||
|
|
||||||
|
|
||||||
|
### 🧹 Chore
|
||||||
|
|
||||||
|
* **angular:** update angular package to a non-experimental version ([#1147](https://github.com/open-feature/js-sdk/issues/1147)) ([5272f76](https://github.com/open-feature/js-sdk/commit/5272f76c4075ebbd21f9b24dacac8f2d22e31ca9)), closes [#1110](https://github.com/open-feature/js-sdk/issues/1110)
|
||||||
|
* update sdk peer ([#1142](https://github.com/open-feature/js-sdk/issues/1142)) ([8bb6206](https://github.com/open-feature/js-sdk/commit/8bb620601e2b8dc7b62d717169b585bd1c886996))
|
||||||
|
|
||||||
## [0.0.9-experimental](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.8-experimental...angular-sdk-v0.0.9-experimental) (2024-11-21)
|
## [0.0.9-experimental](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.8-experimental...angular-sdk-v0.0.9-experimental) (2024-11-21)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -16,8 +16,8 @@
|
||||||
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
|
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
|
||||||
</a>
|
</a>
|
||||||
<!-- x-release-please-start-version -->
|
<!-- x-release-please-start-version -->
|
||||||
<a href="https://github.com/open-feature/js-sdk/releases/tag/angular-sdk-v0.0.9-experimental">
|
<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.9-experimental&color=blue&style=for-the-badge" />
|
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.0.16&color=blue&style=for-the-badge" />
|
||||||
</a>
|
</a>
|
||||||
<!-- x-release-please-end -->
|
<!-- x-release-please-end -->
|
||||||
<br/>
|
<br/>
|
||||||
|
@ -44,21 +44,22 @@ In addition to the features provided by the [web sdk](https://openfeature.dev/do
|
||||||
|
|
||||||
- [Overview](#overview)
|
- [Overview](#overview)
|
||||||
- [Quick start](#quick-start)
|
- [Quick start](#quick-start)
|
||||||
- [Requirements](#requirements)
|
- [Requirements](#requirements)
|
||||||
- [Install](#install)
|
- [Install](#install)
|
||||||
- [npm](#npm)
|
- [npm](#npm)
|
||||||
- [yarn](#yarn)
|
- [yarn](#yarn)
|
||||||
- [Required peer dependencies](#required-peer-dependencies)
|
- [Required peer dependencies](#required-peer-dependencies)
|
||||||
- [Usage](#usage)
|
- [Usage](#usage)
|
||||||
- [Module](#module)
|
- [Module](#module)
|
||||||
- [Minimal Example](#minimal-example)
|
- [Minimal Example](#minimal-example)
|
||||||
- [How to use](#how-to-use)
|
- [How to use](#how-to-use)
|
||||||
- [Boolean Feature Flag](#boolean-feature-flag)
|
- [Boolean Feature Flag](#boolean-feature-flag)
|
||||||
- [Number Feature Flag](#number-feature-flag)
|
- [Number Feature Flag](#number-feature-flag)
|
||||||
- [String Feature Flag](#string-feature-flag)
|
- [String Feature Flag](#string-feature-flag)
|
||||||
- [Object Feature Flag](#object-feature-flag)
|
- [Object Feature Flag](#object-feature-flag)
|
||||||
- [Opting-out of automatic re-rendering](#opting-out-of-automatic-re-rendering)
|
- [Opting-out of automatic re-rendering](#opting-out-of-automatic-re-rendering)
|
||||||
- [Consuming the evaluation details](#consuming-the-evaluation-details)
|
- [Consuming the evaluation details](#consuming-the-evaluation-details)
|
||||||
|
- [Setting Evaluation Context](#setting-evaluation-context)
|
||||||
- [FAQ and troubleshooting](#faq-and-troubleshooting)
|
- [FAQ and troubleshooting](#faq-and-troubleshooting)
|
||||||
- [Resources](#resources)
|
- [Resources](#resources)
|
||||||
|
|
||||||
|
@ -113,7 +114,7 @@ import { OpenFeatureModule } from '@openfeature/angular-sdk';
|
||||||
CommonModule,
|
CommonModule,
|
||||||
OpenFeatureModule.forRoot({
|
OpenFeatureModule.forRoot({
|
||||||
provider: yourFeatureProvider,
|
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: {
|
domainBoundProviders: {
|
||||||
domain1: new YourOpenFeatureProvider(),
|
domain1: new YourOpenFeatureProvider(),
|
||||||
domain2: new YourOtherOpenFeatureProvider(),
|
domain2: new YourOtherOpenFeatureProvider(),
|
||||||
|
@ -281,6 +282,63 @@ This can be used to just render the flag value or details without conditional re
|
||||||
</div>
|
</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
|
## FAQ and troubleshooting
|
||||||
|
|
||||||
> I can import things form the `@openfeature/angular-sdk`, `@openfeature/web-sdk`, and `@openfeature/core`; which should I use?
|
> 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
|
## 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",
|
"name": "@openfeature/angular-sdk",
|
||||||
"version": "0.0.9-experimental",
|
"version": "0.0.16",
|
||||||
"description": "OpenFeature Angular SDK",
|
"description": "OpenFeature Angular SDK",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/open-feature/js-sdk.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": {
|
"scripts": {
|
||||||
"current-published-version": "npm show $npm_package_name@$npm_package_version version",
|
"current-published-version": "npm show $npm_package_name@$npm_package_version version",
|
||||||
"current-version": "echo $npm_package_version",
|
"current-version": "echo $npm_package_version",
|
||||||
"prepack": "shx cp ./../../../../LICENSE ./LICENSE"
|
"prepack": "shx cp ./../../../../LICENSE ./LICENSE"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@angular/common": "^16.2.12 || ^17.3.0 || ^18.0.0 || ^19.0.0",
|
"@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",
|
"@angular/core": "^16.2.12 || ^17.3.0 || ^18.0.0 || ^19.0.0 || ^20.0.0",
|
||||||
"@openfeature/web-sdk": "^1.2.3"
|
"@openfeature/web-sdk": "^1.4.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@openfeature/core": "*",
|
"@openfeature/core": "^1.8.1",
|
||||||
"@openfeature/web-sdk": "*",
|
"@openfeature/web-sdk": "^1.5.0",
|
||||||
"@angular/common": "^19.0.0",
|
"@angular/common": "^20.1.2",
|
||||||
"@angular/core": "^19.0.0"
|
"@angular/core": "^20.1.2"
|
||||||
},
|
},
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|
|
@ -253,6 +253,7 @@ describe('FeatureFlagDirective', () => {
|
||||||
await expectRenderedText(fixture, 'case-2', 'Flag On');
|
await expectRenderedText(fixture, 'case-2', 'Flag On');
|
||||||
|
|
||||||
await updateFlagValue(provider, false);
|
await updateFlagValue(provider, false);
|
||||||
|
fixture.detectChanges(); // Ensure change detection after flag update
|
||||||
await expectRenderedText(fixture, 'case-2', 'Flag Off');
|
await expectRenderedText(fixture, 'case-2', 'Flag Off');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -393,7 +394,9 @@ describe('FeatureFlagDirective', () => {
|
||||||
await waitForClientReady(client);
|
await waitForClientReady(client);
|
||||||
await expectRenderedText(fixture, 'case-6', 'Flag On');
|
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');
|
await expectRenderedText(fixture, 'case-6', 'Flag Off');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -445,7 +448,9 @@ describe('FeatureFlagDirective', () => {
|
||||||
);
|
);
|
||||||
await OpenFeature.setProviderAndWait(newDomain, newProvider);
|
await OpenFeature.setProviderAndWait(newDomain, newProvider);
|
||||||
|
|
||||||
fixture.componentInstance.domain = newDomain;
|
fixture.componentRef.setInput('domain', newDomain);
|
||||||
|
await fixture.whenStable();
|
||||||
|
|
||||||
await expectRenderedText(fixture, 'case-6', 'Flag Off');
|
await expectRenderedText(fixture, 'case-6', 'Flag Off');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -560,8 +565,7 @@ async function createTestingModule(config?: {
|
||||||
],
|
],
|
||||||
}).createComponent(TestComponent);
|
}).createComponent(TestComponent);
|
||||||
|
|
||||||
fixture.componentInstance.domain = domain;
|
fixture.componentRef.setInput('domain', domain);
|
||||||
fixture.detectChanges();
|
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
|
|
||||||
const client = OpenFeature.getClient(domain);
|
const client = OpenFeature.getClient(domain);
|
||||||
|
|
|
@ -8,6 +8,7 @@ import {
|
||||||
OnInit,
|
OnInit,
|
||||||
TemplateRef,
|
TemplateRef,
|
||||||
ViewContainerRef,
|
ViewContainerRef,
|
||||||
|
inject,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import {
|
import {
|
||||||
Client,
|
Client,
|
||||||
|
@ -35,6 +36,9 @@ class FeatureFlagDirectiveContext<T extends FlagValue> {
|
||||||
selector: '[featureFlag]',
|
selector: '[featureFlag]',
|
||||||
})
|
})
|
||||||
export abstract class FeatureFlagDirective<T extends FlagValue> implements OnInit, OnDestroy, OnChanges {
|
export abstract class FeatureFlagDirective<T extends FlagValue> implements OnInit, OnDestroy, OnChanges {
|
||||||
|
protected _changeDetectorRef: ChangeDetectorRef;
|
||||||
|
protected _viewContainerRef: ViewContainerRef;
|
||||||
|
|
||||||
protected _featureFlagDefault: T;
|
protected _featureFlagDefault: T;
|
||||||
protected _featureFlagDomain: string | undefined;
|
protected _featureFlagDomain: string | undefined;
|
||||||
|
|
||||||
|
@ -64,13 +68,7 @@ export abstract class FeatureFlagDirective<T extends FlagValue> implements OnIni
|
||||||
protected _reconcilingTemplateRef: TemplateRef<FeatureFlagDirectiveContext<T>> | null;
|
protected _reconcilingTemplateRef: TemplateRef<FeatureFlagDirectiveContext<T>> | null;
|
||||||
protected _reconcilingViewRef: EmbeddedViewRef<unknown> | null;
|
protected _reconcilingViewRef: EmbeddedViewRef<unknown> | null;
|
||||||
|
|
||||||
protected constructor(
|
protected constructor() {}
|
||||||
protected _changeDetectorRef: ChangeDetectorRef,
|
|
||||||
protected _viewContainerRef: ViewContainerRef,
|
|
||||||
templateRef: TemplateRef<FeatureFlagDirectiveContext<T>>,
|
|
||||||
) {
|
|
||||||
this._thenTemplateRef = templateRef;
|
|
||||||
}
|
|
||||||
|
|
||||||
set featureFlagDomain(domain: string | undefined) {
|
set featureFlagDomain(domain: string | undefined) {
|
||||||
/**
|
/**
|
||||||
|
@ -232,6 +230,10 @@ export abstract class FeatureFlagDirective<T extends FlagValue> implements OnIni
|
||||||
selector: '[booleanFeatureFlag]',
|
selector: '[booleanFeatureFlag]',
|
||||||
})
|
})
|
||||||
export class BooleanFeatureFlagDirective extends FeatureFlagDirective<boolean> implements OnChanges {
|
export class BooleanFeatureFlagDirective extends FeatureFlagDirective<boolean> implements OnChanges {
|
||||||
|
override _changeDetectorRef = inject(ChangeDetectorRef);
|
||||||
|
override _viewContainerRef = inject(ViewContainerRef);
|
||||||
|
override _thenTemplateRef = inject<TemplateRef<FeatureFlagDirectiveContext<boolean>>>(TemplateRef);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The key of the boolean feature flag.
|
* The key of the boolean feature flag.
|
||||||
*/
|
*/
|
||||||
|
@ -242,12 +244,8 @@ export class BooleanFeatureFlagDirective extends FeatureFlagDirective<boolean> i
|
||||||
*/
|
*/
|
||||||
@Input({ required: true }) booleanFeatureFlagDefault: boolean;
|
@Input({ required: true }) booleanFeatureFlagDefault: boolean;
|
||||||
|
|
||||||
constructor(
|
constructor() {
|
||||||
_changeDetectorRef: ChangeDetectorRef,
|
super();
|
||||||
_viewContainerRef: ViewContainerRef,
|
|
||||||
templateRef: TemplateRef<FeatureFlagDirectiveContext<boolean>>,
|
|
||||||
) {
|
|
||||||
super(_changeDetectorRef, _viewContainerRef, templateRef);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override ngOnChanges() {
|
override ngOnChanges() {
|
||||||
|
@ -347,6 +345,10 @@ export class BooleanFeatureFlagDirective extends FeatureFlagDirective<boolean> i
|
||||||
selector: '[numberFeatureFlag]',
|
selector: '[numberFeatureFlag]',
|
||||||
})
|
})
|
||||||
export class NumberFeatureFlagDirective extends FeatureFlagDirective<number> implements OnChanges {
|
export class NumberFeatureFlagDirective extends FeatureFlagDirective<number> implements OnChanges {
|
||||||
|
override _changeDetectorRef = inject(ChangeDetectorRef);
|
||||||
|
override _viewContainerRef = inject(ViewContainerRef);
|
||||||
|
override _thenTemplateRef = inject<TemplateRef<FeatureFlagDirectiveContext<number>>>(TemplateRef);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The key of the number feature flag.
|
* The key of the number feature flag.
|
||||||
*/
|
*/
|
||||||
|
@ -362,12 +364,8 @@ export class NumberFeatureFlagDirective extends FeatureFlagDirective<number> imp
|
||||||
*/
|
*/
|
||||||
@Input({ required: false }) numberFeatureFlagValue?: number;
|
@Input({ required: false }) numberFeatureFlagValue?: number;
|
||||||
|
|
||||||
constructor(
|
constructor() {
|
||||||
_changeDetectorRef: ChangeDetectorRef,
|
super();
|
||||||
_viewContainerRef: ViewContainerRef,
|
|
||||||
templateRef: TemplateRef<FeatureFlagDirectiveContext<number>>,
|
|
||||||
) {
|
|
||||||
super(_changeDetectorRef, _viewContainerRef, templateRef);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override ngOnChanges() {
|
override ngOnChanges() {
|
||||||
|
@ -467,6 +465,10 @@ export class NumberFeatureFlagDirective extends FeatureFlagDirective<number> imp
|
||||||
selector: '[stringFeatureFlag]',
|
selector: '[stringFeatureFlag]',
|
||||||
})
|
})
|
||||||
export class StringFeatureFlagDirective extends FeatureFlagDirective<string> implements OnChanges {
|
export class StringFeatureFlagDirective extends FeatureFlagDirective<string> implements OnChanges {
|
||||||
|
override _changeDetectorRef = inject(ChangeDetectorRef);
|
||||||
|
override _viewContainerRef = inject(ViewContainerRef);
|
||||||
|
override _thenTemplateRef = inject<TemplateRef<FeatureFlagDirectiveContext<string>>>(TemplateRef);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The key of the string feature flag.
|
* The key of the string feature flag.
|
||||||
*/
|
*/
|
||||||
|
@ -482,12 +484,8 @@ export class StringFeatureFlagDirective extends FeatureFlagDirective<string> imp
|
||||||
*/
|
*/
|
||||||
@Input({ required: false }) stringFeatureFlagValue?: string;
|
@Input({ required: false }) stringFeatureFlagValue?: string;
|
||||||
|
|
||||||
constructor(
|
constructor() {
|
||||||
_changeDetectorRef: ChangeDetectorRef,
|
super();
|
||||||
_viewContainerRef: ViewContainerRef,
|
|
||||||
templateRef: TemplateRef<FeatureFlagDirectiveContext<string>>,
|
|
||||||
) {
|
|
||||||
super(_changeDetectorRef, _viewContainerRef, templateRef);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override ngOnChanges() {
|
override ngOnChanges() {
|
||||||
|
@ -587,6 +585,10 @@ export class StringFeatureFlagDirective extends FeatureFlagDirective<string> imp
|
||||||
selector: '[objectFeatureFlag]',
|
selector: '[objectFeatureFlag]',
|
||||||
})
|
})
|
||||||
export class ObjectFeatureFlagDirective<T extends JsonValue> extends FeatureFlagDirective<T> implements OnChanges {
|
export class ObjectFeatureFlagDirective<T extends JsonValue> extends FeatureFlagDirective<T> implements OnChanges {
|
||||||
|
override _changeDetectorRef = inject(ChangeDetectorRef);
|
||||||
|
override _viewContainerRef = inject(ViewContainerRef);
|
||||||
|
override _thenTemplateRef = inject<TemplateRef<FeatureFlagDirectiveContext<T>>>(TemplateRef);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The key of the object feature flag.
|
* The key of the object feature flag.
|
||||||
*/
|
*/
|
||||||
|
@ -602,12 +604,8 @@ export class ObjectFeatureFlagDirective<T extends JsonValue> extends FeatureFlag
|
||||||
*/
|
*/
|
||||||
@Input({ required: false }) objectFeatureFlagValue?: T;
|
@Input({ required: false }) objectFeatureFlagValue?: T;
|
||||||
|
|
||||||
constructor(
|
constructor() {
|
||||||
_changeDetectorRef: ChangeDetectorRef,
|
super();
|
||||||
_viewContainerRef: ViewContainerRef,
|
|
||||||
templateRef: TemplateRef<FeatureFlagDirectiveContext<T>>,
|
|
||||||
) {
|
|
||||||
super(_changeDetectorRef, _viewContainerRef, templateRef);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override ngOnChanges() {
|
override ngOnChanges() {
|
||||||
|
|
|
@ -1,10 +1,13 @@
|
||||||
import { InjectionToken, ModuleWithProviders, NgModule } from '@angular/core';
|
import { InjectionToken, ModuleWithProviders, NgModule } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
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 {
|
export interface OpenFeatureConfig {
|
||||||
provider: Provider;
|
provider: Provider;
|
||||||
domainBoundProviders?: Record<string, Provider>;
|
domainBoundProviders?: Record<string, Provider>;
|
||||||
|
context?: EvaluationContext | EvaluationContextFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OPEN_FEATURE_CONFIG_TOKEN = new InjectionToken<OpenFeatureConfig>('OPEN_FEATURE_CONFIG_TOKEN');
|
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 {
|
export class OpenFeatureModule {
|
||||||
static forRoot(config: OpenFeatureConfig): ModuleWithProviders<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) {
|
if (config.domainBoundProviders) {
|
||||||
Object.entries(config.domainBoundProviders).map(([domain, provider]) =>
|
Object.entries(config.domainBoundProviders).map(([domain, provider]) =>
|
||||||
OpenFeature.setProvider(domain, provider),
|
OpenFeature.setProvider(domain, provider),
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
import { provideZonelessChangeDetection } from '@angular/core';
|
||||||
|
|
||||||
|
export default [provideZonelessChangeDetection()];
|
|
@ -3,15 +3,20 @@
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./out-tsc/spec",
|
"outDir": "./out-tsc/spec",
|
||||||
"types": [
|
"types": [
|
||||||
"jest",
|
"vitest/globals",
|
||||||
"node"
|
"node"
|
||||||
],
|
],
|
||||||
|
"paths": {
|
||||||
|
"angular": [
|
||||||
|
"./dist/angular"
|
||||||
|
]
|
||||||
|
},
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"emitDecoratorMetadata": true
|
"emitDecoratorMetadata": true
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src/**/*.spec.ts",
|
"src/**/*.spec.ts",
|
||||||
"src/**/*.d.ts",
|
"src/**/*.d.ts",
|
||||||
"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,
|
"experimentalDecorators": true,
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"importHelpers": true,
|
"importHelpers": true,
|
||||||
"target": "ES2015",
|
"target": "ES2022",
|
||||||
"module": "ES2015",
|
"module": "ES2022",
|
||||||
"useDefineForClassFields": false,
|
"useDefineForClassFields": false,
|
||||||
"strictNullChecks": false,
|
"strictNullChecks": false,
|
||||||
"lib": [
|
"lib": [
|
||||||
|
|
|
@ -3,13 +3,14 @@
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./out-tsc/spec",
|
"outDir": "./out-tsc/spec",
|
||||||
"types": [
|
"types": [
|
||||||
"jest"
|
"vitest/globals",
|
||||||
|
"node"
|
||||||
],
|
],
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"emitDecoratorMetadata": true
|
"emitDecoratorMetadata": true
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src/**/*.spec.ts",
|
"projects/angular-sdk/src/**/*.spec.ts",
|
||||||
"src/**/*.d.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
|
# 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)
|
## [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" />
|
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
|
||||||
</a>
|
</a>
|
||||||
<!-- x-release-please-start-version -->
|
<!-- x-release-please-start-version -->
|
||||||
<a href="https://github.com/open-feature/js-sdk/releases/tag/nestjs-sdk-v0.2.2">
|
<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.2&color=blue&style=for-the-badge" />
|
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.2.5&color=blue&style=for-the-badge" />
|
||||||
</a>
|
</a>
|
||||||
<!-- x-release-please-end -->
|
<!-- x-release-please-end -->
|
||||||
<br/>
|
<br/>
|
||||||
|
@ -50,7 +50,7 @@ Capabilities include:
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
- Node.js version 18+
|
- Node.js version 20+
|
||||||
- NestJS version 8+
|
- NestJS version 8+
|
||||||
|
|
||||||
### Install
|
### 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:
|
The following list contains the peer dependencies of `@openfeature/nestjs-sdk` with its expected and compatible versions:
|
||||||
|
|
||||||
* `@openfeature/server-sdk`: >=1.7.5
|
- `@openfeature/server-sdk`: >=1.7.5
|
||||||
* `@nestjs/common`: ^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
|
- `@nestjs/core`: ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
|
||||||
* `rxjs`: ^6.0.0 || ^7.0.0 || ^8.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`.
|
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
|
## Module additional information
|
||||||
|
|
||||||
### Flag evaluation context injection
|
### Flag evaluation context injection
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@openfeature/nestjs-sdk",
|
"name": "@openfeature/nestjs-sdk",
|
||||||
"version": "0.2.2",
|
"version": "0.2.5",
|
||||||
"description": "OpenFeature Nest.js SDK",
|
"description": "OpenFeature Nest.js SDK",
|
||||||
"main": "./dist/cjs/index.js",
|
"main": "./dist/cjs/index.js",
|
||||||
"files": [
|
"files": [
|
||||||
|
@ -46,18 +46,18 @@
|
||||||
},
|
},
|
||||||
"homepage": "https://github.com/open-feature/js-sdk#readme",
|
"homepage": "https://github.com/open-feature/js-sdk#readme",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@nestjs/common": "^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",
|
"@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
|
||||||
"rxjs": "^6.0.0 || ^7.0.0 || 8.0.0",
|
"rxjs": "^6.0.0 || ^7.0.0 || 8.0.0",
|
||||||
"@openfeature/server-sdk": ">=1.14.0"
|
"@openfeature/server-sdk": "^1.17.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/common": "^10.3.6",
|
"@nestjs/common": "^11.0.20",
|
||||||
"@nestjs/core": "^10.3.6",
|
"@nestjs/core": "^11.0.20",
|
||||||
"@nestjs/platform-express": "^10.3.6",
|
"@nestjs/platform-express": "^11.0.20",
|
||||||
"@nestjs/testing": "^10.3.6",
|
"@nestjs/testing": "^11.0.20",
|
||||||
"@openfeature/core": "*",
|
"@openfeature/core": "*",
|
||||||
"@openfeature/server-sdk": "*",
|
"@openfeature/server-sdk": "1.18.0",
|
||||||
"@types/supertest": "^6.0.0",
|
"@types/supertest": "^6.0.0",
|
||||||
"supertest": "^7.0.0"
|
"supertest": "^7.0.0"
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,16 +1,10 @@
|
||||||
import { createParamDecorator, Inject } from '@nestjs/common';
|
import { createParamDecorator, Inject } from '@nestjs/common';
|
||||||
import type {
|
import type { EvaluationContext, EvaluationDetails, FlagValue, JsonValue } from '@openfeature/server-sdk';
|
||||||
EvaluationContext,
|
import { Client } from '@openfeature/server-sdk';
|
||||||
EvaluationDetails,
|
|
||||||
FlagValue,
|
|
||||||
JsonValue} from '@openfeature/server-sdk';
|
|
||||||
import {
|
|
||||||
OpenFeature,
|
|
||||||
Client,
|
|
||||||
} from '@openfeature/server-sdk';
|
|
||||||
import { getOpenFeatureClientToken } from './open-feature.module';
|
import { getOpenFeatureClientToken } from './open-feature.module';
|
||||||
import type { Observable } from 'rxjs';
|
import type { Observable } from 'rxjs';
|
||||||
import { from } from 'rxjs';
|
import { from } from 'rxjs';
|
||||||
|
import { getClientForEvaluation } from './utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options for injecting an OpenFeature client into a constructor.
|
* Options for injecting an OpenFeature client into a constructor.
|
||||||
|
@ -56,16 +50,6 @@ interface FeatureProps<T extends FlagValue> {
|
||||||
context?: EvaluationContext;
|
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.
|
* Route handler parameter decorator.
|
||||||
*
|
*
|
||||||
|
|
|
@ -2,5 +2,6 @@ export * from './open-feature.module';
|
||||||
export * from './feature.decorator';
|
export * from './feature.decorator';
|
||||||
export * from './evaluation-context-interceptor';
|
export * from './evaluation-context-interceptor';
|
||||||
export * from './context-factory';
|
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
|
// re-export the server-sdk so consumers can access that API from the nestjs-sdk
|
||||||
export * from '@openfeature/server-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 { InMemoryProvider } from '@openfeature/server-sdk';
|
||||||
|
import type { EvaluationContext } from '@openfeature/server-sdk';
|
||||||
import type { ExecutionContext } from '@nestjs/common';
|
import type { ExecutionContext } from '@nestjs/common';
|
||||||
import { OpenFeatureModule } from '../src';
|
import { OpenFeatureModule } from '../src';
|
||||||
|
|
||||||
|
@ -23,6 +24,17 @@ export const defaultProvider = new InMemoryProvider({
|
||||||
variants: { default: { client: 'default' } },
|
variants: { default: { client: 'default' } },
|
||||||
disabled: false,
|
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 = {
|
export const providers = {
|
||||||
|
|
|
@ -2,7 +2,12 @@ import type { TestingModule } from '@nestjs/testing';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import type { INestApplication } from '@nestjs/common';
|
import type { INestApplication } from '@nestjs/common';
|
||||||
import supertest from 'supertest';
|
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 { exampleContextFactory, getOpenFeatureDefaultTestModule } from './fixtures';
|
||||||
import { OpenFeatureModule } from '../src';
|
import { OpenFeatureModule } from '../src';
|
||||||
import { defaultProvider, providers } from './fixtures';
|
import { defaultProvider, providers } from './fixtures';
|
||||||
|
@ -14,11 +19,9 @@ describe('OpenFeature SDK', () => {
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
moduleRef = await Test.createTestingModule({
|
moduleRef = await Test.createTestingModule({
|
||||||
imports: [
|
imports: [getOpenFeatureDefaultTestModule()],
|
||||||
getOpenFeatureDefaultTestModule()
|
|
||||||
],
|
|
||||||
providers: [OpenFeatureTestService],
|
providers: [OpenFeatureTestService],
|
||||||
controllers: [OpenFeatureController],
|
controllers: [OpenFeatureController, OpenFeatureRequireFlagsEnabledController],
|
||||||
}).compile();
|
}).compile();
|
||||||
app = moduleRef.createNestApplication();
|
app = moduleRef.createNestApplication();
|
||||||
app = await app.init();
|
app = await app.init();
|
||||||
|
@ -112,7 +115,7 @@ describe('OpenFeature SDK', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('evaluation context service should', () => {
|
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');
|
const evaluationSpy = jest.spyOn(defaultProvider, 'resolveBooleanEvaluation');
|
||||||
await supertest(app.getHttpServer())
|
await supertest(app.getHttpServer())
|
||||||
.get('/dynamic-context-in-service')
|
.get('/dynamic-context-in-service')
|
||||||
|
@ -122,26 +125,77 @@ describe('OpenFeature SDK', () => {
|
||||||
expect(evaluationSpy).toHaveBeenCalledWith('testBooleanFlag', false, { targetingKey: 'dynamic-user' }, {});
|
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', () => {
|
describe('Without global context interceptor', () => {
|
||||||
|
|
||||||
let moduleRef: TestingModule;
|
let moduleRef: TestingModule;
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
|
||||||
moduleRef = await Test.createTestingModule({
|
moduleRef = await Test.createTestingModule({
|
||||||
imports: [
|
imports: [
|
||||||
OpenFeatureModule.forRoot({
|
OpenFeatureModule.forRoot({
|
||||||
contextFactory: exampleContextFactory,
|
contextFactory: exampleContextFactory,
|
||||||
defaultProvider,
|
defaultProvider,
|
||||||
providers,
|
providers,
|
||||||
useGlobalInterceptor: false
|
useGlobalInterceptor: false,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
providers: [OpenFeatureTestService],
|
providers: [OpenFeatureTestService],
|
||||||
controllers: [OpenFeatureController, OpenFeatureControllerContextScopedController],
|
controllers: [OpenFeatureController, OpenFeatureContextScopedController],
|
||||||
}).compile();
|
}).compile();
|
||||||
app = moduleRef.createNestApplication();
|
app = moduleRef.createNestApplication();
|
||||||
app = await app.init();
|
app = await app.init();
|
||||||
|
@ -158,7 +212,7 @@ describe('OpenFeature SDK', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('evaluation context service should', () => {
|
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');
|
const evaluationSpy = jest.spyOn(defaultProvider, 'resolveBooleanEvaluation');
|
||||||
await supertest(app.getHttpServer())
|
await supertest(app.getHttpServer())
|
||||||
.get('/dynamic-context-in-service')
|
.get('/dynamic-context-in-service')
|
||||||
|
@ -172,9 +226,26 @@ describe('OpenFeature SDK', () => {
|
||||||
describe('With Controller bound Context interceptor', () => {
|
describe('With Controller bound Context interceptor', () => {
|
||||||
it('should not use context if global context interceptor is not configured', async () => {
|
it('should not use context if global context interceptor is not configured', async () => {
|
||||||
const evaluationSpy = jest.spyOn(defaultProvider, 'resolveBooleanEvaluation');
|
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' }, {});
|
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 { Controller, ForbiddenException, Get, Injectable, UseInterceptors } from '@nestjs/common';
|
||||||
import type { Observable} from 'rxjs';
|
import type { Observable } from 'rxjs';
|
||||||
import { map } 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 type { Client, EvaluationDetails, FlagValue } from '@openfeature/server-sdk';
|
||||||
import { EvaluationContextInterceptor } from '../src';
|
import { EvaluationContextInterceptor } from '../src';
|
||||||
|
|
||||||
|
@ -84,11 +91,40 @@ export class OpenFeatureController {
|
||||||
public async handleDynamicContextInServiceRequest() {
|
public async handleDynamicContextInServiceRequest() {
|
||||||
return this.testService.serviceMethodWithDynamicContext('testBooleanFlag');
|
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()
|
@Controller()
|
||||||
@UseInterceptors(EvaluationContextInterceptor)
|
@UseInterceptors(EvaluationContextInterceptor)
|
||||||
export class OpenFeatureControllerContextScopedController {
|
export class OpenFeatureContextScopedController {
|
||||||
constructor(private testService: OpenFeatureTestService) {}
|
constructor(private testService: OpenFeatureTestService) {}
|
||||||
|
|
||||||
@Get('/controller-context')
|
@Get('/controller-context')
|
||||||
|
@ -101,4 +137,27 @@ export class OpenFeatureControllerContextScopedController {
|
||||||
) {
|
) {
|
||||||
return feature.pipe(map((details) => this.testService.serviceMethod(details)));
|
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,32 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [1.0.1](https://github.com/open-feature/js-sdk/compare/react-sdk-v1.0.0...react-sdk-v1.0.1) (2025-08-18)
|
||||||
|
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
* **react:** re-evaluate flags on re-render to detect silent provider … ([#1226](https://github.com/open-feature/js-sdk/issues/1226)) ([3105595](https://github.com/open-feature/js-sdk/commit/31055959265a53f52102590f54fa3168811ec678))
|
||||||
|
|
||||||
|
## [1.0.0](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.4.11...react-sdk-v1.0.0) (2025-04-14)
|
||||||
|
|
||||||
|
|
||||||
|
### ✨ 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)
|
||||||
|
|
||||||
|
|
||||||
|
### ✨ New Features
|
||||||
|
|
||||||
|
* export useOpenFeatureClientStatus hook ([#1082](https://github.com/open-feature/js-sdk/issues/1082)) ([4a6b860](https://github.com/open-feature/js-sdk/commit/4a6b8605444edeaf43355713357fecb97dd850b6))
|
||||||
|
|
||||||
|
|
||||||
|
### 🧹 Chore
|
||||||
|
|
||||||
|
* update sdk peer ([#1142](https://github.com/open-feature/js-sdk/issues/1142)) ([8bb6206](https://github.com/open-feature/js-sdk/commit/8bb620601e2b8dc7b62d717169b585bd1c886996))
|
||||||
|
|
||||||
## [0.4.10](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.4.9...react-sdk-v0.4.10) (2024-12-18)
|
## [0.4.10](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.4.9...react-sdk-v0.4.10) (2024-12-18)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -16,8 +16,8 @@
|
||||||
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
|
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
|
||||||
</a>
|
</a>
|
||||||
<!-- x-release-please-start-version -->
|
<!-- x-release-please-start-version -->
|
||||||
<a href="https://github.com/open-feature/js-sdk/releases/tag/react-sdk-v0.4.10">
|
<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=v0.4.10&color=blue&style=for-the-badge" />
|
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v1.0.1&color=blue&style=for-the-badge" />
|
||||||
</a>
|
</a>
|
||||||
<!-- x-release-please-end -->
|
<!-- x-release-please-end -->
|
||||||
<br/>
|
<br/>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@openfeature/react-sdk",
|
"name": "@openfeature/react-sdk",
|
||||||
"version": "0.4.10",
|
"version": "1.0.1",
|
||||||
"description": "OpenFeature React SDK",
|
"description": "OpenFeature React SDK",
|
||||||
"main": "./dist/cjs/index.js",
|
"main": "./dist/cjs/index.js",
|
||||||
"files": [
|
"files": [
|
||||||
|
@ -47,7 +47,7 @@
|
||||||
},
|
},
|
||||||
"homepage": "https://github.com/open-feature/js-sdk#readme",
|
"homepage": "https://github.com/open-feature/js-sdk#readme",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@openfeature/web-sdk": "^1.3.0",
|
"@openfeature/web-sdk": "^1.5.0",
|
||||||
"react": ">=16.8.0"
|
"react": ">=16.8.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
@ -5,18 +5,24 @@ import type {
|
||||||
EventHandler,
|
EventHandler,
|
||||||
FlagEvaluationOptions,
|
FlagEvaluationOptions,
|
||||||
FlagValue,
|
FlagValue,
|
||||||
JsonValue} from '@openfeature/web-sdk';
|
JsonValue,
|
||||||
import {
|
|
||||||
ProviderEvents,
|
|
||||||
ProviderStatus,
|
|
||||||
} from '@openfeature/web-sdk';
|
} from '@openfeature/web-sdk';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { ProviderEvents, ProviderStatus } from '@openfeature/web-sdk';
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
DEFAULT_OPTIONS,
|
||||||
|
isEqual,
|
||||||
|
normalizeOptions,
|
||||||
|
suspendUntilInitialized,
|
||||||
|
suspendUntilReconciled,
|
||||||
|
useProviderOptions,
|
||||||
|
} from '../internal';
|
||||||
import type { ReactFlagEvaluationNoSuspenseOptions, ReactFlagEvaluationOptions } from '../options';
|
import type { ReactFlagEvaluationNoSuspenseOptions, ReactFlagEvaluationOptions } from '../options';
|
||||||
import { DEFAULT_OPTIONS, isEqual, normalizeOptions, suspendUntilReady, useProviderOptions } from '../internal';
|
|
||||||
import { useOpenFeatureClient } from '../provider/use-open-feature-client';
|
import { useOpenFeatureClient } from '../provider/use-open-feature-client';
|
||||||
import { useOpenFeatureClientStatus } from '../provider/use-open-feature-client-status';
|
import { useOpenFeatureClientStatus } from '../provider/use-open-feature-client-status';
|
||||||
|
import { useOpenFeatureProvider } from '../provider/use-open-feature-provider';
|
||||||
import type { FlagQuery } from '../query';
|
import type { FlagQuery } from '../query';
|
||||||
import { HookFlagQuery } from './hook-flag-query';
|
import { HookFlagQuery } from '../internal/hook-flag-query';
|
||||||
|
|
||||||
// This type is a bit wild-looking, but I think we need it.
|
// This type is a bit wild-looking, but I think we need it.
|
||||||
// We have to use the conditional, because otherwise useFlag('key', false) would return false, not boolean (too constrained).
|
// We have to use the conditional, because otherwise useFlag('key', false) would return false, not boolean (too constrained).
|
||||||
|
@ -280,27 +286,41 @@ function attachHandlersAndResolve<T extends FlagValue>(
|
||||||
const defaultedOptions = { ...DEFAULT_OPTIONS, ...useProviderOptions(), ...normalizeOptions(options) };
|
const defaultedOptions = { ...DEFAULT_OPTIONS, ...useProviderOptions(), ...normalizeOptions(options) };
|
||||||
const client = useOpenFeatureClient();
|
const client = useOpenFeatureClient();
|
||||||
const status = useOpenFeatureClientStatus();
|
const status = useOpenFeatureClientStatus();
|
||||||
|
const provider = useOpenFeatureProvider();
|
||||||
|
const isFirstRender = useRef(true);
|
||||||
|
|
||||||
// suspense
|
|
||||||
if (defaultedOptions.suspendUntilReady && status === ProviderStatus.NOT_READY) {
|
if (defaultedOptions.suspendUntilReady && status === ProviderStatus.NOT_READY) {
|
||||||
suspendUntilReady(client);
|
suspendUntilInitialized(provider, client);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (defaultedOptions.suspendWhileReconciling && status === ProviderStatus.RECONCILING) {
|
if (defaultedOptions.suspendWhileReconciling && status === ProviderStatus.RECONCILING) {
|
||||||
suspendUntilReady(client);
|
suspendUntilReconciled(client);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [evaluationDetails, setEvaluationDetails] = useState<EvaluationDetails<T>>(
|
const [evaluationDetails, setEvaluationDetails] = useState<EvaluationDetails<T>>(() =>
|
||||||
resolver(client).call(client, flagKey, defaultValue, options),
|
resolver(client).call(client, flagKey, defaultValue, options),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Re-evaluate when dependencies change (handles prop changes like flagKey), or if during a re-render, we have detected a change in the evaluated value
|
||||||
|
useEffect(() => {
|
||||||
|
if (isFirstRender.current) {
|
||||||
|
isFirstRender.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newDetails = resolver(client).call(client, flagKey, defaultValue, options);
|
||||||
|
if (!isEqual(newDetails.value, evaluationDetails.value)) {
|
||||||
|
setEvaluationDetails(newDetails);
|
||||||
|
}
|
||||||
|
}, [client, flagKey, defaultValue, options, resolver, evaluationDetails]);
|
||||||
|
|
||||||
// Maintain a mutable reference to the evaluation details to have a up-to-date reference in the handlers.
|
// Maintain a mutable reference to the evaluation details to have a up-to-date reference in the handlers.
|
||||||
const evaluationDetailsRef = useRef<EvaluationDetails<T>>(evaluationDetails);
|
const evaluationDetailsRef = useRef<EvaluationDetails<T>>(evaluationDetails);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
evaluationDetailsRef.current = evaluationDetails;
|
evaluationDetailsRef.current = evaluationDetails;
|
||||||
}, [evaluationDetails]);
|
}, [evaluationDetails]);
|
||||||
|
|
||||||
const updateEvaluationDetailsCallback = () => {
|
const updateEvaluationDetailsCallback = useCallback(() => {
|
||||||
const updatedEvaluationDetails = resolver(client).call(client, flagKey, defaultValue, options);
|
const updatedEvaluationDetails = resolver(client).call(client, flagKey, defaultValue, options);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -311,41 +331,47 @@ function attachHandlersAndResolve<T extends FlagValue>(
|
||||||
if (!isEqual(updatedEvaluationDetails.value, evaluationDetailsRef.current.value)) {
|
if (!isEqual(updatedEvaluationDetails.value, evaluationDetailsRef.current.value)) {
|
||||||
setEvaluationDetails(updatedEvaluationDetails);
|
setEvaluationDetails(updatedEvaluationDetails);
|
||||||
}
|
}
|
||||||
};
|
}, [client, flagKey, defaultValue, options, resolver]);
|
||||||
|
|
||||||
const configurationChangeCallback: EventHandler<ClientProviderEvents.ConfigurationChanged> = (eventDetails) => {
|
const configurationChangeCallback = useCallback<EventHandler<ClientProviderEvents.ConfigurationChanged>>(
|
||||||
if (shouldEvaluateFlag(flagKey, eventDetails?.flagsChanged)) {
|
(eventDetails) => {
|
||||||
updateEvaluationDetailsCallback();
|
if (shouldEvaluateFlag(flagKey, eventDetails?.flagsChanged)) {
|
||||||
}
|
updateEvaluationDetailsCallback();
|
||||||
};
|
}
|
||||||
|
},
|
||||||
|
[flagKey, updateEvaluationDetailsCallback],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
if (status === ProviderStatus.NOT_READY) {
|
if (status === ProviderStatus.NOT_READY) {
|
||||||
// update when the provider is ready
|
// update when the provider is ready
|
||||||
client.addHandler(ProviderEvents.Ready, updateEvaluationDetailsCallback);
|
client.addHandler(ProviderEvents.Ready, updateEvaluationDetailsCallback, { signal: controller.signal });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (defaultedOptions.updateOnContextChanged) {
|
if (defaultedOptions.updateOnContextChanged) {
|
||||||
// update when the context changes
|
// update when the context changes
|
||||||
client.addHandler(ProviderEvents.ContextChanged, updateEvaluationDetailsCallback);
|
client.addHandler(ProviderEvents.ContextChanged, updateEvaluationDetailsCallback, { signal: controller.signal });
|
||||||
}
|
}
|
||||||
return () => {
|
|
||||||
// cleanup the handlers
|
|
||||||
client.removeHandler(ProviderEvents.Ready, updateEvaluationDetailsCallback);
|
|
||||||
client.removeHandler(ProviderEvents.ContextChanged, updateEvaluationDetailsCallback);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (defaultedOptions.updateOnConfigurationChanged) {
|
if (defaultedOptions.updateOnConfigurationChanged) {
|
||||||
// update when the provider configuration changes
|
// update when the provider configuration changes
|
||||||
client.addHandler(ProviderEvents.ConfigurationChanged, configurationChangeCallback);
|
client.addHandler(ProviderEvents.ConfigurationChanged, configurationChangeCallback, {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
// cleanup the handlers
|
// cleanup the handlers
|
||||||
client.removeHandler(ProviderEvents.ConfigurationChanged, configurationChangeCallback);
|
controller.abort();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [
|
||||||
|
client,
|
||||||
|
status,
|
||||||
|
defaultedOptions.updateOnContextChanged,
|
||||||
|
defaultedOptions.updateOnConfigurationChanged,
|
||||||
|
updateEvaluationDetailsCallback,
|
||||||
|
configurationChangeCallback,
|
||||||
|
]);
|
||||||
|
|
||||||
return evaluationDetails;
|
return evaluationDetails;
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,8 @@ import { normalizeOptions } from '.';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The underlying React context.
|
* The underlying React context.
|
||||||
* DO NOT EXPORT PUBLICLY
|
*
|
||||||
|
* **DO NOT EXPORT PUBLICLY**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
export const Context = React.createContext<
|
export const Context = React.createContext<
|
||||||
|
@ -14,7 +15,8 @@ export const Context = React.createContext<
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a normalized copy of the options used for this OpenFeatureProvider, see {@link normalizeOptions}.
|
* Get a normalized copy of the options used for this OpenFeatureProvider, see {@link normalizeOptions}.
|
||||||
* DO NOT EXPORT PUBLICLY
|
*
|
||||||
|
* **DO NOT EXPORT PUBLICLY**
|
||||||
* @internal
|
* @internal
|
||||||
* @returns {NormalizedOptions} normalized options the defaulted options, not defaulted or normalized.
|
* @returns {NormalizedOptions} normalized options the defaulted options, not defaulted or normalized.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
const context = 'Components using OpenFeature must be wrapped with an <OpenFeatureProvider>.';
|
||||||
|
const tip = 'If you are seeing this in a test, see: https://openfeature.dev/docs/reference/technologies/client/web/react#testing';
|
||||||
|
|
||||||
|
export class MissingContextError extends Error {
|
||||||
|
constructor(reason: string) {
|
||||||
|
super(`${reason}: ${context} ${tip}`);
|
||||||
|
this.name = 'MissingContextError';
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,21 +1,56 @@
|
||||||
import type { Client} from '@openfeature/web-sdk';
|
import type { Client, Provider } from '@openfeature/web-sdk';
|
||||||
import { ProviderEvents } from '@openfeature/web-sdk';
|
import { NOOP_PROVIDER, ProviderEvents } from '@openfeature/web-sdk';
|
||||||
|
import { use } from './use';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A weak map is used to store the global suspense status for each provider. It's
|
||||||
|
* important for this to be global to avoid rerender loops. Using useRef won't
|
||||||
|
* work because the value isn't preserved when a promise is thrown in a component,
|
||||||
|
* which is how suspense operates.
|
||||||
|
*/
|
||||||
|
const globalProviderSuspenseStatus = new WeakMap<Provider, Promise<unknown>>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Suspends until the client is ready to evaluate feature flags.
|
* Suspends until the client is ready to evaluate feature flags.
|
||||||
* DO NOT EXPORT PUBLICLY
|
*
|
||||||
* @param {Client} client OpenFeature client
|
* **DO NOT EXPORT PUBLICLY**
|
||||||
|
* @internal
|
||||||
|
* @param {Provider} provider the provider to suspend for
|
||||||
|
* @param {Client} client the client to check for readiness
|
||||||
*/
|
*/
|
||||||
export function suspendUntilReady(client: Client): Promise<void> {
|
export function suspendUntilInitialized(provider: Provider, client: Client) {
|
||||||
let resolve: (value: unknown) => void;
|
const statusPromiseRef = globalProviderSuspenseStatus.get(provider);
|
||||||
let reject: () => void;
|
if (!statusPromiseRef) {
|
||||||
throw new Promise((_resolve, _reject) => {
|
// Noop provider is never ready, so we resolve immediately
|
||||||
resolve = _resolve;
|
const statusPromise = provider !== NOOP_PROVIDER ? isProviderReady(client) : Promise.resolve();
|
||||||
reject = _reject;
|
globalProviderSuspenseStatus.set(provider, statusPromise);
|
||||||
client.addHandler(ProviderEvents.Ready, resolve);
|
// Use will throw the promise and React will trigger a rerender when it's resolved
|
||||||
client.addHandler(ProviderEvents.Error, reject);
|
use(statusPromise);
|
||||||
}).finally(() => {
|
} else {
|
||||||
client.removeHandler(ProviderEvents.Ready, resolve);
|
// Reuse the existing promise, use won't rethrow if the promise has settled.
|
||||||
client.removeHandler(ProviderEvents.Ready, reject);
|
use(statusPromiseRef);
|
||||||
});
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suspends until the provider has finished reconciling.
|
||||||
|
*
|
||||||
|
* **DO NOT EXPORT PUBLICLY**
|
||||||
|
* @internal
|
||||||
|
* @param {Client} client the client to check for readiness
|
||||||
|
*/
|
||||||
|
export function suspendUntilReconciled(client: Client) {
|
||||||
|
use(isProviderReady(client));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isProviderReady(client: Client) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
try {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
client.addHandler(ProviderEvents.Ready, resolve, { signal: controller.signal });
|
||||||
|
client.addHandler(ProviderEvents.Error, reject, { signal: controller.signal });
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
controller.abort();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,53 @@
|
||||||
|
/// <reference types="react/experimental" />
|
||||||
|
// This function is adopted from https://github.com/vercel/swr
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extends a Promise-like value to include status tracking.
|
||||||
|
* The extra properties are used to manage the lifecycle of the Promise, indicating its current state.
|
||||||
|
* More information can be found in the React RFE for the use hook.
|
||||||
|
* @see https://github.com/reactjs/rfcs/pull/229
|
||||||
|
*/
|
||||||
|
export type UsePromise<T> =
|
||||||
|
Promise<T> & {
|
||||||
|
status?: 'pending' | 'fulfilled' | 'rejected';
|
||||||
|
value?: T;
|
||||||
|
reason?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React.use is a React API that lets you read the value of a resource like a Promise or context.
|
||||||
|
* It was officially added in React 19, so needs to be polyfilled to support older React versions.
|
||||||
|
* @param {UsePromise} thenable A thenable object that represents a Promise-like value.
|
||||||
|
* @returns {unknown} The resolved value of the thenable or throws if it's still pending or rejected.
|
||||||
|
*/
|
||||||
|
export const use =
|
||||||
|
React.use ||
|
||||||
|
// This extra generic is to avoid TypeScript mixing up the generic and JSX syntax
|
||||||
|
// and emitting an error.
|
||||||
|
// We assume that this is only for the `use(thenable)` case, not `use(context)`.
|
||||||
|
// https://github.com/facebook/react/blob/aed00dacfb79d17c53218404c52b1c7aa59c4a89/packages/react-server/src/ReactFizzThenable.js#L45
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
(<T, _>(thenable: UsePromise<T>): T => {
|
||||||
|
switch (thenable.status) {
|
||||||
|
case 'pending':
|
||||||
|
throw thenable;
|
||||||
|
case 'fulfilled':
|
||||||
|
return thenable.value as T;
|
||||||
|
case 'rejected':
|
||||||
|
throw thenable.reason;
|
||||||
|
default:
|
||||||
|
thenable.status = 'pending';
|
||||||
|
thenable.then(
|
||||||
|
(v) => {
|
||||||
|
thenable.status = 'fulfilled';
|
||||||
|
thenable.value = v;
|
||||||
|
},
|
||||||
|
(e) => {
|
||||||
|
thenable.status = 'rejected';
|
||||||
|
thenable.reason = e;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
throw thenable;
|
||||||
|
}
|
||||||
|
});
|
|
@ -31,7 +31,7 @@ type ProviderProps = {
|
||||||
* @param {ProviderProps} properties props for the context provider
|
* @param {ProviderProps} properties props for the context provider
|
||||||
* @returns {OpenFeatureProvider} context provider
|
* @returns {OpenFeatureProvider} context provider
|
||||||
*/
|
*/
|
||||||
export function OpenFeatureProvider({ client, domain, children, ...options }: ProviderProps) {
|
export function OpenFeatureProvider({ client, domain, children, ...options }: ProviderProps): JSX.Element {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
client = OpenFeature.getClient(domain);
|
client = OpenFeature.getClient(domain);
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,22 +10,18 @@ import { ProviderEvents } from '@openfeature/web-sdk';
|
||||||
export function useOpenFeatureClientStatus(): ProviderStatus {
|
export function useOpenFeatureClientStatus(): ProviderStatus {
|
||||||
const client = useOpenFeatureClient();
|
const client = useOpenFeatureClient();
|
||||||
const [status, setStatus] = useState<ProviderStatus>(client.providerStatus);
|
const [status, setStatus] = useState<ProviderStatus>(client.providerStatus);
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateStatus = () => setStatus(client.providerStatus);
|
const updateStatus = () => setStatus(client.providerStatus);
|
||||||
client.addHandler(ProviderEvents.ConfigurationChanged, updateStatus);
|
client.addHandler(ProviderEvents.ConfigurationChanged, updateStatus, { signal: controller.signal });
|
||||||
client.addHandler(ProviderEvents.ContextChanged, updateStatus);
|
client.addHandler(ProviderEvents.ContextChanged, updateStatus, { signal: controller.signal });
|
||||||
client.addHandler(ProviderEvents.Error, updateStatus);
|
client.addHandler(ProviderEvents.Error, updateStatus, { signal: controller.signal });
|
||||||
client.addHandler(ProviderEvents.Ready, updateStatus);
|
client.addHandler(ProviderEvents.Ready, updateStatus, { signal: controller.signal });
|
||||||
client.addHandler(ProviderEvents.Stale, updateStatus);
|
client.addHandler(ProviderEvents.Stale, updateStatus, { signal: controller.signal });
|
||||||
client.addHandler(ProviderEvents.Reconciling, updateStatus);
|
client.addHandler(ProviderEvents.Reconciling, updateStatus, { signal: controller.signal });
|
||||||
return () => {
|
return () => {
|
||||||
client.removeHandler(ProviderEvents.ConfigurationChanged, updateStatus);
|
controller.abort();
|
||||||
client.removeHandler(ProviderEvents.ContextChanged, updateStatus);
|
|
||||||
client.removeHandler(ProviderEvents.Error, updateStatus);
|
|
||||||
client.removeHandler(ProviderEvents.Ready, updateStatus);
|
|
||||||
client.removeHandler(ProviderEvents.Stale, updateStatus);
|
|
||||||
client.removeHandler(ProviderEvents.Reconciling, updateStatus);
|
|
||||||
};
|
};
|
||||||
}, [client]);
|
}, [client]);
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Context } from '../internal';
|
import { Context } from '../internal';
|
||||||
import type { Client } from '@openfeature/web-sdk';
|
import { type Client } from '@openfeature/web-sdk';
|
||||||
|
import { MissingContextError } from '../internal/errors';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the {@link Client} instance for this OpenFeatureProvider context.
|
* Get the {@link Client} instance for this OpenFeatureProvider context.
|
||||||
|
@ -11,9 +12,7 @@ export function useOpenFeatureClient(): Client {
|
||||||
const { client } = React.useContext(Context) || {};
|
const { client } = React.useContext(Context) || {};
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
throw new Error(
|
throw new MissingContextError('No OpenFeature client available');
|
||||||
'No OpenFeature client available - components using OpenFeature must be wrapped with an <OpenFeatureProvider>. If you are seeing this in a test, see: https://openfeature.dev/docs/reference/technologies/client/web/react#testing',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return client;
|
return client;
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Context } from '../internal';
|
||||||
|
import { OpenFeature } from '@openfeature/web-sdk';
|
||||||
|
import type { Provider } from '@openfeature/web-sdk';
|
||||||
|
import { MissingContextError } from '../internal/errors';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the {@link Provider} bound to the domain specified in the OpenFeatureProvider context.
|
||||||
|
* Note that it isn't recommended to interact with the provider directly, but rather through
|
||||||
|
* an OpenFeature client.
|
||||||
|
* @returns {Provider} provider for this scope
|
||||||
|
*/
|
||||||
|
export function useOpenFeatureProvider(): Provider {
|
||||||
|
const openFeatureContext = React.useContext(Context);
|
||||||
|
|
||||||
|
if (!openFeatureContext) {
|
||||||
|
throw new MissingContextError('No OpenFeature context available');
|
||||||
|
}
|
||||||
|
|
||||||
|
return OpenFeature.getProvider(openFeatureContext.domain);
|
||||||
|
}
|
|
@ -2,7 +2,8 @@ import { ProviderStatus } from '@openfeature/web-sdk';
|
||||||
import { useOpenFeatureClient } from './use-open-feature-client';
|
import { useOpenFeatureClient } from './use-open-feature-client';
|
||||||
import { useOpenFeatureClientStatus } from './use-open-feature-client-status';
|
import { useOpenFeatureClientStatus } from './use-open-feature-client-status';
|
||||||
import type { ReactFlagEvaluationOptions } from '../options';
|
import type { ReactFlagEvaluationOptions } from '../options';
|
||||||
import { DEFAULT_OPTIONS, useProviderOptions, normalizeOptions, suspendUntilReady } from '../internal';
|
import { DEFAULT_OPTIONS, useProviderOptions, normalizeOptions, suspendUntilInitialized } from '../internal';
|
||||||
|
import { useOpenFeatureProvider } from './use-open-feature-provider';
|
||||||
|
|
||||||
type Options = Pick<ReactFlagEvaluationOptions, 'suspendUntilReady'>;
|
type Options = Pick<ReactFlagEvaluationOptions, 'suspendUntilReady'>;
|
||||||
|
|
||||||
|
@ -14,14 +15,14 @@ type Options = Pick<ReactFlagEvaluationOptions, 'suspendUntilReady'>;
|
||||||
* @returns {boolean} boolean indicating if provider is {@link ProviderStatus.READY}, useful if suspense is disabled and you want to handle loaders on your own
|
* @returns {boolean} boolean indicating if provider is {@link ProviderStatus.READY}, useful if suspense is disabled and you want to handle loaders on your own
|
||||||
*/
|
*/
|
||||||
export function useWhenProviderReady(options?: Options): boolean {
|
export function useWhenProviderReady(options?: Options): boolean {
|
||||||
const client = useOpenFeatureClient();
|
|
||||||
const status = useOpenFeatureClientStatus();
|
|
||||||
// highest priority > evaluation hook options > provider options > default options > lowest priority
|
// highest priority > evaluation hook options > provider options > default options > lowest priority
|
||||||
const defaultedOptions = { ...DEFAULT_OPTIONS, ...useProviderOptions(), ...normalizeOptions(options) };
|
const defaultedOptions = { ...DEFAULT_OPTIONS, ...useProviderOptions(), ...normalizeOptions(options) };
|
||||||
|
const client = useOpenFeatureClient();
|
||||||
|
const status = useOpenFeatureClientStatus();
|
||||||
|
const provider = useOpenFeatureProvider();
|
||||||
|
|
||||||
// suspense
|
|
||||||
if (defaultedOptions.suspendUntilReady && status === ProviderStatus.NOT_READY) {
|
if (defaultedOptions.suspendUntilReady && status === ProviderStatus.NOT_READY) {
|
||||||
suspendUntilReady(client);
|
suspendUntilInitialized(provider, client);
|
||||||
}
|
}
|
||||||
|
|
||||||
return status === ProviderStatus.READY;
|
return status === ProviderStatus.READY;
|
||||||
|
|
|
@ -6,12 +6,7 @@ import '@testing-library/jest-dom'; // see: https://testing-library.com/docs/rea
|
||||||
import { act, render, renderHook, screen, waitFor } from '@testing-library/react';
|
import { act, render, renderHook, screen, waitFor } from '@testing-library/react';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { startTransition, useState } from 'react';
|
import { startTransition, useState } from 'react';
|
||||||
import type {
|
import type { EvaluationContext, EvaluationDetails, EventContext, Hook } from '../src/';
|
||||||
EvaluationContext,
|
|
||||||
EvaluationDetails,
|
|
||||||
EventContext,
|
|
||||||
Hook
|
|
||||||
} from '../src/';
|
|
||||||
import {
|
import {
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
InMemoryProvider,
|
InMemoryProvider,
|
||||||
|
@ -27,15 +22,18 @@ import {
|
||||||
useObjectFlagValue,
|
useObjectFlagValue,
|
||||||
useStringFlagDetails,
|
useStringFlagDetails,
|
||||||
useStringFlagValue,
|
useStringFlagValue,
|
||||||
useSuspenseFlag
|
useSuspenseFlag,
|
||||||
} from '../src/';
|
} from '../src/';
|
||||||
import { HookFlagQuery } from '../src/evaluation/hook-flag-query';
|
import { HookFlagQuery } from '../src/internal/hook-flag-query';
|
||||||
import { TestingProvider } from './test.utils';
|
import { TestingProvider } from './test.utils';
|
||||||
|
|
||||||
// custom provider to have better control over the emitted events
|
// custom provider to have better control over the emitted events
|
||||||
class CustomEventInMemoryProvider extends InMemoryProvider {
|
class CustomEventInMemoryProvider extends InMemoryProvider {
|
||||||
|
putConfigurationWithCustomEvent(
|
||||||
putConfigurationWithCustomEvent(flagConfiguration: FlagConfiguration, event: ProviderEmittableEvents, eventContext: EventContext) {
|
flagConfiguration: FlagConfiguration,
|
||||||
|
event: ProviderEmittableEvents,
|
||||||
|
eventContext: EventContext,
|
||||||
|
) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
this['_flagConfiguration'] = { ...flagConfiguration }; // private access hack
|
this['_flagConfiguration'] = { ...flagConfiguration }; // private access hack
|
||||||
this.events.emit(event, eventContext);
|
this.events.emit(event, eventContext);
|
||||||
|
@ -395,16 +393,19 @@ describe('evaluation', () => {
|
||||||
|
|
||||||
expect(screen.queryByTestId('render-count')).toHaveTextContent('1');
|
expect(screen.queryByTestId('render-count')).toHaveTextContent('1');
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await rerenderProvider.putConfigurationWithCustomEvent({
|
await rerenderProvider.putConfigurationWithCustomEvent(
|
||||||
...FLAG_CONFIG,
|
{
|
||||||
[BOOL_FLAG_KEY]: {
|
...FLAG_CONFIG,
|
||||||
...FLAG_CONFIG[BOOL_FLAG_KEY],
|
[BOOL_FLAG_KEY]: {
|
||||||
// Change the default; this should be ignored and not cause a re-render because flagsChanged is empty
|
...FLAG_CONFIG[BOOL_FLAG_KEY],
|
||||||
defaultVariant: 'off',
|
// Change the default; this should be ignored and not cause a re-render because flagsChanged is empty
|
||||||
|
defaultVariant: 'off',
|
||||||
|
},
|
||||||
|
// if the flagsChanged is empty, we know nothing has changed, so we don't bother diffing
|
||||||
},
|
},
|
||||||
// if the flagsChanged is empty, we know nothing has changed, so we don't bother diffing
|
ClientProviderEvents.ConfigurationChanged,
|
||||||
}, ClientProviderEvents.ConfigurationChanged, { flagsChanged: [] });
|
{ flagsChanged: [] },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.queryByTestId('render-count')).toHaveTextContent('1');
|
expect(screen.queryByTestId('render-count')).toHaveTextContent('1');
|
||||||
|
@ -420,16 +421,19 @@ describe('evaluation', () => {
|
||||||
|
|
||||||
expect(screen.queryByTestId('render-count')).toHaveTextContent('1');
|
expect(screen.queryByTestId('render-count')).toHaveTextContent('1');
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await rerenderProvider.putConfigurationWithCustomEvent({
|
await rerenderProvider.putConfigurationWithCustomEvent(
|
||||||
...FLAG_CONFIG,
|
{
|
||||||
[BOOL_FLAG_KEY]: {
|
...FLAG_CONFIG,
|
||||||
...FLAG_CONFIG[BOOL_FLAG_KEY],
|
[BOOL_FLAG_KEY]: {
|
||||||
// Change the default variant to trigger a rerender since not only do we check flagsChanged, but we also diff the value
|
...FLAG_CONFIG[BOOL_FLAG_KEY],
|
||||||
defaultVariant: 'off',
|
// Change the default variant to trigger a rerender since not only do we check flagsChanged, but we also diff the value
|
||||||
|
defaultVariant: 'off',
|
||||||
|
},
|
||||||
|
// if the flagsChanged is falsy, we don't know what flags changed - so we attempt to diff everything
|
||||||
},
|
},
|
||||||
// if the flagsChanged is falsy, we don't know what flags changed - so we attempt to diff everything
|
ClientProviderEvents.ConfigurationChanged,
|
||||||
}, ClientProviderEvents.ConfigurationChanged, { flagsChanged: undefined });
|
{ flagsChanged: undefined },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.queryByTestId('render-count')).toHaveTextContent('2');
|
expect(screen.queryByTestId('render-count')).toHaveTextContent('2');
|
||||||
|
@ -573,10 +577,41 @@ describe('evaluation', () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
OpenFeature.clearProviders();
|
||||||
|
});
|
||||||
|
|
||||||
const suspendingProvider = () => {
|
const suspendingProvider = () => {
|
||||||
return new TestingProvider(CONFIG, DELAY); // delay init by 100ms
|
return new TestingProvider(CONFIG, DELAY); // delay init by 100ms
|
||||||
};
|
};
|
||||||
|
|
||||||
|
describe('when using the noop provider', () => {
|
||||||
|
function TestComponent() {
|
||||||
|
const { value } = useSuspenseFlag(SUSPENSE_FLAG_KEY, DEFAULT);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>{value}</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
it('should fallback to the default value on the next rerender', async () => {
|
||||||
|
render(
|
||||||
|
<OpenFeatureProvider>
|
||||||
|
<React.Suspense fallback={<div>{FALLBACK}</div>}>
|
||||||
|
<TestComponent></TestComponent>
|
||||||
|
</React.Suspense>
|
||||||
|
</OpenFeatureProvider>,
|
||||||
|
);
|
||||||
|
// The loading indicator should be shown on the first render
|
||||||
|
expect(screen.queryByText(FALLBACK)).toBeInTheDocument();
|
||||||
|
|
||||||
|
// The default value should be shown on the next render
|
||||||
|
await waitFor(() => expect(screen.queryByText(DEFAULT)).toBeInTheDocument(), {
|
||||||
|
timeout: DELAY,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('updateOnConfigurationChanged=true (default)', () => {
|
describe('updateOnConfigurationChanged=true (default)', () => {
|
||||||
function TestComponent() {
|
function TestComponent() {
|
||||||
const { value } = useFlag(SUSPENSE_FLAG_KEY, DEFAULT);
|
const { value } = useFlag(SUSPENSE_FLAG_KEY, DEFAULT);
|
||||||
|
@ -889,17 +924,124 @@ describe('evaluation', () => {
|
||||||
OpenFeature.setContext(SUSPEND_OFF, { user: TARGETED_USER });
|
OpenFeature.setContext(SUSPEND_OFF, { user: TARGETED_USER });
|
||||||
});
|
});
|
||||||
|
|
||||||
// expect to see static value until we reconcile
|
// With the fix for useState initialization, the hook now immediately
|
||||||
await waitFor(() => expect(screen.queryByText(STATIC_FLAG_VALUE_A)).toBeInTheDocument(), {
|
// reflects provider state changes. This is intentional to handle cases
|
||||||
timeout: DELAY / 2,
|
// where providers don't emit proper events.
|
||||||
});
|
// The value updates immediately to the targeted value.
|
||||||
|
|
||||||
// make sure we updated after reconciling
|
|
||||||
await waitFor(() => expect(screen.queryByText(TARGETED_FLAG_VALUE)).toBeInTheDocument(), {
|
await waitFor(() => expect(screen.queryByText(TARGETED_FLAG_VALUE)).toBeInTheDocument(), {
|
||||||
timeout: DELAY * 2,
|
timeout: DELAY * 2,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('re-render behavior when flag values change without provider events', ()=> {
|
||||||
|
it('should reflect provider state changes on re-render even without provider events', async () => {
|
||||||
|
let providerValue = 'initial-value';
|
||||||
|
|
||||||
|
class SilentUpdateProvider extends InMemoryProvider {
|
||||||
|
resolveBooleanEvaluation() {
|
||||||
|
return {
|
||||||
|
value: true,
|
||||||
|
variant: 'on',
|
||||||
|
reason: StandardResolutionReasons.STATIC,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveStringEvaluation() {
|
||||||
|
return {
|
||||||
|
value: providerValue,
|
||||||
|
variant: providerValue,
|
||||||
|
reason: StandardResolutionReasons.STATIC,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = new SilentUpdateProvider({});
|
||||||
|
await OpenFeature.setProviderAndWait('test', provider);
|
||||||
|
|
||||||
|
// The triggerRender prop forces a re-render
|
||||||
|
const TestComponent = ({ triggerRender }: { triggerRender: number }) => {
|
||||||
|
const { value } = useFlag('test-flag', 'default');
|
||||||
|
return <div data-testid="flag-value" data-render-count={triggerRender}>{value}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const WrapperComponent = () => {
|
||||||
|
const [renderCount, setRenderCount] = useState(0);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button onClick={() => setRenderCount(c => c + 1)}>Force Re-render</button>
|
||||||
|
<TestComponent triggerRender={renderCount} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { getByText } = render(
|
||||||
|
<OpenFeatureProvider client={OpenFeature.getClient('test')}>
|
||||||
|
<WrapperComponent />
|
||||||
|
</OpenFeatureProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initial value should be rendered
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('flag-value')).toHaveTextContent('initial-value');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Change the provider's internal state (without emitting events)
|
||||||
|
providerValue = 'updated-value';
|
||||||
|
|
||||||
|
// Force a re-render of the component
|
||||||
|
act(() => {
|
||||||
|
getByText('Force Re-render').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('flag-value')).toHaveTextContent('updated-value');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update flag value when flag key prop changes without provider events', async () => {
|
||||||
|
const provider = new InMemoryProvider({
|
||||||
|
'flag-a': {
|
||||||
|
disabled: false,
|
||||||
|
variants: { on: 'value-a' },
|
||||||
|
defaultVariant: 'on',
|
||||||
|
},
|
||||||
|
'flag-b': {
|
||||||
|
disabled: false,
|
||||||
|
variants: { on: 'value-b' },
|
||||||
|
defaultVariant: 'on',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await OpenFeature.setProviderAndWait(EVALUATION, provider);
|
||||||
|
|
||||||
|
const TestComponent = ({ flagKey }: { flagKey: string }) => {
|
||||||
|
const { value } = useFlag(flagKey, 'default');
|
||||||
|
return <div data-testid="flag-value">{value}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<OpenFeatureProvider client={OpenFeature.getClient(EVALUATION)}>
|
||||||
|
<TestComponent flagKey="flag-a" />
|
||||||
|
</OpenFeatureProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('flag-value')).toHaveTextContent('value-a');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Change to flag-b (without any provider events)
|
||||||
|
rerender(
|
||||||
|
<OpenFeatureProvider client={OpenFeature.getClient(EVALUATION)}>
|
||||||
|
<TestComponent flagKey="flag-b" />
|
||||||
|
</OpenFeatureProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('flag-value')).toHaveTextContent('value-b');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('context, hooks and options', () => {
|
describe('context, hooks and options', () => {
|
||||||
|
|
|
@ -288,7 +288,7 @@ describe('OpenFeatureProvider', () => {
|
||||||
{ timeout: DELAY * 4 },
|
{ timeout: DELAY * 4 },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText('Will says hi')).toBeInTheDocument();
|
expect(screen.getByText('Will says aloha')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,5 +1,35 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [1.19.0](https://github.com/open-feature/js-sdk/compare/server-sdk-v1.18.0...server-sdk-v1.19.0) (2025-08-14)
|
||||||
|
|
||||||
|
|
||||||
|
### ✨ New Features
|
||||||
|
|
||||||
|
* add evaluation-scoped hook data ([#1216](https://github.com/open-feature/js-sdk/issues/1216)) ([07af3a9](https://github.com/open-feature/js-sdk/commit/07af3a9eda895e9edb24c7ee1e3c1c4f16e17431))
|
||||||
|
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
* update core dep ([#1228](https://github.com/open-feature/js-sdk/issues/1228)) ([845d24c](https://github.com/open-feature/js-sdk/commit/845d24c5fecc80de3080e49fde839f08ecac6b33))
|
||||||
|
|
||||||
|
|
||||||
|
### 🧹 Chore
|
||||||
|
|
||||||
|
* update node to v20+ ([#1203](https://github.com/open-feature/js-sdk/issues/1203)) ([1f33453](https://github.com/open-feature/js-sdk/commit/1f33453c23df0763cbf0d0b44db8d91216377009))
|
||||||
|
|
||||||
|
|
||||||
|
### 📚 Documentation
|
||||||
|
|
||||||
|
* Clarify the behavior of setProviderAndWait ([#1180](https://github.com/open-feature/js-sdk/issues/1180)) ([4fe8d87](https://github.com/open-feature/js-sdk/commit/4fe8d87a2e5df2cbd4086cc4f4a380e8857ed8ba))
|
||||||
|
|
||||||
|
## [1.18.0](https://github.com/open-feature/js-sdk/compare/server-sdk-v1.17.1...server-sdk-v1.18.0) (2025-04-11)
|
||||||
|
|
||||||
|
|
||||||
|
### ✨ 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)
|
## [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" />
|
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
|
||||||
</a>
|
</a>
|
||||||
<!-- x-release-please-start-version -->
|
<!-- x-release-please-start-version -->
|
||||||
<a href="https://github.com/open-feature/js-sdk/releases/tag/server-sdk-v1.17.1">
|
<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.17.1&color=blue&style=for-the-badge" />
|
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v1.19.0&color=blue&style=for-the-badge" />
|
||||||
</a>
|
</a>
|
||||||
<!-- x-release-please-end -->
|
<!-- x-release-please-end -->
|
||||||
<br/>
|
<br/>
|
||||||
|
@ -75,7 +75,11 @@ yarn add @openfeature/server-sdk @openfeature/core
|
||||||
import { OpenFeature } from '@openfeature/server-sdk';
|
import { OpenFeature } from '@openfeature/server-sdk';
|
||||||
|
|
||||||
// Register your feature flag provider
|
// 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
|
// create a new client
|
||||||
const client = OpenFeature.getClient();
|
const client = OpenFeature.getClient();
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@openfeature/server-sdk",
|
"name": "@openfeature/server-sdk",
|
||||||
"version": "1.17.1",
|
"version": "1.19.0",
|
||||||
"description": "OpenFeature SDK for JavaScript",
|
"description": "OpenFeature SDK for JavaScript",
|
||||||
"main": "./dist/cjs/index.js",
|
"main": "./dist/cjs/index.js",
|
||||||
"files": [
|
"files": [
|
||||||
|
@ -45,12 +45,12 @@
|
||||||
},
|
},
|
||||||
"homepage": "https://github.com/open-feature/js-sdk#readme",
|
"homepage": "https://github.com/open-feature/js-sdk#readme",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@openfeature/core": "^1.7.0"
|
"@openfeature/core": "^1.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@openfeature/core": "^1.7.0"
|
"@openfeature/core": "^1.9.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,6 +12,7 @@ import type {
|
||||||
OpenFeatureError,
|
OpenFeatureError,
|
||||||
FlagMetadata,
|
FlagMetadata,
|
||||||
ResolutionDetails,
|
ResolutionDetails,
|
||||||
|
EventOptions,
|
||||||
} from '@openfeature/core';
|
} from '@openfeature/core';
|
||||||
import {
|
import {
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
|
@ -21,6 +22,7 @@ import {
|
||||||
StandardResolutionReasons,
|
StandardResolutionReasons,
|
||||||
instantiateErrorByErrorCode,
|
instantiateErrorByErrorCode,
|
||||||
statusMatchesEvent,
|
statusMatchesEvent,
|
||||||
|
MapHookData,
|
||||||
} from '@openfeature/core';
|
} from '@openfeature/core';
|
||||||
import type { FlagEvaluationOptions } from '../../evaluation';
|
import type { FlagEvaluationOptions } from '../../evaluation';
|
||||||
import type { ProviderEvents } from '../../events';
|
import type { ProviderEvents } from '../../events';
|
||||||
|
@ -79,7 +81,7 @@ export class OpenFeatureClient implements Client {
|
||||||
return this.providerStatusAccessor();
|
return this.providerStatusAccessor();
|
||||||
}
|
}
|
||||||
|
|
||||||
addHandler(eventType: ProviderEvents, handler: EventHandler): void {
|
addHandler(eventType: ProviderEvents, handler: EventHandler, options?: EventOptions): void {
|
||||||
this.emitterAccessor().addHandler(eventType, handler);
|
this.emitterAccessor().addHandler(eventType, handler);
|
||||||
const shouldRunNow = statusMatchesEvent(eventType, this._providerStatus);
|
const shouldRunNow = statusMatchesEvent(eventType, this._providerStatus);
|
||||||
|
|
||||||
|
@ -95,6 +97,12 @@ export class OpenFeatureClient implements Client {
|
||||||
this._logger?.error('Error running event handler:', err);
|
this._logger?.error('Error running event handler:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options?.signal && typeof options.signal.addEventListener === 'function') {
|
||||||
|
options.signal.addEventListener('abort', () => {
|
||||||
|
this.removeHandler(eventType, handler);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
removeHandler(eventType: ProviderEvents, handler: EventHandler) {
|
removeHandler(eventType: ProviderEvents, handler: EventHandler) {
|
||||||
|
@ -269,22 +277,26 @@ export class OpenFeatureClient implements Client {
|
||||||
|
|
||||||
const mergedContext = this.mergeContexts(invocationContext);
|
const mergedContext = this.mergeContexts(invocationContext);
|
||||||
|
|
||||||
// this reference cannot change during the course of evaluation
|
// Create hook context instances for each hook (stable object references for the entire evaluation)
|
||||||
// it may be used as a key in WeakMaps
|
// This ensures hooks can use WeakMaps with hookContext as keys across lifecycle methods
|
||||||
const hookContext: Readonly<HookContext> = {
|
// NOTE: Uses the reversed order to reduce the number of times we have to calculate the index.
|
||||||
flagKey,
|
const hookContexts = allHooksReversed.map<HookContext>(() =>
|
||||||
defaultValue,
|
Object.freeze({
|
||||||
flagValueType: flagType,
|
flagKey,
|
||||||
clientMetadata: this.metadata,
|
defaultValue,
|
||||||
providerMetadata: this._provider.metadata,
|
flagValueType: flagType,
|
||||||
context: mergedContext,
|
clientMetadata: this.metadata,
|
||||||
logger: this._logger,
|
providerMetadata: this._provider.metadata,
|
||||||
};
|
context: mergedContext,
|
||||||
|
logger: this._logger,
|
||||||
|
hookData: new MapHookData(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
let evaluationDetails: EvaluationDetails<T>;
|
let evaluationDetails: EvaluationDetails<T>;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const frozenContext = await this.beforeHooks(allHooks, hookContext, options);
|
const frozenContext = await this.beforeHooks(allHooks, hookContexts, mergedContext, options);
|
||||||
|
|
||||||
this.shortCircuitIfNotReady();
|
this.shortCircuitIfNotReady();
|
||||||
|
|
||||||
|
@ -299,53 +311,71 @@ export class OpenFeatureClient implements Client {
|
||||||
|
|
||||||
if (resolutionDetails.errorCode) {
|
if (resolutionDetails.errorCode) {
|
||||||
const err = instantiateErrorByErrorCode(resolutionDetails.errorCode, resolutionDetails.errorMessage);
|
const err = instantiateErrorByErrorCode(resolutionDetails.errorCode, resolutionDetails.errorMessage);
|
||||||
await this.errorHooks(allHooksReversed, hookContext, err, options);
|
await this.errorHooks(allHooksReversed, hookContexts, err, options);
|
||||||
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err, resolutionDetails.flagMetadata);
|
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err, resolutionDetails.flagMetadata);
|
||||||
} else {
|
} else {
|
||||||
await this.afterHooks(allHooksReversed, hookContext, resolutionDetails, options);
|
await this.afterHooks(allHooksReversed, hookContexts, resolutionDetails, options);
|
||||||
evaluationDetails = resolutionDetails;
|
evaluationDetails = resolutionDetails;
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
await this.errorHooks(allHooksReversed, hookContext, err, options);
|
await this.errorHooks(allHooksReversed, hookContexts, err, options);
|
||||||
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err);
|
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.finallyHooks(allHooksReversed, hookContext, evaluationDetails, options);
|
await this.finallyHooks(allHooksReversed, hookContexts, evaluationDetails, options);
|
||||||
return evaluationDetails;
|
return evaluationDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async beforeHooks(hooks: Hook[], hookContext: HookContext, options: FlagEvaluationOptions) {
|
private async beforeHooks(
|
||||||
for (const hook of hooks) {
|
hooks: Hook[],
|
||||||
// freeze the hookContext
|
hookContexts: HookContext[],
|
||||||
Object.freeze(hookContext);
|
mergedContext: EvaluationContext,
|
||||||
|
options: FlagEvaluationOptions,
|
||||||
|
) {
|
||||||
|
let accumulatedContext = mergedContext;
|
||||||
|
|
||||||
// use Object.assign to avoid modification of frozen hookContext
|
for (const [index, hook] of hooks.entries()) {
|
||||||
Object.assign(hookContext.context, {
|
const hookContextIndex = hooks.length - 1 - index; // reverse index for before hooks
|
||||||
...hookContext.context,
|
const hookContext = hookContexts[hookContextIndex];
|
||||||
...(await hook?.before?.(hookContext, Object.freeze(options.hookHints))),
|
|
||||||
});
|
// Update the context on the stable hook context object
|
||||||
|
Object.assign(hookContext.context, accumulatedContext);
|
||||||
|
|
||||||
|
const hookResult = await hook?.before?.(hookContext, Object.freeze(options.hookHints));
|
||||||
|
if (hookResult) {
|
||||||
|
accumulatedContext = {
|
||||||
|
...accumulatedContext,
|
||||||
|
...hookResult,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < hooks.length; i++) {
|
||||||
|
Object.assign(hookContexts[hookContextIndex].context, accumulatedContext);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// after before hooks, freeze the EvaluationContext.
|
// after before hooks, freeze the EvaluationContext.
|
||||||
return Object.freeze(hookContext.context);
|
return Object.freeze(accumulatedContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async afterHooks(
|
private async afterHooks(
|
||||||
hooks: Hook[],
|
hooks: Hook[],
|
||||||
hookContext: HookContext,
|
hookContexts: HookContext[],
|
||||||
evaluationDetails: EvaluationDetails<FlagValue>,
|
evaluationDetails: EvaluationDetails<FlagValue>,
|
||||||
options: FlagEvaluationOptions,
|
options: FlagEvaluationOptions,
|
||||||
) {
|
) {
|
||||||
// run "after" hooks sequentially
|
// 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);
|
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
|
// run "error" hooks sequentially
|
||||||
for (const hook of hooks) {
|
for (const [index, hook] of hooks.entries()) {
|
||||||
try {
|
try {
|
||||||
|
const hookContext = hookContexts[index];
|
||||||
await hook?.error?.(hookContext, err, options.hookHints);
|
await hook?.error?.(hookContext, err, options.hookHints);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this._logger.error(`Unhandled error during 'error' hook: ${err}`);
|
this._logger.error(`Unhandled error during 'error' hook: ${err}`);
|
||||||
|
@ -359,13 +389,14 @@ export class OpenFeatureClient implements Client {
|
||||||
|
|
||||||
private async finallyHooks(
|
private async finallyHooks(
|
||||||
hooks: Hook[],
|
hooks: Hook[],
|
||||||
hookContext: HookContext,
|
hookContexts: HookContext[],
|
||||||
evaluationDetails: EvaluationDetails<FlagValue>,
|
evaluationDetails: EvaluationDetails<FlagValue>,
|
||||||
options: FlagEvaluationOptions,
|
options: FlagEvaluationOptions,
|
||||||
) {
|
) {
|
||||||
// run "finally" hooks sequentially
|
// run "finally" hooks sequentially
|
||||||
for (const hook of hooks) {
|
for (const [index, hook] of hooks.entries()) {
|
||||||
try {
|
try {
|
||||||
|
const hookContext = hookContexts[index];
|
||||||
await hook?.finally?.(hookContext, evaluationDetails, options.hookHints);
|
await hook?.finally?.(hookContext, evaluationDetails, options.hookHints);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this._logger.error(`Unhandled error during 'finally' hook: ${err}`);
|
this._logger.error(`Unhandled error during 'finally' hook: ${err}`);
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import type { BaseHook, EvaluationContext, FlagValue } from '@openfeature/core';
|
import type { BaseHook, EvaluationContext, FlagValue } from '@openfeature/core';
|
||||||
|
|
||||||
export type Hook = BaseHook<
|
export type Hook<TData = Record<string, unknown>> = BaseHook<
|
||||||
FlagValue,
|
FlagValue,
|
||||||
|
TData,
|
||||||
Promise<EvaluationContext | void> | EvaluationContext | void,
|
Promise<EvaluationContext | void> | EvaluationContext | void,
|
||||||
Promise<void> | 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.
|
* Setting a provider supersedes the current provider used in new and existing unbound clients.
|
||||||
* @param {Provider} provider The provider responsible for flag evaluations.
|
* @param {Provider} provider The provider responsible for flag evaluations.
|
||||||
* @returns {Promise<void>}
|
* @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>;
|
setProviderAndWait(provider: Provider): Promise<void>;
|
||||||
/**
|
/**
|
||||||
|
@ -92,7 +92,7 @@ export class OpenFeatureAPI
|
||||||
* @param {string} domain The name to identify the client
|
* @param {string} domain The name to identify the client
|
||||||
* @param {Provider} provider The provider responsible for flag evaluations.
|
* @param {Provider} provider The provider responsible for flag evaluations.
|
||||||
* @returns {Promise<void>}
|
* @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>;
|
setProviderAndWait(domain: string, provider: Provider): Promise<void>;
|
||||||
async setProviderAndWait(domainOrProvider?: string | Provider, providerOrUndefined?: Provider): Promise<void> {
|
async setProviderAndWait(domainOrProvider?: string | Provider, providerOrUndefined?: Provider): Promise<void> {
|
||||||
|
@ -138,6 +138,27 @@ export class OpenFeatureAPI
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the default provider.
|
||||||
|
*
|
||||||
|
* Note that it isn't recommended to interact with the provider directly, but rather through
|
||||||
|
* an OpenFeature client.
|
||||||
|
* @returns {Provider} Default Provider
|
||||||
|
*/
|
||||||
|
getProvider(): Provider;
|
||||||
|
/**
|
||||||
|
* Get the provider bound to the specified domain.
|
||||||
|
*
|
||||||
|
* Note that it isn't recommended to interact with the provider directly, but rather through
|
||||||
|
* an OpenFeature client.
|
||||||
|
* @param {string} domain An identifier which logically binds clients with providers
|
||||||
|
* @returns {Provider} Domain-scoped provider
|
||||||
|
*/
|
||||||
|
getProvider(domain?: string): Provider;
|
||||||
|
getProvider(domain?: string): Provider {
|
||||||
|
return this.getProviderForClient(domain);
|
||||||
|
}
|
||||||
|
|
||||||
setContext(context: EvaluationContext): this {
|
setContext(context: EvaluationContext): this {
|
||||||
this._context = context;
|
this._context = context;
|
||||||
return this;
|
return this;
|
||||||
|
|
|
@ -449,7 +449,21 @@ describe('Events', () => {
|
||||||
expect(OpenFeature.getHandlers(eventType)).toHaveLength(0);
|
expect(OpenFeature.getHandlers(eventType)).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('The API provides a function allowing the removal of event handlers', () => {
|
it('The event handler can be removed using an abort signal', () => {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const handler1 = jest.fn();
|
||||||
|
const handler2 = jest.fn();
|
||||||
|
const eventType = ProviderEvents.Stale;
|
||||||
|
|
||||||
|
OpenFeature.addHandler(eventType, handler1, { signal: abortController.signal });
|
||||||
|
OpenFeature.addHandler(eventType, handler2);
|
||||||
|
expect(OpenFeature.getHandlers(eventType)).toHaveLength(2);
|
||||||
|
|
||||||
|
abortController.abort();
|
||||||
|
expect(OpenFeature.getHandlers(eventType)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('The API provides a function allowing the removal of event handlers from client', () => {
|
||||||
const client = OpenFeature.getClient(domain);
|
const client = OpenFeature.getClient(domain);
|
||||||
const handler = jest.fn();
|
const handler = jest.fn();
|
||||||
const eventType = ProviderEvents.Stale;
|
const eventType = ProviderEvents.Stale;
|
||||||
|
@ -459,6 +473,21 @@ describe('Events', () => {
|
||||||
client.removeHandler(eventType, handler);
|
client.removeHandler(eventType, handler);
|
||||||
expect(client.getHandlers(eventType)).toHaveLength(0);
|
expect(client.getHandlers(eventType)).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('The event handler on the client can be removed using an abort signal', () => {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const client = OpenFeature.getClient(domain);
|
||||||
|
const handler1 = jest.fn();
|
||||||
|
const handler2 = jest.fn();
|
||||||
|
const eventType = ProviderEvents.Stale;
|
||||||
|
|
||||||
|
client.addHandler(eventType, handler1, { signal: abortController.signal });
|
||||||
|
client.addHandler(eventType, handler2);
|
||||||
|
expect(client.getHandlers(eventType)).toHaveLength(2);
|
||||||
|
|
||||||
|
abortController.abort();
|
||||||
|
expect(client.getHandlers(eventType)).toHaveLength(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Requirement 5.3.1', () => {
|
describe('Requirement 5.3.1', () => {
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -74,8 +74,8 @@ describe('OpenFeature', () => {
|
||||||
it('should set the default provider if no domain is provided', () => {
|
it('should set the default provider if no domain is provided', () => {
|
||||||
const provider = mockProvider();
|
const provider = mockProvider();
|
||||||
OpenFeature.setProvider(provider);
|
OpenFeature.setProvider(provider);
|
||||||
const client = OpenFeature.getClient();
|
const registeredProvider = OpenFeature.getProvider();
|
||||||
expect(client.metadata.providerMetadata.name).toEqual(provider.metadata.name);
|
expect(registeredProvider).toEqual(provider);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not change providers associated with a domain when setting a new default provider', () => {
|
it('should not change providers associated with a domain when setting a new default provider', () => {
|
||||||
|
@ -85,11 +85,11 @@ describe('OpenFeature', () => {
|
||||||
OpenFeature.setProvider(provider);
|
OpenFeature.setProvider(provider);
|
||||||
OpenFeature.setProvider(domain, fakeProvider);
|
OpenFeature.setProvider(domain, fakeProvider);
|
||||||
|
|
||||||
const defaultClient = OpenFeature.getClient();
|
const defaultProvider = OpenFeature.getProvider();
|
||||||
const domainSpecificClient = OpenFeature.getClient(domain);
|
const domainSpecificProvider = OpenFeature.getProvider(domain);
|
||||||
|
|
||||||
expect(defaultClient.metadata.providerMetadata.name).toEqual(provider.metadata.name);
|
expect(defaultProvider).toEqual(provider);
|
||||||
expect(domainSpecificClient.metadata.providerMetadata.name).toEqual(fakeProvider.metadata.name);
|
expect(domainSpecificProvider).toEqual(fakeProvider);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should bind a new provider to existing clients in a matching domain', () => {
|
it('should bind a new provider to existing clients in a matching domain', () => {
|
||||||
|
|
|
@ -1,5 +1,41 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [1.9.0](https://github.com/open-feature/js-sdk/compare/core-v1.8.1...core-v1.9.0) (2025-08-10)
|
||||||
|
|
||||||
|
|
||||||
|
### ✨ New Features
|
||||||
|
|
||||||
|
* add evaluation-scoped hook data ([#1216](https://github.com/open-feature/js-sdk/issues/1216)) ([07af3a9](https://github.com/open-feature/js-sdk/commit/07af3a9eda895e9edb24c7ee1e3c1c4f16e17431))
|
||||||
|
* support Angular 20 ([#1220](https://github.com/open-feature/js-sdk/issues/1220)) ([aa232a9](https://github.com/open-feature/js-sdk/commit/aa232a9d6a8dfa416380ccdecd71843d3e361048))
|
||||||
|
|
||||||
|
## [1.8.1](https://github.com/open-feature/js-sdk/compare/core-v1.8.0...core-v1.8.1) (2025-06-04)
|
||||||
|
|
||||||
|
|
||||||
|
### 🔄 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)
|
||||||
|
|
||||||
|
|
||||||
|
### ✨ New Features
|
||||||
|
|
||||||
|
* 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.7.2](https://github.com/open-feature/js-sdk/compare/core-v1.7.1...core-v1.7.2) (2025-02-18)
|
||||||
|
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
* rename evaluation event property from data to body ([4c2b01e](https://github.com/open-feature/js-sdk/commit/4c2b01e36773091038d758ac10bba06056ff4c45))
|
||||||
|
|
||||||
|
## [1.7.1](https://github.com/open-feature/js-sdk/compare/core-v1.7.0...core-v1.7.1) (2025-02-13)
|
||||||
|
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
* export missing telemetry functionality ([#1148](https://github.com/open-feature/js-sdk/issues/1148)) ([dcbc300](https://github.com/open-feature/js-sdk/commit/dcbc30090e7611c60e06d05826f6471f0c8c4009))
|
||||||
|
|
||||||
## [1.7.0](https://github.com/open-feature/js-sdk/compare/core-v1.6.0...core-v1.7.0) (2025-02-07)
|
## [1.7.0](https://github.com/open-feature/js-sdk/compare/core-v1.6.0...core-v1.7.0) (2025-02-07)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@openfeature/core",
|
"name": "@openfeature/core",
|
||||||
"version": "1.7.0",
|
"version": "1.9.0",
|
||||||
"description": "Shared OpenFeature JS components (server and web)",
|
"description": "Shared OpenFeature JS components (server and web)",
|
||||||
"main": "./dist/cjs/index.js",
|
"main": "./dist/cjs/index.js",
|
||||||
"files": [
|
"files": [
|
||||||
|
|
|
@ -18,3 +18,4 @@ export abstract class OpenFeatureError extends Error {
|
||||||
this.cause = options?.cause;
|
this.cause = options?.cause;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@ import type { JsonValue } from '../types/structure';
|
||||||
export type FlagValueType = 'boolean' | 'string' | 'number' | 'object';
|
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;
|
export type FlagValue = boolean | string | number | JsonValue;
|
||||||
|
|
||||||
|
|
|
@ -66,6 +66,9 @@ export type EventDetails<
|
||||||
export type EventHandler<
|
export type EventHandler<
|
||||||
T extends ServerProviderEvents | ClientProviderEvents = ServerProviderEvents | ClientProviderEvents,
|
T extends ServerProviderEvents | ClientProviderEvents = ServerProviderEvents | ClientProviderEvents,
|
||||||
> = (eventDetails?: EventDetails<T>) => Promise<unknown> | unknown;
|
> = (eventDetails?: EventDetails<T>) => Promise<unknown> | unknown;
|
||||||
|
export type EventOptions = {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
};
|
||||||
|
|
||||||
export interface Eventing<T extends ServerProviderEvents | ClientProviderEvents> {
|
export interface Eventing<T extends ServerProviderEvents | ClientProviderEvents> {
|
||||||
/**
|
/**
|
||||||
|
@ -73,6 +76,7 @@ export interface Eventing<T extends ServerProviderEvents | ClientProviderEvents>
|
||||||
* The handlers are called in the order they have been added.
|
* The handlers are called in the order they have been added.
|
||||||
* @param eventType The provider event type to listen to
|
* @param eventType The provider event type to listen to
|
||||||
* @param {EventHandler} handler The handler to run on occurrence of the event type
|
* @param {EventHandler} handler The handler to run on occurrence of the event type
|
||||||
|
* @param {EventOptions} options Optional options such as signal for aborting
|
||||||
*/
|
*/
|
||||||
addHandler(
|
addHandler(
|
||||||
eventType: T extends ClientProviderEvents
|
eventType: T extends ClientProviderEvents
|
||||||
|
@ -83,14 +87,17 @@ export interface Eventing<T extends ServerProviderEvents | ClientProviderEvents>
|
||||||
? ClientProviderEvents.ConfigurationChanged
|
? ClientProviderEvents.ConfigurationChanged
|
||||||
: ServerProviderEvents.ConfigurationChanged
|
: ServerProviderEvents.ConfigurationChanged
|
||||||
>,
|
>,
|
||||||
|
options?: EventOptions,
|
||||||
): void;
|
): void;
|
||||||
addHandler(
|
addHandler(
|
||||||
eventType: T extends ClientProviderEvents ? ClientNotChangeEvents : ServerNotChangeEvents,
|
eventType: T extends ClientProviderEvents ? ClientNotChangeEvents : ServerNotChangeEvents,
|
||||||
handler: EventHandler<T extends ClientProviderEvents ? ClientNotChangeEvents : ServerNotChangeEvents>,
|
handler: EventHandler<T extends ClientProviderEvents ? ClientNotChangeEvents : ServerNotChangeEvents>,
|
||||||
|
options?: EventOptions,
|
||||||
): void;
|
): void;
|
||||||
addHandler(
|
addHandler(
|
||||||
eventType: T extends ClientProviderEvents ? ClientProviderEvents : ServerProviderEvents,
|
eventType: T extends ClientProviderEvents ? ClientProviderEvents : ServerProviderEvents,
|
||||||
handler: EventHandler<T extends ClientProviderEvents ? ClientProviderEvents : ServerProviderEvents>,
|
handler: EventHandler<T extends ClientProviderEvents ? ClientProviderEvents : ServerProviderEvents>,
|
||||||
|
options?: EventOptions,
|
||||||
): void;
|
): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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 { BeforeHookContext, HookContext, HookHints } from './hooks';
|
||||||
import type { EvaluationDetails, FlagValue } from '../evaluation';
|
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.
|
* Runs before flag values are resolved from the provider.
|
||||||
* If an EvaluationContext is returned, it will be merged with the pre-existing EvaluationContext.
|
* If an EvaluationContext is returned, it will be merged with the pre-existing EvaluationContext.
|
||||||
* @param hookContext
|
* @param hookContext
|
||||||
* @param hookHints
|
* @param hookHints
|
||||||
*/
|
*/
|
||||||
before?(hookContext: BeforeHookContext, hookHints?: HookHints): BeforeHookReturn;
|
before?(hookContext: BeforeHookContext<T, TData>, hookHints?: HookHints): BeforeHookReturn;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs after flag values are successfully resolved from the provider.
|
* 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
|
* @param hookHints
|
||||||
*/
|
*/
|
||||||
after?(
|
after?(
|
||||||
hookContext: Readonly<HookContext<T>>,
|
hookContext: Readonly<HookContext<T, TData>>,
|
||||||
evaluationDetails: EvaluationDetails<T>,
|
evaluationDetails: EvaluationDetails<T>,
|
||||||
hookHints?: HookHints,
|
hookHints?: HookHints,
|
||||||
): HooksReturn;
|
): HooksReturn;
|
||||||
|
@ -28,7 +33,7 @@ export interface BaseHook<T extends FlagValue = FlagValue, BeforeHookReturn = un
|
||||||
* @param error
|
* @param error
|
||||||
* @param hookHints
|
* @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.
|
* 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
|
* @param hookHints
|
||||||
*/
|
*/
|
||||||
finally?(
|
finally?(
|
||||||
hookContext: Readonly<HookContext<T>>,
|
hookContext: Readonly<HookContext<T, TData>>,
|
||||||
evaluationDetails: EvaluationDetails<T>,
|
evaluationDetails: EvaluationDetails<T>,
|
||||||
hookHints?: HookHints,
|
hookHints?: HookHints,
|
||||||
): HooksReturn;
|
): HooksReturn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,10 +2,11 @@ import type { ProviderMetadata } from '../provider';
|
||||||
import type { ClientMetadata } from '../client';
|
import type { ClientMetadata } from '../client';
|
||||||
import type { EvaluationContext, FlagValue, FlagValueType } from '../evaluation';
|
import type { EvaluationContext, FlagValue, FlagValueType } from '../evaluation';
|
||||||
import type { Logger } from '../logger';
|
import type { Logger } from '../logger';
|
||||||
|
import type { HookData } from './hook-data';
|
||||||
|
|
||||||
export type HookHints = Readonly<Record<string, unknown>>;
|
export type HookHints = Readonly<Record<string, unknown>>;
|
||||||
|
|
||||||
export interface HookContext<T extends FlagValue = FlagValue> {
|
export interface HookContext<T extends FlagValue = FlagValue, TData = Record<string, unknown>> {
|
||||||
readonly flagKey: string;
|
readonly flagKey: string;
|
||||||
readonly defaultValue: T;
|
readonly defaultValue: T;
|
||||||
readonly flagValueType: FlagValueType;
|
readonly flagValueType: FlagValueType;
|
||||||
|
@ -13,8 +14,9 @@ export interface HookContext<T extends FlagValue = FlagValue> {
|
||||||
readonly clientMetadata: ClientMetadata;
|
readonly clientMetadata: ClientMetadata;
|
||||||
readonly providerMetadata: ProviderMetadata;
|
readonly providerMetadata: ProviderMetadata;
|
||||||
readonly logger: Logger;
|
readonly logger: Logger;
|
||||||
|
readonly hookData: HookData<TData>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BeforeHookContext extends HookContext {
|
export interface BeforeHookContext<T extends FlagValue = FlagValue, TData = Record<string, unknown>> extends HookContext<T, TData> {
|
||||||
context: EvaluationContext;
|
context: EvaluationContext;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
export * from './hook';
|
export * from './hook';
|
||||||
export * from './hooks';
|
export * from './hooks';
|
||||||
export * from './evaluation-lifecycle';
|
export * from './evaluation-lifecycle';
|
||||||
|
export * from './hook-data';
|
||||||
|
|
|
@ -4,6 +4,7 @@ export * from './client';
|
||||||
export * from './errors';
|
export * from './errors';
|
||||||
export * from './events';
|
export * from './events';
|
||||||
export * from './logger';
|
export * from './logger';
|
||||||
|
export * from './telemetry';
|
||||||
export * from './provider';
|
export * from './provider';
|
||||||
export * from './evaluation';
|
export * from './evaluation';
|
||||||
export * from './type-guards';
|
export * from './type-guards';
|
||||||
|
|
|
@ -7,14 +7,13 @@ import type {
|
||||||
EventDetails,
|
EventDetails,
|
||||||
EventHandler,
|
EventHandler,
|
||||||
Eventing,
|
Eventing,
|
||||||
GenericEventEmitter} from './events';
|
EventOptions,
|
||||||
import {
|
GenericEventEmitter,
|
||||||
AllProviderEvents,
|
|
||||||
statusMatchesEvent,
|
|
||||||
} from './events';
|
} from './events';
|
||||||
|
import { AllProviderEvents, statusMatchesEvent } from './events';
|
||||||
import { isDefined } from './filter';
|
import { isDefined } from './filter';
|
||||||
import type { BaseHook, EvaluationLifeCycle } from './hooks';
|
import type { BaseHook, EvaluationLifeCycle } from './hooks';
|
||||||
import type { Logger, ManageLogger} from './logger';
|
import type { Logger, ManageLogger } from './logger';
|
||||||
import { DefaultLogger, SafeLogger } from './logger';
|
import { DefaultLogger, SafeLogger } from './logger';
|
||||||
import type { ClientProviderStatus, CommonProvider, ProviderMetadata, ServerProviderStatus } from './provider';
|
import type { ClientProviderStatus, CommonProvider, ProviderMetadata, ServerProviderStatus } from './provider';
|
||||||
import { objectOrUndefined, stringOrUndefined } from './type-guards';
|
import { objectOrUndefined, stringOrUndefined } from './type-guards';
|
||||||
|
@ -154,8 +153,9 @@ export abstract class OpenFeatureCommonAPI<
|
||||||
* API (global) events run for all providers.
|
* API (global) events run for all providers.
|
||||||
* @param {AnyProviderEvent} eventType The provider event type to listen to
|
* @param {AnyProviderEvent} eventType The provider event type to listen to
|
||||||
* @param {EventHandler} handler The handler to run on occurrence of the event type
|
* @param {EventHandler} handler The handler to run on occurrence of the event type
|
||||||
|
* @param {EventOptions} options Optional options such as signal for aborting
|
||||||
*/
|
*/
|
||||||
addHandler<T extends AnyProviderEvent>(eventType: T, handler: EventHandler): void {
|
addHandler<T extends AnyProviderEvent>(eventType: T, handler: EventHandler, options?: EventOptions): void {
|
||||||
[...new Map([[undefined, this._defaultProvider]]), ...this._domainScopedProviders].forEach((keyProviderTuple) => {
|
[...new Map([[undefined, this._defaultProvider]]), ...this._domainScopedProviders].forEach((keyProviderTuple) => {
|
||||||
const domain = keyProviderTuple[0];
|
const domain = keyProviderTuple[0];
|
||||||
const provider = keyProviderTuple[1].provider;
|
const provider = keyProviderTuple[1].provider;
|
||||||
|
@ -173,6 +173,11 @@ export abstract class OpenFeatureCommonAPI<
|
||||||
});
|
});
|
||||||
|
|
||||||
this._apiEmitter.addHandler(eventType, handler);
|
this._apiEmitter.addHandler(eventType, handler);
|
||||||
|
if (options?.signal && typeof options.signal.addEventListener === 'function') {
|
||||||
|
options.signal.addEventListener('abort', () => {
|
||||||
|
this.removeHandler(eventType, handler);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -248,7 +253,7 @@ export abstract class OpenFeatureCommonAPI<
|
||||||
// initialize the provider if it implements "initialize" and it's not already registered
|
// initialize the provider if it implements "initialize" and it's not already registered
|
||||||
if (typeof provider.initialize === 'function' && !this.allProviders.includes(provider)) {
|
if (typeof provider.initialize === 'function' && !this.allProviders.includes(provider)) {
|
||||||
initializationPromise = provider
|
initializationPromise = provider
|
||||||
.initialize?.(domain ? this._domainScopedContext.get(domain) ?? this._context : this._context)
|
.initialize?.(domain ? (this._domainScopedContext.get(domain) ?? this._context) : this._context)
|
||||||
?.then(() => {
|
?.then(() => {
|
||||||
wrappedProvider.status = this._statusEnumType.READY;
|
wrappedProvider.status = this._statusEnumType.READY;
|
||||||
// fetch the most recent event emitters, some may have been added during init
|
// fetch the most recent event emitters, some may have been added during init
|
||||||
|
|
|
@ -20,6 +20,14 @@ export const TelemetryAttribute = {
|
||||||
* - example: `flag_not_found`
|
* - example: `flag_not_found`
|
||||||
*/
|
*/
|
||||||
ERROR_CODE: 'error.type',
|
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.
|
* A semantic identifier for an evaluated flag value.
|
||||||
*
|
*
|
||||||
|
@ -28,7 +36,16 @@ export const TelemetryAttribute = {
|
||||||
* - condition: variant is defined on the evaluation details
|
* - condition: variant is defined on the evaluation details
|
||||||
* - example: `blue`; `on`; `true`
|
* - 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.
|
* 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`
|
* - example: `5157782b-2203-4c80-a857-dbbd5e7761db`
|
||||||
*/
|
*/
|
||||||
CONTEXT_ID: 'feature_flag.context.id',
|
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.
|
* The reason code which shows how a feature flag value was determined.
|
||||||
*
|
*
|
||||||
|
@ -52,7 +61,7 @@ export const TelemetryAttribute = {
|
||||||
* - requirement level: `recommended`
|
* - requirement level: `recommended`
|
||||||
* - example: `targeting_match`
|
* - example: `targeting_match`
|
||||||
*/
|
*/
|
||||||
REASON: 'feature_flag.evaluation.reason',
|
REASON: 'feature_flag.result.reason',
|
||||||
/**
|
/**
|
||||||
* Describes a class of error the operation ended with.
|
* Describes a class of error the operation ended with.
|
||||||
*
|
*
|
||||||
|
@ -60,7 +69,7 @@ export const TelemetryAttribute = {
|
||||||
* - requirement level: `recommended`
|
* - requirement level: `recommended`
|
||||||
* - example: `flag_not_found`
|
* - 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.
|
* 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 { ErrorCode, StandardResolutionReasons, type EvaluationDetails, type FlagValue } from '../evaluation/evaluation';
|
||||||
import type { HookContext } from '../hooks/hooks';
|
import type { HookContext } from '../hooks/hooks';
|
||||||
import type { JsonValue } from '../types';
|
|
||||||
import { TelemetryAttribute } from './attributes';
|
import { TelemetryAttribute } from './attributes';
|
||||||
import { TelemetryEvaluationData } from './evaluation-data';
|
|
||||||
import { TelemetryFlagMetadata } from './flag-metadata';
|
import { TelemetryFlagMetadata } from './flag-metadata';
|
||||||
|
|
||||||
type EvaluationEvent = {
|
type EvaluationEvent = {
|
||||||
|
/**
|
||||||
|
* The name of the feature flag evaluation event.
|
||||||
|
*/
|
||||||
name: string;
|
name: string;
|
||||||
attributes: Record<string, string | number | boolean>;
|
/**
|
||||||
data: 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';
|
const FLAG_EVALUATION_EVENT_NAME = 'feature_flag.evaluation';
|
||||||
|
@ -28,12 +33,11 @@ export function createEvaluationEvent(
|
||||||
[TelemetryAttribute.PROVIDER]: hookContext.providerMetadata.name,
|
[TelemetryAttribute.PROVIDER]: hookContext.providerMetadata.name,
|
||||||
[TelemetryAttribute.REASON]: (evaluationDetails.reason ?? StandardResolutionReasons.UNKNOWN).toLowerCase(),
|
[TelemetryAttribute.REASON]: (evaluationDetails.reason ?? StandardResolutionReasons.UNKNOWN).toLowerCase(),
|
||||||
};
|
};
|
||||||
const data: EvaluationEvent['data'] = {};
|
|
||||||
|
|
||||||
if (evaluationDetails.variant) {
|
if (evaluationDetails.variant) {
|
||||||
attributes[TelemetryAttribute.VARIANT] = evaluationDetails.variant;
|
attributes[TelemetryAttribute.VARIANT] = evaluationDetails.variant;
|
||||||
} else {
|
} else {
|
||||||
data[TelemetryEvaluationData.VALUE] = evaluationDetails.value;
|
attributes[TelemetryAttribute.VALUE] = evaluationDetails.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const contextId =
|
const contextId =
|
||||||
|
@ -62,6 +66,5 @@ export function createEvaluationEvent(
|
||||||
return {
|
return {
|
||||||
name: FLAG_EVALUATION_EVENT_NAME,
|
name: FLAG_EVALUATION_EVENT_NAME,
|
||||||
attributes,
|
attributes,
|
||||||
data,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
export * from './attributes';
|
export * from './attributes';
|
||||||
export * from './evaluation-data';
|
|
||||||
export * from './flag-metadata';
|
export * from './flag-metadata';
|
||||||
export * from './evaluation-event';
|
export * from './evaluation-event';
|
||||||
|
|
|
@ -17,14 +17,20 @@ class TestEventEmitter extends GenericEventEmitter<AnyProviderEvent> {
|
||||||
// a little function to make sure we're at least waiting for the event loop
|
// a little function to make sure we're at least waiting for the event loop
|
||||||
// to clear before we start making assertions
|
// to clear before we start making assertions
|
||||||
const wait = (millis = 0) => {
|
const wait = (millis = 0) => {
|
||||||
return new Promise(resolve => {setTimeout(resolve, millis);});
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, millis);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('GenericEventEmitter', () => {
|
describe('GenericEventEmitter', () => {
|
||||||
|
const emitter = new TestEventEmitter();
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
emitter.removeAllHandlers();
|
||||||
|
});
|
||||||
|
|
||||||
describe('addHandler should', function () {
|
describe('addHandler should', function () {
|
||||||
it('attach handler for event type', async function () {
|
it('attach handler for event type', async function () {
|
||||||
const emitter = new TestEventEmitter();
|
|
||||||
|
|
||||||
const handler1 = jest.fn();
|
const handler1 = jest.fn();
|
||||||
emitter.addHandler(AllProviderEvents.Ready, handler1);
|
emitter.addHandler(AllProviderEvents.Ready, handler1);
|
||||||
emitter.emit(AllProviderEvents.Ready);
|
emitter.emit(AllProviderEvents.Ready);
|
||||||
|
@ -35,8 +41,6 @@ describe('GenericEventEmitter', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('attach several handlers for event type', async function () {
|
it('attach several handlers for event type', async function () {
|
||||||
const emitter = new TestEventEmitter();
|
|
||||||
|
|
||||||
const handler1 = jest.fn();
|
const handler1 = jest.fn();
|
||||||
const handler2 = jest.fn();
|
const handler2 = jest.fn();
|
||||||
const handler3 = jest.fn();
|
const handler3 = jest.fn();
|
||||||
|
@ -64,7 +68,6 @@ describe('GenericEventEmitter', () => {
|
||||||
debug: () => done(),
|
debug: () => done(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const emitter = new TestEventEmitter();
|
|
||||||
emitter.setLogger(logger);
|
emitter.setLogger(logger);
|
||||||
|
|
||||||
emitter.addHandler(AllProviderEvents.Ready, async () => {
|
emitter.addHandler(AllProviderEvents.Ready, async () => {
|
||||||
|
@ -74,8 +77,6 @@ describe('GenericEventEmitter', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('trigger handler for event type', async function () {
|
it('trigger handler for event type', async function () {
|
||||||
const emitter = new TestEventEmitter();
|
|
||||||
|
|
||||||
const handler1 = jest.fn();
|
const handler1 = jest.fn();
|
||||||
emitter.addHandler(AllProviderEvents.Ready, handler1);
|
emitter.addHandler(AllProviderEvents.Ready, handler1);
|
||||||
emitter.emit(AllProviderEvents.Ready);
|
emitter.emit(AllProviderEvents.Ready);
|
||||||
|
@ -87,7 +88,6 @@ describe('GenericEventEmitter', () => {
|
||||||
|
|
||||||
it('trigger handler for event type with event data', async function () {
|
it('trigger handler for event type with event data', async function () {
|
||||||
const event: ReadyEvent = { message: 'message' };
|
const event: ReadyEvent = { message: 'message' };
|
||||||
const emitter = new TestEventEmitter();
|
|
||||||
|
|
||||||
const handler1 = jest.fn();
|
const handler1 = jest.fn();
|
||||||
emitter.addHandler(AllProviderEvents.Ready, handler1);
|
emitter.addHandler(AllProviderEvents.Ready, handler1);
|
||||||
|
@ -99,8 +99,6 @@ describe('GenericEventEmitter', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('trigger several handlers for event type', async function () {
|
it('trigger several handlers for event type', async function () {
|
||||||
const emitter = new TestEventEmitter();
|
|
||||||
|
|
||||||
const handler1 = jest.fn();
|
const handler1 = jest.fn();
|
||||||
const handler2 = jest.fn();
|
const handler2 = jest.fn();
|
||||||
const handler3 = jest.fn();
|
const handler3 = jest.fn();
|
||||||
|
@ -121,8 +119,6 @@ describe('GenericEventEmitter', () => {
|
||||||
|
|
||||||
describe('removeHandler should', () => {
|
describe('removeHandler should', () => {
|
||||||
it('remove single handler', async function () {
|
it('remove single handler', async function () {
|
||||||
const emitter = new TestEventEmitter();
|
|
||||||
|
|
||||||
const handler1 = jest.fn();
|
const handler1 = jest.fn();
|
||||||
emitter.addHandler(AllProviderEvents.Ready, handler1);
|
emitter.addHandler(AllProviderEvents.Ready, handler1);
|
||||||
|
|
||||||
|
@ -138,8 +134,6 @@ describe('GenericEventEmitter', () => {
|
||||||
|
|
||||||
describe('removeAllHandlers should', () => {
|
describe('removeAllHandlers should', () => {
|
||||||
it('remove all handlers for event type', async function () {
|
it('remove all handlers for event type', async function () {
|
||||||
const emitter = new TestEventEmitter();
|
|
||||||
|
|
||||||
const handler1 = jest.fn();
|
const handler1 = jest.fn();
|
||||||
const handler2 = jest.fn();
|
const handler2 = jest.fn();
|
||||||
emitter.addHandler(AllProviderEvents.Ready, handler1);
|
emitter.addHandler(AllProviderEvents.Ready, handler1);
|
||||||
|
@ -156,8 +150,6 @@ describe('GenericEventEmitter', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('remove same handler when assigned to multiple events', async function () {
|
it('remove same handler when assigned to multiple events', async function () {
|
||||||
const emitter = new TestEventEmitter();
|
|
||||||
|
|
||||||
const handler = jest.fn();
|
const handler = jest.fn();
|
||||||
emitter.addHandler(AllProviderEvents.Stale, handler);
|
emitter.addHandler(AllProviderEvents.Stale, handler);
|
||||||
emitter.addHandler(AllProviderEvents.ContextChanged, handler);
|
emitter.addHandler(AllProviderEvents.ContextChanged, handler);
|
||||||
|
@ -174,8 +166,6 @@ describe('GenericEventEmitter', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allow addition/removal of duplicate handlers', async function () {
|
it('allow addition/removal of duplicate handlers', async function () {
|
||||||
const emitter = new TestEventEmitter();
|
|
||||||
|
|
||||||
const handler = jest.fn();
|
const handler = jest.fn();
|
||||||
emitter.addHandler(AllProviderEvents.Stale, handler);
|
emitter.addHandler(AllProviderEvents.Stale, handler);
|
||||||
emitter.addHandler(AllProviderEvents.Stale, handler);
|
emitter.addHandler(AllProviderEvents.Stale, handler);
|
||||||
|
@ -191,8 +181,6 @@ describe('GenericEventEmitter', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allow duplicate event handlers and call them', async function () {
|
it('allow duplicate event handlers and call them', async function () {
|
||||||
const emitter = new TestEventEmitter();
|
|
||||||
|
|
||||||
const handler = jest.fn();
|
const handler = jest.fn();
|
||||||
emitter.addHandler(AllProviderEvents.Stale, handler);
|
emitter.addHandler(AllProviderEvents.Stale, handler);
|
||||||
emitter.addHandler(AllProviderEvents.Stale, handler);
|
emitter.addHandler(AllProviderEvents.Stale, handler);
|
||||||
|
@ -205,8 +193,6 @@ describe('GenericEventEmitter', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('remove all handlers only for event type', async function () {
|
it('remove all handlers only for event type', async function () {
|
||||||
const emitter = new TestEventEmitter();
|
|
||||||
|
|
||||||
const handler1 = jest.fn();
|
const handler1 = jest.fn();
|
||||||
const handler2 = jest.fn();
|
const handler2 = jest.fn();
|
||||||
emitter.addHandler(AllProviderEvents.Ready, handler1);
|
emitter.addHandler(AllProviderEvents.Ready, handler1);
|
||||||
|
@ -223,8 +209,6 @@ describe('GenericEventEmitter', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('remove all handlers if no event type is given', async function () {
|
it('remove all handlers if no event type is given', async function () {
|
||||||
const emitter = new TestEventEmitter();
|
|
||||||
|
|
||||||
const handler1 = jest.fn();
|
const handler1 = jest.fn();
|
||||||
const handler2 = jest.fn();
|
const handler2 = jest.fn();
|
||||||
emitter.addHandler(AllProviderEvents.Ready, handler1);
|
emitter.addHandler(AllProviderEvents.Ready, handler1);
|
||||||
|
|
|
@ -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 { createEvaluationEvent } from '../src/telemetry/evaluation-event';
|
||||||
import { ErrorCode, StandardResolutionReasons, type EvaluationDetails } from '../src/evaluation/evaluation';
|
import { ErrorCode, StandardResolutionReasons, type EvaluationDetails } from '../src/evaluation/evaluation';
|
||||||
import type { HookContext } from '../src/hooks/hooks';
|
import type { HookContext } from '../src/hooks/hooks';
|
||||||
import { TelemetryAttribute, TelemetryFlagMetadata, TelemetryEvaluationData } from '../src/telemetry';
|
import { TelemetryAttribute, TelemetryFlagMetadata } from '../src/telemetry';
|
||||||
|
import { MapHookData } from '../src/hooks/hook-data';
|
||||||
|
|
||||||
describe('evaluationEvent', () => {
|
describe('evaluationEvent', () => {
|
||||||
const flagKey = 'test-flag';
|
const flagKey = 'test-flag';
|
||||||
|
@ -25,6 +26,7 @@ describe('evaluationEvent', () => {
|
||||||
error: jest.fn(),
|
error: jest.fn(),
|
||||||
warn: jest.fn(),
|
warn: jest.fn(),
|
||||||
},
|
},
|
||||||
|
hookData: new MapHookData(),
|
||||||
};
|
};
|
||||||
|
|
||||||
it('should return basic event body with mandatory fields', () => {
|
it('should return basic event body with mandatory fields', () => {
|
||||||
|
@ -43,9 +45,7 @@ describe('evaluationEvent', () => {
|
||||||
[TelemetryAttribute.PROVIDER]: 'test-provider',
|
[TelemetryAttribute.PROVIDER]: 'test-provider',
|
||||||
[TelemetryAttribute.REASON]: StandardResolutionReasons.STATIC.toLowerCase(),
|
[TelemetryAttribute.REASON]: StandardResolutionReasons.STATIC.toLowerCase(),
|
||||||
[TelemetryAttribute.CONTEXT_ID]: 'test-target',
|
[TelemetryAttribute.CONTEXT_ID]: 'test-target',
|
||||||
});
|
[TelemetryAttribute.VALUE]: true,
|
||||||
expect(result.data).toEqual({
|
|
||||||
[TelemetryEvaluationData.VALUE]: true,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -61,7 +61,7 @@ describe('evaluationEvent', () => {
|
||||||
const result = createEvaluationEvent(mockHookContext, details);
|
const result = createEvaluationEvent(mockHookContext, details);
|
||||||
|
|
||||||
expect(result.attributes[TelemetryAttribute.VARIANT]).toBe('test-variant');
|
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', () => {
|
it('should include flag metadata when provided', () => {
|
||||||
|
|
|
@ -1,6 +1,51 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
|
||||||
|
## [1.6.1](https://github.com/open-feature/js-sdk/compare/web-sdk-v1.6.0...web-sdk-v1.6.1) (2025-08-14)
|
||||||
|
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
* update core dep ([#1228](https://github.com/open-feature/js-sdk/issues/1228)) ([845d24c](https://github.com/open-feature/js-sdk/commit/845d24c5fecc80de3080e49fde839f08ecac6b33))
|
||||||
|
|
||||||
|
## [1.6.0](https://github.com/open-feature/js-sdk/compare/web-sdk-v1.5.0...web-sdk-v1.6.0) (2025-08-12)
|
||||||
|
|
||||||
|
|
||||||
|
### ✨ New Features
|
||||||
|
|
||||||
|
* add evaluation-scoped hook data ([#1216](https://github.com/open-feature/js-sdk/issues/1216)) ([07af3a9](https://github.com/open-feature/js-sdk/commit/07af3a9eda895e9edb24c7ee1e3c1c4f16e17431))
|
||||||
|
* **web-global-build:** impl ([#1225](https://github.com/open-feature/js-sdk/issues/1225)) ([40a512e](https://github.com/open-feature/js-sdk/commit/40a512e21204eb92dc3ef4161b383f9c1fd74da7))
|
||||||
|
|
||||||
|
|
||||||
|
### 📚 Documentation
|
||||||
|
|
||||||
|
* Clarify the behavior of setProviderAndWait ([#1180](https://github.com/open-feature/js-sdk/issues/1180)) ([4fe8d87](https://github.com/open-feature/js-sdk/commit/4fe8d87a2e5df2cbd4086cc4f4a380e8857ed8ba))
|
||||||
|
|
||||||
|
## [1.5.0](https://github.com/open-feature/js-sdk/compare/web-sdk-v1.4.1...web-sdk-v1.5.0) (2025-04-11)
|
||||||
|
|
||||||
|
|
||||||
|
### ✨ 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)
|
||||||
|
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
* msg missing when providers return err resolutions ([#1134](https://github.com/open-feature/js-sdk/issues/1134)) ([bc9f6e4](https://github.com/open-feature/js-sdk/commit/bc9f6e44da3f1c0a66659aee2d0316629ac34fbf))
|
||||||
|
|
||||||
|
|
||||||
|
### 🧹 Chore
|
||||||
|
|
||||||
|
* update core peer ([8bbd43e](https://github.com/open-feature/js-sdk/commit/8bbd43e579a0c2e0c5b7eec00f94bbcffce04773))
|
||||||
|
|
||||||
## [1.4.0](https://github.com/open-feature/js-sdk/compare/web-sdk-v1.3.2...web-sdk-v1.4.0) (2024-12-18)
|
## [1.4.0](https://github.com/open-feature/js-sdk/compare/web-sdk-v1.3.2...web-sdk-v1.4.0) (2024-12-18)
|
||||||
|
|
||||||
### ⚠ BREAKING CHANGES
|
### ⚠ BREAKING CHANGES
|
||||||
|
|
|
@ -16,8 +16,8 @@
|
||||||
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
|
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
|
||||||
</a>
|
</a>
|
||||||
<!-- x-release-please-start-version -->
|
<!-- x-release-please-start-version -->
|
||||||
<a href="https://github.com/open-feature/js-sdk/releases/tag/web-sdk-v1.4.0">
|
<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.4.0&color=blue&style=for-the-badge" />
|
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v1.6.1&color=blue&style=for-the-badge" />
|
||||||
</a>
|
</a>
|
||||||
<!-- x-release-please-end -->
|
<!-- x-release-please-end -->
|
||||||
<br/>
|
<br/>
|
||||||
|
@ -75,7 +75,11 @@ yarn add @openfeature/web-sdk @openfeature/core
|
||||||
import { OpenFeature } from '@openfeature/web-sdk';
|
import { OpenFeature } from '@openfeature/web-sdk';
|
||||||
|
|
||||||
// Register your feature flag provider
|
// 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
|
// create a new client
|
||||||
const client = OpenFeature.getClient();
|
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:
|
To register a provider and ensure it is ready before further actions are taken, you can use the `setProviderAndWait` method as shown below:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
await OpenFeature.setProviderAndWait(new MyProvider());
|
try {
|
||||||
|
await OpenFeature.setProviderAndWait(new MyProvider());
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize provider:', error);
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Synchronous
|
#### Synchronous
|
||||||
|
@ -172,7 +180,7 @@ await OpenFeature.setContext({ targetingKey: localStorage.getItem("targetingKey"
|
||||||
```
|
```
|
||||||
|
|
||||||
Context is global and setting it is `async`.
|
Context is global and setting it is `async`.
|
||||||
Providers may implement an `onContextChanged` method that receives the old and newer contexts.
|
Providers may implement an `onContextChange` method that receives the old and newer contexts.
|
||||||
Given a context change, providers can use this method internally to detect if the flag values cached on the client are still valid.
|
Given a context change, providers can use this method internally to detect if the flag values cached on the client are still valid.
|
||||||
If needed, a request will be made to the provider with the new context in order to get the correct flag values.
|
If needed, a request will be made to the provider with the new context in order to get the correct flag values.
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
{
|
{
|
||||||
"name": "@openfeature/web-sdk",
|
"name": "@openfeature/web-sdk",
|
||||||
"version": "1.4.0",
|
"version": "1.6.1",
|
||||||
"description": "OpenFeature SDK for Web",
|
"description": "OpenFeature SDK for Web",
|
||||||
"main": "./dist/cjs/index.js",
|
"main": "./dist/cjs/index.js",
|
||||||
|
"unpkg": "dist/global/index.min.js",
|
||||||
|
"jsdelivr": "dist/global/index.min.js",
|
||||||
"files": [
|
"files": [
|
||||||
"dist/"
|
"dist/"
|
||||||
],
|
],
|
||||||
|
@ -20,8 +22,10 @@
|
||||||
"clean": "shx rm -rf ./dist",
|
"clean": "shx rm -rf ./dist",
|
||||||
"build:web-esm": "esbuild src/index.ts --bundle --external:@openfeature/core --sourcemap --target=es2015 --platform=browser --format=esm --outfile=./dist/esm/index.js --analyze",
|
"build:web-esm": "esbuild src/index.ts --bundle --external:@openfeature/core --sourcemap --target=es2015 --platform=browser --format=esm --outfile=./dist/esm/index.js --analyze",
|
||||||
"build:web-cjs": "esbuild src/index.ts --bundle --external:@openfeature/core --sourcemap --target=es2015 --platform=browser --format=cjs --outfile=./dist/cjs/index.js --analyze",
|
"build:web-cjs": "esbuild src/index.ts --bundle --external:@openfeature/core --sourcemap --target=es2015 --platform=browser --format=cjs --outfile=./dist/cjs/index.js --analyze",
|
||||||
|
"build:web-global": "esbuild src/index.ts --bundle --sourcemap --target=es2015 --platform=browser --format=iife --outfile=./dist/global/index.js --global-name=OpenFeature --analyze",
|
||||||
|
"build:web-global:min": "esbuild src/index.ts --bundle --sourcemap --target=es2015 --platform=browser --format=iife --outfile=./dist/global/index.min.js --global-name=OpenFeature --minify --analyze",
|
||||||
"build:rollup-types": "rollup -c ../../rollup.config.mjs",
|
"build:rollup-types": "rollup -c ../../rollup.config.mjs",
|
||||||
"build": "npm run clean && npm run build:web-esm && npm run build:web-cjs && npm run build: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",
|
"postbuild": "shx cp ./../../package.esm.json ./dist/esm/package.json",
|
||||||
"current-version": "echo $npm_package_version",
|
"current-version": "echo $npm_package_version",
|
||||||
"prepack": "shx cp ./../../LICENSE ./LICENSE",
|
"prepack": "shx cp ./../../LICENSE ./LICENSE",
|
||||||
|
@ -46,9 +50,9 @@
|
||||||
},
|
},
|
||||||
"homepage": "https://github.com/open-feature/js-sdk#readme",
|
"homepage": "https://github.com/open-feature/js-sdk#readme",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@openfeature/core": "^1.7.0"
|
"@openfeature/core": "^1.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@openfeature/core": "^1.7.0"
|
"@openfeature/core": "^1.9.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,6 +12,7 @@ import type {
|
||||||
OpenFeatureError,
|
OpenFeatureError,
|
||||||
FlagMetadata,
|
FlagMetadata,
|
||||||
ResolutionDetails,
|
ResolutionDetails,
|
||||||
|
EventOptions,
|
||||||
} from '@openfeature/core';
|
} from '@openfeature/core';
|
||||||
import {
|
import {
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
|
@ -21,6 +22,7 @@ import {
|
||||||
StandardResolutionReasons,
|
StandardResolutionReasons,
|
||||||
instantiateErrorByErrorCode,
|
instantiateErrorByErrorCode,
|
||||||
statusMatchesEvent,
|
statusMatchesEvent,
|
||||||
|
MapHookData,
|
||||||
} from '@openfeature/core';
|
} from '@openfeature/core';
|
||||||
import type { FlagEvaluationOptions } from '../../evaluation';
|
import type { FlagEvaluationOptions } from '../../evaluation';
|
||||||
import type { ProviderEvents } from '../../events';
|
import type { ProviderEvents } from '../../events';
|
||||||
|
@ -74,7 +76,7 @@ export class OpenFeatureClient implements Client {
|
||||||
return this.providerStatusAccessor();
|
return this.providerStatusAccessor();
|
||||||
}
|
}
|
||||||
|
|
||||||
addHandler(eventType: ProviderEvents, handler: EventHandler): void {
|
addHandler(eventType: ProviderEvents, handler: EventHandler, options: EventOptions): void {
|
||||||
this.emitterAccessor().addHandler(eventType, handler);
|
this.emitterAccessor().addHandler(eventType, handler);
|
||||||
const shouldRunNow = statusMatchesEvent(eventType, this.providerStatus);
|
const shouldRunNow = statusMatchesEvent(eventType, this.providerStatus);
|
||||||
|
|
||||||
|
@ -90,6 +92,12 @@ export class OpenFeatureClient implements Client {
|
||||||
this._logger?.error('Error running event handler:', err);
|
this._logger?.error('Error running event handler:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options?.signal && typeof options.signal.addEventListener === 'function') {
|
||||||
|
options.signal.addEventListener('abort', () => {
|
||||||
|
this.removeHandler(eventType, handler);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
removeHandler(notificationType: ProviderEvents, handler: EventHandler): void {
|
removeHandler(notificationType: ProviderEvents, handler: EventHandler): void {
|
||||||
|
@ -224,22 +232,26 @@ export class OpenFeatureClient implements Client {
|
||||||
...this.apiContextAccessor(this?.options?.domain),
|
...this.apiContextAccessor(this?.options?.domain),
|
||||||
};
|
};
|
||||||
|
|
||||||
// this reference cannot change during the course of evaluation
|
// Create hook context instances for each hook (stable object references for the entire evaluation)
|
||||||
// it may be used as a key in WeakMaps
|
// This ensures hooks can use WeakMaps with hookContext as keys across lifecycle methods
|
||||||
const hookContext: Readonly<HookContext> = {
|
// NOTE: Uses the reversed order to reduce the number of times we have to calculate the index.
|
||||||
flagKey,
|
const hookContexts = allHooksReversed.map<HookContext>(() =>
|
||||||
defaultValue,
|
Object.freeze({
|
||||||
flagValueType: flagType,
|
flagKey,
|
||||||
clientMetadata: this.metadata,
|
defaultValue,
|
||||||
providerMetadata: this._provider.metadata,
|
flagValueType: flagType,
|
||||||
context,
|
clientMetadata: this.metadata,
|
||||||
logger: this._logger,
|
providerMetadata: this._provider.metadata,
|
||||||
};
|
context,
|
||||||
|
logger: this._logger,
|
||||||
|
hookData: new MapHookData(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
let evaluationDetails: EvaluationDetails<T>;
|
let evaluationDetails: EvaluationDetails<T>;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.beforeHooks(allHooks, hookContext, options);
|
this.beforeHooks(allHooks, hookContexts, options);
|
||||||
|
|
||||||
this.shortCircuitIfNotReady();
|
this.shortCircuitIfNotReady();
|
||||||
|
|
||||||
|
@ -254,45 +266,48 @@ export class OpenFeatureClient implements Client {
|
||||||
|
|
||||||
if (resolutionDetails.errorCode) {
|
if (resolutionDetails.errorCode) {
|
||||||
const err = instantiateErrorByErrorCode(resolutionDetails.errorCode, resolutionDetails.errorMessage);
|
const err = instantiateErrorByErrorCode(resolutionDetails.errorCode, resolutionDetails.errorMessage);
|
||||||
this.errorHooks(allHooksReversed, hookContext, err, options);
|
this.errorHooks(allHooksReversed, hookContexts, err, options);
|
||||||
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err, resolutionDetails.flagMetadata);
|
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err, resolutionDetails.flagMetadata);
|
||||||
} else {
|
} else {
|
||||||
this.afterHooks(allHooksReversed, hookContext, resolutionDetails, options);
|
this.afterHooks(allHooksReversed, hookContexts, resolutionDetails, options);
|
||||||
evaluationDetails = resolutionDetails;
|
evaluationDetails = resolutionDetails;
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
this.errorHooks(allHooksReversed, hookContext, err, options);
|
this.errorHooks(allHooksReversed, hookContexts, err, options);
|
||||||
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err);
|
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err);
|
||||||
}
|
}
|
||||||
this.finallyHooks(allHooksReversed, hookContext, evaluationDetails, options);
|
this.finallyHooks(allHooksReversed, hookContexts, evaluationDetails, options);
|
||||||
return evaluationDetails;
|
return evaluationDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
private beforeHooks(hooks: Hook[], hookContext: HookContext, options: FlagEvaluationOptions) {
|
private beforeHooks(hooks: Hook[], hookContexts: HookContext[], options: FlagEvaluationOptions) {
|
||||||
Object.freeze(hookContext);
|
for (const [index, hook] of hooks.entries()) {
|
||||||
Object.freeze(hookContext.context);
|
const hookContextIndex = hooks.length - 1 - index; // reverse index for before hooks
|
||||||
|
const hookContext = hookContexts[hookContextIndex];
|
||||||
for (const hook of hooks) {
|
Object.freeze(hookContext);
|
||||||
|
Object.freeze(hookContext.context);
|
||||||
hook?.before?.(hookContext, Object.freeze(options.hookHints));
|
hook?.before?.(hookContext, Object.freeze(options.hookHints));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private afterHooks(
|
private afterHooks(
|
||||||
hooks: Hook[],
|
hooks: Hook[],
|
||||||
hookContext: HookContext,
|
hookContexts: HookContext[],
|
||||||
evaluationDetails: EvaluationDetails<FlagValue>,
|
evaluationDetails: EvaluationDetails<FlagValue>,
|
||||||
options: FlagEvaluationOptions,
|
options: FlagEvaluationOptions,
|
||||||
) {
|
) {
|
||||||
// run "after" hooks sequentially
|
// 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);
|
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
|
// run "error" hooks sequentially
|
||||||
for (const hook of hooks) {
|
for (const [index, hook] of hooks.entries()) {
|
||||||
try {
|
try {
|
||||||
|
const hookContext = hookContexts[index];
|
||||||
hook?.error?.(hookContext, err, options.hookHints);
|
hook?.error?.(hookContext, err, options.hookHints);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this._logger.error(`Unhandled error during 'error' hook: ${err}`);
|
this._logger.error(`Unhandled error during 'error' hook: ${err}`);
|
||||||
|
@ -306,13 +321,14 @@ export class OpenFeatureClient implements Client {
|
||||||
|
|
||||||
private finallyHooks(
|
private finallyHooks(
|
||||||
hooks: Hook[],
|
hooks: Hook[],
|
||||||
hookContext: HookContext,
|
hookContexts: HookContext[],
|
||||||
evaluationDetails: EvaluationDetails<FlagValue>,
|
evaluationDetails: EvaluationDetails<FlagValue>,
|
||||||
options: FlagEvaluationOptions,
|
options: FlagEvaluationOptions,
|
||||||
) {
|
) {
|
||||||
// run "finally" hooks sequentially
|
// run "finally" hooks sequentially
|
||||||
for (const hook of hooks) {
|
for (const [index, hook] of hooks.entries()) {
|
||||||
try {
|
try {
|
||||||
|
const hookContext = hookContexts[index];
|
||||||
hook?.finally?.(hookContext, evaluationDetails, options.hookHints);
|
hook?.finally?.(hookContext, evaluationDetails, options.hookHints);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this._logger.error(`Unhandled error during 'finally' hook: ${err}`);
|
this._logger.error(`Unhandled error during 'finally' hook: ${err}`);
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
import type { BaseHook, FlagValue } from '@openfeature/core';
|
import type { BaseHook, FlagValue } from '@openfeature/core';
|
||||||
|
|
||||||
export type Hook = 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.
|
* Setting a provider supersedes the current provider used in new and existing unbound clients.
|
||||||
* @param {Provider} provider The provider responsible for flag evaluations.
|
* @param {Provider} provider The provider responsible for flag evaluations.
|
||||||
* @returns {Promise<void>}
|
* @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>;
|
setProviderAndWait(provider: Provider): Promise<void>;
|
||||||
/**
|
/**
|
||||||
|
@ -87,7 +87,7 @@ export class OpenFeatureAPI
|
||||||
* @param {Provider} provider The provider responsible for flag evaluations.
|
* @param {Provider} provider The provider responsible for flag evaluations.
|
||||||
* @param {EvaluationContext} context The evaluation context to use for flag evaluations.
|
* @param {EvaluationContext} context The evaluation context to use for flag evaluations.
|
||||||
* @returns {Promise<void>}
|
* @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>;
|
setProviderAndWait(provider: Provider, context: EvaluationContext): Promise<void>;
|
||||||
/**
|
/**
|
||||||
|
@ -97,7 +97,7 @@ export class OpenFeatureAPI
|
||||||
* @param {string} domain The name to identify the client
|
* @param {string} domain The name to identify the client
|
||||||
* @param {Provider} provider The provider responsible for flag evaluations.
|
* @param {Provider} provider The provider responsible for flag evaluations.
|
||||||
* @returns {Promise<void>}
|
* @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>;
|
setProviderAndWait(domain: string, provider: Provider): Promise<void>;
|
||||||
/**
|
/**
|
||||||
|
@ -108,7 +108,7 @@ export class OpenFeatureAPI
|
||||||
* @param {Provider} provider The provider responsible for flag evaluations.
|
* @param {Provider} provider The provider responsible for flag evaluations.
|
||||||
* @param {EvaluationContext} context The evaluation context to use for flag evaluations.
|
* @param {EvaluationContext} context The evaluation context to use for flag evaluations.
|
||||||
* @returns {Promise<void>}
|
* @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>;
|
setProviderAndWait(domain: string, provider: Provider, context: EvaluationContext): Promise<void>;
|
||||||
async setProviderAndWait(
|
async setProviderAndWait(
|
||||||
|
@ -205,6 +205,27 @@ export class OpenFeatureAPI
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the default provider.
|
||||||
|
*
|
||||||
|
* Note that it isn't recommended to interact with the provider directly, but rather through
|
||||||
|
* an OpenFeature client.
|
||||||
|
* @returns {Provider} Default Provider
|
||||||
|
*/
|
||||||
|
getProvider(): Provider;
|
||||||
|
/**
|
||||||
|
* Get the provider bound to the specified domain.
|
||||||
|
*
|
||||||
|
* Note that it isn't recommended to interact with the provider directly, but rather through
|
||||||
|
* an OpenFeature client.
|
||||||
|
* @param {string} domain An identifier which logically binds clients with providers
|
||||||
|
* @returns {Provider} Domain-scoped provider
|
||||||
|
*/
|
||||||
|
getProvider(domain?: string): Provider;
|
||||||
|
getProvider(domain?: string): Provider {
|
||||||
|
return this.getProviderForClient(domain);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the evaluation context globally.
|
* Sets the evaluation context globally.
|
||||||
* This will be used by all providers that have not bound to a domain.
|
* This will be used by all providers that have not bound to a domain.
|
||||||
|
@ -325,9 +346,9 @@ export class OpenFeatureAPI
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A factory function for creating new named OpenFeature clients. Clients can contain
|
* A factory function for creating new domain-scoped OpenFeature clients. Clients
|
||||||
* their own state (e.g. logger, hook, context). Multiple clients can be used
|
* can contain their own state (e.g. logger, hook, context). Multiple domains
|
||||||
* to segment feature flag configuration.
|
* can be used to segment feature flag configuration.
|
||||||
*
|
*
|
||||||
* If there is already a provider bound to this name via {@link this.setProvider setProvider}, this provider will be used.
|
* If there is already a provider bound to this name via {@link this.setProvider setProvider}, this provider will be used.
|
||||||
* Otherwise, the default provider is used until a provider is assigned to that name.
|
* Otherwise, the default provider is used until a provider is assigned to that name.
|
||||||
|
|
|
@ -476,7 +476,21 @@ describe('Events', () => {
|
||||||
expect(OpenFeature.getHandlers(eventType)).toHaveLength(0);
|
expect(OpenFeature.getHandlers(eventType)).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('The API provides a function allowing the removal of event handlers', () => {
|
it('The event handler can be removed using an abort signal', () => {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const handler1 = jest.fn();
|
||||||
|
const handler2 = jest.fn();
|
||||||
|
const eventType = ProviderEvents.Stale;
|
||||||
|
|
||||||
|
OpenFeature.addHandler(eventType, handler1, { signal: abortController.signal });
|
||||||
|
OpenFeature.addHandler(eventType, handler2);
|
||||||
|
expect(OpenFeature.getHandlers(eventType)).toHaveLength(2);
|
||||||
|
|
||||||
|
abortController.abort();
|
||||||
|
expect(OpenFeature.getHandlers(eventType)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('The API provides a function allowing the removal of event handlers from client', () => {
|
||||||
const client = OpenFeature.getClient(domain);
|
const client = OpenFeature.getClient(domain);
|
||||||
const handler = jest.fn();
|
const handler = jest.fn();
|
||||||
const eventType = ProviderEvents.Stale;
|
const eventType = ProviderEvents.Stale;
|
||||||
|
@ -486,6 +500,21 @@ describe('Events', () => {
|
||||||
client.removeHandler(eventType, handler);
|
client.removeHandler(eventType, handler);
|
||||||
expect(client.getHandlers(eventType)).toHaveLength(0);
|
expect(client.getHandlers(eventType)).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('The event handler on the client can be removed using an abort signal', () => {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const client = OpenFeature.getClient(domain);
|
||||||
|
const handler1 = jest.fn();
|
||||||
|
const handler2 = jest.fn();
|
||||||
|
const eventType = ProviderEvents.Stale;
|
||||||
|
|
||||||
|
client.addHandler(eventType, handler1, { signal: abortController.signal });
|
||||||
|
client.addHandler(eventType, handler2);
|
||||||
|
expect(client.getHandlers(eventType)).toHaveLength(2);
|
||||||
|
|
||||||
|
abortController.abort();
|
||||||
|
expect(client.getHandlers(eventType)).toHaveLength(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Requirement 5.3.1', () => {
|
describe('Requirement 5.3.1', () => {
|
||||||
|
|
|
@ -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.]+$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -75,8 +75,8 @@ describe('OpenFeature', () => {
|
||||||
it('should set the default provider if no domain is provided', () => {
|
it('should set the default provider if no domain is provided', () => {
|
||||||
const provider = mockProvider();
|
const provider = mockProvider();
|
||||||
OpenFeature.setProvider(provider);
|
OpenFeature.setProvider(provider);
|
||||||
const client = OpenFeature.getClient();
|
const registeredProvider = OpenFeature.getProvider();
|
||||||
expect(client.metadata.providerMetadata.name).toEqual(provider.metadata.name);
|
expect(registeredProvider).toEqual(provider);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not change providers associated with a domain when setting a new default provider', () => {
|
it('should not change providers associated with a domain when setting a new default provider', () => {
|
||||||
|
@ -86,11 +86,11 @@ describe('OpenFeature', () => {
|
||||||
OpenFeature.setProvider(provider);
|
OpenFeature.setProvider(provider);
|
||||||
OpenFeature.setProvider(domain, fakeProvider);
|
OpenFeature.setProvider(domain, fakeProvider);
|
||||||
|
|
||||||
const defaultClient = OpenFeature.getClient();
|
const defaultProvider = OpenFeature.getProvider();
|
||||||
const domainSpecificClient = OpenFeature.getClient(domain);
|
const domainSpecificProvider = OpenFeature.getProvider(domain);
|
||||||
|
|
||||||
expect(defaultClient.metadata.providerMetadata.name).toEqual(provider.metadata.name);
|
expect(defaultProvider).toEqual(provider);
|
||||||
expect(domainSpecificClient.metadata.providerMetadata.name).toEqual(fakeProvider.metadata.name);
|
expect(domainSpecificProvider).toEqual(fakeProvider);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should bind a new provider to existing clients in a matching domain', () => {
|
it('should bind a new provider to existing clients in a matching domain', () => {
|
||||||
|
|
|
@ -29,7 +29,7 @@
|
||||||
},
|
},
|
||||||
"packages/angular/projects/angular-sdk": {
|
"packages/angular/projects/angular-sdk": {
|
||||||
"release-type": "node",
|
"release-type": "node",
|
||||||
"prerelease": true,
|
"prerelease": false,
|
||||||
"bump-minor-pre-major": true,
|
"bump-minor-pre-major": true,
|
||||||
"bump-patch-for-minor-pre-major": true,
|
"bump-patch-for-minor-pre-major": true,
|
||||||
"extra-files": ["README.md"],
|
"extra-files": ["README.md"],
|
||||||
|
|
Loading…
Reference in New Issue