Compare commits

..

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

87 changed files with 9411 additions and 9941 deletions

View File

@ -29,6 +29,6 @@ jobs:
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 18
registry-url: "https://registry.npmjs.org" registry-url: "https://registry.npmjs.org"
cache: 'npm' cache: 'npm'

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,9 @@ jobs:
strategy: strategy:
matrix: matrix:
node-version: node-version:
- 18.x
- 20.x - 20.x
- 22.x - 22.x
- 24.x
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -37,11 +37,8 @@ jobs:
- name: Lint - name: Lint
run: npm run lint run: npm run lint
- name: Test Jest Projects - name: Test
run: npm run test:jest run: npm run test
- name: Test Angular SDK
run: npm run test:angular
codecov-and-docs: codecov-and-docs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -50,7 +47,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 +69,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,7 +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: permissions:
id-token: write id-token: write
contents: write contents: write
@ -65,7 +64,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

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.10",
"packages/web": "1.6.1", "packages/angular": "0.0.1-experimental",
"packages/server": "1.19.0", "packages/web": "1.4.0",
"packages/shared": "1.9.0", "packages/server": "1.17.1",
"packages/angular/projects/angular-sdk": "0.0.16" "packages/shared": "1.7.0",
"packages/angular/projects/angular-sdk": "0.0.9-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

@ -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

