Compare commits

..

No commits in common. "main" and "server-sdk-v1.16.1" have entirely different histories.

105 changed files with 8875 additions and 11841 deletions

View File

@ -29,6 +29,21 @@ jobs:
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 18
registry-url: "https://registry.npmjs.org" registry-url: "https://registry.npmjs.org"
cache: 'npm' cache: 'npm'
# if this is an @openfeature/core release, but the SDKs to use this version as a peer, and commit back
- name: Update Peer Version in Dependants
if: ${{ endsWith(github.ref_name, env.CORE_PACKAGE) }}
run: |
npm run update-core-peers && \
! git diff-files --quiet && \
( echo 'Updated peer dependency in dependents, committing...'
git add --all && \
git config user.name "openfeature-peer-update-bot" && \
git config user.email "openfeature-peer-update-bot@openfeature.dev" && \
git commit -m 'fix: bump @openfeature/${{ env.CORE_PACKAGE }} peer' -s && \
git push ) || echo 'Peer dependency in dependents is already up to date.'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -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: 20 node-version: 18
cache: 'npm' cache: 'npm'
- name: Install - name: Install

View File

@ -16,9 +16,8 @@ jobs:
strategy: strategy:
matrix: matrix:
node-version: node-version:
- 18.x
- 20.x - 20.x
- 22.x
- 24.x
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -37,11 +36,8 @@ jobs:
- name: Lint - name: Lint
run: npm run lint run: npm run lint
- name: Test Jest Projects - name: Test
run: npm run test:jest run: npm run test
- name: Test Angular SDK
run: npm run test:angular
codecov-and-docs: codecov-and-docs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -50,7 +46,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 18
cache: 'npm' cache: 'npm'
- name: Install - name: Install
@ -72,7 +68,8 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 20 # we need 'fetch' for this test, which is only in 18
node-version: 18
cache: 'npm' cache: 'npm'
- name: Install - name: Install

View File

@ -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: 20 node-version: 18
- name: Generate SBOM - name: Generate SBOM
run: | run: |
npm install -g npm@^10.2.0 npm install -g npm@^10.2.0
@ -54,10 +54,6 @@ 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:
id-token: write
contents: write
steps: steps:
# The logic below handles the npm publication: # The logic below handles the npm publication:
- name: Checkout Repository - name: Checkout Repository
@ -65,7 +61,7 @@ jobs:
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 18
registry-url: "https://registry.npmjs.org" registry-url: "https://registry.npmjs.org"
cache: 'npm' cache: 'npm'
- name: Build Packages - name: Build Packages
@ -78,8 +74,6 @@ jobs:
- name: Publish to NPM - name: Publish to NPM
env: env:
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
# https://docs.npmjs.com/generating-provenance-statements
NPM_CONFIG_PROVENANCE: true
run: npm run publish-all run: npm run publish-all
- name: Build Docs - name: Build Docs

View File

@ -1,8 +1,9 @@
{ {
"packages/nest": "0.2.5", "packages/nest": "0.2.2",
"packages/react": "1.0.1", "packages/react": "0.4.7",
"packages/web": "1.6.1", "packages/angular": "0.0.1-experimental",
"packages/server": "1.19.0", "packages/web": "1.3.0",
"packages/shared": "1.9.0", "packages/server": "1.16.1",
"packages/angular/projects/angular-sdk": "0.0.16" "packages/shared": "1.5.0",
"packages/angular/projects/angular-sdk": "0.0.6-experimental"
} }

View File

@ -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/maintainers * @open-feature/sdk-javascript-maintainers

View File

@ -120,16 +120,6 @@ on each other), the owner should try to get people aligned by:
- If none of the above worked and the PR has been stuck for more than 2 weeks, - If none of the above worked and the PR has been stuck for more than 2 weeks,
the owner should bring it to the OpenFeatures [meeting](README.md#contributing). the owner should bring it to the OpenFeatures [meeting](README.md#contributing).
## Releasing
As with most OpenFeature repos, release-please supports our release process.
For this SDK specifically, keep in mind this is a monorepo with dependencies with between components.
If there are multiple release PRs open, ensure that you release them in order consistent with their dependency graph, waiting for each to fully complete.
For example, if there are pending releases for: `@openfeature/core`, `@openfeature/web-sdk` and `@openfeature/react-sdk`, release them in that order.
Also ensure that if there are changes in an artifact which depend on changes in a dependency, that you reflect that in the `peerDependencies` field.
For example, if a new release of `@openfeature/web-sdk` depends on features added in `@openfeature/core`, update the required minimum version of the `@openfeature/core` peer in the `@openfeature/web-sdk` package.json.
## Design Choices ## Design Choices
As with other OpenFeature SDKs, js-sdk follows the As with other OpenFeature SDKs, js-sdk follows the

View File

@ -161,7 +161,6 @@ 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$': [
@ -190,6 +189,26 @@ export default {
], ],
}, },
}, },
{
displayName: 'angular',
testEnvironment: 'jsdom',
preset: 'jest-preset-angular',
testMatch: ['<rootDir>/packages/angular/projects/angular-sdk/src/**/*.spec.{ts,tsx}'],
setupFilesAfterEnv: ['<rootDir>/packages/angular/setup-jest.ts'],
moduleNameMapper: {
'@openfeature/core': '<rootDir>/packages/shared/src',
'@openfeature/web-sdk': '<rootDir>/packages/web/src',
},
transform: {
'^.+\\.(ts|js|html|svg)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/packages/angular/tsconfig.json',
isolatedModules: true,
},
],
},
}
], ],
// Use this configuration option to add custom reporters to Jest // Use this configuration option to add custom reporters to Jest

16494
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +1,10 @@
{ {
"name": "@openfeature/js", "name": "@openfeature/js",
"engines": {
"npm": "^10.0.0"
},
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"description": "OpenFeature SDK for JavaScript", "description": "OpenFeature SDK for JavaScript",
"scripts": { "scripts": {
"test": "npm run test:jest && npm run test:angular", "test": "jest --selectProjects=shared --selectProjects=server --selectProjects=web --selectProjects=react --selectProjects=angular --silent",
"test:jest": "jest --selectProjects=shared --selectProjects=server --selectProjects=web --selectProjects=react --selectProjects=nest --silent",
"test:angular": "npm run test:coverage --workspace=packages/angular",
"e2e-server": "git submodule update --init --recursive && shx cp test-harness/features/evaluation.feature packages/server/e2e/features && jest --selectProjects=server-e2e --verbose", "e2e-server": "git submodule update --init --recursive && shx cp test-harness/features/evaluation.feature packages/server/e2e/features && jest --selectProjects=server-e2e --verbose",
"e2e-web": "git submodule update --init --recursive && shx cp test-harness/features/evaluation.feature packages/web/e2e/features && jest --selectProjects=web-e2e --verbose", "e2e-web": "git submodule update --init --recursive && shx cp test-harness/features/evaluation.feature packages/web/e2e/features && jest --selectProjects=web-e2e --verbose",
"e2e": "npm run e2e-server && npm run e2e-web", "e2e": "npm run e2e-server && npm run e2e-web",
@ -18,7 +13,9 @@
"clean": "shx rm -rf ./dist", "clean": "shx rm -rf ./dist",
"build": "npm run build --workspace=packages/shared --workspace=packages/server --workspace=packages/web --workspace=packages/react --workspace=packages/angular --workspace=packages/nest", "build": "npm run build --workspace=packages/shared --workspace=packages/server --workspace=packages/web --workspace=packages/react --workspace=packages/angular --workspace=packages/nest",
"publish-all": "npm run publish-if-not-exists --workspace=packages/shared --workspace=packages/server --workspace=packages/web --workspace=packages/react --workspace=packages/angular --workspace=packages/nest", "publish-all": "npm run publish-if-not-exists --workspace=packages/shared --workspace=packages/server --workspace=packages/web --workspace=packages/react --workspace=packages/angular --workspace=packages/nest",
"docs": "typedoc" "docs": "typedoc",
"core-version": "npm run version --workspace=packages/shared",
"update-core-peers": "export OPENFEATURE_CORE_VERSION=$(npm run --silent core-version) && npm run update-core-peer --workspace=packages/server --workspace=packages/web"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@ -36,15 +33,18 @@
"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": "^12.0.0", "@rollup/plugin-typescript": "^11.1.6",
"@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": "^22.0.0", "@types/node": "^20.11.16",
"@types/react": "^18.2.55", "@types/react": "^18.2.55",
"@types/uuid": "^10.0.0", "@types/uuid": "^9.0.8",
"esbuild": "^0.25.0", "esbuild": "^0.24.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",
@ -59,18 +59,23 @@
"jest-environment-jsdom": "^29.7.0", "jest-environment-jsdom": "^29.7.0",
"jest-environment-node": "^29.7.0", "jest-environment-node": "^29.7.0",
"jest-junit": "^16.0.0", "jest-junit": "^16.0.0",
"jest-preset-angular": "^14.2.4",
"ng-packagr": "^18.2.1",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"react": "^18.2.0", "react": "^18.2.0",
"rollup": "^4.0.0", "rollup": "^4.0.0",
"rollup-plugin-dts": "^6.1.1", "rollup-plugin-dts": "^6.1.1",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"shx": "^0.4.0", "shx": "^0.3.4",
"ts-jest": "^29.1.2", "ts-jest": "^29.1.2",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tslib": "^2.3.0", "tslib": "^2.3.0",
"typedoc": "^0.26.0", "typedoc": "^0.26.0",
"typescript": "^4.7.4", "typescript": "^4.7.4",
"uuid": "^11.0.0" "uuid": "^9.0.1"
},
"overrides": {
"typescript": "^4.7.4"
}, },
"workspaces": [ "workspaces": [
"packages/shared", "packages/shared",

View File

@ -10,7 +10,7 @@
"prefix": "lib", "prefix": "lib",
"architect": { "architect": {
"build": { "build": {
"builder": "@angular/build:ng-packagr", "builder": "@angular-devkit/build-angular:ng-packagr",
"options": { "options": {
"project": "projects/angular-sdk/ng-package.json" "project": "projects/angular-sdk/ng-package.json"
}, },
@ -32,15 +32,6 @@
"projects/angular-sdk/**/*.html" "projects/angular-sdk/**/*.html"
] ]
} }
},
"test": {
"builder": "@angular/build:unit-test",
"options": {
"tsConfig": "projects/angular-sdk/tsconfig.spec.json",
"providersFile": "projects/angular-sdk/src/test-provider.ts",
"runner": "vitest",
"buildTarget": "::development"
}
} }
} }
} }

View File

@ -7,42 +7,37 @@
"lint": "ng lint", "lint": "ng lint",
"lint:fix": "ng lint --fix", "lint:fix": "ng lint --fix",
"watch": "ng build --watch --configuration development", "watch": "ng build --watch --configuration development",
"test": "ng test", "test": "jest",
"test:coverage": "ng test --no-watch --code-coverage",
"build": "ng build && npm run postbuild", "build": "ng build && npm run postbuild",
"postbuild": "shx cp ./../../LICENSE ./dist/angular/LICENSE", "postbuild": "shx cp ./../../LICENSE ./dist/angular/LICENSE",
"publish-if-not-exists": "cp $NPM_CONFIG_USERCONFIG .npmrc && if [ \"$(npm --prefix dist/angular run current-published-version -s)\" = \"$(npm --prefix dist/angular run current-version -s)\" ]; then echo 'already published, skipping'; else cd dist/angular && npm publish --access public; fi" "publish-if-not-exists": "cp $NPM_CONFIG_USERCONFIG .npmrc && if [ \"$(npm --prefix dist/angular run current-published-version -s)\" = \"$(npm --prefix dist/angular run current-version -s)\" ]; then echo 'already published, skipping'; else cd dist/angular && npm publish --access public; fi"
}, },
"private": true, "private": true,
"devDependencies": { "devDependencies": {
"@angular-eslint/builder": "^20.1.1", "@angular-devkit/build-angular": "^18.2.10",
"@angular-eslint/eslint-plugin": "^20.1.1", "@angular-eslint/builder": "18.4.0",
"@angular-eslint/eslint-plugin-template": "^20.1.1", "@angular-eslint/eslint-plugin": "18.4.0",
"@angular-eslint/schematics": "^20.1.1", "@angular-eslint/eslint-plugin-template": "18.4.0",
"@angular-eslint/template-parser": "^20.1.1", "@angular-eslint/schematics": "18.4.0",
"@angular/animations": "^20.1.1", "@angular-eslint/template-parser": "18.4.0",
"@angular/build": "^20.1.1", "@angular/animations": "^18.2.9",
"@angular/cli": "^20.1.1", "@angular/cli": "^18.2.10",
"@angular/common": "^20.1.1", "@angular/common": "^18.2.9",
"@angular/compiler": "^20.1.1", "@angular/compiler": "^18.2.9",
"@angular/compiler-cli": "^20.1.1", "@angular/compiler-cli": "^18.2.9",
"@angular/core": "^20.1.1", "@angular/core": "^18.2.9",
"@angular/forms": "^20.1.1", "@angular/forms": "^18.2.9",
"@angular/platform-browser": "^20.1.1", "@angular/platform-browser": "^18.2.9",
"@angular/platform-browser-dynamic": "^20.1.1", "@angular/platform-browser-dynamic": "^18.2.9",
"@angular/router": "^20.1.1", "@angular/router": "^18.2.9",
"@typescript-eslint/eslint-plugin": "7.18.0", "@typescript-eslint/eslint-plugin": "7.11.0",
"@typescript-eslint/parser": "7.18.0", "@typescript-eslint/parser": "7.11.0",
"@vitest/browser": "^3.2.4",
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"jsdom": "^26.1.0", "jest-preset-angular": "^14.2.4",
"ng-packagr": "^20.1.0", "ng-packagr": "^18.2.1",
"playwright": "^1.53.2",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0", "tslib": "^2.3.0",
"typescript": "^5.8.3", "typescript": "^5.5.4",
"vitest": "^3.2.4", "zone.js": "~0.14.3"
"zone.js": "~0.15.0"
} }
} }

View File

@ -1,18 +0,0 @@
# This file is used by the build system to adjust CSS and JS output to support the specified browsers below.
# For additional information regarding the format and rule options, please see:
# https://github.com/browserslist/browserslist#queries
# For Angular's browser support policy, please see:
# https://angular.dev/reference/versions#browser-support
# You can see what browsers were selected by your queries by running:
# npx browserslist
Chrome >= 107
ChromeAndroid >= 107
Edge >= 107
Firefox >= 104
FirefoxAndroid >= 104
Safari >= 16
iOS >= 16

View File

