Compare commits

..

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

87 changed files with 9411 additions and 9941 deletions

View File

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

View File

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

View File

@ -16,9 +16,9 @@ jobs:
strategy:
matrix:
node-version:
- 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: 18
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

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,7 +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
@ -65,7 +64,7 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 18
registry-url: "https://registry.npmjs.org"
cache: 'npm'
- name: Build Packages

View File

@ -1,8 +1,9 @@
{
"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.2.2",
"packages/react": "0.4.10",
"packages/angular": "0.0.1-experimental",
"packages/web": "1.4.0",
"packages/server": "1.17.1",
"packages/shared": "1.7.0",
"packages/angular/projects/angular-sdk": "0.0.9-experimental"
}

View File

@ -3,4 +3,4 @@
#
# Managed by Peribolos: https://github.com/open-feature/community/blob/main/config/open-feature/sdk-javascript/workgroup.yaml
#
* @open-feature/sdk-javascript-maintainers @open-feature/maintainers
* @open-feature/sdk-javascript-maintainers

View File

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

16096
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +1,10 @@
{
"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=web --selectProjects=react --selectProjects=angular --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",
@ -36,15 +31,18 @@
"url": "https://github.com/open-feature/js-sdk/issues"
},
"homepage": "https://github.com/open-feature/js-sdk#readme",
"engines": {
"node": ">=18"
},
"devDependencies": {
"@rollup/plugin-typescript": "^12.0.0",
"@rollup/plugin-typescript": "^11.1.6",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^16.0.0",
"@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",
"esbuild": "^0.24.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-alias": "^1.1.2",
@ -59,18 +57,23 @@
"jest-environment-jsdom": "^29.7.0",
"jest-environment-node": "^29.7.0",
"jest-junit": "^16.0.0",
"jest-preset-angular": "^14.2.4",
"ng-packagr": "^18.2.1",
"prettier": "^3.2.5",
"react": "^18.2.0",
"rollup": "^4.0.0",
"rollup-plugin-dts": "^6.1.1",
"rxjs": "~7.8.0",
"shx": "^0.4.0",
"shx": "^0.3.4",
"ts-jest": "^29.1.2",
"ts-node": "^10.9.2",
"tslib": "^2.3.0",
"typedoc": "^0.26.0",
"typescript": "^4.7.4",
"uuid": "^11.0.0"
"uuid": "^9.0.1"
},
"overrides": {
"typescript": "^4.7.4"
},
"workspaces": [
"packages/shared",

View File

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

View File

@ -7,42 +7,37 @@
"lint": "ng lint",
"lint:fix": "ng lint --fix",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"test:coverage": "ng test --no-watch --code-coverage",
"test": "jest",
"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",
"@angular-devkit/build-angular": "^19.0.0",
"@angular-eslint/builder": "18.4.3",
"@angular-eslint/eslint-plugin": "18.4.3",
"@angular-eslint/eslint-plugin-template": "18.4.3",
"@angular-eslint/schematics": "18.4.3",
"@angular-eslint/template-parser": "18.4.3",
"@angular/animations": "^19.0.0",
"@angular/cli": "^19.0.0",
"@angular/common": "^19.0.0",
"@angular/compiler": "^19.0.0",
"@angular/compiler-cli": "^19.0.0",
"@angular/core": "^19.0.0",
"@angular/forms": "^19.0.0",
"@angular/platform-browser": "^19.0.0",
"@angular/platform-browser-dynamic": "^19.0.0",
"@angular/router": "^19.0.0",
"@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",
"jest-preset-angular": "^14.2.4",
"ng-packagr": "^19.0.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"typescript": "^5.8.3",
"vitest": "^3.2.4",
"typescript": "^5.5.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,61 +1,6 @@
# 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)

View File

@ -16,8 +16,8 @@
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
</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 href="https://github.com/open-feature/js-sdk/releases/tag/angular-sdk-v0.0.9-experimental">
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.0.9-experimental&color=blue&style=for-the-badge" />
</a>
<!-- x-release-please-end -->
<br/>
@ -44,22 +44,21 @@ In addition to the features provided by the [web sdk](https://openfeature.dev/do
- [Overview](#overview)
- [Quick start](#quick-start)
- [Requirements](#requirements)
- [Install](#install)
- [npm](#npm)
- [yarn](#yarn)
- [Required peer dependencies](#required-peer-dependencies)
- [Usage](#usage)
- [Module](#module)
- [Minimal Example](#minimal-example)
- [How to use](#how-to-use)
- [Boolean Feature Flag](#boolean-feature-flag)
- [Number Feature Flag](#number-feature-flag)
- [String Feature Flag](#string-feature-flag)
- [Object Feature Flag](#object-feature-flag)
- [Opting-out of automatic re-rendering](#opting-out-of-automatic-re-rendering)
- [Consuming the evaluation details](#consuming-the-evaluation-details)
- [Setting Evaluation Context](#setting-evaluation-context)
- [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)
- [FAQ and troubleshooting](#faq-and-troubleshooting)
- [Resources](#resources)
@ -114,7 +113,7 @@ import { OpenFeatureModule } from '@openfeature/angular-sdk';
CommonModule,
OpenFeatureModule.forRoot({
provider: yourFeatureProvider,
// domainBoundProviders are optional, mostly needed if more than one provider is used in the application.
// domainBoundProviders are optional, mostly needed if more than one provider is needed
domainBoundProviders: {
domain1: new YourOpenFeatureProvider(),
domain2: new YourOtherOpenFeatureProvider(),
@ -282,63 +281,6 @@ This can be used to just render the flag value or details without conditional re
</div>
```
##### Setting evaluation context
To set the initial evaluation context, you can add the `context` parameter to the `OpenFeatureModule` configuration.
This context can be either an object or a factory function that returns an `EvaluationContext`.
> [!TIP]
> Updating the context can be done directly via the global OpenFeature API using `OpenFeature.setContext()`
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?
@ -349,4 +291,4 @@ Avoid importing anything from `@openfeature/web-sdk` or `@openfeature/core`.
## Resources
- [Example repo](https://github.com/open-feature/angular-test-app)
- [Example repo](https://github.com/open-feature/angular-test-app)

View File

@ -1,34 +1,29 @@
{
"name": "@openfeature/angular-sdk",
"version": "0.0.16",
"version": "0.0.9-experimental",
"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"
"@angular/common": "^16.2.12 || ^17.3.0 || ^18.0.0 || ^19.0.0",
"@angular/core": "^16.2.12 || ^17.3.0 || ^18.0.0 || ^19.0.0",
"@openfeature/web-sdk": "^1.2.3"
},
"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"
"@openfeature/core": "*",
"@openfeature/web-sdk": "*",
"@angular/common": "^19.0.0",
"@angular/core": "^19.0.0"
},
"sideEffects": false,
"keywords": [

View File

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

View File

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

View File

@ -1,13 +1,10 @@
import { InjectionToken, ModuleWithProviders, NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { EvaluationContext, OpenFeature, Provider } from '@openfeature/web-sdk';
export type EvaluationContextFactory = () => EvaluationContext;
import { OpenFeature, Provider } from '@openfeature/web-sdk';
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');
@ -19,9 +16,7 @@ export const OPEN_FEATURE_CONFIG_TOKEN = new InjectionToken<OpenFeatureConfig>('
})
export class OpenFeatureModule {
static forRoot(config: OpenFeatureConfig): ModuleWithProviders<OpenFeatureModule> {
const context = typeof config.context === 'function' ? config.context() : config.context;
OpenFeature.setProvider(config.provider, context);
OpenFeature.setProvider(config.provider);
if (config.domainBoundProviders) {
Object.entries(config.domainBoundProviders).map(([domain, provider]) =>
OpenFeature.setProvider(domain, provider),

View File

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

View File

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

View File

@ -0,0 +1,5 @@
import 'jest-preset-angular/setup-jest';
import { TestBed } from '@angular/core/testing';
import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing';
TestBed.initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting());

View File

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

View File

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

View File

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

View File

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

View File

@ -16,8 +16,8 @@
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
</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.2.2">
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.2.2&color=blue&style=for-the-badge" />
</a>
<!-- x-release-please-end -->
<br/>
@ -50,7 +50,7 @@ Capabilities include:
### Requirements
- Node.js version 20+
- Node.js version 18+
- NestJS version 8+
### Install
@ -72,10 +72,10 @@ yarn add @openfeature/nestjs-sdk @openfeature/server-sdk @openfeature/core
The following list contains the peer dependencies of `@openfeature/nestjs-sdk` with its expected and compatible versions:
- `@openfeature/server-sdk`: >=1.7.5
- `@nestjs/common`: ^8.0.0 || ^9.0.0 || ^10.0.0 || ^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`.
@ -152,24 +152,6 @@ export class OpenFeatureTestService {
}
```
#### Managing Controller or Route Access via Feature Flags
The `RequireFlagsEnabled` decorator can be used to manage access to a controller or route based on the enabled state of a feature flag. The decorator will throw an exception if the required feature flag(s) are not enabled.
```ts
import { Controller, Get } from '@nestjs/common';
import { RequireFlagsEnabled } from '@openfeature/nestjs-sdk';
@Controller()
export class OpenFeatureController {
@RequireFlagsEnabled({ flags: [{ flagKey: 'testBooleanFlag' }] })
@Get('/welcome')
public async welcome() {
return 'Welcome to this OpenFeature-enabled NestJS app!';
}
}
```
## Module additional information
### Flag evaluation context injection

View File

@ -1,6 +1,6 @@
{
"name": "@openfeature/nestjs-sdk",
"version": "0.2.5",
"version": "0.2.2",
"description": "OpenFeature Nest.js SDK",
"main": "./dist/cjs/index.js",
"files": [
@ -46,18 +46,18 @@
},
"homepage": "https://github.com/open-feature/js-sdk#readme",
"peerDependencies": {
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^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.14.0"
},
"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.3.6",
"@nestjs/core": "^10.3.6",
"@nestjs/platform-express": "^10.3.6",
"@nestjs/testing": "^10.3.6",
"@openfeature/core": "*",
"@openfeature/server-sdk": "1.18.0",
"@openfeature/server-sdk": "*",
"@types/supertest": "^6.0.0",
"supertest": "^7.0.0"
}

View File

@ -1,10 +1,16 @@
import { createParamDecorator, Inject } from '@nestjs/common';
import type { EvaluationContext, EvaluationDetails, FlagValue, JsonValue } from '@openfeature/server-sdk';
import { Client } from '@openfeature/server-sdk';
import type {
EvaluationContext,
EvaluationDetails,
FlagValue,
JsonValue} from '@openfeature/server-sdk';
import {
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';
/**
* Options for injecting an OpenFeature client into a constructor.
@ -50,6 +56,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,104 +0,0 @@
import type { CallHandler, ExecutionContext, HttpException, NestInterceptor } from '@nestjs/common';
import { applyDecorators, mixin, NotFoundException, UseInterceptors } from '@nestjs/common';
import { getClientForEvaluation } from './utils';
import type { EvaluationContext } from '@openfeature/server-sdk';
import type { ContextFactory } from './context-factory';
type RequiredFlag = {
flagKey: string;
defaultValue?: boolean;
};
/**
* Options for using one or more Boolean feature flags to control access to a Controller or Route.
*/
interface RequireFlagsEnabledProps {
/**
* The key and default value of the feature flag.
* @see {@link Client#getBooleanValue}
*/
flags: RequiredFlag[];
/**
* The exception to throw if any of the required feature flags are not enabled.
* Defaults to a 404 Not Found exception.
* @see {@link HttpException}
* @default new NotFoundException(`Cannot ${req.method} ${req.url}`)
*/
exception?: HttpException;
/**
* The domain of the OpenFeature client, if a domain scoped client should be used.
* @see {@link OpenFeature#getClient}
*/
domain?: string;
/**
* The {@link EvaluationContext} for evaluating the feature flag.
* @see {@link OpenFeature#setContext}
*/
context?: EvaluationContext;
/**
* A factory function for creating an OpenFeature {@link EvaluationContext} from Nest {@link ExecutionContext}.
* For example, this can be used to get header info from an HTTP request or information from a gRPC call to be used in the {@link EvaluationContext}.
* @see {@link ContextFactory}
*/
contextFactory?: ContextFactory;
}
/**
* Controller or Route permissions handler decorator.
*
* Requires that the given feature flags are enabled for the request to be processed, else throws an exception.
*
* For example:
* ```typescript
* @RequireFlagsEnabled({
* flags: [ // Required, an array of Boolean flags to check, with optional default values (defaults to false)
* { flagKey: 'flagName' },
* { flagKey: 'flagName2', defaultValue: true },
* ],
* exception: new ForbiddenException(), // Optional, defaults to a 404 Not Found Exception
* domain: 'my-domain', // Optional, defaults to the default OpenFeature Client
* context: { // Optional, defaults to the global OpenFeature Context
* targetingKey: 'user-id',
* },
* contextFactory: (context: ExecutionContext) => { // Optional, defaults to the global OpenFeature Context. Takes precedence over the context option.
* return {
* targetingKey: context.switchToHttp().getRequest().headers['x-user-id'],
* };
* },
* })
* @Get('/')
* public async handleGetRequest()
* ```
* @param {RequireFlagsEnabledProps} props The options for injecting the feature flag.
* @returns {ClassDecorator & MethodDecorator} The decorator that can be used to require Boolean Feature Flags to be enabled for a controller or a specific route.
*/
export const RequireFlagsEnabled = (props: RequireFlagsEnabledProps): ClassDecorator & MethodDecorator =>
applyDecorators(UseInterceptors(FlagsEnabledInterceptor(props)));
const FlagsEnabledInterceptor = (props: RequireFlagsEnabledProps) => {
class FlagsEnabledInterceptor implements NestInterceptor {
constructor() {}
async intercept(context: ExecutionContext, next: CallHandler) {
const req = context.switchToHttp().getRequest();
const evaluationContext = props.contextFactory ? await props.contextFactory(context) : props.context;
const client = getClientForEvaluation(props.domain, evaluationContext);
for (const flag of props.flags) {
const endpointAccessible = await client.getBooleanValue(flag.flagKey, flag.defaultValue ?? false);
if (!endpointAccessible) {
throw props.exception || new NotFoundException(`Cannot ${req.method} ${req.url}`);
}
}
return next.handle();
}
}
return mixin(FlagsEnabledInterceptor);
};

View File

@ -1,12 +0,0 @@
import type { Client, EvaluationContext } from '@openfeature/server-sdk';
import { OpenFeature } from '@openfeature/server-sdk';
/**
* Returns a domain scoped or the default OpenFeature client with the given context.
* @param {string} domain The domain of the OpenFeature client.
* @param {EvaluationContext} context The evaluation context of the client.
* @returns {Client} The OpenFeature client.
*/
export function getClientForEvaluation(domain?: string, context?: EvaluationContext) {
return domain ? OpenFeature.getClient(domain, context) : OpenFeature.getClient(context);
}

View File

@ -1,5 +1,4 @@
import { InMemoryProvider } from '@openfeature/server-sdk';
import type { EvaluationContext } from '@openfeature/server-sdk';
import type { ExecutionContext } from '@nestjs/common';
import { OpenFeatureModule } from '../src';
@ -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

@ -2,12 +2,7 @@ import type { TestingModule } from '@nestjs/testing';
import { Test } from '@nestjs/testing';
import type { 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 +14,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 +112,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 +122,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 +158,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 +172,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,14 +1,7 @@
import { Controller, ForbiddenException, Get, Injectable, UseInterceptors } from '@nestjs/common';
import type { Observable } from 'rxjs';
import { Controller, 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 { BooleanFeatureFlag, ObjectFeatureFlag, NumberFeatureFlag, OpenFeatureClient, StringFeatureFlag } from '../src';
import type { Client, EvaluationDetails, FlagValue } from '@openfeature/server-sdk';
import { EvaluationContextInterceptor } from '../src';
@ -91,40 +84,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 +101,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

@ -1,32 +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)

View File

@ -16,8 +16,8 @@
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
</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.4.10">
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.4.10&color=blue&style=for-the-badge" />
</a>
<!-- x-release-please-end -->
<br/>

View File

@ -1,6 +1,6 @@
{
"name": "@openfeature/react-sdk",
"version": "1.0.1",
"version": "0.4.10",
"description": "OpenFeature React SDK",
"main": "./dist/cjs/index.js",
"files": [
@ -47,7 +47,7 @@
},
"homepage": "https://github.com/open-feature/js-sdk#readme",
"peerDependencies": {
"@openfeature/web-sdk": "^1.5.0",
"@openfeature/web-sdk": "^1.3.0",
"react": ">=16.8.0"
},
"devDependencies": {

View File

@ -5,24 +5,18 @@ import type {
EventHandler,
FlagEvaluationOptions,
FlagValue,
JsonValue,
} from '@openfeature/web-sdk';
import { ProviderEvents, ProviderStatus } from '@openfeature/web-sdk';
import { useCallback, useEffect, useRef, useState } from 'react';
JsonValue} from '@openfeature/web-sdk';
import {
DEFAULT_OPTIONS,
isEqual,
normalizeOptions,
suspendUntilInitialized,
suspendUntilReconciled,
useProviderOptions,
} from '../internal';
ProviderEvents,
ProviderStatus,
} from '@openfeature/web-sdk';
import { useEffect, useRef, useState } from 'react';
import type { ReactFlagEvaluationNoSuspenseOptions, ReactFlagEvaluationOptions } from '../options';
import { DEFAULT_OPTIONS, isEqual, normalizeOptions, suspendUntilReady, useProviderOptions } from '../internal';
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';
import { HookFlagQuery } from './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).
@ -286,41 +280,27 @@ function attachHandlersAndResolve<T extends FlagValue>(
const defaultedOptions = { ...DEFAULT_OPTIONS, ...useProviderOptions(), ...normalizeOptions(options) };
const client = useOpenFeatureClient();
const status = useOpenFeatureClientStatus();
const provider = useOpenFeatureProvider();
const isFirstRender = useRef(true);
// suspense
if (defaultedOptions.suspendUntilReady && status === ProviderStatus.NOT_READY) {
suspendUntilInitialized(provider, client);
suspendUntilReady(client);
}
if (defaultedOptions.suspendWhileReconciling && status === ProviderStatus.RECONCILING) {
suspendUntilReconciled(client);
suspendUntilReady(client);
}
const [evaluationDetails, setEvaluationDetails] = useState<EvaluationDetails<T>>(() =>
const [evaluationDetails, setEvaluationDetails] = useState<EvaluationDetails<T>>(
resolver(client).call(client, flagKey, defaultValue, options),
);
// 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 updateEvaluationDetailsCallback = () => {
const updatedEvaluationDetails = resolver(client).call(client, flagKey, defaultValue, options);
/**
@ -331,47 +311,41 @@ function attachHandlersAndResolve<T extends FlagValue>(
if (!isEqual(updatedEvaluationDetails.value, evaluationDetailsRef.current.value)) {
setEvaluationDetails(updatedEvaluationDetails);
}
}, [client, flagKey, defaultValue, options, resolver]);
};
const configurationChangeCallback = useCallback<EventHandler<ClientProviderEvents.ConfigurationChanged>>(
(eventDetails) => {
if (shouldEvaluateFlag(flagKey, eventDetails?.flagsChanged)) {
updateEvaluationDetailsCallback();
}
},
[flagKey, updateEvaluationDetailsCallback],
);
const configurationChangeCallback: EventHandler<ClientProviderEvents.ConfigurationChanged> = (eventDetails) => {
if (shouldEvaluateFlag(flagKey, eventDetails?.flagsChanged)) {
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 });
client.addHandler(ProviderEvents.Ready, updateEvaluationDetailsCallback);
}
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,
});
client.addHandler(ProviderEvents.ContextChanged, updateEvaluationDetailsCallback);
}
return () => {
// cleanup the handlers
controller.abort();
client.removeHandler(ProviderEvents.Ready, updateEvaluationDetailsCallback);
client.removeHandler(ProviderEvents.ContextChanged, updateEvaluationDetailsCallback);
};
}, [
client,
status,
defaultedOptions.updateOnContextChanged,
defaultedOptions.updateOnConfigurationChanged,
updateEvaluationDetailsCallback,
configurationChangeCallback,
]);
}, []);
useEffect(() => {
if (defaultedOptions.updateOnConfigurationChanged) {
// update when the provider configuration changes
client.addHandler(ProviderEvents.ConfigurationChanged, configurationChangeCallback);
}
return () => {
// cleanup the handlers
client.removeHandler(ProviderEvents.ConfigurationChanged, configurationChangeCallback);
};
}, []);
return evaluationDetails;
}

View File

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

View File

@ -1,9 +0,0 @@
const context = 'Components using OpenFeature must be wrapped with an <OpenFeatureProvider>.';
const tip = 'If you are seeing this in a test, see: https://openfeature.dev/docs/reference/technologies/client/web/react#testing';
export class MissingContextError extends Error {
constructor(reason: string) {
super(`${reason}: ${context} ${tip}`);
this.name = 'MissingContextError';
}
}

View File

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

View File

@ -1,53 +0,0 @@
/// <reference types="react/experimental" />
// This function is adopted from https://github.com/vercel/swr
import React from 'react';
/**
* Extends a Promise-like value to include status tracking.
* The extra properties are used to manage the lifecycle of the Promise, indicating its current state.
* More information can be found in the React RFE for the use hook.
* @see https://github.com/reactjs/rfcs/pull/229
*/
export type UsePromise<T> =
Promise<T> & {
status?: 'pending' | 'fulfilled' | 'rejected';
value?: T;
reason?: unknown;
};
/**
* React.use is a React API that lets you read the value of a resource like a Promise or context.
* It was officially added in React 19, so needs to be polyfilled to support older React versions.
* @param {UsePromise} thenable A thenable object that represents a Promise-like value.
* @returns {unknown} The resolved value of the thenable or throws if it's still pending or rejected.
*/
export const use =
React.use ||
// This extra generic is to avoid TypeScript mixing up the generic and JSX syntax
// and emitting an error.
// We assume that this is only for the `use(thenable)` case, not `use(context)`.
// https://github.com/facebook/react/blob/aed00dacfb79d17c53218404c52b1c7aa59c4a89/packages/react-server/src/ReactFizzThenable.js#L45
// eslint-disable-next-line @typescript-eslint/no-unused-vars
(<T, _>(thenable: UsePromise<T>): T => {
switch (thenable.status) {
case 'pending':
throw thenable;
case 'fulfilled':
return thenable.value as T;
case 'rejected':
throw thenable.reason;
default:
thenable.status = 'pending';
thenable.then(
(v) => {
thenable.status = 'fulfilled';
thenable.value = v;
},
(e) => {
thenable.status = 'rejected';
thenable.reason = e;
},
);
throw thenable;
}
});

View File

@ -31,7 +31,7 @@ type ProviderProps = {
* @param {ProviderProps} properties props for the context provider
* @returns {OpenFeatureProvider} context provider
*/
export function OpenFeatureProvider({ client, domain, children, ...options }: ProviderProps): JSX.Element {
export function OpenFeatureProvider({ client, domain, children, ...options }: ProviderProps) {
if (!client) {
client = OpenFeature.getClient(domain);
}

View File

@ -10,18 +10,22 @@ import { ProviderEvents } from '@openfeature/web-sdk';
export function useOpenFeatureClientStatus(): ProviderStatus {
const client = useOpenFeatureClient();
const [status, setStatus] = useState<ProviderStatus>(client.providerStatus);
const controller = new AbortController();
useEffect(() => {
const updateStatus = () => setStatus(client.providerStatus);
client.addHandler(ProviderEvents.ConfigurationChanged, updateStatus, { signal: controller.signal });
client.addHandler(ProviderEvents.ContextChanged, updateStatus, { signal: controller.signal });
client.addHandler(ProviderEvents.Error, updateStatus, { signal: controller.signal });
client.addHandler(ProviderEvents.Ready, updateStatus, { signal: controller.signal });
client.addHandler(ProviderEvents.Stale, updateStatus, { signal: controller.signal });
client.addHandler(ProviderEvents.Reconciling, updateStatus, { signal: controller.signal });
client.addHandler(ProviderEvents.ConfigurationChanged, updateStatus);
client.addHandler(ProviderEvents.ContextChanged, updateStatus);
client.addHandler(ProviderEvents.Error, updateStatus);
client.addHandler(ProviderEvents.Ready, updateStatus);
client.addHandler(ProviderEvents.Stale, updateStatus);
client.addHandler(ProviderEvents.Reconciling, updateStatus);
return () => {
controller.abort();
client.removeHandler(ProviderEvents.ConfigurationChanged, updateStatus);
client.removeHandler(ProviderEvents.ContextChanged, updateStatus);
client.removeHandler(ProviderEvents.Error, updateStatus);
client.removeHandler(ProviderEvents.Ready, updateStatus);
client.removeHandler(ProviderEvents.Stale, updateStatus);
client.removeHandler(ProviderEvents.Reconciling, updateStatus);
};
}, [client]);

View File

@ -1,7 +1,6 @@
import React from 'react';
import { Context } from '../internal';
import { type Client } from '@openfeature/web-sdk';
import { MissingContextError } from '../internal/errors';
import type { Client } from '@openfeature/web-sdk';
/**
* Get the {@link Client} instance for this OpenFeatureProvider context.
@ -12,7 +11,9 @@ export function useOpenFeatureClient(): Client {
const { client } = React.useContext(Context) || {};
if (!client) {
throw new MissingContextError('No OpenFeature client available');
throw new Error(
'No OpenFeature client available - components using OpenFeature must be wrapped with an <OpenFeatureProvider>. If you are seeing this in a test, see: https://openfeature.dev/docs/reference/technologies/client/web/react#testing',
);
}
return client;

View File

@ -1,21 +0,0 @@
import React from 'react';
import { Context } from '../internal';
import { OpenFeature } from '@openfeature/web-sdk';
import type { Provider } from '@openfeature/web-sdk';
import { MissingContextError } from '../internal/errors';
/**
* Get the {@link Provider} bound to the domain specified in the OpenFeatureProvider context.
* Note that it isn't recommended to interact with the provider directly, but rather through
* an OpenFeature client.
* @returns {Provider} provider for this scope
*/
export function useOpenFeatureProvider(): Provider {
const openFeatureContext = React.useContext(Context);
if (!openFeatureContext) {
throw new MissingContextError('No OpenFeature context available');
}
return OpenFeature.getProvider(openFeatureContext.domain);
}

View File

@ -2,8 +2,7 @@ import { ProviderStatus } from '@openfeature/web-sdk';
import { useOpenFeatureClient } from './use-open-feature-client';
import { useOpenFeatureClientStatus } from './use-open-feature-client-status';
import type { ReactFlagEvaluationOptions } from '../options';
import { DEFAULT_OPTIONS, useProviderOptions, normalizeOptions, suspendUntilInitialized } from '../internal';
import { useOpenFeatureProvider } from './use-open-feature-provider';
import { DEFAULT_OPTIONS, useProviderOptions, normalizeOptions, suspendUntilReady } from '../internal';
type Options = Pick<ReactFlagEvaluationOptions, 'suspendUntilReady'>;
@ -15,14 +14,14 @@ type Options = Pick<ReactFlagEvaluationOptions, 'suspendUntilReady'>;
* @returns {boolean} boolean indicating if provider is {@link ProviderStatus.READY}, useful if suspense is disabled and you want to handle loaders on your own
*/
export function useWhenProviderReady(options?: Options): boolean {
// highest priority > evaluation hook options > provider options > default options > lowest priority
const defaultedOptions = { ...DEFAULT_OPTIONS, ...useProviderOptions(), ...normalizeOptions(options) };
const client = useOpenFeatureClient();
const status = useOpenFeatureClientStatus();
const provider = useOpenFeatureProvider();
// highest priority > evaluation hook options > provider options > default options > lowest priority
const defaultedOptions = { ...DEFAULT_OPTIONS, ...useProviderOptions(), ...normalizeOptions(options) };
// suspense
if (defaultedOptions.suspendUntilReady && status === ProviderStatus.NOT_READY) {
suspendUntilInitialized(provider, client);
suspendUntilReady(client);
}
return status === ProviderStatus.READY;

View File

@ -6,7 +6,12 @@ import '@testing-library/jest-dom'; // see: https://testing-library.com/docs/rea
import { act, render, renderHook, screen, waitFor } from '@testing-library/react';
import * as React from 'react';
import { startTransition, useState } from 'react';
import type { EvaluationContext, EvaluationDetails, EventContext, Hook } from '../src/';
import type {
EvaluationContext,
EvaluationDetails,
EventContext,
Hook
} from '../src/';
import {
ErrorCode,
InMemoryProvider,
@ -22,18 +27,15 @@ import {
useObjectFlagValue,
useStringFlagDetails,
useStringFlagValue,
useSuspenseFlag,
useSuspenseFlag
} from '../src/';
import { HookFlagQuery } from '../src/internal/hook-flag-query';
import { HookFlagQuery } from '../src/evaluation/hook-flag-query';
import { TestingProvider } from './test.utils';
// custom provider to have better control over the emitted events
class CustomEventInMemoryProvider extends InMemoryProvider {
putConfigurationWithCustomEvent(
flagConfiguration: FlagConfiguration,
event: ProviderEmittableEvents,
eventContext: EventContext,
) {
putConfigurationWithCustomEvent(flagConfiguration: FlagConfiguration, event: ProviderEmittableEvents, eventContext: EventContext) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this['_flagConfiguration'] = { ...flagConfiguration }; // private access hack
this.events.emit(event, eventContext);
@ -393,19 +395,16 @@ describe('evaluation', () => {
expect(screen.queryByTestId('render-count')).toHaveTextContent('1');
await act(async () => {
await rerenderProvider.putConfigurationWithCustomEvent(
{
...FLAG_CONFIG,
[BOOL_FLAG_KEY]: {
...FLAG_CONFIG[BOOL_FLAG_KEY],
// Change the default; this should be ignored and not cause a re-render because flagsChanged is empty
defaultVariant: 'off',
},
// if the flagsChanged is empty, we know nothing has changed, so we don't bother diffing
await rerenderProvider.putConfigurationWithCustomEvent({
...FLAG_CONFIG,
[BOOL_FLAG_KEY]: {
...FLAG_CONFIG[BOOL_FLAG_KEY],
// Change the default; this should be ignored and not cause a re-render because flagsChanged is empty
defaultVariant: 'off',
},
ClientProviderEvents.ConfigurationChanged,
{ flagsChanged: [] },
);
// if the flagsChanged is empty, we know nothing has changed, so we don't bother diffing
}, ClientProviderEvents.ConfigurationChanged, { flagsChanged: [] });
});
expect(screen.queryByTestId('render-count')).toHaveTextContent('1');
@ -421,19 +420,16 @@ describe('evaluation', () => {
expect(screen.queryByTestId('render-count')).toHaveTextContent('1');
await act(async () => {
await rerenderProvider.putConfigurationWithCustomEvent(
{
...FLAG_CONFIG,
[BOOL_FLAG_KEY]: {
...FLAG_CONFIG[BOOL_FLAG_KEY],
// Change the default variant to trigger a rerender since not only do we check flagsChanged, but we also diff the value
defaultVariant: 'off',
},
// if the flagsChanged is falsy, we don't know what flags changed - so we attempt to diff everything
await rerenderProvider.putConfigurationWithCustomEvent({
...FLAG_CONFIG,
[BOOL_FLAG_KEY]: {
...FLAG_CONFIG[BOOL_FLAG_KEY],
// Change the default variant to trigger a rerender since not only do we check flagsChanged, but we also diff the value
defaultVariant: 'off',
},
ClientProviderEvents.ConfigurationChanged,
{ flagsChanged: undefined },
);
// if the flagsChanged is falsy, we don't know what flags changed - so we attempt to diff everything
}, ClientProviderEvents.ConfigurationChanged, { flagsChanged: undefined });
});
expect(screen.queryByTestId('render-count')).toHaveTextContent('2');
@ -577,41 +573,10 @@ describe('evaluation', () => {
},
};
afterEach(() => {
OpenFeature.clearProviders();
});
const suspendingProvider = () => {
return new TestingProvider(CONFIG, DELAY); // delay init by 100ms
};
describe('when using the noop provider', () => {
function TestComponent() {
const { value } = useSuspenseFlag(SUSPENSE_FLAG_KEY, DEFAULT);
return (
<>
<div>{value}</div>
</>
);
}
it('should fallback to the default value on the next rerender', async () => {
render(
<OpenFeatureProvider>
<React.Suspense fallback={<div>{FALLBACK}</div>}>
<TestComponent></TestComponent>
</React.Suspense>
</OpenFeatureProvider>,
);
// The loading indicator should be shown on the first render
expect(screen.queryByText(FALLBACK)).toBeInTheDocument();
// The default value should be shown on the next render
await waitFor(() => expect(screen.queryByText(DEFAULT)).toBeInTheDocument(), {
timeout: DELAY,
});
});
});
describe('updateOnConfigurationChanged=true (default)', () => {
function TestComponent() {
const { value } = useFlag(SUSPENSE_FLAG_KEY, DEFAULT);
@ -924,124 +889,17 @@ describe('evaluation', () => {
OpenFeature.setContext(SUSPEND_OFF, { user: TARGETED_USER });
});
// With the fix for useState initialization, the hook now immediately
// reflects provider state changes. This is intentional to handle cases
// where providers don't emit proper events.
// The value updates immediately to the targeted value.
// expect to see static value until we reconcile
await waitFor(() => expect(screen.queryByText(STATIC_FLAG_VALUE_A)).toBeInTheDocument(), {
timeout: DELAY / 2,
});
// make sure we updated after reconciling
await waitFor(() => expect(screen.queryByText(TARGETED_FLAG_VALUE)).toBeInTheDocument(), {
timeout: DELAY * 2,
});
});
});
describe('re-render behavior when flag values change without provider events', ()=> {
it('should reflect provider state changes on re-render even without provider events', async () => {
let providerValue = 'initial-value';
class SilentUpdateProvider extends InMemoryProvider {
resolveBooleanEvaluation() {
return {
value: true,
variant: 'on',
reason: StandardResolutionReasons.STATIC,
};
}
resolveStringEvaluation() {
return {
value: providerValue,
variant: providerValue,
reason: StandardResolutionReasons.STATIC,
};
}
}
const provider = new SilentUpdateProvider({});
await OpenFeature.setProviderAndWait('test', provider);
// The triggerRender prop forces a re-render
const TestComponent = ({ triggerRender }: { triggerRender: number }) => {
const { value } = useFlag('test-flag', 'default');
return <div data-testid="flag-value" data-render-count={triggerRender}>{value}</div>;
};
const WrapperComponent = () => {
const [renderCount, setRenderCount] = useState(0);
return (
<>
<button onClick={() => setRenderCount(c => c + 1)}>Force Re-render</button>
<TestComponent triggerRender={renderCount} />
</>
);
};
const { getByText } = render(
<OpenFeatureProvider client={OpenFeature.getClient('test')}>
<WrapperComponent />
</OpenFeatureProvider>
);
// Initial value should be rendered
await waitFor(() => {
expect(screen.getByTestId('flag-value')).toHaveTextContent('initial-value');
});
// Change the provider's internal state (without emitting events)
providerValue = 'updated-value';
// Force a re-render of the component
act(() => {
getByText('Force Re-render').click();
});
await waitFor(() => {
expect(screen.getByTestId('flag-value')).toHaveTextContent('updated-value');
});
});
it('should update flag value when flag key prop changes without provider events', async () => {
const provider = new InMemoryProvider({
'flag-a': {
disabled: false,
variants: { on: 'value-a' },
defaultVariant: 'on',
},
'flag-b': {
disabled: false,
variants: { on: 'value-b' },
defaultVariant: 'on',
},
});
await OpenFeature.setProviderAndWait(EVALUATION, provider);
const TestComponent = ({ flagKey }: { flagKey: string }) => {
const { value } = useFlag(flagKey, 'default');
return <div data-testid="flag-value">{value}</div>;
};
const { rerender } = render(
<OpenFeatureProvider client={OpenFeature.getClient(EVALUATION)}>
<TestComponent flagKey="flag-a" />
</OpenFeatureProvider>
);
await waitFor(() => {
expect(screen.getByTestId('flag-value')).toHaveTextContent('value-a');
});
// Change to flag-b (without any provider events)
rerender(
<OpenFeatureProvider client={OpenFeature.getClient(EVALUATION)}>
<TestComponent flagKey="flag-b" />
</OpenFeatureProvider>
);
await waitFor(() => {
expect(screen.getByTestId('flag-value')).toHaveTextContent('value-b');
});
});
});
});
describe('context, hooks and options', () => {

View File

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

View File

@ -1,35 +1,5 @@
# Changelog
## [1.19.0](https://github.com/open-feature/js-sdk/compare/server-sdk-v1.18.0...server-sdk-v1.19.0) (2025-08-14)
### ✨ New Features
* add evaluation-scoped hook data ([#1216](https://github.com/open-feature/js-sdk/issues/1216)) ([07af3a9](https://github.com/open-feature/js-sdk/commit/07af3a9eda895e9edb24c7ee1e3c1c4f16e17431))
### 🐛 Bug Fixes
* update core dep ([#1228](https://github.com/open-feature/js-sdk/issues/1228)) ([845d24c](https://github.com/open-feature/js-sdk/commit/845d24c5fecc80de3080e49fde839f08ecac6b33))
### 🧹 Chore
* update node to v20+ ([#1203](https://github.com/open-feature/js-sdk/issues/1203)) ([1f33453](https://github.com/open-feature/js-sdk/commit/1f33453c23df0763cbf0d0b44db8d91216377009))
### 📚 Documentation
* Clarify the behavior of setProviderAndWait ([#1180](https://github.com/open-feature/js-sdk/issues/1180)) ([4fe8d87](https://github.com/open-feature/js-sdk/commit/4fe8d87a2e5df2cbd4086cc4f4a380e8857ed8ba))
## [1.18.0](https://github.com/open-feature/js-sdk/compare/server-sdk-v1.17.1...server-sdk-v1.18.0) (2025-04-11)
### ✨ New Features
* add a top-level method for accessing providers ([#1152](https://github.com/open-feature/js-sdk/issues/1152)) ([ae8fce8](https://github.com/open-feature/js-sdk/commit/ae8fce87530005ed20f7e68dc696ce67053fca31))
* add support for abort controllers to event handlers ([#1151](https://github.com/open-feature/js-sdk/issues/1151)) ([6a22483](https://github.com/open-feature/js-sdk/commit/6a224830fa4e62fc30a7802536f6f6fc3f772038))
## [1.17.1](https://github.com/open-feature/js-sdk/compare/server-sdk-v1.17.0...server-sdk-v1.17.1) (2025-02-07)

View File

@ -16,8 +16,8 @@
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
</a>
<!-- x-release-please-start-version -->
<a href="https://github.com/open-feature/js-sdk/releases/tag/server-sdk-v1.19.0">
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v1.19.0&color=blue&style=for-the-badge" />
<a href="https://github.com/open-feature/js-sdk/releases/tag/server-sdk-v1.17.1">
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v1.17.1&color=blue&style=for-the-badge" />
</a>
<!-- x-release-please-end -->
<br/>
@ -75,11 +75,7 @@ yarn add @openfeature/server-sdk @openfeature/core
import { OpenFeature } from '@openfeature/server-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();

View File

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

View File

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

View File

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

View File

@ -82,7 +82,7 @@ export class OpenFeatureAPI
* Setting a provider supersedes the current provider used in new and existing unbound clients.
* @param {Provider} provider The provider responsible for flag evaluations.
* @returns {Promise<void>}
* @throws {Error} If the provider throws an exception during initialization.
* @throws Uncaught exceptions thrown by the provider during initialization.
*/
setProviderAndWait(provider: Provider): Promise<void>;
/**
@ -92,7 +92,7 @@ export class OpenFeatureAPI
* @param {string} domain The name to identify the client
* @param {Provider} provider The provider responsible for flag evaluations.
* @returns {Promise<void>}
* @throws {Error} If the provider throws an exception during initialization.
* @throws Uncaught exceptions thrown by the provider during initialization.
*/
setProviderAndWait(domain: string, provider: Provider): Promise<void>;
async setProviderAndWait(domainOrProvider?: string | Provider, providerOrUndefined?: Provider): Promise<void> {
@ -138,27 +138,6 @@ export class OpenFeatureAPI
return this;
}
/**
* Get the default provider.
*
* Note that it isn't recommended to interact with the provider directly, but rather through
* an OpenFeature client.
* @returns {Provider} Default Provider
*/
getProvider(): Provider;
/**
* Get the provider bound to the specified domain.
*
* Note that it isn't recommended to interact with the provider directly, but rather through
* an OpenFeature client.
* @param {string} domain An identifier which logically binds clients with providers
* @returns {Provider} Domain-scoped provider
*/
getProvider(domain?: string): Provider;
getProvider(domain?: string): Provider {
return this.getProviderForClient(domain);
}
setContext(context: EvaluationContext): this {
this._context = context;
return this;

View File

@ -449,21 +449,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;
@ -473,21 +459,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', () => {

View File

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

View File

@ -74,8 +74,8 @@ describe('OpenFeature', () => {
it('should set the default provider if no domain is provided', () => {
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', () => {
@ -85,11 +85,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

@ -1,41 +1,5 @@
# Changelog
## [1.9.0](https://github.com/open-feature/js-sdk/compare/core-v1.8.1...core-v1.9.0) (2025-08-10)
### ✨ New Features
* add evaluation-scoped hook data ([#1216](https://github.com/open-feature/js-sdk/issues/1216)) ([07af3a9](https://github.com/open-feature/js-sdk/commit/07af3a9eda895e9edb24c7ee1e3c1c4f16e17431))
* support Angular 20 ([#1220](https://github.com/open-feature/js-sdk/issues/1220)) ([aa232a9](https://github.com/open-feature/js-sdk/commit/aa232a9d6a8dfa416380ccdecd71843d3e361048))
## [1.8.1](https://github.com/open-feature/js-sdk/compare/core-v1.8.0...core-v1.8.1) (2025-06-04)
### 🔄 Refactoring
* **telemetry:** update telemetry attributes and remove unused evaluation data ([#1189](https://github.com/open-feature/js-sdk/issues/1189)) ([3e6bcae](https://github.com/open-feature/js-sdk/commit/3e6bcaef0bb5c05e914a0078f0cb884b7f74f068))
## [1.8.0](https://github.com/open-feature/js-sdk/compare/core-v1.7.2...core-v1.8.0) (2025-04-10)
### ✨ New Features
* add support for abort controllers to event handlers ([#1151](https://github.com/open-feature/js-sdk/issues/1151)) ([6a22483](https://github.com/open-feature/js-sdk/commit/6a224830fa4e62fc30a7802536f6f6fc3f772038))
## [1.7.2](https://github.com/open-feature/js-sdk/compare/core-v1.7.1...core-v1.7.2) (2025-02-18)
### 🐛 Bug Fixes
* rename evaluation event property from data to body ([4c2b01e](https://github.com/open-feature/js-sdk/commit/4c2b01e36773091038d758ac10bba06056ff4c45))
## [1.7.1](https://github.com/open-feature/js-sdk/compare/core-v1.7.0...core-v1.7.1) (2025-02-13)
### 🐛 Bug Fixes
* export missing telemetry functionality ([#1148](https://github.com/open-feature/js-sdk/issues/1148)) ([dcbc300](https://github.com/open-feature/js-sdk/commit/dcbc30090e7611c60e06d05826f6471f0c8c4009))
## [1.7.0](https://github.com/open-feature/js-sdk/compare/core-v1.6.0...core-v1.7.0) (2025-02-07)

View File

@ -1,6 +1,6 @@
{
"name": "@openfeature/core",
"version": "1.9.0",
"version": "1.7.0",
"description": "Shared OpenFeature JS components (server and web)",
"main": "./dist/cjs/index.js",
"files": [

View File

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

View File

@ -3,7 +3,7 @@ import type { JsonValue } from '../types/structure';
export type FlagValueType = 'boolean' | 'string' | 'number' | 'object';
/**
* Represents a JSON node value.
* Represents a JSON node value, or Date.
*/
export type FlagValue = boolean | string | number | JsonValue;

View File

@ -66,9 +66,6 @@ export type EventDetails<
export type EventHandler<
T extends ServerProviderEvents | ClientProviderEvents = ServerProviderEvents | ClientProviderEvents,
> = (eventDetails?: EventDetails<T>) => Promise<unknown> | unknown;
export type EventOptions = {
signal?: AbortSignal;
};
export interface Eventing<T extends ServerProviderEvents | ClientProviderEvents> {
/**
@ -76,7 +73,6 @@ export interface Eventing<T extends ServerProviderEvents | ClientProviderEvents>
* The handlers are called in the order they have been added.
* @param eventType The provider event type to listen to
* @param {EventHandler} handler The handler to run on occurrence of the event type
* @param {EventOptions} options Optional options such as signal for aborting
*/
addHandler(
eventType: T extends ClientProviderEvents
@ -87,17 +83,14 @@ export interface Eventing<T extends ServerProviderEvents | ClientProviderEvents>
? ClientProviderEvents.ConfigurationChanged
: ServerProviderEvents.ConfigurationChanged
>,
options?: EventOptions,
): void;
addHandler(
eventType: T extends ClientProviderEvents ? ClientNotChangeEvents : ServerNotChangeEvents,
handler: EventHandler<T extends ClientProviderEvents ? ClientNotChangeEvents : ServerNotChangeEvents>,
options?: EventOptions,
): void;
addHandler(
eventType: T extends ClientProviderEvents ? ClientProviderEvents : ServerProviderEvents,
handler: EventHandler<T extends ClientProviderEvents ? ClientProviderEvents : ServerProviderEvents>,
options?: EventOptions,
): void;
/**

View File

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

View File

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

View File

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

View File

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

View File

@ -4,7 +4,6 @@ export * from './client';
export * from './errors';
export * from './events';
export * from './logger';
export * from './telemetry';
export * from './provider';
export * from './evaluation';
export * from './type-guards';

View File

@ -7,13 +7,14 @@ import type {
EventDetails,
EventHandler,
Eventing,
EventOptions,
GenericEventEmitter,
GenericEventEmitter} from './events';
import {
AllProviderEvents,
statusMatchesEvent,
} from './events';
import { AllProviderEvents, statusMatchesEvent } from './events';
import { isDefined } from './filter';
import type { BaseHook, EvaluationLifeCycle } from './hooks';
import type { Logger, ManageLogger } from './logger';
import type { Logger, ManageLogger} from './logger';
import { DefaultLogger, SafeLogger } from './logger';
import type { ClientProviderStatus, CommonProvider, ProviderMetadata, ServerProviderStatus } from './provider';
import { objectOrUndefined, stringOrUndefined } from './type-guards';
@ -153,9 +154,8 @@ export abstract class OpenFeatureCommonAPI<
* API (global) events run for all providers.
* @param {AnyProviderEvent} eventType The provider event type to listen to
* @param {EventHandler} handler The handler to run on occurrence of the event type
* @param {EventOptions} options Optional options such as signal for aborting
*/
addHandler<T extends AnyProviderEvent>(eventType: T, handler: EventHandler, options?: EventOptions): void {
addHandler<T extends AnyProviderEvent>(eventType: T, handler: EventHandler): void {
[...new Map([[undefined, this._defaultProvider]]), ...this._domainScopedProviders].forEach((keyProviderTuple) => {
const domain = keyProviderTuple[0];
const provider = keyProviderTuple[1].provider;
@ -173,11 +173,6 @@ export abstract class OpenFeatureCommonAPI<
});
this._apiEmitter.addHandler(eventType, handler);
if (options?.signal && typeof options.signal.addEventListener === 'function') {
options.signal.addEventListener('abort', () => {
this.removeHandler(eventType, handler);
});
}
}
/**
@ -253,7 +248,7 @@ export abstract class OpenFeatureCommonAPI<
// initialize the provider if it implements "initialize" and it's not already registered
if (typeof provider.initialize === 'function' && !this.allProviders.includes(provider)) {
initializationPromise = provider
.initialize?.(domain ? (this._domainScopedContext.get(domain) ?? this._context) : this._context)
.initialize?.(domain ? this._domainScopedContext.get(domain) ?? this._context : this._context)
?.then(() => {
wrappedProvider.status = this._statusEnumType.READY;
// fetch the most recent event emitters, some may have been added during init

View File

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

View File

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

View File

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

View File

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

View File

@ -17,20 +17,14 @@ class TestEventEmitter extends GenericEventEmitter<AnyProviderEvent> {
// a little function to make sure we're at least waiting for the event loop
// to clear before we start making assertions
const wait = (millis = 0) => {
return new Promise((resolve) => {
setTimeout(resolve, millis);
});
return new Promise(resolve => {setTimeout(resolve, millis);});
};
describe('GenericEventEmitter', () => {
const emitter = new TestEventEmitter();
afterEach(() => {
emitter.removeAllHandlers();
});
describe('addHandler should', function () {
it('attach handler for event type', async function () {
const emitter = new TestEventEmitter();
const handler1 = jest.fn();
emitter.addHandler(AllProviderEvents.Ready, handler1);
emitter.emit(AllProviderEvents.Ready);
@ -41,6 +35,8 @@ describe('GenericEventEmitter', () => {
});
it('attach several handlers for event type', async function () {
const emitter = new TestEventEmitter();
const handler1 = jest.fn();
const handler2 = jest.fn();
const handler3 = jest.fn();
@ -68,6 +64,7 @@ describe('GenericEventEmitter', () => {
debug: () => done(),
};
const emitter = new TestEventEmitter();
emitter.setLogger(logger);
emitter.addHandler(AllProviderEvents.Ready, async () => {
@ -77,6 +74,8 @@ describe('GenericEventEmitter', () => {
});
it('trigger handler for event type', async function () {
const emitter = new TestEventEmitter();
const handler1 = jest.fn();
emitter.addHandler(AllProviderEvents.Ready, handler1);
emitter.emit(AllProviderEvents.Ready);
@ -88,6 +87,7 @@ describe('GenericEventEmitter', () => {
it('trigger handler for event type with event data', async function () {
const event: ReadyEvent = { message: 'message' };
const emitter = new TestEventEmitter();
const handler1 = jest.fn();
emitter.addHandler(AllProviderEvents.Ready, handler1);
@ -99,6 +99,8 @@ describe('GenericEventEmitter', () => {
});
it('trigger several handlers for event type', async function () {
const emitter = new TestEventEmitter();
const handler1 = jest.fn();
const handler2 = jest.fn();
const handler3 = jest.fn();
@ -119,6 +121,8 @@ describe('GenericEventEmitter', () => {
describe('removeHandler should', () => {
it('remove single handler', async function () {
const emitter = new TestEventEmitter();
const handler1 = jest.fn();
emitter.addHandler(AllProviderEvents.Ready, handler1);
@ -134,6 +138,8 @@ describe('GenericEventEmitter', () => {
describe('removeAllHandlers should', () => {
it('remove all handlers for event type', async function () {
const emitter = new TestEventEmitter();
const handler1 = jest.fn();
const handler2 = jest.fn();
emitter.addHandler(AllProviderEvents.Ready, handler1);
@ -150,6 +156,8 @@ describe('GenericEventEmitter', () => {
});
it('remove same handler when assigned to multiple events', async function () {
const emitter = new TestEventEmitter();
const handler = jest.fn();
emitter.addHandler(AllProviderEvents.Stale, handler);
emitter.addHandler(AllProviderEvents.ContextChanged, handler);
@ -166,6 +174,8 @@ describe('GenericEventEmitter', () => {
});
it('allow addition/removal of duplicate handlers', async function () {
const emitter = new TestEventEmitter();
const handler = jest.fn();
emitter.addHandler(AllProviderEvents.Stale, handler);
emitter.addHandler(AllProviderEvents.Stale, handler);
@ -181,6 +191,8 @@ describe('GenericEventEmitter', () => {
});
it('allow duplicate event handlers and call them', async function () {
const emitter = new TestEventEmitter();
const handler = jest.fn();
emitter.addHandler(AllProviderEvents.Stale, handler);
emitter.addHandler(AllProviderEvents.Stale, handler);
@ -193,6 +205,8 @@ describe('GenericEventEmitter', () => {
});
it('remove all handlers only for event type', async function () {
const emitter = new TestEventEmitter();
const handler1 = jest.fn();
const handler2 = jest.fn();
emitter.addHandler(AllProviderEvents.Ready, handler1);
@ -209,6 +223,8 @@ describe('GenericEventEmitter', () => {
});
it('remove all handlers if no event type is given', async function () {
const emitter = new TestEventEmitter();
const handler1 = jest.fn();
const handler2 = jest.fn();
emitter.addHandler(AllProviderEvents.Ready, handler1);

View File

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

View File

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

View File

@ -1,51 +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

View File

@ -16,8 +16,8 @@
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
</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-v1.4.0">
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v1.4.0&color=blue&style=for-the-badge" />
</a>
<!-- x-release-please-end -->
<br/>
@ -75,11 +75,7 @@ yarn add @openfeature/web-sdk @openfeature/core
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();
@ -125,11 +121,7 @@ Once you've added a provider as a dependency, it can be registered with OpenFeat
To register a provider and ensure it is ready before further actions are taken, you can use the `setProviderAndWait` method as shown below:
```ts
try {
await OpenFeature.setProviderAndWait(new MyProvider());
} catch (error) {
console.error('Failed to initialize provider:', error);
}
await OpenFeature.setProviderAndWait(new MyProvider());
```
#### Synchronous
@ -180,7 +172,7 @@ await OpenFeature.setContext({ targetingKey: localStorage.getItem("targetingKey"
```
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.

View File

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

View File

@ -12,7 +12,6 @@ import type {
OpenFeatureError,
FlagMetadata,
ResolutionDetails,
EventOptions,
} from '@openfeature/core';
import {
ErrorCode,
@ -22,7 +21,6 @@ import {
StandardResolutionReasons,
instantiateErrorByErrorCode,
statusMatchesEvent,
MapHookData,
} from '@openfeature/core';
import type { FlagEvaluationOptions } from '../../evaluation';
import type { ProviderEvents } from '../../events';
@ -76,7 +74,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 +90,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 {
@ -232,26 +224,22 @@ export class OpenFeatureClient implements Client {
...this.apiContextAccessor(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(),
}),
);
// this reference cannot change during the course of evaluation
// it may be used as a key in WeakMaps
const hookContext: Readonly<HookContext> = {
flagKey,
defaultValue,
flagValueType: flagType,
clientMetadata: this.metadata,
providerMetadata: this._provider.metadata,
context,
logger: this._logger,
};
let evaluationDetails: EvaluationDetails<T>;
try {
this.beforeHooks(allHooks, hookContexts, options);
this.beforeHooks(allHooks, hookContext, options);
this.shortCircuitIfNotReady();
@ -266,48 +254,45 @@ export class OpenFeatureClient implements Client {
if (resolutionDetails.errorCode) {
const err = instantiateErrorByErrorCode(resolutionDetails.errorCode, resolutionDetails.errorMessage);
this.errorHooks(allHooksReversed, hookContexts, err, options);
this.errorHooks(allHooksReversed, hookContext, err, options);
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err, resolutionDetails.flagMetadata);
} else {
this.afterHooks(allHooksReversed, hookContexts, resolutionDetails, options);
this.afterHooks(allHooksReversed, hookContext, resolutionDetails, options);
evaluationDetails = resolutionDetails;
}
} catch (err: unknown) {
this.errorHooks(allHooksReversed, hookContexts, err, options);
this.errorHooks(allHooksReversed, hookContext, err, options);
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err);
}
this.finallyHooks(allHooksReversed, hookContexts, evaluationDetails, options);
this.finallyHooks(allHooksReversed, hookContext, 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}`);
@ -321,14 +306,13 @@ export class OpenFeatureClient implements Client {
private finallyHooks(
hooks: Hook[],
hookContexts: HookContext[],
hookContext: HookContext,
evaluationDetails: EvaluationDetails<FlagValue>,
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);
} catch (err) {
this._logger.error(`Unhandled error during 'finally' hook: ${err}`);

View File

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

View File

@ -77,7 +77,7 @@ export class OpenFeatureAPI
* Setting a provider supersedes the current provider used in new and existing unbound clients.
* @param {Provider} provider The provider responsible for flag evaluations.
* @returns {Promise<void>}
* @throws {Error} If the provider throws an exception during initialization.
* @throws Uncaught exceptions thrown by the provider during initialization.
*/
setProviderAndWait(provider: Provider): Promise<void>;
/**
@ -87,7 +87,7 @@ export class OpenFeatureAPI
* @param {Provider} provider The provider responsible for flag evaluations.
* @param {EvaluationContext} context The evaluation context to use for flag evaluations.
* @returns {Promise<void>}
* @throws {Error} If the provider throws an exception during initialization.
* @throws Uncaught exceptions thrown by the provider during initialization.
*/
setProviderAndWait(provider: Provider, context: EvaluationContext): Promise<void>;
/**
@ -97,7 +97,7 @@ export class OpenFeatureAPI
* @param {string} domain The name to identify the client
* @param {Provider} provider The provider responsible for flag evaluations.
* @returns {Promise<void>}
* @throws {Error} If the provider throws an exception during initialization.
* @throws Uncaught exceptions thrown by the provider during initialization.
*/
setProviderAndWait(domain: string, provider: Provider): Promise<void>;
/**
@ -108,7 +108,7 @@ export class OpenFeatureAPI
* @param {Provider} provider The provider responsible for flag evaluations.
* @param {EvaluationContext} context The evaluation context to use for flag evaluations.
* @returns {Promise<void>}
* @throws {Error} If the provider throws an exception during initialization.
* @throws Uncaught exceptions thrown by the provider during initialization.
*/
setProviderAndWait(domain: string, provider: Provider, context: EvaluationContext): Promise<void>;
async setProviderAndWait(
@ -205,27 +205,6 @@ export class OpenFeatureAPI
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.
@ -346,9 +325,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.

View File

@ -476,21 +476,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 +486,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', () => {

View File

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

View File

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

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