Compare commits

..

No commits in common. "main" and "nestjs-sdk-v0.1.1-experimental" have entirely different histories.

210 changed files with 7612 additions and 24894 deletions

View File

@ -1,54 +1,63 @@
{
"env": {
"browser": true,
"es2021": true
},
"ignorePatterns": ["**/dist/**/*"],
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier", "plugin:jsdoc/recommended"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": ["@typescript-eslint", "check-file", "jsdoc"],
"rules": {
"@typescript-eslint/consistent-type-imports": [
"error",
{
"disallowTypeAnnotations": true,
"fixStyle": "separate-type-imports",
"prefer": "type-imports"
}
],
"jsdoc/require-jsdoc": [
"warn",
{
"publicOnly": true
}
],
"jsdoc/check-tag-names": [
"warn",
{
"definedTags": ["experimental"]
}
],
"linebreak-style": ["error", "unix"],
"quotes": [
"error",
"single",
{
"avoidEscape": true
}
],
"semi": ["error", "always"],
"check-file/filename-naming-convention": [
"error",
{
"**/*.{js,ts}": "KEBAB_CASE"
},
{
"ignoreMiddleExtensions": true
}
]
}
}
"env": {
"browser": true,
"es2021": true
},
"ignorePatterns": ["**/dist/**/*"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"prettier",
"plugin:jsdoc/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": [
"@typescript-eslint",
"check-file",
"jsdoc"
],
"rules": {
"jsdoc/require-jsdoc": [
"warn",
{
"publicOnly": true
}
],
"jsdoc/check-tag-names": [
"warn",
{
"definedTags": [
"experimental"
]
}
],
"linebreak-style": [
"error",
"unix"
],
"quotes": [
"error",
"single",
{
"avoidEscape": true
}
],
"semi": [
"error",
"always"
],
"check-file/filename-naming-convention": [
"error",
{
"**/*.{js,ts}": "KEBAB_CASE"
},
{
"ignoreMiddleExtensions": true
}
]
}
}

View File

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

View File

@ -16,7 +16,7 @@ jobs:
- uses: actions/setup-node@v4
with:
registry-url: 'https://registry.npmjs.org'
node-version: 20
node-version: 16
cache: 'npm'
- name: Install

View File

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

View File

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

View File

@ -1,8 +1,7 @@
{
"packages/nest": "0.2.5",
"packages/react": "1.0.1",
"packages/web": "1.6.1",
"packages/server": "1.19.0",
"packages/shared": "1.9.0",
"packages/angular/projects/angular-sdk": "0.0.16"
"packages/nest": "0.1.1-experimental",
"packages/react": "0.2.0-experimental",
"packages/client": "0.4.14",
"packages/server": "1.13.0",
"packages/shared": "0.0.27"
}

View File

@ -1,5 +0,0 @@
{
"cSpell.words": [
"domainless"
]
}

View File

@ -3,4 +3,4 @@
#
# 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

