Compare commits

..

No commits in common. "main" and "core-v1.8.0" have entirely different histories.

68 changed files with 9257 additions and 9452 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.11",
"packages/angular": "0.0.1-experimental",
"packages/web": "1.4.1",
"packages/server": "1.17.1",
"packages/shared": "1.8.0",
"packages/angular/projects/angular-sdk": "0.0.10"
}

View File

@ -190,6 +190,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

16067
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 --selectProjects=nest --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,12 +57,14 @@
"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",
@ -72,6 +72,9 @@
"typescript": "^4.7.4",
"uuid": "^11.0.0"
},
"overrides": {
"typescript": "^4.7.4"
},
"workspaces": [
"packages/shared",
"packages/server",

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,53 +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)

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.10">
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.0.10&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.10",
"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",
"@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.4.1"
},
"dependencies": {
"tslib": "^2.3.0"
},
"devDependencies": {
"@openfeature/core": "^1.8.1",
"@openfeature/web-sdk": "^1.5.0",
"@angular/common": "^20.1.2",
"@angular/core": "^20.1.2"
"@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"
},
"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,20 +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)

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.11">
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.4.11&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.11",
"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.4.1",
"react": ">=16.8.0"
},
"devDependencies": {

View File

@ -8,7 +8,7 @@ import type {
JsonValue,
} from '@openfeature/web-sdk';
import { ProviderEvents, ProviderStatus } from '@openfeature/web-sdk';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import {
DEFAULT_OPTIONS,
isEqual,
@ -287,7 +287,8 @@ function attachHandlersAndResolve<T extends FlagValue>(
const client = useOpenFeatureClient();
const status = useOpenFeatureClientStatus();
const provider = useOpenFeatureProvider();
const isFirstRender = useRef(true);
const controller = new AbortController();
if (defaultedOptions.suspendUntilReady && status === ProviderStatus.NOT_READY) {
suspendUntilInitialized(provider, client);
@ -297,22 +298,9 @@ function attachHandlersAndResolve<T extends FlagValue>(
suspendUntilReconciled(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);
@ -320,7 +308,7 @@ function attachHandlersAndResolve<T extends FlagValue>(
evaluationDetailsRef.current = evaluationDetails;
}, [evaluationDetails]);
const updateEvaluationDetailsCallback = useCallback(() => {
const updateEvaluationDetailsCallback = () => {
const updatedEvaluationDetails = resolver(client).call(client, flagKey, defaultValue, options);
/**
@ -331,19 +319,15 @@ 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 });
@ -364,14 +348,7 @@ function attachHandlersAndResolve<T extends FlagValue>(
// cleanup the handlers
controller.abort();
};
}, [
client,
status,
defaultedOptions.updateOnContextChanged,
defaultedOptions.updateOnConfigurationChanged,
updateEvaluationDetailsCallback,
configurationChangeCallback,
]);
}, []);
return evaluationDetails;
}

View File

@ -924,124 +924,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

@ -22,7 +22,6 @@ import {
StandardResolutionReasons,
instantiateErrorByErrorCode,
statusMatchesEvent,
MapHookData,
} from '@openfeature/core';
import type { FlagEvaluationOptions } from '../../evaluation';
import type { ProviderEvents } from '../../events';
@ -277,26 +276,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 +306,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 +366,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> {

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

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

View File

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

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

@ -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>;
body: 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 body: EvaluationEvent['body'] = {};
if (evaluationDetails.variant) {
attributes[TelemetryAttribute.VARIANT] = evaluationDetails.variant;
} else {
attributes[TelemetryAttribute.VALUE] = evaluationDetails.value;
body[TelemetryEvaluationData.VALUE] = evaluationDetails.value;
}
const contextId =
@ -66,5 +62,6 @@ export function createEvaluationEvent(
return {
name: FLAG_EVALUATION_EVENT_NAME,
attributes,
body,
};
}

View File

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

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.body).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,39 +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)

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.1">
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v1.4.1&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

View File

@ -1,10 +1,8 @@
{
"name": "@openfeature/web-sdk",
"version": "1.6.1",
"version": "1.4.1",
"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

@ -22,7 +22,6 @@ import {
StandardResolutionReasons,
instantiateErrorByErrorCode,
statusMatchesEvent,
MapHookData,
} from '@openfeature/core';
import type { FlagEvaluationOptions } from '../../evaluation';
import type { ProviderEvents } from '../../events';
@ -232,26 +231,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 +261,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 +313,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(

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

@ -20,6 +20,7 @@
"versioning": "default"
},
"packages/react": {
"release-as": "1.0.0",
"release-type": "node",
"prerelease": false,
"bump-minor-pre-major": true,