16086
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",
@ -36,15 +31,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": "^10.0.0",
"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 +57,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": "^19.0.0",
"@angular-eslint/eslint-plugin": "^20.1.1", "@angular-eslint/builder": "18.4.3",
"@angular-eslint/eslint-plugin-template": "^20.1.1", "@angular-eslint/eslint-plugin": "18.4.3",
"@angular-eslint/schematics": "^20.1.1", "@angular-eslint/eslint-plugin-template": "18.4.3",
"@angular-eslint/template-parser": "^20.1.1", "@angular-eslint/schematics": "18.4.3",
"@angular/animations": "^20.1.1", "@angular-eslint/template-parser": "18.4.3",
"@angular/build": "^20.1.1", "@angular/animations": "^19.0.0",
"@angular/cli": "^20.1.1", "@angular/cli": "^19.0.0",
"@angular/common": "^20.1.1", "@angular/common": "^19.0.0",
"@angular/compiler": "^20.1.1", "@angular/compiler": "^19.0.0",
"@angular/compiler-cli": "^20.1.1", "@angular/compiler-cli": "^19.0.0",
"@angular/core": "^20.1.1", "@angular/core": "^19.0.0",
"@angular/forms": "^20.1.1", "@angular/forms": "^19.0.0",
"@angular/platform-browser": "^20.1.1", "@angular/platform-browser": "^19.0.0",
"@angular/platform-browser-dynamic": "^20.1.1", "@angular/platform-browser-dynamic": "^19.0.0",
"@angular/router": "^20.1.1", "@angular/router": "^19.0.0",
"@typescript-eslint/eslint-plugin": "7.18.0", "@typescript-eslint/eslint-plugin": "7.18.0",
"@typescript-eslint/parser": "7.18.0", "@typescript-eslint/parser": "7.18.0",
"@vitest/browser": "^3.2.4",
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"jsdom": "^26.1.0", "jest-preset-angular": "^14.2.4",
"ng-packagr": "^20.1.0", "ng-packagr": "^19.0.0",
"playwright": "^1.53.2",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0", "tslib": "^2.3.0",
"typescript": "^5.8.3", "typescript": "^5.5.4",
"vitest": "^3.2.4",
"zone.js": "~0.15.0" "zone.js": "~0.15.0"
} }
} }

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,61 +1,6 @@
# Changelog # Changelog
## [0.0.16](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.15...angular-sdk-v0.0.16) (2025-07-25)
### ✨ New Features
* support Angular 20 ([#1220](https://github.com/open-feature/js-sdk/issues/1220)) ([aa232a9](https://github.com/open-feature/js-sdk/commit/aa232a9d6a8dfa416380ccdecd71843d3e361048))
## [0.0.15](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.14...angular-sdk-v0.0.15) (2025-05-27)
### 🐛 Bug Fixes
* **angular:** update docs ([#1200](https://github.com/open-feature/js-sdk/issues/1200)) ([b6ea588](https://github.com/open-feature/js-sdk/commit/b6ea5884f2ab9f4f94c8b258c4cf7268ea6dbeb8))
## [0.0.14](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.13...angular-sdk-v0.0.14) (2025-05-25)
### 🐛 Bug Fixes
* **angular:** add license and url field to package.json ([b2784f5](https://github.com/open-feature/js-sdk/commit/b2784f53b85a11c58abb8e2a0f87a31890885c54))
## [0.0.13](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.12...angular-sdk-v0.0.13) (2025-04-20)
### 📚 Documentation
* fix readme typo ([#1174](https://github.com/open-feature/js-sdk/issues/1174)) ([21a32ec](https://github.com/open-feature/js-sdk/commit/21a32ec92ecde9ec43c9d72b5921035af13448d1))
## [0.0.12](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.11...angular-sdk-v0.0.12) (2025-04-11)
### ✨ New Features
* **angular:** add docs for setting evaluation context in angular ([#1170](https://github.com/open-feature/js-sdk/issues/1170)) ([24f1b23](https://github.com/open-feature/js-sdk/commit/24f1b230bf1d57971a336ac21b9ee46e8baf0cab))
## [0.0.11](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.10...angular-sdk-v0.0.11) (2025-04-11)
### ✨ New Features
* **angular:** add option for initial context injection ([aafdb43](https://github.com/open-feature/js-sdk/commit/aafdb4382f113f96a649f5fc0cecadb4178ada67))
## [0.0.10](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.9-experimental...angular-sdk-v0.0.10) (2025-02-13)
### 🧹 Chore
* **angular:** update angular package to a non-experimental version ([#1147](https://github.com/open-feature/js-sdk/issues/1147)) ([5272f76](https://github.com/open-feature/js-sdk/commit/5272f76c4075ebbd21f9b24dacac8f2d22e31ca9)), closes [#1110](https://github.com/open-feature/js-sdk/issues/1110)
* update sdk peer ([#1142](https://github.com/open-feature/js-sdk/issues/1142)) ([8bb6206](https://github.com/open-feature/js-sdk/commit/8bb620601e2b8dc7b62d717169b585bd1c886996))
## [0.0.9-experimental](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.8-experimental...angular-sdk-v0.0.9-experimental) (2024-11-21) ## [0.0.9-experimental](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.8-experimental...angular-sdk-v0.0.9-experimental) (2024-11-21)

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.9-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.9-experimental&color=blue&style=for-the-badge" />
</a> </a>
<!-- x-release-please-end --> <!-- x-release-please-end -->
<br/> <br/>
@ -59,7 +59,6 @@ In addition to the features provided by the [web sdk](https://openfeature.dev/do
- [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?

View File

@ -1,34 +1,29 @@
{ {
"name": "@openfeature/angular-sdk", "name": "@openfeature/angular-sdk",
"version": "0.0.16", "version": "0.0.9-experimental",
"description": "OpenFeature Angular SDK", "description": "OpenFeature Angular SDK",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/open-feature/js-sdk.git" "url": "git+https://github.com/open-feature/js-sdk.git"
}, },
"license": "Apache-2.0",
"bugs": {
"url": "https://github.com/open-feature/js-sdk/issues"
},
"homepage": "https://github.com/open-feature/js-sdk#readme",
"scripts": { "scripts": {
"current-published-version": "npm show $npm_package_name@$npm_package_version version", "current-published-version": "npm show $npm_package_name@$npm_package_version version",
"current-version": "echo $npm_package_version", "current-version": "echo $npm_package_version",
"prepack": "shx cp ./../../../../LICENSE ./LICENSE" "prepack": "shx cp ./../../../../LICENSE ./LICENSE"
}, },
"peerDependencies": { "peerDependencies": {
"@angular/common": "^16.2.12 || ^17.3.0 || ^18.0.0 || ^19.0.0 || ^20.0.0", "@angular/common": "^16.2.12 || ^17.3.0 || ^18.0.0 || ^19.0.0",
"@angular/core": "^16.2.12 || ^17.3.0 || ^18.0.0 || ^19.0.0 || ^20.0.0", "@angular/core": "^16.2.12 || ^17.3.0 || ^18.0.0 || ^19.0.0",
"@openfeature/web-sdk": "^1.4.1" "@openfeature/web-sdk": "^1.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": "^19.0.0",
"@angular/core": "^20.1.2" "@angular/core": "^19.0.0"
}, },
"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,5 @@
import 'jest-preset-angular/setup-jest';
import { TestBed } from '@angular/core/testing';
import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing';
TestBed.initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting());

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();
@ -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();
@ -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,32 +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) ## [0.4.10](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.4.9...react-sdk-v0.4.10) (2024-12-18)

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.10">
<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.10&color=blue&style=for-the-badge" />
</a> </a>
<!-- x-release-please-end --> <!-- x-release-please-end -->
<br/> <br/>

View File

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

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,
useProviderOptions,
} from '../internal';
import type { ReactFlagEvaluationNoSuspenseOptions, ReactFlagEvaluationOptions } from '../options'; import type { ReactFlagEvaluationNoSuspenseOptions, ReactFlagEvaluationOptions } from '../options';
import { DEFAULT_OPTIONS, isEqual, normalizeOptions, suspendUntilReady, useProviderOptions } from '../internal';
import { useOpenFeatureClient } from '../provider/use-open-feature-client'; import { useOpenFeatureClient } from '../provider/use-open-feature-client';
import { useOpenFeatureClientStatus } from '../provider/use-open-feature-client-status'; import { useOpenFeatureClientStatus } from '../provider/use-open-feature-client-status';
import { useOpenFeatureProvider } from '../provider/use-open-feature-provider';
import type { FlagQuery } from '../query'; import type { FlagQuery } from '../query';
import { HookFlagQuery } from '../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).
@ -286,41 +280,27 @@ 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);
useEffect(() => { useEffect(() => {
evaluationDetailsRef.current = evaluationDetails; evaluationDetailsRef.current = evaluationDetails;
}, [evaluationDetails]); }, [evaluationDetails]);
const updateEvaluationDetailsCallback = useCallback(() => { const updateEvaluationDetailsCallback = () => {
const updatedEvaluationDetails = resolver(client).call(client, flagKey, defaultValue, options); const updatedEvaluationDetails = resolver(client).call(client, flagKey, defaultValue, options);
/** /**
@ -331,47 +311,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

@ -5,8 +5,7 @@ import { normalizeOptions } from '.';
/** /**
* The underlying React context. * The underlying React context.
* * DO NOT EXPORT PUBLICLY
* **DO NOT EXPORT PUBLICLY**
* @internal * @internal
*/ */
export const Context = React.createContext< export const Context = React.createContext<
@ -15,8 +14,7 @@ export const Context = React.createContext<
/** /**
* Get a normalized copy of the options used for this OpenFeatureProvider, see {@link normalizeOptions}. * Get a normalized copy of the options used for this OpenFeatureProvider, see {@link normalizeOptions}.
* * DO NOT EXPORT PUBLICLY
* **DO NOT EXPORT PUBLICLY**
* @internal * @internal
* @returns {NormalizedOptions} normalized options the defaulted options, not defaulted or normalized. * @returns {NormalizedOptions} normalized options the defaulted options, not defaulted or normalized.
*/ */

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,56 +1,21 @@
import type { Client, Provider } from '@openfeature/web-sdk'; import type { Client} from '@openfeature/web-sdk';
import { NOOP_PROVIDER, ProviderEvents } from '@openfeature/web-sdk'; import { ProviderEvents } from '@openfeature/web-sdk';
import { use } from './use';
/**
* A weak map is used to store the global suspense status for each provider. It's
* important for this to be global to avoid rerender loops. Using useRef won't
* work because the value isn't preserved when a promise is thrown in a component,
* which is how suspense operates.
*/
const globalProviderSuspenseStatus = new WeakMap<Provider, Promise<unknown>>();
/** /**
* Suspends until the client is ready to evaluate feature flags. * Suspends until the client is ready to evaluate feature flags.
* * DO NOT EXPORT PUBLICLY
* **DO NOT EXPORT PUBLICLY** * @param {Client} client OpenFeature client
* @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) { export function suspendUntilReady(client: Client): Promise<void> {
const statusPromiseRef = globalProviderSuspenseStatus.get(provider); let resolve: (value: unknown) => void;
if (!statusPromiseRef) { let reject: () => void;
// Noop provider is never ready, so we resolve immediately throw new Promise((_resolve, _reject) => {
const statusPromise = provider !== NOOP_PROVIDER ? isProviderReady(client) : Promise.resolve(); resolve = _resolve;
globalProviderSuspenseStatus.set(provider, statusPromise); reject = _reject;
// Use will throw the promise and React will trigger a rerender when it's resolved client.addHandler(ProviderEvents.Ready, resolve);
use(statusPromise); client.addHandler(ProviderEvents.Error, reject);
} else { }).finally(() => {
// Reuse the existing promise, use won't rethrow if the promise has settled. client.removeHandler(ProviderEvents.Ready, resolve);
use(statusPromiseRef); client.removeHandler(ProviderEvents.Ready, reject);
}
}
/**
* 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

@ -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

@ -10,18 +10,22 @@ import { ProviderEvents } from '@openfeature/web-sdk';
export function useOpenFeatureClientStatus(): ProviderStatus { export function useOpenFeatureClientStatus(): ProviderStatus {
const client = useOpenFeatureClient(); const client = useOpenFeatureClient();
const [status, setStatus] = useState<ProviderStatus>(client.providerStatus); const [status, setStatus] = useState<ProviderStatus>(client.providerStatus);
const controller = new AbortController();
useEffect(() => { useEffect(() => {
const updateStatus = () => setStatus(client.providerStatus); const updateStatus = () => setStatus(client.providerStatus);
client.addHandler(ProviderEvents.ConfigurationChanged, updateStatus, { 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 '../internal';
import { type Client } from '@openfeature/web-sdk'; import type { Client } from '@openfeature/web-sdk';
import { MissingContextError } from '../internal/errors';
/** /**
* Get the {@link Client} instance for this OpenFeatureProvider context. * Get the {@link Client} instance for this OpenFeatureProvider context.
@ -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

@ -2,8 +2,7 @@ import { ProviderStatus } from '@openfeature/web-sdk';
import { useOpenFeatureClient } from './use-open-feature-client'; import { useOpenFeatureClient } from './use-open-feature-client';
import { useOpenFeatureClientStatus } from './use-open-feature-client-status'; import { useOpenFeatureClientStatus } from './use-open-feature-client-status';
import type { ReactFlagEvaluationOptions } from '../options'; import type { ReactFlagEvaluationOptions } from '../options';
import { DEFAULT_OPTIONS, useProviderOptions, normalizeOptions, suspendUntilInitialized } from '../internal'; import { DEFAULT_OPTIONS, useProviderOptions, normalizeOptions, suspendUntilReady } from '../internal';
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

@ -6,7 +6,12 @@ import '@testing-library/jest-dom'; // see: https://testing-library.com/docs/rea
import { act, render, renderHook, screen, waitFor } from '@testing-library/react'; import { act, render, renderHook, screen, waitFor } from '@testing-library/react';
import * as React from 'react'; import * as React from 'react';
import { startTransition, useState } from 'react'; import { startTransition, useState } from 'react';
import type { EvaluationContext, EvaluationDetails, EventContext, Hook } from '../src/'; import type {
EvaluationContext,
EvaluationDetails,
EventContext,
Hook
} from '../src/';
import { import {
ErrorCode, ErrorCode,
InMemoryProvider, InMemoryProvider,
@ -22,18 +27,15 @@ import {
useObjectFlagValue, useObjectFlagValue,
useStringFlagDetails, useStringFlagDetails,
useStringFlagValue, useStringFlagValue,
useSuspenseFlag, useSuspenseFlag
} from '../src/'; } from '../src/';
import { HookFlagQuery } from '../src/internal/hook-flag-query'; import { HookFlagQuery } from '../src/evaluation/hook-flag-query';
import { TestingProvider } from './test.utils'; import { TestingProvider } from './test.utils';
// custom provider to have better control over the emitted events // custom provider to have better control over the emitted events
class CustomEventInMemoryProvider extends InMemoryProvider { class CustomEventInMemoryProvider extends InMemoryProvider {
putConfigurationWithCustomEvent(
flagConfiguration: FlagConfiguration, putConfigurationWithCustomEvent(flagConfiguration: FlagConfiguration, event: ProviderEmittableEvents, eventContext: EventContext) {
event: ProviderEmittableEvents,
eventContext: EventContext,
) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
this['_flagConfiguration'] = { ...flagConfiguration }; // private access hack this['_flagConfiguration'] = { ...flagConfiguration }; // private access hack
this.events.emit(event, eventContext); this.events.emit(event, eventContext);
@ -393,8 +395,7 @@ describe('evaluation', () => {
expect(screen.queryByTestId('render-count')).toHaveTextContent('1'); expect(screen.queryByTestId('render-count')).toHaveTextContent('1');
await act(async () => { await act(async () => {
await rerenderProvider.putConfigurationWithCustomEvent( await rerenderProvider.putConfigurationWithCustomEvent({
{
...FLAG_CONFIG, ...FLAG_CONFIG,
[BOOL_FLAG_KEY]: { [BOOL_FLAG_KEY]: {
...FLAG_CONFIG[BOOL_FLAG_KEY], ...FLAG_CONFIG[BOOL_FLAG_KEY],
@ -402,10 +403,8 @@ describe('evaluation', () => {
defaultVariant: 'off', defaultVariant: 'off',
}, },
// if the flagsChanged is empty, we know nothing has changed, so we don't bother diffing // if the flagsChanged is empty, we know nothing has changed, so we don't bother diffing
}, }, ClientProviderEvents.ConfigurationChanged, { flagsChanged: [] });
ClientProviderEvents.ConfigurationChanged,
{ flagsChanged: [] },
);
}); });
expect(screen.queryByTestId('render-count')).toHaveTextContent('1'); expect(screen.queryByTestId('render-count')).toHaveTextContent('1');
@ -421,8 +420,7 @@ describe('evaluation', () => {
expect(screen.queryByTestId('render-count')).toHaveTextContent('1'); expect(screen.queryByTestId('render-count')).toHaveTextContent('1');
await act(async () => { await act(async () => {
await rerenderProvider.putConfigurationWithCustomEvent( await rerenderProvider.putConfigurationWithCustomEvent({
{
...FLAG_CONFIG, ...FLAG_CONFIG,
[BOOL_FLAG_KEY]: { [BOOL_FLAG_KEY]: {
...FLAG_CONFIG[BOOL_FLAG_KEY], ...FLAG_CONFIG[BOOL_FLAG_KEY],
@ -430,10 +428,8 @@ describe('evaluation', () => {
defaultVariant: 'off', defaultVariant: 'off',
}, },
// if the flagsChanged is falsy, we don't know what flags changed - so we attempt to diff everything // if the flagsChanged is falsy, we don't know what flags changed - so we attempt to diff everything
}, }, ClientProviderEvents.ConfigurationChanged, { flagsChanged: undefined });
ClientProviderEvents.ConfigurationChanged,
{ flagsChanged: undefined },
);
}); });
expect(screen.queryByTestId('render-count')).toHaveTextContent('2'); expect(screen.queryByTestId('render-count')).toHaveTextContent('2');
@ -577,41 +573,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 +889,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', () => {

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,35 +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) ## [1.17.1](https://github.com/open-feature/js-sdk/compare/server-sdk-v1.17.0...server-sdk-v1.17.1) (2025-02-07)

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.17.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.17.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();

View File

@ -1,6 +1,6 @@
{ {
"name": "@openfeature/server-sdk", "name": "@openfeature/server-sdk",
"version": "1.19.0", "version": "1.17.1",
"description": "OpenFeature SDK for JavaScript", "description": "OpenFeature SDK for JavaScript",
"main": "./dist/cjs/index.js", "main": "./dist/cjs/index.js",
"files": [ "files": [
@ -45,12 +45,12 @@
}, },
"homepage": "https://github.com/open-feature/js-sdk#readme", "homepage": "https://github.com/open-feature/js-sdk#readme",
"engines": { "engines": {
"node": ">=20" "node": ">=18"
}, },
"peerDependencies": { "peerDependencies": {
"@openfeature/core": "^1.9.0" "@openfeature/core": "^1.7.0"
}, },
"devDependencies": { "devDependencies": {
"@openfeature/core": "^1.9.0" "@openfeature/core": "^1.7.0"
} }
} }

View File

@ -12,7 +12,6 @@ import type {
OpenFeatureError, OpenFeatureError,
FlagMetadata, FlagMetadata,
ResolutionDetails, ResolutionDetails,
EventOptions,
} from '@openfeature/core'; } from '@openfeature/core';
import { import {
ErrorCode, ErrorCode,
@ -22,7 +21,6 @@ import {
StandardResolutionReasons, StandardResolutionReasons,
instantiateErrorByErrorCode, instantiateErrorByErrorCode,
statusMatchesEvent, statusMatchesEvent,
MapHookData,
} from '@openfeature/core'; } from '@openfeature/core';
import type { FlagEvaluationOptions } from '../../evaluation'; import type { FlagEvaluationOptions } from '../../evaluation';
import type { ProviderEvents } from '../../events'; import type { ProviderEvents } from '../../events';
@ -81,7 +79,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 +95,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) {
@ -277,11 +269,9 @@ export class OpenFeatureClient implements Client {
const mergedContext = this.mergeContexts(invocationContext); const mergedContext = this.mergeContexts(invocationContext);
// Create hook context instances for each hook (stable object references for the entire evaluation) // this reference cannot change during the course of evaluation
// This ensures hooks can use WeakMaps with hookContext as keys across lifecycle methods // it may be used as a key in WeakMaps
// NOTE: Uses the reversed order to reduce the number of times we have to calculate the index. const hookContext: Readonly<HookContext> = {
const hookContexts = allHooksReversed.map<HookContext>(() =>
Object.freeze({
flagKey, flagKey,
defaultValue, defaultValue,
flagValueType: flagType, flagValueType: flagType,
@ -289,14 +279,12 @@ export class OpenFeatureClient implements Client {
providerMetadata: this._provider.metadata, providerMetadata: this._provider.metadata,
context: mergedContext, context: mergedContext,
logger: this._logger, logger: this._logger,
hookData: new MapHookData(), };
}),
);
let evaluationDetails: EvaluationDetails<T>; let evaluationDetails: EvaluationDetails<T>;
try { try {
const frozenContext = await this.beforeHooks(allHooks, hookContexts, mergedContext, options); const frozenContext = await this.beforeHooks(allHooks, hookContext, options);
this.shortCircuitIfNotReady(); this.shortCircuitIfNotReady();
@ -311,71 +299,53 @@ export class OpenFeatureClient implements Client {
if (resolutionDetails.errorCode) { if (resolutionDetails.errorCode) {
const err = instantiateErrorByErrorCode(resolutionDetails.errorCode, resolutionDetails.errorMessage); const err = instantiateErrorByErrorCode(resolutionDetails.errorCode, resolutionDetails.errorMessage);
await this.errorHooks(allHooksReversed, hookContexts, err, options); await this.errorHooks(allHooksReversed, hookContext, err, options);
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err, resolutionDetails.flagMetadata); evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err, resolutionDetails.flagMetadata);
} else { } else {
await this.afterHooks(allHooksReversed, hookContexts, resolutionDetails, options); await this.afterHooks(allHooksReversed, hookContext, resolutionDetails, options);
evaluationDetails = resolutionDetails; evaluationDetails = resolutionDetails;
} }
} catch (err: unknown) { } catch (err: unknown) {
await this.errorHooks(allHooksReversed, hookContexts, err, options); await this.errorHooks(allHooksReversed, hookContext, err, options);
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err); evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err);
} }
await this.finallyHooks(allHooksReversed, hookContexts, evaluationDetails, options); await this.finallyHooks(allHooksReversed, hookContext, evaluationDetails, options);
return evaluationDetails; return evaluationDetails;
} }
private async beforeHooks( private async beforeHooks(hooks: Hook[], hookContext: HookContext, options: FlagEvaluationOptions) {
hooks: Hook[], for (const hook of hooks) {
hookContexts: HookContext[], // freeze the hookContext
mergedContext: EvaluationContext, Object.freeze(hookContext);
options: FlagEvaluationOptions,
) {
let accumulatedContext = mergedContext;
for (const [index, hook] of hooks.entries()) { // use Object.assign to avoid modification of frozen hookContext
const hookContextIndex = hooks.length - 1 - index; // reverse index for before hooks Object.assign(hookContext.context, {
const hookContext = hookContexts[hookContextIndex]; ...hookContext.context,
...(await hook?.before?.(hookContext, Object.freeze(options.hookHints))),
// Update the context on the stable hook context object });
Object.assign(hookContext.context, accumulatedContext);
const hookResult = await hook?.before?.(hookContext, Object.freeze(options.hookHints));
if (hookResult) {
accumulatedContext = {
...accumulatedContext,
...hookResult,
};
for (let i = 0; i < hooks.length; i++) {
Object.assign(hookContexts[hookContextIndex].context, accumulatedContext);
}
}
} }
// after before hooks, freeze the EvaluationContext. // after before hooks, freeze the EvaluationContext.
return Object.freeze(accumulatedContext); return Object.freeze(hookContext.context);
} }
private async afterHooks( private async afterHooks(
hooks: Hook[], hooks: Hook[],
hookContexts: HookContext[], hookContext: HookContext,
evaluationDetails: EvaluationDetails<FlagValue>, evaluationDetails: EvaluationDetails<FlagValue>,
options: FlagEvaluationOptions, options: FlagEvaluationOptions,
) { ) {
// run "after" hooks sequentially // run "after" hooks sequentially
for (const [index, hook] of hooks.entries()) { for (const hook of hooks) {
const hookContext = hookContexts[index];
await hook?.after?.(hookContext, evaluationDetails, options.hookHints); await hook?.after?.(hookContext, evaluationDetails, options.hookHints);
} }
} }
private async errorHooks(hooks: Hook[], hookContexts: HookContext[], err: unknown, options: FlagEvaluationOptions) { private async errorHooks(hooks: Hook[], hookContext: HookContext, err: unknown, options: FlagEvaluationOptions) {
// run "error" hooks sequentially // run "error" hooks sequentially
for (const [index, hook] of hooks.entries()) { for (const hook of hooks) {
try { try {
const hookContext = hookContexts[index];
await hook?.error?.(hookContext, err, options.hookHints); await hook?.error?.(hookContext, err, options.hookHints);
} catch (err) { } catch (err) {
this._logger.error(`Unhandled error during 'error' hook: ${err}`); this._logger.error(`Unhandled error during 'error' hook: ${err}`);
@ -389,14 +359,13 @@ export class OpenFeatureClient implements Client {
private async finallyHooks( private async finallyHooks(
hooks: Hook[], hooks: Hook[],
hookContexts: HookContext[], hookContext: HookContext,
evaluationDetails: EvaluationDetails<FlagValue>, evaluationDetails: EvaluationDetails<FlagValue>,
options: FlagEvaluationOptions, options: FlagEvaluationOptions,
) { ) {
// run "finally" hooks sequentially // run "finally" hooks sequentially
for (const [index, hook] of hooks.entries()) { for (const hook of hooks) {
try { try {
const hookContext = hookContexts[index];
await hook?.finally?.(hookContext, evaluationDetails, options.hookHints); await hook?.finally?.(hookContext, evaluationDetails, options.hookHints);
} catch (err) { } catch (err) {
this._logger.error(`Unhandled error during 'finally' hook: ${err}`); this._logger.error(`Unhandled error during 'finally' hook: ${err}`);

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

@ -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

@ -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,41 +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) ## [1.7.0](https://github.com/open-feature/js-sdk/compare/core-v1.6.0...core-v1.7.0) (2025-02-07)

View File

@ -1,6 +1,6 @@
{ {
"name": "@openfeature/core", "name": "@openfeature/core",
"version": "1.9.0", "version": "1.7.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,7 +28,7 @@ export interface BaseHook<
* @param error * @param error
* @param hookHints * @param hookHints
*/ */
error?(hookContext: Readonly<HookContext<T, TData>>, error: unknown, hookHints?: HookHints): HooksReturn; error?(hookContext: Readonly<HookContext<T>>, error: unknown, hookHints?: HookHints): HooksReturn;
/** /**
* Runs after all other hook stages, regardless of success or error. * Runs after all other hook stages, regardless of success or error.
@ -42,9 +37,8 @@ export interface BaseHook<
* @param hookHints * @param hookHints
*/ */
finally?( finally?(
hookContext: Readonly<HookContext<T, TData>>, hookContext: Readonly<HookContext<T>>,
evaluationDetails: EvaluationDetails<T>, evaluationDetails: EvaluationDetails<T>,
hookHints?: HookHints, hookHints?: HookHints,
): HooksReturn; ): HooksReturn;
} }

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,10 +7,11 @@ 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';
@ -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

@ -20,14 +20,6 @@ export const TelemetryAttribute = {
* - example: `flag_not_found` * - example: `flag_not_found`
*/ */
ERROR_CODE: 'error.type', ERROR_CODE: 'error.type',
/**
* A message explaining the nature of an error occurring during flag evaluation.
*
* - type: `string`
* - requirement level: `recommended`
* - example: `Flag not found`
*/
ERROR_MESSAGE: 'error.message',
/** /**
* A semantic identifier for an evaluated flag value. * A semantic identifier for an evaluated flag value.
* *
@ -36,16 +28,7 @@ export const TelemetryAttribute = {
* - condition: variant is defined on the evaluation details * - condition: variant is defined on the evaluation details
* - example: `blue`; `on`; `true` * - example: `blue`; `on`; `true`
*/ */
VARIANT: 'feature_flag.result.variant', VARIANT: 'feature_flag.variant',
/**
* The evaluated value of the feature flag.
*
* - type: `undefined`
* - requirement level: `conditionally required`
* - condition: variant is not defined on the evaluation details
* - example: `#ff0000`; `1`; `true`
*/
VALUE: 'feature_flag.result.value',
/** /**
* The unique identifier for the flag evaluation context. For example, the targeting key. * The unique identifier for the flag evaluation context. For example, the targeting key.
* *
@ -54,6 +37,14 @@ export const TelemetryAttribute = {
* - example: `5157782b-2203-4c80-a857-dbbd5e7761db` * - example: `5157782b-2203-4c80-a857-dbbd5e7761db`
*/ */
CONTEXT_ID: 'feature_flag.context.id', CONTEXT_ID: 'feature_flag.context.id',
/**
* A message explaining the nature of an error occurring during flag evaluation.
*
* - type: `string`
* - requirement level: `recommended`
* - example: `Flag not found`
*/
ERROR_MESSAGE: 'feature_flag.evaluation.error.message',
/** /**
* The reason code which shows how a feature flag value was determined. * The reason code which shows how a feature flag value was determined.
* *
@ -61,7 +52,7 @@ export const TelemetryAttribute = {
* - requirement level: `recommended` * - requirement level: `recommended`
* - example: `targeting_match` * - example: `targeting_match`
*/ */
REASON: 'feature_flag.result.reason', REASON: 'feature_flag.evaluation.reason',
/** /**
* Describes a class of error the operation ended with. * Describes a class of error the operation ended with.
* *
@ -69,7 +60,7 @@ export const TelemetryAttribute = {
* - requirement level: `recommended` * - requirement level: `recommended`
* - example: `flag_not_found` * - example: `flag_not_found`
*/ */
PROVIDER: 'feature_flag.provider.name', PROVIDER: 'feature_flag.provider_name',
/** /**
* The identifier of the flag set to which the feature flag belongs. * The identifier of the flag set to which the feature flag belongs.
* *

View File

@ -0,0 +1,17 @@
/**
* Event data, sometimes referred as "body", is specific to a specific event.
* In this case, the event is `feature_flag.evaluation`. That's why the prefix
* is omitted from the values.
* @see https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-logs/
*/
export const TelemetryEvaluationData = {
/**
* The evaluated value of the feature flag.
*
* - type: `undefined`
* - requirement level: `conditionally required`
* - condition: variant is not defined on the evaluation details
* - example: `#ff0000`; `1`; `true`
*/
VALUE: 'value',
} as const;

View File

@ -1,19 +1,14 @@
import { ErrorCode, StandardResolutionReasons, type EvaluationDetails, type FlagValue } from '../evaluation/evaluation'; import { ErrorCode, StandardResolutionReasons, type EvaluationDetails, type FlagValue } from '../evaluation/evaluation';
import type { HookContext } from '../hooks/hooks'; import type { HookContext } from '../hooks/hooks';
import type { JsonValue } from '../types';
import { TelemetryAttribute } from './attributes'; import { TelemetryAttribute } from './attributes';
import { TelemetryEvaluationData } from './evaluation-data';
import { TelemetryFlagMetadata } from './flag-metadata'; import { TelemetryFlagMetadata } from './flag-metadata';
type EvaluationEvent = { type EvaluationEvent = {
/**
* The name of the feature flag evaluation event.
*/
name: string; name: string;
/** attributes: Record<string, string | number | boolean>;
* The attributes of an OpenTelemetry compliant event for flag evaluation. data: Record<string, JsonValue>;
* @experimental The attributes are subject to change.
* @see https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-logs/
*/
attributes: Record<string, string | number | boolean | FlagValue>;
}; };
const FLAG_EVALUATION_EVENT_NAME = 'feature_flag.evaluation'; const FLAG_EVALUATION_EVENT_NAME = 'feature_flag.evaluation';
@ -33,11 +28,12 @@ export function createEvaluationEvent(
[TelemetryAttribute.PROVIDER]: hookContext.providerMetadata.name, [TelemetryAttribute.PROVIDER]: hookContext.providerMetadata.name,
[TelemetryAttribute.REASON]: (evaluationDetails.reason ?? StandardResolutionReasons.UNKNOWN).toLowerCase(), [TelemetryAttribute.REASON]: (evaluationDetails.reason ?? StandardResolutionReasons.UNKNOWN).toLowerCase(),
}; };
const data: EvaluationEvent['data'] = {};
if (evaluationDetails.variant) { if (evaluationDetails.variant) {
attributes[TelemetryAttribute.VARIANT] = evaluationDetails.variant; attributes[TelemetryAttribute.VARIANT] = evaluationDetails.variant;
} else { } else {
attributes[TelemetryAttribute.VALUE] = evaluationDetails.value; data[TelemetryEvaluationData.VALUE] = evaluationDetails.value;
} }
const contextId = const contextId =
@ -66,5 +62,6 @@ export function createEvaluationEvent(
return { return {
name: FLAG_EVALUATION_EVENT_NAME, name: FLAG_EVALUATION_EVENT_NAME,
attributes, attributes,
data,
}; };
} }

View File

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

View File

@ -17,20 +17,14 @@ 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,8 +1,7 @@
import { createEvaluationEvent } from '../src/telemetry/evaluation-event'; import { createEvaluationEvent } from '../src/telemetry/evaluation-event';
import { ErrorCode, StandardResolutionReasons, type EvaluationDetails } from '../src/evaluation/evaluation'; import { ErrorCode, StandardResolutionReasons, type EvaluationDetails } from '../src/evaluation/evaluation';
import type { HookContext } from '../src/hooks/hooks'; import type { HookContext } from '../src/hooks/hooks';
import { TelemetryAttribute, TelemetryFlagMetadata } from '../src/telemetry'; import { TelemetryAttribute, TelemetryFlagMetadata, TelemetryEvaluationData } from '../src/telemetry';
import { MapHookData } from '../src/hooks/hook-data';
describe('evaluationEvent', () => { describe('evaluationEvent', () => {
const flagKey = 'test-flag'; const flagKey = 'test-flag';
@ -26,7 +25,6 @@ describe('evaluationEvent', () => {
error: jest.fn(), error: jest.fn(),
warn: jest.fn(), warn: jest.fn(),
}, },
hookData: new MapHookData(),
}; };
it('should return basic event body with mandatory fields', () => { it('should return basic event body with mandatory fields', () => {
@ -45,7 +43,9 @@ describe('evaluationEvent', () => {
[TelemetryAttribute.PROVIDER]: 'test-provider', [TelemetryAttribute.PROVIDER]: 'test-provider',
[TelemetryAttribute.REASON]: StandardResolutionReasons.STATIC.toLowerCase(), [TelemetryAttribute.REASON]: StandardResolutionReasons.STATIC.toLowerCase(),
[TelemetryAttribute.CONTEXT_ID]: 'test-target', [TelemetryAttribute.CONTEXT_ID]: 'test-target',
[TelemetryAttribute.VALUE]: true, });
expect(result.data).toEqual({
[TelemetryEvaluationData.VALUE]: true,
}); });
}); });
@ -61,7 +61,7 @@ describe('evaluationEvent', () => {
const result = createEvaluationEvent(mockHookContext, details); const result = createEvaluationEvent(mockHookContext, details);
expect(result.attributes[TelemetryAttribute.VARIANT]).toBe('test-variant'); expect(result.attributes[TelemetryAttribute.VARIANT]).toBe('test-variant');
expect(result.attributes[TelemetryAttribute.VALUE]).toBeUndefined(); expect(result.attributes[TelemetryEvaluationData.VALUE]).toBeUndefined();
}); });
it('should include flag metadata when provided', () => { it('should include flag metadata when provided', () => {

View File

@ -1,51 +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) ## [1.4.0](https://github.com/open-feature/js-sdk/compare/web-sdk-v1.3.2...web-sdk-v1.4.0) (2024-12-18)
### ⚠ BREAKING CHANGES ### ⚠ BREAKING CHANGES

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.4.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.4.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.

View File

@ -1,10 +1,8 @@
{ {
"name": "@openfeature/web-sdk", "name": "@openfeature/web-sdk",
"version": "1.6.1", "version": "1.4.0",
"description": "OpenFeature SDK for Web", "description": "OpenFeature SDK for Web",
"main": "./dist/cjs/index.js", "main": "./dist/cjs/index.js",
"unpkg": "dist/global/index.min.js",
"jsdelivr": "dist/global/index.min.js",
"files": [ "files": [
"dist/" "dist/"
], ],
@ -22,10 +20,8 @@
"clean": "shx rm -rf ./dist", "clean": "shx rm -rf ./dist",
"build:web-esm": "esbuild src/index.ts --bundle --external:@openfeature/core --sourcemap --target=es2015 --platform=browser --format=esm --outfile=./dist/esm/index.js --analyze", "build:web-esm": "esbuild src/index.ts --bundle --external:@openfeature/core --sourcemap --target=es2015 --platform=browser --format=esm --outfile=./dist/esm/index.js --analyze",
"build:web-cjs": "esbuild src/index.ts --bundle --external:@openfeature/core --sourcemap --target=es2015 --platform=browser --format=cjs --outfile=./dist/cjs/index.js --analyze", "build:web-cjs": "esbuild src/index.ts --bundle --external:@openfeature/core --sourcemap --target=es2015 --platform=browser --format=cjs --outfile=./dist/cjs/index.js --analyze",
"build:web-global": "esbuild src/index.ts --bundle --sourcemap --target=es2015 --platform=browser --format=iife --outfile=./dist/global/index.js --global-name=OpenFeature --analyze",
"build:web-global:min": "esbuild src/index.ts --bundle --sourcemap --target=es2015 --platform=browser --format=iife --outfile=./dist/global/index.min.js --global-name=OpenFeature --minify --analyze",
"build:rollup-types": "rollup -c ../../rollup.config.mjs", "build:rollup-types": "rollup -c ../../rollup.config.mjs",
"build": "npm run clean && npm run build:web-esm && npm run build:web-cjs && npm run build:web-global && npm run build:web-global:min && npm run build:rollup-types", "build": "npm run clean && npm run build:web-esm && npm run build:web-cjs && npm run build:rollup-types",
"postbuild": "shx cp ./../../package.esm.json ./dist/esm/package.json", "postbuild": "shx cp ./../../package.esm.json ./dist/esm/package.json",
"current-version": "echo $npm_package_version", "current-version": "echo $npm_package_version",
"prepack": "shx cp ./../../LICENSE ./LICENSE", "prepack": "shx cp ./../../LICENSE ./LICENSE",
@ -50,9 +46,9 @@
}, },
"homepage": "https://github.com/open-feature/js-sdk#readme", "homepage": "https://github.com/open-feature/js-sdk#readme",
"peerDependencies": { "peerDependencies": {
"@openfeature/core": "^1.9.0" "@openfeature/core": "^1.7.0"
}, },
"devDependencies": { "devDependencies": {
"@openfeature/core": "^1.9.0" "@openfeature/core": "^1.7.0"
} }
} }

View File

@ -12,7 +12,6 @@ import type {
OpenFeatureError, OpenFeatureError,
FlagMetadata, FlagMetadata,
ResolutionDetails, ResolutionDetails,
EventOptions,
} from '@openfeature/core'; } from '@openfeature/core';
import { import {
ErrorCode, ErrorCode,
@ -22,7 +21,6 @@ import {
StandardResolutionReasons, StandardResolutionReasons,
instantiateErrorByErrorCode, instantiateErrorByErrorCode,
statusMatchesEvent, statusMatchesEvent,
MapHookData,
} from '@openfeature/core'; } from '@openfeature/core';
import type { FlagEvaluationOptions } from '../../evaluation'; import type { FlagEvaluationOptions } from '../../evaluation';
import type { ProviderEvents } from '../../events'; import type { ProviderEvents } from '../../events';
@ -76,7 +74,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 +90,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 {
@ -232,11 +224,9 @@ export class OpenFeatureClient implements Client {
...this.apiContextAccessor(this?.options?.domain), ...this.apiContextAccessor(this?.options?.domain),
}; };
// Create hook context instances for each hook (stable object references for the entire evaluation) // this reference cannot change during the course of evaluation
// This ensures hooks can use WeakMaps with hookContext as keys across lifecycle methods // it may be used as a key in WeakMaps
// NOTE: Uses the reversed order to reduce the number of times we have to calculate the index. const hookContext: Readonly<HookContext> = {
const hookContexts = allHooksReversed.map<HookContext>(() =>
Object.freeze({
flagKey, flagKey,
defaultValue, defaultValue,
flagValueType: flagType, flagValueType: flagType,
@ -244,14 +234,12 @@ export class OpenFeatureClient implements Client {
providerMetadata: this._provider.metadata, providerMetadata: this._provider.metadata,
context, context,
logger: this._logger, logger: this._logger,
hookData: new MapHookData(), };
}),
);
let evaluationDetails: EvaluationDetails<T>; let evaluationDetails: EvaluationDetails<T>;
try { try {
this.beforeHooks(allHooks, hookContexts, options); this.beforeHooks(allHooks, hookContext, options);
this.shortCircuitIfNotReady(); this.shortCircuitIfNotReady();
@ -266,48 +254,45 @@ export class OpenFeatureClient implements Client {
if (resolutionDetails.errorCode) { if (resolutionDetails.errorCode) {
const err = instantiateErrorByErrorCode(resolutionDetails.errorCode, resolutionDetails.errorMessage); const err = instantiateErrorByErrorCode(resolutionDetails.errorCode, resolutionDetails.errorMessage);
this.errorHooks(allHooksReversed, hookContexts, err, options); this.errorHooks(allHooksReversed, hookContext, err, options);
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err, resolutionDetails.flagMetadata); evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err, resolutionDetails.flagMetadata);
} else { } else {
this.afterHooks(allHooksReversed, hookContexts, resolutionDetails, options); this.afterHooks(allHooksReversed, hookContext, resolutionDetails, options);
evaluationDetails = resolutionDetails; evaluationDetails = resolutionDetails;
} }
} catch (err: unknown) { } catch (err: unknown) {
this.errorHooks(allHooksReversed, hookContexts, err, options); this.errorHooks(allHooksReversed, hookContext, err, options);
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err); evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err);
} }
this.finallyHooks(allHooksReversed, hookContexts, evaluationDetails, options); this.finallyHooks(allHooksReversed, hookContext, evaluationDetails, options);
return evaluationDetails; return evaluationDetails;
} }
private beforeHooks(hooks: Hook[], hookContexts: HookContext[], options: FlagEvaluationOptions) { private beforeHooks(hooks: Hook[], hookContext: HookContext, options: FlagEvaluationOptions) {
for (const [index, hook] of hooks.entries()) {
const hookContextIndex = hooks.length - 1 - index; // reverse index for before hooks
const hookContext = hookContexts[hookContextIndex];
Object.freeze(hookContext); Object.freeze(hookContext);
Object.freeze(hookContext.context); Object.freeze(hookContext.context);
for (const hook of hooks) {
hook?.before?.(hookContext, Object.freeze(options.hookHints)); hook?.before?.(hookContext, Object.freeze(options.hookHints));
} }
} }
private afterHooks( private afterHooks(
hooks: Hook[], hooks: Hook[],
hookContexts: HookContext[], hookContext: HookContext,
evaluationDetails: EvaluationDetails<FlagValue>, evaluationDetails: EvaluationDetails<FlagValue>,
options: FlagEvaluationOptions, options: FlagEvaluationOptions,
) { ) {
// run "after" hooks sequentially // run "after" hooks sequentially
for (const [index, hook] of hooks.entries()) { for (const hook of hooks) {
const hookContext = hookContexts[index];
hook?.after?.(hookContext, evaluationDetails, options.hookHints); hook?.after?.(hookContext, evaluationDetails, options.hookHints);
} }
} }
private errorHooks(hooks: Hook[], hookContexts: HookContext[], err: unknown, options: FlagEvaluationOptions) { private errorHooks(hooks: Hook[], hookContext: HookContext, err: unknown, options: FlagEvaluationOptions) {
// run "error" hooks sequentially // run "error" hooks sequentially
for (const [index, hook] of hooks.entries()) { for (const hook of hooks) {
try { try {
const hookContext = hookContexts[index];
hook?.error?.(hookContext, err, options.hookHints); hook?.error?.(hookContext, err, options.hookHints);
} catch (err) { } catch (err) {
this._logger.error(`Unhandled error during 'error' hook: ${err}`); this._logger.error(`Unhandled error during 'error' hook: ${err}`);
@ -321,14 +306,13 @@ export class OpenFeatureClient implements Client {
private finallyHooks( private finallyHooks(
hooks: Hook[], hooks: Hook[],
hookContexts: HookContext[], hookContext: HookContext,
evaluationDetails: EvaluationDetails<FlagValue>, evaluationDetails: EvaluationDetails<FlagValue>,
options: FlagEvaluationOptions, options: FlagEvaluationOptions,
) { ) {
// run "finally" hooks sequentially // run "finally" hooks sequentially
for (const [index, hook] of hooks.entries()) { for (const hook of hooks) {
try { try {
const hookContext = hookContexts[index];
hook?.finally?.(hookContext, evaluationDetails, options.hookHints); hook?.finally?.(hookContext, evaluationDetails, options.hookHints);
} catch (err) { } catch (err) {
this._logger.error(`Unhandled error during 'finally' hook: ${err}`); this._logger.error(`Unhandled error during 'finally' hook: ${err}`);

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

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

View File

@ -75,8 +75,8 @@ describe('OpenFeature', () => {
it('should set the default provider if no domain is provided', () => { it('should set the default provider if no domain is provided', () => {
const provider = mockProvider(); const provider = mockProvider();
OpenFeature.setProvider(provider); OpenFeature.setProvider(provider);
const 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', () => {
@ -86,11 +86,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

@ -29,7 +29,7 @@
}, },
"packages/angular/projects/angular-sdk": { "packages/angular/projects/angular-sdk": {
"release-type": "node", "release-type": "node",
"prerelease": false, "prerelease": true,
"bump-minor-pre-major": true, "bump-minor-pre-major": true,
"bump-patch-for-minor-pre-major": true, "bump-patch-for-minor-pre-major": true,
"extra-files": ["README.md"], "extra-files": ["README.md"],