@ -8,7 +8,7 @@ node 16+, npm 8+ are recommended.
### Compilation Target(s)
We target `es2015`, and publish both ES-modules and CommonJS modules.
We target `es2022`, and publish both ES-modules and CommonJS modules.
### Installation and Dependencies
@ -19,7 +19,7 @@ We value having as few runtime dependencies as possible. The addition of any dep
### Modules
This repository uses [NPM workspaces](https://docs.npmjs.com/cli/v9/using-npm/workspaces) to establish a simple monorepo.
Within the root project, there is one common project (`packages/shared`) which features common interfaces and code, consumed by the published modules (`packages/server` and `packages/web`).
Within the root project, there is one common project (`packages/shared`) which features common interfaces and code, consumed by the published modules (`packages/server` and `packages/client`).
The shared module is built and published separately, and is a peer dependency of the SDK packages.
Consumers need not install it separately, since `npm` and `yarn` automatically install required peers.
In order to prevent regressions cause by incompatibilities due to version mismatches, the SDKs are locked to a particular version of the `@openfeature/core` module, and the CI enforces that it's released before any dependant SDKs (see [the related workflow](./.github/workflows/audit-pending-releases.yml)).
@ -36,9 +36,9 @@ npm run e2e-server
```
for the server e2e tests and
```
npm run e2e-web
npm run e2e-client
```
for the web e2e tests.
for the client e2e tests.
### Packaging
@ -120,16 +120,6 @@ on each other), the owner should try to get people aligned by:
- If none of the above worked and the PR has been stuck for more than 2 weeks,
the owner should bring it to the OpenFeatures [meeting](README.md#contributing).
## Releasing
As with most OpenFeature repos, release-please supports our release process.
For this SDK specifically, keep in mind this is a monorepo with dependencies with between components.
If there are multiple release PRs open, ensure that you release them in order consistent with their dependency graph, waiting for each to fully complete.
For example, if there are pending releases for: `@openfeature/core`, `@openfeature/web-sdk` and `@openfeature/react-sdk`, release them in that order.
Also ensure that if there are changes in an artifact which depend on changes in a dependency, that you reflect that in the `peerDependencies` field.
For example, if a new release of `@openfeature/web-sdk` depends on features added in `@openfeature/core`, update the required minimum version of the `@openfeature/core` peer in the `@openfeature/web-sdk` package.json.
## Design Choices
As with other OpenFeature SDKs, js-sdk follows the

View File

@ -29,9 +29,7 @@ This repository contains both the server-side JS and web-browser SDKs.
For details, including API documentation, see the respective README files.
- [Server SDK](./packages/server/README.md), for use in Node.js and similar runtimes.
- [NestJS SDK](./packages/nest/README.md), a distribution of the Server SDK with built-in NestJS-specific features.
- [Web SDK](./packages/web/README.md), for use in the web browser.
- [React SDK](./packages/react//README.md), a distribution of the Web SDK with built-in React-specific features.
- [Client SDK](./packages/client/README.md), for use in the web browser.
Each have slightly different APIs, but share many underlying types and components.

View File

@ -124,10 +124,10 @@ export default {
},
},
{
displayName: 'web',
displayName: 'client',
testEnvironment: 'node',
preset: 'ts-jest',
testMatch: ['<rootDir>/packages/web/test/**/*.spec.ts'],
testMatch: ['<rootDir>/packages/client/test/**/*.spec.ts'],
moduleNameMapper: {
'@openfeature/core': '<rootDir>/packages/shared/src',
},
@ -143,10 +143,10 @@ export default {
},
},
{
displayName: 'web-e2e',
displayName: 'client-e2e',
testEnvironment: 'node',
preset: 'ts-jest',
testMatch: ['<rootDir>/packages/web/e2e/**/*.spec.ts'],
testMatch: ['<rootDir>/packages/client/e2e/**/*.spec.ts'],
modulePathIgnorePatterns: ['.*/node-modules/'],
moduleNameMapper: {
'^uuid$': require.resolve('uuid'),
@ -161,7 +161,6 @@ export default {
testMatch: ['<rootDir>/packages/nest/test/**/*.spec.ts'],
moduleNameMapper: {
'@openfeature/core': '<rootDir>/packages/shared/src',
'@openfeature/server-sdk': '<rootDir>/packages/server/src',
},
transform: {
'^.+\\.ts$': [
@ -172,24 +171,6 @@ export default {
],
},
},
{
displayName: 'react',
testEnvironment: 'jsdom',
preset: 'ts-jest',
testMatch: ['<rootDir>/packages/react/test/**/*.spec.{ts,tsx}'],
moduleNameMapper: {
'@openfeature/core': '<rootDir>/packages/shared/src',
'@openfeature/web-sdk': '<rootDir>/packages/web/src',
},
transform: {
'^.+\\.(ts|tsx)$': [
'ts-jest',
{
tsconfig: '<rootDir>/packages/react/test/tsconfig.json',
},
],
},
},
],
// Use this configuration option to add custom reporters to Jest

20954
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,24 +1,20 @@
{
"name": "@openfeature/js",
"engines": {
"npm": "^10.0.0"
},
"version": "0.0.0",
"private": true,
"description": "OpenFeature SDK for JavaScript",
"scripts": {
"test": "npm run test:jest && npm run test:angular",
"test:jest": "jest --selectProjects=shared --selectProjects=server --selectProjects=web --selectProjects=react --selectProjects=nest --silent",
"test:angular": "npm run test:coverage --workspace=packages/angular",
"test": "jest --selectProjects=shared --selectProjects=server --selectProjects=client --silent",
"e2e-server": "git submodule update --init --recursive && shx cp test-harness/features/evaluation.feature packages/server/e2e/features && jest --selectProjects=server-e2e --verbose",
"e2e-web": "git submodule update --init --recursive && shx cp test-harness/features/evaluation.feature packages/web/e2e/features && jest --selectProjects=web-e2e --verbose",
"e2e": "npm run e2e-server && npm run e2e-web",
"lint": "npm run lint --workspace=packages/shared --workspace=packages/server --workspace=packages/web --workspace=packages/react --workspace=packages/angular --workspace=packages/nest",
"lint:fix": "npm run lint:fix --workspace=packages/shared --workspace=packages/server --workspace=packages/web --workspace=packages/react --workspace=packages/angular --workspace=packages/nest",
"e2e-client": "git submodule update --init --recursive && shx cp test-harness/features/evaluation.feature packages/client/e2e/features && jest --selectProjects=client-e2e --verbose",
"e2e": "npm run e2e-server && npm run e2e-client",
"lint": "npm run lint --workspace=packages/shared --workspace=packages/server --workspace=packages/client --workspace=packages/react --workspace=packages/nest",
"clean": "shx rm -rf ./dist",
"build": "npm run build --workspace=packages/shared --workspace=packages/server --workspace=packages/web --workspace=packages/react --workspace=packages/angular --workspace=packages/nest",
"publish-all": "npm run publish-if-not-exists --workspace=packages/shared --workspace=packages/server --workspace=packages/web --workspace=packages/react --workspace=packages/angular --workspace=packages/nest",
"docs": "typedoc"
"build": "npm run build --workspace=packages/shared --workspace=packages/server --workspace=packages/client --workspace=packages/react --workspace=packages/nest",
"publish-all": "npm run publish-if-not-exists --workspace=packages/shared --workspace=packages/server --workspace=packages/client --workspace=packages/react --workspace=packages/nest",
"docs": "typedoc",
"core-version": "npm run version --workspace=packages/shared",
"update-core-peers": "export OPENFEATURE_CORE_VERSION=$(npm run --silent core-version) && npm run update-core-peer --workspace=packages/server --workspace=packages/client"
},
"repository": {
"type": "git",
@ -36,49 +32,48 @@
"url": "https://github.com/open-feature/js-sdk/issues"
},
"homepage": "https://github.com/open-feature/js-sdk#readme",
"engines": {
"node": ">=16"
},
"devDependencies": {
"@rollup/plugin-typescript": "^12.0.0",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^16.0.0",
"@rollup/plugin-typescript": "^11.1.6",
"@types/events": "^3.0.3",
"@types/jest": "^29.5.12",
"@types/node": "^22.0.0",
"@types/node": "^20.11.16",
"@types/react": "^18.2.55",
"@types/uuid": "^10.0.0",
"esbuild": "^0.25.0",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"esbuild": "^0.20.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-alias": "^1.1.2",
"eslint-plugin-check-file": "^2.6.2",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jest": "^28.0.0",
"eslint-plugin-jsdoc": "^50.0.0",
"eventemitter3": "^5.0.1",
"eslint-plugin-jest": "^27.6.3",
"eslint-plugin-jsdoc": "^48.0.6",
"events": "^3.3.0",
"jest": "^29.7.0",
"jest-config": "^29.7.0",
"jest-cucumber": "^4.0.0",
"jest-cucumber": "^3.0.1",
"jest-environment-jsdom": "^29.7.0",
"jest-environment-node": "^29.7.0",
"jest-junit": "^16.0.0",
"prettier": "^3.2.5",
"react": "^18.2.0",
"rollup": "^4.0.0",
"rollup-plugin-dts": "^6.1.1",
"rxjs": "~7.8.0",
"shx": "^0.4.0",
"rollup": "^3.29.4",
"rollup-plugin-dts": "^5.3.1",
"shx": "^0.3.4",
"ts-jest": "^29.1.2",
"ts-node": "^10.9.2",
"tslib": "^2.3.0",
"typedoc": "^0.26.0",
"typedoc": "^0.25.7",
"typescript": "^4.7.4",
"uuid": "^11.0.0"
"uuid": "^9.0.1"
},
"workspaces": [
"packages/shared",
"packages/server",
"packages/web",
"packages/client",
"packages/react",
"packages/angular",
"packages/angular/projects/angular-sdk",
"packages/nest"
]
}
}

View File

@ -1,29 +0,0 @@
{
"root": true,
"ignorePatterns": [
"projects/**/*"
],
"overrides": [
{
"files": [
"*.ts"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@angular-eslint/recommended",
"plugin:@angular-eslint/template/process-inline-templates"
]
},
{
"files": [
"*.html"
],
"extends": [
"plugin:@angular-eslint/template/recommended",
"plugin:@angular-eslint/template/accessibility"
],
"rules": {}
}
]
}

View File

@ -1,45 +0,0 @@
# See http://help.github.com/ignore-files/ for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
# System files
.DS_Store
Thumbs.db
.nx/cache
.nx/workspace-data

View File

@ -1,27 +0,0 @@
# Angular
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 17.2.2.
## Development server
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files.
## Code scaffolding
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
## Build
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory.
## Running unit tests
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
## Running end-to-end tests
Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities.
## Further help
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page.

View File

@ -1,54 +0,0 @@
{
"$schema": "../../node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"angular-sdk": {
"projectType": "library",
"root": "projects/angular-sdk",
"sourceRoot": "projects/angular-sdk/src",
"prefix": "lib",
"architect": {
"build": {
"builder": "@angular/build:ng-packagr",
"options": {
"project": "projects/angular-sdk/ng-package.json"
},
"configurations": {
"production": {
"tsConfig": "projects/angular-sdk/tsconfig.lib.prod.json"
},
"development": {
"tsConfig": "projects/angular-sdk/tsconfig.lib.json"
}
},
"defaultConfiguration": "production"
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": [
"projects/angular-sdk/**/*.ts",
"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"
}
}
}
}
},
"cli": {
"schematicCollections": [
"@angular-eslint/schematics"
],
"analytics": false
}
}

View File

@ -1,48 +0,0 @@
{
"name": "angular",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"lint": "ng lint",
"lint:fix": "ng lint --fix",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"test:coverage": "ng test --no-watch --code-coverage",
"build": "ng build && npm run postbuild",
"postbuild": "shx cp ./../../LICENSE ./dist/angular/LICENSE",
"publish-if-not-exists": "cp $NPM_CONFIG_USERCONFIG .npmrc && if [ \"$(npm --prefix dist/angular run current-published-version -s)\" = \"$(npm --prefix dist/angular run current-version -s)\" ]; then echo 'already published, skipping'; else cd dist/angular && npm publish --access public; fi"
},
"private": true,
"devDependencies": {
"@angular-eslint/builder": "^20.1.1",
"@angular-eslint/eslint-plugin": "^20.1.1",
"@angular-eslint/eslint-plugin-template": "^20.1.1",
"@angular-eslint/schematics": "^20.1.1",
"@angular-eslint/template-parser": "^20.1.1",
"@angular/animations": "^20.1.1",
"@angular/build": "^20.1.1",
"@angular/cli": "^20.1.1",
"@angular/common": "^20.1.1",
"@angular/compiler": "^20.1.1",
"@angular/compiler-cli": "^20.1.1",
"@angular/core": "^20.1.1",
"@angular/forms": "^20.1.1",
"@angular/platform-browser": "^20.1.1",
"@angular/platform-browser-dynamic": "^20.1.1",
"@angular/router": "^20.1.1",
"@typescript-eslint/eslint-plugin": "7.18.0",
"@typescript-eslint/parser": "7.18.0",
"@vitest/browser": "^3.2.4",
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^8.57.0",
"jsdom": "^26.1.0",
"ng-packagr": "^20.1.0",
"playwright": "^1.53.2",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"typescript": "^5.8.3",
"vitest": "^3.2.4",
"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,19 +0,0 @@
{
"extends": "../../.eslintrc.json",
"ignorePatterns": [
"!**/*"
],
"overrides": [
{
"files": [
"*.ts"
]
},
{
"files": [
"*.html"
],
"rules": {}
}
]
}

View File

@ -1,127 +0,0 @@
# Changelog
## [0.0.16](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.15...angular-sdk-v0.0.16) (2025-07-25)
### ✨ New Features
* support Angular 20 ([#1220](https://github.com/open-feature/js-sdk/issues/1220)) ([aa232a9](https://github.com/open-feature/js-sdk/commit/aa232a9d6a8dfa416380ccdecd71843d3e361048))
## [0.0.15](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.14...angular-sdk-v0.0.15) (2025-05-27)
### 🐛 Bug Fixes
* **angular:** update docs ([#1200](https://github.com/open-feature/js-sdk/issues/1200)) ([b6ea588](https://github.com/open-feature/js-sdk/commit/b6ea5884f2ab9f4f94c8b258c4cf7268ea6dbeb8))
## [0.0.14](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.13...angular-sdk-v0.0.14) (2025-05-25)
### 🐛 Bug Fixes
* **angular:** add license and url field to package.json ([b2784f5](https://github.com/open-feature/js-sdk/commit/b2784f53b85a11c58abb8e2a0f87a31890885c54))
## [0.0.13](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.12...angular-sdk-v0.0.13) (2025-04-20)
### 📚 Documentation
* fix readme typo ([#1174](https://github.com/open-feature/js-sdk/issues/1174)) ([21a32ec](https://github.com/open-feature/js-sdk/commit/21a32ec92ecde9ec43c9d72b5921035af13448d1))
## [0.0.12](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.11...angular-sdk-v0.0.12) (2025-04-11)
### ✨ New Features
* **angular:** add docs for setting evaluation context in angular ([#1170](https://github.com/open-feature/js-sdk/issues/1170)) ([24f1b23](https://github.com/open-feature/js-sdk/commit/24f1b230bf1d57971a336ac21b9ee46e8baf0cab))
## [0.0.11](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.10...angular-sdk-v0.0.11) (2025-04-11)
### ✨ New Features
* **angular:** add option for initial context injection ([aafdb43](https://github.com/open-feature/js-sdk/commit/aafdb4382f113f96a649f5fc0cecadb4178ada67))
## [0.0.10](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.9-experimental...angular-sdk-v0.0.10) (2025-02-13)
### 🧹 Chore
* **angular:** update angular package to a non-experimental version ([#1147](https://github.com/open-feature/js-sdk/issues/1147)) ([5272f76](https://github.com/open-feature/js-sdk/commit/5272f76c4075ebbd21f9b24dacac8f2d22e31ca9)), closes [#1110](https://github.com/open-feature/js-sdk/issues/1110)
* update sdk peer ([#1142](https://github.com/open-feature/js-sdk/issues/1142)) ([8bb6206](https://github.com/open-feature/js-sdk/commit/8bb620601e2b8dc7b62d717169b585bd1c886996))
## [0.0.9-experimental](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.8-experimental...angular-sdk-v0.0.9-experimental) (2024-11-21)
### 🧹 Chore
* **angular:** add repository to package.json ([#1093](https://github.com/open-feature/js-sdk/issues/1093)) ([35f000e](https://github.com/open-feature/js-sdk/commit/35f000e0f3c3ff7d60c05883312691d14f01c5fd))
## [0.0.8-experimental](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.7-experimental...angular-sdk-v0.0.8-experimental) (2024-11-21)
### ✨ New Features
* **angular:** add angular 19 to peerDependencies ([4893d6f](https://github.com/open-feature/js-sdk/commit/4893d6f0003fbdcdcd4c7c061e9aed49e20b8976))
## [0.0.7-experimental](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.6-experimental...angular-sdk-v0.0.7-experimental) (2024-11-21)
Note: This version did not release
## [0.0.6-experimental](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.5-experimental...angular-sdk-v0.0.6-experimental) (2024-10-28)
### ✨ New Features
* **angular:** add Angular 18 support ([#1063](https://github.com/open-feature/js-sdk/issues/1063)) ([e62d6d4](https://github.com/open-feature/js-sdk/commit/e62d6d4b7e4a5d0f40592a2c73e7124d22eec98e))
## [0.0.5-experimental](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.4-experimental...angular-sdk-v0.0.5-experimental) (2024-10-21)
### 🐛 Bug Fixes
* **angular:** fix race condition on initialization ([#1052](https://github.com/open-feature/js-sdk/issues/1052)) ([12eaa97](https://github.com/open-feature/js-sdk/commit/12eaa9758d9deb788d74488ef03f18cbd31c0cbe))
## [0.0.4-experimental](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.3-experimental...angular-sdk-v0.0.4-experimental) (2024-09-30)
### 🐛 Bug Fixes
* **angular:** add package description ([#1026](https://github.com/open-feature/js-sdk/issues/1026)) ([dc63ca8](https://github.com/open-feature/js-sdk/commit/dc63ca8b9d6fe8c16089e95f0e336d5e3f759f3b))
## [0.0.3-experimental](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.2-experimental...angular-sdk-v0.0.3-experimental) (2024-09-22)
### 🧹 Chore
* add npm keywords for angular ([#1015](https://github.com/open-feature/js-sdk/issues/1015)) ([6b11165](https://github.com/open-feature/js-sdk/commit/6b11165aa102e62fb8cd4dd218643e2ef0e733cf))
### 📚 Documentation
* **angular:** improve angular readme layout ([#1013](https://github.com/open-feature/js-sdk/issues/1013)) ([ee52da9](https://github.com/open-feature/js-sdk/commit/ee52da9a01fe71fd5b4a4734659a06c48b6dc62c))
## [0.0.2-experimental](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.1-experimental...angular-sdk-v0.0.2-experimental) (2024-09-14)
### 🐛 Bug Fixes
* copy license to package correctly ([#1011](https://github.com/open-feature/js-sdk/issues/1011)) ([458d278](https://github.com/open-feature/js-sdk/commit/458d278345fe8681a966fca3852b2e607bdafccb))
## [0.0.1-experimental](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.2-experimental...angular-sdk-v0.0.3-experimental) (2024-09-14)
### ✨ New Features
* Angular SDK ([#997](https://github.com/open-feature/js-sdk/issues/997)) ([105fd95](https://github.com/open-feature/js-sdk/commit/105fd95e344822ffcfc54d328a28676b6f27f38e))

View File

@ -1,352 +0,0 @@
<!-- markdownlint-disable MD033 -->
<!-- x-hide-in-docs-start -->
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/open-feature/community/0e23508c163a6a1ac8c0ced3e4bd78faafe627c7/assets/logo/horizontal/white/openfeature-horizontal-white.svg" />
<img align="center" alt="OpenFeature Logo" src="https://raw.githubusercontent.com/open-feature/community/0e23508c163a6a1ac8c0ced3e4bd78faafe627c7/assets/logo/horizontal/black/openfeature-horizontal-black.svg" />
</picture>
</p>
<h2 align="center">OpenFeature Angular SDK</h2>
<!-- x-hide-in-docs-end -->
<!-- The 'github-badges' class is used in the docs -->
<p align="center" class="github-badges">
<a href="https://github.com/open-feature/spec/releases/tag/v0.8.0">
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
</a>
<!-- x-release-please-start-version -->
<a href="https://github.com/open-feature/js-sdk/releases/tag/angular-sdk-v0.0.16">
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.0.16&color=blue&style=for-the-badge" />
</a>
<!-- x-release-please-end -->
<br/>
<a href="https://codecov.io/gh/open-feature/js-sdk">
<img alt="codecov" src="https://codecov.io/gh/open-feature/js-sdk/branch/main/graph/badge.svg?token=3DC5XOEHMY" />
</a>
<a href="https://www.npmjs.com/package/@openfeature/angular-sdk">
<img alt="NPM Download" src="https://img.shields.io/npm/dm/%40openfeature%2Fangular-sdk" />
</a>
</p>
<!-- x-hide-in-docs-start -->
[OpenFeature](https://openfeature.dev) is an open specification that provides a vendor-agnostic, community-driven API
for feature flagging that works with your favorite feature flag management tool or in-house solution.
<!-- x-hide-in-docs-end -->
## Overview
The OpenFeature Angular SDK adds Angular-specific functionality to
the [OpenFeature Web SDK](https://openfeature.dev/docs/reference/technologies/client/web).
In addition to the features provided by the [web sdk](https://openfeature.dev/docs/reference/technologies/client/web), capabilities include:
- [Overview](#overview)
- [Quick start](#quick-start)
- [Requirements](#requirements)
- [Install](#install)
- [npm](#npm)
- [yarn](#yarn)
- [Required peer dependencies](#required-peer-dependencies)
- [Usage](#usage)
- [Module](#module)
- [Minimal Example](#minimal-example)
- [How to use](#how-to-use)
- [Boolean Feature Flag](#boolean-feature-flag)
- [Number Feature Flag](#number-feature-flag)
- [String Feature Flag](#string-feature-flag)
- [Object Feature Flag](#object-feature-flag)
- [Opting-out of automatic re-rendering](#opting-out-of-automatic-re-rendering)
- [Consuming the evaluation details](#consuming-the-evaluation-details)
- [Setting Evaluation Context](#setting-evaluation-context)
- [FAQ and troubleshooting](#faq-and-troubleshooting)
- [Resources](#resources)
## Quick start
### Requirements
- ES2015-compatible web browser (Chrome, Edge, Firefox, etc)
- Angular version 16+
### Install
#### npm
```sh
npm install --save @openfeature/angular-sdk
```
#### yarn
```sh
# yarn requires manual installation of the peer dependencies (see below)
yarn add @openfeature/angular-sdk @openfeature/web-sdk @openfeature/core
```
#### Required peer dependencies
The following list contains the peer dependencies of `@openfeature/angular-sdk`.
See the [package.json](./package.json) for the required versions.
* `@openfeature/web-sdk`
* `@angular/common`
* `@angular/core`
### Usage
#### Module
To include the OpenFeature Angular directives in your application, you need to import the `OpenFeatureModule` and
configure it using the `forRoot` method.
```typescript
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { OpenFeatureModule } from '@openfeature/angular-sdk';
@NgModule({
declarations: [
// Other components
],
imports: [
CommonModule,
OpenFeatureModule.forRoot({
provider: yourFeatureProvider,
// domainBoundProviders are optional, mostly needed if more than one provider is used in the application.
domainBoundProviders: {
domain1: new YourOpenFeatureProvider(),
domain2: new YourOtherOpenFeatureProvider(),
},
})
],
})
export class AppModule {
}
```
##### Minimal Example
You don't need to provide all the templates. Here's a minimal example using a boolean feature flag:
If `initializing` and `reconciling` are not given, the feature flag value that is returned by the provider will
determine what will be rendered.
```html
<div *booleanFeatureFlag="'isFeatureEnabled'; default: true">
This is shown when the feature flag is enabled.
</div>
```
This example shows content when the feature flag `isFeatureEnabled` is true with a default value of true.
No `else`, `initializing`, or `reconciling` templates are required in this case.
#### How to use
The library provides four primary directives for feature flags, `booleanFeatureFlag`,
`numberFeatureFlag`, `stringFeatureFlag` and `objectFeatureFlag`.
The first value given to the directive is the flag key that should be evaluated.
For all directives, the default value passed to OpenFeature has to be provided by the `default` parameter.
For all non-boolean directives, the value to compare the evaluation result to can be provided by the `value` parameter.
This parameter is optional, if omitted, the `thenTemplate` will always be rendered.
The `domain` parameter is _optional_ and will be used as domain when getting the OpenFeature provider.
The `updateOnConfigurationChanged` and `updateOnContextChanged` parameter are _optional_ and used to disable the
automatic re-rendering on flag value or context change. They are set to `true` by default.
The template referenced in `else` will be rendered if the evaluated feature flag is `false` for the `booleanFeatureFlag`
directive and if the `value` does not match evaluated flag value for all other directives.
This parameter is _optional_.
The template referenced in `initializing` and `reconciling` will be rendered if OpenFeature provider is in the
corresponding states.
This parameter is _optional_, if omitted, the `then` and `else` templates will be rendered according to the flag value.
##### Boolean Feature Flag
```html
<div
*booleanFeatureFlag="'isFeatureEnabled'; default: true; domain: 'userDomain'; else: booleanFeatureElse; initializing: booleanFeatureInitializing; reconciling: booleanFeatureReconciling">
This is shown when the feature flag is enabled.
</div>
<ng-template #booleanFeatureElse>
This is shown when the feature flag is disabled.
</ng-template>
<ng-template #booleanFeatureInitializing>
This is shown when the feature flag is initializing.
</ng-template>
<ng-template #booleanFeatureReconciling>
This is shown when the feature flag is reconciling.
</ng-template>
```
##### Number Feature Flag
```html
<div
*numberFeatureFlag="'discountRate'; value: 10; default: 5; domain: 'userDomain'; else: numberFeatureElse; initializing: numberFeatureInitializing; reconciling: numberFeatureReconciling">
This is shown when the feature flag matches the specified discount rate.
</div>
<ng-template #numberFeatureElse>
This is shown when the feature flag does not match the specified discount rate.
</ng-template>
<ng-template #numberFeatureInitializing>
This is shown when the feature flag is initializing.
</ng-template>
<ng-template #numberFeatureReconciling>
This is shown when the feature flag is reconciling.
</ng-template>
```
##### String Feature Flag
```html
<div
*stringFeatureFlag="'themeColor'; value: 'dark'; default: 'light'; domain: 'userDomain'; else: stringFeatureElse; initializing: stringFeatureInitializing; reconciling: stringFeatureReconciling">
This is shown when the feature flag matches the specified theme color.
</div>
<ng-template #stringFeatureElse>
This is shown when the feature flag does not match the specified theme color.
</ng-template>
<ng-template #stringFeatureInitializing>
This is shown when the feature flag is initializing.
</ng-template>
<ng-template #stringFeatureReconciling>
This is shown when the feature flag is reconciling.
</ng-template>
```
##### Object Feature Flag
```html
<div
*objectFeatureFlag="'userConfig'; value: { theme: 'dark' }; default: { theme: 'light' }; domain: 'userDomain'; else: objectFeatureElse; initializing: objectFeatureInitializing; reconciling: objectFeatureReconciling">
This is shown when the feature flag matches the specified user configuration.
</div>
<ng-template #objectFeatureElse>
This is shown when the feature flag does not match the specified user configuration.
</ng-template>
<ng-template #objectFeatureInitializing>
This is shown when the feature flag is initializing.
</ng-template>
<ng-template #objectFeatureReconciling>
This is shown when the feature flag is reconciling.
</ng-template>
```
##### Opting-out of automatic re-rendering
By default, the directive re-renders when the flag value changes or the context changes.
In cases, this is not desired, re-rendering can be disabled for both events:
```html
<div *booleanFeatureFlag="'isFeatureEnabled'; default: true; updateOnContextChanged: false; updateOnConfigurationChanged: false;">
This is shown when the feature flag is enabled.
</div>
```
##### Consuming the evaluation details
The `evaluation details` can be used when rendering the templates.
The directives [`$implicit`](https://angular.dev/guide/directives/structural-directives#structural-directive-shorthand)
value will be bound to the flag value and additionally the value `evaluationDetails` will be
bound to the whole evaluation details.
They can be referenced in all templates.
The following example shows `value` being implicitly bound and `details` being bound to the evaluation details.
```html
<div
*stringFeatureFlag="'themeColor'; value: 'dark'; default: 'light'; else: stringFeatureElse; let value; let details = evaluationDetails">
It was a match!
The theme color is {{ value }} because of {{ details.reason }}
</div>
<ng-template #stringFeatureElse let-value let-details='evaluationDetails'>
It was no match!
The theme color is {{ value }} because of {{ details.reason }}
</ng-template>
```
When the expected flag value is omitted, the template will always be rendered.
This can be used to just render the flag value or details without conditional rendering.
```html
<div *stringFeatureFlag="'themeColor'; default: 'light'; let value;">
The theme color is {{ value }}.
</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
> I can import things form the `@openfeature/angular-sdk`, `@openfeature/web-sdk`, and `@openfeature/core`; which should I use?
The `@openfeature/angular-sdk` re-exports everything from its peers (`@openfeature/web-sdk` and `@openfeature/core`), and adds the Angular-specific features.
You can import everything from the `@openfeature/angular-sdk` directly.
Avoid importing anything from `@openfeature/web-sdk` or `@openfeature/core`.
## Resources
- [Example repo](https://github.com/open-feature/angular-test-app)

View File

@ -1,8 +0,0 @@
{
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
"dest": "../../dist/angular",
"keepLifecycleScripts": true,
"lib": {
"entryFile": "src/public-api.ts"
}
}

View File

@ -1,43 +0,0 @@
{
"name": "@openfeature/angular-sdk",
"version": "0.0.16",
"description": "OpenFeature Angular SDK",
"repository": {
"type": "git",
"url": "git+https://github.com/open-feature/js-sdk.git"
},
"license": "Apache-2.0",
"bugs": {
"url": "https://github.com/open-feature/js-sdk/issues"
},
"homepage": "https://github.com/open-feature/js-sdk#readme",
"scripts": {
"current-published-version": "npm show $npm_package_name@$npm_package_version version",
"current-version": "echo $npm_package_version",
"prepack": "shx cp ./../../../../LICENSE ./LICENSE"
},
"peerDependencies": {
"@angular/common": "^16.2.12 || ^17.3.0 || ^18.0.0 || ^19.0.0 || ^20.0.0",
"@angular/core": "^16.2.12 || ^17.3.0 || ^18.0.0 || ^19.0.0 || ^20.0.0",
"@openfeature/web-sdk": "^1.4.1"
},
"dependencies": {
"tslib": "^2.3.0"
},
"devDependencies": {
"@openfeature/core": "^1.8.1",
"@openfeature/web-sdk": "^1.5.0",
"@angular/common": "^20.1.2",
"@angular/core": "^20.1.2"
},
"sideEffects": false,
"keywords": [
"openfeature",
"feature",
"flags",
"toggles",
"browser",
"web",
"angular"
]
}

View File

@ -1,608 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component, Input } from '@angular/core';
import { OpenFeatureModule } from './open-feature.module';
import { By } from '@angular/platform-browser';
import { Client, ClientProviderEvents, FlagValue, InMemoryProvider, OpenFeature } from '@openfeature/web-sdk';
import { TestingProvider } from '../test/test.utils';
import { v4 } from 'uuid';
import {
BooleanFeatureFlagDirective,
NumberFeatureFlagDirective,
ObjectFeatureFlagDirective,
StringFeatureFlagDirective,
} from './feature-flag.directive';
@Component({
standalone: true,
imports: [
BooleanFeatureFlagDirective,
NumberFeatureFlagDirective,
StringFeatureFlagDirective,
ObjectFeatureFlagDirective,
],
template: `
<ng-container>
<div class="case-1">
<div *booleanFeatureFlag="'test-flag'; default: true; domain: domain" class="flag-status">Flag On</div>
</div>
<div class="case-2">
<div *booleanFeatureFlag="'test-flag'; default: true; else: elseTemplate; domain: domain" class="flag-status">
Flag On
</div>
<ng-template #elseTemplate>
<div class="flag-status">Flag Off</div>
</ng-template>
</div>
<div class="case-3">
<div
*booleanFeatureFlag="'test-flag'; default: false; initializing: initializingTemplate; domain: domain"
class="flag-status"
>
Flag On
</div>
<ng-template #initializingTemplate>
<div class="flag-status">Initializing</div>
</ng-template>
</div>
<div class="case-4">
<div
*booleanFeatureFlag="'test-flag'; default: false; reconciling: reconcilingTemplate; domain: domain"
class="flag-status"
>
Flag On
</div>
<ng-template #reconcilingTemplate>
<div class="flag-status">Reconciling</div>
</ng-template>
</div>
<div class="case-5">
<div
*booleanFeatureFlag="
'test-flag';
default: false;
else: elseTemplate;
initializing: initializingTemplate;
reconciling: reconcilingTemplate;
domain: domain
"
class="flag-status"
>
Flag On
</div>
<ng-template #elseTemplate>
<div class="flag-status">Flag Off</div>
</ng-template>
<ng-template #initializingTemplate>
<div class="flag-status">Initializing</div>
</ng-template>
<ng-template #reconcilingTemplate>
<div class="flag-status">Reconciling</div>
</ng-template>
</div>
<div class="case-6">
<div
*booleanFeatureFlag="specialFlagKey; default: true; else: elseTemplate; domain: domain"
class="flag-status"
>
Flag On
</div>
<ng-template #elseTemplate>
<div class="flag-status">Flag Off</div>
</ng-template>
</div>
<div class="case-7">
<div
*numberFeatureFlag="'test-flag'; default: 0; value: 1; else: elseTemplate; domain: domain"
class="flag-status"
>
Flag On
</div>
<ng-template #elseTemplate>
<div class="flag-status">Flag Off</div>
</ng-template>
</div>
<div class="case-8">
<div
*stringFeatureFlag="'test-flag'; default: 'default'; value: 'on'; else: elseTemplate; domain: domain"
class="flag-status"
>
Flag On
</div>
<ng-template #elseTemplate>
<div class="flag-status">Flag Off</div>
</ng-template>
</div>
<div class="case-9">
<div
*objectFeatureFlag="
'test-flag';
default: {};
value: { prop2: true, prop1: true };
else: elseTemplate;
domain: domain
"
class="flag-status"
>
Flag On
</div>
<ng-template #elseTemplate>
<div class="flag-status">Flag Off</div>
</ng-template>
</div>
<div class="case-10">
<div
*booleanFeatureFlag="
'test-flag';
default: false;
domain: domain;
else: elseTemplateWithContext;
let value;
let evaluationDetails = evaluationDetails
"
class="flag-status"
>
then {{ value }} {{ evaluationDetails.reason }}
</div>
<ng-template #elseTemplateWithContext let-value let-evaluationDetails="evaluationDetails">
<div class="flag-status">else {{ value }} {{ evaluationDetails.reason }}</div>
</ng-template>
</div>
<div class="case-11">
<div
*stringFeatureFlag="'test-flag'; default: 'default'; domain: domain; let value = $implicit"
class="flag-status"
>
{{ value }}
</div>
</div>
<div class="case-12">
<div
*booleanFeatureFlag="
'test-flag';
default: true;
else: elseTemplate;
domain: domain;
updateOnConfigurationChanged: false
"
class="flag-status"
>
Flag On
</div>
<ng-template #elseTemplate>
<div class="flag-status">Flag Off</div>
</ng-template>
</div>
</ng-container>
`,
})
class TestComponent {
@Input() domain: string;
@Input() specialFlagKey: string = 'test-flag';
protected readonly JSON = JSON;
}
describe('FeatureFlagDirective', () => {
describe('thenTemplate', () => {
it('should not be rendered if disabled by the flag', async () => {
const { fixture } = await createTestingModule({
flagConfiguration: {
'test-flag': {
variants: { default: false },
defaultVariant: 'default',
disabled: false,
},
},
});
await expectAmountElements(fixture, 'case-1', 0);
});
it('should be rendered if enabled by the flag', async () => {
const { fixture } = await createTestingModule({
flagConfiguration: {
'test-flag': {
variants: { default: true },
defaultVariant: 'default',
disabled: false,
},
},
});
await expectRenderedText(fixture, 'case-2', 'Flag On');
});
});
describe('elseTemplate', () => {
it('should not be rendered if not existent but enabled by the flag', async () => {
const { fixture } = await createTestingModule({
flagConfiguration: {
'test-flag': {
variants: { default: false },
defaultVariant: 'default',
disabled: false,
},
},
});
await expectAmountElements(fixture, 'case-1', 0);
});
it('should not be rendered if existent but disabled by the flag', async () => {
const { fixture } = await createTestingModule({
flagConfiguration: {
'test-flag': {
variants: { default: true },
defaultVariant: 'default',
disabled: false,
},
},
});
await expectRenderedText(fixture, 'case-2', 'Flag On');
});
it('should be rendered if existent and enabled by the flag', async () => {
const { fixture, provider } = await createTestingModule({
flagConfiguration: {
'test-flag': {
variants: { default: true },
defaultVariant: 'default',
disabled: false,
},
},
});
await expectRenderedText(fixture, 'case-2', 'Flag On');
await updateFlagValue(provider, false);
fixture.detectChanges(); // Ensure change detection after flag update
await expectRenderedText(fixture, 'case-2', 'Flag Off');
});
});
describe('initializingTemplate', () => {
it('should not be rendered if provider is ready', async () => {
const { fixture } = await createTestingModule({
flagConfiguration: {
'test-flag': {
variants: { default: true },
defaultVariant: 'default',
disabled: false,
},
},
});
await expectRenderedText(fixture, 'case-3', 'Flag On');
});
it('should be rendered if provider is not ready', async () => {
const { fixture } = await createTestingModule({
flagConfiguration: {
'test-flag': {
variants: { default: true },
defaultVariant: 'default',
disabled: false,
},
},
providerInitDelay: 1000,
});
await expectRenderedText(fixture, 'case-3', 'Initializing');
});
it('should render until the provider is initialized', async () => {
const { fixture, client } = await createTestingModule({
flagConfiguration: {
'test-flag': {
variants: { default: true },
defaultVariant: 'default',
disabled: false,
},
},
providerInitDelay: 1000,
});
await expectRenderedText(fixture, 'case-3', 'Initializing');
await waitForClientReady(client);
await expectRenderedText(fixture, 'case-3', 'Flag On');
});
});
describe('reconcilingTemplate', () => {
it('should not be rendered if provider is ready', async () => {
const { fixture } = await createTestingModule({
flagConfiguration: {
'test-flag': {
variants: { default: true },
defaultVariant: 'default',
disabled: false,
},
},
});
await expectRenderedText(fixture, 'case-3', 'Flag On');
});
it('should be rendered while provider is reconciling', async () => {
const { fixture, domain, client } = await createTestingModule({
flagConfiguration: {
'test-flag': {
variants: { default: true },
defaultVariant: 'default',
disabled: false,
},
},
providerInitDelay: 500,
});
await waitForClientReady(client);
await expectRenderedText(fixture, 'case-4', 'Flag On');
const setContextPromise = OpenFeature.setContext(domain, { newCtx: true });
await expectRenderedText(fixture, 'case-4', 'Reconciling');
await setContextPromise;
await expectRenderedText(fixture, 'case-4', 'Flag On');
});
});
describe('complex case', () => {
it('should use initializing, then, else and reconciling in one go', async () => {
const { fixture, provider, client, domain } = await createTestingModule({
flagConfiguration: {
'test-flag': {
variants: { default: true },
defaultVariant: 'default',
disabled: false,
},
},
providerInitDelay: 500,
});
// Initializing
await expectRenderedText(fixture, 'case-5', 'Initializing');
await waitForClientReady(client);
await expectRenderedText(fixture, 'case-5', 'Flag On');
// Updating
await updateFlagValue(provider, false);
await expectRenderedText(fixture, 'case-5', 'Flag Off');
// Reconciling
const setContextPromise = OpenFeature.setContext(domain, { newCtx: true });
await expectRenderedText(fixture, 'case-5', 'Reconciling');
await setContextPromise;
await expectRenderedText(fixture, 'case-5', 'Flag Off');
// Updating 2
await updateFlagValue(provider, true);
await expectRenderedText(fixture, 'case-5', 'Flag On');
});
it('should evaluate on flag key change', async () => {
const { fixture, client } = await createTestingModule({
flagConfiguration: {
'test-flag': {
variants: { default: true },
defaultVariant: 'default',
disabled: false,
},
'new-test-flag': {
variants: { default: false },
defaultVariant: 'default',
disabled: false,
},
},
});
await waitForClientReady(client);
await expectRenderedText(fixture, 'case-6', 'Flag On');
fixture.componentRef.setInput('specialFlagKey', 'new-test-flag');
await fixture.whenStable();
await expectRenderedText(fixture, 'case-6', 'Flag Off');
});
it('should opt-out of re-rendering when flag value changes', async () => {
const { fixture, client, provider } = await createTestingModule({
flagConfiguration: {
'test-flag': {
variants: { default: true },
defaultVariant: 'default',
disabled: false,
},
'new-test-flag': {
variants: { default: false },
defaultVariant: 'default',
disabled: false,
},
},
});
await waitForClientReady(client);
await expectRenderedText(fixture, 'case-12', 'Flag On');
await updateFlagValue(provider, false);
await expectRenderedText(fixture, 'case-12', 'Flag On');
});
it('should evaluate on flag domain change', async () => {
const { fixture, client } = await createTestingModule({
flagConfiguration: {
'test-flag': {
variants: { default: true },
defaultVariant: 'default',
disabled: false,
},
},
});
await waitForClientReady(client);
await expectRenderedText(fixture, 'case-6', 'Flag On');
const newDomain = v4();
const newProvider = new TestingProvider(
{
'test-flag': {
variants: { default: false },
defaultVariant: 'default',
disabled: false,
},
},
0,
);
await OpenFeature.setProviderAndWait(newDomain, newProvider);
fixture.componentRef.setInput('domain', newDomain);
await fixture.whenStable();
await expectRenderedText(fixture, 'case-6', 'Flag Off');
});
});
describe('numberFeatureFlag', () => {
it('should render thenTemplate on match and else elseTemplate ', async () => {
const { fixture, provider } = await createTestingModule({
flagConfiguration: {
'test-flag': {
variants: { default: 1 },
defaultVariant: 'default',
disabled: false,
},
},
});
await expectRenderedText(fixture, 'case-7', 'Flag On');
await updateFlagValue(provider, 2);
await expectRenderedText(fixture, 'case-7', 'Flag Off');
});
});
describe('stringFeatureFlag', () => {
it('should render thenTemplate on match and else elseTemplate ', async () => {
const { fixture, provider } = await createTestingModule({
flagConfiguration: {
'test-flag': {
variants: { default: 'on' },
defaultVariant: 'default',
disabled: false,
},
},
});
await expectRenderedText(fixture, 'case-8', 'Flag On');
await updateFlagValue(provider, 'another-value');
await expectRenderedText(fixture, 'case-8', 'Flag Off');
});
});
describe('objectFeatureFlag', () => {
it('should render thenTemplate on match and else elseTemplate', async () => {
const { fixture, provider } = await createTestingModule({
flagConfiguration: {
'test-flag': {
variants: { default: { prop1: true, prop2: true } },
defaultVariant: 'default',
disabled: false,
},
},
});
await expectRenderedText(fixture, 'case-9', 'Flag On');
await updateFlagValue(provider, { prop2: 'string' });
await expectRenderedText(fixture, 'case-9', 'Flag Off');
});
});
describe('context', () => {
it('should render thenTemplate from context', async () => {
const { fixture } = await createTestingModule({
flagConfiguration: {
'test-flag': {
variants: { default: true },
defaultVariant: 'default',
disabled: false,
},
},
});
await expectRenderedText(fixture, 'case-10', 'then true STATIC');
});
it('should render elseTemplate from context', async () => {
const { fixture } = await createTestingModule({
flagConfiguration: {
'test-flag': {
variants: { default: false },
defaultVariant: 'default',
disabled: false,
},
},
});
await expectRenderedText(fixture, 'case-10', 'else false STATIC');
});
it('should always render if no expected value is given', async () => {
const { fixture } = await createTestingModule({
flagConfiguration: {
'test-flag': {
variants: { default: 'flag-value' },
defaultVariant: 'default',
disabled: false,
},
},
});
await expectRenderedText(fixture, 'case-11', 'flag-value');
});
});
});
async function createTestingModule(config?: {
flagConfiguration?: ConstructorParameters<typeof InMemoryProvider>[0];
providerInitDelay?: number;
}): Promise<{ fixture: ComponentFixture<TestComponent>; provider: TestingProvider; domain: string; client: Client }> {
const domain = v4();
const provider = new TestingProvider(config?.flagConfiguration ?? {}, config?.providerInitDelay ?? 0);
const fixture = TestBed.configureTestingModule({
imports: [
OpenFeatureModule.forRoot({ provider: new InMemoryProvider(), domainBoundProviders: { [domain]: provider } }),
TestComponent,
],
}).createComponent(TestComponent);
fixture.componentRef.setInput('domain', domain);
await fixture.whenStable();
const client = OpenFeature.getClient(domain);
if (!config.providerInitDelay) {
await waitForClientReady(client);
}
return { provider, domain, client, fixture };
}
async function waitForClientReady(client: Client) {
await new Promise((resolve) => client.addHandler(ClientProviderEvents.Ready, resolve));
}
async function updateFlagValue<T extends FlagValue>(provider: TestingProvider, value: T) {
await provider.putConfiguration({
'test-flag': {
variants: { default: value },
defaultVariant: 'default',
disabled: false,
},
});
}
async function getElements(fixture: ComponentFixture<TestComponent>, testCase: string) {
fixture.detectChanges();
await fixture.whenStable();
return fixture.debugElement.queryAll(By.css(`.${testCase} .flag-status`));
}
async function expectAmountElements(fixture: ComponentFixture<TestComponent>, testCase: string, amount: number) {
const divElements = await getElements(fixture, testCase);
expect(divElements.length).toEqual(amount);
}
async function expectRenderedText(fixture: ComponentFixture<TestComponent>, testCase: string, rendered: string) {
const divElements = await getElements(fixture, testCase);
expect(divElements.length).toEqual(1);
expect(divElements[0].nativeElement.textContent.trim()).toBe(rendered);
}

View File

@ -1,706 +0,0 @@
import {
ChangeDetectorRef,
Directive,
EmbeddedViewRef,
Input,
OnChanges,
OnDestroy,
OnInit,
TemplateRef,
ViewContainerRef,
inject,
} from '@angular/core';
import {
Client,
ClientProviderEvents,
ClientProviderStatus,
EvaluationDetails,
EventHandler,
FlagValue,
JsonValue,
OpenFeature,
} from '@openfeature/web-sdk';
class FeatureFlagDirectiveContext<T extends FlagValue> {
$implicit!: T;
evaluationDetails: EvaluationDetails<T>;
constructor(details: EvaluationDetails<T>) {
this.$implicit = details.value;
this.evaluationDetails = details;
}
}
@Directive({
standalone: true,
selector: '[featureFlag]',
})
export abstract class FeatureFlagDirective<T extends FlagValue> implements OnInit, OnDestroy, OnChanges {
protected _changeDetectorRef: ChangeDetectorRef;
protected _viewContainerRef: ViewContainerRef;
protected _featureFlagDefault: T;
protected _featureFlagDomain: string | undefined;
protected _featureFlagKey: string;
protected _featureFlagValue?: T;
protected _client: Client;
protected _lastEvaluationResult: EvaluationDetails<T>;
protected _readyHandler: EventHandler<ClientProviderEvents.Ready> | null = null;
protected _flagChangeHandler: EventHandler<ClientProviderEvents.ConfigurationChanged> | null = null;
protected _contextChangeHandler: EventHandler<ClientProviderEvents.Error> | null = null;
protected _reconcilingHandler: EventHandler<ClientProviderEvents.Reconciling> | null = null;
protected _updateOnContextChanged: boolean = true;
protected _updateOnConfigurationChanged: boolean = true;
protected _thenTemplateRef: TemplateRef<FeatureFlagDirectiveContext<T>> | null;
protected _thenViewRef: EmbeddedViewRef<unknown> | null;
protected _elseTemplateRef: TemplateRef<FeatureFlagDirectiveContext<T>> | null;
protected _elseViewRef: EmbeddedViewRef<unknown> | null;
protected _initializingTemplateRef: TemplateRef<FeatureFlagDirectiveContext<T>> | null;
protected _initializingViewRef: EmbeddedViewRef<unknown> | null;
protected _reconcilingTemplateRef: TemplateRef<FeatureFlagDirectiveContext<T>> | null;
protected _reconcilingViewRef: EmbeddedViewRef<unknown> | null;
protected constructor() {}
set featureFlagDomain(domain: string | undefined) {
/**
* We have to handle the change of the domain explicitly because we need to get a new client when the domain changes.
* This can not be done if we simply relay the onChanges method.
*/
this._featureFlagDomain = domain;
this.initClient();
}
ngOnInit(): void {
this.initClient();
}
ngOnChanges(): void {
this._flagChangeHandler?.();
}
ngOnDestroy(): void {
if (this._client) {
this.disposeClient(this._client);
this._client = null;
}
}
private initClient(): void {
if (this._client) {
this.disposeClient(this._client);
}
this._client = OpenFeature.getClient(this._featureFlagDomain);
const baseHandler = () => {
const result = this.getFlagDetails(this._featureFlagKey, this._featureFlagDefault);
this.onFlagValue(result, this._client.providerStatus);
};
this._flagChangeHandler = () => {
if (this._updateOnConfigurationChanged) {
baseHandler();
}
};
this._contextChangeHandler = () => {
if (this._updateOnContextChanged) {
baseHandler();
}
};
this._readyHandler = () => baseHandler();
this._reconcilingHandler = () => baseHandler();
this._client.addHandler(ClientProviderEvents.ConfigurationChanged, this._flagChangeHandler);
this._client.addHandler(ClientProviderEvents.ContextChanged, this._contextChangeHandler);
this._client.addHandler(ClientProviderEvents.Ready, this._readyHandler);
this._client.addHandler(ClientProviderEvents.Reconciling, this._reconcilingHandler);
}
private disposeClient(client: Client) {
if (this._contextChangeHandler()) {
client.removeHandler(ClientProviderEvents.ContextChanged, this._contextChangeHandler);
}
if (this._flagChangeHandler) {
client.removeHandler(ClientProviderEvents.ConfigurationChanged, this._flagChangeHandler);
}
if (this._readyHandler) {
client.removeHandler(ClientProviderEvents.Ready, this._readyHandler);
}
if (this._reconcilingHandler) {
client.removeHandler(ClientProviderEvents.Reconciling, this._reconcilingHandler);
}
}
protected getFlagDetails(flagKey: string, defaultValue: T): EvaluationDetails<T> {
if (typeof defaultValue === 'boolean') {
return this._client.getBooleanDetails(flagKey, defaultValue) as EvaluationDetails<T>;
} else if (typeof defaultValue === 'number') {
return this._client.getNumberDetails(flagKey, defaultValue) as EvaluationDetails<T>;
} else if (typeof defaultValue === 'string') {
return this._client.getStringDetails(flagKey, defaultValue) as EvaluationDetails<T>;
} else {
return this._client.getObjectDetails(flagKey, defaultValue) as EvaluationDetails<T>;
}
}
protected onFlagValue(result: EvaluationDetails<T>, status: ClientProviderStatus): void {
const shouldInitialize = this._initializingTemplateRef && status === ClientProviderStatus.NOT_READY;
const shouldReconcile = this._reconcilingTemplateRef && status === ClientProviderStatus.RECONCILING;
const context = new FeatureFlagDirectiveContext(result);
const resultChanged = !deepEqual(this._lastEvaluationResult, result);
const isValueMatch = !this._featureFlagValue || deepEqual(result.value, this._featureFlagValue);
if (this._initializingViewRef && shouldInitialize && !resultChanged) {
return;
} else if (this._reconcilingViewRef && shouldReconcile && !resultChanged) {
return;
} else if (this._thenViewRef && isValueMatch && !shouldInitialize && !shouldReconcile && !resultChanged) {
return;
} else if (this._elseViewRef && !isValueMatch && !shouldInitialize && !shouldReconcile && !resultChanged) {
return;
}
this._lastEvaluationResult = result;
this._viewContainerRef.clear();
this._initializingViewRef = null;
this._reconcilingViewRef = null;
this._thenViewRef = null;
this._elseViewRef = null;
if (this._initializingTemplateRef && status === ClientProviderStatus.NOT_READY) {
this._initializingViewRef = this._viewContainerRef.createEmbeddedView(this._initializingTemplateRef, context);
} else if (this._reconcilingTemplateRef && status === ClientProviderStatus.RECONCILING) {
this._reconcilingViewRef = this._viewContainerRef.createEmbeddedView(this._reconcilingTemplateRef, context);
} else if (isValueMatch) {
this._thenViewRef = this._viewContainerRef.createEmbeddedView(this._thenTemplateRef, context);
} else if (this._elseTemplateRef) {
this._elseViewRef = this._viewContainerRef.createEmbeddedView(this._elseTemplateRef, context);
}
this._changeDetectorRef.markForCheck();
}
}
/**
* A structural directive that conditionally includes a template based on the evaluation
* of a boolean feature flag.
* When the flag evaluates to true, Angular renders the template provided in a `then` clause,
* and when false, Angular renders the template provided in an optional `else` clause.
* The default template for the `else` clause is blank.
*
* Usage examples:
*
* ```
* <div *booleanFeatureFlag="'flagKey'; default: false; let value">{{ value }}</div>
* ```
* ```
* <div *booleanFeatureFlag="flagKey; default: false; else: elseTemplate">Content to render when flag is true.</div>
* <ng-template #elseTemplate>Content to render when flag is false.</ng-template>
* ```
*
* @usageNotes
*
* You can specify templates for other statuses such as initializing and reconciling.
*
* ```
* <div *booleanFeatureFlag="flagKey; default:true; else: elseTemplate; initializing: initializingTemplate; reconciling: reconcilingTemplate">Content to render when flag is true.</div>
* <ng-template #elseTemplate>Content to render when flag is false.</ng-template>
* <ng-template #initializingTemplate>Loading...</ng-template>
* <ng-template #reconcilingTemplate>Reconfiguring...</ng-template>
* ```
*
*/
@Directive({
standalone: true,
selector: '[booleanFeatureFlag]',
})
export class BooleanFeatureFlagDirective extends FeatureFlagDirective<boolean> implements OnChanges {
override _changeDetectorRef = inject(ChangeDetectorRef);
override _viewContainerRef = inject(ViewContainerRef);
override _thenTemplateRef = inject<TemplateRef<FeatureFlagDirectiveContext<boolean>>>(TemplateRef);
/**
* The key of the boolean feature flag.
*/
@Input({ required: true }) booleanFeatureFlag: string;
/**
* The default value for the boolean feature flag.
*/
@Input({ required: true }) booleanFeatureFlagDefault: boolean;
constructor() {
super();
}
override ngOnChanges() {
this._featureFlagKey = this.booleanFeatureFlag;
this._featureFlagDefault = this.booleanFeatureFlagDefault;
this._featureFlagValue = true;
super.ngOnChanges();
}
/**
* The domain of the boolean feature flag.
*/
@Input({ required: false })
set booleanFeatureFlagDomain(domain: string | undefined) {
super.featureFlagDomain = domain;
}
/**
* Update the component if the provider emits a ConfigurationChanged event.
* Set to false to prevent components from re-rendering when flag value changes
* are received by the associated provider.
* Defaults to true.
*/
@Input({ required: false })
set booleanFeatureFlagUpdateOnConfigurationChanged(enabled: boolean | undefined) {
this._updateOnConfigurationChanged = enabled ?? true;
}
/**
* Update the component when the OpenFeature context changes.
* Set to false to prevent components from re-rendering when attributes which
* may be factors in flag evaluation change.
* Defaults to true.
*/
@Input({ required: false })
set booleanFeatureFlagUpdateOnContextChanged(enabled: boolean | undefined) {
this._updateOnContextChanged = enabled ?? true;
}
/**
* Template to be displayed when the feature flag is false.
*/
@Input()
set booleanFeatureFlagElse(tpl: TemplateRef<FeatureFlagDirectiveContext<boolean>>) {
this._elseTemplateRef = tpl;
}
/**
* Template to be displayed when the provider is not ready.
*/
@Input()
set booleanFeatureFlagInitializing(tpl: TemplateRef<FeatureFlagDirectiveContext<boolean>>) {
this._initializingTemplateRef = tpl;
}
/**
* Template to be displayed when the provider is reconciling.
*/
@Input()
set booleanFeatureFlagReconciling(tpl: TemplateRef<FeatureFlagDirectiveContext<boolean>>) {
this._reconcilingTemplateRef = tpl;
}
}
/**
* A structural directive that conditionally includes a template based on the evaluation
* of a number feature flag.
* When the flag matches the provided value or no expected value is given, Angular renders the template provided
* in a `then` clause, and when it doesn't match, Angular renders the template provided
* in an optional `else` clause.
* The default template for the `else` clause is blank.
*
* Usage examples:
*
* ```
* <div *numberFeatureFlag="'flagKey'; default: 0; let value">{{ value }}</div>
* ```
* ```
* <div *numberFeatureFlag="'flagKey'; value: 1; default: 0; else: elseTemplate">Content to render when flag matches value.</div>
* <ng-template #elseTemplate>Content to render when flag does not match value.</ng-template>
* ```
*
* @usageNotes
*
* You can specify templates for other statuses such as initializing and reconciling.
*
* ```
* <div *numberFeatureFlag="flagKey; default: 0; value: flagValue; else: elseTemplate; initializing: initializingTemplate; reconciling: reconcilingTemplate">Content to render when flag matches value.</div>
* <ng-template #elseTemplate>Content to render when flag does not match value.</ng-template>
* <ng-template #initializingTemplate>Loading...</ng-template>
* <ng-template #reconcilingTemplate>Reconfiguring...</ng-template>
* ```
*
*/
@Directive({
standalone: true,
selector: '[numberFeatureFlag]',
})
export class NumberFeatureFlagDirective extends FeatureFlagDirective<number> implements OnChanges {
override _changeDetectorRef = inject(ChangeDetectorRef);
override _viewContainerRef = inject(ViewContainerRef);
override _thenTemplateRef = inject<TemplateRef<FeatureFlagDirectiveContext<number>>>(TemplateRef);
/**
* The key of the number feature flag.
*/
@Input({ required: true }) numberFeatureFlag: string;
/**
* The default value for the number feature flag.
*/
@Input({ required: true }) numberFeatureFlagDefault: number;
/**
* The expected value of this number feature flag, for which the `then` template should be rendered.
*/
@Input({ required: false }) numberFeatureFlagValue?: number;
constructor() {
super();
}
override ngOnChanges() {
this._featureFlagKey = this.numberFeatureFlag;
this._featureFlagDefault = this.numberFeatureFlagDefault;
this._featureFlagValue = this.numberFeatureFlagValue;
super.ngOnChanges();
}
/**
* The domain of the number feature flag.
*/
@Input({ required: false })
set numberFeatureFlagDomain(domain: string | undefined) {
super.featureFlagDomain = domain;
}
/**
* Update the component if the provider emits a ConfigurationChanged event.
* Set to false to prevent components from re-rendering when flag value changes
* are received by the associated provider.
* Defaults to true.
*/
@Input({ required: false })
set numberFeatureFlagUpdateOnConfigurationChanged(enabled: boolean | undefined) {
this._updateOnConfigurationChanged = enabled ?? true;
}
/**
* Update the component when the OpenFeature context changes.
* Set to false to prevent components from re-rendering when attributes which
* may be factors in flag evaluation change.
* Defaults to true.
*/
@Input({ required: false })
set numberFeatureFlagUpdateOnContextChanged(enabled: boolean | undefined) {
this._updateOnContextChanged = enabled ?? true;
}
/**
* Template to be displayed when the feature flag does not match value.
*/
@Input()
set numberFeatureFlagElse(tpl: TemplateRef<FeatureFlagDirectiveContext<number>>) {
this._elseTemplateRef = tpl;
}
/**
* Template to be displayed when the feature flag is not ready.
*/
@Input()
set numberFeatureFlagInitializing(tpl: TemplateRef<FeatureFlagDirectiveContext<number>>) {
this._initializingTemplateRef = tpl;
}
/**
* Template to be displayed when the feature flag is not ready.
*/
@Input()
set numberFeatureFlagReconciling(tpl: TemplateRef<FeatureFlagDirectiveContext<number>>) {
this._reconcilingTemplateRef = tpl;
}
}
/**
* A structural directive that conditionally includes a template based on the evaluation
* of a string feature flag.
* When the flag matches the provided value or no expected value is given, Angular renders the template provided
* in a `then` clause, and when it doesn't match, Angular renders the template provided
* in an optional `else` clause.
* The default template for the `else` clause is blank.
*
* Usage examples:
*
* ```
* <div *stringFeatureFlag="'flagKey'; default: 'default'; let value">{{ value }}</div>
* ```
* ```
* <div *stringFeatureFlag="flagKey; default: 'default'; value: flagValue; else: elseTemplate">Content to render when flag matches value.</div>
* <ng-template #elseTemplate>Content to render when flag does not match value.</ng-template>
* ```
*
* @usageNotes
*
* You can specify templates for other statuses such as initializing and reconciling.
*
* ```
* <div *stringFeatureFlag="flagKey; default: 'default'; value: flagValue; else: elseTemplate; initializing: initializingTemplate; reconciling: reconcilingTemplate">Content to render when flag matches value.</div>
* <ng-template #elseTemplate>Content to render when flag does not match value.</ng-template>
* <ng-template #initializingTemplate>Loading...</ng-template>
* <ng-template #reconcilingTemplate>Reconfiguring...</ng-template>
* ```
*
*/
@Directive({
standalone: true,
selector: '[stringFeatureFlag]',
})
export class StringFeatureFlagDirective extends FeatureFlagDirective<string> implements OnChanges {
override _changeDetectorRef = inject(ChangeDetectorRef);
override _viewContainerRef = inject(ViewContainerRef);
override _thenTemplateRef = inject<TemplateRef<FeatureFlagDirectiveContext<string>>>(TemplateRef);
/**
* The key of the string feature flag.
*/
@Input({ required: true }) stringFeatureFlag: string;
/**
* The default value for the string feature flag.
*/
@Input({ required: true }) stringFeatureFlagDefault: string;
/**
* The expected value of this string feature flag, for which the `then` template should be rendered.
*/
@Input({ required: false }) stringFeatureFlagValue?: string;
constructor() {
super();
}
override ngOnChanges() {
this._featureFlagKey = this.stringFeatureFlag;
this._featureFlagDefault = this.stringFeatureFlagDefault;
this._featureFlagValue = this.stringFeatureFlagValue;
super.ngOnChanges();
}
/**
* The domain for the string feature flag.
*/
@Input({ required: false })
set stringFeatureFlagDomain(domain: string | undefined) {
super.featureFlagDomain = domain;
}
/**
* Update the component if the provider emits a ConfigurationChanged event.
* Set to false to prevent components from re-rendering when flag value changes
* are received by the associated provider.
* Defaults to true.
*/
@Input({ required: false })
set stringFeatureFlagUpdateOnConfigurationChanged(enabled: boolean | undefined) {
this._updateOnConfigurationChanged = enabled ?? true;
}
/**
* Update the component when the OpenFeature context changes.
* Set to false to prevent components from re-rendering when attributes which
* may be factors in flag evaluation change.
* Defaults to true.
*/
@Input({ required: false })
set stringFeatureFlagUpdateOnContextChanged(enabled: boolean | undefined) {
this._updateOnContextChanged = enabled ?? true;
}
/**
* Template to be displayed when the feature flag does not match value.
*/
@Input()
set stringFeatureFlagElse(tpl: TemplateRef<FeatureFlagDirectiveContext<string>>) {
this._elseTemplateRef = tpl;
}
/**
* Template to be displayed when the feature flag is not ready.
*/
@Input()
set stringFeatureFlagInitializing(tpl: TemplateRef<FeatureFlagDirectiveContext<string>>) {
this._initializingTemplateRef = tpl;
}
/**
* Template to be displayed when the feature flag is reconciling.
*/
@Input()
set stringFeatureFlagReconciling(tpl: TemplateRef<FeatureFlagDirectiveContext<string>>) {
this._reconcilingTemplateRef = tpl;
}
}
/**
* A structural directive that conditionally includes a template based on the evaluation
* of an object feature flag.
* When the flag matches the provided value or no expected value is given, Angular renders the template provided
* in a `then` clause, and when it doesn't match, Angular renders the template provided
* in an optional `else` clause.
* The default template for the `else` clause is blank.
*
* Usage examples:
*
* ```
* <div *objectFeatureFlag="'flagKey'; default: {}; let value">{{ value }}</div>
* ```
* ```
* <div *objectFeatureFlag="flagKey; default: {}; value: flagValue; else: elseTemplate">Content to render when flag matches value.</div>
* <ng-template #elseTemplate>Content to render when flag does not match value.</ng-template>
* ```
*
* @usageNotes
*
* You can specify templates for other statuses such as initializing and reconciling.
*
* ```
* <div *objectFeatureFlag="flagKey; default: {}; value: flagValue; else: elseTemplate; initializing: initializingTemplate; reconciling: reconcilingTemplate">Content to render when flag matches value.</div>
* <ng-template #elseTemplate>Content to render when flag does not match value.</ng-template>
* <ng-template #initializingTemplate>Loading...</ng-template>
* <ng-template #reconcilingTemplate>Reconfiguring...</ng-template>
* ```
*
*/
@Directive({
standalone: true,
selector: '[objectFeatureFlag]',
})
export class ObjectFeatureFlagDirective<T extends JsonValue> extends FeatureFlagDirective<T> implements OnChanges {
override _changeDetectorRef = inject(ChangeDetectorRef);
override _viewContainerRef = inject(ViewContainerRef);
override _thenTemplateRef = inject<TemplateRef<FeatureFlagDirectiveContext<T>>>(TemplateRef);
/**
* The key of the object feature flag.
*/
@Input({ required: true }) objectFeatureFlag: string;
/**
* The default value for the object feature flag.
*/
@Input({ required: true }) objectFeatureFlagDefault: T;
/**
* The expected value of this object feature flag, for which the `then` template should be rendered.
*/
@Input({ required: false }) objectFeatureFlagValue?: T;
constructor() {
super();
}
override ngOnChanges() {
this._featureFlagKey = this.objectFeatureFlag;
this._featureFlagDefault = this.objectFeatureFlagDefault;
this._featureFlagValue = this.objectFeatureFlagValue;
super.ngOnChanges();
}
/**
* The domain for the object feature flag.
*/
@Input({ required: false })
set objectFeatureFlagDomain(domain: string | undefined) {
super.featureFlagDomain = domain;
}
/**
* Update the component if the provider emits a ConfigurationChanged event.
* Set to false to prevent components from re-rendering when flag value changes
* are received by the associated provider.
* Defaults to true.
*/
@Input({ required: false })
set objectFeatureFlagUpdateOnConfigurationChanged(enabled: boolean | undefined) {
this._updateOnConfigurationChanged = enabled ?? true;
}
/**
* Update the component when the OpenFeature context changes.
* Set to false to prevent components from re-rendering when attributes which
* may be factors in flag evaluation change.
* Defaults to true.
*/
@Input({ required: false })
set objectFeatureFlagUpdateOnContextChanged(enabled: boolean | undefined) {
this._updateOnContextChanged = enabled ?? true;
}
/**
* Template to be displayed when the feature flag does not match value.
*/
@Input()
set objectFeatureFlagElse(tpl: TemplateRef<FeatureFlagDirectiveContext<T>>) {
this._elseTemplateRef = tpl;
}
/**
* Template to be displayed when the feature flag is not ready.
*/
@Input()
set objectFeatureFlagInitializing(tpl: TemplateRef<FeatureFlagDirectiveContext<T>>) {
this._initializingTemplateRef = tpl;
}
/**
* Template to be displayed when the feature flag is reconciling.
*/
@Input()
set objectFeatureFlagReconciling(tpl: TemplateRef<FeatureFlagDirectiveContext<T>>) {
this._reconcilingTemplateRef = tpl;
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function deepEqual(obj1: any, obj2: any): boolean {
if (obj1 === obj2) {
// If both objects are identical
return true;
}
if (typeof obj1 !== 'object' || obj1 === null || typeof obj2 !== 'object' || obj2 === null) {
// One of them is not an object or one of them is null
return false;
}
const keys1 = Object.keys(obj1);
const keys2 = Object.keys(obj2);
if (keys1.length !== keys2.length) {
// Different number of properties
return false;
}
for (const key of keys1) {
if (!keys2.includes(key)) {
// obj2 does not have a property that obj1 has
return false;
}
// Recursive check for each property
if (!deepEqual(obj1[key], obj2[key])) {
return false;
}
}
return true;
}

View File

@ -1,36 +0,0 @@
import { InjectionToken, ModuleWithProviders, NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { EvaluationContext, OpenFeature, Provider } from '@openfeature/web-sdk';
export type EvaluationContextFactory = () => EvaluationContext;
export interface OpenFeatureConfig {
provider: Provider;
domainBoundProviders?: Record<string, Provider>;
context?: EvaluationContext | EvaluationContextFactory;
}
export const OPEN_FEATURE_CONFIG_TOKEN = new InjectionToken<OpenFeatureConfig>('OPEN_FEATURE_CONFIG_TOKEN');
@NgModule({
declarations: [],
imports: [CommonModule],
exports: [],
})
export class OpenFeatureModule {
static forRoot(config: OpenFeatureConfig): ModuleWithProviders<OpenFeatureModule> {
const context = typeof config.context === 'function' ? config.context() : config.context;
OpenFeature.setProvider(config.provider, context);
if (config.domainBoundProviders) {
Object.entries(config.domainBoundProviders).map(([domain, provider]) =>
OpenFeature.setProvider(domain, provider),
);
}
return {
ngModule: OpenFeatureModule,
providers: [{ provide: OPEN_FEATURE_CONFIG_TOKEN, useValue: config }],
};
}
}

View File

@ -1,9 +0,0 @@
/*
* Public API Surface of angular
*/
export * from './lib/feature-flag.directive';
export * from './lib/open-feature.module';
// re-export the web-sdk so consumers can access that API from the angular-sdk
export * from '@openfeature/web-sdk';

View File

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

View File

@ -1,20 +0,0 @@
import { InMemoryProvider } from '@openfeature/web-sdk';
export class TestingProvider extends InMemoryProvider {
constructor(
flagConfiguration: ConstructorParameters<typeof InMemoryProvider>[0],
private delay: number,
) {
super(flagConfiguration);
}
// artificially delay our init (delaying PROVIDER_READY event)
async initialize(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, this.delay));
}
// artificially delay context changes
async onContextChange(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, this.delay));
}
}