@ -1,82 +1,5 @@
# 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)
### 🧹 Chore
* **angular:** add repository to package.json ([#1093](https://github.com/open-feature/js-sdk/issues/1093)) ([35f000e](https://github.com/open-feature/js-sdk/commit/35f000e0f3c3ff7d60c05883312691d14f01c5fd))
## [0.0.8-experimental](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.7-experimental...angular-sdk-v0.0.8-experimental) (2024-11-21)
### ✨ New Features
* **angular:** add angular 19 to peerDependencies ([4893d6f](https://github.com/open-feature/js-sdk/commit/4893d6f0003fbdcdcd4c7c061e9aed49e20b8976))
## [0.0.7-experimental](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.6-experimental...angular-sdk-v0.0.7-experimental) (2024-11-21)
Note: This version did not release
## [0.0.6-experimental](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.5-experimental...angular-sdk-v0.0.6-experimental) (2024-10-28) ## [0.0.6-experimental](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.5-experimental...angular-sdk-v0.0.6-experimental) (2024-10-28)

View File

@ -16,8 +16,8 @@
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" /> <img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
</a> </a>
<!-- x-release-please-start-version --> <!-- x-release-please-start-version -->
<a href="https://github.com/open-feature/js-sdk/releases/tag/angular-sdk-v0.0.16"> <a href="https://github.com/open-feature/js-sdk/releases/tag/angular-sdk-v0.0.6-experimental">
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.0.16&color=blue&style=for-the-badge" /> <img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.0.6-experimental&color=blue&style=for-the-badge" />
</a> </a>
<!-- x-release-please-end --> <!-- x-release-please-end -->
<br/> <br/>
@ -44,22 +44,21 @@ 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)
@ -114,7 +113,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 used in the application. // domainBoundProviders are optional, mostly needed if more than one provider is needed
domainBoundProviders: { domainBoundProviders: {
domain1: new YourOpenFeatureProvider(), domain1: new YourOpenFeatureProvider(),
domain2: new YourOtherOpenFeatureProvider(), domain2: new YourOtherOpenFeatureProvider(),
@ -282,63 +281,6 @@ 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()`
Heres 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?
@ -349,4 +291,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)

View File

@ -1,34 +1,25 @@
{ {
"name": "@openfeature/angular-sdk", "name": "@openfeature/angular-sdk",
"version": "0.0.16", "version": "0.0.6-experimental",
"description": "OpenFeature Angular SDK", "description": "OpenFeature Angular SDK",
"repository": {
"type": "git",
"url": "git+https://github.com/open-feature/js-sdk.git"
},
"license": "Apache-2.0",
"bugs": {
"url": "https://github.com/open-feature/js-sdk/issues"
},
"homepage": "https://github.com/open-feature/js-sdk#readme",
"scripts": { "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 || ^20.0.0", "@angular/common": "^16.2.12 || ^17.3.0 || ^18.0.0",
"@angular/core": "^16.2.12 || ^17.3.0 || ^18.0.0 || ^19.0.0 || ^20.0.0", "@angular/core": "^16.2.12 || ^17.3.0 || ^18.0.0",
"@openfeature/web-sdk": "^1.4.1" "@openfeature/web-sdk": "^1.2.3"
}, },
"dependencies": { "dependencies": {
"tslib": "^2.3.0" "tslib": "^2.3.0"
}, },
"devDependencies": { "devDependencies": {
"@openfeature/core": "^1.8.1", "@openfeature/core": "*",
"@openfeature/web-sdk": "^1.5.0", "@openfeature/web-sdk": "*",
"@angular/common": "^20.1.2", "@angular/common": "^18.2.9",
"@angular/core": "^20.1.2" "@angular/core": "^18.2.9"
}, },
"sideEffects": false, "sideEffects": false,
"keywords": [ "keywords": [

View File

@ -253,7 +253,6 @@ describe('FeatureFlagDirective', () => {
await expectRenderedText(fixture, 'case-2', 'Flag On'); await expectRenderedText(fixture, 'case-2', 'Flag On');
await updateFlagValue(provider, false); await updateFlagValue(provider, false);
fixture.detectChanges(); // Ensure change detection after flag update
await expectRenderedText(fixture, 'case-2', 'Flag Off'); await expectRenderedText(fixture, 'case-2', 'Flag Off');
}); });
}); });
@ -394,9 +393,7 @@ describe('FeatureFlagDirective', () => {
await waitForClientReady(client); await waitForClientReady(client);
await expectRenderedText(fixture, 'case-6', 'Flag On'); await expectRenderedText(fixture, 'case-6', 'Flag On');
fixture.componentRef.setInput('specialFlagKey', 'new-test-flag'); fixture.componentInstance.specialFlagKey = 'new-test-flag';
await fixture.whenStable();
await expectRenderedText(fixture, 'case-6', 'Flag Off'); await expectRenderedText(fixture, 'case-6', 'Flag Off');
}); });
@ -448,9 +445,7 @@ describe('FeatureFlagDirective', () => {
); );
await OpenFeature.setProviderAndWait(newDomain, newProvider); await OpenFeature.setProviderAndWait(newDomain, newProvider);
fixture.componentRef.setInput('domain', newDomain); fixture.componentInstance.domain = newDomain;
await fixture.whenStable();
await expectRenderedText(fixture, 'case-6', 'Flag Off'); await expectRenderedText(fixture, 'case-6', 'Flag Off');
}); });
}); });
@ -565,7 +560,8 @@ async function createTestingModule(config?: {
], ],
}).createComponent(TestComponent); }).createComponent(TestComponent);
fixture.componentRef.setInput('domain', domain); fixture.componentInstance.domain = domain;
fixture.detectChanges();
await fixture.whenStable(); await fixture.whenStable();
const client = OpenFeature.getClient(domain); const client = OpenFeature.getClient(domain);

View File

@ -8,7 +8,6 @@ import {
OnInit, OnInit,
TemplateRef, TemplateRef,
ViewContainerRef, ViewContainerRef,
inject,
} from '@angular/core'; } from '@angular/core';
import { import {
Client, Client,
@ -36,9 +35,6 @@ class FeatureFlagDirectiveContext<T extends FlagValue> {
selector: '[featureFlag]', selector: '[featureFlag]',
}) })
export abstract class FeatureFlagDirective<T extends FlagValue> implements OnInit, OnDestroy, OnChanges { export abstract class FeatureFlagDirective<T extends FlagValue> implements OnInit, OnDestroy, OnChanges {
protected _changeDetectorRef: ChangeDetectorRef;
protected _viewContainerRef: ViewContainerRef;
protected _featureFlagDefault: T; protected _featureFlagDefault: T;
protected _featureFlagDomain: string | undefined; protected _featureFlagDomain: string | undefined;
@ -68,7 +64,13 @@ export abstract class FeatureFlagDirective<T extends FlagValue> implements OnIni
protected _reconcilingTemplateRef: TemplateRef<FeatureFlagDirectiveContext<T>> | null; protected _reconcilingTemplateRef: TemplateRef<FeatureFlagDirectiveContext<T>> | null;
protected _reconcilingViewRef: EmbeddedViewRef<unknown> | null; protected _reconcilingViewRef: EmbeddedViewRef<unknown> | null;
protected constructor() {} protected constructor(
protected _changeDetectorRef: ChangeDetectorRef,
protected _viewContainerRef: ViewContainerRef,
templateRef: TemplateRef<FeatureFlagDirectiveContext<T>>,
) {
this._thenTemplateRef = templateRef;
}
set featureFlagDomain(domain: string | undefined) { set featureFlagDomain(domain: string | undefined) {
/** /**
@ -230,10 +232,6 @@ export abstract class FeatureFlagDirective<T extends FlagValue> implements OnIni
selector: '[booleanFeatureFlag]', selector: '[booleanFeatureFlag]',
}) })
export class BooleanFeatureFlagDirective extends FeatureFlagDirective<boolean> implements OnChanges { export class BooleanFeatureFlagDirective extends FeatureFlagDirective<boolean> implements OnChanges {
override _changeDetectorRef = inject(ChangeDetectorRef);
override _viewContainerRef = inject(ViewContainerRef);
override _thenTemplateRef = inject<TemplateRef<FeatureFlagDirectiveContext<boolean>>>(TemplateRef);
/** /**
* The key of the boolean feature flag. * The key of the boolean feature flag.
*/ */
@ -244,8 +242,12 @@ export class BooleanFeatureFlagDirective extends FeatureFlagDirective<boolean> i
*/ */
@Input({ required: true }) booleanFeatureFlagDefault: boolean; @Input({ required: true }) booleanFeatureFlagDefault: boolean;
constructor() { constructor(
super(); _changeDetectorRef: ChangeDetectorRef,
_viewContainerRef: ViewContainerRef,
templateRef: TemplateRef<FeatureFlagDirectiveContext<boolean>>,
) {
super(_changeDetectorRef, _viewContainerRef, templateRef);
} }
override ngOnChanges() { override ngOnChanges() {
@ -345,10 +347,6 @@ export class BooleanFeatureFlagDirective extends FeatureFlagDirective<boolean> i
selector: '[numberFeatureFlag]', selector: '[numberFeatureFlag]',
}) })
export class NumberFeatureFlagDirective extends FeatureFlagDirective<number> implements OnChanges { export class NumberFeatureFlagDirective extends FeatureFlagDirective<number> implements OnChanges {
override _changeDetectorRef = inject(ChangeDetectorRef);
override _viewContainerRef = inject(ViewContainerRef);
override _thenTemplateRef = inject<TemplateRef<FeatureFlagDirectiveContext<number>>>(TemplateRef);
/** /**
* The key of the number feature flag. * The key of the number feature flag.
*/ */
@ -364,8 +362,12 @@ export class NumberFeatureFlagDirective extends FeatureFlagDirective<number> imp
*/ */
@Input({ required: false }) numberFeatureFlagValue?: number; @Input({ required: false }) numberFeatureFlagValue?: number;
constructor() { constructor(
super(); _changeDetectorRef: ChangeDetectorRef,
_viewContainerRef: ViewContainerRef,
templateRef: TemplateRef<FeatureFlagDirectiveContext<number>>,
) {
super(_changeDetectorRef, _viewContainerRef, templateRef);
} }
override ngOnChanges() { override ngOnChanges() {
@ -465,10 +467,6 @@ export class NumberFeatureFlagDirective extends FeatureFlagDirective<number> imp
selector: '[stringFeatureFlag]', selector: '[stringFeatureFlag]',
}) })
export class StringFeatureFlagDirective extends FeatureFlagDirective<string> implements OnChanges { export class StringFeatureFlagDirective extends FeatureFlagDirective<string> implements OnChanges {
override _changeDetectorRef = inject(ChangeDetectorRef);
override _viewContainerRef = inject(ViewContainerRef);
override _thenTemplateRef = inject<TemplateRef<FeatureFlagDirectiveContext<string>>>(TemplateRef);
/** /**
* The key of the string feature flag. * The key of the string feature flag.
*/ */
@ -484,8 +482,12 @@ export class StringFeatureFlagDirective extends FeatureFlagDirective<string> imp
*/ */
@Input({ required: false }) stringFeatureFlagValue?: string; @Input({ required: false }) stringFeatureFlagValue?: string;
constructor() { constructor(
super(); _changeDetectorRef: ChangeDetectorRef,
_viewContainerRef: ViewContainerRef,
templateRef: TemplateRef<FeatureFlagDirectiveContext<string>>,
) {
super(_changeDetectorRef, _viewContainerRef, templateRef);
} }
override ngOnChanges() { override ngOnChanges() {
@ -585,10 +587,6 @@ export class StringFeatureFlagDirective extends FeatureFlagDirective<string> imp
selector: '[objectFeatureFlag]', selector: '[objectFeatureFlag]',
}) })
export class ObjectFeatureFlagDirective<T extends JsonValue> extends FeatureFlagDirective<T> implements OnChanges { export class ObjectFeatureFlagDirective<T extends JsonValue> extends FeatureFlagDirective<T> implements OnChanges {
override _changeDetectorRef = inject(ChangeDetectorRef);
override _viewContainerRef = inject(ViewContainerRef);
override _thenTemplateRef = inject<TemplateRef<FeatureFlagDirectiveContext<T>>>(TemplateRef);
/** /**
* The key of the object feature flag. * The key of the object feature flag.
*/ */
@ -604,8 +602,12 @@ export class ObjectFeatureFlagDirective<T extends JsonValue> extends FeatureFlag
*/ */
@Input({ required: false }) objectFeatureFlagValue?: T; @Input({ required: false }) objectFeatureFlagValue?: T;
constructor() { constructor(
super(); _changeDetectorRef: ChangeDetectorRef,
_viewContainerRef: ViewContainerRef,
templateRef: TemplateRef<FeatureFlagDirectiveContext<T>>,
) {
super(_changeDetectorRef, _viewContainerRef, templateRef);
} }
override ngOnChanges() { override ngOnChanges() {

View File

@ -1,13 +1,10 @@
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 { EvaluationContext, OpenFeature, Provider } from '@openfeature/web-sdk'; import { 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');
@ -19,9 +16,7 @@ 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> {
const context = typeof config.context === 'function' ? config.context() : config.context; OpenFeature.setProvider(config.provider);
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),

View File

@ -1,3 +0,0 @@
import { provideZonelessChangeDetection } from '@angular/core';
export default [provideZonelessChangeDetection()];

View File

@ -3,20 +3,15 @@
"compilerOptions": { "compilerOptions": {
"outDir": "./out-tsc/spec", "outDir": "./out-tsc/spec",
"types": [ "types": [
"vitest/globals", "jest",
"node" "node"
], ],
"paths": {
"angular": [
"./dist/angular"
]
},
"esModuleInterop": true, "esModuleInterop": true,
"emitDecoratorMetadata": true "emitDecoratorMetadata": true
}, },
"include": [ "include": [
"src/**/*.spec.ts", "src/**/*.spec.ts",
"src/**/*.d.ts", "src/**/*.d.ts",
"src/test-provider.ts" "setup-jest.ts"
] ]
} }

View File

@ -0,0 +1 @@
import 'jest-preset-angular/setup-jest';

View File

@ -23,8 +23,8 @@
"experimentalDecorators": true, "experimentalDecorators": true,
"moduleResolution": "node", "moduleResolution": "node",
"importHelpers": true, "importHelpers": true,
"target": "ES2022", "target": "ES2015",
"module": "ES2022", "module": "ES2015",
"useDefineForClassFields": false, "useDefineForClassFields": false,
"strictNullChecks": false, "strictNullChecks": false,
"lib": [ "lib": [

View File

@ -3,14 +3,13 @@
"compilerOptions": { "compilerOptions": {
"outDir": "./out-tsc/spec", "outDir": "./out-tsc/spec",
"types": [ "types": [
"vitest/globals", "jest"
"node"
], ],
"esModuleInterop": true, "esModuleInterop": true,
"emitDecoratorMetadata": true "emitDecoratorMetadata": true
}, },
"include": [ "include": [
"projects/angular-sdk/src/**/*.spec.ts", "src/**/*.spec.ts",
"projects/angular-sdk/src/**/*.d.ts" "src/**/*.d.ts"
] ]
} }

View File

@ -1,10 +0,0 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
},
},
});

View File

@ -1,34 +1,5 @@
# 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)

View File

@ -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.5"> <a href="https://github.com/open-feature/js-sdk/releases/tag/nestjs-sdk-v0.2.2">
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.2.5&color=blue&style=for-the-badge" /> <img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.2.2&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 20+ - Node.js version 18+
- 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 || ^11.0.0 * `@nestjs/common`: ^8.0.0 || ^9.0.0 || ^10.0.0
- `@nestjs/core`: ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 * `@nestjs/core`: ^8.0.0 || ^9.0.0 || ^10.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,24 +152,6 @@ 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

View File

@ -1,6 +1,6 @@
{ {
"name": "@openfeature/nestjs-sdk", "name": "@openfeature/nestjs-sdk",
"version": "0.2.5", "version": "0.2.2",
"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 || ^11.0.0", "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0",
"@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.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.17.1" "@openfeature/server-sdk": ">=1.14.0"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/common": "^11.0.20", "@nestjs/common": "^10.3.6",
"@nestjs/core": "^11.0.20", "@nestjs/core": "^10.3.6",
"@nestjs/platform-express": "^11.0.20", "@nestjs/platform-express": "^10.3.6",
"@nestjs/testing": "^11.0.20", "@nestjs/testing": "^10.3.6",
"@openfeature/core": "*", "@openfeature/core": "*",
"@openfeature/server-sdk": "1.18.0", "@openfeature/server-sdk": "*",
"@types/supertest": "^6.0.0", "@types/supertest": "^6.0.0",
"supertest": "^7.0.0" "supertest": "^7.0.0"
} }

View File

@ -1,10 +1,16 @@
import { createParamDecorator, Inject } from '@nestjs/common'; import { createParamDecorator, Inject } from '@nestjs/common';
import type { EvaluationContext, EvaluationDetails, FlagValue, JsonValue } from '@openfeature/server-sdk'; import type {
import { Client } from '@openfeature/server-sdk'; EvaluationContext,
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.
@ -50,6 +56,16 @@ 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.
* *

View File

@ -2,6 +2,5 @@ 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';

View File

@ -1,104 +0,0 @@
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);
};

View File

@ -1,12 +0,0 @@
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);
}

View File

@ -1,5 +1,4 @@
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';
@ -24,17 +23,6 @@ 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 = {

View File

@ -2,12 +2,7 @@ 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 { import { OpenFeatureController, OpenFeatureControllerContextScopedController, OpenFeatureTestService } from './test-app';
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';
@ -19,9 +14,11 @@ describe('OpenFeature SDK', () => {
beforeAll(async () => { beforeAll(async () => {
moduleRef = await Test.createTestingModule({ moduleRef = await Test.createTestingModule({
imports: [getOpenFeatureDefaultTestModule()], imports: [
getOpenFeatureDefaultTestModule()
],
providers: [OpenFeatureTestService], providers: [OpenFeatureTestService],
controllers: [OpenFeatureController, OpenFeatureRequireFlagsEnabledController], controllers: [OpenFeatureController],
}).compile(); }).compile();
app = moduleRef.createNestApplication(); app = moduleRef.createNestApplication();
app = await app.init(); app = await app.init();
@ -115,7 +112,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')
@ -125,77 +122,26 @@ 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, OpenFeatureContextScopedController], controllers: [OpenFeatureController, OpenFeatureControllerContextScopedController],
}).compile(); }).compile();
app = moduleRef.createNestApplication(); app = moduleRef.createNestApplication();
app = await app.init(); app = await app.init();
@ -212,7 +158,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')
@ -226,26 +172,9 @@ 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()) await supertest(app.getHttpServer()).get('/controller-context').set('x-user-id', '123').expect(200).expect('true');
.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);
});
});
}); });
}); });

View File

@ -1,14 +1,7 @@
import { Controller, ForbiddenException, Get, Injectable, UseInterceptors } from '@nestjs/common'; import { Controller, 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 { import { BooleanFeatureFlag, ObjectFeatureFlag, NumberFeatureFlag, OpenFeatureClient, StringFeatureFlag } from '../src';
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';
@ -91,40 +84,11 @@ 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 OpenFeatureContextScopedController { export class OpenFeatureControllerContextScopedController {
constructor(private testService: OpenFeatureTestService) {} constructor(private testService: OpenFeatureTestService) {}
@Get('/controller-context') @Get('/controller-context')
@ -137,27 +101,4 @@ export class OpenFeatureContextScopedController {
) { ) {
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!';
}
} }

View File

@ -1,63 +1,5 @@
# Changelog # Changelog
## [1.0.1](https://github.com/open-feature/js-sdk/compare/react-sdk-v1.0.0...react-sdk-v1.0.1) (2025-08-18)
### 🐛 Bug Fixes
* **react:** re-evaluate flags on re-render to detect silent provider … ([#1226](https://github.com/open-feature/js-sdk/issues/1226)) ([3105595](https://github.com/open-feature/js-sdk/commit/31055959265a53f52102590f54fa3168811ec678))
## [1.0.0](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.4.11...react-sdk-v1.0.0) (2025-04-14)
### ✨ 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)
### 🔄 Refactoring
* export public option types ([#1101](https://github.com/open-feature/js-sdk/issues/1101)) ([16321c3](https://github.com/open-feature/js-sdk/commit/16321c31f27c5fce2c8e2adea893cf6e7e8ce3de))
## [0.4.9](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.4.8...react-sdk-v0.4.9) (2024-12-04)
### ✨ New Features
* re-render if flagsChanged is falsy ([#1095](https://github.com/open-feature/js-sdk/issues/1095)) ([78516f4](https://github.com/open-feature/js-sdk/commit/78516f4181c82baf8c42fd64798fc2cfd8ff1056))
### 📚 Documentation
* fix typos, links, and format ([#1075](https://github.com/open-feature/js-sdk/issues/1075)) ([418409e](https://github.com/open-feature/js-sdk/commit/418409e3faafc6868a9f893267a4733db9931f93))
## [0.4.8](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.4.7...react-sdk-v0.4.8) (2024-10-29)
### 🧹 Chore
* bump minimum web peer ([#1072](https://github.com/open-feature/js-sdk/issues/1072)) ([eca8205](https://github.com/open-feature/js-sdk/commit/eca8205da7945395d19c09a4da67cd4c2d516227))
### 📚 Documentation
* add tracking sections ([#1068](https://github.com/open-feature/js-sdk/issues/1068)) ([e131faf](https://github.com/open-feature/js-sdk/commit/e131faffad9025e9c7194f39558bf3b3cec31807))
## [0.4.7](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.4.6...react-sdk-v0.4.7) (2024-10-29) ## [0.4.7](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.4.6...react-sdk-v0.4.7) (2024-10-29)

View File

@ -16,8 +16,8 @@
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" /> <img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
</a> </a>
<!-- x-release-please-start-version --> <!-- x-release-please-start-version -->
<a href="https://github.com/open-feature/js-sdk/releases/tag/react-sdk-v1.0.1"> <a href="https://github.com/open-feature/js-sdk/releases/tag/react-sdk-v0.4.7">
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v1.0.1&color=blue&style=for-the-badge" /> <img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.4.7&color=blue&style=for-the-badge" />
</a> </a>
<!-- x-release-please-end --> <!-- x-release-please-end -->
<br/> <br/>
@ -149,7 +149,7 @@ function Page() {
} }
``` ```
You can use the strongly typed flag value and flag evaluation detail hooks as well if you prefer. You can use the strongly-typed flag value and flag evaluation detail hooks as well, if you prefer.
```tsx ```tsx
import { useBooleanFlagValue } from '@openfeature/react-sdk'; import { useBooleanFlagValue } from '@openfeature/react-sdk';
@ -175,7 +175,7 @@ const {
Multiple providers can be used by passing a `domain` to the `OpenFeatureProvider`: Multiple providers can be used by passing a `domain` to the `OpenFeatureProvider`:
```tsx ```tsx
// Flags within this domain will use the client/provider associated with `my-domain`, // Flags within this domain will use the a client/provider associated with `my-domain`,
function App() { function App() {
return ( return (
<OpenFeatureProvider domain={'my-domain'}> <OpenFeatureProvider domain={'my-domain'}>
@ -233,19 +233,16 @@ function Page() {
} }
``` ```
If your provider doesn't support updates, this configuration has no impact. Note that if your provider doesn't support updates, this configuration has no impact.
> [!NOTE]
> If your provider includes a list of [flags changed](https://open-feature.github.io/js-sdk/types/_openfeature_server_sdk.ConfigChangeEvent.html) in its `PROVIDER_CONFIGURATION_CHANGED` event, that list of flags is used to decide which flag evaluation hooks should re-run by diffing the latest value of these flags with the previous render.
> If your provider event does not the include the `flags changed` list, then the SDK diffs all flags with the previous render to determine which hooks should re-run.
#### Suspense Support #### Suspense Support
> [!NOTE] > [!NOTE]
> React suspense is an experimental feature and is subject to change in future versions. > React suspense is an experimental feature and subject to change in future versions.
Frequently, providers need to perform some initial startup tasks. Frequently, providers need to perform some initial startup tasks.
It may be desirable not to display components with feature flags until this is complete or when the context changes. It may be desireable not to display components with feature flags until this is complete, or when the context changes.
Built-in [suspense](https://react.dev/reference/react/Suspense) support makes this easy. Built-in [suspense](https://react.dev/reference/react/Suspense) support makes this easy.
Use `useSuspenseFlag` or pass `{ suspend: true }` in the hook options to leverage this functionality. Use `useSuspenseFlag` or pass `{ suspend: true }` in the hook options to leverage this functionality.
@ -286,21 +283,22 @@ This can be disabled in the hook options (or in the [OpenFeatureProvider](#openf
The tracking API allows you to use OpenFeature abstractions and objects to associate user actions with feature flag evaluations. The tracking API allows you to use OpenFeature abstractions and objects to associate user actions with feature flag evaluations.
This is essential for robust experimentation powered by feature flags. This is essential for robust experimentation powered by feature flags.
For example, a flag enhancing the appearance of a UI component might drive user engagement to a new feature; to test this hypothesis, telemetry collected by a [hook](https://openfeature.dev/docs/reference/technologies/client/web/#hooks) or [provider](https://openfeature.dev/docs/reference/technologies/client/web/#providers) can be associated with telemetry reported in the client's `track` function. For example, a flag enhancing the appearance of a UI component might drive user engagement to a new feature; to test this hypothesis, telemetry collected by a [hook](#hooks) or [provider](#providers) can be associated with telemetry reported in the client's `track` function.
The React SDK includes a hook for firing tracking events in the `<OpenFeatureProvider>` context in use: The React SDK includes a hook for firing tracking events in the <OpenFeatureProvider> context in use:
```tsx ```tsx
function MyComponent() { function MyComponent() {
// get a tracking function for this <OpenFeatureProvider>.
const { track } = useTrack();
// call the tracking event // get a tracking function for this <OpenFeatureProvider>.
// can be done in render, useEffect, or in handlers, depending on your use case const { track } = useTrack();
track(eventName, trackingDetails);
return <>...</>; // call the tracking event
} // can be done in render, useEffect, or in handlers, depending on your use case
track(eventName, trackingDetails);
return <>...</>;
}
``` ```
### Testing ### Testing
@ -367,23 +365,23 @@ class MyTestProvider implements Partial<Provider> {
> I get an error that says something like: `A React component suspended while rendering, but no fallback UI was specified.` > I get an error that says something like: `A React component suspended while rendering, but no fallback UI was specified.`
The OpenFeature React SDK features built-in [suspense support](#suspense-support). The OpenFeature React SDK features built-in [suspense support](#suspense-support).
This means that it will render your loading fallback automatically while your provider starts up and during context reconciliation for any of your components using feature flags! This means that it will render your loading fallback automatically while the your provider starts up, and during context reconciliation for any of your components using feature flags!
If you use suspense and neglect to create a suspense boundary around any components using feature flags, you will see this error. If you use suspense and neglect to create a suspense boundary around any components using feature flags, you will see this error.
Add a suspense boundary to resolve this issue. Add a suspense boundary to resolve this issue.
Alternatively, you can disable this suspense (the default) by removing `suspendWhileReconciling=true`, `suspendUntilReady=true` or `suspend=true` in the [evaluation hooks](#evaluation-hooks) or the [OpenFeatureProvider](#openfeatureprovider-context-provider) (which applies to all evaluation hooks in child components). Alternatively, you can disable this suspense (the default) by removing `suspendWhileReconciling=true`, `suspendUntilReady=true` or `suspend=true` in the [evaluation hooks](#evaluation-hooks) or the [OpenFeatureProvider](#openfeatureprovider-context-provider) (which applies to all evaluation hooks in child components).
> I get odd rendering issues or errors when components mount if I use the suspense features. > I get odd rendering issues, or errors when components mount, if I use the suspense features.
In React 16/17's "Legacy Suspense", when a component suspends, its sibling components initially mount and then are hidden. In React 16/17's "Legacy Suspense", when a component suspends, its sibling components initially mount and then are hidden.
This can cause surprising effects and inconsistencies if sibling components are rendered while the provider is still getting ready. This can cause surprising effects and inconsistencies if sibling components are rendered while the provider is still getting ready.
To fix this, you can upgrade to React 18, which uses "Concurrent Suspense", in which siblings are not mounted until their suspended sibling resolves. To fix this, you can upgrade to React 18, which uses "Concurrent Suspense", in which siblings are not mounted until their suspended sibling resolves.
Alternatively, if you cannot upgrade to React 18, you can use the `useWhenProviderReady` utility hook in any sibling components to prevent them from mounting until the provider is ready. Alternatively, if you cannot upgrade to React 18, you can use the `useWhenProviderReady` utility hook in any sibling components to prevent them from mounting until the provider is ready.
> I am using multiple `OpenFeatureProvider` contexts, but they share the same provider or evaluation context. Why? > I am using multiple `OpenFeatureProvider` contexts, but they are sharing the same provider or evaluation context. Why?
The `OpenFeatureProvider` binds a `client` to all child components, but the provider and context associated with that client is controlled by the `domain` parameter. The `OpenFeatureProvider` binds a `client` to all child components, but the provider and context associated with that client is controlled by the `domain` parameter.
This is consistent with all OpenFeature SDKs. This is consistent with all OpenFeature SDKs.
To scope an OpenFeatureProvider to a particular provider/context, set the `domain` parameter on your `OpenFeatureProvider`: To scope an OpenFeatureProvider to a particular provider/context set the `domain` parameter on your `OpenFeatureProvider`:
```tsx ```tsx
<OpenFeatureProvider domain={'my-domain'}> <OpenFeatureProvider domain={'my-domain'}>
@ -393,7 +391,7 @@ To scope an OpenFeatureProvider to a particular provider/context, set the `domai
> I can import things form the `@openfeature/react-sdk`, `@openfeature/web-sdk`, and `@openfeature/core`; which should I use? > I can import things form the `@openfeature/react-sdk`, `@openfeature/web-sdk`, and `@openfeature/core`; which should I use?
The `@openfeature/react-sdk` re-exports everything from its peers (`@openfeature/web-sdk` and `@openfeature/core`) and adds the React-specific features. The `@openfeature/react-sdk` re-exports everything from its peers (`@openfeature/web-sdk` and `@openfeature/core`), and adds the React-specific features.
You can import everything from the `@openfeature/react-sdk` directly. You can import everything from the `@openfeature/react-sdk` directly.
Avoid importing anything from `@openfeature/web-sdk` or `@openfeature/core`. Avoid importing anything from `@openfeature/web-sdk` or `@openfeature/core`.

View File

@ -1,6 +1,6 @@
{ {
"name": "@openfeature/react-sdk", "name": "@openfeature/react-sdk",
"version": "1.0.1", "version": "0.4.7",
"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.5.0", "@openfeature/web-sdk": "^1.2.2",
"react": ">=16.8.0" "react": ">=16.8.0"
}, },
"devDependencies": { "devDependencies": {

View File

@ -1,22 +1,18 @@
import type { Client } from '@openfeature/web-sdk'; import type { Client } from '@openfeature/web-sdk';
import React from 'react'; import React from 'react';
import type { NormalizedOptions, ReactFlagEvaluationOptions } from '../options'; import type { NormalizedOptions, ReactFlagEvaluationOptions} from '../common';
import { normalizeOptions } from '.'; import { normalizeOptions } from '../common';
/** /**
* 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<{ client: Client; domain?: string; options: ReactFlagEvaluationOptions } | undefined>(undefined);
{ client: Client; domain?: string; options: ReactFlagEvaluationOptions } | undefined
>(undefined);
/** /**
* 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.
*/ */

View File

@ -0,0 +1,83 @@
import type { FlagEvaluationOptions } from '@openfeature/web-sdk';
export type ReactFlagEvaluationOptions = ({
/**
* Enable or disable all suspense functionality.
* Cannot be used in conjunction with `suspendUntilReady` and `suspendWhileReconciling` options.
* @experimental Suspense is an experimental feature subject to change in future versions.
*/
suspend?: boolean;
suspendUntilReady?: never;
suspendWhileReconciling?: never;
} | {
/**
* Suspend flag evaluations while the provider is not ready.
* Set to false if you don't want to show suspense fallbacks until the provider is initialized.
* Defaults to false.
* Cannot be used in conjunction with `suspend` option.
* @experimental Suspense is an experimental feature subject to change in future versions.
*/
suspendUntilReady?: boolean;
/**
* Suspend flag evaluations while the provider's context is being reconciled.
* Set to true if you want to show suspense fallbacks while flags are re-evaluated after context changes.
* Defaults to false.
* Cannot be used in conjunction with `suspend` option.
* @experimental Suspense is an experimental feature subject to change in future versions.
*/
suspendWhileReconciling?: boolean;
suspend?: never;
}) & {
/**
* Update the component if the provider emits a ConfigurationChanged event.
* Set to false to prevent components from re-rendering when flag value changes
* are received by the associated provider.
* Defaults to true.
*/
updateOnConfigurationChanged?: boolean;
/**
* Update the component when the OpenFeature context changes.
* Set to false to prevent components from re-rendering when attributes which
* may be factors in flag evaluation change.
* Defaults to true.
*/
updateOnContextChanged?: boolean;
} & FlagEvaluationOptions;
export type NormalizedOptions = Omit<ReactFlagEvaluationOptions, 'suspend'>;
/**
* Default options.
* DO NOT EXPORT PUBLICLY
* @internal
*/
export const DEFAULT_OPTIONS: ReactFlagEvaluationOptions = {
updateOnContextChanged: true,
updateOnConfigurationChanged: true,
suspendUntilReady: false,
suspendWhileReconciling: false,
};
/**
* Returns normalization options (all `undefined` fields removed, and `suspend` decomposed to `suspendUntilReady` and `suspendWhileReconciling`).
* DO NOT EXPORT PUBLICLY
* @internal
* @param {ReactFlagEvaluationOptions} options options to normalize
* @returns {NormalizedOptions} normalized options
*/
export const normalizeOptions: (options?: ReactFlagEvaluationOptions) => NormalizedOptions = (options: ReactFlagEvaluationOptions = {}) => {
const updateOnContextChanged = options.updateOnContextChanged;
const updateOnConfigurationChanged = options.updateOnConfigurationChanged;
// fall-back the suspense options to the catch-all `suspend` property
const suspendUntilReady = 'suspendUntilReady' in options ? options.suspendUntilReady : options.suspend;
const suspendWhileReconciling = 'suspendWhileReconciling' in options ? options.suspendWhileReconciling : options.suspend;
return {
// only return these if properly set (no undefined to allow overriding with spread)
...(typeof suspendUntilReady === 'boolean' && {suspendUntilReady}),
...(typeof suspendWhileReconciling === 'boolean' && {suspendWhileReconciling}),
...(typeof updateOnContextChanged === 'boolean' && {updateOnContextChanged}),
...(typeof updateOnConfigurationChanged === 'boolean' && {updateOnConfigurationChanged}),
};
};

View File

@ -0,0 +1,21 @@
import type { Client} from '@openfeature/web-sdk';
import { ProviderEvents } from '@openfeature/web-sdk';
/**
* Suspends until the client is ready to evaluate feature flags.
* DO NOT EXPORT PUBLICLY
* @param {Client} client OpenFeature client
*/
export function suspendUntilReady(client: Client): Promise<void> {
let resolve: (value: unknown) => void;
let reject: () => void;
throw new Promise((_resolve, _reject) => {
resolve = _resolve;
reject = _reject;
client.addHandler(ProviderEvents.Ready, resolve);
client.addHandler(ProviderEvents.Error, reject);
}).finally(() => {
client.removeHandler(ProviderEvents.Ready, resolve);
client.removeHandler(ProviderEvents.Ready, reject);
});
}

View File

@ -1,7 +1,7 @@
import { useCallback, useContext, useRef } from 'react'; import { useCallback, useContext, useRef } from 'react';
import type { EvaluationContext } from '@openfeature/web-sdk'; import type { EvaluationContext } from '@openfeature/web-sdk';
import { OpenFeature } from '@openfeature/web-sdk'; import { OpenFeature } from '@openfeature/web-sdk';
import { Context } from '../internal'; import { Context } from '../common';
export type ContextMutationOptions = { export type ContextMutationOptions = {
/** /**

View File

@ -5,24 +5,18 @@ import type {
EventHandler, EventHandler,
FlagEvaluationOptions, FlagEvaluationOptions,
FlagValue, FlagValue,
JsonValue, JsonValue} from '@openfeature/web-sdk';
} from '@openfeature/web-sdk';
import { ProviderEvents, ProviderStatus } from '@openfeature/web-sdk';
import { useCallback, useEffect, useRef, useState } from 'react';
import { import {
DEFAULT_OPTIONS, ProviderEvents,
isEqual, ProviderStatus,
normalizeOptions, } from '@openfeature/web-sdk';
suspendUntilInitialized, import { useEffect, useRef, useState } from 'react';
suspendUntilReconciled, import type { ReactFlagEvaluationOptions} from '../common';
useProviderOptions, import { DEFAULT_OPTIONS, isEqual, normalizeOptions, suspendUntilReady, useProviderOptions } from '../common';
} from '../internal';
import type { ReactFlagEvaluationNoSuspenseOptions, ReactFlagEvaluationOptions } from '../options';
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 '../internal/hook-flag-query'; import { HookFlagQuery } from './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).
@ -39,6 +33,9 @@ type ConstrainedFlagQuery<T> = FlagQuery<
: JsonValue : JsonValue
>; >;
// suspense options removed for the useSuspenseFlag hooks
type NoSuspenseOptions = Omit<ReactFlagEvaluationOptions, 'suspend' | 'suspendUntilReady' | 'suspendWhileReconciling'>;
/** /**
* Evaluates a feature flag generically, returning an react-flavored queryable object. * Evaluates a feature flag generically, returning an react-flavored queryable object.
* The resolver method to use is based on the type of the defaultValue. * The resolver method to use is based on the type of the defaultValue.
@ -87,13 +84,13 @@ type UseFlagReturn<T extends FlagValue> = ReturnType<typeof useFlag<T>>;
* @param {string} flagKey the flag identifier * @param {string} flagKey the flag identifier
* @template {FlagValue} T A optional generic argument constraining the default. * @template {FlagValue} T A optional generic argument constraining the default.
* @param {T} defaultValue the default value; used to determine what resolved type should be used. * @param {T} defaultValue the default value; used to determine what resolved type should be used.
* @param {ReactFlagEvaluationNoSuspenseOptions} options for this evaluation * @param {NoSuspenseOptions} options for this evaluation
* @returns { UseFlagReturn<T> } a queryable object containing useful information about the flag. * @returns { UseFlagReturn<T> } a queryable object containing useful information about the flag.
*/ */
export function useSuspenseFlag<T extends FlagValue = FlagValue>( export function useSuspenseFlag<T extends FlagValue = FlagValue>(
flagKey: string, flagKey: string,
defaultValue: T, defaultValue: T,
options?: ReactFlagEvaluationNoSuspenseOptions, options?: NoSuspenseOptions,
): UseFlagReturn<T> { ): UseFlagReturn<T> {
return useFlag(flagKey, defaultValue, { ...options, suspendUntilReady: true, suspendWhileReconciling: true }); return useFlag(flagKey, defaultValue, { ...options, suspendUntilReady: true, suspendWhileReconciling: true });
} }
@ -270,8 +267,7 @@ export function useObjectFlagDetails<T extends JsonValue = JsonValue>(
// determines if a flag should be re-evaluated based on a list of changed flags // determines if a flag should be re-evaluated based on a list of changed flags
function shouldEvaluateFlag(flagKey: string, flagsChanged?: string[]): boolean { function shouldEvaluateFlag(flagKey: string, flagsChanged?: string[]): boolean {
// if flagsChange is missing entirely, we don't know what to re-render return !!flagsChanged && flagsChanged.includes(flagKey);
return !flagsChanged || flagsChanged.includes(flagKey);
} }
function attachHandlersAndResolve<T extends FlagValue>( function attachHandlersAndResolve<T extends FlagValue>(
@ -286,33 +282,19 @@ 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) {
suspendUntilInitialized(provider, client); suspendUntilReady(client);
} }
if (defaultedOptions.suspendWhileReconciling && status === ProviderStatus.RECONCILING) { if (defaultedOptions.suspendWhileReconciling && status === ProviderStatus.RECONCILING) {
suspendUntilReconciled(client); suspendUntilReady(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);
@ -320,7 +302,7 @@ function attachHandlersAndResolve<T extends FlagValue>(
evaluationDetailsRef.current = evaluationDetails; evaluationDetailsRef.current = evaluationDetails;
}, [evaluationDetails]); }, [evaluationDetails]);
const updateEvaluationDetailsCallback = useCallback(() => { const updateEvaluationDetailsCallback = () => {
const updatedEvaluationDetails = resolver(client).call(client, flagKey, defaultValue, options); const updatedEvaluationDetails = resolver(client).call(client, flagKey, defaultValue, options);
/** /**
@ -331,47 +313,41 @@ function attachHandlersAndResolve<T extends FlagValue>(
if (!isEqual(updatedEvaluationDetails.value, evaluationDetailsRef.current.value)) { if (!isEqual(updatedEvaluationDetails.value, evaluationDetailsRef.current.value)) {
setEvaluationDetails(updatedEvaluationDetails); setEvaluationDetails(updatedEvaluationDetails);
} }
}, [client, flagKey, defaultValue, options, resolver]); };
const configurationChangeCallback = useCallback<EventHandler<ClientProviderEvents.ConfigurationChanged>>( const configurationChangeCallback: EventHandler<ClientProviderEvents.ConfigurationChanged> = (eventDetails) => {
(eventDetails) => { if (shouldEvaluateFlag(flagKey, eventDetails?.flagsChanged)) {
if (shouldEvaluateFlag(flagKey, eventDetails?.flagsChanged)) { updateEvaluationDetailsCallback();
updateEvaluationDetailsCallback(); }
} };
},
[flagKey, updateEvaluationDetailsCallback],
);
useEffect(() => { useEffect(() => {
const controller = new AbortController();
if (status === ProviderStatus.NOT_READY) { if (status === ProviderStatus.NOT_READY) {
// update when the provider is ready // update when the provider is ready
client.addHandler(ProviderEvents.Ready, updateEvaluationDetailsCallback, { signal: controller.signal }); client.addHandler(ProviderEvents.Ready, updateEvaluationDetailsCallback);
} }
if (defaultedOptions.updateOnContextChanged) { if (defaultedOptions.updateOnContextChanged) {
// update when the context changes // update when the context changes
client.addHandler(ProviderEvents.ContextChanged, updateEvaluationDetailsCallback, { signal: controller.signal }); client.addHandler(ProviderEvents.ContextChanged, updateEvaluationDetailsCallback);
}
if (defaultedOptions.updateOnConfigurationChanged) {
// update when the provider configuration changes
client.addHandler(ProviderEvents.ConfigurationChanged, configurationChangeCallback, {
signal: controller.signal,
});
} }
return () => { return () => {
// cleanup the handlers // cleanup the handlers
controller.abort(); client.removeHandler(ProviderEvents.Ready, updateEvaluationDetailsCallback);
client.removeHandler(ProviderEvents.ContextChanged, updateEvaluationDetailsCallback);
}; };
}, [ }, []);
client,
status, useEffect(() => {
defaultedOptions.updateOnContextChanged, if (defaultedOptions.updateOnConfigurationChanged) {
defaultedOptions.updateOnConfigurationChanged, // update when the provider configuration changes
updateEvaluationDetailsCallback, client.addHandler(ProviderEvents.ConfigurationChanged, configurationChangeCallback);
configurationChangeCallback, }
]); return () => {
// cleanup the handlers
client.removeHandler(ProviderEvents.ConfigurationChanged, configurationChangeCallback);
};
}, []);
return evaluationDetails; return evaluationDetails;
} }

View File

@ -3,6 +3,5 @@ export * from './query';
export * from './provider'; export * from './provider';
export * from './context'; export * from './context';
export * from './tracking'; export * from './tracking';
export * from './options';
// re-export the web-sdk so consumers can access that API from the react-sdk // re-export the web-sdk so consumers can access that API from the react-sdk
export * from '@openfeature/web-sdk'; export * from '@openfeature/web-sdk';

View File

@ -1,9 +0,0 @@
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';
}
}

View File

@ -1,40 +0,0 @@
import type { ReactFlagEvaluationOptions, NormalizedOptions } from '../options';
/**
* Default options.
* DO NOT EXPORT PUBLICLY
* @internal
*/
export const DEFAULT_OPTIONS: ReactFlagEvaluationOptions = {
updateOnContextChanged: true,
updateOnConfigurationChanged: true,
suspendUntilReady: false,
suspendWhileReconciling: false,
};
/**
* Returns normalization options (all `undefined` fields removed, and `suspend` decomposed to `suspendUntilReady` and `suspendWhileReconciling`).
* DO NOT EXPORT PUBLICLY
* @internal
* @param {ReactFlagEvaluationOptions} options options to normalize
* @returns {NormalizedOptions} normalized options
*/
export const normalizeOptions: (options?: ReactFlagEvaluationOptions) => NormalizedOptions = (
options: ReactFlagEvaluationOptions = {},
) => {
const updateOnContextChanged = options.updateOnContextChanged;
const updateOnConfigurationChanged = options.updateOnConfigurationChanged;
// fall-back the suspense options to the catch-all `suspend` property
const suspendUntilReady = 'suspendUntilReady' in options ? options.suspendUntilReady : options.suspend;
const suspendWhileReconciling =
'suspendWhileReconciling' in options ? options.suspendWhileReconciling : options.suspend;
return {
// only return these if properly set (no undefined to allow overriding with spread)
...(typeof suspendUntilReady === 'boolean' && { suspendUntilReady }),
...(typeof suspendWhileReconciling === 'boolean' && { suspendWhileReconciling }),
...(typeof updateOnContextChanged === 'boolean' && { updateOnContextChanged }),
...(typeof updateOnConfigurationChanged === 'boolean' && { updateOnConfigurationChanged }),
};
};

View File

@ -1,56 +0,0 @@
import type { Client, Provider } 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.
*
* **DO NOT EXPORT PUBLICLY**
* @internal
* @param {Provider} provider the provider to suspend for
* @param {Client} client the client to check for readiness
*/
export function suspendUntilInitialized(provider: Provider, client: Client) {
const statusPromiseRef = globalProviderSuspenseStatus.get(provider);
if (!statusPromiseRef) {
// Noop provider is never ready, so we resolve immediately
const statusPromise = provider !== NOOP_PROVIDER ? isProviderReady(client) : Promise.resolve();
globalProviderSuspenseStatus.set(provider, statusPromise);
// Use will throw the promise and React will trigger a rerender when it's resolved
use(statusPromise);
} else {
// Reuse the existing promise, use won't rethrow if the promise has settled.
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();
}
}

View File

@ -1,53 +0,0 @@
/// <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;
}
});

View File

@ -1,53 +0,0 @@
import type { FlagEvaluationOptions } from '@openfeature/web-sdk';
export type ReactFlagEvaluationOptions = (
| {
/**
* Enable or disable all suspense functionality.
* Cannot be used in conjunction with `suspendUntilReady` and `suspendWhileReconciling` options.
* @experimental Suspense is an experimental feature subject to change in future versions.
*/
suspend?: boolean;
suspendUntilReady?: never;
suspendWhileReconciling?: never;
}
| {
/**
* Suspend flag evaluations while the provider is not ready.
* Set to false if you don't want to show suspense fallbacks until the provider is initialized.
* Defaults to false.
* Cannot be used in conjunction with `suspend` option.
* @experimental Suspense is an experimental feature subject to change in future versions.
*/
suspendUntilReady?: boolean;
/**
* Suspend flag evaluations while the provider's context is being reconciled.
* Set to true if you want to show suspense fallbacks while flags are re-evaluated after context changes.
* Defaults to false.
* Cannot be used in conjunction with `suspend` option.
* @experimental Suspense is an experimental feature subject to change in future versions.
*/
suspendWhileReconciling?: boolean;
suspend?: never;
}
) & {
/**
* Update the component if the provider emits a ConfigurationChanged event.
* Set to false to prevent components from re-rendering when flag value changes
* are received by the associated provider.
* Defaults to true.
*/
updateOnConfigurationChanged?: boolean;
/**
* Update the component when the OpenFeature context changes.
* Set to false to prevent components from re-rendering when attributes which
* may be factors in flag evaluation change.
* Defaults to true.
*/
updateOnContextChanged?: boolean;
} & FlagEvaluationOptions;
// suspense options removed for the useSuspenseFlag hooks
export type ReactFlagEvaluationNoSuspenseOptions = Omit<ReactFlagEvaluationOptions, 'suspend' | 'suspendUntilReady' | 'suspendWhileReconciling'>;
export type NormalizedOptions = Omit<ReactFlagEvaluationOptions, 'suspend'>;

View File

@ -1,5 +1,4 @@
export * from './provider'; export * from './provider';
export * from './use-open-feature-client'; export * from './use-open-feature-client';
export * from './use-when-provider-ready'; export * from './use-when-provider-ready';
export * from './use-open-feature-client-status';
export * from './test-provider'; export * from './test-provider';

View File

@ -1,8 +1,8 @@
import type { Client} from '@openfeature/web-sdk'; import type { Client} from '@openfeature/web-sdk';
import { OpenFeature } from '@openfeature/web-sdk'; import { OpenFeature } from '@openfeature/web-sdk';
import * as React from 'react'; import * as React from 'react';
import type { ReactFlagEvaluationOptions } from '../options'; import type { ReactFlagEvaluationOptions } from '../common';
import { Context } from '../internal'; import { Context } from '../common';
type ClientOrDomain = type ClientOrDomain =
| { | {
@ -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): JSX.Element { export function OpenFeatureProvider({ client, domain, children, ...options }: ProviderProps) {
if (!client) { if (!client) {
client = OpenFeature.getClient(domain); client = OpenFeature.getClient(domain);
} }

View File

@ -7,7 +7,7 @@ import {
OpenFeature OpenFeature
} from '@openfeature/web-sdk'; } from '@openfeature/web-sdk';
import React from 'react'; import React from 'react';
import type { NormalizedOptions } from '../options'; import type { NormalizedOptions } from '../common';
import { OpenFeatureProvider } from './provider'; import { OpenFeatureProvider } from './provider';
type FlagValueMap = { [flagKey: string]: JsonValue }; type FlagValueMap = { [flagKey: string]: JsonValue };

View File

@ -9,19 +9,23 @@ 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(client.providerStatus);
const controller = new AbortController();
useEffect(() => { useEffect(() => {
const updateStatus = () => setStatus(client.providerStatus); const updateStatus = () => setStatus(client.providerStatus);
client.addHandler(ProviderEvents.ConfigurationChanged, updateStatus, { signal: controller.signal }); client.addHandler(ProviderEvents.ConfigurationChanged, updateStatus);
client.addHandler(ProviderEvents.ContextChanged, updateStatus, { signal: controller.signal }); client.addHandler(ProviderEvents.ContextChanged, updateStatus);
client.addHandler(ProviderEvents.Error, updateStatus, { signal: controller.signal }); client.addHandler(ProviderEvents.Error, updateStatus);
client.addHandler(ProviderEvents.Ready, updateStatus, { signal: controller.signal }); client.addHandler(ProviderEvents.Ready, updateStatus);
client.addHandler(ProviderEvents.Stale, updateStatus, { signal: controller.signal }); client.addHandler(ProviderEvents.Stale, updateStatus);
client.addHandler(ProviderEvents.Reconciling, updateStatus, { signal: controller.signal }); client.addHandler(ProviderEvents.Reconciling, updateStatus);
return () => { return () => {
controller.abort(); client.removeHandler(ProviderEvents.ConfigurationChanged, updateStatus);
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]);

View File

@ -1,7 +1,6 @@
import React from 'react'; import React from 'react';
import { Context } from '../internal'; import { Context } from '../common';
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.
@ -12,7 +11,9 @@ export function useOpenFeatureClient(): Client {
const { client } = React.useContext(Context) || {}; const { client } = React.useContext(Context) || {};
if (!client) { if (!client) {
throw new MissingContextError('No OpenFeature client available'); throw new Error(
'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;

View File

@ -1,21 +0,0 @@
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);
}

View File

@ -1,9 +1,8 @@
import { ProviderStatus } from '@openfeature/web-sdk'; 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 '../common';
import { DEFAULT_OPTIONS, useProviderOptions, normalizeOptions, suspendUntilInitialized } from '../internal'; import { DEFAULT_OPTIONS, useProviderOptions, normalizeOptions, suspendUntilReady } from '../common';
import { useOpenFeatureProvider } from './use-open-feature-provider';
type Options = Pick<ReactFlagEvaluationOptions, 'suspendUntilReady'>; type Options = Pick<ReactFlagEvaluationOptions, 'suspendUntilReady'>;
@ -15,14 +14,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 {
// highest priority > evaluation hook options > provider options > default options > lowest priority
const defaultedOptions = { ...DEFAULT_OPTIONS, ...useProviderOptions(), ...normalizeOptions(options) };
const client = useOpenFeatureClient(); const client = useOpenFeatureClient();
const status = useOpenFeatureClientStatus(); const status = useOpenFeatureClientStatus();
const provider = useOpenFeatureProvider(); // highest priority > evaluation hook options > provider options > default options > lowest priority
const defaultedOptions = { ...DEFAULT_OPTIONS, ...useProviderOptions(), ...normalizeOptions(options) };
// suspense
if (defaultedOptions.suspendUntilReady && status === ProviderStatus.NOT_READY) { if (defaultedOptions.suspendUntilReady && status === ProviderStatus.NOT_READY) {
suspendUntilInitialized(provider, client); suspendUntilReady(client);
} }
return status === ProviderStatus.READY; return status === ProviderStatus.READY;

View File

@ -1,12 +1,10 @@
import { jest } from '@jest/globals';
import type { ProviderEmittableEvents } from '@openfeature/web-sdk';
import { ClientProviderEvents } from '@openfeature/web-sdk';
import type { FlagConfiguration } from '@openfeature/web-sdk/src/provider/in-memory-provider/flag-configuration';
import '@testing-library/jest-dom'; // see: https://testing-library.com/docs/react-testing-library/setup import '@testing-library/jest-dom'; // see: https://testing-library.com/docs/react-testing-library/setup
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 type {
import type { EvaluationContext, EvaluationDetails, EventContext, Hook } from '../src/'; EvaluationContext,
EvaluationDetails,
Hook} from '../src/';
import { import {
ErrorCode, ErrorCode,
InMemoryProvider, InMemoryProvider,
@ -24,21 +22,10 @@ import {
useStringFlagValue, useStringFlagValue,
useSuspenseFlag, useSuspenseFlag,
} from '../src/'; } from '../src/';
import { HookFlagQuery } from '../src/internal/hook-flag-query';
import { TestingProvider } from './test.utils'; import { TestingProvider } from './test.utils';
import { HookFlagQuery } from '../src/evaluation/hook-flag-query';
// custom provider to have better control over the emitted events import { startTransition, useState } from 'react';
class CustomEventInMemoryProvider extends InMemoryProvider { import { jest } from '@jest/globals';
putConfigurationWithCustomEvent(
flagConfiguration: FlagConfiguration,
event: ProviderEmittableEvents,
eventContext: EventContext,
) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this['_flagConfiguration'] = { ...flagConfiguration }; // private access hack
this.events.emit(event, eventContext);
}
}
describe('evaluation', () => { describe('evaluation', () => {
const EVALUATION = 'evaluation'; const EVALUATION = 'evaluation';
@ -275,7 +262,7 @@ describe('evaluation', () => {
describe('re-render', () => { describe('re-render', () => {
const RERENDER_DOMAIN = 'rerender'; const RERENDER_DOMAIN = 'rerender';
const rerenderProvider = new CustomEventInMemoryProvider(FLAG_CONFIG); const rerenderProvider = new InMemoryProvider(FLAG_CONFIG);
function TestComponentFactory() { function TestComponentFactory() {
let renderCount = 0; let renderCount = 0;
@ -383,7 +370,7 @@ describe('evaluation', () => {
expect(screen.queryByTestId('render-count')).toHaveTextContent('2'); expect(screen.queryByTestId('render-count')).toHaveTextContent('2');
}); });
it('should not render on flag change when the provider change event has empty flagsChanged', async () => { it('should not render on flag change because the provider did not include changed flags in the change event', async () => {
const TestComponent = TestComponentFactory(); const TestComponent = TestComponentFactory();
render( render(
<OpenFeatureProvider domain={RERENDER_DOMAIN}> <OpenFeatureProvider domain={RERENDER_DOMAIN}>
@ -393,52 +380,14 @@ 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.putConfiguration({
{ ...FLAG_CONFIG,
...FLAG_CONFIG, });
[BOOL_FLAG_KEY]: {
...FLAG_CONFIG[BOOL_FLAG_KEY],
// 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
},
ClientProviderEvents.ConfigurationChanged,
{ flagsChanged: [] },
);
}); });
expect(screen.queryByTestId('render-count')).toHaveTextContent('1'); expect(screen.queryByTestId('render-count')).toHaveTextContent('1');
}); });
it('should re-render on flag change because the provider change event has falsy flagsChanged', async () => {
const TestComponent = TestComponentFactory();
render(
<OpenFeatureProvider domain={RERENDER_DOMAIN}>
<TestComponent></TestComponent>
</OpenFeatureProvider>,
);
expect(screen.queryByTestId('render-count')).toHaveTextContent('1');
await act(async () => {
await rerenderProvider.putConfigurationWithCustomEvent(
{
...FLAG_CONFIG,
[BOOL_FLAG_KEY]: {
...FLAG_CONFIG[BOOL_FLAG_KEY],
// 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
},
ClientProviderEvents.ConfigurationChanged,
{ flagsChanged: undefined },
);
});
expect(screen.queryByTestId('render-count')).toHaveTextContent('2');
});
it('should not rerender on flag change because the evaluated values did not change', async () => { it('should not rerender on flag change because the evaluated values did not change', async () => {
const TestComponent = TestComponentFactory(); const TestComponent = TestComponentFactory();
render( render(
@ -577,41 +526,10 @@ 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);
@ -924,124 +842,17 @@ describe('evaluation', () => {
OpenFeature.setContext(SUSPEND_OFF, { user: TARGETED_USER }); OpenFeature.setContext(SUSPEND_OFF, { user: TARGETED_USER });
}); });
// With the fix for useState initialization, the hook now immediately // expect to see static value until we reconcile
// reflects provider state changes. This is intentional to handle cases await waitFor(() => expect(screen.queryByText(STATIC_FLAG_VALUE_A)).toBeInTheDocument(), {
// where providers don't emit proper events. timeout: DELAY / 2,
// The value updates immediately to the targeted value. });
// make sure we updated after reconciling
await waitFor(() => expect(screen.queryByText(TARGETED_FLAG_VALUE)).toBeInTheDocument(), { await waitFor(() => expect(screen.queryByText(TARGETED_FLAG_VALUE)).toBeInTheDocument(), {
timeout: DELAY * 2, timeout: DELAY * 2,
}); });
}); });
}); });
describe('re-render behavior when flag values change without provider events', ()=> {
it('should reflect provider state changes on re-render even without provider events', async () => {
let providerValue = 'initial-value';
class SilentUpdateProvider extends InMemoryProvider {
resolveBooleanEvaluation() {
return {
value: true,
variant: 'on',
reason: StandardResolutionReasons.STATIC,
};
}
resolveStringEvaluation() {
return {
value: providerValue,
variant: providerValue,
reason: StandardResolutionReasons.STATIC,
};
}
}
const provider = new SilentUpdateProvider({});
await OpenFeature.setProviderAndWait('test', provider);
// The triggerRender prop forces a re-render
const TestComponent = ({ triggerRender }: { triggerRender: number }) => {
const { value } = useFlag('test-flag', 'default');
return <div data-testid="flag-value" data-render-count={triggerRender}>{value}</div>;
};
const WrapperComponent = () => {
const [renderCount, setRenderCount] = useState(0);
return (
<>
<button onClick={() => setRenderCount(c => c + 1)}>Force Re-render</button>
<TestComponent triggerRender={renderCount} />
</>
);
};
const { getByText } = render(
<OpenFeatureProvider client={OpenFeature.getClient('test')}>
<WrapperComponent />
</OpenFeatureProvider>
);
// Initial value should be rendered
await waitFor(() => {
expect(screen.getByTestId('flag-value')).toHaveTextContent('initial-value');
});
// Change the provider's internal state (without emitting events)
providerValue = 'updated-value';
// Force a re-render of the component
act(() => {
getByText('Force Re-render').click();
});
await waitFor(() => {
expect(screen.getByTestId('flag-value')).toHaveTextContent('updated-value');
});
});
it('should update flag value when flag key prop changes without provider events', async () => {
const provider = new InMemoryProvider({
'flag-a': {
disabled: false,
variants: { on: 'value-a' },
defaultVariant: 'on',
},
'flag-b': {
disabled: false,
variants: { on: 'value-b' },
defaultVariant: 'on',
},
});
await OpenFeature.setProviderAndWait(EVALUATION, provider);
const TestComponent = ({ flagKey }: { flagKey: string }) => {
const { value } = useFlag(flagKey, 'default');
return <div data-testid="flag-value">{value}</div>;
};
const { rerender } = render(
<OpenFeatureProvider client={OpenFeature.getClient(EVALUATION)}>
<TestComponent flagKey="flag-a" />
</OpenFeatureProvider>
);
await waitFor(() => {
expect(screen.getByTestId('flag-value')).toHaveTextContent('value-a');
});
// Change to flag-b (without any provider events)
rerender(
<OpenFeatureProvider client={OpenFeature.getClient(EVALUATION)}>
<TestComponent flagKey="flag-b" />
</OpenFeatureProvider>
);
await waitFor(() => {
expect(screen.getByTestId('flag-value')).toHaveTextContent('value-b');
});
});
});
}); });
describe('context, hooks and options', () => { describe('context, hooks and options', () => {
@ -1294,4 +1105,3 @@ describe('evaluation', () => {
}); });
}); });
}); });

View File

@ -1,4 +1,4 @@
import { isEqual } from '../src/internal/is-equal'; import { isEqual } from '../src/common/is-equal';
describe('isEqual', () => { describe('isEqual', () => {
it('should return true for equal primitive values', () => { it('should return true for equal primitive values', () => {

View File

@ -1,4 +1,4 @@
import { normalizeOptions } from '../src/internal/options'; import { normalizeOptions } from '../src/common/options';
describe('normalizeOptions', () => { describe('normalizeOptions', () => {
// we spread results from this function, so we never want to return null // we spread results from this function, so we never want to return null

View File

@ -288,7 +288,7 @@ describe('OpenFeatureProvider', () => {
{ timeout: DELAY * 4 }, { timeout: DELAY * 4 },
); );
expect(screen.getByText('Will says aloha')).toBeInTheDocument(); expect(screen.getByText('Will says hi')).toBeInTheDocument();
}); });
}); });
}); });

View File

@ -1,86 +1,5 @@
# Changelog # Changelog
## [1.19.0](https://github.com/open-feature/js-sdk/compare/server-sdk-v1.18.0...server-sdk-v1.19.0) (2025-08-14)
### ✨ New Features
* add evaluation-scoped hook data ([#1216](https://github.com/open-feature/js-sdk/issues/1216)) ([07af3a9](https://github.com/open-feature/js-sdk/commit/07af3a9eda895e9edb24c7ee1e3c1c4f16e17431))
### 🐛 Bug Fixes
* update core dep ([#1228](https://github.com/open-feature/js-sdk/issues/1228)) ([845d24c](https://github.com/open-feature/js-sdk/commit/845d24c5fecc80de3080e49fde839f08ecac6b33))
### 🧹 Chore
* update node to v20+ ([#1203](https://github.com/open-feature/js-sdk/issues/1203)) ([1f33453](https://github.com/open-feature/js-sdk/commit/1f33453c23df0763cbf0d0b44db8d91216377009))
### 📚 Documentation
* Clarify the behavior of setProviderAndWait ([#1180](https://github.com/open-feature/js-sdk/issues/1180)) ([4fe8d87](https://github.com/open-feature/js-sdk/commit/4fe8d87a2e5df2cbd4086cc4f4a380e8857ed8ba))
## [1.18.0](https://github.com/open-feature/js-sdk/compare/server-sdk-v1.17.1...server-sdk-v1.18.0) (2025-04-11)
### ✨ 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)
### 🐛 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))
### 📚 Documentation
* fix eval context link ([#1132](https://github.com/open-feature/js-sdk/issues/1132)) ([f6bc695](https://github.com/open-feature/js-sdk/commit/f6bc6951a32fbbed9b35c1bdfd023b02874b87a1))
## [1.17.0](https://github.com/open-feature/js-sdk/compare/server-sdk-v1.16.2...server-sdk-v1.17.0) (2024-12-18)
### ⚠ BREAKING CHANGES
The signature of the `finally` hook stage has been changed. The signature now includes the `evaluation details`, as per the [OpenFeature specification](https://openfeature.dev/specification/sections/hooks#requirement-438). Note that since hooks are still `experimental,` this does not constitute a change requiring a new major version. To migrate, update any hook that implements the `finally` stage to accept `evaluation details` as the second argument.
* add evaluation details to finally hook ([#1087](https://github.com/open-feature/js-sdk/issues/1087)) ([2135254](https://github.com/open-feature/js-sdk/commit/2135254c4bee52b4bcadfbf8b99a896cfd930cca))
### ✨ New Features
* add evaluation details to finally hook ([#1087](https://github.com/open-feature/js-sdk/issues/1087)) ([2135254](https://github.com/open-feature/js-sdk/commit/2135254c4bee52b4bcadfbf8b99a896cfd930cca))
### 📚 Documentation
* fix comment in README for Hooks after method ([#1103](https://github.com/open-feature/js-sdk/issues/1103)) ([e335615](https://github.com/open-feature/js-sdk/commit/e3356157d5910d9196e8968c20d4c9a46c4de910))
### 🔄 Refactoring
* improve track interface for providers ([#1100](https://github.com/open-feature/js-sdk/issues/1100)) ([5e5b160](https://github.com/open-feature/js-sdk/commit/5e5b16022122b71760634ac90e3fd962aa831c74))
## [1.16.2](https://github.com/open-feature/js-sdk/compare/server-sdk-v1.16.1...server-sdk-v1.16.2) (2024-11-07)
### 🧹 Chore
* loosen peer dependency requirements, remove some ci automation ([#1080](https://github.com/open-feature/js-sdk/issues/1080)) ([ef3ba21](https://github.com/open-feature/js-sdk/commit/ef3ba2167ac95cd0c6a046d206bd60bbcf84e80c))
### 🚀 Performance
* avoid using exceptions for flow control ([#1074](https://github.com/open-feature/js-sdk/issues/1074)) ([26264d6](https://github.com/open-feature/js-sdk/commit/26264d6d090b2ed31b27d36e71194b9fa911563b))
## [1.16.1](https://github.com/open-feature/js-sdk/compare/server-sdk-v1.16.0...server-sdk-v1.16.1) (2024-10-29) ## [1.16.1](https://github.com/open-feature/js-sdk/compare/server-sdk-v1.16.0...server-sdk-v1.16.1) (2024-10-29)

View File

@ -16,8 +16,8 @@
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" /> <img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
</a> </a>
<!-- x-release-please-start-version --> <!-- x-release-please-start-version -->
<a href="https://github.com/open-feature/js-sdk/releases/tag/server-sdk-v1.19.0"> <a href="https://github.com/open-feature/js-sdk/releases/tag/server-sdk-v1.16.1">
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v1.19.0&color=blue&style=for-the-badge" /> <img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v1.16.1&color=blue&style=for-the-badge" />
</a> </a>
<!-- x-release-please-end --> <!-- x-release-please-end -->
<br/> <br/>
@ -75,11 +75,7 @@ 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
try { await OpenFeature.setProviderAndWait(new YourProviderOfChoice());
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();
@ -101,12 +97,12 @@ See [here](https://open-feature.github.io/js-sdk/modules/_openfeature_server_sdk
| Status | Features | Description | | Status | Features | Description |
| ------ | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | ------ | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| ✅ | [Providers](#providers) | Integrate with a commercial, open source, or in-house feature management tool. | | ✅ | [Providers](#providers) | Integrate with a commercial, open source, or in-house feature management tool. |
| ✅ | [Targeting](#targeting) | Contextually-aware flag evaluation using [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context). | | ✅ | [Targeting](#targeting) | Contextually-aware flag evaluation using [evaluation context](/docs/reference/concepts/evaluation-context). |
| ✅ | [Hooks](#hooks) | Add functionality to various stages of the flag evaluation life-cycle. | | ✅ | [Hooks](#hooks) | Add functionality to various stages of the flag evaluation life-cycle. |
| ✅ | [Logging](#logging) | Integrate with popular logging packages. | | ✅ | [Logging](#logging) | Integrate with popular logging packages. |
| ✅ | [Domains](#domains) | Logically bind clients with providers. | | ✅ | [Domains](#domains) | Logically bind clients with providers. |
| ✅ | [Eventing](#eventing) | React to state changes in the provider or flag management system. | | ✅ | [Eventing](#eventing) | React to state changes in the provider or flag management system. |
| ✅ | [Transaction Context Propagation](#transaction-context-propagation) | Set a specific [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context) for a transaction (e.g. an HTTP request or a thread) | | ✅ | [Transaction Context Propagation](#transaction-context-propagation) | Set a specific [evaluation context](/docs/reference/concepts/evaluation-context) for a transaction (e.g. an HTTP request or a thread) |
| ✅ | [Tracking](#tracking) | Associate user actions with feature flag evaluations, particularly for A/B testing. | | ✅ | [Tracking](#tracking) | Associate user actions with feature flag evaluations, particularly for A/B testing. |
| ✅ | [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. | | ✅ | [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. |
| ✅ | [Extending](#extending) | Extend OpenFeature with custom providers and hooks. | | ✅ | [Extending](#extending) | Extend OpenFeature with custom providers and hooks. |
@ -387,7 +383,7 @@ import type { Hook, HookContext, EvaluationDetails, FlagValue } from "@openfeatu
export class MyHook implements Hook { export class MyHook implements Hook {
after(hookContext: HookContext, evaluationDetails: EvaluationDetails<FlagValue>) { after(hookContext: HookContext, evaluationDetails: EvaluationDetails<FlagValue>) {
// code that runs after flag values are successfully resolved from the provider // code that runs when there's an error during a flag evaluation
} }
} }
``` ```

View File

@ -1,6 +1,6 @@
{ {
"name": "@openfeature/server-sdk", "name": "@openfeature/server-sdk",
"version": "1.19.0", "version": "1.16.1",
"description": "OpenFeature SDK for JavaScript", "description": "OpenFeature SDK for JavaScript",
"main": "./dist/cjs/index.js", "main": "./dist/cjs/index.js",
"files": [ "files": [
@ -25,7 +25,8 @@
"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",
"publish-if-not-exists": "cp $NPM_CONFIG_USERCONFIG .npmrc && if [ \"$(npm show $npm_package_name@$npm_package_version version)\" = \"$(npm run current-version -s)\" ]; then echo 'already published, skipping'; else npm publish --access public; fi" "publish-if-not-exists": "cp $NPM_CONFIG_USERCONFIG .npmrc && if [ \"$(npm show $npm_package_name@$npm_package_version version)\" = \"$(npm run current-version -s)\" ]; then echo 'already published, skipping'; else npm publish --access public; fi",
"update-core-peer": "npm install --save-peer --save-exact @openfeature/core@$OPENFEATURE_CORE_VERSION"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@ -45,12 +46,12 @@
}, },
"homepage": "https://github.com/open-feature/js-sdk#readme", "homepage": "https://github.com/open-feature/js-sdk#readme",
"engines": { "engines": {
"node": ">=20" "node": ">=18"
}, },
"peerDependencies": { "peerDependencies": {
"@openfeature/core": "^1.9.0" "@openfeature/core": "1.5.0"
}, },
"devDependencies": { "devDependencies": {
"@openfeature/core": "^1.9.0" "@openfeature/core": "1.5.0"
} }
} }

View File

@ -10,10 +10,7 @@ import type {
Logger, Logger,
TrackingEventDetails, TrackingEventDetails,
OpenFeatureError, OpenFeatureError,
FlagMetadata, ResolutionDetails} from '@openfeature/core';
ResolutionDetails,
EventOptions,
} from '@openfeature/core';
import { import {
ErrorCode, ErrorCode,
ProviderFatalError, ProviderFatalError,
@ -22,13 +19,12 @@ 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';
import type { InternalEventEmitter } from '../../events/internal/internal-event-emitter'; import type { InternalEventEmitter } from '../../events/internal/internal-event-emitter';
import type { Hook } from '../../hooks'; import type { Hook } from '../../hooks';
import type { Provider } from '../../provider'; import type { Provider} from '../../provider';
import { ProviderStatus } from '../../provider'; import { ProviderStatus } from '../../provider';
import type { Client } from './../client'; import type { Client } from './../client';
@ -81,7 +77,7 @@ export class OpenFeatureClient implements Client {
return this.providerStatusAccessor(); return this.providerStatusAccessor();
} }
addHandler(eventType: ProviderEvents, handler: EventHandler, options?: EventOptions): void { addHandler(eventType: ProviderEvents, handler: EventHandler): void {
this.emitterAccessor().addHandler(eventType, handler); this.emitterAccessor().addHandler(eventType, handler);
const shouldRunNow = statusMatchesEvent(eventType, this._providerStatus); const shouldRunNow = statusMatchesEvent(eventType, this._providerStatus);
@ -97,12 +93,6 @@ 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) {
@ -236,7 +226,7 @@ export class OpenFeatureClient implements Client {
return this.evaluate<T>(flagKey, this._provider.resolveObjectEvaluation, defaultValue, 'object', context, options); return this.evaluate<T>(flagKey, this._provider.resolveObjectEvaluation, defaultValue, 'object', context, options);
} }
track(occurrenceKey: string, context: EvaluationContext = {}, occurrenceDetails: TrackingEventDetails = {}): void { track(occurrenceKey: string, context: EvaluationContext, occurrenceDetails: TrackingEventDetails): void {
try { try {
this.shortCircuitIfNotReady(); this.shortCircuitIfNotReady();
@ -277,105 +267,90 @@ export class OpenFeatureClient implements Client {
const mergedContext = this.mergeContexts(invocationContext); const mergedContext = this.mergeContexts(invocationContext);
// Create hook context instances for each hook (stable object references for the entire evaluation) // this reference cannot change during the course of evaluation
// This ensures hooks can use WeakMaps with hookContext as keys across lifecycle methods // it may be used as a key in WeakMaps
// NOTE: Uses the reversed order to reduce the number of times we have to calculate the index. const hookContext: Readonly<HookContext> = {
const hookContexts = allHooksReversed.map<HookContext>(() => flagKey,
Object.freeze({ defaultValue,
flagKey, flagValueType: flagType,
defaultValue, clientMetadata: this.metadata,
flagValueType: flagType, providerMetadata: this._provider.metadata,
clientMetadata: this.metadata, context: mergedContext,
providerMetadata: this._provider.metadata, logger: this._logger,
context: mergedContext, };
logger: this._logger,
hookData: new MapHookData(),
}),
);
let evaluationDetails: EvaluationDetails<T>;
try { try {
const frozenContext = await this.beforeHooks(allHooks, hookContexts, mergedContext, options); const frozenContext = await this.beforeHooks(allHooks, hookContext, options);
this.shortCircuitIfNotReady(); this.shortCircuitIfNotReady();
// run the referenced resolver, binding the provider. // run the referenced resolver, binding the provider.
const resolution = await resolver.call(this._provider, flagKey, defaultValue, frozenContext, this._logger); const resolution = await resolver.call(this._provider, flagKey, defaultValue, frozenContext, this._logger);
const resolutionDetails = { const evaluationDetails = {
...resolution, ...resolution,
flagMetadata: Object.freeze(resolution.flagMetadata ?? {}), flagMetadata: Object.freeze(resolution.flagMetadata ?? {}),
flagKey, flagKey,
}; };
if (resolutionDetails.errorCode) { if (evaluationDetails.errorCode) {
const err = instantiateErrorByErrorCode(resolutionDetails.errorCode, resolutionDetails.errorMessage); throw instantiateErrorByErrorCode(evaluationDetails.errorCode);
await this.errorHooks(allHooksReversed, hookContexts, err, options);
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err, resolutionDetails.flagMetadata);
} else {
await this.afterHooks(allHooksReversed, hookContexts, resolutionDetails, options);
evaluationDetails = resolutionDetails;
} }
} catch (err: unknown) {
await this.errorHooks(allHooksReversed, hookContexts, err, options);
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err);
}
await this.finallyHooks(allHooksReversed, hookContexts, evaluationDetails, options); await this.afterHooks(allHooksReversed, hookContext, evaluationDetails, options);
return evaluationDetails;
return evaluationDetails;
} catch (err: unknown) {
const errorMessage: string = (err as Error)?.message;
const errorCode: ErrorCode = (err as OpenFeatureError)?.code || ErrorCode.GENERAL;
await this.errorHooks(allHooksReversed, hookContext, err, options);
return {
errorCode,
errorMessage,
value: defaultValue,
reason: StandardResolutionReasons.ERROR,
flagMetadata: Object.freeze({}),
flagKey,
};
} finally {
await this.finallyHooks(allHooksReversed, hookContext, options);
}
} }
private async beforeHooks( private async beforeHooks(hooks: Hook[], hookContext: HookContext, options: FlagEvaluationOptions) {
hooks: Hook[], for (const hook of hooks) {
hookContexts: HookContext[], // freeze the hookContext
mergedContext: EvaluationContext, Object.freeze(hookContext);
options: FlagEvaluationOptions,
) {
let accumulatedContext = mergedContext;
for (const [index, hook] of hooks.entries()) { // use Object.assign to avoid modification of frozen hookContext
const hookContextIndex = hooks.length - 1 - index; // reverse index for before hooks Object.assign(hookContext.context, {
const hookContext = hookContexts[hookContextIndex]; ...hookContext.context,
...(await hook?.before?.(hookContext, Object.freeze(options.hookHints))),
// Update the context on the stable hook context object });
Object.assign(hookContext.context, accumulatedContext);
const hookResult = await hook?.before?.(hookContext, Object.freeze(options.hookHints));
if (hookResult) {
accumulatedContext = {
...accumulatedContext,
...hookResult,
};
for (let i = 0; i < hooks.length; i++) {
Object.assign(hookContexts[hookContextIndex].context, accumulatedContext);
}
}
} }
// after before hooks, freeze the EvaluationContext. // after before hooks, freeze the EvaluationContext.
return Object.freeze(accumulatedContext); return Object.freeze(hookContext.context);
} }
private async afterHooks( private async afterHooks(
hooks: Hook[], hooks: Hook[],
hookContexts: HookContext[], hookContext: HookContext,
evaluationDetails: EvaluationDetails<FlagValue>, evaluationDetails: EvaluationDetails<FlagValue>,
options: FlagEvaluationOptions, options: FlagEvaluationOptions,
) { ) {
// run "after" hooks sequentially // run "after" hooks sequentially
for (const [index, hook] of hooks.entries()) { for (const hook of hooks) {
const hookContext = hookContexts[index];
await hook?.after?.(hookContext, evaluationDetails, options.hookHints); await hook?.after?.(hookContext, evaluationDetails, options.hookHints);
} }
} }
private async errorHooks(hooks: Hook[], hookContexts: HookContext[], err: unknown, options: FlagEvaluationOptions) { private async errorHooks(hooks: Hook[], hookContext: HookContext, err: unknown, options: FlagEvaluationOptions) {
// run "error" hooks sequentially // run "error" hooks sequentially
for (const [index, hook] of hooks.entries()) { for (const hook of hooks) {
try { try {
const hookContext = hookContexts[index];
await hook?.error?.(hookContext, err, options.hookHints); await hook?.error?.(hookContext, err, options.hookHints);
} catch (err) { } catch (err) {
this._logger.error(`Unhandled error during 'error' hook: ${err}`); this._logger.error(`Unhandled error during 'error' hook: ${err}`);
@ -387,17 +362,11 @@ export class OpenFeatureClient implements Client {
} }
} }
private async finallyHooks( private async finallyHooks(hooks: Hook[], hookContext: HookContext, options: FlagEvaluationOptions) {
hooks: Hook[],
hookContexts: HookContext[],
evaluationDetails: EvaluationDetails<FlagValue>,
options: FlagEvaluationOptions,
) {
// run "finally" hooks sequentially // run "finally" hooks sequentially
for (const [index, hook] of hooks.entries()) { for (const hook of hooks) {
try { try {
const hookContext = hookContexts[index]; await hook?.finally?.(hookContext, 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}`);
if (err instanceof Error) { if (err instanceof Error) {
@ -438,23 +407,4 @@ export class OpenFeatureClient implements Client {
throw new ProviderFatalError('provider is in an irrecoverable error state'); throw new ProviderFatalError('provider is in an irrecoverable error state');
} }
} }
private getErrorEvaluationDetails<T extends FlagValue>(
flagKey: string,
defaultValue: T,
err: unknown,
flagMetadata: FlagMetadata = {},
): EvaluationDetails<T> {
const errorMessage: string = (err as Error)?.message;
const errorCode: ErrorCode = (err as OpenFeatureError)?.code || ErrorCode.GENERAL;
return {
errorCode,
errorMessage,
value: defaultValue,
reason: StandardResolutionReasons.ERROR,
flagMetadata: Object.freeze(flagMetadata),
flagKey,
};
}
} }

View File

@ -1,8 +1,7 @@
import type { BaseHook, EvaluationContext, FlagValue } from '@openfeature/core'; import type { BaseHook, EvaluationContext, FlagValue } from '@openfeature/core';
export type Hook<TData = Record<string, unknown>> = BaseHook< export type Hook = BaseHook<
FlagValue, FlagValue,
TData,
Promise<EvaluationContext | void> | EvaluationContext | void, Promise<EvaluationContext | void> | EvaluationContext | void,
Promise<void> | void Promise<void> | void
>; >;

View File

@ -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 {Error} If the provider throws an exception during initialization. * @throws Uncaught exceptions thrown by the provider 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 {Error} If the provider throws an exception during initialization. * @throws Uncaught exceptions thrown by the provider 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,27 +138,6 @@ 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;

View File

@ -859,18 +859,6 @@ describe('OpenFeatureClient', () => {
}).not.toThrow(); }).not.toThrow();
}); });
it('provide empty tracking details to provider if not supplied in call', async () => {
await OpenFeature.setProviderAndWait({ ...MOCK_PROVIDER });
const client = OpenFeature.getClient();
client.track(eventName);
expect(MOCK_PROVIDER.track).toHaveBeenCalledWith(
eventName,
expect.any(Object),
expect.any(Object),
);
});
it('should call provider with correct context', async () => { it('should call provider with correct context', async () => {
await OpenFeature.setProviderAndWait({ ...MOCK_PROVIDER }); await OpenFeature.setProviderAndWait({ ...MOCK_PROVIDER });
OpenFeature.setContext({ [globalContextKey]: globalContextValue }); OpenFeature.setContext({ [globalContextKey]: globalContextValue });

View File

@ -449,21 +449,7 @@ describe('Events', () => {
expect(OpenFeature.getHandlers(eventType)).toHaveLength(0); expect(OpenFeature.getHandlers(eventType)).toHaveLength(0);
}); });
it('The event handler can be removed using an abort signal', () => { it('The API provides a function allowing the removal of event handlers', () => {
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;
@ -473,21 +459,6 @@ 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', () => {

View File

@ -1,508 +0,0 @@
import { OpenFeature } from '../src';
import type { Client } from '../src/client';
import type {
JsonValue,
ResolutionDetails,
HookContext,
BeforeHookContext,
HookData} from '@openfeature/core';
import {
StandardResolutionReasons
} from '@openfeature/core';
import type { Provider } from '../src/provider';
import type { Hook } from '../src/hooks';
const BOOLEAN_VALUE = true;
const STRING_VALUE = 'val';
const NUMBER_VALUE = 1;
const OBJECT_VALUE = { key: 'value' };
// A test hook that stores data in the before stage and retrieves it in after/error/finally
class TestHookWithData implements Hook {
beforeData: unknown;
afterData: unknown;
errorData: unknown;
finallyData: unknown;
async before(hookContext: BeforeHookContext) {
// Store some data
hookContext.hookData.set('testKey', 'testValue');
hookContext.hookData.set('timestamp', Date.now());
hookContext.hookData.set('object', { nested: 'value' });
this.beforeData = hookContext.hookData.get('testKey');
}
async after(hookContext: HookContext) {
// Retrieve data stored in before
this.afterData = hookContext.hookData.get('testKey');
}
async error(hookContext: HookContext) {
// Retrieve data stored in before
this.errorData = hookContext.hookData.get('testKey');
}
async finally(hookContext: HookContext) {
// Retrieve data stored in before
this.finallyData = hookContext.hookData.get('testKey');
}
}
// Typed hook example demonstrating improved type safety
interface OpenTelemetryData {
spanId: string;
traceId: string;
startTime: number;
attributes: Record<string, string | number | boolean>;
}
class TypedOpenTelemetryHook implements Hook {
spanId?: string;
duration?: number;
async before(hookContext: BeforeHookContext) {
const spanId = `span-${Math.random().toString(36).substring(2, 11)}`;
const traceId = `trace-${Math.random().toString(36).substring(2, 11)}`;
// Demonstrate that we can cast for type safety while maintaining compatibility
const typedHookData = hookContext.hookData as unknown as HookData<OpenTelemetryData>;
// Type-safe setting with proper intellisense
typedHookData.set('spanId', spanId);
typedHookData.set('traceId', traceId);
typedHookData.set('startTime', Date.now());
typedHookData.set('attributes', {
flagKey: hookContext.flagKey,
clientName: hookContext.clientMetadata.name || 'unknown',
providerName: hookContext.providerMetadata.name,
});
this.spanId = spanId;
}
async after(hookContext: HookContext) {
// Type-safe getting with proper return types
const typedHookData = hookContext.hookData as unknown as HookData<OpenTelemetryData>;
const startTime: number | undefined = typedHookData.get('startTime');
const spanId: string | undefined = typedHookData.get('spanId');
if (startTime && spanId) {
this.duration = Date.now() - startTime;
// Simulate span completion
}
}
async error(hookContext: HookContext) {
const typedHookData = hookContext.hookData as unknown as HookData<OpenTelemetryData>;
const spanId: string | undefined = typedHookData.get('spanId');
if (spanId) {
// Mark span as error
}
}
}
// A timing hook that measures evaluation duration
class TimingHook implements Hook {
duration?: number;
async before(hookContext: BeforeHookContext) {
hookContext.hookData.set('startTime', Date.now());
}
async after(hookContext: HookContext) {
const startTime = hookContext.hookData.get('startTime') as number;
if (startTime) {
this.duration = Date.now() - startTime;
}
}
async error(hookContext: HookContext) {
const startTime = hookContext.hookData.get('startTime') as number;
if (startTime) {
this.duration = Date.now() - startTime;
}
}
}
// Hook that tests hook data isolation
class IsolationTestHook implements Hook {
hookId: string;
constructor(id: string) {
this.hookId = id;
}
before(hookContext: BeforeHookContext) {
const storedId = hookContext.hookData.get('hookId');
if (storedId) {
throw new Error('Hook data isolation violated! Data is set in before hook.');
}
// Each hook instance should have its own data
hookContext.hookData.set('hookId', this.hookId);
hookContext.hookData.set(`data_${this.hookId}`, `value_${this.hookId}`);
}
after(hookContext: HookContext) {
// Verify we can only see our own data
const storedId = hookContext.hookData.get('hookId');
if (storedId !== this.hookId) {
throw new Error(`Hook data isolation violated! Expected ${this.hookId}, got ${storedId}`);
}
}
}
// Mock provider for testing
const MOCK_PROVIDER: Provider = {
metadata: { name: 'mock-provider' },
async resolveBooleanEvaluation(): Promise<ResolutionDetails<boolean>> {
return {
value: BOOLEAN_VALUE,
variant: 'default',
reason: StandardResolutionReasons.DEFAULT,
};
},
async resolveStringEvaluation(): Promise<ResolutionDetails<string>> {
return {
value: STRING_VALUE,
variant: 'default',
reason: StandardResolutionReasons.DEFAULT,
};
},
async resolveNumberEvaluation(): Promise<ResolutionDetails<number>> {
return {
value: NUMBER_VALUE,
variant: 'default',
reason: StandardResolutionReasons.DEFAULT,
};
},
async resolveObjectEvaluation<T extends JsonValue>(): Promise<ResolutionDetails<T>> {
return {
value: OBJECT_VALUE as unknown as T,
variant: 'default',
reason: StandardResolutionReasons.DEFAULT,
};
},
};
// Mock provider that throws an error
const ERROR_PROVIDER: Provider = {
metadata: { name: 'error-provider' },
async resolveBooleanEvaluation(): Promise<ResolutionDetails<boolean>> {
throw new Error('Provider error');
},
async resolveStringEvaluation(): Promise<ResolutionDetails<string>> {
throw new Error('Provider error');
},
async resolveNumberEvaluation(): Promise<ResolutionDetails<number>> {
throw new Error('Provider error');
},
async resolveObjectEvaluation<T extends JsonValue>(): Promise<ResolutionDetails<T>> {
throw new Error('Provider error');
},
};
describe('Hook Data', () => {
let client: Client;
beforeEach(async () => {
OpenFeature.clearHooks();
await OpenFeature.setProviderAndWait(MOCK_PROVIDER);
client = OpenFeature.getClient();
});
afterEach(async () => {
await OpenFeature.clearProviders();
});
describe('Basic Hook Data Functionality', () => {
it('should allow hooks to store and retrieve data across stages', async () => {
const hook = new TestHookWithData();
client.addHooks(hook);
await client.getBooleanValue('test-flag', false);
// Verify data was stored in before and retrieved in all other stages
expect(hook.beforeData).toBe('testValue');
expect(hook.afterData).toBe('testValue');
expect(hook.finallyData).toBe('testValue');
});
it('should support storing different data types', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const storedValues: any = {};
const hook: Hook = {
async before(hookContext: BeforeHookContext) {
// Store various types
hookContext.hookData.set('string', 'test');
hookContext.hookData.set('number', 42);
hookContext.hookData.set('boolean', true);
hookContext.hookData.set('object', { key: 'value' });
hookContext.hookData.set('array', [1, 2, 3]);
hookContext.hookData.set('null', null);
hookContext.hookData.set('undefined', undefined);
},
async after(hookContext: HookContext) {
storedValues.string = hookContext.hookData.get('string');
storedValues.number = hookContext.hookData.get('number');
storedValues.boolean = hookContext.hookData.get('boolean');
storedValues.object = hookContext.hookData.get('object');
storedValues.array = hookContext.hookData.get('array');
storedValues.null = hookContext.hookData.get('null');
storedValues.undefined = hookContext.hookData.get('undefined');
},
};
client.addHooks(hook);
await client.getBooleanValue('test-flag', false);
expect(storedValues.string).toBe('test');
expect(storedValues.number).toBe(42);
expect(storedValues.boolean).toBe(true);
expect(storedValues.object).toEqual({ key: 'value' });
expect(storedValues.array).toEqual([1, 2, 3]);
expect(storedValues.null).toBeNull();
expect(storedValues.undefined).toBeUndefined();
});
it('should handle hook data in error scenarios', async () => {
await OpenFeature.setProviderAndWait(ERROR_PROVIDER);
const hook = new TestHookWithData();
client.addHooks(hook);
await client.getBooleanValue('test-flag', false);
// Verify data was accessible in error and finally stages
expect(hook.beforeData).toBe('testValue');
expect(hook.errorData).toBe('testValue');
expect(hook.finallyData).toBe('testValue');
expect(hook.afterData).toBeUndefined(); // after should not run on error
});
});
describe('Hook Data API', () => {
it('should support has() method', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const hasResults: any = {};
const hook: Hook = {
async before(hookContext: BeforeHookContext) {
hookContext.hookData.set('exists', 'value');
hasResults.beforeExists = hookContext.hookData.has('exists');
hasResults.beforeNotExists = hookContext.hookData.has('notExists');
},
async after(hookContext: HookContext) {
hasResults.afterExists = hookContext.hookData.has('exists');
hasResults.afterNotExists = hookContext.hookData.has('notExists');
},
};
client.addHooks(hook);
await client.getBooleanValue('test-flag', false);
expect(hasResults.beforeExists).toBe(true);
expect(hasResults.beforeNotExists).toBe(false);
expect(hasResults.afterExists).toBe(true);
expect(hasResults.afterNotExists).toBe(false);
});
it('should support delete() method', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const deleteResults: any = {};
const hook: Hook = {
async before(hookContext: BeforeHookContext) {
hookContext.hookData.set('toDelete', 'value');
deleteResults.hasBeforeDelete = hookContext.hookData.has('toDelete');
deleteResults.deleteResult = hookContext.hookData.delete('toDelete');
deleteResults.hasAfterDelete = hookContext.hookData.has('toDelete');
deleteResults.deleteAgainResult = hookContext.hookData.delete('toDelete');
},
};
client.addHooks(hook);
await client.getBooleanValue('test-flag', false);
expect(deleteResults.hasBeforeDelete).toBe(true);
expect(deleteResults.deleteResult).toBe(true);
expect(deleteResults.hasAfterDelete).toBe(false);
expect(deleteResults.deleteAgainResult).toBe(false);
});
it('should support clear() method', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const clearResults: any = {};
const hook: Hook = {
async before(hookContext: BeforeHookContext) {
hookContext.hookData.set('key1', 'value1');
hookContext.hookData.set('key2', 'value2');
hookContext.hookData.set('key3', 'value3');
clearResults.hasBeforeClear = hookContext.hookData.has('key1');
hookContext.hookData.clear();
clearResults.hasAfterClear = hookContext.hookData.has('key1');
},
async after(hookContext: HookContext) {
// Verify all data was cleared
clearResults.afterHasKey1 = hookContext.hookData.has('key1');
clearResults.afterHasKey2 = hookContext.hookData.has('key2');
clearResults.afterHasKey3 = hookContext.hookData.has('key3');
},
};
client.addHooks(hook);
await client.getBooleanValue('test-flag', false);
expect(clearResults.hasBeforeClear).toBe(true);
expect(clearResults.hasAfterClear).toBe(false);
expect(clearResults.afterHasKey1).toBe(false);
expect(clearResults.afterHasKey2).toBe(false);
expect(clearResults.afterHasKey3).toBe(false);
});
});
describe('Hook Data Isolation', () => {
it('should isolate data between different hook instances', async () => {
const hook1 = new IsolationTestHook('hook1');
const hook2 = new IsolationTestHook('hook2');
const hook3 = new IsolationTestHook('hook3');
client.addHooks(hook1, hook2, hook3);
expect(await client.getBooleanValue('test-flag', false)).toBe(true);
});
it('should isolate data between the same hook instance', async () => {
const hook = new IsolationTestHook('hook');
client.addHooks(hook, hook);
expect(await client.getBooleanValue('test-flag', false)).toBe(true);
});
it('should not share data between different evaluations', async () => {
let firstEvalData: unknown;
let secondEvalData: unknown;
const hook: Hook = {
async before(hookContext: BeforeHookContext) {
// Check if data exists from previous evaluation
const existingData = hookContext.hookData.get('evalData');
if (existingData) {
throw new Error('Hook data leaked between evaluations!');
}
hookContext.hookData.set('evalData', 'evaluation-specific');
},
async after(hookContext: HookContext) {
if (!firstEvalData) {
firstEvalData = hookContext.hookData.get('evalData');
} else {
secondEvalData = hookContext.hookData.get('evalData');
}
},
};
client.addHooks(hook);
// First evaluation
await client.getBooleanValue('test-flag', false);
// Second evaluation
await client.getBooleanValue('test-flag', false);
expect(firstEvalData).toBe('evaluation-specific');
expect(secondEvalData).toBe('evaluation-specific');
});
it('should isolate data between global, client, and invocation hooks', async () => {
const globalHook = new IsolationTestHook('global');
const clientHook = new IsolationTestHook('client');
const invocationHook = new IsolationTestHook('invocation');
OpenFeature.addHooks(globalHook);
client.addHooks(clientHook);
expect(await client.getBooleanValue('test-flag', false, {}, { hooks: [invocationHook] })).toBe(true);
});
});
describe('Use Cases', () => {
it('should support timing measurements', async () => {
const timingHook = new TimingHook();
client.addHooks(timingHook);
await client.getBooleanValue('test-flag', false);
expect(timingHook.duration).toBeDefined();
expect(timingHook.duration).toBeGreaterThanOrEqual(0);
});
it('should support multi-stage validation accumulation', async () => {
let finalErrors: string[] = [];
const validationHook: Hook = {
async before(hookContext: BeforeHookContext) {
hookContext.hookData.set('errors', []);
// Simulate validation
const errors = hookContext.hookData.get('errors') as string[];
if (!hookContext.context.userId) {
errors.push('Missing userId');
}
if (!hookContext.context.region) {
errors.push('Missing region');
}
},
async finally(hookContext: HookContext) {
finalErrors = (hookContext.hookData.get('errors') as string[]) || [];
},
};
client.addHooks(validationHook);
await client.getBooleanValue('test-flag', false, {});
expect(finalErrors).toContain('Missing userId');
expect(finalErrors).toContain('Missing region');
});
it('should support request correlation', async () => {
let correlationId: string | undefined;
const correlationHook: Hook = {
async before(hookContext: BeforeHookContext) {
const id = `req-${Date.now()}-${Math.random()}`;
hookContext.hookData.set('correlationId', id);
},
async after(hookContext: HookContext) {
correlationId = hookContext.hookData.get('correlationId') as string;
},
};
client.addHooks(correlationHook);
await client.getBooleanValue('test-flag', false);
expect(correlationId).toBeDefined();
expect(correlationId).toMatch(/^req-\d+-[\d.]+$/);
});
it('should support typed hook data for better type safety', async () => {
const typedHook = new TypedOpenTelemetryHook();
client.addHooks(typedHook);
await client.getBooleanValue('test-flag', false);
// Verify the typed hook worked correctly
expect(typedHook.spanId).toBeDefined();
expect(typedHook.spanId).toMatch(/^span-[a-z0-9]+$/);
expect(typedHook.duration).toBeDefined();
expect(typeof typedHook.duration).toBe('number');
expect(typedHook.duration).toBeGreaterThanOrEqual(0);
});
});
});

View File

@ -439,30 +439,6 @@ describe('Hooks', () => {
}); });
}); });
}); });
describe('Requirement 4.3.8', () => {
it('"evaluation details" passed to the "finally" stage matches the evaluation details returned to the application author', async () => {
OpenFeature.setProvider(MOCK_PROVIDER);
let evaluationDetailsHooks;
const evaluationDetails = await client.getBooleanDetails(
FLAG_KEY,
false,
{},
{
hooks: [
{
finally: (_, details) => {
evaluationDetailsHooks = details;
},
},
],
},
);
expect(evaluationDetailsHooks).toEqual(evaluationDetails);
});
});
}); });
describe('Requirement 4.4.2', () => { describe('Requirement 4.4.2', () => {
@ -946,14 +922,14 @@ describe('Hooks', () => {
done(err); done(err);
} }
}, },
after: (_hookContext, _evaluationDetails, hookHints) => { after: (_hookContext, _evaluationDetils, hookHints) => {
try { try {
expect(hookHints?.hint).toBeTruthy(); expect(hookHints?.hint).toBeTruthy();
} catch (err) { } catch (err) {
done(err); done(err);
} }
}, },
finally: (_, _evaluationDetails, hookHints) => { finally: (_, hookHints) => {
try { try {
expect(hookHints?.hint).toBeTruthy(); expect(hookHints?.hint).toBeTruthy();
done(); done();

View File

@ -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 registeredProvider = OpenFeature.getProvider(); const client = OpenFeature.getClient();
expect(registeredProvider).toEqual(provider); expect(client.metadata.providerMetadata.name).toEqual(provider.metadata.name);
}); });
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 defaultProvider = OpenFeature.getProvider(); const defaultClient = OpenFeature.getClient();
const domainSpecificProvider = OpenFeature.getProvider(domain); const domainSpecificClient = OpenFeature.getClient(domain);
expect(defaultProvider).toEqual(provider); expect(defaultClient.metadata.providerMetadata.name).toEqual(provider.metadata.name);
expect(domainSpecificProvider).toEqual(fakeProvider); expect(domainSpecificClient.metadata.providerMetadata.name).toEqual(fakeProvider.metadata.name);
}); });
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', () => {

View File

@ -1,67 +1,5 @@
# Changelog # Changelog
## [1.9.0](https://github.com/open-feature/js-sdk/compare/core-v1.8.1...core-v1.9.0) (2025-08-10)
### ✨ New Features
* add evaluation-scoped hook data ([#1216](https://github.com/open-feature/js-sdk/issues/1216)) ([07af3a9](https://github.com/open-feature/js-sdk/commit/07af3a9eda895e9edb24c7ee1e3c1c4f16e17431))
* support Angular 20 ([#1220](https://github.com/open-feature/js-sdk/issues/1220)) ([aa232a9](https://github.com/open-feature/js-sdk/commit/aa232a9d6a8dfa416380ccdecd71843d3e361048))
## [1.8.1](https://github.com/open-feature/js-sdk/compare/core-v1.8.0...core-v1.8.1) (2025-06-04)
### 🔄 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)
### ✨ New Features
* add telemetry helper utils ([#1120](https://github.com/open-feature/js-sdk/issues/1120)) ([1e93b3c](https://github.com/open-feature/js-sdk/commit/1e93b3c6fa4494dfb41389043921751e00acafd2))
## [1.6.0](https://github.com/open-feature/js-sdk/compare/core-v1.5.0...core-v1.6.0) (2024-12-12)
### ⚠ BREAKING CHANGES
The signature of the `finally` hook stage has been changed. The signature now includes the `evaluation details`, as per the [OpenFeature specification](https://openfeature.dev/specification/sections/hooks#requirement-438).
Note that since hooks are still `experimental,` this does not constitute a change requiring a new major version.
To migrate, update any hook that implements the `finally` stage to accept `evaluation details` as the second argument.
* add evaluation details to finally hook ([#1087](https://github.com/open-feature/js-sdk/issues/1087)) ([2135254](https://github.com/open-feature/js-sdk/commit/2135254c4bee52b4bcadfbf8b99a896cfd930cca))
### ✨ New Features
* add evaluation details to finally hook ([#1087](https://github.com/open-feature/js-sdk/issues/1087)) ([2135254](https://github.com/open-feature/js-sdk/commit/2135254c4bee52b4bcadfbf8b99a896cfd930cca))
### 🔄 Refactoring
* improve track interface for providers ([#1100](https://github.com/open-feature/js-sdk/issues/1100)) ([5e5b160](https://github.com/open-feature/js-sdk/commit/5e5b16022122b71760634ac90e3fd962aa831c74))
## [1.5.0](https://github.com/open-feature/js-sdk/compare/core-v1.4.0...core-v1.5.0) (2024-10-29) ## [1.5.0](https://github.com/open-feature/js-sdk/compare/core-v1.4.0...core-v1.5.0) (2024-10-29)

View File

@ -1,6 +1,6 @@
{ {
"name": "@openfeature/core", "name": "@openfeature/core",
"version": "1.9.0", "version": "1.5.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": [

View File

@ -18,4 +18,3 @@ export abstract class OpenFeatureError extends Error {
this.cause = options?.cause; this.cause = options?.cause;
} }
} }

View File

@ -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. * Represents a JSON node value, or Date.
*/ */
export type FlagValue = boolean | string | number | JsonValue; export type FlagValue = boolean | string | number | JsonValue;

View File

@ -66,9 +66,6 @@ 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> {
/** /**
@ -76,7 +73,6 @@ 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
@ -87,17 +83,14 @@ 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;
/** /**

View File

@ -1,72 +0,0 @@
/**
* A mutable data structure for hooks to maintain state across their lifecycle.
* Each hook instance gets its own isolated data store that persists for the
* duration of a single flag evaluation.
* @template TData - A record type that defines the shape of the stored data
*/
export interface HookData<TData = Record<string, unknown>> {
/**
* Sets a value in the hook data store.
* @param key The key to store the value under
* @param value The value to store
*/
set<K extends keyof TData>(key: K, value: TData[K]): void;
set(key: string, value: unknown): void;
/**
* Gets a value from the hook data store.
* @param key The key to retrieve the value for
* @returns The stored value, or undefined if not found
*/
get<K extends keyof TData>(key: K): TData[K] | undefined;
get(key: string): unknown;
/**
* Checks if a key exists in the hook data store.
* @param key The key to check
* @returns True if the key exists, false otherwise
*/
has<K extends keyof TData>(key: K): boolean;
has(key: string): boolean;
/**
* Deletes a value from the hook data store.
* @param key The key to delete
* @returns True if the key was deleted, false if it didn't exist
*/
delete<K extends keyof TData>(key: K): boolean;
delete(key: string): boolean;
/**
* Clears all values from the hook data store.
*/
clear(): void;
}
/**
* Default implementation of HookData using a Map.
* @template TData - A record type that defines the shape of the stored data
*/
export class MapHookData<TData = Record<string, unknown>> implements HookData<TData> {
private readonly data = new Map<keyof TData, TData[keyof TData]>();
set<K extends keyof TData>(key: K, value: TData[K]): void {
this.data.set(key, value);
}
get<K extends keyof TData>(key: K): TData[K] | undefined {
return this.data.get(key) as TData[K] | undefined;
}
has<K extends keyof TData>(key: K): boolean {
return this.data.has(key);
}
delete<K extends keyof TData>(key: K): boolean {
return this.data.delete(key);
}
clear(): void {
this.data.clear();
}
}

View File

@ -1,19 +1,14 @@
import type { BeforeHookContext, HookContext, HookHints } from './hooks'; import type { BeforeHookContext, HookContext, HookHints } from './hooks';
import type { EvaluationDetails, FlagValue } from '../evaluation'; import type { EvaluationDetails, FlagValue } from '../evaluation';
export interface BaseHook< export interface BaseHook<T extends FlagValue = FlagValue, BeforeHookReturn = unknown, HooksReturn = unknown> {
T extends FlagValue = FlagValue,
TData = Record<string, unknown>,
BeforeHookReturn = unknown,
HooksReturn = unknown
> {
/** /**
* Runs before flag values are resolved from the provider. * Runs before flag values are resolved from the provider.
* If an EvaluationContext is returned, it will be merged with the pre-existing EvaluationContext. * If an EvaluationContext is returned, it will be merged with the pre-existing EvaluationContext.
* @param hookContext * @param hookContext
* @param hookHints * @param hookHints
*/ */
before?(hookContext: BeforeHookContext<T, TData>, hookHints?: HookHints): BeforeHookReturn; before?(hookContext: BeforeHookContext, hookHints?: HookHints): BeforeHookReturn;
/** /**
* Runs after flag values are successfully resolved from the provider. * Runs after flag values are successfully resolved from the provider.
@ -22,7 +17,7 @@ export interface BaseHook<
* @param hookHints * @param hookHints
*/ */
after?( after?(
hookContext: Readonly<HookContext<T, TData>>, hookContext: Readonly<HookContext<T>>,
evaluationDetails: EvaluationDetails<T>, evaluationDetails: EvaluationDetails<T>,
hookHints?: HookHints, hookHints?: HookHints,
): HooksReturn; ): HooksReturn;
@ -33,18 +28,13 @@ export interface BaseHook<
* @param error * @param error
* @param hookHints * @param hookHints
*/ */
error?(hookContext: Readonly<HookContext<T, TData>>, error: unknown, hookHints?: HookHints): HooksReturn; error?(hookContext: Readonly<HookContext<T>>, error: unknown, hookHints?: HookHints): HooksReturn;
/** /**
* Runs after all other hook stages, regardless of success or error. * Runs after all other hook stages, regardless of success or error.
* Errors thrown here are unhandled by the client and will surface in application code.
* @param hookContext * @param hookContext
* @param evaluationDetails
* @param hookHints * @param hookHints
*/ */
finally?( finally?(hookContext: Readonly<HookContext<T>>, hookHints?: HookHints): HooksReturn;
hookContext: Readonly<HookContext<T, TData>>,
evaluationDetails: EvaluationDetails<T>,
hookHints?: HookHints,
): HooksReturn;
} }

View File

@ -2,11 +2,10 @@ import type { ProviderMetadata } from '../provider';
import type { ClientMetadata } from '../client'; import type { ClientMetadata } from '../client';
import type { EvaluationContext, FlagValue, FlagValueType } from '../evaluation'; import type { EvaluationContext, FlagValue, FlagValueType } from '../evaluation';
import type { Logger } from '../logger'; import type { Logger } from '../logger';
import type { HookData } from './hook-data';
export type HookHints = Readonly<Record<string, unknown>>; export type HookHints = Readonly<Record<string, unknown>>;
export interface HookContext<T extends FlagValue = FlagValue, TData = Record<string, unknown>> { export interface HookContext<T extends FlagValue = FlagValue> {
readonly flagKey: string; readonly flagKey: string;
readonly defaultValue: T; readonly defaultValue: T;
readonly flagValueType: FlagValueType; readonly flagValueType: FlagValueType;
@ -14,9 +13,8 @@ export interface HookContext<T extends FlagValue = FlagValue, TData = Record<str
readonly clientMetadata: ClientMetadata; readonly clientMetadata: ClientMetadata;
readonly providerMetadata: ProviderMetadata; readonly providerMetadata: ProviderMetadata;
readonly logger: Logger; readonly logger: Logger;
readonly hookData: HookData<TData>;
} }
export interface BeforeHookContext<T extends FlagValue = FlagValue, TData = Record<string, unknown>> extends HookContext<T, TData> { export interface BeforeHookContext extends HookContext {
context: EvaluationContext; context: EvaluationContext;
} }

View File

@ -1,4 +1,3 @@
export * from './hook'; export * from './hook';
export * from './hooks'; export * from './hooks';
export * from './evaluation-lifecycle'; export * from './evaluation-lifecycle';
export * from './hook-data';

View File

@ -4,7 +4,6 @@ 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';

View File

@ -7,13 +7,14 @@ import type {
EventDetails, EventDetails,
EventHandler, EventHandler,
Eventing, Eventing,
EventOptions, GenericEventEmitter} from './events';
GenericEventEmitter, import {
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';
@ -153,9 +154,8 @@ 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, options?: EventOptions): void { addHandler<T extends AnyProviderEvent>(eventType: T, handler: EventHandler): 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,11 +173,6 @@ 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);
});
}
} }
/** /**
@ -253,7 +248,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

View File

@ -133,5 +133,5 @@ export interface CommonProvider<S extends ClientProviderStatus | ServerProviderS
* @param context * @param context
* @param trackingEventDetails * @param trackingEventDetails
*/ */
track?(trackingEventName: string, context: EvaluationContext, trackingEventDetails: TrackingEventDetails): void; track?(trackingEventName: string, context?: EvaluationContext, trackingEventDetails?: TrackingEventDetails): void;
} }

View File

@ -1,89 +0,0 @@
/**
* The attributes of an OpenTelemetry compliant event for flag evaluation.
* @see https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-logs/
*/
export const TelemetryAttribute = {
/**
* The lookup key of the feature flag.
*
* - type: `string`
* - requirement level: `required`
* - example: `logo-color`
*/
KEY: 'feature_flag.key',
/**
* Describes a class of error the operation ended with.
*
* - type: `string`
* - requirement level: `conditionally required`
* - condition: `reason` is `error`
* - example: `flag_not_found`
*/
ERROR_CODE: 'error.type',
/**
* A message explaining the nature of an error occurring during flag evaluation.
*
* - type: `string`
* - requirement level: `recommended`
* - example: `Flag not found`
*/
ERROR_MESSAGE: 'error.message',
/**
* A semantic identifier for an evaluated flag value.
*
* - type: `string`
* - requirement level: `conditionally required`
* - condition: variant is defined on the evaluation details
* - example: `blue`; `on`; `true`
*/
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.
*
* - type: `string`
* - requirement level: `recommended`
* - example: `5157782b-2203-4c80-a857-dbbd5e7761db`
*/
CONTEXT_ID: 'feature_flag.context.id',
/**
* The reason code which shows how a feature flag value was determined.
*
* - type: `string`
* - requirement level: `recommended`
* - example: `targeting_match`
*/
REASON: 'feature_flag.result.reason',
/**
* Describes a class of error the operation ended with.
*
* - type: `string`
* - requirement level: `recommended`
* - example: `flag_not_found`
*/
PROVIDER: 'feature_flag.provider.name',
/**
* The identifier of the flag set to which the feature flag belongs.
*
* - type: `string`
* - requirement level: `recommended`
* - example: `proj-1`; `ab98sgs`; `service1/dev`
*/
FLAG_SET_ID: 'feature_flag.set.id',
/**
* The version of the ruleset used during the evaluation. This may be any stable value which uniquely identifies the ruleset.
*
* - type: `string`
* - requirement level: `recommended`
* - example: `1.0.0`; `2021-01-01`
*/
VERSION: 'feature_flag.version',
} as const;

View File

@ -1,70 +0,0 @@
import { ErrorCode, StandardResolutionReasons, type EvaluationDetails, type FlagValue } from '../evaluation/evaluation';
import type { HookContext } from '../hooks/hooks';
import { TelemetryAttribute } from './attributes';
import { TelemetryFlagMetadata } from './flag-metadata';
type EvaluationEvent = {
/**
* The name of the feature flag evaluation event.
*/
name: string;
/**
* 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';
/**
* Returns an OpenTelemetry compliant event for flag evaluation.
* @param {HookContext} hookContext Contextual information about the flag evaluation
* @param {EvaluationDetails} evaluationDetails The details of the flag evaluation
* @returns {EvaluationEvent} An evaluation event object containing the event name and attributes
*/
export function createEvaluationEvent(
hookContext: Readonly<HookContext<FlagValue>>,
evaluationDetails: EvaluationDetails<FlagValue>,
): EvaluationEvent {
const attributes: EvaluationEvent['attributes'] = {
[TelemetryAttribute.KEY]: hookContext.flagKey,
[TelemetryAttribute.PROVIDER]: hookContext.providerMetadata.name,
[TelemetryAttribute.REASON]: (evaluationDetails.reason ?? StandardResolutionReasons.UNKNOWN).toLowerCase(),
};
if (evaluationDetails.variant) {
attributes[TelemetryAttribute.VARIANT] = evaluationDetails.variant;
} else {
attributes[TelemetryAttribute.VALUE] = evaluationDetails.value;
}
const contextId =
evaluationDetails.flagMetadata[TelemetryFlagMetadata.CONTEXT_ID] || hookContext.context.targetingKey;
if (contextId) {
attributes[TelemetryAttribute.CONTEXT_ID] = contextId;
}
const setId = evaluationDetails.flagMetadata[TelemetryFlagMetadata.FLAG_SET_ID];
if (setId) {
attributes[TelemetryAttribute.FLAG_SET_ID] = setId;
}
const version = evaluationDetails.flagMetadata[TelemetryFlagMetadata.VERSION];
if (version) {
attributes[TelemetryAttribute.VERSION] = version;
}
if (evaluationDetails.reason === StandardResolutionReasons.ERROR) {
attributes[TelemetryAttribute.ERROR_CODE] = (evaluationDetails.errorCode ?? ErrorCode.GENERAL).toLowerCase();
if (evaluationDetails.errorMessage) {
attributes[TelemetryAttribute.ERROR_MESSAGE] = evaluationDetails.errorMessage;
}
}
return {
name: FLAG_EVALUATION_EVENT_NAME,
attributes,
};
}

View File

@ -1,20 +0,0 @@
/**
* Well-known flag metadata attributes for telemetry events.
* @see https://openfeature.dev/specification/appendix-d#flag-metadata
*/
export const TelemetryFlagMetadata = {
/**
* The context identifier returned in the flag metadata uniquely identifies
* the subject of the flag evaluation. If not available, the targeting key
* should be used.
*/
CONTEXT_ID: 'contextId',
/**
* A logical identifier for the flag set.
*/
FLAG_SET_ID: 'flagSetId',
/**
* A version string (format unspecified) for the flag or flag set.
*/
VERSION: 'version',
} as const;

View File

@ -1,3 +0,0 @@
export * from './attributes';
export * from './flag-metadata';
export * from './evaluation-event';

View File

@ -14,23 +14,17 @@ 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) => { return new Promise(resolve => {setTimeout(resolve, millis);});
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);
@ -41,6 +35,8 @@ 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();
@ -68,6 +64,7 @@ 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 () => {
@ -77,6 +74,8 @@ 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);
@ -88,6 +87,7 @@ 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,6 +99,8 @@ 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();
@ -119,6 +121,8 @@ 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);
@ -134,6 +138,8 @@ 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);
@ -150,6 +156,8 @@ 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);
@ -166,6 +174,8 @@ 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);
@ -181,6 +191,8 @@ 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);
@ -193,6 +205,8 @@ 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);
@ -209,6 +223,8 @@ 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);

View File

@ -1,214 +0,0 @@
import type { HookData, BaseHook, BeforeHookContext, HookContext } from '../src/hooks';
import { MapHookData } from '../src/hooks';
import type { FlagValue } from '../src/evaluation';
describe('Hook Data Type Safety', () => {
it('should provide type safety with typed hook data', () => {
// Define a strict type for hook data
interface MyHookData {
startTime: number;
userId: string;
metadata: { version: string; feature: boolean };
tags: string[];
}
const hookData = new MapHookData<MyHookData>();
// Type-safe setting and getting
hookData.set('startTime', 123456);
hookData.set('userId', 'user-123');
hookData.set('metadata', { version: '1.0.0', feature: true });
hookData.set('tags', ['tag1', 'tag2']);
// TypeScript should infer the correct return types
const startTime: number | undefined = hookData.get('startTime');
const userId: string | undefined = hookData.get('userId');
const metadata: { version: string; feature: boolean } | undefined = hookData.get('metadata');
const tags: string[] | undefined = hookData.get('tags');
// Verify the values
expect(startTime).toBe(123456);
expect(userId).toBe('user-123');
expect(metadata).toEqual({ version: '1.0.0', feature: true });
expect(tags).toEqual(['tag1', 'tag2']);
// Type-safe existence checks
expect(hookData.has('startTime')).toBe(true);
expect(hookData.has('userId')).toBe(true);
expect(hookData.has('metadata')).toBe(true);
expect(hookData.has('tags')).toBe(true);
// Type-safe deletion
expect(hookData.delete('tags')).toBe(true);
expect(hookData.has('tags')).toBe(false);
});
it('should support untyped usage for backward compatibility', () => {
const hookData: HookData = new MapHookData();
// Untyped usage still works
hookData.set('anyKey', 'anyValue');
hookData.set('numberKey', 42);
hookData.set('objectKey', { nested: true });
const value: unknown = hookData.get('anyKey');
const numberValue: unknown = hookData.get('numberKey');
const objectValue: unknown = hookData.get('objectKey');
expect(value).toBe('anyValue');
expect(numberValue).toBe(42);
expect(objectValue).toEqual({ nested: true });
});
it('should support mixed usage with typed and untyped keys', () => {
interface PartiallyTypedData {
correlationId: string;
timestamp: number;
}
const hookData: HookData<PartiallyTypedData> = new MapHookData<PartiallyTypedData>();
// Typed usage
hookData.set('correlationId', 'abc-123');
hookData.set('timestamp', Date.now());
// Untyped usage for additional keys
hookData.set('dynamicKey', 'dynamicValue');
// Type-safe retrieval for typed keys
const correlationId: string | undefined = hookData.get('correlationId');
const timestamp: number | undefined = hookData.get('timestamp');
// Untyped retrieval for dynamic keys
const dynamicValue: unknown = hookData.get('dynamicKey');
expect(correlationId).toBe('abc-123');
expect(typeof timestamp).toBe('number');
expect(dynamicValue).toBe('dynamicValue');
});
it('should work with complex nested types', () => {
interface ComplexHookData {
request: {
id: string;
headers: Record<string, string>;
body?: { [key: string]: unknown };
};
response: {
status: number;
data: unknown;
headers: Record<string, string>;
};
metrics: {
startTime: number;
endTime?: number;
duration?: number;
};
}
const hookData: HookData<ComplexHookData> = new MapHookData<ComplexHookData>();
const requestData = {
id: 'req-123',
headers: { 'Content-Type': 'application/json' },
body: { flag: 'test-flag' },
};
hookData.set('request', requestData);
hookData.set('metrics', { startTime: Date.now() });
const retrievedRequest = hookData.get('request');
const retrievedMetrics = hookData.get('metrics');
expect(retrievedRequest).toEqual(requestData);
expect(retrievedMetrics?.startTime).toBeDefined();
expect(typeof retrievedMetrics?.startTime).toBe('number');
});
it('should support generic type inference', () => {
// This function demonstrates how the generic types work in practice
function createTypedHookData<T>(): HookData<T> {
return new MapHookData<T>();
}
interface TimingData {
start: number;
checkpoint: number;
}
const timingHookData = createTypedHookData<TimingData>();
timingHookData.set('start', performance.now());
timingHookData.set('checkpoint', performance.now());
const start: number | undefined = timingHookData.get('start');
const checkpoint: number | undefined = timingHookData.get('checkpoint');
expect(typeof start).toBe('number');
expect(typeof checkpoint).toBe('number');
});
it('should work with BaseHook interface without casting', () => {
interface TestHookData {
testId: string;
startTime: number;
metadata: { version: string };
}
class TestTypedHook implements BaseHook<FlagValue, TestHookData> {
capturedData: { testId?: string; duration?: number } = {};
before(hookContext: BeforeHookContext<FlagValue, TestHookData>) {
// No casting needed - TypeScript knows the types
hookContext.hookData.set('testId', 'test-123');
hookContext.hookData.set('startTime', Date.now());
hookContext.hookData.set('metadata', { version: '1.0.0' });
}
after(hookContext: HookContext<FlagValue, TestHookData>) {
// Type-safe getting with proper return types
const testId: string | undefined = hookContext.hookData.get('testId');
const startTime: number | undefined = hookContext.hookData.get('startTime');
if (testId && startTime) {
this.capturedData = {
testId,
duration: Date.now() - startTime,
};
}
}
}
const hook = new TestTypedHook();
// Create mock contexts that satisfy the BaseHook interface
const mockBeforeContext: BeforeHookContext<FlagValue, TestHookData> = {
flagKey: 'test-flag',
defaultValue: true,
flagValueType: 'boolean',
context: {},
clientMetadata: {
name: 'test-client',
domain: 'test-domain',
providerMetadata: { name: 'test-provider' },
},
providerMetadata: { name: 'test-provider' },
logger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() },
hookData: new MapHookData<TestHookData>(),
};
const mockAfterContext: HookContext<FlagValue, TestHookData> = {
...mockBeforeContext,
context: Object.freeze({}),
};
// Execute the hook methods
hook.before!(mockBeforeContext);
hook.after!(mockAfterContext);
// Verify the typed hook worked correctly
expect(hook.capturedData.testId).toBe('test-123');
expect(hook.capturedData.duration).toBeDefined();
expect(typeof hook.capturedData.duration).toBe('number');
});
});

View File

@ -1,113 +0,0 @@
import { createEvaluationEvent } from '../src/telemetry/evaluation-event';
import { ErrorCode, StandardResolutionReasons, type EvaluationDetails } from '../src/evaluation/evaluation';
import type { HookContext } from '../src/hooks/hooks';
import { TelemetryAttribute, TelemetryFlagMetadata } from '../src/telemetry';
import { MapHookData } from '../src/hooks/hook-data';
describe('evaluationEvent', () => {
const flagKey = 'test-flag';
const providerMetadata = {
name: 'test-provider',
};
const mockHookContext: HookContext<boolean> = {
flagKey,
providerMetadata: providerMetadata,
context: {
targetingKey: 'test-target',
},
clientMetadata: {
providerMetadata,
},
defaultValue: false,
flagValueType: 'boolean',
logger: {
debug: jest.fn(),
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
},
hookData: new MapHookData(),
};
it('should return basic event body with mandatory fields', () => {
const details: EvaluationDetails<boolean> = {
value: true,
reason: StandardResolutionReasons.STATIC,
flagMetadata: {},
flagKey,
};
const result = createEvaluationEvent(mockHookContext, details);
expect(result.name).toBe('feature_flag.evaluation');
expect(result.attributes).toEqual({
[TelemetryAttribute.KEY]: 'test-flag',
[TelemetryAttribute.PROVIDER]: 'test-provider',
[TelemetryAttribute.REASON]: StandardResolutionReasons.STATIC.toLowerCase(),
[TelemetryAttribute.CONTEXT_ID]: 'test-target',
[TelemetryAttribute.VALUE]: true,
});
});
it('should include variant when provided', () => {
const details: EvaluationDetails<boolean> = {
flagKey,
value: true,
variant: 'test-variant',
reason: StandardResolutionReasons.STATIC,
flagMetadata: {},
};
const result = createEvaluationEvent(mockHookContext, details);
expect(result.attributes[TelemetryAttribute.VARIANT]).toBe('test-variant');
expect(result.attributes[TelemetryAttribute.VALUE]).toBeUndefined();
});
it('should include flag metadata when provided', () => {
const details: EvaluationDetails<boolean> = {
flagKey,
value: true,
reason: StandardResolutionReasons.STATIC,
flagMetadata: {
[TelemetryFlagMetadata.FLAG_SET_ID]: 'test-set',
[TelemetryFlagMetadata.VERSION]: 'v1.0',
[TelemetryFlagMetadata.CONTEXT_ID]: 'metadata-context',
},
};
const result = createEvaluationEvent(mockHookContext, details);
expect(result.attributes[TelemetryAttribute.FLAG_SET_ID]).toBe('test-set');
expect(result.attributes[TelemetryAttribute.VERSION]).toBe('v1.0');
expect(result.attributes[TelemetryAttribute.CONTEXT_ID]).toBe('metadata-context');
});
it('should handle error cases', () => {
const details: EvaluationDetails<boolean> = {
flagKey,
value: false,
reason: StandardResolutionReasons.ERROR,
errorCode: ErrorCode.GENERAL,
errorMessage: 'test error',
flagMetadata: {},
};
const result = createEvaluationEvent(mockHookContext, details);
expect(result.attributes[TelemetryAttribute.ERROR_CODE]).toBe(ErrorCode.GENERAL.toLowerCase());
expect(result.attributes[TelemetryAttribute.ERROR_MESSAGE]).toBe('test error');
});
it('should use unknown reason when reason is not provided', () => {
const details: EvaluationDetails<boolean> = {
flagKey,
value: true,
flagMetadata: {},
};
const result = createEvaluationEvent(mockHookContext, details);
expect(result.attributes[TelemetryAttribute.REASON]).toBe(StandardResolutionReasons.UNKNOWN.toLowerCase());
});
});

View File

@ -1,97 +1,6 @@
# Changelog # Changelog
## [1.6.1](https://github.com/open-feature/js-sdk/compare/web-sdk-v1.6.0...web-sdk-v1.6.1) (2025-08-14)
### 🐛 Bug Fixes
* update core dep ([#1228](https://github.com/open-feature/js-sdk/issues/1228)) ([845d24c](https://github.com/open-feature/js-sdk/commit/845d24c5fecc80de3080e49fde839f08ecac6b33))
## [1.6.0](https://github.com/open-feature/js-sdk/compare/web-sdk-v1.5.0...web-sdk-v1.6.0) (2025-08-12)
### ✨ New Features
* add evaluation-scoped hook data ([#1216](https://github.com/open-feature/js-sdk/issues/1216)) ([07af3a9](https://github.com/open-feature/js-sdk/commit/07af3a9eda895e9edb24c7ee1e3c1c4f16e17431))
* **web-global-build:** impl ([#1225](https://github.com/open-feature/js-sdk/issues/1225)) ([40a512e](https://github.com/open-feature/js-sdk/commit/40a512e21204eb92dc3ef4161b383f9c1fd74da7))
### 📚 Documentation
* Clarify the behavior of setProviderAndWait ([#1180](https://github.com/open-feature/js-sdk/issues/1180)) ([4fe8d87](https://github.com/open-feature/js-sdk/commit/4fe8d87a2e5df2cbd4086cc4f4a380e8857ed8ba))
## [1.5.0](https://github.com/open-feature/js-sdk/compare/web-sdk-v1.4.1...web-sdk-v1.5.0) (2025-04-11)
### ✨ 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)
### ⚠ BREAKING CHANGES
The signature of the `finally` hook stage has been changed. The signature now includes the `evaluation details`, as per the [OpenFeature specification](https://openfeature.dev/specification/sections/hooks#requirement-438). Note that since hooks are still `experimental,` this does not constitute a change requiring a new major version. To migrate, update any hook that implements the `finally` stage to accept `evaluation details` as the second argument.
* add evaluation details to finally hook ([#1087](https://github.com/open-feature/js-sdk/issues/1087)) ([2135254](https://github.com/open-feature/js-sdk/commit/2135254c4bee52b4bcadfbf8b99a896cfd930cca))
### ✨ New Features
* add evaluation details to finally hook ([#1087](https://github.com/open-feature/js-sdk/issues/1087)) ([2135254](https://github.com/open-feature/js-sdk/commit/2135254c4bee52b4bcadfbf8b99a896cfd930cca))
### 📚 Documentation
* fix comment in README for Hooks after method ([#1102](https://github.com/open-feature/js-sdk/issues/1102)) ([ba8d1ae](https://github.com/open-feature/js-sdk/commit/ba8d1aeec837cb089cda3499d44ecc505ea0c947))
### 🔄 Refactoring
* improve track interface for providers ([#1100](https://github.com/open-feature/js-sdk/issues/1100)) ([5e5b160](https://github.com/open-feature/js-sdk/commit/5e5b16022122b71760634ac90e3fd962aa831c74))
## [1.3.2](https://github.com/open-feature/js-sdk/compare/web-sdk-v1.3.1...web-sdk-v1.3.2) (2024-11-07)
### 🐛 Bug Fixes
* update OpenFeature core version to 1.5.0 ([#1077](https://github.com/open-feature/js-sdk/issues/1077)) ([a3469b6](https://github.com/open-feature/js-sdk/commit/a3469b6799c3f0d77cb38dcdc708ce615123d7f6))
### 🧹 Chore
* loosen peer dependency requirements, remove some ci automation ([#1080](https://github.com/open-feature/js-sdk/issues/1080)) ([ef3ba21](https://github.com/open-feature/js-sdk/commit/ef3ba2167ac95cd0c6a046d206bd60bbcf84e80c))
### 🚀 Performance
* avoid using exceptions for flow control ([#1074](https://github.com/open-feature/js-sdk/issues/1074)) ([26264d6](https://github.com/open-feature/js-sdk/commit/26264d6d090b2ed31b27d36e71194b9fa911563b))
## [1.3.1](https://github.com/open-feature/js-sdk/compare/web-sdk-v1.3.0...web-sdk-v1.3.1) (2024-10-29)
### 📚 Documentation
* add tracking sections ([#1068](https://github.com/open-feature/js-sdk/issues/1068)) ([e131faf](https://github.com/open-feature/js-sdk/commit/e131faffad9025e9c7194f39558bf3b3cec31807))
## [1.3.0](https://github.com/open-feature/js-sdk/compare/web-sdk-v1.2.4...web-sdk-v1.3.0) (2024-10-29) ## [1.3.0](https://github.com/open-feature/js-sdk/compare/web-sdk-v1.2.4...web-sdk-v1.3.0) (2024-10-29)

View File

@ -16,8 +16,8 @@
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" /> <img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
</a> </a>
<!-- x-release-please-start-version --> <!-- x-release-please-start-version -->
<a href="https://github.com/open-feature/js-sdk/releases/tag/web-sdk-v1.6.1"> <a href="https://github.com/open-feature/js-sdk/releases/tag/web-sdk-v1.3.0">
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v1.6.1&color=blue&style=for-the-badge" /> <img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v1.3.0&color=blue&style=for-the-badge" />
</a> </a>
<!-- x-release-please-end --> <!-- x-release-please-end -->
<br/> <br/>
@ -75,11 +75,7 @@ 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
try { await OpenFeature.setProviderAndWait(new YourProviderOfChoice());
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();
@ -125,11 +121,7 @@ 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
try { await OpenFeature.setProviderAndWait(new MyProvider());
await OpenFeature.setProviderAndWait(new MyProvider());
} catch (error) {
console.error('Failed to initialize provider:', error);
}
``` ```
#### Synchronous #### Synchronous
@ -180,7 +172,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 `onContextChange` method that receives the old and newer contexts. Providers may implement an `onContextChanged` 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.
@ -387,7 +379,7 @@ import type { Hook, HookContext, EvaluationDetails, FlagValue } from "@openfeatu
export class MyHook implements Hook { export class MyHook implements Hook {
after(hookContext: HookContext, evaluationDetails: EvaluationDetails<FlagValue>) { after(hookContext: HookContext, evaluationDetails: EvaluationDetails<FlagValue>) {
// code that runs after flag values are successfully resolved from the provider // code that runs when there's an error during a flag evaluation
} }
} }
``` ```

View File

@ -1,10 +1,8 @@
{ {
"name": "@openfeature/web-sdk", "name": "@openfeature/web-sdk",
"version": "1.6.1", "version": "1.3.0",
"description": "OpenFeature SDK for Web", "description": "OpenFeature SDK for Web",
"main": "./dist/cjs/index.js", "main": "./dist/cjs/index.js",
"unpkg": "dist/global/index.min.js",
"jsdelivr": "dist/global/index.min.js",
"files": [ "files": [
"dist/" "dist/"
], ],
@ -22,14 +20,13 @@
"clean": "shx rm -rf ./dist", "clean": "shx rm -rf ./dist",
"build:web-esm": "esbuild src/index.ts --bundle --external:@openfeature/core --sourcemap --target=es2015 --platform=browser --format=esm --outfile=./dist/esm/index.js --analyze", "build:web-esm": "esbuild src/index.ts --bundle --external:@openfeature/core --sourcemap --target=es2015 --platform=browser --format=esm --outfile=./dist/esm/index.js --analyze",
"build:web-cjs": "esbuild src/index.ts --bundle --external:@openfeature/core --sourcemap --target=es2015 --platform=browser --format=cjs --outfile=./dist/cjs/index.js --analyze", "build:web-cjs": "esbuild src/index.ts --bundle --external:@openfeature/core --sourcemap --target=es2015 --platform=browser --format=cjs --outfile=./dist/cjs/index.js --analyze",
"build:web-global": "esbuild src/index.ts --bundle --sourcemap --target=es2015 --platform=browser --format=iife --outfile=./dist/global/index.js --global-name=OpenFeature --analyze",
"build:web-global:min": "esbuild src/index.ts --bundle --sourcemap --target=es2015 --platform=browser --format=iife --outfile=./dist/global/index.min.js --global-name=OpenFeature --minify --analyze",
"build:rollup-types": "rollup -c ../../rollup.config.mjs", "build:rollup-types": "rollup -c ../../rollup.config.mjs",
"build": "npm run clean && npm run build:web-esm && npm run build:web-cjs && npm run build:web-global && npm run build:web-global:min && npm run build:rollup-types", "build": "npm run clean && npm run build:web-esm && npm run build:web-cjs && npm run build:rollup-types",
"postbuild": "shx cp ./../../package.esm.json ./dist/esm/package.json", "postbuild": "shx cp ./../../package.esm.json ./dist/esm/package.json",
"current-version": "echo $npm_package_version", "current-version": "echo $npm_package_version",
"prepack": "shx cp ./../../LICENSE ./LICENSE", "prepack": "shx cp ./../../LICENSE ./LICENSE",
"publish-if-not-exists": "cp $NPM_CONFIG_USERCONFIG .npmrc && if [ \"$(npm show $npm_package_name@$npm_package_version version)\" = \"$(npm run current-version -s)\" ]; then echo 'already published, skipping'; else npm publish --access public; fi" "publish-if-not-exists": "cp $NPM_CONFIG_USERCONFIG .npmrc && if [ \"$(npm show $npm_package_name@$npm_package_version version)\" = \"$(npm run current-version -s)\" ]; then echo 'already published, skipping'; else npm publish --access public; fi",
"update-core-peer": "npm install --save-peer --save-exact @openfeature/core@$OPENFEATURE_CORE_VERSION"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@ -50,9 +47,9 @@
}, },
"homepage": "https://github.com/open-feature/js-sdk#readme", "homepage": "https://github.com/open-feature/js-sdk#readme",
"peerDependencies": { "peerDependencies": {
"@openfeature/core": "^1.9.0" "@openfeature/core": "1.4.0"
}, },
"devDependencies": { "devDependencies": {
"@openfeature/core": "^1.9.0" "@openfeature/core": "1.5.0"
} }
} }

View File

@ -10,10 +10,7 @@ import type {
Logger, Logger,
TrackingEventDetails, TrackingEventDetails,
OpenFeatureError, OpenFeatureError,
FlagMetadata, ResolutionDetails } from '@openfeature/core';
ResolutionDetails,
EventOptions,
} from '@openfeature/core';
import { import {
ErrorCode, ErrorCode,
ProviderFatalError, ProviderFatalError,
@ -22,13 +19,12 @@ 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';
import type { InternalEventEmitter } from '../../events/internal/internal-event-emitter'; import type { InternalEventEmitter } from '../../events/internal/internal-event-emitter';
import type { Hook } from '../../hooks'; import type { Hook } from '../../hooks';
import type { Provider } from '../../provider'; import type { Provider} from '../../provider';
import { ProviderStatus } from '../../provider'; import { ProviderStatus } from '../../provider';
import type { Client } from './../client'; import type { Client } from './../client';
@ -76,7 +72,7 @@ export class OpenFeatureClient implements Client {
return this.providerStatusAccessor(); return this.providerStatusAccessor();
} }
addHandler(eventType: ProviderEvents, handler: EventHandler, options: EventOptions): void { addHandler(eventType: ProviderEvents, handler: EventHandler): void {
this.emitterAccessor().addHandler(eventType, handler); this.emitterAccessor().addHandler(eventType, handler);
const shouldRunNow = statusMatchesEvent(eventType, this.providerStatus); const shouldRunNow = statusMatchesEvent(eventType, this.providerStatus);
@ -92,12 +88,6 @@ 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 {
@ -193,7 +183,7 @@ export class OpenFeatureClient implements Client {
return this.evaluate<T>(flagKey, this._provider.resolveObjectEvaluation, defaultValue, 'object', options); return this.evaluate<T>(flagKey, this._provider.resolveObjectEvaluation, defaultValue, 'object', options);
} }
track(occurrenceKey: string, occurrenceDetails: TrackingEventDetails = {}): void { track(occurrenceKey: string, occurrenceDetails: TrackingEventDetails): void {
try { try {
this.shortCircuitIfNotReady(); this.shortCircuitIfNotReady();
@ -232,82 +222,83 @@ export class OpenFeatureClient implements Client {
...this.apiContextAccessor(this?.options?.domain), ...this.apiContextAccessor(this?.options?.domain),
}; };
// Create hook context instances for each hook (stable object references for the entire evaluation) // this reference cannot change during the course of evaluation
// This ensures hooks can use WeakMaps with hookContext as keys across lifecycle methods // it may be used as a key in WeakMaps
// NOTE: Uses the reversed order to reduce the number of times we have to calculate the index. const hookContext: Readonly<HookContext> = {
const hookContexts = allHooksReversed.map<HookContext>(() => flagKey,
Object.freeze({ defaultValue,
flagKey, flagValueType: flagType,
defaultValue, clientMetadata: this.metadata,
flagValueType: flagType, providerMetadata: this._provider.metadata,
clientMetadata: this.metadata, context,
providerMetadata: this._provider.metadata, logger: this._logger,
context, };
logger: this._logger,
hookData: new MapHookData(),
}),
);
let evaluationDetails: EvaluationDetails<T>;
try { try {
this.beforeHooks(allHooks, hookContexts, options); this.beforeHooks(allHooks, hookContext, options);
this.shortCircuitIfNotReady(); this.shortCircuitIfNotReady();
// run the referenced resolver, binding the provider. // run the referenced resolver, binding the provider.
const resolution = resolver.call(this._provider, flagKey, defaultValue, context, this._logger); const resolution = resolver.call(this._provider, flagKey, defaultValue, context, this._logger);
const resolutionDetails = { const evaluationDetails = {
...resolution, ...resolution,
flagMetadata: Object.freeze(resolution.flagMetadata ?? {}), flagMetadata: Object.freeze(resolution.flagMetadata ?? {}),
flagKey, flagKey,
}; };
if (resolutionDetails.errorCode) { if (evaluationDetails.errorCode) {
const err = instantiateErrorByErrorCode(resolutionDetails.errorCode, resolutionDetails.errorMessage); throw instantiateErrorByErrorCode(evaluationDetails.errorCode);
this.errorHooks(allHooksReversed, hookContexts, err, options);
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err, resolutionDetails.flagMetadata);
} else {
this.afterHooks(allHooksReversed, hookContexts, resolutionDetails, options);
evaluationDetails = resolutionDetails;
} }
this.afterHooks(allHooksReversed, hookContext, evaluationDetails, options);
return evaluationDetails;
} catch (err: unknown) { } catch (err: unknown) {
this.errorHooks(allHooksReversed, hookContexts, err, options); const errorMessage: string = (err as Error)?.message;
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err); const errorCode: ErrorCode = (err as OpenFeatureError)?.code || ErrorCode.GENERAL;
this.errorHooks(allHooksReversed, hookContext, err, options);
return {
errorCode,
errorMessage,
value: defaultValue,
reason: StandardResolutionReasons.ERROR,
flagMetadata: Object.freeze({}),
flagKey,
};
} finally {
this.finallyHooks(allHooksReversed, hookContext, options);
} }
this.finallyHooks(allHooksReversed, hookContexts, evaluationDetails, options);
return evaluationDetails;
} }
private beforeHooks(hooks: Hook[], hookContexts: HookContext[], options: FlagEvaluationOptions) { private beforeHooks(hooks: Hook[], hookContext: HookContext, options: FlagEvaluationOptions) {
for (const [index, hook] of hooks.entries()) { Object.freeze(hookContext);
const hookContextIndex = hooks.length - 1 - index; // reverse index for before hooks Object.freeze(hookContext.context);
const hookContext = hookContexts[hookContextIndex];
Object.freeze(hookContext); for (const hook of hooks) {
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[],
hookContexts: HookContext[], hookContext: HookContext,
evaluationDetails: EvaluationDetails<FlagValue>, evaluationDetails: EvaluationDetails<FlagValue>,
options: FlagEvaluationOptions, options: FlagEvaluationOptions,
) { ) {
// run "after" hooks sequentially // run "after" hooks sequentially
for (const [index, hook] of hooks.entries()) { for (const hook of hooks) {
const hookContext = hookContexts[index];
hook?.after?.(hookContext, evaluationDetails, options.hookHints); hook?.after?.(hookContext, evaluationDetails, options.hookHints);
} }
} }
private errorHooks(hooks: Hook[], hookContexts: HookContext[], err: unknown, options: FlagEvaluationOptions) { private errorHooks(hooks: Hook[], hookContext: HookContext, err: unknown, options: FlagEvaluationOptions) {
// run "error" hooks sequentially // run "error" hooks sequentially
for (const [index, hook] of hooks.entries()) { for (const hook of hooks) {
try { try {
const hookContext = hookContexts[index];
hook?.error?.(hookContext, err, options.hookHints); hook?.error?.(hookContext, err, options.hookHints);
} catch (err) { } catch (err) {
this._logger.error(`Unhandled error during 'error' hook: ${err}`); this._logger.error(`Unhandled error during 'error' hook: ${err}`);
@ -319,17 +310,11 @@ export class OpenFeatureClient implements Client {
} }
} }
private finallyHooks( private finallyHooks(hooks: Hook[], hookContext: HookContext, options: FlagEvaluationOptions) {
hooks: Hook[],
hookContexts: HookContext[],
evaluationDetails: EvaluationDetails<FlagValue>,
options: FlagEvaluationOptions,
) {
// run "finally" hooks sequentially // run "finally" hooks sequentially
for (const [index, hook] of hooks.entries()) { for (const hook of hooks) {
try { try {
const hookContext = hookContexts[index]; hook?.finally?.(hookContext, 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}`);
if (err instanceof Error) { if (err instanceof Error) {
@ -356,23 +341,4 @@ export class OpenFeatureClient implements Client {
throw new ProviderFatalError('provider is in an irrecoverable error state'); throw new ProviderFatalError('provider is in an irrecoverable error state');
} }
} }
private getErrorEvaluationDetails<T extends FlagValue>(
flagKey: string,
defaultValue: T,
err: unknown,
flagMetadata: FlagMetadata = {},
): EvaluationDetails<T> {
const errorMessage: string = (err as Error)?.message;
const errorCode: ErrorCode = (err as OpenFeatureError)?.code || ErrorCode.GENERAL;
return {
errorCode,
errorMessage,
value: defaultValue,
reason: StandardResolutionReasons.ERROR,
flagMetadata: Object.freeze(flagMetadata),
flagKey,
};
}
} }

View File

@ -1,3 +1,3 @@
import type { BaseHook, FlagValue } from '@openfeature/core'; import type { BaseHook, FlagValue } from '@openfeature/core';
export type Hook<TData = Record<string, unknown>> = BaseHook<FlagValue, TData, void, void>; export type Hook = BaseHook<FlagValue, void, void>;

View File

@ -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 {Error} If the provider throws an exception during initialization. * @throws Uncaught exceptions thrown by the provider 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 {Error} If the provider throws an exception during initialization. * @throws Uncaught exceptions thrown by the provider 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 {Error} If the provider throws an exception during initialization. * @throws Uncaught exceptions thrown by the provider 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 {Error} If the provider throws an exception during initialization. * @throws Uncaught exceptions thrown by the provider 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,27 +205,6 @@ 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.
@ -346,9 +325,9 @@ export class OpenFeatureAPI
} }
/** /**
* A factory function for creating new domain-scoped OpenFeature clients. Clients * A factory function for creating new named OpenFeature clients. Clients can contain
* can contain their own state (e.g. logger, hook, context). Multiple domains * their own state (e.g. logger, hook, context). Multiple clients can be used
* can be used to segment feature flag configuration. * 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.

View File

@ -655,18 +655,6 @@ describe('OpenFeatureClient', () => {
}).not.toThrow(); }).not.toThrow();
}); });
it('provide empty tracking details to provider if not supplied in call', async () => {
await OpenFeature.setProviderAndWait({ ...MOCK_PROVIDER });
const client = OpenFeature.getClient();
client.track(eventName);
expect(MOCK_PROVIDER.track).toHaveBeenCalledWith(
eventName,
expect.any(Object),
expect.any(Object),
);
});
it('should no-op and not throw if provider throws', async () => { it('should no-op and not throw if provider throws', async () => {
await OpenFeature.setProviderAndWait({ await OpenFeature.setProviderAndWait({
...MOCK_PROVIDER, ...MOCK_PROVIDER,

Some files were not shown because too many files have changed in this diff Show More