View File

@ -1,19 +0,0 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "../../out-tsc/lib",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": [],
"paths": {
"angular": [
"./dist/angular"
],
}
},
"exclude": [
"**/*.spec.ts"
]
}

View File

@ -1,15 +0,0 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "./tsconfig.lib.json",
"compilerOptions": {
"declarationMap": false
},
"paths": {
"angular": [
"./dist/angular"
]
},
"angularCompilerOptions": {
"compilationMode": "partial"
}
}

View File

@ -1,22 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"vitest/globals",
"node"
],
"paths": {
"angular": [
"./dist/angular"
]
},
"esModuleInterop": true,
"emitDecoratorMetadata": true
},
"include": [
"src/**/*.spec.ts",
"src/**/*.d.ts",
"src/test-provider.ts"
]
}

View File

@ -1,42 +0,0 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"compileOnSave": false,
"compilerOptions": {
"paths": {
"angular": [
"./dist/angular"
],
"@openfeature/core": [ "../shared/src" ],
"@openfeature/web-sdk": [ "../web/src" ]
},
"outDir": "./dist/out-tsc",
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"esModuleInterop": true,
"sourceMap": true,
"declaration": false,
"experimentalDecorators": true,
"moduleResolution": "node",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022",
"useDefineForClassFields": false,
"strictNullChecks": false,
"lib": [
"ES2022",
"dom"
],
},
"angularCompilerOptions": {
"disableTypeScriptVersionCheck": true,
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

View File

@ -1,16 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"vitest/globals",
"node"
],
"esModuleInterop": true,
"emitDecoratorMetadata": true
},
"include": [
"projects/angular-sdk/src/**/*.spec.ts",
"projects/angular-sdk/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,233 +1,6 @@
# Changelog
## [1.6.1](https://github.com/open-feature/js-sdk/compare/web-sdk-v1.6.0...web-sdk-v1.6.1) (2025-08-14)
### 🐛 Bug Fixes
* update core dep ([#1228](https://github.com/open-feature/js-sdk/issues/1228)) ([845d24c](https://github.com/open-feature/js-sdk/commit/845d24c5fecc80de3080e49fde839f08ecac6b33))
## [1.6.0](https://github.com/open-feature/js-sdk/compare/web-sdk-v1.5.0...web-sdk-v1.6.0) (2025-08-12)
### ✨ New Features
* add evaluation-scoped hook data ([#1216](https://github.com/open-feature/js-sdk/issues/1216)) ([07af3a9](https://github.com/open-feature/js-sdk/commit/07af3a9eda895e9edb24c7ee1e3c1c4f16e17431))
* **web-global-build:** impl ([#1225](https://github.com/open-feature/js-sdk/issues/1225)) ([40a512e](https://github.com/open-feature/js-sdk/commit/40a512e21204eb92dc3ef4161b383f9c1fd74da7))
### 📚 Documentation
* Clarify the behavior of setProviderAndWait ([#1180](https://github.com/open-feature/js-sdk/issues/1180)) ([4fe8d87](https://github.com/open-feature/js-sdk/commit/4fe8d87a2e5df2cbd4086cc4f4a380e8857ed8ba))
## [1.5.0](https://github.com/open-feature/js-sdk/compare/web-sdk-v1.4.1...web-sdk-v1.5.0) (2025-04-11)
### ✨ New Features
* add a top-level method for accessing providers ([#1152](https://github.com/open-feature/js-sdk/issues/1152)) ([ae8fce8](https://github.com/open-feature/js-sdk/commit/ae8fce87530005ed20f7e68dc696ce67053fca31))
* add support for abort controllers to event handlers ([#1151](https://github.com/open-feature/js-sdk/issues/1151)) ([6a22483](https://github.com/open-feature/js-sdk/commit/6a224830fa4e62fc30a7802536f6f6fc3f772038))
### 🐛 Bug Fixes
* Typo in name of the function ([2c5b37c](https://github.com/open-feature/js-sdk/commit/2c5b37c79d72d60864c27b9e67d96e99ef4ae241))
## [1.4.1](https://github.com/open-feature/js-sdk/compare/web-sdk-v1.4.0...web-sdk-v1.4.1) (2025-02-07)
### 🐛 Bug Fixes
* msg missing when providers return err resolutions ([#1134](https://github.com/open-feature/js-sdk/issues/1134)) ([bc9f6e4](https://github.com/open-feature/js-sdk/commit/bc9f6e44da3f1c0a66659aee2d0316629ac34fbf))
### 🧹 Chore
* update core peer ([8bbd43e](https://github.com/open-feature/js-sdk/commit/8bbd43e579a0c2e0c5b7eec00f94bbcffce04773))
## [1.4.0](https://github.com/open-feature/js-sdk/compare/web-sdk-v1.3.2...web-sdk-v1.4.0) (2024-12-18)
### ⚠ BREAKING CHANGES
The signature of the `finally` hook stage has been changed. The signature now includes the `evaluation details`, as per the [OpenFeature specification](https://openfeature.dev/specification/sections/hooks#requirement-438). Note that since hooks are still `experimental,` this does not constitute a change requiring a new major version. To migrate, update any hook that implements the `finally` stage to accept `evaluation details` as the second argument.
* add evaluation details to finally hook ([#1087](https://github.com/open-feature/js-sdk/issues/1087)) ([2135254](https://github.com/open-feature/js-sdk/commit/2135254c4bee52b4bcadfbf8b99a896cfd930cca))
### ✨ New Features
* add evaluation details to finally hook ([#1087](https://github.com/open-feature/js-sdk/issues/1087)) ([2135254](https://github.com/open-feature/js-sdk/commit/2135254c4bee52b4bcadfbf8b99a896cfd930cca))
### 📚 Documentation
* fix comment in README for Hooks after method ([#1102](https://github.com/open-feature/js-sdk/issues/1102)) ([ba8d1ae](https://github.com/open-feature/js-sdk/commit/ba8d1aeec837cb089cda3499d44ecc505ea0c947))
### 🔄 Refactoring
* improve track interface for providers ([#1100](https://github.com/open-feature/js-sdk/issues/1100)) ([5e5b160](https://github.com/open-feature/js-sdk/commit/5e5b16022122b71760634ac90e3fd962aa831c74))
## [1.3.2](https://github.com/open-feature/js-sdk/compare/web-sdk-v1.3.1...web-sdk-v1.3.2) (2024-11-07)
### 🐛 Bug Fixes
* update OpenFeature core version to 1.5.0 ([#1077](https://github.com/open-feature/js-sdk/issues/1077)) ([a3469b6](https://github.com/open-feature/js-sdk/commit/a3469b6799c3f0d77cb38dcdc708ce615123d7f6))
### 🧹 Chore
* loosen peer dependency requirements, remove some ci automation ([#1080](https://github.com/open-feature/js-sdk/issues/1080)) ([ef3ba21](https://github.com/open-feature/js-sdk/commit/ef3ba2167ac95cd0c6a046d206bd60bbcf84e80c))
### 🚀 Performance
* avoid using exceptions for flow control ([#1074](https://github.com/open-feature/js-sdk/issues/1074)) ([26264d6](https://github.com/open-feature/js-sdk/commit/26264d6d090b2ed31b27d36e71194b9fa911563b))
## [1.3.1](https://github.com/open-feature/js-sdk/compare/web-sdk-v1.3.0...web-sdk-v1.3.1) (2024-10-29)
### 📚 Documentation
* add tracking sections ([#1068](https://github.com/open-feature/js-sdk/issues/1068)) ([e131faf](https://github.com/open-feature/js-sdk/commit/e131faffad9025e9c7194f39558bf3b3cec31807))
## [1.3.0](https://github.com/open-feature/js-sdk/compare/web-sdk-v1.2.4...web-sdk-v1.3.0) (2024-10-29)
### ✨ New Features
* implement tracking as per spec ([#1020](https://github.com/open-feature/js-sdk/issues/1020)) ([80f182e](https://github.com/open-feature/js-sdk/commit/80f182e1afbd3a705bf3de6a0d9886ccb3424b44))
### 🧹 Chore
* import type lint rule and fixes ([#1039](https://github.com/open-feature/js-sdk/issues/1039)) ([01fcb93](https://github.com/open-feature/js-sdk/commit/01fcb933d2cbd131a0f4a005173cdd1906087e18))
* **main:** release core 1.5.0 ([#1040](https://github.com/open-feature/js-sdk/issues/1040)) ([fe3ad8e](https://github.com/open-feature/js-sdk/commit/fe3ad8eeb9219ff08ba287cab228016da0b88e88))
### 📚 Documentation
* update domain context terminology ([#1037](https://github.com/open-feature/js-sdk/issues/1037)) ([924802b](https://github.com/open-feature/js-sdk/commit/924802b21d70889631e1fb0fb02225a7f8d2638d))
## [1.2.4](https://github.com/open-feature/js-sdk/compare/web-sdk-v1.2.3...web-sdk-v1.2.4) (2024-09-20)
### 🧹 Chore
* **web:** bump core peer version ([#1018](https://github.com/open-feature/js-sdk/issues/1018)) ([970335e](https://github.com/open-feature/js-sdk/commit/970335e92bbaa7bf093120da3fab03659b0c11bf))
## [1.2.3](https://github.com/open-feature/js-sdk/compare/web-sdk-v1.2.2...web-sdk-v1.2.3) (2024-08-28)
### 🧹 Chore
* **main:** release core 1.4.0 ([#984](https://github.com/open-feature/js-sdk/issues/984)) ([01344b2](https://github.com/open-feature/js-sdk/commit/01344b28c1381d9de3aefde89be841b597a00b70))
* move client/ dir to web/ ([#991](https://github.com/open-feature/js-sdk/issues/991)) ([df4e72e](https://github.com/open-feature/js-sdk/commit/df4e72eabc3370801303470ca37263a0d4d9bb38))
## [1.2.2](https://github.com/open-feature/js-sdk/compare/web-sdk-v1.2.1...web-sdk-v1.2.2) (2024-08-22)
### 🐛 Bug Fixes
* race condition in test provider with suspense ([#980](https://github.com/open-feature/js-sdk/issues/980)) ([0f187fe](https://github.com/open-feature/js-sdk/commit/0f187fe0b584e66b6283531eb7879c320967f921))
## [1.2.1](https://github.com/open-feature/js-sdk/compare/web-sdk-v1.2.0...web-sdk-v1.2.1) (2024-06-12)
### 🐛 Bug Fixes
* **web-sdk:** pin core version to 1.3.0 ([#964](https://github.com/open-feature/js-sdk/issues/964)) ([3cde37a](https://github.com/open-feature/js-sdk/commit/3cde37a5ee29e71a0eb3fd8680b081d865a588f9))
## [1.2.0](https://github.com/open-feature/js-sdk/compare/web-sdk-v1.1.0...web-sdk-v1.2.0) (2024-06-11)
### ✨ New Features
* lower compilation target to es2015 ([#957](https://github.com/open-feature/js-sdk/issues/957)) ([c2d6c17](https://github.com/open-feature/js-sdk/commit/c2d6c1761ae19f937deaff2f011a0380f8af7350))
### 🧹 Chore
* **main:** release core 1.3.0 ([#958](https://github.com/open-feature/js-sdk/issues/958)) ([25086c5](https://github.com/open-feature/js-sdk/commit/25086c5456d81fa040ce95ea1a067543408e3150))
## [1.1.0](https://github.com/open-feature/js-sdk/compare/web-sdk-v1.0.3...web-sdk-v1.1.0) (2024-05-13)
### ✨ New Features
* set context during provider init on web ([#919](https://github.com/open-feature/js-sdk/issues/919)) ([7e6c1c6](https://github.com/open-feature/js-sdk/commit/7e6c1c6e7082e75535bf81b4e70c8c57ef870b77))
### 🐛 Bug Fixes
* remove export of OpenFeatureClient ([#794](https://github.com/open-feature/js-sdk/issues/794)) ([3d197f2](https://github.com/open-feature/js-sdk/commit/3d197f2ea74f00ef904fc6a6960160d0cf4ded9a))
* removes exports of OpenFeatureClient class and makes event props readonly ([#918](https://github.com/open-feature/js-sdk/issues/918)) ([e9a25c2](https://github.com/open-feature/js-sdk/commit/e9a25c21cb17c3b5700bca652e3c0ed15e8f49b4))
* run error hook when provider returns reason error or error code ([#926](https://github.com/open-feature/js-sdk/issues/926)) ([c6d0b5d](https://github.com/open-feature/js-sdk/commit/c6d0b5da9c7f4c11319422fbe8c668a7613b044d))
* skip reconciling event for synchronous onContextChange operations ([#931](https://github.com/open-feature/js-sdk/issues/931)) ([6c25f29](https://github.com/open-feature/js-sdk/commit/6c25f29f11ddb9d4ee617f1ed3f1d26be4f554ac))
### 🧹 Chore
* **main:** release core 1.2.0 ([#927](https://github.com/open-feature/js-sdk/issues/927)) ([692ad5b](https://github.com/open-feature/js-sdk/commit/692ad5b27a052a4c5abba81fe1caa071edd59ee7))
### 📚 Documentation
* add tip about supported usage in the install section ([#941](https://github.com/open-feature/js-sdk/issues/941)) ([f0de667](https://github.com/open-feature/js-sdk/commit/f0de66770be778d7a51063e706c9cccbba4b214e))
## [1.0.3](https://github.com/open-feature/js-sdk/compare/web-sdk-v1.0.2...web-sdk-v1.0.3) (2024-04-18)
### 🧹 Chore
* bump spec version badge to v0.8.0 ([#910](https://github.com/open-feature/js-sdk/issues/910)) ([a7b2c4b](https://github.com/open-feature/js-sdk/commit/a7b2c4bca09112d49e637735466502adb1438ebe))
## [1.0.2](https://github.com/open-feature/js-sdk/compare/web-sdk-v1.0.1...web-sdk-v1.0.2) (2024-04-02)
### 🐛 Bug Fixes
* return metadata for the bound provider in hookContext ([#883](https://github.com/open-feature/js-sdk/issues/883)) ([fd84025](https://github.com/open-feature/js-sdk/commit/fd84025bdfe30e8d730fa546d01c1ad6c6953189))
### 🧹 Chore
* **main:** release core 1.1.0 ([#899](https://github.com/open-feature/js-sdk/issues/899)) ([b3e5f7e](https://github.com/open-feature/js-sdk/commit/b3e5f7eb2aac5d5533c51764242e06a6ba508082))
## [1.0.1](https://github.com/open-feature/js-sdk/compare/web-sdk-v1.0.0...web-sdk-v1.0.1) (2024-03-25)
### 📚 Documentation
* add peer dep explainer ([#876](https://github.com/open-feature/js-sdk/issues/876)) ([cfd23b9](https://github.com/open-feature/js-sdk/commit/cfd23b90f0ca2673253fbbe30f4db585e746bc63))
## [1.0.0](https://github.com/open-feature/js-sdk/compare/web-sdk-v0.4.16...web-sdk-v1.0.0) (2024-03-13)
### 🧹 Chore
* prompt web-sdk 1.0 ([#871](https://github.com/open-feature/js-sdk/issues/871)) ([7d50d93](https://github.com/open-feature/js-sdk/commit/7d50d931d5cda349a31969c997e7581ea4883b6a))
## [0.4.16](https://github.com/open-feature/js-sdk/compare/web-sdk-v0.4.15...web-sdk-v0.4.16) (2024-03-12)
### 🧹 Chore
* **main:** release core 1.0.0 ([#869](https://github.com/open-feature/js-sdk/issues/869)) ([4191a02](https://github.com/open-feature/js-sdk/commit/4191a02dbc5b66053b63d19e2e9c5bf750aaf4bf))
## [0.4.15](https://github.com/open-feature/js-sdk/compare/web-sdk-v0.4.14...web-sdk-v0.4.15) (2024-03-05)
### ✨ New Features
* use EvenEmitter3 for web-sdk ([#847](https://github.com/open-feature/js-sdk/issues/847)) ([861cf83](https://github.com/open-feature/js-sdk/commit/861cf8378271daf6205c5fc199ffc1bde8dfcc64))
### 🧹 Chore
* **main:** release core 0.0.28 ([#849](https://github.com/open-feature/js-sdk/issues/849)) ([31b92a9](https://github.com/open-feature/js-sdk/commit/31b92a97c19071334cb7cf10767be9d40be55943))
## [0.4.14](https://github.com/open-feature/js-sdk/compare/web-sdk-v0.4.13...web-sdk-v0.4.14) (2024-03-05)

View File

@ -12,15 +12,18 @@
<!-- x-hide-in-docs-end -->
<!-- The 'github-badges' class is used in the docs -->
<p align="center" class="github-badges">
<a href="https://github.com/open-feature/spec/releases/tag/v0.8.0">
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
<a href="https://github.com/open-feature/spec/releases/tag/v0.7.0">
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.7.0&color=yellow&style=for-the-badge" />
</a>
<!-- x-release-please-start-version -->
<a href="https://github.com/open-feature/js-sdk/releases/tag/web-sdk-v1.6.1">
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v1.6.1&color=blue&style=for-the-badge" />
<a href="https://github.com/open-feature/js-sdk/releases/tag/web-sdk-v0.4.14">
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.4.14&color=blue&style=for-the-badge" />
</a>
<!-- x-release-please-end -->
<br/>
<a href="https://www.repostatus.org/#wip">
<img alt="Project Status" src="https://www.repostatus.org/badges/latest/wip.svg" />
</a>
<a href="https://open-feature.github.io/js-sdk/modules/_openfeature_web_sdk.html">
<img alt="API Reference" src="https://img.shields.io/badge/reference-teal?logo=javascript&logoColor=white" />
</a>
@ -36,7 +39,7 @@
</p>
<!-- x-hide-in-docs-start -->
[OpenFeature](https://openfeature.dev) is an open specification that provides a vendor-agnostic, community-driven API for feature flagging that works with your favorite feature flag management tool or in-house solution.
[OpenFeature](https://openfeature.dev) is an open specification that provides a vendor-agnostic, community-driven API for feature flagging that works with your favorite feature flag management tool.
<!-- x-hide-in-docs-end -->
@ -44,7 +47,7 @@
### Requirements
- ES2015-compatible web browser (Chrome, Edge, Firefox, etc)
- ES2022-compatible web browser (Chrome, Edge, Firefox, etc)
### Install
@ -54,9 +57,6 @@
npm install --save @openfeature/web-sdk
```
> [!TIP]
> This SDK is designed to run in the browser. If you're interested in server support, check out the [Node.js SDK](https://openfeature.dev/docs/reference/technologies/server/javascript/).
#### yarn
```sh
@ -64,22 +64,13 @@ npm install --save @openfeature/web-sdk
yarn add @openfeature/web-sdk @openfeature/core
```
> [!NOTE]
> `@openfeature/core` contains common components used by all OpenFeature JavaScript implementations.
> Every SDK version has a requirement on a single, specific version of this dependency.
> For more information, and similar implications on libraries developed with OpenFeature see [considerations when extending](#considerations).
### Usage
```ts
import { OpenFeature } from '@openfeature/web-sdk';
// Register your feature flag provider
try {
await OpenFeature.setProviderAndWait(new YourProviderOfChoice());
} catch (error) {
console.error('Failed to initialize provider:', error);
}
await OpenFeature.setProviderAndWait(new YourProviderOfChoice());
// create a new client
const client = OpenFeature.getClient();
@ -106,7 +97,6 @@ See [here](https://open-feature.github.io/js-sdk/modules/_openfeature_web_sdk.ht
| ✅ | [Logging](#logging) | Integrate with popular logging packages. |
| ✅ | [Domains](#domains) | Logically bind clients with providers. |
| ✅ | [Eventing](#eventing) | React to state changes in the provider or flag management system. |
| ✅ | [Tracking](#tracking) | Associate user actions with feature flag evaluations, particularly for A/B testing. |
| ✅ | [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. |
| ✅ | [Extending](#extending) | Extend OpenFeature with custom providers and hooks. |
@ -125,12 +115,8 @@ Once you've added a provider as a dependency, it can be registered with OpenFeat
To register a provider and ensure it is ready before further actions are taken, you can use the `setProviderAndWait` method as shown below:
```ts
try {
await OpenFeature.setProviderAndWait(new MyProvider());
} catch (error) {
console.error('Failed to initialize provider:', error);
}
```
await OpenFeature.setProviderAndWait(new MyProvider());
```
#### Synchronous
@ -167,20 +153,13 @@ Sometimes, the value of a flag must consider some dynamic criteria about the app
In OpenFeature, we refer to this as [targeting](https://openfeature.dev/specification/glossary#targeting).
If the flag management system you're using supports targeting, you can provide the input data using the [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context).
```ts
// Sets global context during provider registration
await OpenFeature.setProvider(new MyProvider(), { origin: document.location.host });
```
Change context after the provider has been registered using `setContext`.
```ts
// Set a value to the global context
await OpenFeature.setContext({ targetingKey: localStorage.getItem("targetingKey") });
await OpenFeature.setContext({ origin: document.location.host });
```
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.
If needed, a request will be made to the provider with the new context in order to get the correct flag values.
@ -249,24 +228,6 @@ const domainScopedClient = OpenFeature.getClient("my-domain");
Domains can be defined on a provider during registration.
For more details, please refer to the [providers](#providers) section.
#### Manage evaluation context for domains
By default, domain-scoped clients use the global context.
This can be overridden by explicitly setting context when registering the provider or by referencing the domain when updating context:
```ts
OpenFeature.setProvider("my-domain", new NewCachedProvider(), { targetingKey: localStorage.getItem("targetingKey") });
```
To change context after the provider has been registered, use `setContext` with a domain:
```ts
await OpenFeature.setContext("my-domain", { targetingKey: localStorage.getItem("targetingKey") })
```
Once a domain's context has been defined, it will override the global context for all clients bound to the domain.
Context can be cleared for a domain by calling `OpenFeature.clearContext("my-domain")` or `OpenFeature.clearContexts()` to reset all context.
### Eventing
Events allow you to react to state changes in the provider or underlying flag management system, such as flag definition changes, provider readiness, or error conditions.
@ -290,21 +251,6 @@ client.addHandler(ProviderEvents.Error, (eventDetails) => {
});
```
### Tracking
The tracking API allows you to use OpenFeature abstractions and objects to associate user actions with feature flag evaluations.
This is essential for robust experimentation powered by feature flags.
For example, a flag enhancing the appearance of a UI component might drive user engagement to a new feature; to test this hypothesis, telemetry collected by a [hook](#hooks) or [provider](#providers) can be associated with telemetry reported in the client's `track` function.
```ts
// flag is evaluated
client.getBooleanValue('new-feature', false);
// new feature is used and track function is called recording the usage
useNewFeature();
client.track('new-feature-used');
```
### Shutdown
The OpenFeature API provides a close function to perform a cleanup of all registered providers.
@ -363,7 +309,7 @@ class MyProvider implements Provider {
}
// implement with "new OpenFeatureEventEmitter()", and use "emit()" to emit events
events?: ProviderEventEmitter<AnyProviderEvent> | undefined;
events?: ProviderEventEmitter<AnyProviderEvent> | undefined;
initialize?(context?: EvaluationContext | undefined): Promise<void> {
// code to initialize your provider
@ -387,17 +333,9 @@ import type { Hook, HookContext, EvaluationDetails, FlagValue } from "@openfeatu
export class MyHook implements Hook {
after(hookContext: HookContext, evaluationDetails: EvaluationDetails<FlagValue>) {
// code that runs after flag values are successfully resolved from the provider
// code that runs when there's an error during a flag evaluation
}
}
```
> Built a new hook? [Let us know](https://github.com/open-feature/openfeature.dev/issues/new?assignees=&labels=hook&projects=&template=document-hook.yaml&title=%5BHook%5D%3A+) so we can add it to the docs!
### Considerations
When developing a library based on OpenFeature components, it's important to list the `@openfeature/web-sdk` as a `peerDependency` of your package.
This is a general best-practice when developing JavaScript libraries that have dependencies in common with their consuming application.
Failing to do this can result in multiple copies of the OpenFeature SDK in the consumer, which can lead to type errors, and broken singleton behavior.
The `@openfeature/core` package itself follows this pattern: the `@openfeature/web-sdk` has a peer dependency on `@openfeature/core`, and uses whatever copy of that module the consumer has installed (note that NPM installs peers automatically unless `--legacy-peer-deps` is set, while yarn does not, and PNPM does so based on its configuration).
When developing such libraries, it's NOT necessary to add a `peerDependency` on `@openfeature/core`, since the `@openfeature/web-sdk` establishes that dependency itself transitively.

View File

@ -1,17 +1,16 @@
import type {
import {
EvaluationContext,
EvaluationDetails,
JsonObject,
JsonValue,
ResolutionDetails} from '@openfeature/core';
import {
ResolutionDetails,
StandardResolutionReasons,
} from '@openfeature/core';
import { defineFeature, loadFeature } from 'jest-cucumber';
import { InMemoryProvider, OpenFeature } from '../../src';
import flagConfiguration from './flags-config';
// load the feature file.
const feature = loadFeature('packages/web/e2e/features/evaluation.feature');
const feature = loadFeature('packages/client/e2e/features/evaluation.feature');
// get a client (flagd provider registered in setup)
const client = OpenFeature.getClient();

View File

@ -1,10 +1,8 @@
{
"name": "@openfeature/web-sdk",
"version": "1.6.1",
"version": "0.4.14",
"description": "OpenFeature SDK for Web",
"main": "./dist/cjs/index.js",
"unpkg": "dist/global/index.min.js",
"jsdelivr": "dist/global/index.min.js",
"files": [
"dist/"
],
@ -18,18 +16,16 @@
"scripts": {
"test": "jest --verbose",
"lint": "eslint ./",
"lint:fix": "eslint ./ --fix",
"clean": "shx rm -rf ./dist",
"build:web-esm": "esbuild src/index.ts --bundle --external:@openfeature/core --sourcemap --target=es2015 --platform=browser --format=esm --outfile=./dist/esm/index.js --analyze",
"build:web-cjs": "esbuild src/index.ts --bundle --external:@openfeature/core --sourcemap --target=es2015 --platform=browser --format=cjs --outfile=./dist/cjs/index.js --analyze",
"build:web-global": "esbuild src/index.ts --bundle --sourcemap --target=es2015 --platform=browser --format=iife --outfile=./dist/global/index.js --global-name=OpenFeature --analyze",
"build:web-global:min": "esbuild src/index.ts --bundle --sourcemap --target=es2015 --platform=browser --format=iife --outfile=./dist/global/index.min.js --global-name=OpenFeature --minify --analyze",
"build:web-esm": "esbuild src/index.ts --bundle --external:@openfeature/core --sourcemap --target=es2022 --platform=browser --format=esm --outfile=./dist/esm/index.js --analyze",
"build:web-cjs": "esbuild src/index.ts --bundle --external:@openfeature/core --sourcemap --target=es2022 --platform=browser --format=cjs --outfile=./dist/cjs/index.js --analyze",
"build:rollup-types": "rollup -c ../../rollup.config.mjs",
"build": "npm run clean && npm run build:web-esm && npm run build:web-cjs && npm run build: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",
"current-version": "echo $npm_package_version",
"prepack": "shx cp ./../../LICENSE ./LICENSE",
"publish-if-not-exists": "cp $NPM_CONFIG_USERCONFIG .npmrc && if [ \"$(npm show $npm_package_name@$npm_package_version version)\" = \"$(npm run current-version -s)\" ]; then echo 'already published, skipping'; else npm publish --access public; fi"
"publish-if-not-exists": "cp $NPM_CONFIG_USERCONFIG .npmrc && if [ \"$(npm show $npm_package_name@$npm_package_version version)\" = \"$(npm run current-version -s)\" ]; then echo 'already published, skipping'; else npm publish --access public; fi",
"update-core-peer": "npm install --save-peer --save-exact @openfeature/core@$OPENFEATURE_CORE_VERSION"
},
"repository": {
"type": "git",
@ -50,9 +46,9 @@
},
"homepage": "https://github.com/open-feature/js-sdk#readme",
"peerDependencies": {
"@openfeature/core": "^1.9.0"
"@openfeature/core": "0.0.27"
},
"devDependencies": {
"@openfeature/core": "^1.9.0"
"@openfeature/core": "0.0.27"
}
}

View File

@ -0,0 +1,12 @@
import { ClientMetadata, EvaluationLifeCycle, Eventing, ManageLogger } from '@openfeature/core';
import { Features } from '../evaluation';
import { ProviderStatus } from '../provider';
import { ProviderEvents } from '../events';
export interface Client extends EvaluationLifeCycle<Client>, Features, ManageLogger<Client>, Eventing<ProviderEvents> {
readonly metadata: ClientMetadata;
/**
* Returns the status of the associated provider.
*/
readonly providerStatus: ProviderStatus;
}

View File

@ -0,0 +1,2 @@
export * from './client';
export * from './open-feature-client';

View File

@ -1,5 +1,6 @@
import type {
import {
ClientMetadata,
ErrorCode,
EvaluationContext,
EvaluationDetails,
EventHandler,
@ -8,29 +9,21 @@ import type {
HookContext,
JsonValue,
Logger,
TrackingEventDetails,
OpenFeatureError,
FlagMetadata,
ResolutionDetails,
EventOptions,
} from '@openfeature/core';
import {
ErrorCode,
ProviderFatalError,
ProviderNotReadyError,
ResolutionDetails,
SafeLogger,
StandardResolutionReasons,
instantiateErrorByErrorCode,
statusMatchesEvent,
MapHookData,
statusMatchesEvent
} from '@openfeature/core';
import type { FlagEvaluationOptions } from '../../evaluation';
import type { ProviderEvents } from '../../events';
import type { InternalEventEmitter } from '../../events/internal/internal-event-emitter';
import type { Hook } from '../../hooks';
import type { Provider } from '../../provider';
import { ProviderStatus } from '../../provider';
import type { Client } from './../client';
import { FlagEvaluationOptions } from '../evaluation';
import { ProviderEvents } from '../events';
import { InternalEventEmitter } from '../events/internal/internal-event-emitter';
import { Hook } from '../hooks';
import { OpenFeature } from '../open-feature';
import { Provider, ProviderStatus } from '../provider';
import { Client } from './client';
type OpenFeatureClientOptions = {
/**
@ -41,11 +34,6 @@ type OpenFeatureClientOptions = {
version?: string;
};
/**
* This implementation of the {@link Client} is meant to only be instantiated by the SDK.
* It should not be used outside the SDK and so should not be exported.
* @internal
*/
export class OpenFeatureClient implements Client {
private _hooks: Hook[] = [];
private _clientLogger?: Logger;
@ -56,8 +44,6 @@ export class OpenFeatureClient implements Client {
private readonly providerAccessor: () => Provider,
private readonly providerStatusAccessor: () => ProviderStatus,
private readonly emitterAccessor: () => InternalEventEmitter,
private readonly apiContextAccessor: (domain?: string) => EvaluationContext,
private readonly apiHooksAccessor: () => Hook[],
private readonly globalLogger: () => Logger,
private readonly options: OpenFeatureClientOptions,
) {}
@ -76,7 +62,7 @@ export class OpenFeatureClient implements Client {
return this.providerStatusAccessor();
}
addHandler(eventType: ProviderEvents, handler: EventHandler, options: EventOptions): void {
addHandler(eventType: ProviderEvents, handler: EventHandler): void {
this.emitterAccessor().addHandler(eventType, handler);
const shouldRunNow = statusMatchesEvent(eventType, this.providerStatus);
@ -92,12 +78,6 @@ export class OpenFeatureClient implements Client {
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 {
@ -193,24 +173,6 @@ export class OpenFeatureClient implements Client {
return this.evaluate<T>(flagKey, this._provider.resolveObjectEvaluation, defaultValue, 'object', options);
}
track(occurrenceKey: string, occurrenceDetails: TrackingEventDetails = {}): void {
try {
this.shortCircuitIfNotReady();
if (typeof this._provider.track === 'function') {
// copy and freeze the context
const frozenContext = Object.freeze({
...this.apiContextAccessor(this?.options?.domain),
});
return this._provider.track?.(occurrenceKey, frozenContext, occurrenceDetails);
} else {
this._logger.debug('Provider does not support the track function; will no-op.');
}
} catch (err) {
this._logger.debug('Error recording tracking event.', err);
}
}
private evaluate<T extends FlagValue>(
flagKey: string,
resolver: (flagKey: string, defaultValue: T, context: EvaluationContext, logger: Logger) => ResolutionDetails<T>,
@ -221,7 +183,7 @@ export class OpenFeatureClient implements Client {
// merge global, client, and evaluation context
const allHooks = [
...this.apiHooksAccessor(),
...OpenFeature.getHooks(),
...this.getHooks(),
...(options.hooks || []),
...(this._provider.hooks || []),
@ -229,85 +191,87 @@ export class OpenFeatureClient implements Client {
const allHooksReversed = [...allHooks].reverse();
const context = {
...this.apiContextAccessor(this?.options?.domain),
...OpenFeature.getContext(this?.options?.domain),
};
// Create hook context instances for each hook (stable object references for the entire evaluation)
// This ensures hooks can use WeakMaps with hookContext as keys across lifecycle methods
// NOTE: Uses the reversed order to reduce the number of times we have to calculate the index.
const hookContexts = allHooksReversed.map<HookContext>(() =>
Object.freeze({
flagKey,
defaultValue,
flagValueType: flagType,
clientMetadata: this.metadata,
providerMetadata: this._provider.metadata,
context,
logger: this._logger,
hookData: new MapHookData(),
}),
);
let evaluationDetails: EvaluationDetails<T>;
// this reference cannot change during the course of evaluation
// it may be used as a key in WeakMaps
const hookContext: Readonly<HookContext> = {
flagKey,
defaultValue,
flagValueType: flagType,
clientMetadata: this.metadata,
providerMetadata: OpenFeature.providerMetadata,
context,
logger: this._logger,
};
try {
this.beforeHooks(allHooks, hookContexts, options);
this.shortCircuitIfNotReady();
this.beforeHooks(allHooks, hookContext, options);
// short circuit evaluation entirely if provider is in a bad state
if (this.providerStatus === ProviderStatus.NOT_READY) {
throw new ProviderNotReadyError('provider has not yet initialized');
} else if (this.providerStatus === ProviderStatus.FATAL) {
throw new ProviderFatalError('provider is in an irrecoverable error state');
}
// run the referenced resolver, binding the provider.
const resolution = resolver.call(this._provider, flagKey, defaultValue, context, this._logger);
const resolutionDetails = {
const evaluationDetails = {
...resolution,
flagMetadata: Object.freeze(resolution.flagMetadata ?? {}),
flagKey,
};
if (resolutionDetails.errorCode) {
const err = instantiateErrorByErrorCode(resolutionDetails.errorCode, resolutionDetails.errorMessage);
this.errorHooks(allHooksReversed, hookContexts, err, options);
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err, resolutionDetails.flagMetadata);
} else {
this.afterHooks(allHooksReversed, hookContexts, resolutionDetails, options);
evaluationDetails = resolutionDetails;
}
this.afterHooks(allHooksReversed, hookContext, evaluationDetails, options);
return evaluationDetails;
} catch (err: unknown) {
this.errorHooks(allHooksReversed, hookContexts, err, options);
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err);
const errorMessage: string = (err as Error)?.message;
const errorCode: ErrorCode = (err as OpenFeatureError)?.code || ErrorCode.GENERAL;
this.errorHooks(allHooksReversed, hookContext, err, options);
return {
errorCode,
errorMessage,
value: defaultValue,
reason: StandardResolutionReasons.ERROR,
flagMetadata: Object.freeze({}),
flagKey,
};
} finally {
this.finallyHooks(allHooksReversed, hookContext, options);
}
this.finallyHooks(allHooksReversed, hookContexts, evaluationDetails, options);
return evaluationDetails;
}
private beforeHooks(hooks: Hook[], hookContexts: HookContext[], options: FlagEvaluationOptions) {
for (const [index, hook] of hooks.entries()) {
const hookContextIndex = hooks.length - 1 - index; // reverse index for before hooks
const hookContext = hookContexts[hookContextIndex];
Object.freeze(hookContext);
Object.freeze(hookContext.context);
private beforeHooks(hooks: Hook[], hookContext: HookContext, options: FlagEvaluationOptions) {
Object.freeze(hookContext);
Object.freeze(hookContext.context);
for (const hook of hooks) {
hook?.before?.(hookContext, Object.freeze(options.hookHints));
}
}
private afterHooks(
hooks: Hook[],
hookContexts: HookContext[],
hookContext: HookContext,
evaluationDetails: EvaluationDetails<FlagValue>,
options: FlagEvaluationOptions,
) {
// run "after" hooks sequentially
for (const [index, hook] of hooks.entries()) {
const hookContext = hookContexts[index];
for (const hook of hooks) {
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
for (const [index, hook] of hooks.entries()) {
for (const hook of hooks) {
try {
const hookContext = hookContexts[index];
hook?.error?.(hookContext, err, options.hookHints);
} catch (err) {
this._logger.error(`Unhandled error during 'error' hook: ${err}`);
@ -319,17 +283,11 @@ export class OpenFeatureClient implements Client {
}
}
private finallyHooks(
hooks: Hook[],
hookContexts: HookContext[],
evaluationDetails: EvaluationDetails<FlagValue>,
options: FlagEvaluationOptions,
) {
private finallyHooks(hooks: Hook[], hookContext: HookContext, options: FlagEvaluationOptions) {
// run "finally" hooks sequentially
for (const [index, hook] of hooks.entries()) {
for (const hook of hooks) {
try {
const hookContext = hookContexts[index];
hook?.finally?.(hookContext, evaluationDetails, options.hookHints);
hook?.finally?.(hookContext, options.hookHints);
} catch (err) {
this._logger.error(`Unhandled error during 'finally' hook: ${err}`);
if (err instanceof Error) {
@ -347,32 +305,4 @@ export class OpenFeatureClient implements Client {
private get _logger() {
return this._clientLogger || this.globalLogger();
}
private shortCircuitIfNotReady() {
// short circuit evaluation entirely if provider is in a bad state
if (this.providerStatus === ProviderStatus.NOT_READY) {
throw new ProviderNotReadyError('provider has not yet initialized');
} else if (this.providerStatus === ProviderStatus.FATAL) {
throw new ProviderFatalError('provider is in an irrecoverable error state');
}
}
private getErrorEvaluationDetails<T extends FlagValue>(
flagKey: string,
defaultValue: T,
err: unknown,
flagMetadata: FlagMetadata = {},
): EvaluationDetails<T> {
const errorMessage: string = (err as Error)?.message;
const errorCode: ErrorCode = (err as OpenFeatureError)?.code || ErrorCode.GENERAL;
return {
errorCode,
errorMessage,
value: defaultValue,
reason: StandardResolutionReasons.ERROR,
flagMetadata: Object.freeze(flagMetadata),
flagKey,
};
}
}

View File

@ -1,4 +1,4 @@
import type { EvaluationDetails, BaseHook, HookHints, JsonValue } from '@openfeature/core';
import { EvaluationDetails, BaseHook, HookHints, JsonValue } from '@openfeature/core';
export interface FlagEvaluationOptions {
hooks?: BaseHook[];

View File

@ -1,6 +1,5 @@
import type { CommonEventDetails} from '@openfeature/core';
import { GenericEventEmitter } from '@openfeature/core';
import type { ProviderEvents } from '../events';
import { CommonEventDetails, GenericEventEmitter } from '@openfeature/core';
import { ProviderEvents } from '../events';
/**
* The InternalEventEmitter is not exported publicly and should only be used within the SDK. It extends the

View File

@ -1,7 +1,6 @@
import { GenericEventEmitter } from '@openfeature/core';
import { EventEmitter } from 'eventemitter3';
import type { ProviderEmittableEvents } from './events';
import { EventEmitter } from 'events';
import { ProviderEmittableEvents } from './events';
/**
* The OpenFeatureEventEmitter can be used by provider developers to emit
* events at various parts of the provider lifecycle.
@ -10,9 +9,12 @@ import type { ProviderEmittableEvents } from './events';
* the result of the initialize method.
*/
export class OpenFeatureEventEmitter extends GenericEventEmitter<ProviderEmittableEvents> {
protected readonly eventEmitter = new EventEmitter();
protected readonly eventEmitter = new EventEmitter({ captureRejections: true });
constructor() {
super();
this.eventEmitter.on('error', (err) => {
this._logger?.error('Error running event handler:', err);
});
}
}

View File

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

View File

@ -4,5 +4,4 @@ export * from './evaluation';
export * from './open-feature';
export * from './events';
export * from './hooks';
export * from './tracking';
export * from '@openfeature/core';

View File

@ -1,20 +1,17 @@
import type {
import {
ClientProviderStatus,
EvaluationContext,
GenericEventEmitter,
ManageContext} from '@openfeature/core';
import {
ManageContext,
OpenFeatureCommonAPI,
ProviderWrapper,
objectOrUndefined,
stringOrUndefined,
} from '@openfeature/core';
import type { Client } from './client';
import { OpenFeatureClient } from './client/internal/open-feature-client';
import { Client, OpenFeatureClient } from './client';
import { OpenFeatureEventEmitter, ProviderEvents } from './events';
import type { Hook } from './hooks';
import type { Provider} from './provider';
import { NOOP_PROVIDER, ProviderStatus } from './provider';
import { Hook } from './hooks';
import { NOOP_PROVIDER, Provider, ProviderStatus } from './provider';
// use a symbol as a key for the global singleton
const GLOBAL_OPENFEATURE_API_KEY = Symbol.for('@openfeature/web-sdk/api');
@ -29,17 +26,10 @@ type DomainRecord = {
const _globalThis = globalThis as OpenFeatureGlobal;
export class OpenFeatureAPI
extends OpenFeatureCommonAPI<ClientProviderStatus, Provider, Hook>
implements ManageContext<Promise<void>>
{
export class OpenFeatureAPI extends OpenFeatureCommonAPI<ClientProviderStatus, Provider, Hook> implements ManageContext<Promise<void>> {
protected _statusEnumType: typeof ProviderStatus = ProviderStatus;
protected _apiEmitter: GenericEventEmitter<ProviderEvents> = new OpenFeatureEventEmitter();
protected _defaultProvider: ProviderWrapper<Provider, ClientProviderStatus> = new ProviderWrapper(
NOOP_PROVIDER,
ProviderStatus.NOT_READY,
this._statusEnumType,
);
protected _defaultProvider: ProviderWrapper<Provider, ClientProviderStatus> = new ProviderWrapper(NOOP_PROVIDER, ProviderStatus.NOT_READY, this._statusEnumType);
protected _domainScopedProviders: Map<string, ProviderWrapper<Provider, ClientProviderStatus>> = new Map();
protected _createEventEmitter = () => new OpenFeatureEventEmitter();
@ -71,161 +61,6 @@ export class OpenFeatureAPI
return this._domainScopedProviders.get(domain)?.status ?? this._defaultProvider.status;
}
/**
* Sets the default provider for flag evaluations and returns a promise that resolves when the provider is ready.
* This provider will be used by domainless clients and clients associated with domains to which no provider is bound.
* Setting a provider supersedes the current provider used in new and existing unbound clients.
* @param {Provider} provider The provider responsible for flag evaluations.
* @returns {Promise<void>}
* @throws {Error} If the provider throws an exception during initialization.
*/
setProviderAndWait(provider: Provider): Promise<void>;
/**
* Sets the default provider for flag evaluations and returns a promise that resolves when the provider is ready.
* This provider will be used by domainless clients and clients associated with domains to which no provider is bound.
* Setting a provider supersedes the current provider used in new and existing unbound clients.
* @param {Provider} provider The provider responsible for flag evaluations.
* @param {EvaluationContext} context The evaluation context to use for flag evaluations.
* @returns {Promise<void>}
* @throws {Error} If the provider throws an exception during initialization.
*/
setProviderAndWait(provider: Provider, context: EvaluationContext): Promise<void>;
/**
* Sets the provider that OpenFeature will use for flag evaluations on clients bound to the same domain.
* A promise is returned that resolves when the provider is ready.
* Setting a provider supersedes the current provider used in new and existing clients bound to the same domain.
* @param {string} domain The name to identify the client
* @param {Provider} provider The provider responsible for flag evaluations.
* @returns {Promise<void>}
* @throws {Error} If the provider throws an exception during initialization.
*/
setProviderAndWait(domain: string, provider: Provider): Promise<void>;
/**
* Sets the provider that OpenFeature will use for flag evaluations on clients bound to the same domain.
* A promise is returned that resolves when the provider is ready.
* Setting a provider supersedes the current provider used in new and existing clients bound to the same domain.
* @param {string} domain The name to identify the client
* @param {Provider} provider The provider responsible for flag evaluations.
* @param {EvaluationContext} context The evaluation context to use for flag evaluations.
* @returns {Promise<void>}
* @throws {Error} If the provider throws an exception during initialization.
*/
setProviderAndWait(domain: string, provider: Provider, context: EvaluationContext): Promise<void>;
async setProviderAndWait(
clientOrProvider?: string | Provider,
providerContextOrUndefined?: Provider | EvaluationContext,
contextOrUndefined?: EvaluationContext,
): Promise<void> {
const domain = stringOrUndefined(clientOrProvider);
const provider = domain
? objectOrUndefined<Provider>(providerContextOrUndefined)
: objectOrUndefined<Provider>(clientOrProvider);
const context = domain
? objectOrUndefined<EvaluationContext>(contextOrUndefined)
: objectOrUndefined<EvaluationContext>(providerContextOrUndefined);
if (context) {
// synonymously setting context prior to provider initialization.
// No context change event will be emitted.
if (domain) {
this._domainScopedContext.set(domain, context);
} else {
this._context = context;
}
}
await this.setAwaitableProvider(domain, provider);
}
/**
* Sets the default provider for flag evaluations.
* This provider will be used by domainless clients and clients associated with domains to which no provider is bound.
* Setting a provider supersedes the current provider used in new and existing unbound clients.
* @param {Provider} provider The provider responsible for flag evaluations.
* @returns {this} OpenFeature API
*/
setProvider(provider: Provider): this;
/**
* Sets the default provider and evaluation context for flag evaluations.
* This provider will be used by domainless clients and clients associated with domains to which no provider is bound.
* Setting a provider supersedes the current provider used in new and existing unbound clients.
* @param {Provider} provider The provider responsible for flag evaluations.
* @param context {EvaluationContext} The evaluation context to use for flag evaluations.
* @returns {this} OpenFeature API
*/
setProvider(provider: Provider, context: EvaluationContext): this;
/**
* Sets the provider for flag evaluations of providers with the given name.
* Setting a provider supersedes the current provider used in new and existing clients bound to the same domain.
* @param {string} domain The name to identify the client
* @param {Provider} provider The provider responsible for flag evaluations.
* @returns {this} OpenFeature API
*/
setProvider(domain: string, provider: Provider): this;
/**
* Sets the provider and evaluation context flag evaluations of providers with the given name.
* Setting a provider supersedes the current provider used in new and existing clients bound to the same domain.
* @param {string} domain The name to identify the client
* @param {Provider} provider The provider responsible for flag evaluations.
* @param context {EvaluationContext} The evaluation context to use for flag evaluations.
* @returns {this} OpenFeature API
*/
setProvider(domain: string, provider: Provider, context: EvaluationContext): this;
setProvider(
domainOrProvider?: string | Provider,
providerContextOrUndefined?: Provider | EvaluationContext,
contextOrUndefined?: EvaluationContext,
): this {
const domain = stringOrUndefined(domainOrProvider);
const provider = domain
? objectOrUndefined<Provider>(providerContextOrUndefined)
: objectOrUndefined<Provider>(domainOrProvider);
const context = domain
? objectOrUndefined<EvaluationContext>(contextOrUndefined)
: objectOrUndefined<EvaluationContext>(providerContextOrUndefined);
if (context) {
// synonymously setting context prior to provider initialization.
// No context change event will be emitted.
if (domain) {
this._domainScopedContext.set(domain, context);
} else {
this._context = context;
}
}
const maybePromise = this.setAwaitableProvider(domain, provider);
// The setProvider method doesn't return a promise so we need to catch and
// log any errors that occur during provider initialization to avoid having
// an unhandled promise rejection.
Promise.resolve(maybePromise).catch((err) => {
this._logger.error('Error during provider initialization:', err);
});
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.
* This will be used by all providers that have not bound to a domain.
@ -276,7 +111,9 @@ export class OpenFeatureAPI
...unboundProviders,
];
await Promise.all(
allDomainRecords.map((dm) => this.runProviderContextChangeHandler(dm.domain, dm.wrapper, oldContext, context)),
allDomainRecords.map((dm) =>
this.runProviderContextChangeHandler(dm.domain, dm.wrapper, oldContext, context),
),
);
}
}
@ -292,7 +129,7 @@ export class OpenFeatureAPI
* @param {string} domain An identifier which logically binds clients with providers
* @returns {EvaluationContext} Evaluation context
*/
getContext(domain?: string | undefined): EvaluationContext;
getContext(domain?: string): EvaluationContext;
getContext(domainOrUndefined?: string): EvaluationContext {
const domain = stringOrUndefined(domainOrUndefined);
if (domain) {
@ -346,9 +183,9 @@ export class OpenFeatureAPI
}
/**
* A factory function for creating new domain-scoped OpenFeature clients. Clients
* can contain their own state (e.g. logger, hook, context). Multiple domains
* can be used to segment feature flag configuration.
* A factory function for creating new named OpenFeature clients. Clients can contain
* their own state (e.g. logger, hook, context). Multiple clients can be used
* to segment feature flag configuration.
*
* If there is already a provider bound to this name via {@link this.setProvider setProvider}, this provider will be used.
* Otherwise, the default provider is used until a provider is assigned to that name.
@ -363,8 +200,6 @@ export class OpenFeatureAPI
() => this.getProviderForClient(domain),
() => this.getProviderStatus(domain),
() => this.buildAndCacheEventEmitterForClient(domain),
(domain?: string) => this.getContext(domain),
() => this.getHooks(),
() => this._logger,
{ domain, version },
);
@ -387,23 +222,17 @@ export class OpenFeatureAPI
): Promise<void> {
// this should always be set according to the typings, but let's be defensive considering JS
const providerName = wrapper.provider?.metadata?.name || 'unnamed-provider';
try {
if (typeof wrapper.provider.onContextChange === 'function') {
const maybePromise = wrapper.provider.onContextChange(oldContext, newContext);
// only reconcile if the onContextChange method returns a promise
if (typeof maybePromise?.then === 'function') {
wrapper.incrementPendingContextChanges();
wrapper.status = this._statusEnumType.RECONCILING;
this.getAssociatedEventEmitters(domain).forEach((emitter) => {
emitter?.emit(ProviderEvents.Reconciling, { domain, providerName });
});
this._apiEmitter?.emit(ProviderEvents.Reconciling, { domain, providerName });
await maybePromise;
wrapper.decrementPendingContextChanges();
}
wrapper.incrementPendingContextChanges();
wrapper.status = this._statusEnumType.RECONCILING;
this.getAssociatedEventEmitters(domain).forEach((emitter) => {
emitter?.emit(ProviderEvents.Reconciling, { domain, providerName });
});
this._apiEmitter?.emit(ProviderEvents.Reconciling, { domain, providerName });
await wrapper.provider.onContextChange(oldContext, newContext);
wrapper.decrementPendingContextChanges();
}
// only run the event handlers, and update the state if the onContextChange method succeeded
wrapper.status = this._statusEnumType.READY;

View File

@ -3,7 +3,7 @@
* It might cause confusion since these types are not a part of the general API,
* but just for the in-memory provider.
*/
import type { EvaluationContext, JsonValue } from '@openfeature/core';
import { EvaluationContext, JsonValue } from '@openfeature/core';
type Variants<T> = Record<string, T>;

View File

@ -1,19 +1,18 @@
import type {
import {
EvaluationContext,
FlagNotFoundError,
FlagValueType,
GeneralError,
JsonValue,
Logger,
ResolutionDetails} from '@openfeature/core';
import {
FlagNotFoundError,
GeneralError,
OpenFeatureError,
ResolutionDetails,
StandardResolutionReasons,
TypeMismatchError,
} from '@openfeature/core';
import type { Provider } from '../provider';
import { Provider } from '../provider';
import { OpenFeatureEventEmitter, ProviderEvents } from '../../events';
import type { FlagConfiguration, Flag } from './flag-configuration';
import { FlagConfiguration, Flag } from './flag-configuration';
import { VariantNotFoundError } from './variant-not-found-error';
/**
@ -32,16 +31,30 @@ export class InMemoryProvider implements Provider {
this._flagConfiguration = { ...flagConfiguration };
}
async initialize(context?: EvaluationContext | undefined): Promise<void> {
try {
for (const key in this._flagConfiguration) {
this.resolveFlagWithReason(key, context);
}
this._context = context;
} catch (err) {
throw new Error('initialization failure', { cause: err });
}
}
/**
* Overwrites the configured flags.
* @param { FlagConfiguration } flagConfiguration new flag configuration
*/
async putConfiguration(flagConfiguration: FlagConfiguration) {
try {
const flagsChanged = Object.entries({...flagConfiguration, ...this._flagConfiguration})
.map(([key]) => key);
const flagsChanged = Object.entries(flagConfiguration)
.filter(([key, value]) => this._flagConfiguration[key] !== value)
.map(([key]) => key);
this._flagConfiguration = { ...flagConfiguration };
this._flagConfiguration = { ...flagConfiguration };
try {
await this.initialize(this._context);
this.events.emit(ProviderEvents.ConfigurationChanged, { flagsChanged });
} catch (err) {
this.events.emit(ProviderEvents.Error);
@ -85,12 +98,8 @@ export class InMemoryProvider implements Provider {
return this.resolveAndCheckFlag<T>(flagKey, defaultValue, context || this._context, logger);
}
private resolveAndCheckFlag<T extends JsonValue | FlagValueType>(
flagKey: string,
defaultValue: T,
context?: EvaluationContext,
logger?: Logger,
): ResolutionDetails<T> {
private resolveAndCheckFlag<T extends JsonValue | FlagValueType>(flagKey: string,
defaultValue: T, context?: EvaluationContext, logger?: Logger): ResolutionDetails<T> {
if (!(flagKey in this._flagConfiguration)) {
const message = `no flag found with key ${flagKey}`;
logger?.debug(message);

View File

@ -1,5 +1,5 @@
import type { JsonValue, ResolutionDetails } from '@openfeature/core';
import type { Provider } from './provider';
import { JsonValue, ResolutionDetails } from '@openfeature/core';
import { Provider } from './provider';
const REASON_NO_OP = 'No-op';

View File

@ -1,12 +1,5 @@
import type {
CommonProvider,
EvaluationContext,
JsonValue,
Logger,
ResolutionDetails,
} from '@openfeature/core';
import { ClientProviderStatus } from '@openfeature/core';
import type { Hook } from '../hooks';
import { ClientProviderStatus, CommonProvider, EvaluationContext, JsonValue, Logger, ResolutionDetails } from '@openfeature/core';
import { Hook } from '../hooks';
export { ClientProviderStatus as ProviderStatus };
@ -26,18 +19,12 @@ export interface Provider extends CommonProvider<ClientProviderStatus> {
readonly hooks?: Hook[];
/**
* A handler function to reconcile changes made to the static context.
* A handler function to reconcile changes when the static context.
* Called by the SDK when the context is changed.
*
* Returning a promise will put the provider in the RECONCILING state and
* emit the ProviderEvents.Reconciling event.
*
* Return void will avoid putting the provider in the RECONCILING state and
* **not** emit the ProviderEvents.Reconciling event.
* @param oldContext
* @param newContext
*/
onContextChange?(oldContext: EvaluationContext, newContext: EvaluationContext): Promise<void> | void;
onContextChange?(oldContext: EvaluationContext, newContext: EvaluationContext): Promise<void>;
/**
* Resolve a boolean flag and its evaluation details.

View File

@ -1,15 +1,20 @@
import type { TrackingEventDetails } from '@openfeature/core';
import type { Client, EvaluationDetails, JsonArray, JsonObject, JsonValue, Provider, ResolutionDetails } from '../src';
import {
Client,
ErrorCode,
EvaluationDetails,
FlagNotFoundError,
GeneralError,
JsonArray,
JsonObject,
JsonValue,
OpenFeature,
OpenFeatureClient,
Provider,
ProviderFatalError,
ProviderStatus,
ResolutionDetails,
StandardResolutionReasons,
} from '../src';
import { OpenFeatureClient } from '../src/client/internal/open-feature-client';
const BOOLEAN_VALUE = true;
const STRING_VALUE = 'val';
@ -56,10 +61,6 @@ const MOCK_PROVIDER: Provider = {
return Promise.resolve(undefined);
},
track: jest.fn((): void => {
return;
}),
resolveNumberEvaluation: jest.fn((): ResolutionDetails<number> => {
return {
value: NUMBER_VALUE,
@ -129,15 +130,12 @@ describe('OpenFeatureClient', () => {
resolveBooleanEvaluation(): ResolutionDetails<boolean> {
throw new Error('Method not implemented.');
}
resolveStringEvaluation(): ResolutionDetails<string> {
throw new Error('Method not implemented.');
}
resolveNumberEvaluation(): ResolutionDetails<number> {
throw new Error('Method not implemented.');
}
resolveObjectEvaluation<T extends JsonValue>(): ResolutionDetails<T> {
throw new Error('Method not implemented.');
}
@ -238,7 +236,7 @@ describe('OpenFeatureClient', () => {
numberFlag,
defaultNumberValue,
{},
{},
{}
);
});
});
@ -250,7 +248,7 @@ describe('OpenFeatureClient', () => {
const defaultNumberValue = 4096;
const value: MyRestrictedNumber = client.getNumberValue<MyRestrictedNumber>(
numberFlag,
defaultNumberValue,
defaultNumberValue
);
expect(value).toEqual(NUMBER_VALUE);
@ -258,7 +256,7 @@ describe('OpenFeatureClient', () => {
numberFlag,
defaultNumberValue,
{},
{},
{}
);
});
});
@ -335,7 +333,7 @@ describe('OpenFeatureClient', () => {
booleanFlag,
defaultBooleanValue,
{},
{},
{}
);
});
});
@ -384,7 +382,7 @@ describe('OpenFeatureClient', () => {
// No generic information exists at runtime, but this test has some value in ensuring the generic args still exist in the typings.
const client = OpenFeature.getClient();
const details: ResolutionDetails<JsonValue> = client.getObjectDetails('flag', { key: 'value' });
expect(details).toBeDefined();
});
});
@ -397,7 +395,7 @@ describe('OpenFeatureClient', () => {
},
initialize: () => {
return Promise.resolve();
},
}
} as unknown as Provider;
it('status must be READY if init resolves', async () => {
await OpenFeature.setProviderAndWait('1.7.1, 1.7.3', initProvider);
@ -413,7 +411,7 @@ describe('OpenFeatureClient', () => {
},
initialize: async () => {
return Promise.reject(new GeneralError());
},
}
} as unknown as Provider;
it('status must be ERROR if init rejects', async () => {
await expect(OpenFeature.setProviderAndWait('1.7.4', errorProvider)).rejects.toThrow();
@ -429,7 +427,7 @@ describe('OpenFeatureClient', () => {
},
initialize: () => {
return Promise.reject(new ProviderFatalError());
},
}
} as unknown as Provider;
it('must short circuit and return PROVIDER_FATAL code if provider FATAL', async () => {
await expect(OpenFeature.setProviderAndWait('1.7.5, 1.7.6, 1.7.8', fatalProvider)).rejects.toThrow();
@ -452,7 +450,7 @@ describe('OpenFeatureClient', () => {
return new Promise(() => {
return; // promise never resolves
});
},
}
} as unknown as Provider;
it('must short circuit and return PROVIDER_NOT_READY code if provider NOT_READY', async () => {
OpenFeature.setProviderAndWait('1.7.7', neverReadyProvider).catch(() => {
@ -466,45 +464,45 @@ describe('OpenFeatureClient', () => {
expect(details.errorCode).toEqual(ErrorCode.PROVIDER_NOT_READY);
});
});
describe('Evaluation details structure', () => {
const flagKey = 'number-details';
const defaultValue = 1970;
let details: EvaluationDetails<number>;
describe('Normal execution', () => {
beforeEach(() => {
const client = OpenFeature.getClient();
details = client.getNumberDetails(flagKey, defaultValue);
expect(details).toBeDefined();
});
describe('Requirement 1.4.2, 1.4.3', () => {
it('should contain flag value', () => {
expect(details.value).toEqual(NUMBER_VALUE);
});
});
describe('Requirement 1.4.4', () => {
it('should contain flag key', () => {
expect(details.flagKey).toEqual(flagKey);
});
});
describe('Requirement 1.4.5', () => {
it('should contain flag variant', () => {
expect(details.variant).toEqual(NUMBER_VARIANT);
});
});
describe('Requirement 1.4.6', () => {
it('should contain reason', () => {
expect(details.reason).toEqual(REASON);
});
});
});
describe('Abnormal execution', () => {
const NON_OPEN_FEATURE_ERROR_MESSAGE = 'A null dereference or something, I dunno.';
const OPEN_FEATURE_ERROR_MESSAGE = "This ain't the flag you're looking for.";
@ -524,14 +522,14 @@ describe('OpenFeatureClient', () => {
} as unknown as Provider;
const defaultNumberValue = 123;
const defaultStringValue = 'hey!';
beforeEach(() => {
OpenFeature.setProvider(errorProvider);
client = OpenFeature.getClient();
nonOpenFeatureErrorDetails = client.getNumberDetails('some-flag', defaultNumberValue);
openFeatureErrorDetails = client.getStringDetails('some-flag', defaultStringValue);
});
describe('Requirement 1.4.7', () => {
describe('OpenFeatureError', () => {
it('should contain error code', () => {
@ -539,7 +537,7 @@ describe('OpenFeatureClient', () => {
expect(openFeatureErrorDetails.errorCode).toEqual(ErrorCode.FLAG_NOT_FOUND); // should get code from thrown OpenFeatureError
});
});
describe('Non-OpenFeatureError', () => {
it('should contain error code', () => {
expect(nonOpenFeatureErrorDetails.errorCode).toBeTruthy();
@ -547,30 +545,30 @@ describe('OpenFeatureClient', () => {
});
});
});
describe('Requirement 1.4.8', () => {
it('should contain error reason', () => {
expect(nonOpenFeatureErrorDetails.reason).toEqual(StandardResolutionReasons.ERROR);
expect(openFeatureErrorDetails.reason).toEqual(StandardResolutionReasons.ERROR);
});
});
describe('Requirement 1.4.9', () => {
it('must not throw, must return default', () => {
nonOpenFeatureErrorDetails = client.getNumberDetails('some-flag', defaultNumberValue);
expect(nonOpenFeatureErrorDetails).toBeTruthy();
expect(nonOpenFeatureErrorDetails.value).toEqual(defaultNumberValue);
});
});
describe('Requirement 1.4.12', () => {
describe('OpenFeatureError', () => {
it('should contain "error" message', () => {
expect(openFeatureErrorDetails.errorMessage).toEqual(OPEN_FEATURE_ERROR_MESSAGE);
});
});
describe('Non-OpenFeatureError', () => {
it('should contain "error" message', () => {
expect(nonOpenFeatureErrorDetails.errorMessage).toEqual(NON_OPEN_FEATURE_ERROR_MESSAGE);
@ -578,14 +576,14 @@ describe('OpenFeatureClient', () => {
});
});
});
describe('Requirement 1.4.13, Requirement 1.4.14', () => {
it('should return immutable `flag metadata` as defined by the provider', () => {
const flagMetadata = {
url: 'https://test.dev',
version: '1',
};
const flagMetadataProvider = {
metadata: {
name: 'flag-metadata',
@ -597,14 +595,14 @@ describe('OpenFeatureClient', () => {
};
}),
} as unknown as Provider;
OpenFeature.setProvider(flagMetadataProvider);
const client = OpenFeature.getClient();
const response = client.getBooleanDetails('some-flag', false);
expect(response.flagMetadata).toBe(flagMetadata);
expect(Object.isFrozen(response.flagMetadata)).toBeTruthy();
});
it('should return empty `flag metadata` because it was not set by the provider', () => {
// The mock provider doesn't contain flag metadata
OpenFeature.setProvider(MOCK_PROVIDER);
@ -635,64 +633,5 @@ describe('OpenFeatureClient', () => {
expect(OpenFeature.getClient().providerStatus).toEqual(ProviderStatus.READY);
});
});
describe('tracking', () => {
describe('Requirement 2.7.1, Requirement 6.1.2.1', () => {
const eventName = 'test-tracking-event';
const trackingValue = 1234;
const trackingDetails: TrackingEventDetails = {
value: trackingValue,
};
const contextKey = 'key';
const contextValue = 'val';
it('should no-op and not throw if tracking not defined on provider', async () => {
await OpenFeature.setProviderAndWait({ ...MOCK_PROVIDER, track: undefined });
const client = OpenFeature.getClient();
expect(() => {
client.track(eventName, trackingDetails);
}).not.toThrow();
});
it('provide empty tracking details to provider if not supplied in call', async () => {
await OpenFeature.setProviderAndWait({ ...MOCK_PROVIDER });
const client = OpenFeature.getClient();
client.track(eventName);
expect(MOCK_PROVIDER.track).toHaveBeenCalledWith(
eventName,
expect.any(Object),
expect.any(Object),
);
});
it('should no-op and not throw if provider throws', async () => {
await OpenFeature.setProviderAndWait({
...MOCK_PROVIDER,
track: () => {
throw new Error('fake error');
},
});
const client = OpenFeature.getClient();
expect(() => {
client.track(eventName, trackingDetails);
}).not.toThrow();
});
it('should call provider with correct context', async () => {
await OpenFeature.setProviderAndWait({ ...MOCK_PROVIDER });
await OpenFeature.setContext({ [contextKey]: contextValue });
const client = OpenFeature.getClient();
client.track(eventName, trackingDetails);
expect(MOCK_PROVIDER.track).toHaveBeenCalledWith(
eventName,
expect.objectContaining({ [contextKey]: contextValue }),
expect.objectContaining({ value: trackingValue }),
);
});
});
});
});

View File

@ -1,7 +1,4 @@
import type { EvaluationContext, JsonValue, Provider, ProviderMetadata, ResolutionDetails } from '../src';
import { OpenFeature } from '../src';
const initializeMock = jest.fn();
import { EvaluationContext, JsonValue, OpenFeature, Provider, ProviderMetadata, ResolutionDetails } from '../src';
class MockProvider implements Provider {
readonly metadata: ProviderMetadata;
@ -10,8 +7,6 @@ class MockProvider implements Provider {
this.metadata = { name: options?.name ?? 'mock-provider' };
}
initialize = initializeMock;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onContextChange(oldContext: EvaluationContext, newContext: EvaluationContext): Promise<void> {
return Promise.resolve();
@ -20,7 +15,7 @@ class MockProvider implements Provider {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
resolveBooleanEvaluation = jest.fn((flagKey: string, defaultValue: boolean, context: EvaluationContext) => {
return {
value: true,
value: true
};
});
@ -40,7 +35,6 @@ class MockProvider implements Provider {
describe('Evaluation Context', () => {
afterEach(async () => {
await OpenFeature.clearContexts();
jest.clearAllMocks();
});
describe('Requirement 3.2.2', () => {
@ -65,42 +59,6 @@ describe('Evaluation Context', () => {
expect(OpenFeature.getContext('invalid')).toEqual(defaultContext);
});
describe('Set context during provider registration', () => {
it('should set the context for the default provider', () => {
const context: EvaluationContext = { property1: false };
const provider = new MockProvider();
OpenFeature.setProvider(provider, context);
expect(OpenFeature.getContext()).toEqual(context);
});
it('should set the context for a domain', async () => {
const context: EvaluationContext = { property1: false };
const domain = 'test';
const provider = new MockProvider({ name: domain });
OpenFeature.setProvider(domain, provider, context);
expect(OpenFeature.getContext()).toEqual({});
expect(OpenFeature.getContext(domain)).toEqual(context);
});
it('should set the context for the default provider prior to initialization', async () => {
const context: EvaluationContext = { property1: false };
const provider = new MockProvider();
await OpenFeature.setProviderAndWait(provider, context);
expect(initializeMock).toHaveBeenCalledWith(context);
expect(OpenFeature.getContext()).toEqual(context);
});
it('should set the context for a domain prior to initialization', async () => {
const context: EvaluationContext = { property1: false };
const domain = 'test';
const provider = new MockProvider({ name: domain });
await OpenFeature.setProviderAndWait(domain, provider, context);
expect(OpenFeature.getContext()).toEqual({});
expect(OpenFeature.getContext(domain)).toEqual(context);
expect(initializeMock).toHaveBeenCalledWith(context);
});
});
describe('Context Management', () => {
it('should reset global context', async () => {
const globalContext: EvaluationContext = { scope: 'global' };

View File

@ -1,18 +1,16 @@
import type { EventDetails } from '@openfeature/core';
import { EventDetails } from '@openfeature/core';
import { v4 as uuid } from 'uuid';
import type {
JsonValue,
Provider,
ProviderMetadata,
ResolutionDetails,
StaleEvent
} from '../src';
import {
JsonValue,
NOOP_PROVIDER,
OpenFeature,
OpenFeatureEventEmitter,
Provider,
ProviderEvents,
ProviderStatus
ProviderMetadata,
ProviderStatus,
ResolutionDetails,
StaleEvent
} from '../src';
const TIMEOUT = 1000;
@ -24,12 +22,11 @@ class MockProvider implements Provider {
readonly runsOn = 'client';
private hasInitialize: boolean;
private hasContextChanged: boolean;
private asyncContextChangedHandler: boolean;
private failOnInit: boolean;
private failOnContextChange: boolean;
private asyncDelay?: number;
private enableEvents: boolean;
onContextChange?: () => Promise<void> | void;
onContextChange?: () => Promise<void>;
initialize?: () => Promise<void>;
constructor(options?: {
@ -38,7 +35,6 @@ class MockProvider implements Provider {
enableEvents?: boolean;
failOnInit?: boolean;
hasContextChanged?: boolean;
asyncContextChangedHandler?: boolean;
failOnContextChange?: boolean;
name?: string;
}) {
@ -49,7 +45,6 @@ class MockProvider implements Provider {
this.enableEvents = options?.enableEvents ?? true;
this.failOnInit = options?.failOnInit ?? false;
this.failOnContextChange = options?.failOnContextChange ?? false;
this.asyncContextChangedHandler = options?.asyncContextChangedHandler ?? true;
if (this.hasContextChanged) {
this.onContextChange = this.changeHandler;
}
@ -85,19 +80,15 @@ class MockProvider implements Provider {
}
private changeHandler() {
if (this.asyncContextChangedHandler) {
return new Promise<void>((resolve, reject) =>
setTimeout(() => {
if (this.failOnContextChange) {
reject(new Error(ERR_MESSAGE));
} else {
resolve();
}
}, this.asyncDelay),
);
} else if (this.failOnContextChange) {
throw new Error(ERR_MESSAGE);
}
return new Promise<void>((resolve, reject) =>
setTimeout(() => {
if (this.failOnContextChange) {
reject(new Error(ERR_MESSAGE));
} else {
resolve();
}
}, this.asyncDelay),
);
}
}
@ -476,21 +467,7 @@ describe('Events', () => {
expect(OpenFeature.getHandlers(eventType)).toHaveLength(0);
});
it('The event handler can be removed using an abort signal', () => {
const abortController = new AbortController();
const handler1 = jest.fn();
const handler2 = jest.fn();
const eventType = ProviderEvents.Stale;
OpenFeature.addHandler(eventType, handler1, { signal: abortController.signal });
OpenFeature.addHandler(eventType, handler2);
expect(OpenFeature.getHandlers(eventType)).toHaveLength(2);
abortController.abort();
expect(OpenFeature.getHandlers(eventType)).toHaveLength(1);
});
it('The API provides a function allowing the removal of event handlers from client', () => {
it('The API provides a function allowing the removal of event handlers', () => {
const client = OpenFeature.getClient(domain);
const handler = jest.fn();
const eventType = ProviderEvents.Stale;
@ -500,21 +477,6 @@ describe('Events', () => {
client.removeHandler(eventType, handler);
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', () => {
@ -636,25 +598,6 @@ describe('Events', () => {
expect(handler).toHaveBeenCalledTimes(2);
});
it('Reconciling events are not emitted for synchronous onContextChange operations', async () => {
const provider = new MockProvider({
hasInitialize: false,
hasContextChanged: true,
asyncContextChangedHandler: false,
});
const reconcileHandler = jest.fn(() => {});
const changedEventHandler = jest.fn(() => {});
await OpenFeature.setProviderAndWait(domain, provider);
OpenFeature.addHandler(ProviderEvents.Reconciling, reconcileHandler);
OpenFeature.addHandler(ProviderEvents.ContextChanged, changedEventHandler);
await OpenFeature.setContext(domain, {});
expect(reconcileHandler).not.toHaveBeenCalled();
expect(changedEventHandler).toHaveBeenCalledTimes(1);
});
});
describe('provider has no context changed handler', () => {
@ -672,7 +615,7 @@ describe('Events', () => {
});
});
});
describe('client', () => {
describe('provider has context changed handler', () => {
it('Stale and ContextChanged are emitted', async () => {
@ -867,9 +810,12 @@ describe('Events', () => {
};
client.addHandler(ProviderEvents.ContextChanged, handler);
// update context change twice
await Promise.all([OpenFeature.setContext(domain, {}), OpenFeature.setContext(domain, {})]);
await Promise.all([
OpenFeature.setContext(domain, {}),
OpenFeature.setContext(domain, {}),
]);
// should only have run once
expect(runs).toEqual(1);

View File

@ -1,5 +1,13 @@
import type { Provider, ResolutionDetails, Client, FlagValueType, EvaluationContext, Hook } from '../src';
import { GeneralError, OpenFeature, StandardResolutionReasons, ErrorCode } from '../src';
import {
Provider,
ResolutionDetails,
Client,
FlagValueType,
EvaluationContext,
GeneralError,
OpenFeature,
Hook,
} from '../src';
const BOOLEAN_VALUE = true;
@ -69,39 +77,6 @@ describe('Hooks', () => {
],
});
});
it('client metadata and provider metadata must match the client and provider used to resolve the flag', (done) => {
const provider: Provider = {
metadata: {
name: 'mock-my-domain-provider',
},
resolveBooleanEvaluation: jest.fn((): Promise<ResolutionDetails<boolean>> => {
return Promise.resolve({
value: BOOLEAN_VALUE,
variant: BOOLEAN_VARIANT,
reason: REASON,
});
}),
} as unknown as Provider;
OpenFeature.setProvider('my-domain', provider);
const client = OpenFeature.getClient('my-domain');
client.getBooleanValue(FLAG_KEY, false, {
hooks: [
{
before: (hookContext) => {
try {
expect(hookContext.providerMetadata).toEqual(provider.metadata);
expect(hookContext.clientMetadata).toEqual(client.metadata);
done();
} catch (err) {
done(err);
}
},
},
],
});
});
});
describe('Requirement 4.1.3', () => {
@ -198,27 +173,6 @@ describe('Hooks', () => {
],
});
});
it('"error" must run if resolution details contains an error code', () => {
(MOCK_ERROR_PROVIDER.resolveBooleanEvaluation as jest.Mock).mockReturnValue({
value: BOOLEAN_VALUE,
errorCode: ErrorCode.FLAG_NOT_FOUND,
});
const mockErrorHook = jest.fn();
const details = client.getBooleanDetails(FLAG_KEY, false, {
hooks: [{ error: mockErrorHook }],
});
expect(mockErrorHook).toHaveBeenCalled();
expect(details).toEqual(
expect.objectContaining({
errorCode: ErrorCode.FLAG_NOT_FOUND,
reason: StandardResolutionReasons.ERROR,
}),
);
});
});
});
@ -271,25 +225,6 @@ describe('Hooks', () => {
});
});
});
describe('Requirement 4.3.8', () => {
it('"evaluation details" passed to the "finally" stage matches the evaluation details returned to the application author', () => {
OpenFeature.setProvider(MOCK_PROVIDER);
let evaluationDetailsHooks;
const evaluationDetails = client.getBooleanDetails(FLAG_KEY, false, {
hooks: [
{
finally: (_, details) => {
evaluationDetailsHooks = details;
},
},
],
});
expect(evaluationDetailsHooks).toEqual(evaluationDetails);
});
});
});
describe('Requirement 4.4.2', () => {
@ -767,14 +702,14 @@ describe('Hooks', () => {
done(err);
}
},
after: (_hookContext, _evaluationDetails, hookHints) => {
after: (_hookContext, _evaluationDetils, hookHints) => {
try {
expect(hookHints?.hint).toBeTruthy();
} catch (err) {
done(err);
}
},
finally: (_, _evaluationDetails, hookHints) => {
finally: (_, hookHints) => {
try {
expect(hookHints?.hint).toBeTruthy();
done();

View File

@ -1,7 +1,44 @@
import { FlagNotFoundError, InMemoryProvider, ProviderEvents, StandardResolutionReasons, TypeMismatchError } from '../src';
import { FlagNotFoundError, GeneralError, InMemoryProvider, ProviderEvents, StandardResolutionReasons, TypeMismatchError } from '../src';
import { FlagConfiguration } from '../src/provider/in-memory-provider/flag-configuration';
import { VariantNotFoundError } from '../src/provider/in-memory-provider/variant-not-found-error';
describe('in-memory provider', () => {
describe('initialize', () => {
it('Should not throw for valid flags', async () => {
const booleanFlagSpec = {
'a-boolean-flag': {
variants: {
on: true,
off: false,
},
disabled: false,
defaultVariant: 'on',
},
};
const provider = new InMemoryProvider(booleanFlagSpec);
await provider.initialize();
});
it('Should throw on invalid flags', async () => {
const throwingFlagSpec: FlagConfiguration = {
'a-boolean-flag': {
variants: {
on: true,
off: false,
},
disabled: false,
defaultVariant: 'on',
contextEvaluator: () => {
throw new GeneralError('context eval error');
},
},
};
const provider = new InMemoryProvider(throwingFlagSpec);
const someContext = {};
await expect(provider.initialize(someContext)).rejects.toThrow();
});
});
describe('boolean flags', () => {
const provider = new InMemoryProvider({});
it('resolves to default variant with reason static', async () => {
@ -494,6 +531,8 @@ describe('in-memory provider', () => {
},
});
await provider.initialize();
const firstResolution = provider.resolveStringEvaluation('some-flag', 'deafaultFirstResolution');
expect(firstResolution).toEqual({
@ -538,6 +577,8 @@ describe('in-memory provider', () => {
const provider = new InMemoryProvider(flagsSpec);
await provider.initialize();
// I passed configuration by reference, so maybe I can mess
// with it behind the providers back!
flagsSpec['some-flag'] = substituteSpec;

View File

@ -1,7 +1,5 @@
import type { Paradigm } from '@openfeature/core';
import type { Provider} from '../src';
import { OpenFeature, OpenFeatureAPI, ProviderStatus } from '../src';
import { OpenFeatureClient } from '../src/client/internal/open-feature-client';
import { Paradigm } from '@openfeature/core';
import { OpenFeature, OpenFeatureAPI, OpenFeatureClient, Provider, ProviderStatus } from '../src';
const mockProvider = (config?: { initialStatus?: ProviderStatus; runsOn?: Paradigm }) => {
return {
@ -75,8 +73,8 @@ describe('OpenFeature', () => {
it('should set the default provider if no domain is provided', () => {
const provider = mockProvider();
OpenFeature.setProvider(provider);
const registeredProvider = OpenFeature.getProvider();
expect(registeredProvider).toEqual(provider);
const client = OpenFeature.getClient();
expect(client.metadata.providerMetadata.name).toEqual(provider.metadata.name);
});
it('should not change providers associated with a domain when setting a new default provider', () => {
@ -86,11 +84,11 @@ describe('OpenFeature', () => {
OpenFeature.setProvider(provider);
OpenFeature.setProvider(domain, fakeProvider);
const defaultProvider = OpenFeature.getProvider();
const domainSpecificProvider = OpenFeature.getProvider(domain);
const defaultClient = OpenFeature.getClient();
const domainSpecificClient = OpenFeature.getClient(domain);
expect(defaultProvider).toEqual(provider);
expect(domainSpecificProvider).toEqual(fakeProvider);
expect(defaultClient.metadata.providerMetadata.name).toEqual(provider.metadata.name);
expect(domainSpecificClient.metadata.providerMetadata.name).toEqual(fakeProvider.metadata.name);
});
it('should bind a new provider to existing clients in a matching domain', () => {

View File

@ -9,9 +9,9 @@
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "ES2015", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
"target": "ES2022", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
"lib": [
"ES2015",
"ES2022",
"DOM"
], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
@ -24,7 +24,7 @@
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
/* Modules */
"module": "ES2015", /* Specify what module code is generated. */
"module": "ES2022", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
"moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */

View File

@ -1,110 +1,5 @@
# 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)
### 🧹 Chore
* import type lint rule and fixes ([#1039](https://github.com/open-feature/js-sdk/issues/1039)) ([01fcb93](https://github.com/open-feature/js-sdk/commit/01fcb933d2cbd131a0f4a005173cdd1906087e18))
## [0.2.1-experimental](https://github.com/open-feature/js-sdk/compare/nestjs-sdk-v0.2.0-experimental...nestjs-sdk-v0.2.1-experimental) (2024-06-11)
### ✨ New Features
* lower compilation target to es2015 ([#957](https://github.com/open-feature/js-sdk/issues/957)) ([c2d6c17](https://github.com/open-feature/js-sdk/commit/c2d6c1761ae19f937deaff2f011a0380f8af7350))
## [0.2.0-experimental](https://github.com/open-feature/js-sdk/compare/nestjs-sdk-v0.1.5-experimental...nestjs-sdk-v0.2.0-experimental) (2024-05-19)
### ⚠ BREAKING CHANGES
* rename FeatureClient decorator to OpenFeatureClient ([#949](https://github.com/open-feature/js-sdk/issues/949))
### ✨ New Features
* rename FeatureClient decorator to OpenFeatureClient ([#949](https://github.com/open-feature/js-sdk/issues/949)) ([a531238](https://github.com/open-feature/js-sdk/commit/a531238124510e6aa150ff619972f8880346507b))
## [0.1.5-experimental](https://github.com/open-feature/js-sdk/compare/nestjs-sdk-v0.1.4-experimental...nestjs-sdk-v0.1.5-experimental) (2024-05-13)
### 🐛 Bug Fixes
* removes exports of OpenFeatureClient class and makes event props readonly ([#918](https://github.com/open-feature/js-sdk/issues/918)) ([e9a25c2](https://github.com/open-feature/js-sdk/commit/e9a25c21cb17c3b5700bca652e3c0ed15e8f49b4))
### 🧹 Chore
* remove node 16 ([#875](https://github.com/open-feature/js-sdk/issues/875)) ([c1878e4](https://github.com/open-feature/js-sdk/commit/c1878e4effac3c8c9aa8a34cee4214f628a1e4ca))
* **deps:** update dependency supertest to v7 ([#939](https://github.com/open-feature/js-sdk/issues/939)) ([9083df8](https://github.com/open-feature/js-sdk/commit/9083df8463d6f970111dedee114aedc0a20e2a3c))
## [0.1.4-experimental](https://github.com/open-feature/js-sdk/compare/nestjs-sdk-v0.1.3-experimental...nestjremove node 16 ([#875](https://github.com/open-feature/js-sdk/issues/875)) ([c1878e4](https://github.com/open-feature/js-sdk/commit/c1878e4effac3c8c9aa8a34cee4214f628a1e4ca))
### 🐛 Bug Fixes
* removes exports of OpenFeatureClient class and makes event props readonly ([#918](https://github.com/open-feature/js-sdk/issues/918)) ([e9a25c2](https://github.com/open-feature/js-sdk/commit/e9a25c21cb17c3b5700bca652e3c0ed15e8f49b4))
### 🧹 Chore
* **deps:** update dependency supertest to v7 ([#939](https://github.com/open-feature/js-sdk/issues/939)) ([9083df8](https://github.com/open-feature/js-sdk/commit/9083df8463d6f970111dedee114aedc0a20e2a3c))
## [0.1.4-experimental](https://github.com/open-feature/js-sdk/compare/s-sdk-v0.1.4-experimental) (2024-04-18)
### 🧹 Chore
* bump spec version badge to v0.8.0 ([#910](https://github.com/open-feature/js-sdk/issues/910)) ([a7b2c4b](https://github.com/open-feature/js-sdk/commit/a7b2c4bca09112d49e637735466502adb1438ebe))
## [0.1.3-experimental](https://github.com/open-feature/js-sdk/compare/nestjs-sdk-v0.1.2-experimental...nestjs-sdk-v0.1.3-experimental) (2024-04-02)
### 🐛 Bug Fixes
* **deps:** resolve CVE-2024-29041 with nest update ([#889](https://github.com/open-feature/js-sdk/issues/889)) ([042ec5f](https://github.com/open-feature/js-sdk/commit/042ec5f70863ecc974371481be24f08c65321f7c))
### 📚 Documentation
* remove duplicate npm install section ([fd0fcfc](https://github.com/open-feature/js-sdk/commit/fd0fcfcfc815803a967e971b7e575c24e46c93bc))
## [0.1.2-experimental](https://github.com/open-feature/js-sdk/compare/nestjs-sdk-v0.1.1-experimental...nestjs-sdk-v0.1.2-experimental) (2024-03-06)
### 🐛 Bug Fixes
* **types:** conflicts with peer types ([#852](https://github.com/open-feature/js-sdk/issues/852)) ([fdc8576](https://github.com/open-feature/js-sdk/commit/fdc8576f472253604e26c36e10c0d315f71dbe1c))
## [0.1.1-experimental](https://github.com/open-feature/js-sdk/compare/nestjs-sdk-v0.1.0-experimental...nestjs-sdk-v0.1.1-experimental) (2024-03-05)

View File

@ -12,12 +12,12 @@
<!-- x-hide-in-docs-end -->
<!-- The 'github-badges' class is used in the docs -->
<p align="center" class="github-badges">
<a href="https://github.com/open-feature/spec/releases/tag/v0.8.0">
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
<a href="https://github.com/open-feature/spec/releases/tag/v0.7.0">
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.7.0&color=yellow&style=for-the-badge" />
</a>
<!-- x-release-please-start-version -->
<a href="https://github.com/open-feature/js-sdk/releases/tag/nestjs-sdk-v0.2.5">
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.2.5&color=blue&style=for-the-badge" />
<a href="https://github.com/open-feature/js-sdk/releases/tag/nestjs-sdk-v0.1.1-experimental">
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.1.1-experimental&color=blue&style=for-the-badge" />
</a>
<!-- x-release-please-end -->
<br/>
@ -50,7 +50,7 @@ Capabilities include:
### Requirements
- Node.js version 20+
- Node.js version 16+
- NestJS version 8+
### Install
@ -61,21 +61,14 @@ Capabilities include:
npm install --save @openfeature/nestjs-sdk
```
#### yarn
```sh
# yarn requires manual installation of the peer dependencies (see below)
yarn add @openfeature/nestjs-sdk @openfeature/server-sdk @openfeature/core
```
#### Required peer dependencies
The following list contains the peer dependencies of `@openfeature/nestjs-sdk` with its expected and compatible versions:
- `@openfeature/server-sdk`: >=1.7.5
- `@nestjs/common`: ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
- `@nestjs/core`: ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
- `rxjs`: ^6.0.0 || ^7.0.0 || ^8.0.0
* `@openfeature/server-sdk`: >=1.7.5
* `@nestjs/common`: ^8.0.0 || ^9.0.0 || ^10.0.0
* `@nestjs/core`: ^8.0.0 || ^9.0.0 || ^10.0.0
* `rxjs`: ^6.0.0 || ^7.0.0 || ^8.0.0
The minimum required version of `@openfeature/server-sdk` currently is `1.7.5`.
@ -137,13 +130,13 @@ It is also possible to inject the default or domain scoped OpenFeature clients i
```ts
import { Injectable } from '@nestjs/common';
import { OpenFeatureClient, Client } from '@openfeature/nestjs-sdk';
import { FeatureClient, Client } from '@openfeature/nestjs-sdk';
@Injectable()
export class OpenFeatureTestService {
constructor(
@OpenFeatureClient() private defaultClient: Client,
@OpenFeatureClient({ domain: 'my-domain' }) private scopedClient: Client,
@FeatureClient() private defaultClient: Client,
@FeatureClient({ domain: 'my-domain' }) private scopedClient: Client,
) {}
public async getBoolean() {
@ -152,24 +145,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
### Flag evaluation context injection

View File

@ -1,6 +1,6 @@
{
"name": "@openfeature/nestjs-sdk",
"version": "0.2.5",
"version": "0.1.1-experimental",
"description": "OpenFeature Nest.js SDK",
"main": "./dist/cjs/index.js",
"files": [
@ -16,10 +16,9 @@
"scripts": {
"test": "jest --verbose",
"lint": "eslint ./",
"lint:fix": "eslint ./ --fix",
"clean": "shx rm -rf ./dist",
"build:esm": "esbuild src/index.ts --bundle --external:@nestjs/* --external:@openfeature/server-sdk --sourcemap --target=es2015 --platform=node --format=esm --outfile=./dist/esm/index.js --analyze",
"build:cjs": "esbuild src/index.ts --bundle --external:@nestjs/* --external:@openfeature/server-sdk --sourcemap --target=es2015 --platform=node --format=cjs --outfile=./dist/cjs/index.js --analyze",
"build:esm": "esbuild src/index.ts --bundle --external:@nestjs/* --external:@openfeature/server-sdk --sourcemap --target=es2022 --platform=node --format=esm --outfile=./dist/esm/index.js --analyze",
"build:cjs": "esbuild src/index.ts --bundle --external:@nestjs/* --external:@openfeature/server-sdk --sourcemap --target=es2022 --platform=node --format=cjs --outfile=./dist/cjs/index.js --analyze",
"build:rollup-types": "rollup -c ../../rollup.config.mjs",
"build": "npm run clean && npm run build:esm && npm run build:cjs && npm run build:rollup-types",
"postbuild": "shx cp ./../../package.esm.json ./dist/esm/package.json",
@ -46,19 +45,19 @@
},
"homepage": "https://github.com/open-feature/js-sdk#readme",
"peerDependencies": {
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
"@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0",
"@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0",
"rxjs": "^6.0.0 || ^7.0.0 || 8.0.0",
"@openfeature/server-sdk": "^1.17.1"
"@openfeature/server-sdk": ">=1.7.5"
},
"devDependencies": {
"@nestjs/common": "^11.0.20",
"@nestjs/core": "^11.0.20",
"@nestjs/platform-express": "^11.0.20",
"@nestjs/testing": "^11.0.20",
"@nestjs/common": "^10.2.10",
"@nestjs/core": "^10.2.10",
"@nestjs/platform-express": "^10.2.10",
"@nestjs/testing": "^10.2.10",
"@openfeature/core": "*",
"@openfeature/server-sdk": "1.18.0",
"@openfeature/server-sdk": "*",
"@types/supertest": "^6.0.0",
"supertest": "^7.0.0"
"supertest": "^6.3.3"
}
}

View File

@ -1,6 +1,5 @@
import type { EvaluationContext } from '@openfeature/core';
import type { ExecutionContext} from '@nestjs/common';
import { Inject } from '@nestjs/common';
import { EvaluationContext } from '@openfeature/core';
import { ExecutionContext, Inject } from '@nestjs/common';
/**
* A factory function for creating an OpenFeature {@link EvaluationContext} from Nest {@link ExecutionContext}.

View File

@ -1,7 +1,5 @@
import type { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import type { ContextFactory} from './context-factory';
import { ContextFactoryToken } from './context-factory';
import { CallHandler, ExecutionContext, Inject, Injectable, NestInterceptor } from '@nestjs/common';
import { ContextFactory, ContextFactoryToken } from './context-factory';
import { Observable } from 'rxjs';
import { OpenFeature } from '@openfeature/server-sdk';
import { OpenFeatureModule } from './open-feature.module';

View File

@ -1,10 +1,14 @@
import { createParamDecorator, Inject } from '@nestjs/common';
import type { EvaluationContext, EvaluationDetails, FlagValue, JsonValue } from '@openfeature/server-sdk';
import { Client } from '@openfeature/server-sdk';
import {
EvaluationContext,
EvaluationDetails,
FlagValue,
JsonValue,
OpenFeature,
Client,
} from '@openfeature/server-sdk';
import { getOpenFeatureClientToken } from './open-feature.module';
import type { Observable } from 'rxjs';
import { from } from 'rxjs';
import { getClientForEvaluation } from './utils';
import { from, Observable } from 'rxjs';
/**
* Options for injecting an OpenFeature client into a constructor.
@ -22,7 +26,7 @@ interface FeatureClientProps {
* @param {FeatureClientProps} [props] The options for injecting the client.
* @returns {PropertyDecorator & ParameterDecorator} The decorator function.
*/
export const OpenFeatureClient = (props?: FeatureClientProps) => Inject(getOpenFeatureClientToken(props?.domain));
export const FeatureClient = (props?: FeatureClientProps) => Inject(getOpenFeatureClientToken(props?.domain));
/**
* Options for injecting a feature flag into a route handler.
@ -50,6 +54,16 @@ interface FeatureProps<T extends FlagValue> {
context?: EvaluationContext;
}
/**
* Returns a domain scoped or the default OpenFeature client with the given context.
* @param {string} domain The domain of the OpenFeature client.
* @param {EvaluationContext} context The evaluation context of the client.
* @returns {Client} The OpenFeature client.
*/
function getClientForEvaluation(domain?: string, context?: EvaluationContext) {
return domain ? OpenFeature.getClient(domain, context) : OpenFeature.getClient(context);
}
/**
* Route handler parameter decorator.
*

View File

@ -2,6 +2,5 @@ export * from './open-feature.module';
export * from './feature.decorator';
export * from './evaluation-context-interceptor';
export * from './context-factory';
export * from './require-flags-enabled.decorator';
// re-export the server-sdk so consumers can access that API from the nestjs-sdk
export * from '@openfeature/server-sdk';

View File

@ -1,27 +1,24 @@
import type {
import {
DynamicModule,
Module,
FactoryProvider as NestFactoryProvider,
ValueProvider,
ClassProvider,
Provider as NestProvider} from '@nestjs/common';
import {
Module,
Provider as NestProvider,
ExecutionContext,
} from '@nestjs/common';
import type {
import {
Client,
Hook,
OpenFeature,
Provider,
EvaluationContext,
ServerProviderEvents,
EventHandler,
Logger} from '@openfeature/server-sdk';
import {
OpenFeature,
Logger,
AsyncLocalStorageTransactionContextPropagator,
} from '@openfeature/server-sdk';
import type { ContextFactory} from './context-factory';
import { ContextFactoryToken } from './context-factory';
import { ContextFactory, ContextFactoryToken } from './context-factory';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { EvaluationContextInterceptor } from './evaluation-context-interceptor';
import { ShutdownService } from './shutdown.service';

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,5 +1,4 @@
import type { OnApplicationShutdown } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
import { OpenFeature } from '@openfeature/server-sdk';
@Injectable()

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,6 +1,5 @@
import { InMemoryProvider } from '@openfeature/server-sdk';
import type { EvaluationContext } from '@openfeature/server-sdk';
import type { ExecutionContext } from '@nestjs/common';
import { ExecutionContext } from '@nestjs/common';
import { OpenFeatureModule } from '../src';
export const defaultProvider = new InMemoryProvider({
@ -24,17 +23,6 @@ export const defaultProvider = new InMemoryProvider({
variants: { default: { client: 'default' } },
disabled: false,
},
testBooleanFlag2: {
defaultVariant: 'default',
variants: { default: false, enabled: true },
disabled: false,
contextEvaluator: (ctx: EvaluationContext) => {
if (ctx.targetingKey === '123') {
return 'enabled';
}
return 'default';
},
},
});
export const providers = {

View File

@ -1,13 +1,7 @@
import type { TestingModule } from '@nestjs/testing';
import { Test } from '@nestjs/testing';
import type { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import supertest from 'supertest';
import {
OpenFeatureController,
OpenFeatureContextScopedController,
OpenFeatureRequireFlagsEnabledController,
OpenFeatureTestService,
} from './test-app';
import { OpenFeatureController, OpenFeatureControllerContextScopedController, OpenFeatureTestService } from './test-app';
import { exampleContextFactory, getOpenFeatureDefaultTestModule } from './fixtures';
import { OpenFeatureModule } from '../src';
import { defaultProvider, providers } from './fixtures';
@ -19,9 +13,11 @@ describe('OpenFeature SDK', () => {
beforeAll(async () => {
moduleRef = await Test.createTestingModule({
imports: [getOpenFeatureDefaultTestModule()],
imports: [
getOpenFeatureDefaultTestModule()
],
providers: [OpenFeatureTestService],
controllers: [OpenFeatureController, OpenFeatureRequireFlagsEnabledController],
controllers: [OpenFeatureController],
}).compile();
app = moduleRef.createNestApplication();
app = await app.init();
@ -115,7 +111,7 @@ describe('OpenFeature SDK', () => {
});
describe('evaluation context service should', () => {
it('inject the evaluation context from contex factory', async function () {
it('inject the evaluation context from contex factory', async function() {
const evaluationSpy = jest.spyOn(defaultProvider, 'resolveBooleanEvaluation');
await supertest(app.getHttpServer())
.get('/dynamic-context-in-service')
@ -125,77 +121,26 @@ describe('OpenFeature SDK', () => {
expect(evaluationSpy).toHaveBeenCalledWith('testBooleanFlag', false, { targetingKey: 'dynamic-user' }, {});
});
});
describe('require flags enabled decorator', () => {
describe('OpenFeatureController', () => {
it('should sucessfully return the response if the flag is enabled', async () => {
await supertest(app.getHttpServer()).get('/flags-enabled').expect(200).expect('Get Boolean Flag Success!');
});
it('should throw an exception if the flag is disabled', async () => {
jest.spyOn(defaultProvider, 'resolveBooleanEvaluation').mockResolvedValueOnce({
value: false,
reason: 'DISABLED',
});
await supertest(app.getHttpServer()).get('/flags-enabled').expect(404);
});
it('should throw a custom exception if the flag is disabled', async () => {
jest.spyOn(defaultProvider, 'resolveBooleanEvaluation').mockResolvedValueOnce({
value: false,
reason: 'DISABLED',
});
await supertest(app.getHttpServer()).get('/flags-enabled-custom-exception').expect(403);
});
it('should throw a custom exception if the flag is disabled with context', async () => {
await supertest(app.getHttpServer())
.get('/flags-enabled-custom-exception-with-context')
.set('x-user-id', '123')
.expect(403);
});
});
describe('OpenFeatureControllerRequireFlagsEnabled', () => {
it('should allow access to the RequireFlagsEnabled controller with global context interceptor', async () => {
await supertest(app.getHttpServer())
.get('/require-flags-enabled')
.set('x-user-id', '123')
.expect(200)
.expect('Hello, world!');
});
it('should throw a 403 - Forbidden exception if user does not match targeting requirements', async () => {
await supertest(app.getHttpServer()).get('/require-flags-enabled').set('x-user-id', 'not-123').expect(403);
});
it('should throw a 403 - Forbidden exception if one of the flags is disabled', async () => {
jest.spyOn(defaultProvider, 'resolveBooleanEvaluation').mockResolvedValueOnce({
value: false,
reason: 'DISABLED',
});
await supertest(app.getHttpServer()).get('/require-flags-enabled').set('x-user-id', '123').expect(403);
});
});
});
});
describe('Without global context interceptor', () => {
let moduleRef: TestingModule;
let app: INestApplication;
beforeAll(async () => {
moduleRef = await Test.createTestingModule({
imports: [
OpenFeatureModule.forRoot({
contextFactory: exampleContextFactory,
defaultProvider,
providers,
useGlobalInterceptor: false,
useGlobalInterceptor: false
}),
],
providers: [OpenFeatureTestService],
controllers: [OpenFeatureController, OpenFeatureContextScopedController],
controllers: [OpenFeatureController, OpenFeatureControllerContextScopedController],
}).compile();
app = moduleRef.createNestApplication();
app = await app.init();
@ -212,7 +157,7 @@ describe('OpenFeature SDK', () => {
});
describe('evaluation context service should', () => {
it('inject empty context if no context interceptor is configured', async function () {
it('inject empty context if no context interceptor is configured', async function() {
const evaluationSpy = jest.spyOn(defaultProvider, 'resolveBooleanEvaluation');
await supertest(app.getHttpServer())
.get('/dynamic-context-in-service')
@ -226,26 +171,9 @@ describe('OpenFeature SDK', () => {
describe('With Controller bound Context interceptor', () => {
it('should not use context if global context interceptor is not configured', async () => {
const evaluationSpy = jest.spyOn(defaultProvider, 'resolveBooleanEvaluation');
await supertest(app.getHttpServer())
.get('/controller-context')
.set('x-user-id', '123')
.expect(200)
.expect('true');
await supertest(app.getHttpServer()).get('/controller-context').set('x-user-id', '123').expect(200).expect('true');
expect(evaluationSpy).toHaveBeenCalledWith('testBooleanFlag', false, { targetingKey: '123' }, {});
});
});
describe('require flags enabled decorator', () => {
it('should return a 404 - Not Found exception if the flag is disabled', async () => {
jest.spyOn(providers.domainScopedClient, 'resolveBooleanEvaluation').mockResolvedValueOnce({
value: false,
reason: 'DISABLED',
});
await supertest(app.getHttpServer())
.get('/controller-context/flags-enabled')
.set('x-user-id', '123')
.expect(404);
});
});
});
});

View File

@ -1,8 +1,6 @@
import type { TestingModule } from '@nestjs/testing';
import { Test } from '@nestjs/testing';
import { Test, TestingModule } from '@nestjs/testing';
import { getOpenFeatureClientToken, OpenFeatureModule, ServerProviderEvents } from '../src';
import type { Client} from '@openfeature/server-sdk';
import { OpenFeature } from '@openfeature/server-sdk';
import { OpenFeature, OpenFeatureClient } from '@openfeature/server-sdk';
import { getOpenFeatureDefaultTestModule } from './fixtures';
describe('OpenFeatureModule', () => {
@ -33,19 +31,19 @@ describe('OpenFeatureModule', () => {
it('should return the SDKs default provider and not throw', async () => {
expect(() => {
moduleWithoutProvidersRef.get<Client>(getOpenFeatureClientToken());
moduleWithoutProvidersRef.get<OpenFeatureClient>(getOpenFeatureClientToken());
}).not.toThrow();
});
});
it('should return the default provider', async () => {
const client = moduleRef.get<Client>(getOpenFeatureClientToken());
const client = moduleRef.get<OpenFeatureClient>(getOpenFeatureClientToken());
expect(client).toBeDefined();
expect(await client.getStringValue('testStringFlag', '')).toEqual('expected-string-value-default');
});
it('should inject the client with the given scope', async () => {
const client = moduleRef.get<Client>(getOpenFeatureClientToken('domainScopedClient'));
const client = moduleRef.get<OpenFeatureClient>(getOpenFeatureClientToken('domainScopedClient'));
expect(client).toBeDefined();
expect(await client.getStringValue('testStringFlag', '')).toEqual('expected-string-value-scoped');
});

View File

@ -1,22 +1,14 @@
import { Controller, ForbiddenException, Get, Injectable, UseInterceptors } from '@nestjs/common';
import type { Observable } from 'rxjs';
import { map } from 'rxjs';
import {
BooleanFeatureFlag,
ObjectFeatureFlag,
NumberFeatureFlag,
OpenFeatureClient,
StringFeatureFlag,
RequireFlagsEnabled,
} from '../src';
import type { Client, EvaluationDetails, FlagValue } from '@openfeature/server-sdk';
import { Controller, Get, Injectable, UseInterceptors } from '@nestjs/common';
import { Observable, map } from 'rxjs';
import { BooleanFeatureFlag, ObjectFeatureFlag, NumberFeatureFlag, FeatureClient, StringFeatureFlag } from '../src';
import { OpenFeatureClient, EvaluationDetails, FlagValue } from '@openfeature/server-sdk';
import { EvaluationContextInterceptor } from '../src';
@Injectable()
export class OpenFeatureTestService {
constructor(
@OpenFeatureClient() public defaultClient: Client,
@OpenFeatureClient({ domain: 'domainScopedClient' }) public domainScopedClient: Client,
@FeatureClient() public defaultClient: OpenFeatureClient,
@FeatureClient({ domain: 'domainScopedClient' }) public domainScopedClient: OpenFeatureClient,
) {}
public async serviceMethod(flag: EvaluationDetails<FlagValue>) {
@ -91,40 +83,11 @@ export class OpenFeatureController {
public async handleDynamicContextInServiceRequest() {
return this.testService.serviceMethodWithDynamicContext('testBooleanFlag');
}
@RequireFlagsEnabled({
flags: [{ flagKey: 'testBooleanFlag' }],
})
@Get('/flags-enabled')
public async handleGuardedBooleanRequest() {
return 'Get Boolean Flag Success!';
}
@RequireFlagsEnabled({
flags: [{ flagKey: 'testBooleanFlag' }],
exception: new ForbiddenException(),
})
@Get('/flags-enabled-custom-exception')
public async handleBooleanRequestWithCustomException() {
return 'Get Boolean Flag Success!';
}
@RequireFlagsEnabled({
flags: [{ flagKey: 'testBooleanFlag2' }],
exception: new ForbiddenException(),
context: {
targetingKey: 'user-id',
},
})
@Get('/flags-enabled-custom-exception-with-context')
public async handleBooleanRequestWithCustomExceptionAndContext() {
return 'Get Boolean Flag Success!';
}
}
@Controller()
@UseInterceptors(EvaluationContextInterceptor)
export class OpenFeatureContextScopedController {
export class OpenFeatureControllerContextScopedController {
constructor(private testService: OpenFeatureTestService) {}
@Get('/controller-context')
@ -137,27 +100,4 @@ export class OpenFeatureContextScopedController {
) {
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

@ -9,10 +9,10 @@
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "ES2015",
"target": "ES2022",
/* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
"lib": [
"ES2015"
"ES2022"
],
/* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
@ -27,7 +27,7 @@
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
/* Modules */
"module": "ES2015",
"module": "ES2022",
/* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
"moduleResolution": "node",

View File

@ -1,233 +1,5 @@
# Changelog
## [1.0.1](https://github.com/open-feature/js-sdk/compare/react-sdk-v1.0.0...react-sdk-v1.0.1) (2025-08-18)
### 🐛 Bug Fixes
* **react:** re-evaluate flags on re-render to detect silent provider … ([#1226](https://github.com/open-feature/js-sdk/issues/1226)) ([3105595](https://github.com/open-feature/js-sdk/commit/31055959265a53f52102590f54fa3168811ec678))
## [1.0.0](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.4.11...react-sdk-v1.0.0) (2025-04-14)
### ✨ New Features
* add polyfill for react use hook ([#1157](https://github.com/open-feature/js-sdk/issues/1157)) ([5afe61f](https://github.com/open-feature/js-sdk/commit/5afe61f9e351b037b04c93a1d81aee8016756748))
* add support for abort controllers to event handlers ([#1151](https://github.com/open-feature/js-sdk/issues/1151)) ([6a22483](https://github.com/open-feature/js-sdk/commit/6a224830fa4e62fc30a7802536f6f6fc3f772038))
## [0.4.11](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.4.10...react-sdk-v0.4.11) (2025-02-07)
### ✨ New Features
* export useOpenFeatureClientStatus hook ([#1082](https://github.com/open-feature/js-sdk/issues/1082)) ([4a6b860](https://github.com/open-feature/js-sdk/commit/4a6b8605444edeaf43355713357fecb97dd850b6))
### 🧹 Chore
* update sdk peer ([#1142](https://github.com/open-feature/js-sdk/issues/1142)) ([8bb6206](https://github.com/open-feature/js-sdk/commit/8bb620601e2b8dc7b62d717169b585bd1c886996))
## [0.4.10](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.4.9...react-sdk-v0.4.10) (2024-12-18)
### 🔄 Refactoring
* export public option types ([#1101](https://github.com/open-feature/js-sdk/issues/1101)) ([16321c3](https://github.com/open-feature/js-sdk/commit/16321c31f27c5fce2c8e2adea893cf6e7e8ce3de))
## [0.4.9](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.4.8...react-sdk-v0.4.9) (2024-12-04)
### ✨ New Features
* re-render if flagsChanged is falsy ([#1095](https://github.com/open-feature/js-sdk/issues/1095)) ([78516f4](https://github.com/open-feature/js-sdk/commit/78516f4181c82baf8c42fd64798fc2cfd8ff1056))
### 📚 Documentation
* fix typos, links, and format ([#1075](https://github.com/open-feature/js-sdk/issues/1075)) ([418409e](https://github.com/open-feature/js-sdk/commit/418409e3faafc6868a9f893267a4733db9931f93))
## [0.4.8](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.4.7...react-sdk-v0.4.8) (2024-10-29)
### 🧹 Chore
* bump minimum web peer ([#1072](https://github.com/open-feature/js-sdk/issues/1072)) ([eca8205](https://github.com/open-feature/js-sdk/commit/eca8205da7945395d19c09a4da67cd4c2d516227))
### 📚 Documentation
* add tracking sections ([#1068](https://github.com/open-feature/js-sdk/issues/1068)) ([e131faf](https://github.com/open-feature/js-sdk/commit/e131faffad9025e9c7194f39558bf3b3cec31807))
## [0.4.7](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.4.6...react-sdk-v0.4.7) (2024-10-29)
### ✨ New Features
* avoid re-resolving flags unaffected by a change event ([#1024](https://github.com/open-feature/js-sdk/issues/1024)) ([b8f9b4e](https://github.com/open-feature/js-sdk/commit/b8f9b4ebaf4bdd93669fc6da09d9f97a498174d9))
* implement tracking as per spec ([#1020](https://github.com/open-feature/js-sdk/issues/1020)) ([80f182e](https://github.com/open-feature/js-sdk/commit/80f182e1afbd3a705bf3de6a0d9886ccb3424b44))
* use mutate context hook ([#1031](https://github.com/open-feature/js-sdk/issues/1031)) ([ec3d967](https://github.com/open-feature/js-sdk/commit/ec3d967f8b9dd0854706a904a5360f0a0b843595))
### 🧹 Chore
* add js docs for context mutator hook ([#1045](https://github.com/open-feature/js-sdk/issues/1045)) ([def3fe8](https://github.com/open-feature/js-sdk/commit/def3fe8dafc3d6ed3451a493e76842b7d2e8363c))
* import type lint rule and fixes ([#1039](https://github.com/open-feature/js-sdk/issues/1039)) ([01fcb93](https://github.com/open-feature/js-sdk/commit/01fcb933d2cbd131a0f4a005173cdd1906087e18))
## [0.4.6](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.4.5...react-sdk-v0.4.6) (2024-09-23)
### 🐛 Bug Fixes
* failure to re-render on changes ([#1021](https://github.com/open-feature/js-sdk/issues/1021)) ([c927044](https://github.com/open-feature/js-sdk/commit/c927044c4934f0b8edfd2cdbbc0d60ad546b3dbc))
## [0.4.5](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.4.4...react-sdk-v0.4.5) (2024-09-04)
### ✨ New Features
* **react:** prevent rerenders when value is unchanged ([#987](https://github.com/open-feature/js-sdk/issues/987)) ([b7fc08e](https://github.com/open-feature/js-sdk/commit/b7fc08e27d225bdbf72c1985e7eef85adcd896b0))
## [0.4.4](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.4.3...react-sdk-v0.4.4) (2024-08-28)
### 🧹 Chore
* move client/ dir to web/ ([#991](https://github.com/open-feature/js-sdk/issues/991)) ([df4e72e](https://github.com/open-feature/js-sdk/commit/df4e72eabc3370801303470ca37263a0d4d9bb38))
### 📚 Documentation
* **react:** update the error message ([#978](https://github.com/open-feature/js-sdk/issues/978)) ([429c4ae](https://github.com/open-feature/js-sdk/commit/429c4ae941b66a1aa82b5aeea4bdb8b57bd05022))
## [0.4.3](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.4.2...react-sdk-v0.4.3) (2024-08-22)
### 🐛 Bug Fixes
* race condition in test provider with suspense ([#980](https://github.com/open-feature/js-sdk/issues/980)) ([0f187fe](https://github.com/open-feature/js-sdk/commit/0f187fe0b584e66b6283531eb7879c320967f921))
### 🧹 Chore
* fix flaky test timing ([ad46ade](https://github.com/open-feature/js-sdk/commit/ad46ade143b10366103d4ac199d728e8ae5ba7e8))
## [0.4.2](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.4.1...react-sdk-v0.4.2) (2024-07-29)
### ✨ New Features
* add test provider ([#971](https://github.com/open-feature/js-sdk/issues/971)) ([1c12d4d](https://github.com/open-feature/js-sdk/commit/1c12d4d548195bfc8c2f898a90ea97063aa8b3f7))
## [0.4.1](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.4.0...react-sdk-v0.4.1) (2024-06-11)
### ✨ New Features
* lower compilation target to es2015 ([#957](https://github.com/open-feature/js-sdk/issues/957)) ([c2d6c17](https://github.com/open-feature/js-sdk/commit/c2d6c1761ae19f937deaff2f011a0380f8af7350))
## [0.4.0](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.3.4...react-sdk-v0.4.0) (2024-05-13)
### ⚠ BREAKING CHANGES
* disable suspense by default, add suspense hooks ([#940](https://github.com/open-feature/js-sdk/issues/940))
### ✨ New Features
* disable suspense by default, add suspense hooks ([#940](https://github.com/open-feature/js-sdk/issues/940)) ([6bcef89](https://github.com/open-feature/js-sdk/commit/6bcef8977d0134c131af259dc0190a296e790382))
* set context during provider init on web ([#919](https://github.com/open-feature/js-sdk/issues/919)) ([7e6c1c6](https://github.com/open-feature/js-sdk/commit/7e6c1c6e7082e75535bf81b4e70c8c57ef870b77))
## [0.3.4](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.3.3...react-sdk-v0.3.4) (2024-05-01)
### 🐛 Bug Fixes
* delayed suspense causes "flicker" ([#921](https://github.com/open-feature/js-sdk/issues/921)) ([4bce2a0](https://github.com/open-feature/js-sdk/commit/4bce2a0f1a5a716160b8862f1882d24c97688288))
## [0.3.3](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.3.2...react-sdk-v0.3.3) (2024-04-23)
### 🐛 Bug Fixes
* invocation hooks not called ([#916](https://github.com/open-feature/js-sdk/issues/916)) ([2f77738](https://github.com/open-feature/js-sdk/commit/2f7773809007733d1ccaeeaa58b1799d6c1731b4))
## [0.3.2](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.3.2-experimental...react-sdk-v0.3.2) (2024-04-18)
### 🧹 Chore
* remove pre-release, update readme ([#908](https://github.com/open-feature/js-sdk/issues/908)) ([2532379](https://github.com/open-feature/js-sdk/commit/2532379f2ee5c38090a3e2c671edb2a6ca026bd5))
## [0.3.2-experimental](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.3.1-experimental...react-sdk-v0.3.2-experimental) (2024-04-11)
### 🐛 Bug Fixes
* re-render w/ useWhenProviderReady, add tests ([#901](https://github.com/open-feature/js-sdk/issues/901)) ([0f2094e](https://github.com/open-feature/js-sdk/commit/0f2094e2360ffed58a6103c00e5ba0ade6ac50eb))
## [0.3.1-experimental](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.3.0-experimental...react-sdk-v0.3.1-experimental) (2024-04-09)
### 🐛 Bug Fixes
* default options (re-renders not firing by default) ([#905](https://github.com/open-feature/js-sdk/issues/905)) ([a85e723](https://github.com/open-feature/js-sdk/commit/a85e72333fab85b3fcad87542c11fbed85ca9d85))
## [0.3.0-experimental](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.2.4-experimental...react-sdk-v0.3.0-experimental) (2024-04-08)
### ⚠ BREAKING CHANGES
* options inheritance, useWhenProviderReady, suspend by default ([#900](https://github.com/open-feature/js-sdk/issues/900))
### ✨ New Features
* options inheritance, useWhenProviderReady, suspend by default ([#900](https://github.com/open-feature/js-sdk/issues/900)) ([539e741](https://github.com/open-feature/js-sdk/commit/539e7415de8dae333fed72ae80590021d9600830))
## [0.2.4-experimental](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.2.3-experimental...react-sdk-v0.2.4-experimental) (2024-04-03)
### ✨ New Features
* query-style, generic useFlag hook ([#897](https://github.com/open-feature/js-sdk/issues/897)) ([5c17b8d](https://github.com/open-feature/js-sdk/commit/5c17b8dfcffd2f0145e5b2c79fa9dff842bbac92))
### 🔄 Refactoring
* dir restructure ([#894](https://github.com/open-feature/js-sdk/issues/894)) ([ce9f65c](https://github.com/open-feature/js-sdk/commit/ce9f65c6ec41867f67c528997cf3acef367f9260))
## [0.2.3-experimental](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.2.2-experimental...react-sdk-v0.2.3-experimental) (2024-03-25)
### 🐛 Bug Fixes
* make domain/client optional ([#884](https://github.com/open-feature/js-sdk/issues/884)) ([2b633b5](https://github.com/open-feature/js-sdk/commit/2b633b56778dde9a8955f19ca207fa0e8dced884))
### 🧹 Chore
* prompt web-sdk 1.0 ([#871](https://github.com/open-feature/js-sdk/issues/871)) ([7d50d93](https://github.com/open-feature/js-sdk/commit/7d50d931d5cda349a31969c997e7581ea4883b6a))
### 📚 Documentation
* fix invalid link fragment ([9d63803](https://github.com/open-feature/js-sdk/commit/9d638038c0062704dc701bfbba3004e89ed59e3e))
* remove emojis from react readme ([9e0e368](https://github.com/open-feature/js-sdk/commit/9e0e368d2328de2c7a4a5d91068aa75ecd70f8ed))
## [0.2.2-experimental](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.2.1-experimental...react-sdk-v0.2.2-experimental) (2024-03-06)
### 🐛 Bug Fixes
* **types:** conflicts with peer types ([#852](https://github.com/open-feature/js-sdk/issues/852)) ([fdc8576](https://github.com/open-feature/js-sdk/commit/fdc8576f472253604e26c36e10c0d315f71dbe1c))
## [0.2.1-experimental](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.2.0-experimental...react-sdk-v0.2.1-experimental) (2024-03-05)
### ✨ New Features
* maintain state in SDK, add RECONCILING ([#795](https://github.com/open-feature/js-sdk/issues/795)) ([cfb0a69](https://github.com/open-feature/js-sdk/commit/cfb0a69c42bd06bf59a7b8761fd90739872a8aeb))
* suspend on RECONCILING, mem provider fixes ([#796](https://github.com/open-feature/js-sdk/issues/796)) ([8101ff1](https://github.com/open-feature/js-sdk/commit/8101ff197ff97808d14114e56aae27023f9b09f6))
## [0.2.0-experimental](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.1.1-experimental...react-sdk-v0.2.0-experimental) (2024-02-27)

View File

@ -12,12 +12,12 @@
<!-- x-hide-in-docs-end -->
<!-- The 'github-badges' class is used in the docs -->
<p align="center" class="github-badges">
<a href="https://github.com/open-feature/spec/releases/tag/v0.8.0">
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
<a href="https://github.com/open-feature/spec/releases/tag/v0.7.0">
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.7.0&color=yellow&style=for-the-badge" />
</a>
<!-- x-release-please-start-version -->
<a href="https://github.com/open-feature/js-sdk/releases/tag/react-sdk-v1.0.1">
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v1.0.1&color=blue&style=for-the-badge" />
<a href="https://github.com/open-feature/js-sdk/releases/tag/react-sdk-v0.2.0-experimental">
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.2.0-experimental&color=blue&style=for-the-badge" />
</a>
<!-- x-release-please-end -->
<br/>
@ -34,36 +34,24 @@
<!-- x-hide-in-docs-end -->
🧪 This SDK is experimental.
## Overview
The OpenFeature React SDK adds React-specific functionality to the [OpenFeature Web SDK](https://openfeature.dev/docs/reference/technologies/client/web).
In addition to the feature provided by the [web sdk](https://openfeature.dev/docs/reference/technologies/client/web), capabilities include:
- [Overview](#overview)
- [Quick start](#quick-start)
- [Requirements](#requirements)
- [Install](#install)
- [npm](#npm)
- [yarn](#yarn)
- [Required peer dependencies](#required-peer-dependencies)
- [Usage](#usage)
- [OpenFeatureProvider context provider](#openfeatureprovider-context-provider)
- [Evaluation hooks](#evaluation-hooks)
- [Multiple Providers and Domains](#multiple-providers-and-domains)
- [Re-rendering with Context Changes](#re-rendering-with-context-changes)
- [Re-rendering with Flag Configuration Changes](#re-rendering-with-flag-configuration-changes)
- [Suspense Support](#suspense-support)
- [Tracking](#tracking)
- [Testing](#testing)
- [FAQ and troubleshooting](#faq-and-troubleshooting)
- [Resources](#resources)
- [Multiple Providers and domains](#multiple-providers-and-domains)
- [Re-rendering with Context Changes](#re-rendering-with-context-changes)
- [Re-rendering with Flag Configuration Changes](#re-rendering-with-flag-configuration-changes)
- [Suspense Support](#suspense-support)
## Quick start
## 🚀 Quick start
### Requirements
- ES2015-compatible web browser (Chrome, Edge, Firefox, etc)
- ES2022-compatible web browser (Chrome, Edge, Firefox, etc)
- React version 16.8+
### Install
@ -74,31 +62,19 @@ In addition to the feature provided by the [web sdk](https://openfeature.dev/doc
npm install --save @openfeature/react-sdk
```
#### yarn
```sh
# yarn requires manual installation of the peer dependencies (see below)
yarn add @openfeature/react-sdk @openfeature/web-sdk @openfeature/core
```
#### Required peer dependencies
The following list contains the peer dependencies of `@openfeature/react-sdk`.
See the [package.json](./package.json) for the required versions.
The following list contains the peer dependencies of `@openfeature/react-sdk` with its expected and compatible versions:
* `@openfeature/web-sdk`
* `react`
* `@openfeature/web-sdk`: >=0.4.10
* `react`: >=16.8.0
### Usage
#### OpenFeatureProvider context provider
The `OpenFeatureProvider` is a [React context provider](https://react.dev/reference/react/createContext#provider) which represents a scope for feature flag evaluations within a React application.
It binds an OpenFeature client to all evaluations within child components, and allows the use of evaluation hooks.
The example below shows how to use the `OpenFeatureProvider` with OpenFeature's `InMemoryProvider`.
```tsx
import { EvaluationContext, OpenFeatureProvider, useFlag, OpenFeature, InMemoryProvider } from '@openfeature/react-sdk';
import { EvaluationContext, OpenFeatureProvider, useBooleanFlagValue, useBooleanFlagDetails, OpenFeature, InMemoryProvider } from '@openfeature/react-sdk';
const flagConfig = {
'new-message': {
@ -117,11 +93,8 @@ const flagConfig = {
},
};
// Instantiate and set our provider (be sure this only happens once)!
// Note: there's no need to await its initialization, the React SDK handles re-rendering and suspense for you!
OpenFeature.setProvider(new InMemoryProvider(flagConfig));
// Enclose your content in the configured provider
function App() {
return (
<OpenFeatureProvider>
@ -129,39 +102,26 @@ function App() {
</OpenFeatureProvider>
);
}
```
#### Evaluation hooks
Within the provider, you can use the various evaluation hooks to evaluate flags.
```tsx
function Page() {
// Use the "query-style" flag evaluation hook, specifying a flag-key and a default value.
const { value: showNewMessage } = useFlag('new-message', true);
const newMessage = useBooleanFlagValue('new-message', false);
return (
<div className="App">
<header className="App-header">
{showNewMessage ? <p>Welcome to this OpenFeature-enabled React app!</p> : <p>Welcome to this React app.</p>}
{newMessage ? <p>Welcome to this OpenFeature-enabled React app!</p> : <p>Welcome to this React app.</p>}
</header>
</div>
)
}
export default App;
```
You can use the strongly typed flag value and flag evaluation detail hooks as well if you prefer.
```tsx
import { useBooleanFlagValue } from '@openfeature/react-sdk';
// boolean flag evaluation
const value = useBooleanFlagValue('new-message', false);
```
You use the detailed flag evaluation hooks to evaluate the flag and get additional information about the flag and the evaluation.
```tsx
import { useBooleanFlagDetails } from '@openfeature/react-sdk';
// "detailed" boolean flag evaluation
const {
value,
variant,
@ -170,12 +130,13 @@ const {
} = useBooleanFlagDetails('new-message', false);
```
#### Multiple Providers and Domains
### Multiple Providers and Domains
Multiple providers can be used by passing a `domain` to the `OpenFeatureProvider`:
```tsx
// Flags within this domain will use the client/provider associated with `my-domain`,
// Flags within this domain will use the a client/provider associated with `my-domain`,
function App() {
return (
<OpenFeatureProvider domain={'my-domain'}>
@ -191,63 +152,47 @@ This is analogous to:
OpenFeature.getClient('my-domain');
```
For more information about `domains`, refer to the [web SDK](https://github.com/open-feature/js-sdk/blob/main/packages/web/README.md).
For more information about `domains`, refer to the [web SDK](https://github.com/open-feature/js-sdk/blob/main/packages/client/README.md).
#### Re-rendering with Context Changes
### Re-rendering with Context Changes
By default, if the OpenFeature [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context) is modified, components will be re-rendered.
This is useful in cases where flag values are dependant on user-attributes or other application state (user logged in, items in card, etc).
You can disable this feature in the hook options (or in the [OpenFeatureProvider](#openfeatureprovider-context-provider)):
You can disable this feature in the hook options:
```tsx
function Page() {
const { value: showNewMessage } = useFlag('new-message', false, { updateOnContextChanged: false });
const newMessage = useBooleanFlagValue('new-message', false, { updateOnContextChanged: false });
return (
<div className="App">
<header className="App-header">
{showNewMessage ? <p>Welcome to this OpenFeature-enabled React app!</p> : <p>Welcome to this React app.</p>}
</header>
</div>
);
<MyComponents></MyComponents>
)
}
```
For more information about how evaluation context works in the React SDK, see the documentation on OpenFeature's [static context SDK paradigm](https://openfeature.dev/specification/glossary/#static-context-paradigm).
#### Re-rendering with Flag Configuration Changes
### Re-rendering with Flag Configuration Changes
By default, if the underlying provider emits a `ConfigurationChanged` event, components will be re-rendered.
This is useful if you want your UI to immediately reflect changes in the backend flag configuration.
You can disable this feature in the hook options (or in the [OpenFeatureProvider](#openfeatureprovider-context-provider)):
You can disable this feature in the hook options:
```tsx
function Page() {
const { value: showNewMessage } = useFlag('new-message', false, { updateOnConfigurationChanged: false });
const newMessage = useBooleanFlagValue('new-message', false, { updateOnConfigurationChanged: false });
return (
<div className="App">
<header className="App-header">
{showNewMessage ? <p>Welcome to this OpenFeature-enabled React app!</p> : <p>Welcome to this React app.</p>}
</header>
</div>
);
<MyComponents></MyComponents>
)
}
```
If your provider doesn't support updates, this configuration has no impact.
Note that if your provider doesn't support updates, this configuration has no impact.
> [!NOTE]
> If your provider includes a list of [flags changed](https://open-feature.github.io/js-sdk/types/_openfeature_server_sdk.ConfigChangeEvent.html) in its `PROVIDER_CONFIGURATION_CHANGED` event, that list of flags is used to decide which flag evaluation hooks should re-run by diffing the latest value of these flags with the previous render.
> If your provider event does not the include the `flags changed` list, then the SDK diffs all flags with the previous render to determine which hooks should re-run.
#### Suspense Support
> [!NOTE]
> React suspense is an experimental feature and is subject to change in future versions.
### Suspense Support
Frequently, providers need to perform some initial startup tasks.
It may be desirable not to display components with feature flags until this is complete or when the context changes.
Built-in [suspense](https://react.dev/reference/react/Suspense) support makes this easy.
Use `useSuspenseFlag` or pass `{ suspend: true }` in the hook options to leverage this functionality.
It may be desireable not to display components with feature flags until this is complete.
Built-in [suspense](https://react.dev/reference/react/Suspense) support makes this easy:
```tsx
function Content() {
@ -260,12 +205,12 @@ function Content() {
}
function Message() {
// component to render after READY, equivalent to useFlag('new-message', false, { suspend: true });
const { value: showNewMessage } = useSuspenseFlag('new-message', false);
// component to render after READY.
const newMessage = useBooleanFlagValue('new-message', false);
return (
<>
{showNewMessage ? (
{newMessage ? (
<p>Welcome to this OpenFeature-enabled React app!</p>
) : (
<p>Welcome to this plain old React app!</p>
@ -280,123 +225,6 @@ function Fallback() {
}
```
This can be disabled in the hook options (or in the [OpenFeatureProvider](#openfeatureprovider-context-provider)).
#### Tracking
The tracking API allows you to use OpenFeature abstractions and objects to associate user actions with feature flag evaluations.
This is essential for robust experimentation powered by feature flags.
For example, a flag enhancing the appearance of a UI component might drive user engagement to a new feature; to test this hypothesis, telemetry collected by a [hook](https://openfeature.dev/docs/reference/technologies/client/web/#hooks) or [provider](https://openfeature.dev/docs/reference/technologies/client/web/#providers) can be associated with telemetry reported in the client's `track` function.
The React SDK includes a hook for firing tracking events in the `<OpenFeatureProvider>` context in use:
```tsx
function MyComponent() {
// get a tracking function for this <OpenFeatureProvider>.
const { track } = useTrack();
// call the tracking event
// can be done in render, useEffect, or in handlers, depending on your use case
track(eventName, trackingDetails);
return <>...</>;
}
```
### Testing
The React SDK includes a built-in context provider for testing.
This allows you to easily test components that use evaluation hooks, such as `useFlag`.
If you try to test a component (in this case, `MyComponent`) which uses an evaluation hook, you might see an error message like:
> No OpenFeature client available - components using OpenFeature must be wrapped with an `<OpenFeatureProvider>`.
You can resolve this by simply wrapping your component under test in the OpenFeatureTestProvider:
```tsx
// use default values for all evaluations
<OpenFeatureTestProvider>
<MyComponent />
</OpenFeatureTestProvider>
```
The basic configuration above will simply use the default value provided in code.
If you'd like to control the values returned by the evaluation hooks, you can pass a map of flag keys and values:
```tsx
// return `true` for all evaluations of `'my-boolean-flag'`
<OpenFeatureTestProvider flagValueMap={{ 'my-boolean-flag': true }}>
<MyComponent />
</OpenFeatureTestProvider>
```
Additionally, you can pass an artificial delay for the provider startup to test your suspense boundaries or loaders/spinners impacted by feature flags:
```tsx
// delay the provider start by 1000ms and then return `true` for all evaluations of `'my-boolean-flag'`
<OpenFeatureTestProvider delayMs={1000} flagValueMap={{ 'my-boolean-flag': true }}>
<MyComponent />
</OpenFeatureTestProvider>
```
For maximum control, you can also pass your own mock provider implementation.
The type of this option is `Partial<Provider>`, so you can pass an incomplete implementation:
```tsx
class MyTestProvider implements Partial<Provider> {
// implement the relevant resolver
resolveBooleanEvaluation(): ResolutionDetails<boolean> {
return {
value: true,
variant: 'my-variant',
reason: 'MY_REASON',
};
}
}
```
```tsx
// use your custom testing provider
<OpenFeatureTestProvider provider={new MyTestProvider()}>
<MyComponent />
</OpenFeatureTestProvider>,
```
## FAQ and troubleshooting
> I get an error that says something like: `A React component suspended while rendering, but no fallback UI was specified.`
The OpenFeature React SDK features built-in [suspense support](#suspense-support).
This means that it will render your loading fallback automatically while your provider starts up and during context reconciliation for any of your components using feature flags!
If you use suspense and neglect to create a suspense boundary around any components using feature flags, you will see this error.
Add a suspense boundary to resolve this issue.
Alternatively, you can disable this suspense (the default) by removing `suspendWhileReconciling=true`, `suspendUntilReady=true` or `suspend=true` in the [evaluation hooks](#evaluation-hooks) or the [OpenFeatureProvider](#openfeatureprovider-context-provider) (which applies to all evaluation hooks in child components).
> I get odd rendering issues or errors when components mount if I use the suspense features.
In React 16/17's "Legacy Suspense", when a component suspends, its sibling components initially mount and then are hidden.
This can cause surprising effects and inconsistencies if sibling components are rendered while the provider is still getting ready.
To fix this, you can upgrade to React 18, which uses "Concurrent Suspense", in which siblings are not mounted until their suspended sibling resolves.
Alternatively, if you cannot upgrade to React 18, you can use the `useWhenProviderReady` utility hook in any sibling components to prevent them from mounting until the provider is ready.
> I am using multiple `OpenFeatureProvider` contexts, but they share the same provider or evaluation context. Why?
The `OpenFeatureProvider` binds a `client` to all child components, but the provider and context associated with that client is controlled by the `domain` parameter.
This is consistent with all OpenFeature SDKs.
To scope an OpenFeatureProvider to a particular provider/context, set the `domain` parameter on your `OpenFeatureProvider`:
```tsx
<OpenFeatureProvider domain={'my-domain'}>
<Page></Page>
</OpenFeatureProvider>
```
> I can import things form the `@openfeature/react-sdk`, `@openfeature/web-sdk`, and `@openfeature/core`; which should I use?
The `@openfeature/react-sdk` re-exports everything from its peers (`@openfeature/web-sdk` and `@openfeature/core`) and adds the React-specific features.
You can import everything from the `@openfeature/react-sdk` directly.
Avoid importing anything from `@openfeature/web-sdk` or `@openfeature/core`.
## Resources
- [Example repo](https://github.com/open-feature/react-test-app)
- [Example repo](https://github.com/open-feature/react-test-app)

View File

@ -1,6 +1,6 @@
{
"name": "@openfeature/react-sdk",
"version": "1.0.1",
"version": "0.2.0-experimental",
"description": "OpenFeature React SDK",
"main": "./dist/cjs/index.js",
"files": [
@ -16,10 +16,9 @@
"scripts": {
"test": "jest --verbose",
"lint": "eslint ./",
"lint:fix": "eslint ./ --fix",
"clean": "shx rm -rf ./dist",
"build:react-esm": "esbuild src/index.ts --bundle --external:react --external:@openfeature/web-sdk --sourcemap --target=es2015 --platform=browser --format=esm --outfile=./dist/esm/index.js --analyze",
"build:react-cjs": "esbuild src/index.ts --bundle --external:react --external:@openfeature/web-sdk --sourcemap --target=es2015 --platform=browser --format=cjs --outfile=./dist/cjs/index.js --analyze",
"build:react-esm": "esbuild src/index.ts --bundle --external:react --external:@openfeature/web-sdk --sourcemap --target=es2022 --platform=browser --format=esm --outfile=./dist/esm/index.js --analyze",
"build:react-cjs": "esbuild src/index.ts --bundle --external:react --external:@openfeature/web-sdk --sourcemap --target=es2022 --platform=browser --format=cjs --outfile=./dist/cjs/index.js --analyze",
"build:rollup-types": "rollup -c ../../rollup.config.mjs",
"build": "npm run clean && npm run build:react-esm && npm run build:react-cjs && npm run build:rollup-types",
"postbuild": "shx cp ./../../package.esm.json ./dist/esm/package.json",
@ -47,7 +46,7 @@
},
"homepage": "https://github.com/open-feature/js-sdk#readme",
"peerDependencies": {
"@openfeature/web-sdk": "^1.5.0",
"@openfeature/web-sdk": ">=0.4.14",
"react": ">=16.8.0"
},
"devDependencies": {

View File

@ -1 +0,0 @@
export * from './use-context-mutator';

View File

@ -1,51 +0,0 @@
import { useCallback, useContext, useRef } from 'react';
import type { EvaluationContext } from '@openfeature/web-sdk';
import { OpenFeature } from '@openfeature/web-sdk';
import { Context } from '../internal';
export type ContextMutationOptions = {
/**
* Mutate the default context instead of the domain scoped context applied at the `<OpenFeatureProvider/>`.
* Note, if the `<OpenFeatureProvider/>` has no domain specified, the default is used.
* See the {@link https://openfeature.dev/docs/reference/technologies/client/web/#manage-evaluation-context-for-domains|documentation} for more information.
* @default false
*/
defaultContext?: boolean;
};
export type ContextMutation = {
/**
* Context-aware function to set the desired context (see: {@link ContextMutationOptions} for details).
* There's generally no need to await the result of this function; flag evaluation hooks will re-render when the context is updated.
* This promise never rejects.
* @param updatedContext
* @returns Promise for awaiting the context update
*/
setContext: (updatedContext: EvaluationContext) => Promise<void>;
};
/**
* Get context-aware tracking function(s) for mutating the evaluation context associated with this domain, or the default context if `defaultContext: true`.
* See the {@link https://openfeature.dev/docs/reference/technologies/client/web/#targeting-and-context|documentation} for more information.
* @param {ContextMutationOptions} options options for the generated function
* @returns {ContextMutation} context-aware function(s) to mutate evaluation context
*/
export function useContextMutator(options: ContextMutationOptions = { defaultContext: false }): ContextMutation {
const { domain } = useContext(Context) || {};
const previousContext = useRef<null | EvaluationContext>(null);
const setContext = useCallback(async (updatedContext: EvaluationContext) => {
if (previousContext.current !== updatedContext) {
if (!domain || options?.defaultContext) {
OpenFeature.setContext(updatedContext);
} else {
OpenFeature.setContext(domain, updatedContext);
}
previousContext.current = updatedContext;
}
}, [domain]);
return {
setContext,
};
}

View File

@ -1 +0,0 @@
export * from './use-feature-flag';

View File

@ -1,377 +0,0 @@
import type {
Client,
ClientProviderEvents,
EvaluationDetails,
EventHandler,
FlagEvaluationOptions,
FlagValue,
JsonValue,
} from '@openfeature/web-sdk';
import { ProviderEvents, ProviderStatus } from '@openfeature/web-sdk';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
DEFAULT_OPTIONS,
isEqual,
normalizeOptions,
suspendUntilInitialized,
suspendUntilReconciled,
useProviderOptions,
} from '../internal';
import type { ReactFlagEvaluationNoSuspenseOptions, ReactFlagEvaluationOptions } from '../options';
import { useOpenFeatureClient } from '../provider/use-open-feature-client';
import { useOpenFeatureClientStatus } from '../provider/use-open-feature-client-status';
import { useOpenFeatureProvider } from '../provider/use-open-feature-provider';
import type { FlagQuery } from '../query';
import { HookFlagQuery } from '../internal/hook-flag-query';
// 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 a duplicate for the hook return below, this one is just used for casting because the name isn't as clear
type ConstrainedFlagQuery<T> = FlagQuery<
T extends boolean
? boolean
: T extends number
? number
: T extends string
? string
: T extends JsonValue
? T
: JsonValue
>;
/**
* Evaluates a feature flag generically, returning an react-flavored queryable object.
* The resolver method to use is based on the type of the defaultValue.
* For type-specific hooks, use {@link useBooleanFlagValue}, {@link useBooleanFlagDetails} and equivalents.
* By default, components will re-render when the flag value changes.
* @param {string} flagKey the flag identifier
* @template {FlagValue} T A optional generic argument constraining the default.
* @param {T} defaultValue the default value; used to determine what resolved type should be used.
* @param {ReactFlagEvaluationOptions} options for this evaluation
* @returns { FlagQuery } a queryable object containing useful information about the flag.
*/
export function useFlag<T extends FlagValue = FlagValue>(
flagKey: string,
defaultValue: T,
options?: ReactFlagEvaluationOptions,
): FlagQuery<
T extends boolean
? boolean
: T extends number
? number
: T extends string
? string
: T extends JsonValue
? T
: JsonValue
> {
// use the default value to determine the resolver to call
const query =
typeof defaultValue === 'boolean'
? new HookFlagQuery<boolean>(useBooleanFlagDetails(flagKey, defaultValue, options))
: typeof defaultValue === 'number'
? new HookFlagQuery<number>(useNumberFlagDetails(flagKey, defaultValue, options))
: typeof defaultValue === 'string'
? new HookFlagQuery<string>(useStringFlagDetails(flagKey, defaultValue, options))
: new HookFlagQuery<JsonValue>(useObjectFlagDetails(flagKey, defaultValue, options));
// TS sees this as HookFlagQuery<JsonValue>, because the compiler isn't aware of the `typeof` checks above.
return query as unknown as ConstrainedFlagQuery<T>;
}
// alias to the return value of useFlag, used to keep useSuspenseFlag consistent
type UseFlagReturn<T extends FlagValue> = ReturnType<typeof useFlag<T>>;
/**
* Equivalent to {@link useFlag} with `options: { suspend: true }`
* @experimental Suspense is an experimental feature subject to change in future versions.
* @param {string} flagKey the flag identifier
* @template {FlagValue} T A optional generic argument constraining the default.
* @param {T} defaultValue the default value; used to determine what resolved type should be used.
* @param {ReactFlagEvaluationNoSuspenseOptions} options for this evaluation
* @returns { UseFlagReturn<T> } a queryable object containing useful information about the flag.
*/
export function useSuspenseFlag<T extends FlagValue = FlagValue>(
flagKey: string,
defaultValue: T,
options?: ReactFlagEvaluationNoSuspenseOptions,
): UseFlagReturn<T> {
return useFlag(flagKey, defaultValue, { ...options, suspendUntilReady: true, suspendWhileReconciling: true });
}
/**
* Evaluates a feature flag, returning a boolean.
* By default, components will re-render when the flag value changes.
* For a generic hook returning a queryable interface, see {@link useFlag}.
* @param {string} flagKey the flag identifier
* @param {boolean} defaultValue the default value
* @param {ReactFlagEvaluationOptions} options options for this evaluation
* @returns { boolean} a EvaluationDetails object for this evaluation
*/
export function useBooleanFlagValue(
flagKey: string,
defaultValue: boolean,
options?: ReactFlagEvaluationOptions,
): boolean {
return useBooleanFlagDetails(flagKey, defaultValue, options).value;
}
/**
* Evaluates a feature flag, returning evaluation details.
* By default, components will re-render when the flag value changes.
* For a generic hook returning a queryable interface, see {@link useFlag}.
* @param {string} flagKey the flag identifier
* @param {boolean} defaultValue the default value
* @param {ReactFlagEvaluationOptions} options options for this evaluation
* @returns { EvaluationDetails<boolean>} a EvaluationDetails object for this evaluation
*/
export function useBooleanFlagDetails(
flagKey: string,
defaultValue: boolean,
options?: ReactFlagEvaluationOptions,
): EvaluationDetails<boolean> {
return attachHandlersAndResolve(
flagKey,
defaultValue,
(client) => {
return client.getBooleanDetails;
},
options,
);
}
/**
* Evaluates a feature flag, returning a string.
* By default, components will re-render when the flag value changes.
* For a generic hook returning a queryable interface, see {@link useFlag}.
* @param {string} flagKey the flag identifier
* @template {string} [T=string] A optional generic argument constraining the string
* @param {T} defaultValue the default value
* @param {ReactFlagEvaluationOptions} options options for this evaluation
* @returns { boolean} a EvaluationDetails object for this evaluation
*/
export function useStringFlagValue<T extends string = string>(
flagKey: string,
defaultValue: T,
options?: ReactFlagEvaluationOptions,
): string {
return useStringFlagDetails(flagKey, defaultValue, options).value;
}
/**
* Evaluates a feature flag, returning evaluation details.
* By default, components will re-render when the flag value changes.
* For a generic hook returning a queryable interface, see {@link useFlag}.
* @param {string} flagKey the flag identifier
* @template {string} [T=string] A optional generic argument constraining the string
* @param {T} defaultValue the default value
* @param {ReactFlagEvaluationOptions} options options for this evaluation
* @returns { EvaluationDetails<string>} a EvaluationDetails object for this evaluation
*/
export function useStringFlagDetails<T extends string = string>(
flagKey: string,
defaultValue: T,
options?: ReactFlagEvaluationOptions,
): EvaluationDetails<string> {
return attachHandlersAndResolve(
flagKey,
defaultValue,
(client) => {
return client.getStringDetails<T>;
},
options,
);
}
/**
* Evaluates a feature flag, returning a number.
* By default, components will re-render when the flag value changes.
* For a generic hook returning a queryable interface, see {@link useFlag}.
* @param {string} flagKey the flag identifier
* @template {number} [T=number] A optional generic argument constraining the number
* @param {T} defaultValue the default value
* @param {ReactFlagEvaluationOptions} options options for this evaluation
* @returns { boolean} a EvaluationDetails object for this evaluation
*/
export function useNumberFlagValue<T extends number = number>(
flagKey: string,
defaultValue: T,
options?: ReactFlagEvaluationOptions,
): number {
return useNumberFlagDetails(flagKey, defaultValue, options).value;
}
/**
* Evaluates a feature flag, returning evaluation details.
* By default, components will re-render when the flag value changes.
* For a generic hook returning a queryable interface, see {@link useFlag}.
* @param {string} flagKey the flag identifier
* @template {number} [T=number] A optional generic argument constraining the number
* @param {T} defaultValue the default value
* @param {ReactFlagEvaluationOptions} options options for this evaluation
* @returns { EvaluationDetails<number>} a EvaluationDetails object for this evaluation
*/
export function useNumberFlagDetails<T extends number = number>(
flagKey: string,
defaultValue: T,
options?: ReactFlagEvaluationOptions,
): EvaluationDetails<number> {
return attachHandlersAndResolve(
flagKey,
defaultValue,
(client) => {
return client.getNumberDetails<T>;
},
options,
);
}
/**
* Evaluates a feature flag, returning an object.
* By default, components will re-render when the flag value changes.
* For a generic hook returning a queryable interface, see {@link useFlag}.
* @param {string} flagKey the flag identifier
* @template {JsonValue} [T=JsonValue] A optional generic argument describing the structure
* @param {T} defaultValue the default value
* @param {ReactFlagEvaluationOptions} options options for this evaluation
* @returns { boolean} a EvaluationDetails object for this evaluation
*/
export function useObjectFlagValue<T extends JsonValue = JsonValue>(
flagKey: string,
defaultValue: T,
options?: ReactFlagEvaluationOptions,
): T {
return useObjectFlagDetails<T>(flagKey, defaultValue, options).value;
}
/**
* Evaluates a feature flag, returning evaluation details.
* By default, components will re-render when the flag value changes.
* For a generic hook returning a queryable interface, see {@link useFlag}.
* @param {string} flagKey the flag identifier
* @param {T} defaultValue the default value
* @template {JsonValue} [T=JsonValue] A optional generic argument describing the structure
* @param {ReactFlagEvaluationOptions} options options for this evaluation
* @returns { EvaluationDetails<T>} a EvaluationDetails object for this evaluation
*/
export function useObjectFlagDetails<T extends JsonValue = JsonValue>(
flagKey: string,
defaultValue: T,
options?: ReactFlagEvaluationOptions,
): EvaluationDetails<T> {
return attachHandlersAndResolve(
flagKey,
defaultValue,
(client) => {
return client.getObjectDetails<T>;
},
options,
);
}
// determines if a flag should be re-evaluated based on a list of changed flags
function shouldEvaluateFlag(flagKey: string, flagsChanged?: string[]): boolean {
// if flagsChange is missing entirely, we don't know what to re-render
return !flagsChanged || flagsChanged.includes(flagKey);
}
function attachHandlersAndResolve<T extends FlagValue>(
flagKey: string,
defaultValue: T,
resolver: (
client: Client,
) => (flagKey: string, defaultValue: T, options?: FlagEvaluationOptions) => EvaluationDetails<T>,
options?: ReactFlagEvaluationOptions,
): EvaluationDetails<T> {
// highest priority > evaluation hook options > provider options > default options > lowest priority
const defaultedOptions = { ...DEFAULT_OPTIONS, ...useProviderOptions(), ...normalizeOptions(options) };
const client = useOpenFeatureClient();
const status = useOpenFeatureClientStatus();
const provider = useOpenFeatureProvider();
const isFirstRender = useRef(true);
if (defaultedOptions.suspendUntilReady && status === ProviderStatus.NOT_READY) {
suspendUntilInitialized(provider, client);
}
if (defaultedOptions.suspendWhileReconciling && status === ProviderStatus.RECONCILING) {
suspendUntilReconciled(client);
}
const [evaluationDetails, setEvaluationDetails] = useState<EvaluationDetails<T>>(() =>
resolver(client).call(client, flagKey, defaultValue, options),
);
// Re-evaluate when dependencies change (handles prop changes like flagKey), or if during a re-render, we have detected a change in the evaluated value
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
const newDetails = resolver(client).call(client, flagKey, defaultValue, options);
if (!isEqual(newDetails.value, evaluationDetails.value)) {
setEvaluationDetails(newDetails);
}
}, [client, flagKey, defaultValue, options, resolver, evaluationDetails]);
// Maintain a mutable reference to the evaluation details to have a up-to-date reference in the handlers.
const evaluationDetailsRef = useRef<EvaluationDetails<T>>(evaluationDetails);
useEffect(() => {
evaluationDetailsRef.current = evaluationDetails;
}, [evaluationDetails]);
const updateEvaluationDetailsCallback = useCallback(() => {
const updatedEvaluationDetails = resolver(client).call(client, flagKey, defaultValue, options);
/**
* Avoid re-rendering if the value hasn't changed. We could expose a means
* to define a custom comparison function if users require a more
* sophisticated comparison in the future.
*/
if (!isEqual(updatedEvaluationDetails.value, evaluationDetailsRef.current.value)) {
setEvaluationDetails(updatedEvaluationDetails);
}
}, [client, flagKey, defaultValue, options, resolver]);
const configurationChangeCallback = useCallback<EventHandler<ClientProviderEvents.ConfigurationChanged>>(
(eventDetails) => {
if (shouldEvaluateFlag(flagKey, eventDetails?.flagsChanged)) {
updateEvaluationDetailsCallback();
}
},
[flagKey, updateEvaluationDetailsCallback],
);
useEffect(() => {
const controller = new AbortController();
if (status === ProviderStatus.NOT_READY) {
// update when the provider is ready
client.addHandler(ProviderEvents.Ready, updateEvaluationDetailsCallback, { signal: controller.signal });
}
if (defaultedOptions.updateOnContextChanged) {
// update when the context changes
client.addHandler(ProviderEvents.ContextChanged, updateEvaluationDetailsCallback, { signal: controller.signal });
}
if (defaultedOptions.updateOnConfigurationChanged) {
// update when the provider configuration changes
client.addHandler(ProviderEvents.ConfigurationChanged, configurationChangeCallback, {
signal: controller.signal,
});
}
return () => {
// cleanup the handlers
controller.abort();
};
}, [
client,
status,
defaultedOptions.updateOnContextChanged,
defaultedOptions.updateOnConfigurationChanged,
updateEvaluationDetailsCallback,
configurationChangeCallback,
]);
return evaluationDetails;
}

View File

@ -1,8 +1,4 @@
export * from './evaluation';
export * from './query';
export * from './use-feature-flag';
export * from './provider';
export * from './context';
export * from './tracking';
export * from './options';
// re-export the web-sdk so consumers can access that API from the react-sdk
export * from '@openfeature/web-sdk';

View File

@ -1,26 +0,0 @@
import type { Client } from '@openfeature/web-sdk';
import React from 'react';
import type { NormalizedOptions, ReactFlagEvaluationOptions } from '../options';
import { normalizeOptions } from '.';
/**
* The underlying React context.
*
* **DO NOT EXPORT PUBLICLY**
* @internal
*/
export const Context = React.createContext<
{ client: Client; domain?: string; options: ReactFlagEvaluationOptions } | undefined
>(undefined);
/**
* Get a normalized copy of the options used for this OpenFeatureProvider, see {@link normalizeOptions}.
*
* **DO NOT EXPORT PUBLICLY**
* @internal
* @returns {NormalizedOptions} normalized options the defaulted options, not defaulted or normalized.
*/
export function useProviderOptions(): NormalizedOptions {
const { options } = React.useContext(Context) || {};
return normalizeOptions(options);
}

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';
}
}

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