Compare commits
303 Commits
nestjs-sdk
...
main
Author | SHA1 | Date |
---|---|---|
|
ee236396cd | |
|
9aab3d053b | |
|
3105595926 | |
|
1dbbd5161b | |
|
2287083de7 | |
|
04139affcb | |
|
1e330a2e13 | |
|
7df8a8eedc | |
|
3b3515b601 | |
|
63a1feb213 | |
|
845d24c5fe | |
|
dc1970e717 | |
|
e6e5ff3edf | |
|
40a512e212 | |
|
28850b7f6d | |
|
aa232a9d6a | |
|
07af3a9eda | |
|
d8bd93b6d5 | |
|
1b3ac12a35 | |
|
d3bca54979 | |
|
baf0dfbd5b | |
|
2fd944d317 | |
|
3598b5e822 | |
|
52ed31ad96 | |
|
3e6bcaef0b | |
|
66a3ce05af | |
|
1cb4a506aa | |
|
1f33453c23 | |
|
2838d6afce | |
|
aadc7a6636 | |
|
dae36bba1f | |
|
b6ea5884f2 | |
|
affdecb619 | |
|
f2121671fa | |
|
9f887a965c | |
|
b2784f53b8 | |
|
14761998bf | |
|
43b14b4869 | |
|
cd8017d537 | |
|
273af9e20b | |
|
8f9b1ae34f | |
|
cb14827639 | |
|
c00675c9bd | |
|
1e58d2b65e | |
|
59c2f5df76 | |
|
59b8fe904f | |
|
4fe8d87a2e | |
|
a259b9097b | |
|
d13adca9cd | |
|
42a3b39c24 | |
|
2f55a36dca | |
|
21a32ec92e | |
|
07a3d85742 | |
|
37bcf3cf48 | |
|
aff60f6776 | |
|
7864bd704c | |
|
191433e705 | |
|
24f1b230bf | |
|
5f85a56362 | |
|
9218e987a0 | |
|
7f81917226 | |
|
aafdb4382f | |
|
b60c3df372 | |
|
5b19eb035a | |
|
5afe61f9e3 | |
|
60401b6cec | |
|
4482c2b33a | |
|
2c5b37c79d | |
|
cf89e7da24 | |
|
ae8fce8753 | |
|
eec21dda82 | |
|
6a224830fa | |
|
a703ee7a6f | |
|
4c2b01e367 | |
|
40deec0414 | |
|
5272f76c40 | |
|
30004ea88f | |
|
dcbc30090e | |
|
23ba0b3d8c | |
|
8bb620601e | |
|
ffceec91bc | |
|
045842027c | |
|
8bbd43e579 | |
|
644b9a148d | |
|
0f3e80cb64 | |
|
d202db53db | |
|
1e93b3c6fa | |
|
bc9f6e44da | |
|
f6bc6951a3 | |
|
9196ac9acd | |
|
c577e0d28e | |
|
31eff43554 | |
|
006698f7dd | |
|
59ef7a3bb1 | |
|
952c43f3b8 | |
|
b99fc01a6d | |
|
4a6b860544 | |
|
9e338f2ca3 | |
|
0587d8a5da | |
|
52c8de630b | |
|
edab97019a | |
|
79de7cabd3 | |
|
bd26d0a234 | |
|
38d9a3bee3 | |
|
2135254c4b | |
|
5e5b160221 | |
|
ba8d1aeec8 | |
|
16321c31f2 | |
|
e3356157d5 | |
|
d09f5467ea | |
|
3c9f2c6e36 | |
|
78516f4181 | |
|
5ece80e16a | |
|
35f000e0f3 | |
|
d521f2dd6e | |
|
2a21f4fd60 | |
|
39da1a9ba1 | |
|
6016465f9a | |
|
4893d6f000 | |
|
21f53ef956 | |
|
c97d6d1794 | |
|
1ba149d8e5 | |
|
6f1c0b33ec | |
|
ef3ba2167a | |
|
a3469b6799 | |
|
26264d6d09 | |
|
418409e3fa | |
|
57ec47bb21 | |
|
eca8205da7 | |
|
b257cb41b9 | |
|
0cc2590d02 | |
|
e131faffad | |
|
62f7668959 | |
|
278cf3fe70 | |
|
21212cdde0 | |
|
5ce2106e15 | |
|
fe3ad8eeb9 | |
|
80f182e1af | |
|
7f9001ec0a | |
|
e62d6d4b7e | |
|
c15b7e521f | |
|
d5457cc3dd | |
|
de5778b65f | |
|
9b5a30ecb3 | |
|
56369839b6 | |
|
12eaa9758d | |
|
8d0d0e6774 | |
|
39ca6d4d10 | |
|
4849306173 | |
|
f7eca3aaa3 | |
|
0527254402 | |
|
699c356f8c | |
|
4c22ff721a | |
|
ece9f69222 | |
|
0a99a015f6 | |
|
def3fe8daf | |
|
ec3d967f8b | |
|
4b03c2f1e6 | |
|
1a73961e9b | |
|
01fcb933d2 | |
|
924802b21d | |
|
c4ccf5f095 | |
|
b8f9b4ebaf | |
|
c1374bb7b3 | |
|
dc63ca8b9d | |
|
0e6d611ee5 | |
|
c0d155bdda | |
|
cf4526f56a | |
|
e86f18b5b6 | |
|
e6adae04a5 | |
|
c927044c49 | |
|
baec2fb350 | |
|
6b11165aa1 | |
|
7257b82a14 | |
|
970335e92b | |
|
52bbf15222 | |
|
ee52da9a01 | |
|
8bdc16430c | |
|
458d278345 | |
|
eb42c4c9e6 | |
|
457099d946 | |
|
f74056c02b | |
|
4a370cc73f | |
|
ed3aaa48c0 | |
|
105fd95e34 | |
|
86e7103b14 | |
|
e017300b79 | |
|
b7fc08e27d | |
|
05b14a9d14 | |
|
74dfccde7e | |
|
290184b04f | |
|
01344b28c1 | |
|
df4e72eabc | |
|
d9d5a3d3ef | |
|
4f45897438 | |
|
249daba9bc | |
|
bebacfa061 | |
|
8d759d8de9 | |
|
429c4ae941 | |
|
16b0d74340 | |
|
964d65b775 | |
|
51d43230d9 | |
|
6a277efa72 | |
|
ad46ade143 | |
|
0f187fe0b5 | |
|
cb4e56ae80 | |
|
1c12d4d548 | |
|
a621e6d804 | |
|
3cde37a5ee | |
|
d53e2eebe1 | |
|
cffcf6a75f | |
|
a91b42b147 | |
|
a3a9b22bef | |
|
25086c5456 | |
|
c2d6c1761a | |
|
d202994029 | |
|
a531238124 | |
|
e9d3ebc3d8 | |
|
988a1f767d | |
|
ee08911c17 | |
|
f2ebd11072 | |
|
692ad5b27a | |
|
6bcef8977d | |
|
c1878e4eff | |
|
9083df8463 | |
|
3d0b2a7672 | |
|
d5c68eacc4 | |
|
93101db88b | |
|
b45f52a175 | |
|
cce3e06c9b | |
|
3d197f2ea7 | |
|
7e6c1c6e70 | |
|
c6d0b5da9c | |
|
f0de66770b | |
|
d904f3f394 | |
|
413b9f8ec8 | |
|
22779d4e46 | |
|
0666334cb6 | |
|
06ed240a8f | |
|
d5283d878a | |
|
b288a99e27 | |
|
804fba329f | |
|
6c25f29f11 | |
|
488ec8a259 | |
|
e9a25c21cb | |
|
6dd558ee61 | |
|
2bbede7711 | |
|
ad8898e8a9 | |
|
4bce2a0f1a | |
|
86a9230add | |
|
2f77738090 | |
|
e4287ed1a5 | |
|
5e28dbb81a | |
|
4baf6ad3f4 | |
|
dc07385f25 | |
|
8dd1eb7413 | |
|
a7b2c4bca0 | |
|
2532379f2e | |
|
ba1be5cc81 | |
|
0f2094e236 | |
|
240a46165d | |
|
a85e72333f | |
|
0eefa84823 | |
|
539e7415de | |
|
37c50b744e | |
|
cba22e70e7 | |
|
5c17b8dfcf | |
|
9ec4399f71 | |
|
bca0ef2939 | |
|
50707e4a5d | |
|
b3e5f7eb2a | |
|
7f4f0808a6 | |
|
ce9f65c6ec | |
|
042ec5f708 | |
|
fd84025bdf | |
|
fc4867799d | |
|
638a7652eb | |
|
3e37254917 | |
|
c8255d4fa1 | |
|
2b633b5677 | |
|
cfd23b90f0 | |
|
8ef8c2c6a9 | |
|
7d50d931d5 | |
|
e47a472753 | |
|
80a6465b58 | |
|
4191a02dbc | |
|
d06b285cc3 | |
|
9d638038c0 | |
|
fd0fcfcfc8 | |
|
84a26db427 | |
|
9e0e368d23 | |
|
3546ba5e11 | |
|
a1ecb61f44 | |
|
97a34b630e | |
|
29a66b31f7 | |
|
fdc8576f47 | |
|
0a12a5edd3 | |
|
a89e7ba955 | |
|
ac42a7e48e | |
|
42decdb418 | |
|
31b92a97c1 | |
|
861cf83782 | |
|
1461074f20 |
115
.eslintrc.json
115
.eslintrc.json
|
@ -1,63 +1,54 @@
|
||||||
{
|
{
|
||||||
"env": {
|
"env": {
|
||||||
"browser": true,
|
"browser": true,
|
||||||
"es2021": true
|
"es2021": true
|
||||||
},
|
},
|
||||||
"ignorePatterns": ["**/dist/**/*"],
|
"ignorePatterns": ["**/dist/**/*"],
|
||||||
"extends": [
|
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier", "plugin:jsdoc/recommended"],
|
||||||
"eslint:recommended",
|
"parser": "@typescript-eslint/parser",
|
||||||
"plugin:@typescript-eslint/recommended",
|
"parserOptions": {
|
||||||
"prettier",
|
"ecmaVersion": "latest",
|
||||||
"plugin:jsdoc/recommended"
|
"sourceType": "module"
|
||||||
],
|
},
|
||||||
"parser": "@typescript-eslint/parser",
|
"plugins": ["@typescript-eslint", "check-file", "jsdoc"],
|
||||||
"parserOptions": {
|
"rules": {
|
||||||
"ecmaVersion": "latest",
|
"@typescript-eslint/consistent-type-imports": [
|
||||||
"sourceType": "module"
|
"error",
|
||||||
},
|
{
|
||||||
"plugins": [
|
"disallowTypeAnnotations": true,
|
||||||
"@typescript-eslint",
|
"fixStyle": "separate-type-imports",
|
||||||
"check-file",
|
"prefer": "type-imports"
|
||||||
"jsdoc"
|
}
|
||||||
],
|
],
|
||||||
"rules": {
|
"jsdoc/require-jsdoc": [
|
||||||
"jsdoc/require-jsdoc": [
|
"warn",
|
||||||
"warn",
|
{
|
||||||
{
|
"publicOnly": true
|
||||||
"publicOnly": true
|
}
|
||||||
}
|
],
|
||||||
],
|
"jsdoc/check-tag-names": [
|
||||||
"jsdoc/check-tag-names": [
|
"warn",
|
||||||
"warn",
|
{
|
||||||
{
|
"definedTags": ["experimental"]
|
||||||
"definedTags": [
|
}
|
||||||
"experimental"
|
],
|
||||||
]
|
"linebreak-style": ["error", "unix"],
|
||||||
}
|
"quotes": [
|
||||||
],
|
"error",
|
||||||
"linebreak-style": [
|
"single",
|
||||||
"error",
|
{
|
||||||
"unix"
|
"avoidEscape": true
|
||||||
],
|
}
|
||||||
"quotes": [
|
],
|
||||||
"error",
|
"semi": ["error", "always"],
|
||||||
"single",
|
"check-file/filename-naming-convention": [
|
||||||
{
|
"error",
|
||||||
"avoidEscape": true
|
{
|
||||||
}
|
"**/*.{js,ts}": "KEBAB_CASE"
|
||||||
],
|
},
|
||||||
"semi": [
|
{
|
||||||
"error",
|
"ignoreMiddleExtensions": true
|
||||||
"always"
|
}
|
||||||
],
|
]
|
||||||
"check-file/filename-naming-convention": [
|
}
|
||||||
"error",
|
}
|
||||||
{
|
|
||||||
"**/*.{js,ts}": "KEBAB_CASE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ignoreMiddleExtensions": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -29,21 +29,6 @@ jobs:
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 16
|
node-version: 20
|
||||||
registry-url: "https://registry.npmjs.org"
|
registry-url: "https://registry.npmjs.org"
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
# if this is an @openfeature/core release, but the SDKs to use this version as a peer, and commit back
|
|
||||||
- name: Update Peer Version in Dependants
|
|
||||||
if: ${{ endsWith(github.ref_name, env.CORE_PACKAGE) }}
|
|
||||||
run: |
|
|
||||||
npm run update-core-peers && \
|
|
||||||
! git diff-files --quiet && \
|
|
||||||
( echo 'Updated peer dependency in dependents, committing...'
|
|
||||||
git add --all && \
|
|
||||||
git config user.name "openfeature-peer-update-bot" && \
|
|
||||||
git config user.email "openfeature-peer-update-bot@openfeature.dev" && \
|
|
||||||
git commit -m 'fix: bump @openfeature/${{ env.CORE_PACKAGE }} peer' -s && \
|
|
||||||
git push ) || echo 'Peer dependency in dependents is already up to date.'
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
|
@ -16,7 +16,7 @@ jobs:
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
registry-url: 'https://registry.npmjs.org'
|
registry-url: 'https://registry.npmjs.org'
|
||||||
node-version: 16
|
node-version: 20
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install
|
- name: Install
|
||||||
|
|
|
@ -16,9 +16,9 @@ jobs:
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
node-version:
|
node-version:
|
||||||
- 16.x
|
|
||||||
- 18.x
|
|
||||||
- 20.x
|
- 20.x
|
||||||
|
- 22.x
|
||||||
|
- 24.x
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
@ -37,8 +37,11 @@ jobs:
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: npm run lint
|
run: npm run lint
|
||||||
|
|
||||||
- name: Test
|
- name: Test Jest Projects
|
||||||
run: npm run test
|
run: npm run test:jest
|
||||||
|
|
||||||
|
- name: Test Angular SDK
|
||||||
|
run: npm run test:angular
|
||||||
|
|
||||||
codecov-and-docs:
|
codecov-and-docs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
@ -47,7 +50,7 @@ jobs:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 16
|
node-version: 20
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install
|
- name: Install
|
||||||
|
@ -69,8 +72,7 @@ jobs:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
# we need 'fetch' for this test, which is only in 18
|
node-version: 20
|
||||||
node-version: 18
|
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install
|
- name: Install
|
||||||
|
@ -80,4 +82,4 @@ jobs:
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: SDK e2e tests
|
- name: SDK e2e tests
|
||||||
run: npm run e2e
|
run: npm run e2e
|
||||||
|
|
|
@ -38,7 +38,7 @@ jobs:
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: 20
|
||||||
- name: Generate SBOM
|
- name: Generate SBOM
|
||||||
run: |
|
run: |
|
||||||
npm install -g npm@^10.2.0
|
npm install -g npm@^10.2.0
|
||||||
|
@ -54,6 +54,10 @@ jobs:
|
||||||
needs: release-please
|
needs: release-please
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: ${{ needs.release-please.outputs.release_created }}
|
if: ${{ needs.release-please.outputs.release_created }}
|
||||||
|
environment: publish
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
# The logic below handles the npm publication:
|
# The logic below handles the npm publication:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
|
@ -61,7 +65,7 @@ jobs:
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 16
|
node-version: 20
|
||||||
registry-url: "https://registry.npmjs.org"
|
registry-url: "https://registry.npmjs.org"
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
- name: Build Packages
|
- name: Build Packages
|
||||||
|
@ -74,6 +78,8 @@ jobs:
|
||||||
- name: Publish to NPM
|
- name: Publish to NPM
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
|
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
|
||||||
|
# https://docs.npmjs.com/generating-provenance-statements
|
||||||
|
NPM_CONFIG_PROVENANCE: true
|
||||||
run: npm run publish-all
|
run: npm run publish-all
|
||||||
|
|
||||||
- name: Build Docs
|
- name: Build Docs
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
{
|
{
|
||||||
"packages/nest": "0.1.1-experimental",
|
"packages/nest": "0.2.5",
|
||||||
"packages/react": "0.2.0-experimental",
|
"packages/react": "1.0.1",
|
||||||
"packages/client": "0.4.14",
|
"packages/web": "1.6.1",
|
||||||
"packages/server": "1.13.0",
|
"packages/server": "1.19.0",
|
||||||
"packages/shared": "0.0.27"
|
"packages/shared": "1.9.0",
|
||||||
|
"packages/angular/projects/angular-sdk": "0.0.16"
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"cSpell.words": [
|
||||||
|
"domainless"
|
||||||
|
]
|
||||||
|
}
|
|
@ -3,4 +3,4 @@
|
||||||
#
|
#
|
||||||
# Managed by Peribolos: https://github.com/open-feature/community/blob/main/config/open-feature/sdk-javascript/workgroup.yaml
|
# Managed by Peribolos: https://github.com/open-feature/community/blob/main/config/open-feature/sdk-javascript/workgroup.yaml
|
||||||
#
|
#
|
||||||
* @open-feature/sdk-javascript-maintainers
|
* @open-feature/sdk-javascript-maintainers @open-feature/maintainers
|
||||||
|
|
|
@ -8,7 +8,7 @@ node 16+, npm 8+ are recommended.
|
||||||
|
|
||||||
### Compilation Target(s)
|
### Compilation Target(s)
|
||||||
|
|
||||||
We target `es2022`, and publish both ES-modules and CommonJS modules.
|
We target `es2015`, and publish both ES-modules and CommonJS modules.
|
||||||
|
|
||||||
### Installation and Dependencies
|
### Installation and Dependencies
|
||||||
|
|
||||||
|
@ -19,7 +19,7 @@ We value having as few runtime dependencies as possible. The addition of any dep
|
||||||
### Modules
|
### Modules
|
||||||
|
|
||||||
This repository uses [NPM workspaces](https://docs.npmjs.com/cli/v9/using-npm/workspaces) to establish a simple monorepo.
|
This repository uses [NPM workspaces](https://docs.npmjs.com/cli/v9/using-npm/workspaces) to establish a simple monorepo.
|
||||||
Within the root project, there is one common project (`packages/shared`) which features common interfaces and code, consumed by the published modules (`packages/server` and `packages/client`).
|
Within the root project, there is one common project (`packages/shared`) which features common interfaces and code, consumed by the published modules (`packages/server` and `packages/web`).
|
||||||
The shared module is built and published separately, and is a peer dependency of the SDK packages.
|
The shared module is built and published separately, and is a peer dependency of the SDK packages.
|
||||||
Consumers need not install it separately, since `npm` and `yarn` automatically install required peers.
|
Consumers need not install it separately, since `npm` and `yarn` automatically install required peers.
|
||||||
In order to prevent regressions cause by incompatibilities due to version mismatches, the SDKs are locked to a particular version of the `@openfeature/core` module, and the CI enforces that it's released before any dependant SDKs (see [the related workflow](./.github/workflows/audit-pending-releases.yml)).
|
In order to prevent regressions cause by incompatibilities due to version mismatches, the SDKs are locked to a particular version of the `@openfeature/core` module, and the CI enforces that it's released before any dependant SDKs (see [the related workflow](./.github/workflows/audit-pending-releases.yml)).
|
||||||
|
@ -36,9 +36,9 @@ npm run e2e-server
|
||||||
```
|
```
|
||||||
for the server e2e tests and
|
for the server e2e tests and
|
||||||
```
|
```
|
||||||
npm run e2e-client
|
npm run e2e-web
|
||||||
```
|
```
|
||||||
for the client e2e tests.
|
for the web e2e tests.
|
||||||
|
|
||||||
### Packaging
|
### Packaging
|
||||||
|
|
||||||
|
@ -120,6 +120,16 @@ on each other), the owner should try to get people aligned by:
|
||||||
- If none of the above worked and the PR has been stuck for more than 2 weeks,
|
- If none of the above worked and the PR has been stuck for more than 2 weeks,
|
||||||
the owner should bring it to the OpenFeatures [meeting](README.md#contributing).
|
the owner should bring it to the OpenFeatures [meeting](README.md#contributing).
|
||||||
|
|
||||||
|
## Releasing
|
||||||
|
|
||||||
|
As with most OpenFeature repos, release-please supports our release process.
|
||||||
|
For this SDK specifically, keep in mind this is a monorepo with dependencies with between components.
|
||||||
|
If there are multiple release PRs open, ensure that you release them in order consistent with their dependency graph, waiting for each to fully complete.
|
||||||
|
For example, if there are pending releases for: `@openfeature/core`, `@openfeature/web-sdk` and `@openfeature/react-sdk`, release them in that order.
|
||||||
|
|
||||||
|
Also ensure that if there are changes in an artifact which depend on changes in a dependency, that you reflect that in the `peerDependencies` field.
|
||||||
|
For example, if a new release of `@openfeature/web-sdk` depends on features added in `@openfeature/core`, update the required minimum version of the `@openfeature/core` peer in the `@openfeature/web-sdk` package.json.
|
||||||
|
|
||||||
## Design Choices
|
## Design Choices
|
||||||
|
|
||||||
As with other OpenFeature SDKs, js-sdk follows the
|
As with other OpenFeature SDKs, js-sdk follows the
|
||||||
|
|
|
@ -29,7 +29,9 @@ This repository contains both the server-side JS and web-browser SDKs.
|
||||||
For details, including API documentation, see the respective README files.
|
For details, including API documentation, see the respective README files.
|
||||||
|
|
||||||
- [Server SDK](./packages/server/README.md), for use in Node.js and similar runtimes.
|
- [Server SDK](./packages/server/README.md), for use in Node.js and similar runtimes.
|
||||||
- [Client SDK](./packages/client/README.md), for use in the web browser.
|
- [NestJS SDK](./packages/nest/README.md), a distribution of the Server SDK with built-in NestJS-specific features.
|
||||||
|
- [Web SDK](./packages/web/README.md), for use in the web browser.
|
||||||
|
- [React SDK](./packages/react//README.md), a distribution of the Web SDK with built-in React-specific features.
|
||||||
|
|
||||||
Each have slightly different APIs, but share many underlying types and components.
|
Each have slightly different APIs, but share many underlying types and components.
|
||||||
|
|
||||||
|
|
|
@ -124,10 +124,10 @@ export default {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
displayName: 'client',
|
displayName: 'web',
|
||||||
testEnvironment: 'node',
|
testEnvironment: 'node',
|
||||||
preset: 'ts-jest',
|
preset: 'ts-jest',
|
||||||
testMatch: ['<rootDir>/packages/client/test/**/*.spec.ts'],
|
testMatch: ['<rootDir>/packages/web/test/**/*.spec.ts'],
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'@openfeature/core': '<rootDir>/packages/shared/src',
|
'@openfeature/core': '<rootDir>/packages/shared/src',
|
||||||
},
|
},
|
||||||
|
@ -143,10 +143,10 @@ export default {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
displayName: 'client-e2e',
|
displayName: 'web-e2e',
|
||||||
testEnvironment: 'node',
|
testEnvironment: 'node',
|
||||||
preset: 'ts-jest',
|
preset: 'ts-jest',
|
||||||
testMatch: ['<rootDir>/packages/client/e2e/**/*.spec.ts'],
|
testMatch: ['<rootDir>/packages/web/e2e/**/*.spec.ts'],
|
||||||
modulePathIgnorePatterns: ['.*/node-modules/'],
|
modulePathIgnorePatterns: ['.*/node-modules/'],
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'^uuid$': require.resolve('uuid'),
|
'^uuid$': require.resolve('uuid'),
|
||||||
|
@ -161,6 +161,7 @@ export default {
|
||||||
testMatch: ['<rootDir>/packages/nest/test/**/*.spec.ts'],
|
testMatch: ['<rootDir>/packages/nest/test/**/*.spec.ts'],
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'@openfeature/core': '<rootDir>/packages/shared/src',
|
'@openfeature/core': '<rootDir>/packages/shared/src',
|
||||||
|
'@openfeature/server-sdk': '<rootDir>/packages/server/src',
|
||||||
},
|
},
|
||||||
transform: {
|
transform: {
|
||||||
'^.+\\.ts$': [
|
'^.+\\.ts$': [
|
||||||
|
@ -171,6 +172,24 @@ export default {
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
displayName: 'react',
|
||||||
|
testEnvironment: 'jsdom',
|
||||||
|
preset: 'ts-jest',
|
||||||
|
testMatch: ['<rootDir>/packages/react/test/**/*.spec.{ts,tsx}'],
|
||||||
|
moduleNameMapper: {
|
||||||
|
'@openfeature/core': '<rootDir>/packages/shared/src',
|
||||||
|
'@openfeature/web-sdk': '<rootDir>/packages/web/src',
|
||||||
|
},
|
||||||
|
transform: {
|
||||||
|
'^.+\\.(ts|tsx)$': [
|
||||||
|
'ts-jest',
|
||||||
|
{
|
||||||
|
tsconfig: '<rootDir>/packages/react/test/tsconfig.json',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
// Use this configuration option to add custom reporters to Jest
|
// Use this configuration option to add custom reporters to Jest
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
63
package.json
63
package.json
|
@ -1,20 +1,24 @@
|
||||||
{
|
{
|
||||||
"name": "@openfeature/js",
|
"name": "@openfeature/js",
|
||||||
|
"engines": {
|
||||||
|
"npm": "^10.0.0"
|
||||||
|
},
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "OpenFeature SDK for JavaScript",
|
"description": "OpenFeature SDK for JavaScript",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "jest --selectProjects=shared --selectProjects=server --selectProjects=client --silent",
|
"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",
|
||||||
"e2e-server": "git submodule update --init --recursive && shx cp test-harness/features/evaluation.feature packages/server/e2e/features && jest --selectProjects=server-e2e --verbose",
|
"e2e-server": "git submodule update --init --recursive && shx cp test-harness/features/evaluation.feature packages/server/e2e/features && jest --selectProjects=server-e2e --verbose",
|
||||||
"e2e-client": "git submodule update --init --recursive && shx cp test-harness/features/evaluation.feature packages/client/e2e/features && jest --selectProjects=client-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-client",
|
"e2e": "npm run e2e-server && npm run e2e-web",
|
||||||
"lint": "npm run lint --workspace=packages/shared --workspace=packages/server --workspace=packages/client --workspace=packages/react --workspace=packages/nest",
|
"lint": "npm run lint --workspace=packages/shared --workspace=packages/server --workspace=packages/web --workspace=packages/react --workspace=packages/angular --workspace=packages/nest",
|
||||||
|
"lint:fix": "npm run lint:fix --workspace=packages/shared --workspace=packages/server --workspace=packages/web --workspace=packages/react --workspace=packages/angular --workspace=packages/nest",
|
||||||
"clean": "shx rm -rf ./dist",
|
"clean": "shx rm -rf ./dist",
|
||||||
"build": "npm run build --workspace=packages/shared --workspace=packages/server --workspace=packages/client --workspace=packages/react --workspace=packages/nest",
|
"build": "npm run build --workspace=packages/shared --workspace=packages/server --workspace=packages/web --workspace=packages/react --workspace=packages/angular --workspace=packages/nest",
|
||||||
"publish-all": "npm run publish-if-not-exists --workspace=packages/shared --workspace=packages/server --workspace=packages/client --workspace=packages/react --workspace=packages/nest",
|
"publish-all": "npm run publish-if-not-exists --workspace=packages/shared --workspace=packages/server --workspace=packages/web --workspace=packages/react --workspace=packages/angular --workspace=packages/nest",
|
||||||
"docs": "typedoc",
|
"docs": "typedoc"
|
||||||
"core-version": "npm run version --workspace=packages/shared",
|
|
||||||
"update-core-peers": "export OPENFEATURE_CORE_VERSION=$(npm run --silent core-version) && npm run update-core-peer --workspace=packages/server --workspace=packages/client"
|
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
@ -32,48 +36,49 @@
|
||||||
"url": "https://github.com/open-feature/js-sdk/issues"
|
"url": "https://github.com/open-feature/js-sdk/issues"
|
||||||
},
|
},
|
||||||
"homepage": "https://github.com/open-feature/js-sdk#readme",
|
"homepage": "https://github.com/open-feature/js-sdk#readme",
|
||||||
"engines": {
|
|
||||||
"node": ">=16"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rollup/plugin-typescript": "^11.1.6",
|
"@rollup/plugin-typescript": "^12.0.0",
|
||||||
"@types/events": "^3.0.3",
|
"@testing-library/jest-dom": "^6.4.2",
|
||||||
|
"@testing-library/react": "^16.0.0",
|
||||||
"@types/jest": "^29.5.12",
|
"@types/jest": "^29.5.12",
|
||||||
"@types/node": "^20.11.16",
|
"@types/node": "^22.0.0",
|
||||||
"@types/react": "^18.2.55",
|
"@types/react": "^18.2.55",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"@typescript-eslint/parser": "^6.21.0",
|
"esbuild": "^0.25.0",
|
||||||
"esbuild": "^0.20.0",
|
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-import-resolver-alias": "^1.1.2",
|
"eslint-import-resolver-alias": "^1.1.2",
|
||||||
"eslint-plugin-check-file": "^2.6.2",
|
"eslint-plugin-check-file": "^2.6.2",
|
||||||
"eslint-plugin-import": "^2.29.1",
|
"eslint-plugin-import": "^2.29.1",
|
||||||
"eslint-plugin-jest": "^27.6.3",
|
"eslint-plugin-jest": "^28.0.0",
|
||||||
"eslint-plugin-jsdoc": "^48.0.6",
|
"eslint-plugin-jsdoc": "^50.0.0",
|
||||||
"events": "^3.3.0",
|
"eventemitter3": "^5.0.1",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"jest-config": "^29.7.0",
|
"jest-config": "^29.7.0",
|
||||||
"jest-cucumber": "^3.0.1",
|
"jest-cucumber": "^4.0.0",
|
||||||
"jest-environment-jsdom": "^29.7.0",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
"jest-environment-node": "^29.7.0",
|
"jest-environment-node": "^29.7.0",
|
||||||
"jest-junit": "^16.0.0",
|
"jest-junit": "^16.0.0",
|
||||||
"prettier": "^3.2.5",
|
"prettier": "^3.2.5",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"rollup": "^3.29.4",
|
"rollup": "^4.0.0",
|
||||||
"rollup-plugin-dts": "^5.3.1",
|
"rollup-plugin-dts": "^6.1.1",
|
||||||
"shx": "^0.3.4",
|
"rxjs": "~7.8.0",
|
||||||
|
"shx": "^0.4.0",
|
||||||
"ts-jest": "^29.1.2",
|
"ts-jest": "^29.1.2",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typedoc": "^0.25.7",
|
"tslib": "^2.3.0",
|
||||||
|
"typedoc": "^0.26.0",
|
||||||
"typescript": "^4.7.4",
|
"typescript": "^4.7.4",
|
||||||
"uuid": "^9.0.1"
|
"uuid": "^11.0.0"
|
||||||
},
|
},
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/shared",
|
"packages/shared",
|
||||||
"packages/server",
|
"packages/server",
|
||||||
"packages/client",
|
"packages/web",
|
||||||
"packages/react",
|
"packages/react",
|
||||||
|
"packages/angular",
|
||||||
|
"packages/angular/projects/angular-sdk",
|
||||||
"packages/nest"
|
"packages/nest"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"root": true,
|
||||||
|
"ignorePatterns": [
|
||||||
|
"projects/**/*"
|
||||||
|
],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"*.ts"
|
||||||
|
],
|
||||||
|
"extends": [
|
||||||
|
"eslint:recommended",
|
||||||
|
"plugin:@typescript-eslint/recommended",
|
||||||
|
"plugin:@angular-eslint/recommended",
|
||||||
|
"plugin:@angular-eslint/template/process-inline-templates"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"*.html"
|
||||||
|
],
|
||||||
|
"extends": [
|
||||||
|
"plugin:@angular-eslint/template/recommended",
|
||||||
|
"plugin:@angular-eslint/template/accessibility"
|
||||||
|
],
|
||||||
|
"rules": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
|
@ -0,0 +1,45 @@
|
||||||
|
# See http://help.github.com/ignore-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# Compiled output
|
||||||
|
/dist
|
||||||
|
/tmp
|
||||||
|
/out-tsc
|
||||||
|
/bazel-out
|
||||||
|
|
||||||
|
# Node
|
||||||
|
/node_modules
|
||||||
|
npm-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
.idea/
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.c9/
|
||||||
|
*.launch
|
||||||
|
.settings/
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# Visual Studio Code
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.history/*
|
||||||
|
|
||||||
|
# Miscellaneous
|
||||||
|
/.angular/cache
|
||||||
|
.sass-cache/
|
||||||
|
/connect.lock
|
||||||
|
/coverage
|
||||||
|
/libpeerconnection.log
|
||||||
|
testem.log
|
||||||
|
/typings
|
||||||
|
|
||||||
|
# System files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
.nx/cache
|
||||||
|
.nx/workspace-data
|
|
@ -0,0 +1,27 @@
|
||||||
|
# Angular
|
||||||
|
|
||||||
|
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 17.2.2.
|
||||||
|
|
||||||
|
## Development server
|
||||||
|
|
||||||
|
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files.
|
||||||
|
|
||||||
|
## Code scaffolding
|
||||||
|
|
||||||
|
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory.
|
||||||
|
|
||||||
|
## Running unit tests
|
||||||
|
|
||||||
|
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
|
||||||
|
|
||||||
|
## Running end-to-end tests
|
||||||
|
|
||||||
|
Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities.
|
||||||
|
|
||||||
|
## Further help
|
||||||
|
|
||||||
|
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page.
|
|
@ -0,0 +1,54 @@
|
||||||
|
{
|
||||||
|
"$schema": "../../node_modules/@angular/cli/lib/config/schema.json",
|
||||||
|
"version": 1,
|
||||||
|
"newProjectRoot": "projects",
|
||||||
|
"projects": {
|
||||||
|
"angular-sdk": {
|
||||||
|
"projectType": "library",
|
||||||
|
"root": "projects/angular-sdk",
|
||||||
|
"sourceRoot": "projects/angular-sdk/src",
|
||||||
|
"prefix": "lib",
|
||||||
|
"architect": {
|
||||||
|
"build": {
|
||||||
|
"builder": "@angular/build:ng-packagr",
|
||||||
|
"options": {
|
||||||
|
"project": "projects/angular-sdk/ng-package.json"
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"tsConfig": "projects/angular-sdk/tsconfig.lib.prod.json"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"tsConfig": "projects/angular-sdk/tsconfig.lib.json"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "production"
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"builder": "@angular-eslint/builder:lint",
|
||||||
|
"options": {
|
||||||
|
"lintFilePatterns": [
|
||||||
|
"projects/angular-sdk/**/*.ts",
|
||||||
|
"projects/angular-sdk/**/*.html"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"builder": "@angular/build:unit-test",
|
||||||
|
"options": {
|
||||||
|
"tsConfig": "projects/angular-sdk/tsconfig.spec.json",
|
||||||
|
"providersFile": "projects/angular-sdk/src/test-provider.ts",
|
||||||
|
"runner": "vitest",
|
||||||
|
"buildTarget": "::development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cli": {
|
||||||
|
"schematicCollections": [
|
||||||
|
"@angular-eslint/schematics"
|
||||||
|
],
|
||||||
|
"analytics": false
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
{
|
||||||
|
"name": "angular",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"ng": "ng",
|
||||||
|
"start": "ng serve",
|
||||||
|
"lint": "ng lint",
|
||||||
|
"lint:fix": "ng lint --fix",
|
||||||
|
"watch": "ng build --watch --configuration development",
|
||||||
|
"test": "ng test",
|
||||||
|
"test:coverage": "ng test --no-watch --code-coverage",
|
||||||
|
"build": "ng build && npm run postbuild",
|
||||||
|
"postbuild": "shx cp ./../../LICENSE ./dist/angular/LICENSE",
|
||||||
|
"publish-if-not-exists": "cp $NPM_CONFIG_USERCONFIG .npmrc && if [ \"$(npm --prefix dist/angular run current-published-version -s)\" = \"$(npm --prefix dist/angular run current-version -s)\" ]; then echo 'already published, skipping'; else cd dist/angular && npm publish --access public; fi"
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"devDependencies": {
|
||||||
|
"@angular-eslint/builder": "^20.1.1",
|
||||||
|
"@angular-eslint/eslint-plugin": "^20.1.1",
|
||||||
|
"@angular-eslint/eslint-plugin-template": "^20.1.1",
|
||||||
|
"@angular-eslint/schematics": "^20.1.1",
|
||||||
|
"@angular-eslint/template-parser": "^20.1.1",
|
||||||
|
"@angular/animations": "^20.1.1",
|
||||||
|
"@angular/build": "^20.1.1",
|
||||||
|
"@angular/cli": "^20.1.1",
|
||||||
|
"@angular/common": "^20.1.1",
|
||||||
|
"@angular/compiler": "^20.1.1",
|
||||||
|
"@angular/compiler-cli": "^20.1.1",
|
||||||
|
"@angular/core": "^20.1.1",
|
||||||
|
"@angular/forms": "^20.1.1",
|
||||||
|
"@angular/platform-browser": "^20.1.1",
|
||||||
|
"@angular/platform-browser-dynamic": "^20.1.1",
|
||||||
|
"@angular/router": "^20.1.1",
|
||||||
|
"@typescript-eslint/eslint-plugin": "7.18.0",
|
||||||
|
"@typescript-eslint/parser": "7.18.0",
|
||||||
|
"@vitest/browser": "^3.2.4",
|
||||||
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"jsdom": "^26.1.0",
|
||||||
|
"ng-packagr": "^20.1.0",
|
||||||
|
"playwright": "^1.53.2",
|
||||||
|
"rxjs": "~7.8.0",
|
||||||
|
"tslib": "^2.3.0",
|
||||||
|
"typescript": "^5.8.3",
|
||||||
|
"vitest": "^3.2.4",
|
||||||
|
"zone.js": "~0.15.0"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
# 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
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"extends": "../../.eslintrc.json",
|
||||||
|
"ignorePatterns": [
|
||||||
|
"!**/*"
|
||||||
|
],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"*.ts"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"*.html"
|
||||||
|
],
|
||||||
|
"rules": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
|
@ -0,0 +1,127 @@
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
|
||||||
|
## [0.0.16](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.15...angular-sdk-v0.0.16) (2025-07-25)
|
||||||
|
|
||||||
|
|
||||||
|
### ✨ New Features
|
||||||
|
|
||||||
|
* support Angular 20 ([#1220](https://github.com/open-feature/js-sdk/issues/1220)) ([aa232a9](https://github.com/open-feature/js-sdk/commit/aa232a9d6a8dfa416380ccdecd71843d3e361048))
|
||||||
|
|
||||||
|
|
||||||
|
## [0.0.15](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.14...angular-sdk-v0.0.15) (2025-05-27)
|
||||||
|
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
* **angular:** update docs ([#1200](https://github.com/open-feature/js-sdk/issues/1200)) ([b6ea588](https://github.com/open-feature/js-sdk/commit/b6ea5884f2ab9f4f94c8b258c4cf7268ea6dbeb8))
|
||||||
|
|
||||||
|
|
||||||
|
## [0.0.14](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.13...angular-sdk-v0.0.14) (2025-05-25)
|
||||||
|
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
* **angular:** add license and url field to package.json ([b2784f5](https://github.com/open-feature/js-sdk/commit/b2784f53b85a11c58abb8e2a0f87a31890885c54))
|
||||||
|
|
||||||
|
|
||||||
|
## [0.0.13](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.12...angular-sdk-v0.0.13) (2025-04-20)
|
||||||
|
|
||||||
|
|
||||||
|
### 📚 Documentation
|
||||||
|
|
||||||
|
* fix readme typo ([#1174](https://github.com/open-feature/js-sdk/issues/1174)) ([21a32ec](https://github.com/open-feature/js-sdk/commit/21a32ec92ecde9ec43c9d72b5921035af13448d1))
|
||||||
|
|
||||||
|
## [0.0.12](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.11...angular-sdk-v0.0.12) (2025-04-11)
|
||||||
|
|
||||||
|
|
||||||
|
### ✨ New Features
|
||||||
|
|
||||||
|
* **angular:** add docs for setting evaluation context in angular ([#1170](https://github.com/open-feature/js-sdk/issues/1170)) ([24f1b23](https://github.com/open-feature/js-sdk/commit/24f1b230bf1d57971a336ac21b9ee46e8baf0cab))
|
||||||
|
|
||||||
|
|
||||||
|
## [0.0.11](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.10...angular-sdk-v0.0.11) (2025-04-11)
|
||||||
|
|
||||||
|
|
||||||
|
### ✨ New Features
|
||||||
|
|
||||||
|
* **angular:** add option for initial context injection ([aafdb43](https://github.com/open-feature/js-sdk/commit/aafdb4382f113f96a649f5fc0cecadb4178ada67))
|
||||||
|
|
||||||
|
|
||||||
|
## [0.0.10](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.9-experimental...angular-sdk-v0.0.10) (2025-02-13)
|
||||||
|
|
||||||
|
|
||||||
|
### 🧹 Chore
|
||||||
|
|
||||||
|
* **angular:** update angular package to a non-experimental version ([#1147](https://github.com/open-feature/js-sdk/issues/1147)) ([5272f76](https://github.com/open-feature/js-sdk/commit/5272f76c4075ebbd21f9b24dacac8f2d22e31ca9)), closes [#1110](https://github.com/open-feature/js-sdk/issues/1110)
|
||||||
|
* update sdk peer ([#1142](https://github.com/open-feature/js-sdk/issues/1142)) ([8bb6206](https://github.com/open-feature/js-sdk/commit/8bb620601e2b8dc7b62d717169b585bd1c886996))
|
||||||
|
|
||||||
|
## [0.0.9-experimental](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.8-experimental...angular-sdk-v0.0.9-experimental) (2024-11-21)
|
||||||
|
|
||||||
|
|
||||||
|
### 🧹 Chore
|
||||||
|
|
||||||
|
* **angular:** add repository to package.json ([#1093](https://github.com/open-feature/js-sdk/issues/1093)) ([35f000e](https://github.com/open-feature/js-sdk/commit/35f000e0f3c3ff7d60c05883312691d14f01c5fd))
|
||||||
|
|
||||||
|
## [0.0.8-experimental](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.7-experimental...angular-sdk-v0.0.8-experimental) (2024-11-21)
|
||||||
|
|
||||||
|
|
||||||
|
### ✨ New Features
|
||||||
|
|
||||||
|
* **angular:** add angular 19 to peerDependencies ([4893d6f](https://github.com/open-feature/js-sdk/commit/4893d6f0003fbdcdcd4c7c061e9aed49e20b8976))
|
||||||
|
|
||||||
|
|
||||||
|
## [0.0.7-experimental](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.6-experimental...angular-sdk-v0.0.7-experimental) (2024-11-21)
|
||||||
|
|
||||||
|
|
||||||
|
Note: This version did not release
|
||||||
|
|
||||||
|
|
||||||
|
## [0.0.6-experimental](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.5-experimental...angular-sdk-v0.0.6-experimental) (2024-10-28)
|
||||||
|
|
||||||
|
|
||||||
|
### ✨ New Features
|
||||||
|
|
||||||
|
* **angular:** add Angular 18 support ([#1063](https://github.com/open-feature/js-sdk/issues/1063)) ([e62d6d4](https://github.com/open-feature/js-sdk/commit/e62d6d4b7e4a5d0f40592a2c73e7124d22eec98e))
|
||||||
|
|
||||||
|
|
||||||
|
## [0.0.5-experimental](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.4-experimental...angular-sdk-v0.0.5-experimental) (2024-10-21)
|
||||||
|
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
* **angular:** fix race condition on initialization ([#1052](https://github.com/open-feature/js-sdk/issues/1052)) ([12eaa97](https://github.com/open-feature/js-sdk/commit/12eaa9758d9deb788d74488ef03f18cbd31c0cbe))
|
||||||
|
|
||||||
|
|
||||||
|
## [0.0.4-experimental](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.3-experimental...angular-sdk-v0.0.4-experimental) (2024-09-30)
|
||||||
|
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
* **angular:** add package description ([#1026](https://github.com/open-feature/js-sdk/issues/1026)) ([dc63ca8](https://github.com/open-feature/js-sdk/commit/dc63ca8b9d6fe8c16089e95f0e336d5e3f759f3b))
|
||||||
|
|
||||||
|
## [0.0.3-experimental](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.2-experimental...angular-sdk-v0.0.3-experimental) (2024-09-22)
|
||||||
|
|
||||||
|
|
||||||
|
### 🧹 Chore
|
||||||
|
|
||||||
|
* add npm keywords for angular ([#1015](https://github.com/open-feature/js-sdk/issues/1015)) ([6b11165](https://github.com/open-feature/js-sdk/commit/6b11165aa102e62fb8cd4dd218643e2ef0e733cf))
|
||||||
|
|
||||||
|
|
||||||
|
### 📚 Documentation
|
||||||
|
|
||||||
|
* **angular:** improve angular readme layout ([#1013](https://github.com/open-feature/js-sdk/issues/1013)) ([ee52da9](https://github.com/open-feature/js-sdk/commit/ee52da9a01fe71fd5b4a4734659a06c48b6dc62c))
|
||||||
|
|
||||||
|
## [0.0.2-experimental](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.1-experimental...angular-sdk-v0.0.2-experimental) (2024-09-14)
|
||||||
|
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
* copy license to package correctly ([#1011](https://github.com/open-feature/js-sdk/issues/1011)) ([458d278](https://github.com/open-feature/js-sdk/commit/458d278345fe8681a966fca3852b2e607bdafccb))
|
||||||
|
|
||||||
|
## [0.0.1-experimental](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.2-experimental...angular-sdk-v0.0.3-experimental) (2024-09-14)
|
||||||
|
|
||||||
|
|
||||||
|
### ✨ New Features
|
||||||
|
|
||||||
|
* Angular SDK ([#997](https://github.com/open-feature/js-sdk/issues/997)) ([105fd95](https://github.com/open-feature/js-sdk/commit/105fd95e344822ffcfc54d328a28676b6f27f38e))
|
|
@ -0,0 +1,352 @@
|
||||||
|
<!-- markdownlint-disable MD033 -->
|
||||||
|
<!-- x-hide-in-docs-start -->
|
||||||
|
<p align="center">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/open-feature/community/0e23508c163a6a1ac8c0ced3e4bd78faafe627c7/assets/logo/horizontal/white/openfeature-horizontal-white.svg" />
|
||||||
|
<img align="center" alt="OpenFeature Logo" src="https://raw.githubusercontent.com/open-feature/community/0e23508c163a6a1ac8c0ced3e4bd78faafe627c7/assets/logo/horizontal/black/openfeature-horizontal-black.svg" />
|
||||||
|
</picture>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 align="center">OpenFeature Angular SDK</h2>
|
||||||
|
|
||||||
|
<!-- x-hide-in-docs-end -->
|
||||||
|
<!-- The 'github-badges' class is used in the docs -->
|
||||||
|
<p align="center" class="github-badges">
|
||||||
|
<a href="https://github.com/open-feature/spec/releases/tag/v0.8.0">
|
||||||
|
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
|
||||||
|
</a>
|
||||||
|
<!-- x-release-please-start-version -->
|
||||||
|
<a href="https://github.com/open-feature/js-sdk/releases/tag/angular-sdk-v0.0.16">
|
||||||
|
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.0.16&color=blue&style=for-the-badge" />
|
||||||
|
</a>
|
||||||
|
<!-- x-release-please-end -->
|
||||||
|
<br/>
|
||||||
|
<a href="https://codecov.io/gh/open-feature/js-sdk">
|
||||||
|
<img alt="codecov" src="https://codecov.io/gh/open-feature/js-sdk/branch/main/graph/badge.svg?token=3DC5XOEHMY" />
|
||||||
|
</a>
|
||||||
|
<a href="https://www.npmjs.com/package/@openfeature/angular-sdk">
|
||||||
|
<img alt="NPM Download" src="https://img.shields.io/npm/dm/%40openfeature%2Fangular-sdk" />
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<!-- x-hide-in-docs-start -->
|
||||||
|
|
||||||
|
[OpenFeature](https://openfeature.dev) is an open specification that provides a vendor-agnostic, community-driven API
|
||||||
|
for feature flagging that works with your favorite feature flag management tool or in-house solution.
|
||||||
|
|
||||||
|
<!-- x-hide-in-docs-end -->
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The OpenFeature Angular SDK adds Angular-specific functionality to
|
||||||
|
the [OpenFeature Web SDK](https://openfeature.dev/docs/reference/technologies/client/web).
|
||||||
|
|
||||||
|
In addition to the features provided by the [web sdk](https://openfeature.dev/docs/reference/technologies/client/web), capabilities include:
|
||||||
|
|
||||||
|
- [Overview](#overview)
|
||||||
|
- [Quick start](#quick-start)
|
||||||
|
- [Requirements](#requirements)
|
||||||
|
- [Install](#install)
|
||||||
|
- [npm](#npm)
|
||||||
|
- [yarn](#yarn)
|
||||||
|
- [Required peer dependencies](#required-peer-dependencies)
|
||||||
|
- [Usage](#usage)
|
||||||
|
- [Module](#module)
|
||||||
|
- [Minimal Example](#minimal-example)
|
||||||
|
- [How to use](#how-to-use)
|
||||||
|
- [Boolean Feature Flag](#boolean-feature-flag)
|
||||||
|
- [Number Feature Flag](#number-feature-flag)
|
||||||
|
- [String Feature Flag](#string-feature-flag)
|
||||||
|
- [Object Feature Flag](#object-feature-flag)
|
||||||
|
- [Opting-out of automatic re-rendering](#opting-out-of-automatic-re-rendering)
|
||||||
|
- [Consuming the evaluation details](#consuming-the-evaluation-details)
|
||||||
|
- [Setting Evaluation Context](#setting-evaluation-context)
|
||||||
|
- [FAQ and troubleshooting](#faq-and-troubleshooting)
|
||||||
|
- [Resources](#resources)
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
- ES2015-compatible web browser (Chrome, Edge, Firefox, etc)
|
||||||
|
- Angular version 16+
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
#### npm
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install --save @openfeature/angular-sdk
|
||||||
|
```
|
||||||
|
|
||||||
|
#### yarn
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# yarn requires manual installation of the peer dependencies (see below)
|
||||||
|
yarn add @openfeature/angular-sdk @openfeature/web-sdk @openfeature/core
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Required peer dependencies
|
||||||
|
|
||||||
|
The following list contains the peer dependencies of `@openfeature/angular-sdk`.
|
||||||
|
See the [package.json](./package.json) for the required versions.
|
||||||
|
|
||||||
|
* `@openfeature/web-sdk`
|
||||||
|
* `@angular/common`
|
||||||
|
* `@angular/core`
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
#### Module
|
||||||
|
|
||||||
|
To include the OpenFeature Angular directives in your application, you need to import the `OpenFeatureModule` and
|
||||||
|
configure it using the `forRoot` method.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { OpenFeatureModule } from '@openfeature/angular-sdk';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [
|
||||||
|
// Other components
|
||||||
|
],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
OpenFeatureModule.forRoot({
|
||||||
|
provider: yourFeatureProvider,
|
||||||
|
// domainBoundProviders are optional, mostly needed if more than one provider is used in the application.
|
||||||
|
domainBoundProviders: {
|
||||||
|
domain1: new YourOpenFeatureProvider(),
|
||||||
|
domain2: new YourOtherOpenFeatureProvider(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Minimal Example
|
||||||
|
|
||||||
|
You don't need to provide all the templates. Here's a minimal example using a boolean feature flag:
|
||||||
|
|
||||||
|
If `initializing` and `reconciling` are not given, the feature flag value that is returned by the provider will
|
||||||
|
determine what will be rendered.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div *booleanFeatureFlag="'isFeatureEnabled'; default: true">
|
||||||
|
This is shown when the feature flag is enabled.
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
This example shows content when the feature flag `isFeatureEnabled` is true with a default value of true.
|
||||||
|
No `else`, `initializing`, or `reconciling` templates are required in this case.
|
||||||
|
|
||||||
|
#### How to use
|
||||||
|
|
||||||
|
The library provides four primary directives for feature flags, `booleanFeatureFlag`,
|
||||||
|
`numberFeatureFlag`, `stringFeatureFlag` and `objectFeatureFlag`.
|
||||||
|
|
||||||
|
The first value given to the directive is the flag key that should be evaluated.
|
||||||
|
|
||||||
|
For all directives, the default value passed to OpenFeature has to be provided by the `default` parameter.
|
||||||
|
|
||||||
|
For all non-boolean directives, the value to compare the evaluation result to can be provided by the `value` parameter.
|
||||||
|
This parameter is optional, if omitted, the `thenTemplate` will always be rendered.
|
||||||
|
|
||||||
|
The `domain` parameter is _optional_ and will be used as domain when getting the OpenFeature provider.
|
||||||
|
|
||||||
|
The `updateOnConfigurationChanged` and `updateOnContextChanged` parameter are _optional_ and used to disable the
|
||||||
|
automatic re-rendering on flag value or context change. They are set to `true` by default.
|
||||||
|
|
||||||
|
The template referenced in `else` will be rendered if the evaluated feature flag is `false` for the `booleanFeatureFlag`
|
||||||
|
directive and if the `value` does not match evaluated flag value for all other directives.
|
||||||
|
This parameter is _optional_.
|
||||||
|
|
||||||
|
The template referenced in `initializing` and `reconciling` will be rendered if OpenFeature provider is in the
|
||||||
|
corresponding states.
|
||||||
|
This parameter is _optional_, if omitted, the `then` and `else` templates will be rendered according to the flag value.
|
||||||
|
|
||||||
|
##### Boolean Feature Flag
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div
|
||||||
|
*booleanFeatureFlag="'isFeatureEnabled'; default: true; domain: 'userDomain'; else: booleanFeatureElse; initializing: booleanFeatureInitializing; reconciling: booleanFeatureReconciling">
|
||||||
|
This is shown when the feature flag is enabled.
|
||||||
|
</div>
|
||||||
|
<ng-template #booleanFeatureElse>
|
||||||
|
This is shown when the feature flag is disabled.
|
||||||
|
</ng-template>
|
||||||
|
<ng-template #booleanFeatureInitializing>
|
||||||
|
This is shown when the feature flag is initializing.
|
||||||
|
</ng-template>
|
||||||
|
<ng-template #booleanFeatureReconciling>
|
||||||
|
This is shown when the feature flag is reconciling.
|
||||||
|
</ng-template>
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Number Feature Flag
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div
|
||||||
|
*numberFeatureFlag="'discountRate'; value: 10; default: 5; domain: 'userDomain'; else: numberFeatureElse; initializing: numberFeatureInitializing; reconciling: numberFeatureReconciling">
|
||||||
|
This is shown when the feature flag matches the specified discount rate.
|
||||||
|
</div>
|
||||||
|
<ng-template #numberFeatureElse>
|
||||||
|
This is shown when the feature flag does not match the specified discount rate.
|
||||||
|
</ng-template>
|
||||||
|
<ng-template #numberFeatureInitializing>
|
||||||
|
This is shown when the feature flag is initializing.
|
||||||
|
</ng-template>
|
||||||
|
<ng-template #numberFeatureReconciling>
|
||||||
|
This is shown when the feature flag is reconciling.
|
||||||
|
</ng-template>
|
||||||
|
```
|
||||||
|
|
||||||
|
##### String Feature Flag
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div
|
||||||
|
*stringFeatureFlag="'themeColor'; value: 'dark'; default: 'light'; domain: 'userDomain'; else: stringFeatureElse; initializing: stringFeatureInitializing; reconciling: stringFeatureReconciling">
|
||||||
|
This is shown when the feature flag matches the specified theme color.
|
||||||
|
</div>
|
||||||
|
<ng-template #stringFeatureElse>
|
||||||
|
This is shown when the feature flag does not match the specified theme color.
|
||||||
|
</ng-template>
|
||||||
|
<ng-template #stringFeatureInitializing>
|
||||||
|
This is shown when the feature flag is initializing.
|
||||||
|
</ng-template>
|
||||||
|
<ng-template #stringFeatureReconciling>
|
||||||
|
This is shown when the feature flag is reconciling.
|
||||||
|
</ng-template>
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Object Feature Flag
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div
|
||||||
|
*objectFeatureFlag="'userConfig'; value: { theme: 'dark' }; default: { theme: 'light' }; domain: 'userDomain'; else: objectFeatureElse; initializing: objectFeatureInitializing; reconciling: objectFeatureReconciling">
|
||||||
|
This is shown when the feature flag matches the specified user configuration.
|
||||||
|
</div>
|
||||||
|
<ng-template #objectFeatureElse>
|
||||||
|
This is shown when the feature flag does not match the specified user configuration.
|
||||||
|
</ng-template>
|
||||||
|
<ng-template #objectFeatureInitializing>
|
||||||
|
This is shown when the feature flag is initializing.
|
||||||
|
</ng-template>
|
||||||
|
<ng-template #objectFeatureReconciling>
|
||||||
|
This is shown when the feature flag is reconciling.
|
||||||
|
</ng-template>
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Opting-out of automatic re-rendering
|
||||||
|
|
||||||
|
By default, the directive re-renders when the flag value changes or the context changes.
|
||||||
|
|
||||||
|
In cases, this is not desired, re-rendering can be disabled for both events:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div *booleanFeatureFlag="'isFeatureEnabled'; default: true; updateOnContextChanged: false; updateOnConfigurationChanged: false;">
|
||||||
|
This is shown when the feature flag is enabled.
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Consuming the evaluation details
|
||||||
|
|
||||||
|
The `evaluation details` can be used when rendering the templates.
|
||||||
|
The directives [`$implicit`](https://angular.dev/guide/directives/structural-directives#structural-directive-shorthand)
|
||||||
|
value will be bound to the flag value and additionally the value `evaluationDetails` will be
|
||||||
|
bound to the whole evaluation details.
|
||||||
|
They can be referenced in all templates.
|
||||||
|
|
||||||
|
The following example shows `value` being implicitly bound and `details` being bound to the evaluation details.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div
|
||||||
|
*stringFeatureFlag="'themeColor'; value: 'dark'; default: 'light'; else: stringFeatureElse; let value; let details = evaluationDetails">
|
||||||
|
It was a match!
|
||||||
|
The theme color is {{ value }} because of {{ details.reason }}
|
||||||
|
</div>
|
||||||
|
<ng-template #stringFeatureElse let-value let-details='evaluationDetails'>
|
||||||
|
It was no match!
|
||||||
|
The theme color is {{ value }} because of {{ details.reason }}
|
||||||
|
</ng-template>
|
||||||
|
```
|
||||||
|
|
||||||
|
When the expected flag value is omitted, the template will always be rendered.
|
||||||
|
This can be used to just render the flag value or details without conditional rendering.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div *stringFeatureFlag="'themeColor'; default: 'light'; let value;">
|
||||||
|
The theme color is {{ value }}.
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Setting evaluation context
|
||||||
|
|
||||||
|
To set the initial evaluation context, you can add the `context` parameter to the `OpenFeatureModule` configuration.
|
||||||
|
This context can be either an object or a factory function that returns an `EvaluationContext`.
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> Updating the context can be done directly via the global OpenFeature API using `OpenFeature.setContext()`
|
||||||
|
|
||||||
|
Here’s how you can define and use the initial client evaluation context:
|
||||||
|
|
||||||
|
###### Using a static object
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { OpenFeatureModule } from '@openfeature/angular-sdk';
|
||||||
|
|
||||||
|
const initialContext = {
|
||||||
|
user: {
|
||||||
|
id: 'user123',
|
||||||
|
role: 'admin',
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
OpenFeatureModule.forRoot({
|
||||||
|
provider: yourFeatureProvider,
|
||||||
|
context: initialContext
|
||||||
|
})
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
|
```
|
||||||
|
|
||||||
|
###### Using a factory function
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { OpenFeatureModule, EvaluationContext } from '@openfeature/angular-sdk';
|
||||||
|
|
||||||
|
const contextFactory = (): EvaluationContext => loadContextFromLocalStorage();
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
OpenFeatureModule.forRoot({
|
||||||
|
provider: yourFeatureProvider,
|
||||||
|
context: contextFactory
|
||||||
|
})
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
|
```
|
||||||
|
|
||||||
|
## FAQ and troubleshooting
|
||||||
|
|
||||||
|
> I can import things form the `@openfeature/angular-sdk`, `@openfeature/web-sdk`, and `@openfeature/core`; which should I use?
|
||||||
|
|
||||||
|
The `@openfeature/angular-sdk` re-exports everything from its peers (`@openfeature/web-sdk` and `@openfeature/core`), and adds the Angular-specific features.
|
||||||
|
You can import everything from the `@openfeature/angular-sdk` directly.
|
||||||
|
Avoid importing anything from `@openfeature/web-sdk` or `@openfeature/core`.
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- [Example repo](https://github.com/open-feature/angular-test-app)
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
|
||||||
|
"dest": "../../dist/angular",
|
||||||
|
"keepLifecycleScripts": true,
|
||||||
|
"lib": {
|
||||||
|
"entryFile": "src/public-api.ts"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,43 @@
|
||||||
|
{
|
||||||
|
"name": "@openfeature/angular-sdk",
|
||||||
|
"version": "0.0.16",
|
||||||
|
"description": "OpenFeature Angular SDK",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/open-feature/js-sdk.git"
|
||||||
|
},
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/open-feature/js-sdk/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/open-feature/js-sdk#readme",
|
||||||
|
"scripts": {
|
||||||
|
"current-published-version": "npm show $npm_package_name@$npm_package_version version",
|
||||||
|
"current-version": "echo $npm_package_version",
|
||||||
|
"prepack": "shx cp ./../../../../LICENSE ./LICENSE"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@angular/common": "^16.2.12 || ^17.3.0 || ^18.0.0 || ^19.0.0 || ^20.0.0",
|
||||||
|
"@angular/core": "^16.2.12 || ^17.3.0 || ^18.0.0 || ^19.0.0 || ^20.0.0",
|
||||||
|
"@openfeature/web-sdk": "^1.4.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@openfeature/core": "^1.8.1",
|
||||||
|
"@openfeature/web-sdk": "^1.5.0",
|
||||||
|
"@angular/common": "^20.1.2",
|
||||||
|
"@angular/core": "^20.1.2"
|
||||||
|
},
|
||||||
|
"sideEffects": false,
|
||||||
|
"keywords": [
|
||||||
|
"openfeature",
|
||||||
|
"feature",
|
||||||
|
"flags",
|
||||||
|
"toggles",
|
||||||
|
"browser",
|
||||||
|
"web",
|
||||||
|
"angular"
|
||||||
|
]
|
||||||
|
}
|
|
@ -0,0 +1,608 @@
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { Component, Input } from '@angular/core';
|
||||||
|
import { OpenFeatureModule } from './open-feature.module';
|
||||||
|
import { By } from '@angular/platform-browser';
|
||||||
|
import { Client, ClientProviderEvents, FlagValue, InMemoryProvider, OpenFeature } from '@openfeature/web-sdk';
|
||||||
|
import { TestingProvider } from '../test/test.utils';
|
||||||
|
import { v4 } from 'uuid';
|
||||||
|
import {
|
||||||
|
BooleanFeatureFlagDirective,
|
||||||
|
NumberFeatureFlagDirective,
|
||||||
|
ObjectFeatureFlagDirective,
|
||||||
|
StringFeatureFlagDirective,
|
||||||
|
} from './feature-flag.directive';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
BooleanFeatureFlagDirective,
|
||||||
|
NumberFeatureFlagDirective,
|
||||||
|
StringFeatureFlagDirective,
|
||||||
|
ObjectFeatureFlagDirective,
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<ng-container>
|
||||||
|
<div class="case-1">
|
||||||
|
<div *booleanFeatureFlag="'test-flag'; default: true; domain: domain" class="flag-status">Flag On</div>
|
||||||
|
</div>
|
||||||
|
<div class="case-2">
|
||||||
|
<div *booleanFeatureFlag="'test-flag'; default: true; else: elseTemplate; domain: domain" class="flag-status">
|
||||||
|
Flag On
|
||||||
|
</div>
|
||||||
|
<ng-template #elseTemplate>
|
||||||
|
<div class="flag-status">Flag Off</div>
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
|
<div class="case-3">
|
||||||
|
<div
|
||||||
|
*booleanFeatureFlag="'test-flag'; default: false; initializing: initializingTemplate; domain: domain"
|
||||||
|
class="flag-status"
|
||||||
|
>
|
||||||
|
Flag On
|
||||||
|
</div>
|
||||||
|
<ng-template #initializingTemplate>
|
||||||
|
<div class="flag-status">Initializing</div>
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
|
<div class="case-4">
|
||||||
|
<div
|
||||||
|
*booleanFeatureFlag="'test-flag'; default: false; reconciling: reconcilingTemplate; domain: domain"
|
||||||
|
class="flag-status"
|
||||||
|
>
|
||||||
|
Flag On
|
||||||
|
</div>
|
||||||
|
<ng-template #reconcilingTemplate>
|
||||||
|
<div class="flag-status">Reconciling</div>
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
|
<div class="case-5">
|
||||||
|
<div
|
||||||
|
*booleanFeatureFlag="
|
||||||
|
'test-flag';
|
||||||
|
default: false;
|
||||||
|
else: elseTemplate;
|
||||||
|
initializing: initializingTemplate;
|
||||||
|
reconciling: reconcilingTemplate;
|
||||||
|
domain: domain
|
||||||
|
"
|
||||||
|
class="flag-status"
|
||||||
|
>
|
||||||
|
Flag On
|
||||||
|
</div>
|
||||||
|
<ng-template #elseTemplate>
|
||||||
|
<div class="flag-status">Flag Off</div>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template #initializingTemplate>
|
||||||
|
<div class="flag-status">Initializing</div>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template #reconcilingTemplate>
|
||||||
|
<div class="flag-status">Reconciling</div>
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
|
<div class="case-6">
|
||||||
|
<div
|
||||||
|
*booleanFeatureFlag="specialFlagKey; default: true; else: elseTemplate; domain: domain"
|
||||||
|
class="flag-status"
|
||||||
|
>
|
||||||
|
Flag On
|
||||||
|
</div>
|
||||||
|
<ng-template #elseTemplate>
|
||||||
|
<div class="flag-status">Flag Off</div>
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
|
<div class="case-7">
|
||||||
|
<div
|
||||||
|
*numberFeatureFlag="'test-flag'; default: 0; value: 1; else: elseTemplate; domain: domain"
|
||||||
|
class="flag-status"
|
||||||
|
>
|
||||||
|
Flag On
|
||||||
|
</div>
|
||||||
|
<ng-template #elseTemplate>
|
||||||
|
<div class="flag-status">Flag Off</div>
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
|
<div class="case-8">
|
||||||
|
<div
|
||||||
|
*stringFeatureFlag="'test-flag'; default: 'default'; value: 'on'; else: elseTemplate; domain: domain"
|
||||||
|
class="flag-status"
|
||||||
|
>
|
||||||
|
Flag On
|
||||||
|
</div>
|
||||||
|
<ng-template #elseTemplate>
|
||||||
|
<div class="flag-status">Flag Off</div>
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
|
<div class="case-9">
|
||||||
|
<div
|
||||||
|
*objectFeatureFlag="
|
||||||
|
'test-flag';
|
||||||
|
default: {};
|
||||||
|
value: { prop2: true, prop1: true };
|
||||||
|
else: elseTemplate;
|
||||||
|
domain: domain
|
||||||
|
"
|
||||||
|
class="flag-status"
|
||||||
|
>
|
||||||
|
Flag On
|
||||||
|
</div>
|
||||||
|
<ng-template #elseTemplate>
|
||||||
|
<div class="flag-status">Flag Off</div>
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
|
<div class="case-10">
|
||||||
|
<div
|
||||||
|
*booleanFeatureFlag="
|
||||||
|
'test-flag';
|
||||||
|
default: false;
|
||||||
|
domain: domain;
|
||||||
|
else: elseTemplateWithContext;
|
||||||
|
let value;
|
||||||
|
let evaluationDetails = evaluationDetails
|
||||||
|
"
|
||||||
|
class="flag-status"
|
||||||
|
>
|
||||||
|
then {{ value }} {{ evaluationDetails.reason }}
|
||||||
|
</div>
|
||||||
|
<ng-template #elseTemplateWithContext let-value let-evaluationDetails="evaluationDetails">
|
||||||
|
<div class="flag-status">else {{ value }} {{ evaluationDetails.reason }}</div>
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
|
<div class="case-11">
|
||||||
|
<div
|
||||||
|
*stringFeatureFlag="'test-flag'; default: 'default'; domain: domain; let value = $implicit"
|
||||||
|
class="flag-status"
|
||||||
|
>
|
||||||
|
{{ value }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="case-12">
|
||||||
|
<div
|
||||||
|
*booleanFeatureFlag="
|
||||||
|
'test-flag';
|
||||||
|
default: true;
|
||||||
|
else: elseTemplate;
|
||||||
|
domain: domain;
|
||||||
|
updateOnConfigurationChanged: false
|
||||||
|
"
|
||||||
|
class="flag-status"
|
||||||
|
>
|
||||||
|
Flag On
|
||||||
|
</div>
|
||||||
|
<ng-template #elseTemplate>
|
||||||
|
<div class="flag-status">Flag Off</div>
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
class TestComponent {
|
||||||
|
@Input() domain: string;
|
||||||
|
@Input() specialFlagKey: string = 'test-flag';
|
||||||
|
protected readonly JSON = JSON;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('FeatureFlagDirective', () => {
|
||||||
|
describe('thenTemplate', () => {
|
||||||
|
it('should not be rendered if disabled by the flag', async () => {
|
||||||
|
const { fixture } = await createTestingModule({
|
||||||
|
flagConfiguration: {
|
||||||
|
'test-flag': {
|
||||||
|
variants: { default: false },
|
||||||
|
defaultVariant: 'default',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectAmountElements(fixture, 'case-1', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be rendered if enabled by the flag', async () => {
|
||||||
|
const { fixture } = await createTestingModule({
|
||||||
|
flagConfiguration: {
|
||||||
|
'test-flag': {
|
||||||
|
variants: { default: true },
|
||||||
|
defaultVariant: 'default',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectRenderedText(fixture, 'case-2', 'Flag On');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('elseTemplate', () => {
|
||||||
|
it('should not be rendered if not existent but enabled by the flag', async () => {
|
||||||
|
const { fixture } = await createTestingModule({
|
||||||
|
flagConfiguration: {
|
||||||
|
'test-flag': {
|
||||||
|
variants: { default: false },
|
||||||
|
defaultVariant: 'default',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectAmountElements(fixture, 'case-1', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not be rendered if existent but disabled by the flag', async () => {
|
||||||
|
const { fixture } = await createTestingModule({
|
||||||
|
flagConfiguration: {
|
||||||
|
'test-flag': {
|
||||||
|
variants: { default: true },
|
||||||
|
defaultVariant: 'default',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expectRenderedText(fixture, 'case-2', 'Flag On');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be rendered if existent and enabled by the flag', async () => {
|
||||||
|
const { fixture, provider } = await createTestingModule({
|
||||||
|
flagConfiguration: {
|
||||||
|
'test-flag': {
|
||||||
|
variants: { default: true },
|
||||||
|
defaultVariant: 'default',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expectRenderedText(fixture, 'case-2', 'Flag On');
|
||||||
|
|
||||||
|
await updateFlagValue(provider, false);
|
||||||
|
fixture.detectChanges(); // Ensure change detection after flag update
|
||||||
|
await expectRenderedText(fixture, 'case-2', 'Flag Off');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('initializingTemplate', () => {
|
||||||
|
it('should not be rendered if provider is ready', async () => {
|
||||||
|
const { fixture } = await createTestingModule({
|
||||||
|
flagConfiguration: {
|
||||||
|
'test-flag': {
|
||||||
|
variants: { default: true },
|
||||||
|
defaultVariant: 'default',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectRenderedText(fixture, 'case-3', 'Flag On');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be rendered if provider is not ready', async () => {
|
||||||
|
const { fixture } = await createTestingModule({
|
||||||
|
flagConfiguration: {
|
||||||
|
'test-flag': {
|
||||||
|
variants: { default: true },
|
||||||
|
defaultVariant: 'default',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
providerInitDelay: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectRenderedText(fixture, 'case-3', 'Initializing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render until the provider is initialized', async () => {
|
||||||
|
const { fixture, client } = await createTestingModule({
|
||||||
|
flagConfiguration: {
|
||||||
|
'test-flag': {
|
||||||
|
variants: { default: true },
|
||||||
|
defaultVariant: 'default',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
providerInitDelay: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectRenderedText(fixture, 'case-3', 'Initializing');
|
||||||
|
await waitForClientReady(client);
|
||||||
|
await expectRenderedText(fixture, 'case-3', 'Flag On');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reconcilingTemplate', () => {
|
||||||
|
it('should not be rendered if provider is ready', async () => {
|
||||||
|
const { fixture } = await createTestingModule({
|
||||||
|
flagConfiguration: {
|
||||||
|
'test-flag': {
|
||||||
|
variants: { default: true },
|
||||||
|
defaultVariant: 'default',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectRenderedText(fixture, 'case-3', 'Flag On');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be rendered while provider is reconciling', async () => {
|
||||||
|
const { fixture, domain, client } = await createTestingModule({
|
||||||
|
flagConfiguration: {
|
||||||
|
'test-flag': {
|
||||||
|
variants: { default: true },
|
||||||
|
defaultVariant: 'default',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
providerInitDelay: 500,
|
||||||
|
});
|
||||||
|
await waitForClientReady(client);
|
||||||
|
await expectRenderedText(fixture, 'case-4', 'Flag On');
|
||||||
|
|
||||||
|
const setContextPromise = OpenFeature.setContext(domain, { newCtx: true });
|
||||||
|
await expectRenderedText(fixture, 'case-4', 'Reconciling');
|
||||||
|
|
||||||
|
await setContextPromise;
|
||||||
|
await expectRenderedText(fixture, 'case-4', 'Flag On');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('complex case', () => {
|
||||||
|
it('should use initializing, then, else and reconciling in one go', async () => {
|
||||||
|
const { fixture, provider, client, domain } = await createTestingModule({
|
||||||
|
flagConfiguration: {
|
||||||
|
'test-flag': {
|
||||||
|
variants: { default: true },
|
||||||
|
defaultVariant: 'default',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
providerInitDelay: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initializing
|
||||||
|
await expectRenderedText(fixture, 'case-5', 'Initializing');
|
||||||
|
await waitForClientReady(client);
|
||||||
|
await expectRenderedText(fixture, 'case-5', 'Flag On');
|
||||||
|
|
||||||
|
// Updating
|
||||||
|
await updateFlagValue(provider, false);
|
||||||
|
await expectRenderedText(fixture, 'case-5', 'Flag Off');
|
||||||
|
|
||||||
|
// Reconciling
|
||||||
|
const setContextPromise = OpenFeature.setContext(domain, { newCtx: true });
|
||||||
|
await expectRenderedText(fixture, 'case-5', 'Reconciling');
|
||||||
|
await setContextPromise;
|
||||||
|
await expectRenderedText(fixture, 'case-5', 'Flag Off');
|
||||||
|
|
||||||
|
// Updating 2
|
||||||
|
await updateFlagValue(provider, true);
|
||||||
|
await expectRenderedText(fixture, 'case-5', 'Flag On');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should evaluate on flag key change', async () => {
|
||||||
|
const { fixture, client } = await createTestingModule({
|
||||||
|
flagConfiguration: {
|
||||||
|
'test-flag': {
|
||||||
|
variants: { default: true },
|
||||||
|
defaultVariant: 'default',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
'new-test-flag': {
|
||||||
|
variants: { default: false },
|
||||||
|
defaultVariant: 'default',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await waitForClientReady(client);
|
||||||
|
await expectRenderedText(fixture, 'case-6', 'Flag On');
|
||||||
|
|
||||||
|
fixture.componentRef.setInput('specialFlagKey', 'new-test-flag');
|
||||||
|
await fixture.whenStable();
|
||||||
|
|
||||||
|
await expectRenderedText(fixture, 'case-6', 'Flag Off');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should opt-out of re-rendering when flag value changes', async () => {
|
||||||
|
const { fixture, client, provider } = await createTestingModule({
|
||||||
|
flagConfiguration: {
|
||||||
|
'test-flag': {
|
||||||
|
variants: { default: true },
|
||||||
|
defaultVariant: 'default',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
'new-test-flag': {
|
||||||
|
variants: { default: false },
|
||||||
|
defaultVariant: 'default',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await waitForClientReady(client);
|
||||||
|
await expectRenderedText(fixture, 'case-12', 'Flag On');
|
||||||
|
|
||||||
|
await updateFlagValue(provider, false);
|
||||||
|
await expectRenderedText(fixture, 'case-12', 'Flag On');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should evaluate on flag domain change', async () => {
|
||||||
|
const { fixture, client } = await createTestingModule({
|
||||||
|
flagConfiguration: {
|
||||||
|
'test-flag': {
|
||||||
|
variants: { default: true },
|
||||||
|
defaultVariant: 'default',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await waitForClientReady(client);
|
||||||
|
await expectRenderedText(fixture, 'case-6', 'Flag On');
|
||||||
|
|
||||||
|
const newDomain = v4();
|
||||||
|
const newProvider = new TestingProvider(
|
||||||
|
{
|
||||||
|
'test-flag': {
|
||||||
|
variants: { default: false },
|
||||||
|
defaultVariant: 'default',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
await OpenFeature.setProviderAndWait(newDomain, newProvider);
|
||||||
|
|
||||||
|
fixture.componentRef.setInput('domain', newDomain);
|
||||||
|
await fixture.whenStable();
|
||||||
|
|
||||||
|
await expectRenderedText(fixture, 'case-6', 'Flag Off');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('numberFeatureFlag', () => {
|
||||||
|
it('should render thenTemplate on match and else elseTemplate ', async () => {
|
||||||
|
const { fixture, provider } = await createTestingModule({
|
||||||
|
flagConfiguration: {
|
||||||
|
'test-flag': {
|
||||||
|
variants: { default: 1 },
|
||||||
|
defaultVariant: 'default',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expectRenderedText(fixture, 'case-7', 'Flag On');
|
||||||
|
|
||||||
|
await updateFlagValue(provider, 2);
|
||||||
|
await expectRenderedText(fixture, 'case-7', 'Flag Off');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stringFeatureFlag', () => {
|
||||||
|
it('should render thenTemplate on match and else elseTemplate ', async () => {
|
||||||
|
const { fixture, provider } = await createTestingModule({
|
||||||
|
flagConfiguration: {
|
||||||
|
'test-flag': {
|
||||||
|
variants: { default: 'on' },
|
||||||
|
defaultVariant: 'default',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expectRenderedText(fixture, 'case-8', 'Flag On');
|
||||||
|
|
||||||
|
await updateFlagValue(provider, 'another-value');
|
||||||
|
await expectRenderedText(fixture, 'case-8', 'Flag Off');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('objectFeatureFlag', () => {
|
||||||
|
it('should render thenTemplate on match and else elseTemplate', async () => {
|
||||||
|
const { fixture, provider } = await createTestingModule({
|
||||||
|
flagConfiguration: {
|
||||||
|
'test-flag': {
|
||||||
|
variants: { default: { prop1: true, prop2: true } },
|
||||||
|
defaultVariant: 'default',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expectRenderedText(fixture, 'case-9', 'Flag On');
|
||||||
|
|
||||||
|
await updateFlagValue(provider, { prop2: 'string' });
|
||||||
|
await expectRenderedText(fixture, 'case-9', 'Flag Off');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('context', () => {
|
||||||
|
it('should render thenTemplate from context', async () => {
|
||||||
|
const { fixture } = await createTestingModule({
|
||||||
|
flagConfiguration: {
|
||||||
|
'test-flag': {
|
||||||
|
variants: { default: true },
|
||||||
|
defaultVariant: 'default',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expectRenderedText(fixture, 'case-10', 'then true STATIC');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render elseTemplate from context', async () => {
|
||||||
|
const { fixture } = await createTestingModule({
|
||||||
|
flagConfiguration: {
|
||||||
|
'test-flag': {
|
||||||
|
variants: { default: false },
|
||||||
|
defaultVariant: 'default',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expectRenderedText(fixture, 'case-10', 'else false STATIC');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should always render if no expected value is given', async () => {
|
||||||
|
const { fixture } = await createTestingModule({
|
||||||
|
flagConfiguration: {
|
||||||
|
'test-flag': {
|
||||||
|
variants: { default: 'flag-value' },
|
||||||
|
defaultVariant: 'default',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expectRenderedText(fixture, 'case-11', 'flag-value');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createTestingModule(config?: {
|
||||||
|
flagConfiguration?: ConstructorParameters<typeof InMemoryProvider>[0];
|
||||||
|
providerInitDelay?: number;
|
||||||
|
}): Promise<{ fixture: ComponentFixture<TestComponent>; provider: TestingProvider; domain: string; client: Client }> {
|
||||||
|
const domain = v4();
|
||||||
|
const provider = new TestingProvider(config?.flagConfiguration ?? {}, config?.providerInitDelay ?? 0);
|
||||||
|
|
||||||
|
const fixture = TestBed.configureTestingModule({
|
||||||
|
imports: [
|
||||||
|
OpenFeatureModule.forRoot({ provider: new InMemoryProvider(), domainBoundProviders: { [domain]: provider } }),
|
||||||
|
TestComponent,
|
||||||
|
],
|
||||||
|
}).createComponent(TestComponent);
|
||||||
|
|
||||||
|
fixture.componentRef.setInput('domain', domain);
|
||||||
|
await fixture.whenStable();
|
||||||
|
|
||||||
|
const client = OpenFeature.getClient(domain);
|
||||||
|
if (!config.providerInitDelay) {
|
||||||
|
await waitForClientReady(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { provider, domain, client, fixture };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForClientReady(client: Client) {
|
||||||
|
await new Promise((resolve) => client.addHandler(ClientProviderEvents.Ready, resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateFlagValue<T extends FlagValue>(provider: TestingProvider, value: T) {
|
||||||
|
await provider.putConfiguration({
|
||||||
|
'test-flag': {
|
||||||
|
variants: { default: value },
|
||||||
|
defaultVariant: 'default',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getElements(fixture: ComponentFixture<TestComponent>, testCase: string) {
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
return fixture.debugElement.queryAll(By.css(`.${testCase} .flag-status`));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectAmountElements(fixture: ComponentFixture<TestComponent>, testCase: string, amount: number) {
|
||||||
|
const divElements = await getElements(fixture, testCase);
|
||||||
|
expect(divElements.length).toEqual(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectRenderedText(fixture: ComponentFixture<TestComponent>, testCase: string, rendered: string) {
|
||||||
|
const divElements = await getElements(fixture, testCase);
|
||||||
|
expect(divElements.length).toEqual(1);
|
||||||
|
expect(divElements[0].nativeElement.textContent.trim()).toBe(rendered);
|
||||||
|
}
|
|
@ -0,0 +1,706 @@
|
||||||
|
import {
|
||||||
|
ChangeDetectorRef,
|
||||||
|
Directive,
|
||||||
|
EmbeddedViewRef,
|
||||||
|
Input,
|
||||||
|
OnChanges,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit,
|
||||||
|
TemplateRef,
|
||||||
|
ViewContainerRef,
|
||||||
|
inject,
|
||||||
|
} from '@angular/core';
|
||||||
|
import {
|
||||||
|
Client,
|
||||||
|
ClientProviderEvents,
|
||||||
|
ClientProviderStatus,
|
||||||
|
EvaluationDetails,
|
||||||
|
EventHandler,
|
||||||
|
FlagValue,
|
||||||
|
JsonValue,
|
||||||
|
OpenFeature,
|
||||||
|
} from '@openfeature/web-sdk';
|
||||||
|
|
||||||
|
class FeatureFlagDirectiveContext<T extends FlagValue> {
|
||||||
|
$implicit!: T;
|
||||||
|
evaluationDetails: EvaluationDetails<T>;
|
||||||
|
|
||||||
|
constructor(details: EvaluationDetails<T>) {
|
||||||
|
this.$implicit = details.value;
|
||||||
|
this.evaluationDetails = details;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Directive({
|
||||||
|
standalone: true,
|
||||||
|
selector: '[featureFlag]',
|
||||||
|
})
|
||||||
|
export abstract class FeatureFlagDirective<T extends FlagValue> implements OnInit, OnDestroy, OnChanges {
|
||||||
|
protected _changeDetectorRef: ChangeDetectorRef;
|
||||||
|
protected _viewContainerRef: ViewContainerRef;
|
||||||
|
|
||||||
|
protected _featureFlagDefault: T;
|
||||||
|
protected _featureFlagDomain: string | undefined;
|
||||||
|
|
||||||
|
protected _featureFlagKey: string;
|
||||||
|
protected _featureFlagValue?: T;
|
||||||
|
|
||||||
|
protected _client: Client;
|
||||||
|
protected _lastEvaluationResult: EvaluationDetails<T>;
|
||||||
|
|
||||||
|
protected _readyHandler: EventHandler<ClientProviderEvents.Ready> | null = null;
|
||||||
|
protected _flagChangeHandler: EventHandler<ClientProviderEvents.ConfigurationChanged> | null = null;
|
||||||
|
protected _contextChangeHandler: EventHandler<ClientProviderEvents.Error> | null = null;
|
||||||
|
protected _reconcilingHandler: EventHandler<ClientProviderEvents.Reconciling> | null = null;
|
||||||
|
|
||||||
|
protected _updateOnContextChanged: boolean = true;
|
||||||
|
protected _updateOnConfigurationChanged: boolean = true;
|
||||||
|
|
||||||
|
protected _thenTemplateRef: TemplateRef<FeatureFlagDirectiveContext<T>> | null;
|
||||||
|
protected _thenViewRef: EmbeddedViewRef<unknown> | null;
|
||||||
|
|
||||||
|
protected _elseTemplateRef: TemplateRef<FeatureFlagDirectiveContext<T>> | null;
|
||||||
|
protected _elseViewRef: EmbeddedViewRef<unknown> | null;
|
||||||
|
|
||||||
|
protected _initializingTemplateRef: TemplateRef<FeatureFlagDirectiveContext<T>> | null;
|
||||||
|
protected _initializingViewRef: EmbeddedViewRef<unknown> | null;
|
||||||
|
|
||||||
|
protected _reconcilingTemplateRef: TemplateRef<FeatureFlagDirectiveContext<T>> | null;
|
||||||
|
protected _reconcilingViewRef: EmbeddedViewRef<unknown> | null;
|
||||||
|
|
||||||
|
protected constructor() {}
|
||||||
|
|
||||||
|
set featureFlagDomain(domain: string | undefined) {
|
||||||
|
/**
|
||||||
|
* We have to handle the change of the domain explicitly because we need to get a new client when the domain changes.
|
||||||
|
* This can not be done if we simply relay the onChanges method.
|
||||||
|
*/
|
||||||
|
this._featureFlagDomain = domain;
|
||||||
|
this.initClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.initClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnChanges(): void {
|
||||||
|
this._flagChangeHandler?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
if (this._client) {
|
||||||
|
this.disposeClient(this._client);
|
||||||
|
this._client = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private initClient(): void {
|
||||||
|
if (this._client) {
|
||||||
|
this.disposeClient(this._client);
|
||||||
|
}
|
||||||
|
this._client = OpenFeature.getClient(this._featureFlagDomain);
|
||||||
|
|
||||||
|
const baseHandler = () => {
|
||||||
|
const result = this.getFlagDetails(this._featureFlagKey, this._featureFlagDefault);
|
||||||
|
this.onFlagValue(result, this._client.providerStatus);
|
||||||
|
};
|
||||||
|
|
||||||
|
this._flagChangeHandler = () => {
|
||||||
|
if (this._updateOnConfigurationChanged) {
|
||||||
|
baseHandler();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this._contextChangeHandler = () => {
|
||||||
|
if (this._updateOnContextChanged) {
|
||||||
|
baseHandler();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this._readyHandler = () => baseHandler();
|
||||||
|
this._reconcilingHandler = () => baseHandler();
|
||||||
|
|
||||||
|
this._client.addHandler(ClientProviderEvents.ConfigurationChanged, this._flagChangeHandler);
|
||||||
|
this._client.addHandler(ClientProviderEvents.ContextChanged, this._contextChangeHandler);
|
||||||
|
this._client.addHandler(ClientProviderEvents.Ready, this._readyHandler);
|
||||||
|
this._client.addHandler(ClientProviderEvents.Reconciling, this._reconcilingHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
private disposeClient(client: Client) {
|
||||||
|
if (this._contextChangeHandler()) {
|
||||||
|
client.removeHandler(ClientProviderEvents.ContextChanged, this._contextChangeHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._flagChangeHandler) {
|
||||||
|
client.removeHandler(ClientProviderEvents.ConfigurationChanged, this._flagChangeHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._readyHandler) {
|
||||||
|
client.removeHandler(ClientProviderEvents.Ready, this._readyHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._reconcilingHandler) {
|
||||||
|
client.removeHandler(ClientProviderEvents.Reconciling, this._reconcilingHandler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getFlagDetails(flagKey: string, defaultValue: T): EvaluationDetails<T> {
|
||||||
|
if (typeof defaultValue === 'boolean') {
|
||||||
|
return this._client.getBooleanDetails(flagKey, defaultValue) as EvaluationDetails<T>;
|
||||||
|
} else if (typeof defaultValue === 'number') {
|
||||||
|
return this._client.getNumberDetails(flagKey, defaultValue) as EvaluationDetails<T>;
|
||||||
|
} else if (typeof defaultValue === 'string') {
|
||||||
|
return this._client.getStringDetails(flagKey, defaultValue) as EvaluationDetails<T>;
|
||||||
|
} else {
|
||||||
|
return this._client.getObjectDetails(flagKey, defaultValue) as EvaluationDetails<T>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onFlagValue(result: EvaluationDetails<T>, status: ClientProviderStatus): void {
|
||||||
|
const shouldInitialize = this._initializingTemplateRef && status === ClientProviderStatus.NOT_READY;
|
||||||
|
const shouldReconcile = this._reconcilingTemplateRef && status === ClientProviderStatus.RECONCILING;
|
||||||
|
|
||||||
|
const context = new FeatureFlagDirectiveContext(result);
|
||||||
|
|
||||||
|
const resultChanged = !deepEqual(this._lastEvaluationResult, result);
|
||||||
|
const isValueMatch = !this._featureFlagValue || deepEqual(result.value, this._featureFlagValue);
|
||||||
|
|
||||||
|
if (this._initializingViewRef && shouldInitialize && !resultChanged) {
|
||||||
|
return;
|
||||||
|
} else if (this._reconcilingViewRef && shouldReconcile && !resultChanged) {
|
||||||
|
return;
|
||||||
|
} else if (this._thenViewRef && isValueMatch && !shouldInitialize && !shouldReconcile && !resultChanged) {
|
||||||
|
return;
|
||||||
|
} else if (this._elseViewRef && !isValueMatch && !shouldInitialize && !shouldReconcile && !resultChanged) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._lastEvaluationResult = result;
|
||||||
|
this._viewContainerRef.clear();
|
||||||
|
this._initializingViewRef = null;
|
||||||
|
this._reconcilingViewRef = null;
|
||||||
|
this._thenViewRef = null;
|
||||||
|
this._elseViewRef = null;
|
||||||
|
|
||||||
|
if (this._initializingTemplateRef && status === ClientProviderStatus.NOT_READY) {
|
||||||
|
this._initializingViewRef = this._viewContainerRef.createEmbeddedView(this._initializingTemplateRef, context);
|
||||||
|
} else if (this._reconcilingTemplateRef && status === ClientProviderStatus.RECONCILING) {
|
||||||
|
this._reconcilingViewRef = this._viewContainerRef.createEmbeddedView(this._reconcilingTemplateRef, context);
|
||||||
|
} else if (isValueMatch) {
|
||||||
|
this._thenViewRef = this._viewContainerRef.createEmbeddedView(this._thenTemplateRef, context);
|
||||||
|
} else if (this._elseTemplateRef) {
|
||||||
|
this._elseViewRef = this._viewContainerRef.createEmbeddedView(this._elseTemplateRef, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A structural directive that conditionally includes a template based on the evaluation
|
||||||
|
* of a boolean feature flag.
|
||||||
|
* When the flag evaluates to true, Angular renders the template provided in a `then` clause,
|
||||||
|
* and when false, Angular renders the template provided in an optional `else` clause.
|
||||||
|
* The default template for the `else` clause is blank.
|
||||||
|
*
|
||||||
|
* Usage examples:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* <div *booleanFeatureFlag="'flagKey'; default: false; let value">{{ value }}</div>
|
||||||
|
* ```
|
||||||
|
* ```
|
||||||
|
* <div *booleanFeatureFlag="flagKey; default: false; else: elseTemplate">Content to render when flag is true.</div>
|
||||||
|
* <ng-template #elseTemplate>Content to render when flag is false.</ng-template>
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @usageNotes
|
||||||
|
*
|
||||||
|
* You can specify templates for other statuses such as initializing and reconciling.
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* <div *booleanFeatureFlag="flagKey; default:true; else: elseTemplate; initializing: initializingTemplate; reconciling: reconcilingTemplate">Content to render when flag is true.</div>
|
||||||
|
* <ng-template #elseTemplate>Content to render when flag is false.</ng-template>
|
||||||
|
* <ng-template #initializingTemplate>Loading...</ng-template>
|
||||||
|
* <ng-template #reconcilingTemplate>Reconfiguring...</ng-template>
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
@Directive({
|
||||||
|
standalone: true,
|
||||||
|
selector: '[booleanFeatureFlag]',
|
||||||
|
})
|
||||||
|
export class BooleanFeatureFlagDirective extends FeatureFlagDirective<boolean> implements OnChanges {
|
||||||
|
override _changeDetectorRef = inject(ChangeDetectorRef);
|
||||||
|
override _viewContainerRef = inject(ViewContainerRef);
|
||||||
|
override _thenTemplateRef = inject<TemplateRef<FeatureFlagDirectiveContext<boolean>>>(TemplateRef);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The key of the boolean feature flag.
|
||||||
|
*/
|
||||||
|
@Input({ required: true }) booleanFeatureFlag: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default value for the boolean feature flag.
|
||||||
|
*/
|
||||||
|
@Input({ required: true }) booleanFeatureFlagDefault: boolean;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
override ngOnChanges() {
|
||||||
|
this._featureFlagKey = this.booleanFeatureFlag;
|
||||||
|
this._featureFlagDefault = this.booleanFeatureFlagDefault;
|
||||||
|
this._featureFlagValue = true;
|
||||||
|
super.ngOnChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The domain of the boolean feature flag.
|
||||||
|
*/
|
||||||
|
@Input({ required: false })
|
||||||
|
set booleanFeatureFlagDomain(domain: string | undefined) {
|
||||||
|
super.featureFlagDomain = domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the component if the provider emits a ConfigurationChanged event.
|
||||||
|
* Set to false to prevent components from re-rendering when flag value changes
|
||||||
|
* are received by the associated provider.
|
||||||
|
* Defaults to true.
|
||||||
|
*/
|
||||||
|
@Input({ required: false })
|
||||||
|
set booleanFeatureFlagUpdateOnConfigurationChanged(enabled: boolean | undefined) {
|
||||||
|
this._updateOnConfigurationChanged = enabled ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the component when the OpenFeature context changes.
|
||||||
|
* Set to false to prevent components from re-rendering when attributes which
|
||||||
|
* may be factors in flag evaluation change.
|
||||||
|
* Defaults to true.
|
||||||
|
*/
|
||||||
|
@Input({ required: false })
|
||||||
|
set booleanFeatureFlagUpdateOnContextChanged(enabled: boolean | undefined) {
|
||||||
|
this._updateOnContextChanged = enabled ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template to be displayed when the feature flag is false.
|
||||||
|
*/
|
||||||
|
@Input()
|
||||||
|
set booleanFeatureFlagElse(tpl: TemplateRef<FeatureFlagDirectiveContext<boolean>>) {
|
||||||
|
this._elseTemplateRef = tpl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template to be displayed when the provider is not ready.
|
||||||
|
*/
|
||||||
|
@Input()
|
||||||
|
set booleanFeatureFlagInitializing(tpl: TemplateRef<FeatureFlagDirectiveContext<boolean>>) {
|
||||||
|
this._initializingTemplateRef = tpl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template to be displayed when the provider is reconciling.
|
||||||
|
*/
|
||||||
|
@Input()
|
||||||
|
set booleanFeatureFlagReconciling(tpl: TemplateRef<FeatureFlagDirectiveContext<boolean>>) {
|
||||||
|
this._reconcilingTemplateRef = tpl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A structural directive that conditionally includes a template based on the evaluation
|
||||||
|
* of a number feature flag.
|
||||||
|
* When the flag matches the provided value or no expected value is given, Angular renders the template provided
|
||||||
|
* in a `then` clause, and when it doesn't match, Angular renders the template provided
|
||||||
|
* in an optional `else` clause.
|
||||||
|
* The default template for the `else` clause is blank.
|
||||||
|
*
|
||||||
|
* Usage examples:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* <div *numberFeatureFlag="'flagKey'; default: 0; let value">{{ value }}</div>
|
||||||
|
* ```
|
||||||
|
* ```
|
||||||
|
* <div *numberFeatureFlag="'flagKey'; value: 1; default: 0; else: elseTemplate">Content to render when flag matches value.</div>
|
||||||
|
* <ng-template #elseTemplate>Content to render when flag does not match value.</ng-template>
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @usageNotes
|
||||||
|
*
|
||||||
|
* You can specify templates for other statuses such as initializing and reconciling.
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* <div *numberFeatureFlag="flagKey; default: 0; value: flagValue; else: elseTemplate; initializing: initializingTemplate; reconciling: reconcilingTemplate">Content to render when flag matches value.</div>
|
||||||
|
* <ng-template #elseTemplate>Content to render when flag does not match value.</ng-template>
|
||||||
|
* <ng-template #initializingTemplate>Loading...</ng-template>
|
||||||
|
* <ng-template #reconcilingTemplate>Reconfiguring...</ng-template>
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
@Directive({
|
||||||
|
standalone: true,
|
||||||
|
selector: '[numberFeatureFlag]',
|
||||||
|
})
|
||||||
|
export class NumberFeatureFlagDirective extends FeatureFlagDirective<number> implements OnChanges {
|
||||||
|
override _changeDetectorRef = inject(ChangeDetectorRef);
|
||||||
|
override _viewContainerRef = inject(ViewContainerRef);
|
||||||
|
override _thenTemplateRef = inject<TemplateRef<FeatureFlagDirectiveContext<number>>>(TemplateRef);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The key of the number feature flag.
|
||||||
|
*/
|
||||||
|
@Input({ required: true }) numberFeatureFlag: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default value for the number feature flag.
|
||||||
|
*/
|
||||||
|
@Input({ required: true }) numberFeatureFlagDefault: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The expected value of this number feature flag, for which the `then` template should be rendered.
|
||||||
|
*/
|
||||||
|
@Input({ required: false }) numberFeatureFlagValue?: number;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
override ngOnChanges() {
|
||||||
|
this._featureFlagKey = this.numberFeatureFlag;
|
||||||
|
this._featureFlagDefault = this.numberFeatureFlagDefault;
|
||||||
|
this._featureFlagValue = this.numberFeatureFlagValue;
|
||||||
|
super.ngOnChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The domain of the number feature flag.
|
||||||
|
*/
|
||||||
|
@Input({ required: false })
|
||||||
|
set numberFeatureFlagDomain(domain: string | undefined) {
|
||||||
|
super.featureFlagDomain = domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the component if the provider emits a ConfigurationChanged event.
|
||||||
|
* Set to false to prevent components from re-rendering when flag value changes
|
||||||
|
* are received by the associated provider.
|
||||||
|
* Defaults to true.
|
||||||
|
*/
|
||||||
|
@Input({ required: false })
|
||||||
|
set numberFeatureFlagUpdateOnConfigurationChanged(enabled: boolean | undefined) {
|
||||||
|
this._updateOnConfigurationChanged = enabled ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the component when the OpenFeature context changes.
|
||||||
|
* Set to false to prevent components from re-rendering when attributes which
|
||||||
|
* may be factors in flag evaluation change.
|
||||||
|
* Defaults to true.
|
||||||
|
*/
|
||||||
|
@Input({ required: false })
|
||||||
|
set numberFeatureFlagUpdateOnContextChanged(enabled: boolean | undefined) {
|
||||||
|
this._updateOnContextChanged = enabled ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template to be displayed when the feature flag does not match value.
|
||||||
|
*/
|
||||||
|
@Input()
|
||||||
|
set numberFeatureFlagElse(tpl: TemplateRef<FeatureFlagDirectiveContext<number>>) {
|
||||||
|
this._elseTemplateRef = tpl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template to be displayed when the feature flag is not ready.
|
||||||
|
*/
|
||||||
|
@Input()
|
||||||
|
set numberFeatureFlagInitializing(tpl: TemplateRef<FeatureFlagDirectiveContext<number>>) {
|
||||||
|
this._initializingTemplateRef = tpl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template to be displayed when the feature flag is not ready.
|
||||||
|
*/
|
||||||
|
@Input()
|
||||||
|
set numberFeatureFlagReconciling(tpl: TemplateRef<FeatureFlagDirectiveContext<number>>) {
|
||||||
|
this._reconcilingTemplateRef = tpl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A structural directive that conditionally includes a template based on the evaluation
|
||||||
|
* of a string feature flag.
|
||||||
|
* When the flag matches the provided value or no expected value is given, Angular renders the template provided
|
||||||
|
* in a `then` clause, and when it doesn't match, Angular renders the template provided
|
||||||
|
* in an optional `else` clause.
|
||||||
|
* The default template for the `else` clause is blank.
|
||||||
|
*
|
||||||
|
* Usage examples:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* <div *stringFeatureFlag="'flagKey'; default: 'default'; let value">{{ value }}</div>
|
||||||
|
* ```
|
||||||
|
* ```
|
||||||
|
* <div *stringFeatureFlag="flagKey; default: 'default'; value: flagValue; else: elseTemplate">Content to render when flag matches value.</div>
|
||||||
|
* <ng-template #elseTemplate>Content to render when flag does not match value.</ng-template>
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @usageNotes
|
||||||
|
*
|
||||||
|
* You can specify templates for other statuses such as initializing and reconciling.
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* <div *stringFeatureFlag="flagKey; default: 'default'; value: flagValue; else: elseTemplate; initializing: initializingTemplate; reconciling: reconcilingTemplate">Content to render when flag matches value.</div>
|
||||||
|
* <ng-template #elseTemplate>Content to render when flag does not match value.</ng-template>
|
||||||
|
* <ng-template #initializingTemplate>Loading...</ng-template>
|
||||||
|
* <ng-template #reconcilingTemplate>Reconfiguring...</ng-template>
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
@Directive({
|
||||||
|
standalone: true,
|
||||||
|
selector: '[stringFeatureFlag]',
|
||||||
|
})
|
||||||
|
export class StringFeatureFlagDirective extends FeatureFlagDirective<string> implements OnChanges {
|
||||||
|
override _changeDetectorRef = inject(ChangeDetectorRef);
|
||||||
|
override _viewContainerRef = inject(ViewContainerRef);
|
||||||
|
override _thenTemplateRef = inject<TemplateRef<FeatureFlagDirectiveContext<string>>>(TemplateRef);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The key of the string feature flag.
|
||||||
|
*/
|
||||||
|
@Input({ required: true }) stringFeatureFlag: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default value for the string feature flag.
|
||||||
|
*/
|
||||||
|
@Input({ required: true }) stringFeatureFlagDefault: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The expected value of this string feature flag, for which the `then` template should be rendered.
|
||||||
|
*/
|
||||||
|
@Input({ required: false }) stringFeatureFlagValue?: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
override ngOnChanges() {
|
||||||
|
this._featureFlagKey = this.stringFeatureFlag;
|
||||||
|
this._featureFlagDefault = this.stringFeatureFlagDefault;
|
||||||
|
this._featureFlagValue = this.stringFeatureFlagValue;
|
||||||
|
super.ngOnChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The domain for the string feature flag.
|
||||||
|
*/
|
||||||
|
@Input({ required: false })
|
||||||
|
set stringFeatureFlagDomain(domain: string | undefined) {
|
||||||
|
super.featureFlagDomain = domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the component if the provider emits a ConfigurationChanged event.
|
||||||
|
* Set to false to prevent components from re-rendering when flag value changes
|
||||||
|
* are received by the associated provider.
|
||||||
|
* Defaults to true.
|
||||||
|
*/
|
||||||
|
@Input({ required: false })
|
||||||
|
set stringFeatureFlagUpdateOnConfigurationChanged(enabled: boolean | undefined) {
|
||||||
|
this._updateOnConfigurationChanged = enabled ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the component when the OpenFeature context changes.
|
||||||
|
* Set to false to prevent components from re-rendering when attributes which
|
||||||
|
* may be factors in flag evaluation change.
|
||||||
|
* Defaults to true.
|
||||||
|
*/
|
||||||
|
@Input({ required: false })
|
||||||
|
set stringFeatureFlagUpdateOnContextChanged(enabled: boolean | undefined) {
|
||||||
|
this._updateOnContextChanged = enabled ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template to be displayed when the feature flag does not match value.
|
||||||
|
*/
|
||||||
|
@Input()
|
||||||
|
set stringFeatureFlagElse(tpl: TemplateRef<FeatureFlagDirectiveContext<string>>) {
|
||||||
|
this._elseTemplateRef = tpl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template to be displayed when the feature flag is not ready.
|
||||||
|
*/
|
||||||
|
@Input()
|
||||||
|
set stringFeatureFlagInitializing(tpl: TemplateRef<FeatureFlagDirectiveContext<string>>) {
|
||||||
|
this._initializingTemplateRef = tpl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template to be displayed when the feature flag is reconciling.
|
||||||
|
*/
|
||||||
|
@Input()
|
||||||
|
set stringFeatureFlagReconciling(tpl: TemplateRef<FeatureFlagDirectiveContext<string>>) {
|
||||||
|
this._reconcilingTemplateRef = tpl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A structural directive that conditionally includes a template based on the evaluation
|
||||||
|
* of an object feature flag.
|
||||||
|
* When the flag matches the provided value or no expected value is given, Angular renders the template provided
|
||||||
|
* in a `then` clause, and when it doesn't match, Angular renders the template provided
|
||||||
|
* in an optional `else` clause.
|
||||||
|
* The default template for the `else` clause is blank.
|
||||||
|
*
|
||||||
|
* Usage examples:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* <div *objectFeatureFlag="'flagKey'; default: {}; let value">{{ value }}</div>
|
||||||
|
* ```
|
||||||
|
* ```
|
||||||
|
* <div *objectFeatureFlag="flagKey; default: {}; value: flagValue; else: elseTemplate">Content to render when flag matches value.</div>
|
||||||
|
* <ng-template #elseTemplate>Content to render when flag does not match value.</ng-template>
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @usageNotes
|
||||||
|
*
|
||||||
|
* You can specify templates for other statuses such as initializing and reconciling.
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* <div *objectFeatureFlag="flagKey; default: {}; value: flagValue; else: elseTemplate; initializing: initializingTemplate; reconciling: reconcilingTemplate">Content to render when flag matches value.</div>
|
||||||
|
* <ng-template #elseTemplate>Content to render when flag does not match value.</ng-template>
|
||||||
|
* <ng-template #initializingTemplate>Loading...</ng-template>
|
||||||
|
* <ng-template #reconcilingTemplate>Reconfiguring...</ng-template>
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
@Directive({
|
||||||
|
standalone: true,
|
||||||
|
selector: '[objectFeatureFlag]',
|
||||||
|
})
|
||||||
|
export class ObjectFeatureFlagDirective<T extends JsonValue> extends FeatureFlagDirective<T> implements OnChanges {
|
||||||
|
override _changeDetectorRef = inject(ChangeDetectorRef);
|
||||||
|
override _viewContainerRef = inject(ViewContainerRef);
|
||||||
|
override _thenTemplateRef = inject<TemplateRef<FeatureFlagDirectiveContext<T>>>(TemplateRef);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The key of the object feature flag.
|
||||||
|
*/
|
||||||
|
@Input({ required: true }) objectFeatureFlag: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default value for the object feature flag.
|
||||||
|
*/
|
||||||
|
@Input({ required: true }) objectFeatureFlagDefault: T;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The expected value of this object feature flag, for which the `then` template should be rendered.
|
||||||
|
*/
|
||||||
|
@Input({ required: false }) objectFeatureFlagValue?: T;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
override ngOnChanges() {
|
||||||
|
this._featureFlagKey = this.objectFeatureFlag;
|
||||||
|
this._featureFlagDefault = this.objectFeatureFlagDefault;
|
||||||
|
this._featureFlagValue = this.objectFeatureFlagValue;
|
||||||
|
super.ngOnChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The domain for the object feature flag.
|
||||||
|
*/
|
||||||
|
@Input({ required: false })
|
||||||
|
set objectFeatureFlagDomain(domain: string | undefined) {
|
||||||
|
super.featureFlagDomain = domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the component if the provider emits a ConfigurationChanged event.
|
||||||
|
* Set to false to prevent components from re-rendering when flag value changes
|
||||||
|
* are received by the associated provider.
|
||||||
|
* Defaults to true.
|
||||||
|
*/
|
||||||
|
@Input({ required: false })
|
||||||
|
set objectFeatureFlagUpdateOnConfigurationChanged(enabled: boolean | undefined) {
|
||||||
|
this._updateOnConfigurationChanged = enabled ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the component when the OpenFeature context changes.
|
||||||
|
* Set to false to prevent components from re-rendering when attributes which
|
||||||
|
* may be factors in flag evaluation change.
|
||||||
|
* Defaults to true.
|
||||||
|
*/
|
||||||
|
@Input({ required: false })
|
||||||
|
set objectFeatureFlagUpdateOnContextChanged(enabled: boolean | undefined) {
|
||||||
|
this._updateOnContextChanged = enabled ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template to be displayed when the feature flag does not match value.
|
||||||
|
*/
|
||||||
|
@Input()
|
||||||
|
set objectFeatureFlagElse(tpl: TemplateRef<FeatureFlagDirectiveContext<T>>) {
|
||||||
|
this._elseTemplateRef = tpl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template to be displayed when the feature flag is not ready.
|
||||||
|
*/
|
||||||
|
@Input()
|
||||||
|
set objectFeatureFlagInitializing(tpl: TemplateRef<FeatureFlagDirectiveContext<T>>) {
|
||||||
|
this._initializingTemplateRef = tpl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template to be displayed when the feature flag is reconciling.
|
||||||
|
*/
|
||||||
|
@Input()
|
||||||
|
set objectFeatureFlagReconciling(tpl: TemplateRef<FeatureFlagDirectiveContext<T>>) {
|
||||||
|
this._reconcilingTemplateRef = tpl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function deepEqual(obj1: any, obj2: any): boolean {
|
||||||
|
if (obj1 === obj2) {
|
||||||
|
// If both objects are identical
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof obj1 !== 'object' || obj1 === null || typeof obj2 !== 'object' || obj2 === null) {
|
||||||
|
// One of them is not an object or one of them is null
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys1 = Object.keys(obj1);
|
||||||
|
const keys2 = Object.keys(obj2);
|
||||||
|
|
||||||
|
if (keys1.length !== keys2.length) {
|
||||||
|
// Different number of properties
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of keys1) {
|
||||||
|
if (!keys2.includes(key)) {
|
||||||
|
// obj2 does not have a property that obj1 has
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursive check for each property
|
||||||
|
if (!deepEqual(obj1[key], obj2[key])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { InjectionToken, ModuleWithProviders, NgModule } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { EvaluationContext, OpenFeature, Provider } from '@openfeature/web-sdk';
|
||||||
|
|
||||||
|
export type EvaluationContextFactory = () => EvaluationContext;
|
||||||
|
|
||||||
|
export interface OpenFeatureConfig {
|
||||||
|
provider: Provider;
|
||||||
|
domainBoundProviders?: Record<string, Provider>;
|
||||||
|
context?: EvaluationContext | EvaluationContextFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OPEN_FEATURE_CONFIG_TOKEN = new InjectionToken<OpenFeatureConfig>('OPEN_FEATURE_CONFIG_TOKEN');
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [],
|
||||||
|
imports: [CommonModule],
|
||||||
|
exports: [],
|
||||||
|
})
|
||||||
|
export class OpenFeatureModule {
|
||||||
|
static forRoot(config: OpenFeatureConfig): ModuleWithProviders<OpenFeatureModule> {
|
||||||
|
const context = typeof config.context === 'function' ? config.context() : config.context;
|
||||||
|
OpenFeature.setProvider(config.provider, context);
|
||||||
|
|
||||||
|
if (config.domainBoundProviders) {
|
||||||
|
Object.entries(config.domainBoundProviders).map(([domain, provider]) =>
|
||||||
|
OpenFeature.setProvider(domain, provider),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ngModule: OpenFeatureModule,
|
||||||
|
providers: [{ provide: OPEN_FEATURE_CONFIG_TOKEN, useValue: config }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
/*
|
||||||
|
* Public API Surface of angular
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './lib/feature-flag.directive';
|
||||||
|
export * from './lib/open-feature.module';
|
||||||
|
|
||||||
|
// re-export the web-sdk so consumers can access that API from the angular-sdk
|
||||||
|
export * from '@openfeature/web-sdk';
|
|
@ -0,0 +1,3 @@
|
||||||
|
import { provideZonelessChangeDetection } from '@angular/core';
|
||||||
|
|
||||||
|
export default [provideZonelessChangeDetection()];
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { InMemoryProvider } from '@openfeature/web-sdk';
|
||||||
|
|
||||||
|
export class TestingProvider extends InMemoryProvider {
|
||||||
|
constructor(
|
||||||
|
flagConfiguration: ConstructorParameters<typeof InMemoryProvider>[0],
|
||||||
|
private delay: number,
|
||||||
|
) {
|
||||||
|
super(flagConfiguration);
|
||||||
|
}
|
||||||
|
|
||||||
|
// artificially delay our init (delaying PROVIDER_READY event)
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, this.delay));
|
||||||
|
}
|
||||||
|
|
||||||
|
// artificially delay context changes
|
||||||
|
async onContextChange(): Promise<void> {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, this.delay));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../out-tsc/lib",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"inlineSources": true,
|
||||||
|
"types": [],
|
||||||
|
"paths": {
|
||||||
|
"angular": [
|
||||||
|
"./dist/angular"
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exclude": [
|
||||||
|
"**/*.spec.ts"
|
||||||
|
]
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.lib.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"declarationMap": false
|
||||||
|
},
|
||||||
|
"paths": {
|
||||||
|
"angular": [
|
||||||
|
"./dist/angular"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"compilationMode": "partial"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/spec",
|
||||||
|
"types": [
|
||||||
|
"vitest/globals",
|
||||||
|
"node"
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"angular": [
|
||||||
|
"./dist/angular"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"emitDecoratorMetadata": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.spec.ts",
|
||||||
|
"src/**/*.d.ts",
|
||||||
|
"src/test-provider.ts"
|
||||||
|
]
|
||||||
|
}
|
|
@ -0,0 +1,42 @@
|
||||||
|
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||||
|
{
|
||||||
|
"compileOnSave": false,
|
||||||
|
"compilerOptions": {
|
||||||
|
"paths": {
|
||||||
|
"angular": [
|
||||||
|
"./dist/angular"
|
||||||
|
],
|
||||||
|
"@openfeature/core": [ "../shared/src" ],
|
||||||
|
"@openfeature/web-sdk": [ "../web/src" ]
|
||||||
|
},
|
||||||
|
"outDir": "./dist/out-tsc",
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"declaration": false,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"importHelpers": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"useDefineForClassFields": false,
|
||||||
|
"strictNullChecks": false,
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"dom"
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"disableTypeScriptVersionCheck": true,
|
||||||
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
|
"strictInjectionParameters": true,
|
||||||
|
"strictInputAccessModifiers": true,
|
||||||
|
"strictTemplates": true
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/spec",
|
||||||
|
"types": [
|
||||||
|
"vitest/globals",
|
||||||
|
"node"
|
||||||
|
],
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"emitDecoratorMetadata": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"projects/angular-sdk/src/**/*.spec.ts",
|
||||||
|
"projects/angular-sdk/src/**/*.d.ts"
|
||||||
|
]
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'json', 'html'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
|
@ -1,12 +0,0 @@
|
||||||
import { ClientMetadata, EvaluationLifeCycle, Eventing, ManageLogger } from '@openfeature/core';
|
|
||||||
import { Features } from '../evaluation';
|
|
||||||
import { ProviderStatus } from '../provider';
|
|
||||||
import { ProviderEvents } from '../events';
|
|
||||||
|
|
||||||
export interface Client extends EvaluationLifeCycle<Client>, Features, ManageLogger<Client>, Eventing<ProviderEvents> {
|
|
||||||
readonly metadata: ClientMetadata;
|
|
||||||
/**
|
|
||||||
* Returns the status of the associated provider.
|
|
||||||
*/
|
|
||||||
readonly providerStatus: ProviderStatus;
|
|
||||||
}
|
|
|
@ -1,2 +0,0 @@
|
||||||
export * from './client';
|
|
||||||
export * from './open-feature-client';
|
|
|
@ -1,3 +0,0 @@
|
||||||
import { BaseHook, FlagValue } from '@openfeature/core';
|
|
||||||
|
|
||||||
export type Hook = BaseHook<FlagValue, void, void>;
|
|
|
@ -1,5 +1,110 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.2.5](https://github.com/open-feature/js-sdk/compare/nestjs-sdk-v0.2.4...nestjs-sdk-v0.2.5) (2025-05-27)
|
||||||
|
|
||||||
|
|
||||||
|
### ✨ New Features
|
||||||
|
|
||||||
|
* adds RequireFlagsEnabled decorator ([#1159](https://github.com/open-feature/js-sdk/issues/1159)) ([59b8fe9](https://github.com/open-feature/js-sdk/commit/59b8fe904f053e4aa3d0c72631af34183ff54dc7))
|
||||||
|
|
||||||
|
|
||||||
|
## [0.2.4](https://github.com/open-feature/js-sdk/compare/nestjs-sdk-v0.2.3...nestjs-sdk-v0.2.4) (2025-04-20)
|
||||||
|
|
||||||
|
|
||||||
|
### 🧹 Chore
|
||||||
|
|
||||||
|
* **nest:** allow nestjs version 11 ([#1176](https://github.com/open-feature/js-sdk/issues/1176)) ([42a3b39](https://github.com/open-feature/js-sdk/commit/42a3b39c2488002f249b37ce86794ef2f77eb31c))
|
||||||
|
|
||||||
|
## [0.2.3](https://github.com/open-feature/js-sdk/compare/nestjs-sdk-v0.2.2...nestjs-sdk-v0.2.3) (2025-04-11)
|
||||||
|
|
||||||
|
|
||||||
|
### 🧹 Chore
|
||||||
|
|
||||||
|
* update sdk peer ([#1142](https://github.com/open-feature/js-sdk/issues/1142)) ([8bb6206](https://github.com/open-feature/js-sdk/commit/8bb620601e2b8dc7b62d717169b585bd1c886996))
|
||||||
|
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
* The following workspace dependencies were updated
|
||||||
|
* devDependencies
|
||||||
|
* @openfeature/server-sdk bumped from * to 1.18.0
|
||||||
|
|
||||||
|
## [0.2.2](https://github.com/open-feature/js-sdk/compare/nestjs-sdk-v0.2.1-experimental...nestjs-sdk-v0.2.2) (2024-10-29)
|
||||||
|
|
||||||
|
|
||||||
|
### 🧹 Chore
|
||||||
|
|
||||||
|
* import type lint rule and fixes ([#1039](https://github.com/open-feature/js-sdk/issues/1039)) ([01fcb93](https://github.com/open-feature/js-sdk/commit/01fcb933d2cbd131a0f4a005173cdd1906087e18))
|
||||||
|
|
||||||
|
## [0.2.1-experimental](https://github.com/open-feature/js-sdk/compare/nestjs-sdk-v0.2.0-experimental...nestjs-sdk-v0.2.1-experimental) (2024-06-11)
|
||||||
|
|
||||||
|
|
||||||
|
### ✨ New Features
|
||||||
|
|
||||||
|
* lower compilation target to es2015 ([#957](https://github.com/open-feature/js-sdk/issues/957)) ([c2d6c17](https://github.com/open-feature/js-sdk/commit/c2d6c1761ae19f937deaff2f011a0380f8af7350))
|
||||||
|
|
||||||
|
## [0.2.0-experimental](https://github.com/open-feature/js-sdk/compare/nestjs-sdk-v0.1.5-experimental...nestjs-sdk-v0.2.0-experimental) (2024-05-19)
|
||||||
|
|
||||||
|
|
||||||
|
### ⚠ BREAKING CHANGES
|
||||||
|
|
||||||
|
* rename FeatureClient decorator to OpenFeatureClient ([#949](https://github.com/open-feature/js-sdk/issues/949))
|
||||||
|
|
||||||
|
### ✨ New Features
|
||||||
|
|
||||||
|
* rename FeatureClient decorator to OpenFeatureClient ([#949](https://github.com/open-feature/js-sdk/issues/949)) ([a531238](https://github.com/open-feature/js-sdk/commit/a531238124510e6aa150ff619972f8880346507b))
|
||||||
|
|
||||||
|
## [0.1.5-experimental](https://github.com/open-feature/js-sdk/compare/nestjs-sdk-v0.1.4-experimental...nestjs-sdk-v0.1.5-experimental) (2024-05-13)
|
||||||
|
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
* removes exports of OpenFeatureClient class and makes event props readonly ([#918](https://github.com/open-feature/js-sdk/issues/918)) ([e9a25c2](https://github.com/open-feature/js-sdk/commit/e9a25c21cb17c3b5700bca652e3c0ed15e8f49b4))
|
||||||
|
|
||||||
|
|
||||||
|
### 🧹 Chore
|
||||||
|
|
||||||
|
* remove node 16 ([#875](https://github.com/open-feature/js-sdk/issues/875)) ([c1878e4](https://github.com/open-feature/js-sdk/commit/c1878e4effac3c8c9aa8a34cee4214f628a1e4ca))
|
||||||
|
* **deps:** update dependency supertest to v7 ([#939](https://github.com/open-feature/js-sdk/issues/939)) ([9083df8](https://github.com/open-feature/js-sdk/commit/9083df8463d6f970111dedee114aedc0a20e2a3c))
|
||||||
|
|
||||||
|
## [0.1.4-experimental](https://github.com/open-feature/js-sdk/compare/nestjs-sdk-v0.1.3-experimental...nestjremove node 16 ([#875](https://github.com/open-feature/js-sdk/issues/875)) ([c1878e4](https://github.com/open-feature/js-sdk/commit/c1878e4effac3c8c9aa8a34cee4214f628a1e4ca))
|
||||||
|
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
* removes exports of OpenFeatureClient class and makes event props readonly ([#918](https://github.com/open-feature/js-sdk/issues/918)) ([e9a25c2](https://github.com/open-feature/js-sdk/commit/e9a25c21cb17c3b5700bca652e3c0ed15e8f49b4))
|
||||||
|
|
||||||
|
|
||||||
|
### 🧹 Chore
|
||||||
|
|
||||||
|
* **deps:** update dependency supertest to v7 ([#939](https://github.com/open-feature/js-sdk/issues/939)) ([9083df8](https://github.com/open-feature/js-sdk/commit/9083df8463d6f970111dedee114aedc0a20e2a3c))
|
||||||
|
|
||||||
|
## [0.1.4-experimental](https://github.com/open-feature/js-sdk/compare/s-sdk-v0.1.4-experimental) (2024-04-18)
|
||||||
|
|
||||||
|
|
||||||
|
### 🧹 Chore
|
||||||
|
|
||||||
|
* bump spec version badge to v0.8.0 ([#910](https://github.com/open-feature/js-sdk/issues/910)) ([a7b2c4b](https://github.com/open-feature/js-sdk/commit/a7b2c4bca09112d49e637735466502adb1438ebe))
|
||||||
|
|
||||||
|
## [0.1.3-experimental](https://github.com/open-feature/js-sdk/compare/nestjs-sdk-v0.1.2-experimental...nestjs-sdk-v0.1.3-experimental) (2024-04-02)
|
||||||
|
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
* **deps:** resolve CVE-2024-29041 with nest update ([#889](https://github.com/open-feature/js-sdk/issues/889)) ([042ec5f](https://github.com/open-feature/js-sdk/commit/042ec5f70863ecc974371481be24f08c65321f7c))
|
||||||
|
|
||||||
|
|
||||||
|
### 📚 Documentation
|
||||||
|
|
||||||
|
* remove duplicate npm install section ([fd0fcfc](https://github.com/open-feature/js-sdk/commit/fd0fcfcfc815803a967e971b7e575c24e46c93bc))
|
||||||
|
|
||||||
|
## [0.1.2-experimental](https://github.com/open-feature/js-sdk/compare/nestjs-sdk-v0.1.1-experimental...nestjs-sdk-v0.1.2-experimental) (2024-03-06)
|
||||||
|
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
* **types:** conflicts with peer types ([#852](https://github.com/open-feature/js-sdk/issues/852)) ([fdc8576](https://github.com/open-feature/js-sdk/commit/fdc8576f472253604e26c36e10c0d315f71dbe1c))
|
||||||
|
|
||||||
## [0.1.1-experimental](https://github.com/open-feature/js-sdk/compare/nestjs-sdk-v0.1.0-experimental...nestjs-sdk-v0.1.1-experimental) (2024-03-05)
|
## [0.1.1-experimental](https://github.com/open-feature/js-sdk/compare/nestjs-sdk-v0.1.0-experimental...nestjs-sdk-v0.1.1-experimental) (2024-03-05)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -12,12 +12,12 @@
|
||||||
<!-- x-hide-in-docs-end -->
|
<!-- x-hide-in-docs-end -->
|
||||||
<!-- The 'github-badges' class is used in the docs -->
|
<!-- The 'github-badges' class is used in the docs -->
|
||||||
<p align="center" class="github-badges">
|
<p align="center" class="github-badges">
|
||||||
<a href="https://github.com/open-feature/spec/releases/tag/v0.7.0">
|
<a href="https://github.com/open-feature/spec/releases/tag/v0.8.0">
|
||||||
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.7.0&color=yellow&style=for-the-badge" />
|
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
|
||||||
</a>
|
</a>
|
||||||
<!-- x-release-please-start-version -->
|
<!-- x-release-please-start-version -->
|
||||||
<a href="https://github.com/open-feature/js-sdk/releases/tag/nestjs-sdk-v0.1.1-experimental">
|
<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.1.1-experimental&color=blue&style=for-the-badge" />
|
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.2.5&color=blue&style=for-the-badge" />
|
||||||
</a>
|
</a>
|
||||||
<!-- x-release-please-end -->
|
<!-- x-release-please-end -->
|
||||||
<br/>
|
<br/>
|
||||||
|
@ -50,7 +50,7 @@ Capabilities include:
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
- Node.js version 16+
|
- Node.js version 20+
|
||||||
- NestJS version 8+
|
- NestJS version 8+
|
||||||
|
|
||||||
### Install
|
### Install
|
||||||
|
@ -61,14 +61,21 @@ Capabilities include:
|
||||||
npm install --save @openfeature/nestjs-sdk
|
npm install --save @openfeature/nestjs-sdk
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### yarn
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# yarn requires manual installation of the peer dependencies (see below)
|
||||||
|
yarn add @openfeature/nestjs-sdk @openfeature/server-sdk @openfeature/core
|
||||||
|
```
|
||||||
|
|
||||||
#### Required peer dependencies
|
#### Required peer dependencies
|
||||||
|
|
||||||
The following list contains the peer dependencies of `@openfeature/nestjs-sdk` with its expected and compatible versions:
|
The following list contains the peer dependencies of `@openfeature/nestjs-sdk` with its expected and compatible versions:
|
||||||
|
|
||||||
* `@openfeature/server-sdk`: >=1.7.5
|
- `@openfeature/server-sdk`: >=1.7.5
|
||||||
* `@nestjs/common`: ^8.0.0 || ^9.0.0 || ^10.0.0
|
- `@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
|
- `@nestjs/core`: ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
|
||||||
* `rxjs`: ^6.0.0 || ^7.0.0 || ^8.0.0
|
- `rxjs`: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||||
|
|
||||||
The minimum required version of `@openfeature/server-sdk` currently is `1.7.5`.
|
The minimum required version of `@openfeature/server-sdk` currently is `1.7.5`.
|
||||||
|
|
||||||
|
@ -130,13 +137,13 @@ It is also possible to inject the default or domain scoped OpenFeature clients i
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { FeatureClient, Client } from '@openfeature/nestjs-sdk';
|
import { OpenFeatureClient, Client } from '@openfeature/nestjs-sdk';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OpenFeatureTestService {
|
export class OpenFeatureTestService {
|
||||||
constructor(
|
constructor(
|
||||||
@FeatureClient() private defaultClient: Client,
|
@OpenFeatureClient() private defaultClient: Client,
|
||||||
@FeatureClient({ domain: 'my-domain' }) private scopedClient: Client,
|
@OpenFeatureClient({ domain: 'my-domain' }) private scopedClient: Client,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async getBoolean() {
|
public async getBoolean() {
|
||||||
|
@ -145,6 +152,24 @@ export class OpenFeatureTestService {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Managing Controller or Route Access via Feature Flags
|
||||||
|
|
||||||
|
The `RequireFlagsEnabled` decorator can be used to manage access to a controller or route based on the enabled state of a feature flag. The decorator will throw an exception if the required feature flag(s) are not enabled.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { RequireFlagsEnabled } from '@openfeature/nestjs-sdk';
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
export class OpenFeatureController {
|
||||||
|
@RequireFlagsEnabled({ flags: [{ flagKey: 'testBooleanFlag' }] })
|
||||||
|
@Get('/welcome')
|
||||||
|
public async welcome() {
|
||||||
|
return 'Welcome to this OpenFeature-enabled NestJS app!';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Module additional information
|
## Module additional information
|
||||||
|
|
||||||
### Flag evaluation context injection
|
### Flag evaluation context injection
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@openfeature/nestjs-sdk",
|
"name": "@openfeature/nestjs-sdk",
|
||||||
"version": "0.1.1-experimental",
|
"version": "0.2.5",
|
||||||
"description": "OpenFeature Nest.js SDK",
|
"description": "OpenFeature Nest.js SDK",
|
||||||
"main": "./dist/cjs/index.js",
|
"main": "./dist/cjs/index.js",
|
||||||
"files": [
|
"files": [
|
||||||
|
@ -16,9 +16,10 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "jest --verbose",
|
"test": "jest --verbose",
|
||||||
"lint": "eslint ./",
|
"lint": "eslint ./",
|
||||||
|
"lint:fix": "eslint ./ --fix",
|
||||||
"clean": "shx rm -rf ./dist",
|
"clean": "shx rm -rf ./dist",
|
||||||
"build:esm": "esbuild src/index.ts --bundle --external:@nestjs/* --external:@openfeature/server-sdk --sourcemap --target=es2022 --platform=node --format=esm --outfile=./dist/esm/index.js --analyze",
|
"build:esm": "esbuild src/index.ts --bundle --external:@nestjs/* --external:@openfeature/server-sdk --sourcemap --target=es2015 --platform=node --format=esm --outfile=./dist/esm/index.js --analyze",
|
||||||
"build:cjs": "esbuild src/index.ts --bundle --external:@nestjs/* --external:@openfeature/server-sdk --sourcemap --target=es2022 --platform=node --format=cjs --outfile=./dist/cjs/index.js --analyze",
|
"build:cjs": "esbuild src/index.ts --bundle --external:@nestjs/* --external:@openfeature/server-sdk --sourcemap --target=es2015 --platform=node --format=cjs --outfile=./dist/cjs/index.js --analyze",
|
||||||
"build:rollup-types": "rollup -c ../../rollup.config.mjs",
|
"build:rollup-types": "rollup -c ../../rollup.config.mjs",
|
||||||
"build": "npm run clean && npm run build:esm && npm run build:cjs && npm run build:rollup-types",
|
"build": "npm run clean && npm run build:esm && npm run build:cjs && npm run build:rollup-types",
|
||||||
"postbuild": "shx cp ./../../package.esm.json ./dist/esm/package.json",
|
"postbuild": "shx cp ./../../package.esm.json ./dist/esm/package.json",
|
||||||
|
@ -45,19 +46,19 @@
|
||||||
},
|
},
|
||||||
"homepage": "https://github.com/open-feature/js-sdk#readme",
|
"homepage": "https://github.com/open-feature/js-sdk#readme",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0",
|
"@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",
|
"@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
|
||||||
"rxjs": "^6.0.0 || ^7.0.0 || 8.0.0",
|
"rxjs": "^6.0.0 || ^7.0.0 || 8.0.0",
|
||||||
"@openfeature/server-sdk": ">=1.7.5"
|
"@openfeature/server-sdk": "^1.17.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/common": "^10.2.10",
|
"@nestjs/common": "^11.0.20",
|
||||||
"@nestjs/core": "^10.2.10",
|
"@nestjs/core": "^11.0.20",
|
||||||
"@nestjs/platform-express": "^10.2.10",
|
"@nestjs/platform-express": "^11.0.20",
|
||||||
"@nestjs/testing": "^10.2.10",
|
"@nestjs/testing": "^11.0.20",
|
||||||
"@openfeature/core": "*",
|
"@openfeature/core": "*",
|
||||||
"@openfeature/server-sdk": "*",
|
"@openfeature/server-sdk": "1.18.0",
|
||||||
"@types/supertest": "^6.0.0",
|
"@types/supertest": "^6.0.0",
|
||||||
"supertest": "^6.3.3"
|
"supertest": "^7.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { EvaluationContext } from '@openfeature/core';
|
import type { EvaluationContext } from '@openfeature/core';
|
||||||
import { ExecutionContext, Inject } from '@nestjs/common';
|
import type { ExecutionContext} from '@nestjs/common';
|
||||||
|
import { Inject } from '@nestjs/common';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A factory function for creating an OpenFeature {@link EvaluationContext} from Nest {@link ExecutionContext}.
|
* A factory function for creating an OpenFeature {@link EvaluationContext} from Nest {@link ExecutionContext}.
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import { CallHandler, ExecutionContext, Inject, Injectable, NestInterceptor } from '@nestjs/common';
|
import type { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common';
|
||||||
import { ContextFactory, ContextFactoryToken } from './context-factory';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type { ContextFactory} from './context-factory';
|
||||||
|
import { ContextFactoryToken } from './context-factory';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { OpenFeature } from '@openfeature/server-sdk';
|
import { OpenFeature } from '@openfeature/server-sdk';
|
||||||
import { OpenFeatureModule } from './open-feature.module';
|
import { OpenFeatureModule } from './open-feature.module';
|
||||||
|
|
|
@ -1,14 +1,10 @@
|
||||||
import { createParamDecorator, Inject } from '@nestjs/common';
|
import { createParamDecorator, Inject } from '@nestjs/common';
|
||||||
import {
|
import type { EvaluationContext, EvaluationDetails, FlagValue, JsonValue } from '@openfeature/server-sdk';
|
||||||
EvaluationContext,
|
import { Client } from '@openfeature/server-sdk';
|
||||||
EvaluationDetails,
|
|
||||||
FlagValue,
|
|
||||||
JsonValue,
|
|
||||||
OpenFeature,
|
|
||||||
Client,
|
|
||||||
} from '@openfeature/server-sdk';
|
|
||||||
import { getOpenFeatureClientToken } from './open-feature.module';
|
import { getOpenFeatureClientToken } from './open-feature.module';
|
||||||
import { from, Observable } from 'rxjs';
|
import type { Observable } from 'rxjs';
|
||||||
|
import { from } from 'rxjs';
|
||||||
|
import { getClientForEvaluation } from './utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options for injecting an OpenFeature client into a constructor.
|
* Options for injecting an OpenFeature client into a constructor.
|
||||||
|
@ -26,7 +22,7 @@ interface FeatureClientProps {
|
||||||
* @param {FeatureClientProps} [props] The options for injecting the client.
|
* @param {FeatureClientProps} [props] The options for injecting the client.
|
||||||
* @returns {PropertyDecorator & ParameterDecorator} The decorator function.
|
* @returns {PropertyDecorator & ParameterDecorator} The decorator function.
|
||||||
*/
|
*/
|
||||||
export const FeatureClient = (props?: FeatureClientProps) => Inject(getOpenFeatureClientToken(props?.domain));
|
export const OpenFeatureClient = (props?: FeatureClientProps) => Inject(getOpenFeatureClientToken(props?.domain));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options for injecting a feature flag into a route handler.
|
* Options for injecting a feature flag into a route handler.
|
||||||
|
@ -54,16 +50,6 @@ interface FeatureProps<T extends FlagValue> {
|
||||||
context?: EvaluationContext;
|
context?: EvaluationContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a domain scoped or the default OpenFeature client with the given context.
|
|
||||||
* @param {string} domain The domain of the OpenFeature client.
|
|
||||||
* @param {EvaluationContext} context The evaluation context of the client.
|
|
||||||
* @returns {Client} The OpenFeature client.
|
|
||||||
*/
|
|
||||||
function getClientForEvaluation(domain?: string, context?: EvaluationContext) {
|
|
||||||
return domain ? OpenFeature.getClient(domain, context) : OpenFeature.getClient(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route handler parameter decorator.
|
* Route handler parameter decorator.
|
||||||
*
|
*
|
||||||
|
|
|
@ -2,5 +2,6 @@ export * from './open-feature.module';
|
||||||
export * from './feature.decorator';
|
export * from './feature.decorator';
|
||||||
export * from './evaluation-context-interceptor';
|
export * from './evaluation-context-interceptor';
|
||||||
export * from './context-factory';
|
export * from './context-factory';
|
||||||
|
export * from './require-flags-enabled.decorator';
|
||||||
// re-export the server-sdk so consumers can access that API from the nestjs-sdk
|
// re-export the server-sdk so consumers can access that API from the nestjs-sdk
|
||||||
export * from '@openfeature/server-sdk';
|
export * from '@openfeature/server-sdk';
|
||||||
|
|
|
@ -1,24 +1,27 @@
|
||||||
import {
|
import type {
|
||||||
DynamicModule,
|
DynamicModule,
|
||||||
Module,
|
|
||||||
FactoryProvider as NestFactoryProvider,
|
FactoryProvider as NestFactoryProvider,
|
||||||
ValueProvider,
|
ValueProvider,
|
||||||
ClassProvider,
|
ClassProvider,
|
||||||
Provider as NestProvider,
|
Provider as NestProvider} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
Module,
|
||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
import type {
|
||||||
Client,
|
Client,
|
||||||
Hook,
|
Hook,
|
||||||
OpenFeature,
|
|
||||||
Provider,
|
Provider,
|
||||||
EvaluationContext,
|
EvaluationContext,
|
||||||
ServerProviderEvents,
|
ServerProviderEvents,
|
||||||
EventHandler,
|
EventHandler,
|
||||||
Logger,
|
Logger} from '@openfeature/server-sdk';
|
||||||
|
import {
|
||||||
|
OpenFeature,
|
||||||
AsyncLocalStorageTransactionContextPropagator,
|
AsyncLocalStorageTransactionContextPropagator,
|
||||||
} from '@openfeature/server-sdk';
|
} from '@openfeature/server-sdk';
|
||||||
import { ContextFactory, ContextFactoryToken } from './context-factory';
|
import type { ContextFactory} from './context-factory';
|
||||||
|
import { ContextFactoryToken } from './context-factory';
|
||||||
import { APP_INTERCEPTOR } from '@nestjs/core';
|
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||||
import { EvaluationContextInterceptor } from './evaluation-context-interceptor';
|
import { EvaluationContextInterceptor } from './evaluation-context-interceptor';
|
||||||
import { ShutdownService } from './shutdown.service';
|
import { ShutdownService } from './shutdown.service';
|
||||||
|
|
|
@ -0,0 +1,104 @@
|
||||||
|
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);
|
||||||
|
};
|
|
@ -1,4 +1,5 @@
|
||||||
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
|
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
import { OpenFeature } from '@openfeature/server-sdk';
|
import { OpenFeature } from '@openfeature/server-sdk';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
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);
|
||||||
|
}
|
|
@ -1,5 +1,6 @@
|
||||||
import { InMemoryProvider } from '@openfeature/server-sdk';
|
import { InMemoryProvider } from '@openfeature/server-sdk';
|
||||||
import { ExecutionContext } from '@nestjs/common';
|
import type { EvaluationContext } from '@openfeature/server-sdk';
|
||||||
|
import type { ExecutionContext } from '@nestjs/common';
|
||||||
import { OpenFeatureModule } from '../src';
|
import { OpenFeatureModule } from '../src';
|
||||||
|
|
||||||
export const defaultProvider = new InMemoryProvider({
|
export const defaultProvider = new InMemoryProvider({
|
||||||
|
@ -23,6 +24,17 @@ export const defaultProvider = new InMemoryProvider({
|
||||||
variants: { default: { client: 'default' } },
|
variants: { default: { client: 'default' } },
|
||||||
disabled: false,
|
disabled: false,
|
||||||
},
|
},
|
||||||
|
testBooleanFlag2: {
|
||||||
|
defaultVariant: 'default',
|
||||||
|
variants: { default: false, enabled: true },
|
||||||
|
disabled: false,
|
||||||
|
contextEvaluator: (ctx: EvaluationContext) => {
|
||||||
|
if (ctx.targetingKey === '123') {
|
||||||
|
return 'enabled';
|
||||||
|
}
|
||||||
|
return 'default';
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const providers = {
|
export const providers = {
|
||||||
|
|
|
@ -1,7 +1,13 @@
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import type { TestingModule } from '@nestjs/testing';
|
||||||
import { INestApplication } from '@nestjs/common';
|
import { Test } from '@nestjs/testing';
|
||||||
|
import type { INestApplication } from '@nestjs/common';
|
||||||
import supertest from 'supertest';
|
import supertest from 'supertest';
|
||||||
import { OpenFeatureController, OpenFeatureControllerContextScopedController, OpenFeatureTestService } from './test-app';
|
import {
|
||||||
|
OpenFeatureController,
|
||||||
|
OpenFeatureContextScopedController,
|
||||||
|
OpenFeatureRequireFlagsEnabledController,
|
||||||
|
OpenFeatureTestService,
|
||||||
|
} from './test-app';
|
||||||
import { exampleContextFactory, getOpenFeatureDefaultTestModule } from './fixtures';
|
import { exampleContextFactory, getOpenFeatureDefaultTestModule } from './fixtures';
|
||||||
import { OpenFeatureModule } from '../src';
|
import { OpenFeatureModule } from '../src';
|
||||||
import { defaultProvider, providers } from './fixtures';
|
import { defaultProvider, providers } from './fixtures';
|
||||||
|
@ -13,11 +19,9 @@ describe('OpenFeature SDK', () => {
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
moduleRef = await Test.createTestingModule({
|
moduleRef = await Test.createTestingModule({
|
||||||
imports: [
|
imports: [getOpenFeatureDefaultTestModule()],
|
||||||
getOpenFeatureDefaultTestModule()
|
|
||||||
],
|
|
||||||
providers: [OpenFeatureTestService],
|
providers: [OpenFeatureTestService],
|
||||||
controllers: [OpenFeatureController],
|
controllers: [OpenFeatureController, OpenFeatureRequireFlagsEnabledController],
|
||||||
}).compile();
|
}).compile();
|
||||||
app = moduleRef.createNestApplication();
|
app = moduleRef.createNestApplication();
|
||||||
app = await app.init();
|
app = await app.init();
|
||||||
|
@ -111,7 +115,7 @@ describe('OpenFeature SDK', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('evaluation context service should', () => {
|
describe('evaluation context service should', () => {
|
||||||
it('inject the evaluation context from contex factory', async function() {
|
it('inject the evaluation context from contex factory', async function () {
|
||||||
const evaluationSpy = jest.spyOn(defaultProvider, 'resolveBooleanEvaluation');
|
const evaluationSpy = jest.spyOn(defaultProvider, 'resolveBooleanEvaluation');
|
||||||
await supertest(app.getHttpServer())
|
await supertest(app.getHttpServer())
|
||||||
.get('/dynamic-context-in-service')
|
.get('/dynamic-context-in-service')
|
||||||
|
@ -121,26 +125,77 @@ describe('OpenFeature SDK', () => {
|
||||||
expect(evaluationSpy).toHaveBeenCalledWith('testBooleanFlag', false, { targetingKey: 'dynamic-user' }, {});
|
expect(evaluationSpy).toHaveBeenCalledWith('testBooleanFlag', false, { targetingKey: 'dynamic-user' }, {});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('require flags enabled decorator', () => {
|
||||||
|
describe('OpenFeatureController', () => {
|
||||||
|
it('should sucessfully return the response if the flag is enabled', async () => {
|
||||||
|
await supertest(app.getHttpServer()).get('/flags-enabled').expect(200).expect('Get Boolean Flag Success!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw an exception if the flag is disabled', async () => {
|
||||||
|
jest.spyOn(defaultProvider, 'resolveBooleanEvaluation').mockResolvedValueOnce({
|
||||||
|
value: false,
|
||||||
|
reason: 'DISABLED',
|
||||||
|
});
|
||||||
|
await supertest(app.getHttpServer()).get('/flags-enabled').expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw a custom exception if the flag is disabled', async () => {
|
||||||
|
jest.spyOn(defaultProvider, 'resolveBooleanEvaluation').mockResolvedValueOnce({
|
||||||
|
value: false,
|
||||||
|
reason: 'DISABLED',
|
||||||
|
});
|
||||||
|
await supertest(app.getHttpServer()).get('/flags-enabled-custom-exception').expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw a custom exception if the flag is disabled with context', async () => {
|
||||||
|
await supertest(app.getHttpServer())
|
||||||
|
.get('/flags-enabled-custom-exception-with-context')
|
||||||
|
.set('x-user-id', '123')
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('OpenFeatureControllerRequireFlagsEnabled', () => {
|
||||||
|
it('should allow access to the RequireFlagsEnabled controller with global context interceptor', async () => {
|
||||||
|
await supertest(app.getHttpServer())
|
||||||
|
.get('/require-flags-enabled')
|
||||||
|
.set('x-user-id', '123')
|
||||||
|
.expect(200)
|
||||||
|
.expect('Hello, world!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw a 403 - Forbidden exception if user does not match targeting requirements', async () => {
|
||||||
|
await supertest(app.getHttpServer()).get('/require-flags-enabled').set('x-user-id', 'not-123').expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw a 403 - Forbidden exception if one of the flags is disabled', async () => {
|
||||||
|
jest.spyOn(defaultProvider, 'resolveBooleanEvaluation').mockResolvedValueOnce({
|
||||||
|
value: false,
|
||||||
|
reason: 'DISABLED',
|
||||||
|
});
|
||||||
|
await supertest(app.getHttpServer()).get('/require-flags-enabled').set('x-user-id', '123').expect(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Without global context interceptor', () => {
|
describe('Without global context interceptor', () => {
|
||||||
|
|
||||||
let moduleRef: TestingModule;
|
let moduleRef: TestingModule;
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
|
||||||
moduleRef = await Test.createTestingModule({
|
moduleRef = await Test.createTestingModule({
|
||||||
imports: [
|
imports: [
|
||||||
OpenFeatureModule.forRoot({
|
OpenFeatureModule.forRoot({
|
||||||
contextFactory: exampleContextFactory,
|
contextFactory: exampleContextFactory,
|
||||||
defaultProvider,
|
defaultProvider,
|
||||||
providers,
|
providers,
|
||||||
useGlobalInterceptor: false
|
useGlobalInterceptor: false,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
providers: [OpenFeatureTestService],
|
providers: [OpenFeatureTestService],
|
||||||
controllers: [OpenFeatureController, OpenFeatureControllerContextScopedController],
|
controllers: [OpenFeatureController, OpenFeatureContextScopedController],
|
||||||
}).compile();
|
}).compile();
|
||||||
app = moduleRef.createNestApplication();
|
app = moduleRef.createNestApplication();
|
||||||
app = await app.init();
|
app = await app.init();
|
||||||
|
@ -157,7 +212,7 @@ describe('OpenFeature SDK', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('evaluation context service should', () => {
|
describe('evaluation context service should', () => {
|
||||||
it('inject empty context if no context interceptor is configured', async function() {
|
it('inject empty context if no context interceptor is configured', async function () {
|
||||||
const evaluationSpy = jest.spyOn(defaultProvider, 'resolveBooleanEvaluation');
|
const evaluationSpy = jest.spyOn(defaultProvider, 'resolveBooleanEvaluation');
|
||||||
await supertest(app.getHttpServer())
|
await supertest(app.getHttpServer())
|
||||||
.get('/dynamic-context-in-service')
|
.get('/dynamic-context-in-service')
|
||||||
|
@ -171,9 +226,26 @@ describe('OpenFeature SDK', () => {
|
||||||
describe('With Controller bound Context interceptor', () => {
|
describe('With Controller bound Context interceptor', () => {
|
||||||
it('should not use context if global context interceptor is not configured', async () => {
|
it('should not use context if global context interceptor is not configured', async () => {
|
||||||
const evaluationSpy = jest.spyOn(defaultProvider, 'resolveBooleanEvaluation');
|
const evaluationSpy = jest.spyOn(defaultProvider, 'resolveBooleanEvaluation');
|
||||||
await supertest(app.getHttpServer()).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' }, {});
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import type { TestingModule } from '@nestjs/testing';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
import { getOpenFeatureClientToken, OpenFeatureModule, ServerProviderEvents } from '../src';
|
import { getOpenFeatureClientToken, OpenFeatureModule, ServerProviderEvents } from '../src';
|
||||||
import { OpenFeature, OpenFeatureClient } from '@openfeature/server-sdk';
|
import type { Client} from '@openfeature/server-sdk';
|
||||||
|
import { OpenFeature } from '@openfeature/server-sdk';
|
||||||
import { getOpenFeatureDefaultTestModule } from './fixtures';
|
import { getOpenFeatureDefaultTestModule } from './fixtures';
|
||||||
|
|
||||||
describe('OpenFeatureModule', () => {
|
describe('OpenFeatureModule', () => {
|
||||||
|
@ -31,19 +33,19 @@ describe('OpenFeatureModule', () => {
|
||||||
|
|
||||||
it('should return the SDKs default provider and not throw', async () => {
|
it('should return the SDKs default provider and not throw', async () => {
|
||||||
expect(() => {
|
expect(() => {
|
||||||
moduleWithoutProvidersRef.get<OpenFeatureClient>(getOpenFeatureClientToken());
|
moduleWithoutProvidersRef.get<Client>(getOpenFeatureClientToken());
|
||||||
}).not.toThrow();
|
}).not.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return the default provider', async () => {
|
it('should return the default provider', async () => {
|
||||||
const client = moduleRef.get<OpenFeatureClient>(getOpenFeatureClientToken());
|
const client = moduleRef.get<Client>(getOpenFeatureClientToken());
|
||||||
expect(client).toBeDefined();
|
expect(client).toBeDefined();
|
||||||
expect(await client.getStringValue('testStringFlag', '')).toEqual('expected-string-value-default');
|
expect(await client.getStringValue('testStringFlag', '')).toEqual('expected-string-value-default');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should inject the client with the given scope', async () => {
|
it('should inject the client with the given scope', async () => {
|
||||||
const client = moduleRef.get<OpenFeatureClient>(getOpenFeatureClientToken('domainScopedClient'));
|
const client = moduleRef.get<Client>(getOpenFeatureClientToken('domainScopedClient'));
|
||||||
expect(client).toBeDefined();
|
expect(client).toBeDefined();
|
||||||
expect(await client.getStringValue('testStringFlag', '')).toEqual('expected-string-value-scoped');
|
expect(await client.getStringValue('testStringFlag', '')).toEqual('expected-string-value-scoped');
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,14 +1,22 @@
|
||||||
import { Controller, Get, Injectable, UseInterceptors } from '@nestjs/common';
|
import { Controller, ForbiddenException, Get, Injectable, UseInterceptors } from '@nestjs/common';
|
||||||
import { Observable, map } from 'rxjs';
|
import type { Observable } from 'rxjs';
|
||||||
import { BooleanFeatureFlag, ObjectFeatureFlag, NumberFeatureFlag, FeatureClient, StringFeatureFlag } from '../src';
|
import { map } from 'rxjs';
|
||||||
import { OpenFeatureClient, EvaluationDetails, FlagValue } from '@openfeature/server-sdk';
|
import {
|
||||||
|
BooleanFeatureFlag,
|
||||||
|
ObjectFeatureFlag,
|
||||||
|
NumberFeatureFlag,
|
||||||
|
OpenFeatureClient,
|
||||||
|
StringFeatureFlag,
|
||||||
|
RequireFlagsEnabled,
|
||||||
|
} from '../src';
|
||||||
|
import type { Client, EvaluationDetails, FlagValue } from '@openfeature/server-sdk';
|
||||||
import { EvaluationContextInterceptor } from '../src';
|
import { EvaluationContextInterceptor } from '../src';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OpenFeatureTestService {
|
export class OpenFeatureTestService {
|
||||||
constructor(
|
constructor(
|
||||||
@FeatureClient() public defaultClient: OpenFeatureClient,
|
@OpenFeatureClient() public defaultClient: Client,
|
||||||
@FeatureClient({ domain: 'domainScopedClient' }) public domainScopedClient: OpenFeatureClient,
|
@OpenFeatureClient({ domain: 'domainScopedClient' }) public domainScopedClient: Client,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async serviceMethod(flag: EvaluationDetails<FlagValue>) {
|
public async serviceMethod(flag: EvaluationDetails<FlagValue>) {
|
||||||
|
@ -83,11 +91,40 @@ export class OpenFeatureController {
|
||||||
public async handleDynamicContextInServiceRequest() {
|
public async handleDynamicContextInServiceRequest() {
|
||||||
return this.testService.serviceMethodWithDynamicContext('testBooleanFlag');
|
return this.testService.serviceMethodWithDynamicContext('testBooleanFlag');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@RequireFlagsEnabled({
|
||||||
|
flags: [{ flagKey: 'testBooleanFlag' }],
|
||||||
|
})
|
||||||
|
@Get('/flags-enabled')
|
||||||
|
public async handleGuardedBooleanRequest() {
|
||||||
|
return 'Get Boolean Flag Success!';
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequireFlagsEnabled({
|
||||||
|
flags: [{ flagKey: 'testBooleanFlag' }],
|
||||||
|
exception: new ForbiddenException(),
|
||||||
|
})
|
||||||
|
@Get('/flags-enabled-custom-exception')
|
||||||
|
public async handleBooleanRequestWithCustomException() {
|
||||||
|
return 'Get Boolean Flag Success!';
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequireFlagsEnabled({
|
||||||
|
flags: [{ flagKey: 'testBooleanFlag2' }],
|
||||||
|
exception: new ForbiddenException(),
|
||||||
|
context: {
|
||||||
|
targetingKey: 'user-id',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@Get('/flags-enabled-custom-exception-with-context')
|
||||||
|
public async handleBooleanRequestWithCustomExceptionAndContext() {
|
||||||
|
return 'Get Boolean Flag Success!';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
@UseInterceptors(EvaluationContextInterceptor)
|
@UseInterceptors(EvaluationContextInterceptor)
|
||||||
export class OpenFeatureControllerContextScopedController {
|
export class OpenFeatureContextScopedController {
|
||||||
constructor(private testService: OpenFeatureTestService) {}
|
constructor(private testService: OpenFeatureTestService) {}
|
||||||
|
|
||||||
@Get('/controller-context')
|
@Get('/controller-context')
|
||||||
|
@ -100,4 +137,27 @@ export class OpenFeatureControllerContextScopedController {
|
||||||
) {
|
) {
|
||||||
return feature.pipe(map((details) => this.testService.serviceMethod(details)));
|
return feature.pipe(map((details) => this.testService.serviceMethod(details)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@RequireFlagsEnabled({
|
||||||
|
flags: [{ flagKey: 'testBooleanFlag' }],
|
||||||
|
domain: 'domainScopedClient',
|
||||||
|
})
|
||||||
|
@Get('/controller-context/flags-enabled')
|
||||||
|
public async handleBooleanRequest() {
|
||||||
|
return 'Get Boolean Flag Success!';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('require-flags-enabled')
|
||||||
|
@RequireFlagsEnabled({
|
||||||
|
flags: [{ flagKey: 'testBooleanFlag', defaultValue: false }, { flagKey: 'testBooleanFlag2' }],
|
||||||
|
exception: new ForbiddenException(),
|
||||||
|
})
|
||||||
|
export class OpenFeatureRequireFlagsEnabledController {
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
@Get('/')
|
||||||
|
public async handleGetRequest() {
|
||||||
|
return 'Hello, world!';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,10 +9,10 @@
|
||||||
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||||
/* Language and Environment */
|
/* Language and Environment */
|
||||||
"target": "ES2022",
|
"target": "ES2015",
|
||||||
/* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
/* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||||
"lib": [
|
"lib": [
|
||||||
"ES2022"
|
"ES2015"
|
||||||
],
|
],
|
||||||
/* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
/* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||||
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||||
|
@ -27,7 +27,7 @@
|
||||||
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||||
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||||
/* Modules */
|
/* Modules */
|
||||||
"module": "ES2022",
|
"module": "ES2015",
|
||||||
/* Specify what module code is generated. */
|
/* Specify what module code is generated. */
|
||||||
// "rootDir": "./", /* Specify the root folder within your source files. */
|
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
|
|
|
@ -1,5 +1,233 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [1.0.1](https://github.com/open-feature/js-sdk/compare/react-sdk-v1.0.0...react-sdk-v1.0.1) (2025-08-18)
|
||||||
|
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
* **react:** re-evaluate flags on re-render to detect silent provider … ([#1226](https://github.com/open-feature/js-sdk/issues/1226)) ([3105595](https://github.com/open-feature/js-sdk/commit/31055959265a53f52102590f54fa3168811ec678))
|
||||||
|
|
||||||
|
## [1.0.0](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.4.11...react-sdk-v1.0.0) (2025-04-14)
|
||||||
|
|
||||||
|
|
||||||
|
### ✨ New Features
|
||||||
|
|
||||||
|
* add polyfill for react use hook ([#1157](https://github.com/open-feature/js-sdk/issues/1157)) ([5afe61f](https://github.com/open-feature/js-sdk/commit/5afe61f9e351b037b04c93a1d81aee8016756748))
|
||||||
|
* add support for abort controllers to event handlers ([#1151](https://github.com/open-feature/js-sdk/issues/1151)) ([6a22483](https://github.com/open-feature/js-sdk/commit/6a224830fa4e62fc30a7802536f6f6fc3f772038))
|
||||||
|
|
||||||
|
## [0.4.11](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.4.10...react-sdk-v0.4.11) (2025-02-07)
|
||||||
|
|
||||||
|
|
||||||
|
### ✨ New Features
|
||||||
|
|
||||||
|
* export useOpenFeatureClientStatus hook ([#1082](https://github.com/open-feature/js-sdk/issues/1082)) ([4a6b860](https://github.com/open-feature/js-sdk/commit/4a6b8605444edeaf43355713357fecb97dd850b6))
|
||||||
|
|
||||||
|
|
||||||
|
### 🧹 Chore
|
||||||
|
|
||||||
|
* update sdk peer ([#1142](https://github.com/open-feature/js-sdk/issues/1142)) ([8bb6206](https://github.com/open-feature/js-sdk/commit/8bb620601e2b8dc7b62d717169b585bd1c886996))
|
||||||
|
|
||||||
|
## [0.4.10](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.4.9...react-sdk-v0.4.10) (2024-12-18)
|
||||||
|
|
||||||
|
|
||||||
|
### 🔄 Refactoring
|
||||||
|
|
||||||
|
* export public option types ([#1101](https://github.com/open-feature/js-sdk/issues/1101)) ([16321c3](https://github.com/open-feature/js-sdk/commit/16321c31f27c5fce2c8e2adea893cf6e7e8ce3de))
|
||||||
|
|
||||||
|
## [0.4.9](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.4.8...react-sdk-v0.4.9) (2024-12-04)
|
||||||
|
|
||||||
|
|
||||||
|
### ✨ New Features
|
||||||
|
|
||||||
|
* re-render if flagsChanged is falsy ([#1095](https://github.com/open-feature/js-sdk/issues/1095)) ([78516f4](https://github.com/open-feature/js-sdk/commit/78516f4181c82baf8c42fd64798fc2cfd8ff1056))
|
||||||
|
|
||||||
|
|
||||||
|
### 📚 Documentation
|
||||||
|
|
||||||
|
* fix typos, links, and format ([#1075](https://github.com/open-feature/js-sdk/issues/1075)) ([418409e](https://github.com/open-feature/js-sdk/commit/418409e3faafc6868a9f893267a4733db9931f93))
|
||||||
|
|
||||||
|
## [0.4.8](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.4.7...react-sdk-v0.4.8) (2024-10-29)
|
||||||
|
|
||||||
|
|
||||||
|
### 🧹 Chore
|
||||||
|
|
||||||
|
* bump minimum web peer ([#1072](https://github.com/open-feature/js-sdk/issues/1072)) ([eca8205](https://github.com/open-feature/js-sdk/commit/eca8205da7945395d19c09a4da67cd4c2d516227))
|
||||||
|
|
||||||
|
|
||||||
|
### 📚 Documentation
|
||||||
|
|
||||||
|
* add tracking sections ([#1068](https://github.com/open-feature/js-sdk/issues/1068)) ([e131faf](https://github.com/open-feature/js-sdk/commit/e131faffad9025e9c7194f39558bf3b3cec31807))
|
||||||
|
|
||||||
|
## [0.4.7](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.4.6...react-sdk-v0.4.7) (2024-10-29)
|
||||||
|
|
||||||
|
|
||||||
|
### ✨ New Features
|
||||||
|
|
||||||
|
* avoid re-resolving flags unaffected by a change event ([#1024](https://github.com/open-feature/js-sdk/issues/1024)) ([b8f9b4e](https://github.com/open-feature/js-sdk/commit/b8f9b4ebaf4bdd93669fc6da09d9f97a498174d9))
|
||||||
|
* implement tracking as per spec ([#1020](https://github.com/open-feature/js-sdk/issues/1020)) ([80f182e](https://github.com/open-feature/js-sdk/commit/80f182e1afbd3a705bf3de6a0d9886ccb3424b44))
|
||||||
|
* use mutate context hook ([#1031](https://github.com/open-feature/js-sdk/issues/1031)) ([ec3d967](https://github.com/open-feature/js-sdk/commit/ec3d967f8b9dd0854706a904a5360f0a0b843595))
|
||||||
|
|
||||||
|
|
||||||
|
### 🧹 Chore
|
||||||
|
|
||||||
|
* add js docs for context mutator hook ([#1045](https://github.com/open-feature/js-sdk/issues/1045)) ([def3fe8](https://github.com/open-feature/js-sdk/commit/def3fe8dafc3d6ed3451a493e76842b7d2e8363c))
|
||||||
|
* import type lint rule and fixes ([#1039](https://github.com/open-feature/js-sdk/issues/1039)) ([01fcb93](https://github.com/open-feature/js-sdk/commit/01fcb933d2cbd131a0f4a005173cdd1906087e18))
|
||||||
|
|
||||||
|
## [0.4.6](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.4.5...react-sdk-v0.4.6) (2024-09-23)
|
||||||
|
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
* failure to re-render on changes ([#1021](https://github.com/open-feature/js-sdk/issues/1021)) ([c927044](https://github.com/open-feature/js-sdk/commit/c927044c4934f0b8edfd2cdbbc0d60ad546b3dbc))
|
||||||
|
|
||||||
|
## [0.4.5](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.4.4...react-sdk-v0.4.5) (2024-09-04)
|
||||||
|
|
||||||
|
|
||||||
|
### ✨ New Features
|
||||||
|
|
||||||
|
* **react:** prevent rerenders when value is unchanged ([#987](https://github.com/open-feature/js-sdk/issues/987)) ([b7fc08e](https://github.com/open-feature/js-sdk/commit/b7fc08e27d225bdbf72c1985e7eef85adcd896b0))
|
||||||
|
|
||||||
|
## [0.4.4](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.4.3...react-sdk-v0.4.4) (2024-08-28)
|
||||||
|
|
||||||
|
|
||||||
|
### 🧹 Chore
|
||||||
|
|
||||||
|
* move client/ dir to web/ ([#991](https://github.com/open-feature/js-sdk/issues/991)) ([df4e72e](https://github.com/open-feature/js-sdk/commit/df4e72eabc3370801303470ca37263a0d4d9bb38))
|
||||||
|
|
||||||
|
|
||||||
|
### 📚 Documentation
|
||||||
|
|
||||||
|
* **react:** update the error message ([#978](https://github.com/open-feature/js-sdk/issues/978)) ([429c4ae](https://github.com/open-feature/js-sdk/commit/429c4ae941b66a1aa82b5aeea4bdb8b57bd05022))
|
||||||
|
|
||||||
|
## [0.4.3](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.4.2...react-sdk-v0.4.3) (2024-08-22)
|
||||||
|
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
* race condition in test provider with suspense ([#980](https://github.com/open-feature/js-sdk/issues/980)) ([0f187fe](https://github.com/open-feature/js-sdk/commit/0f187fe0b584e66b6283531eb7879c320967f921))
|
||||||
|
|
||||||
|
|
||||||
|
### 🧹 Chore
|
||||||
|
|
||||||
|
* fix flaky test timing ([ad46ade](https://github.com/open-feature/js-sdk/commit/ad46ade143b10366103d4ac199d728e8ae5ba7e8))
|
||||||
|
|
||||||
|
## [0.4.2](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.4.1...react-sdk-v0.4.2) (2024-07-29)
|
||||||
|
|
||||||
|
|
||||||
|
### ✨ New Features
|
||||||
|
|
||||||
|
* add test provider ([#971](https://github.com/open-feature/js-sdk/issues/971)) ([1c12d4d](https://github.com/open-feature/js-sdk/commit/1c12d4d548195bfc8c2f898a90ea97063aa8b3f7))
|
||||||
|
|
||||||
|
## [0.4.1](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.4.0...react-sdk-v0.4.1) (2024-06-11)
|
||||||
|
|
||||||
|
|
||||||
|
### ✨ New Features
|
||||||
|
|
||||||
|
* lower compilation target to es2015 ([#957](https://github.com/open-feature/js-sdk/issues/957)) ([c2d6c17](https://github.com/open-feature/js-sdk/commit/c2d6c1761ae19f937deaff2f011a0380f8af7350))
|
||||||
|
|
||||||
|
## [0.4.0](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.3.4...react-sdk-v0.4.0) (2024-05-13)
|
||||||
|
|
||||||
|
|
||||||
|
### ⚠ BREAKING CHANGES
|
||||||
|
|
||||||
|
* disable suspense by default, add suspense hooks ([#940](https://github.com/open-feature/js-sdk/issues/940))
|
||||||
|
|
||||||
|
### ✨ New Features
|
||||||
|
|
||||||
|
* disable suspense by default, add suspense hooks ([#940](https://github.com/open-feature/js-sdk/issues/940)) ([6bcef89](https://github.com/open-feature/js-sdk/commit/6bcef8977d0134c131af259dc0190a296e790382))
|
||||||
|
* set context during provider init on web ([#919](https://github.com/open-feature/js-sdk/issues/919)) ([7e6c1c6](https://github.com/open-feature/js-sdk/commit/7e6c1c6e7082e75535bf81b4e70c8c57ef870b77))
|
||||||
|
|
||||||
|
## [0.3.4](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.3.3...react-sdk-v0.3.4) (2024-05-01)
|
||||||
|
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
* delayed suspense causes "flicker" ([#921](https://github.com/open-feature/js-sdk/issues/921)) ([4bce2a0](https://github.com/open-feature/js-sdk/commit/4bce2a0f1a5a716160b8862f1882d24c97688288))
|
||||||
|
|
||||||
|
## [0.3.3](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.3.2...react-sdk-v0.3.3) (2024-04-23)
|
||||||
|
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
* invocation hooks not called ([#916](https://github.com/open-feature/js-sdk/issues/916)) ([2f77738](https://github.com/open-feature/js-sdk/commit/2f7773809007733d1ccaeeaa58b1799d6c1731b4))
|
||||||
|
|
||||||
|
## [0.3.2](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.3.2-experimental...react-sdk-v0.3.2) (2024-04-18)
|
||||||
|
|
||||||
|
|
||||||
|
### 🧹 Chore
|
||||||
|
|
||||||
|
* remove pre-release, update readme ([#908](https://github.com/open-feature/js-sdk/issues/908)) ([2532379](https://github.com/open-feature/js-sdk/commit/2532379f2ee5c38090a3e2c671edb2a6ca026bd5))
|
||||||
|
|
||||||
|
## [0.3.2-experimental](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.3.1-experimental...react-sdk-v0.3.2-experimental) (2024-04-11)
|
||||||
|
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
* re-render w/ useWhenProviderReady, add tests ([#901](https://github.com/open-feature/js-sdk/issues/901)) ([0f2094e](https://github.com/open-feature/js-sdk/commit/0f2094e2360ffed58a6103c00e5ba0ade6ac50eb))
|
||||||
|
|
||||||
|
## [0.3.1-experimental](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.3.0-experimental...react-sdk-v0.3.1-experimental) (2024-04-09)
|
||||||
|
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
* default options (re-renders not firing by default) ([#905](https://github.com/open-feature/js-sdk/issues/905)) ([a85e723](https://github.com/open-feature/js-sdk/commit/a85e72333fab85b3fcad87542c11fbed85ca9d85))
|
||||||
|
|
||||||
|
## [0.3.0-experimental](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.2.4-experimental...react-sdk-v0.3.0-experimental) (2024-04-08)
|
||||||
|
|
||||||
|
|
||||||
|
### ⚠ BREAKING CHANGES
|
||||||
|
|
||||||
|
* options inheritance, useWhenProviderReady, suspend by default ([#900](https://github.com/open-feature/js-sdk/issues/900))
|
||||||
|
|
||||||
|
### ✨ New Features
|
||||||
|
|
||||||
|
* options inheritance, useWhenProviderReady, suspend by default ([#900](https://github.com/open-feature/js-sdk/issues/900)) ([539e741](https://github.com/open-feature/js-sdk/commit/539e7415de8dae333fed72ae80590021d9600830))
|
||||||
|
|
||||||
|
## [0.2.4-experimental](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.2.3-experimental...react-sdk-v0.2.4-experimental) (2024-04-03)
|
||||||
|
|
||||||
|
|
||||||
|
### ✨ New Features
|
||||||
|
|
||||||
|
* query-style, generic useFlag hook ([#897](https://github.com/open-feature/js-sdk/issues/897)) ([5c17b8d](https://github.com/open-feature/js-sdk/commit/5c17b8dfcffd2f0145e5b2c79fa9dff842bbac92))
|
||||||
|
|
||||||
|
|
||||||
|
### 🔄 Refactoring
|
||||||
|
|
||||||
|
* dir restructure ([#894](https://github.com/open-feature/js-sdk/issues/894)) ([ce9f65c](https://github.com/open-feature/js-sdk/commit/ce9f65c6ec41867f67c528997cf3acef367f9260))
|
||||||
|
|
||||||
|
## [0.2.3-experimental](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.2.2-experimental...react-sdk-v0.2.3-experimental) (2024-03-25)
|
||||||
|
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
* make domain/client optional ([#884](https://github.com/open-feature/js-sdk/issues/884)) ([2b633b5](https://github.com/open-feature/js-sdk/commit/2b633b56778dde9a8955f19ca207fa0e8dced884))
|
||||||
|
|
||||||
|
|
||||||
|
### 🧹 Chore
|
||||||
|
|
||||||
|
* prompt web-sdk 1.0 ([#871](https://github.com/open-feature/js-sdk/issues/871)) ([7d50d93](https://github.com/open-feature/js-sdk/commit/7d50d931d5cda349a31969c997e7581ea4883b6a))
|
||||||
|
|
||||||
|
|
||||||
|
### 📚 Documentation
|
||||||
|
|
||||||
|
* fix invalid link fragment ([9d63803](https://github.com/open-feature/js-sdk/commit/9d638038c0062704dc701bfbba3004e89ed59e3e))
|
||||||
|
* remove emojis from react readme ([9e0e368](https://github.com/open-feature/js-sdk/commit/9e0e368d2328de2c7a4a5d91068aa75ecd70f8ed))
|
||||||
|
|
||||||
|
## [0.2.2-experimental](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.2.1-experimental...react-sdk-v0.2.2-experimental) (2024-03-06)
|
||||||
|
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
* **types:** conflicts with peer types ([#852](https://github.com/open-feature/js-sdk/issues/852)) ([fdc8576](https://github.com/open-feature/js-sdk/commit/fdc8576f472253604e26c36e10c0d315f71dbe1c))
|
||||||
|
|
||||||
|
## [0.2.1-experimental](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.2.0-experimental...react-sdk-v0.2.1-experimental) (2024-03-05)
|
||||||
|
|
||||||
|
|
||||||
|
### ✨ New Features
|
||||||
|
|
||||||
|
* maintain state in SDK, add RECONCILING ([#795](https://github.com/open-feature/js-sdk/issues/795)) ([cfb0a69](https://github.com/open-feature/js-sdk/commit/cfb0a69c42bd06bf59a7b8761fd90739872a8aeb))
|
||||||
|
* suspend on RECONCILING, mem provider fixes ([#796](https://github.com/open-feature/js-sdk/issues/796)) ([8101ff1](https://github.com/open-feature/js-sdk/commit/8101ff197ff97808d14114e56aae27023f9b09f6))
|
||||||
|
|
||||||
## [0.2.0-experimental](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.1.1-experimental...react-sdk-v0.2.0-experimental) (2024-02-27)
|
## [0.2.0-experimental](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.1.1-experimental...react-sdk-v0.2.0-experimental) (2024-02-27)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -12,12 +12,12 @@
|
||||||
<!-- x-hide-in-docs-end -->
|
<!-- x-hide-in-docs-end -->
|
||||||
<!-- The 'github-badges' class is used in the docs -->
|
<!-- The 'github-badges' class is used in the docs -->
|
||||||
<p align="center" class="github-badges">
|
<p align="center" class="github-badges">
|
||||||
<a href="https://github.com/open-feature/spec/releases/tag/v0.7.0">
|
<a href="https://github.com/open-feature/spec/releases/tag/v0.8.0">
|
||||||
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.7.0&color=yellow&style=for-the-badge" />
|
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
|
||||||
</a>
|
</a>
|
||||||
<!-- x-release-please-start-version -->
|
<!-- x-release-please-start-version -->
|
||||||
<a href="https://github.com/open-feature/js-sdk/releases/tag/react-sdk-v0.2.0-experimental">
|
<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=v0.2.0-experimental&color=blue&style=for-the-badge" />
|
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v1.0.1&color=blue&style=for-the-badge" />
|
||||||
</a>
|
</a>
|
||||||
<!-- x-release-please-end -->
|
<!-- x-release-please-end -->
|
||||||
<br/>
|
<br/>
|
||||||
|
@ -34,24 +34,36 @@
|
||||||
|
|
||||||
<!-- x-hide-in-docs-end -->
|
<!-- x-hide-in-docs-end -->
|
||||||
|
|
||||||
🧪 This SDK is experimental.
|
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The OpenFeature React SDK adds React-specific functionality to the [OpenFeature Web SDK](https://openfeature.dev/docs/reference/technologies/client/web).
|
The OpenFeature React SDK adds React-specific functionality to the [OpenFeature Web SDK](https://openfeature.dev/docs/reference/technologies/client/web).
|
||||||
|
|
||||||
In addition to the feature provided by the [web sdk](https://openfeature.dev/docs/reference/technologies/client/web), capabilities include:
|
In addition to the feature provided by the [web sdk](https://openfeature.dev/docs/reference/technologies/client/web), capabilities include:
|
||||||
|
|
||||||
- [Multiple Providers and domains](#multiple-providers-and-domains)
|
- [Overview](#overview)
|
||||||
- [Re-rendering with Context Changes](#re-rendering-with-context-changes)
|
- [Quick start](#quick-start)
|
||||||
- [Re-rendering with Flag Configuration Changes](#re-rendering-with-flag-configuration-changes)
|
- [Requirements](#requirements)
|
||||||
- [Suspense Support](#suspense-support)
|
- [Install](#install)
|
||||||
|
- [npm](#npm)
|
||||||
|
- [yarn](#yarn)
|
||||||
|
- [Required peer dependencies](#required-peer-dependencies)
|
||||||
|
- [Usage](#usage)
|
||||||
|
- [OpenFeatureProvider context provider](#openfeatureprovider-context-provider)
|
||||||
|
- [Evaluation hooks](#evaluation-hooks)
|
||||||
|
- [Multiple Providers and Domains](#multiple-providers-and-domains)
|
||||||
|
- [Re-rendering with Context Changes](#re-rendering-with-context-changes)
|
||||||
|
- [Re-rendering with Flag Configuration Changes](#re-rendering-with-flag-configuration-changes)
|
||||||
|
- [Suspense Support](#suspense-support)
|
||||||
|
- [Tracking](#tracking)
|
||||||
|
- [Testing](#testing)
|
||||||
|
- [FAQ and troubleshooting](#faq-and-troubleshooting)
|
||||||
|
- [Resources](#resources)
|
||||||
|
|
||||||
## 🚀 Quick start
|
## Quick start
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
- ES2022-compatible web browser (Chrome, Edge, Firefox, etc)
|
- ES2015-compatible web browser (Chrome, Edge, Firefox, etc)
|
||||||
- React version 16.8+
|
- React version 16.8+
|
||||||
|
|
||||||
### Install
|
### Install
|
||||||
|
@ -62,19 +74,31 @@ In addition to the feature provided by the [web sdk](https://openfeature.dev/doc
|
||||||
npm install --save @openfeature/react-sdk
|
npm install --save @openfeature/react-sdk
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### yarn
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# yarn requires manual installation of the peer dependencies (see below)
|
||||||
|
yarn add @openfeature/react-sdk @openfeature/web-sdk @openfeature/core
|
||||||
|
```
|
||||||
|
|
||||||
#### Required peer dependencies
|
#### Required peer dependencies
|
||||||
|
|
||||||
The following list contains the peer dependencies of `@openfeature/react-sdk` with its expected and compatible versions:
|
The following list contains the peer dependencies of `@openfeature/react-sdk`.
|
||||||
|
See the [package.json](./package.json) for the required versions.
|
||||||
|
|
||||||
* `@openfeature/web-sdk`: >=0.4.10
|
* `@openfeature/web-sdk`
|
||||||
* `react`: >=16.8.0
|
* `react`
|
||||||
|
|
||||||
### Usage
|
### Usage
|
||||||
|
|
||||||
|
#### OpenFeatureProvider context provider
|
||||||
|
|
||||||
|
The `OpenFeatureProvider` is a [React context provider](https://react.dev/reference/react/createContext#provider) which represents a scope for feature flag evaluations within a React application.
|
||||||
|
It binds an OpenFeature client to all evaluations within child components, and allows the use of evaluation hooks.
|
||||||
The example below shows how to use the `OpenFeatureProvider` with OpenFeature's `InMemoryProvider`.
|
The example below shows how to use the `OpenFeatureProvider` with OpenFeature's `InMemoryProvider`.
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { EvaluationContext, OpenFeatureProvider, useBooleanFlagValue, useBooleanFlagDetails, OpenFeature, InMemoryProvider } from '@openfeature/react-sdk';
|
import { EvaluationContext, OpenFeatureProvider, useFlag, OpenFeature, InMemoryProvider } from '@openfeature/react-sdk';
|
||||||
|
|
||||||
const flagConfig = {
|
const flagConfig = {
|
||||||
'new-message': {
|
'new-message': {
|
||||||
|
@ -93,8 +117,11 @@ const flagConfig = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Instantiate and set our provider (be sure this only happens once)!
|
||||||
|
// Note: there's no need to await its initialization, the React SDK handles re-rendering and suspense for you!
|
||||||
OpenFeature.setProvider(new InMemoryProvider(flagConfig));
|
OpenFeature.setProvider(new InMemoryProvider(flagConfig));
|
||||||
|
|
||||||
|
// Enclose your content in the configured provider
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<OpenFeatureProvider>
|
<OpenFeatureProvider>
|
||||||
|
@ -102,26 +129,39 @@ function App() {
|
||||||
</OpenFeatureProvider>
|
</OpenFeatureProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Evaluation hooks
|
||||||
|
|
||||||
|
Within the provider, you can use the various evaluation hooks to evaluate flags.
|
||||||
|
|
||||||
|
```tsx
|
||||||
function Page() {
|
function Page() {
|
||||||
const newMessage = useBooleanFlagValue('new-message', false);
|
// Use the "query-style" flag evaluation hook, specifying a flag-key and a default value.
|
||||||
|
const { value: showNewMessage } = useFlag('new-message', true);
|
||||||
return (
|
return (
|
||||||
<div className="App">
|
<div className="App">
|
||||||
<header className="App-header">
|
<header className="App-header">
|
||||||
{newMessage ? <p>Welcome to this OpenFeature-enabled React app!</p> : <p>Welcome to this React app.</p>}
|
{showNewMessage ? <p>Welcome to this OpenFeature-enabled React app!</p> : <p>Welcome to this React app.</p>}
|
||||||
</header>
|
</header>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
|
||||||
```
|
```
|
||||||
|
|
||||||
You use the detailed flag evaluation hooks to evaluate the flag and get additional information about the flag and the evaluation.
|
You can use the strongly typed flag value and flag evaluation detail hooks as well if you prefer.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useBooleanFlagValue } from '@openfeature/react-sdk';
|
||||||
|
|
||||||
|
// boolean flag evaluation
|
||||||
|
const value = useBooleanFlagValue('new-message', false);
|
||||||
|
```
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { useBooleanFlagDetails } from '@openfeature/react-sdk';
|
import { useBooleanFlagDetails } from '@openfeature/react-sdk';
|
||||||
|
|
||||||
|
// "detailed" boolean flag evaluation
|
||||||
const {
|
const {
|
||||||
value,
|
value,
|
||||||
variant,
|
variant,
|
||||||
|
@ -130,13 +170,12 @@ const {
|
||||||
} = useBooleanFlagDetails('new-message', false);
|
} = useBooleanFlagDetails('new-message', false);
|
||||||
```
|
```
|
||||||
|
|
||||||
### Multiple Providers and Domains
|
#### Multiple Providers and Domains
|
||||||
|
|
||||||
|
|
||||||
Multiple providers can be used by passing a `domain` to the `OpenFeatureProvider`:
|
Multiple providers can be used by passing a `domain` to the `OpenFeatureProvider`:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// Flags within this domain will use the a client/provider associated with `my-domain`,
|
// Flags within this domain will use the client/provider associated with `my-domain`,
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<OpenFeatureProvider domain={'my-domain'}>
|
<OpenFeatureProvider domain={'my-domain'}>
|
||||||
|
@ -152,47 +191,63 @@ This is analogous to:
|
||||||
OpenFeature.getClient('my-domain');
|
OpenFeature.getClient('my-domain');
|
||||||
```
|
```
|
||||||
|
|
||||||
For more information about `domains`, refer to the [web SDK](https://github.com/open-feature/js-sdk/blob/main/packages/client/README.md).
|
For more information about `domains`, refer to the [web SDK](https://github.com/open-feature/js-sdk/blob/main/packages/web/README.md).
|
||||||
|
|
||||||
### Re-rendering with Context Changes
|
#### Re-rendering with Context Changes
|
||||||
|
|
||||||
By default, if the OpenFeature [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context) is modified, components will be re-rendered.
|
By default, if the OpenFeature [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context) is modified, components will be re-rendered.
|
||||||
This is useful in cases where flag values are dependant on user-attributes or other application state (user logged in, items in card, etc).
|
This is useful in cases where flag values are dependant on user-attributes or other application state (user logged in, items in card, etc).
|
||||||
You can disable this feature in the hook options:
|
You can disable this feature in the hook options (or in the [OpenFeatureProvider](#openfeatureprovider-context-provider)):
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
function Page() {
|
function Page() {
|
||||||
const newMessage = useBooleanFlagValue('new-message', false, { updateOnContextChanged: false });
|
const { value: showNewMessage } = useFlag('new-message', false, { updateOnContextChanged: false });
|
||||||
return (
|
return (
|
||||||
<MyComponents></MyComponents>
|
<div className="App">
|
||||||
)
|
<header className="App-header">
|
||||||
|
{showNewMessage ? <p>Welcome to this OpenFeature-enabled React app!</p> : <p>Welcome to this React app.</p>}
|
||||||
|
</header>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
For more information about how evaluation context works in the React SDK, see the documentation on OpenFeature's [static context SDK paradigm](https://openfeature.dev/specification/glossary/#static-context-paradigm).
|
For more information about how evaluation context works in the React SDK, see the documentation on OpenFeature's [static context SDK paradigm](https://openfeature.dev/specification/glossary/#static-context-paradigm).
|
||||||
|
|
||||||
### Re-rendering with Flag Configuration Changes
|
#### Re-rendering with Flag Configuration Changes
|
||||||
|
|
||||||
By default, if the underlying provider emits a `ConfigurationChanged` event, components will be re-rendered.
|
By default, if the underlying provider emits a `ConfigurationChanged` event, components will be re-rendered.
|
||||||
This is useful if you want your UI to immediately reflect changes in the backend flag configuration.
|
This is useful if you want your UI to immediately reflect changes in the backend flag configuration.
|
||||||
You can disable this feature in the hook options:
|
You can disable this feature in the hook options (or in the [OpenFeatureProvider](#openfeatureprovider-context-provider)):
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
function Page() {
|
function Page() {
|
||||||
const newMessage = useBooleanFlagValue('new-message', false, { updateOnConfigurationChanged: false });
|
const { value: showNewMessage } = useFlag('new-message', false, { updateOnConfigurationChanged: false });
|
||||||
return (
|
return (
|
||||||
<MyComponents></MyComponents>
|
<div className="App">
|
||||||
)
|
<header className="App-header">
|
||||||
|
{showNewMessage ? <p>Welcome to this OpenFeature-enabled React app!</p> : <p>Welcome to this React app.</p>}
|
||||||
|
</header>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Note that if your provider doesn't support updates, this configuration has no impact.
|
If your provider doesn't support updates, this configuration has no impact.
|
||||||
|
|
||||||
### Suspense Support
|
> [!NOTE]
|
||||||
|
> If your provider includes a list of [flags changed](https://open-feature.github.io/js-sdk/types/_openfeature_server_sdk.ConfigChangeEvent.html) in its `PROVIDER_CONFIGURATION_CHANGED` event, that list of flags is used to decide which flag evaluation hooks should re-run by diffing the latest value of these flags with the previous render.
|
||||||
|
> If your provider event does not the include the `flags changed` list, then the SDK diffs all flags with the previous render to determine which hooks should re-run.
|
||||||
|
|
||||||
|
#### Suspense Support
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> React suspense is an experimental feature and is subject to change in future versions.
|
||||||
|
|
||||||
Frequently, providers need to perform some initial startup tasks.
|
Frequently, providers need to perform some initial startup tasks.
|
||||||
It may be desireable not to display components with feature flags until this is complete.
|
It may be desirable not to display components with feature flags until this is complete or when the context changes.
|
||||||
Built-in [suspense](https://react.dev/reference/react/Suspense) support makes this easy:
|
Built-in [suspense](https://react.dev/reference/react/Suspense) support makes this easy.
|
||||||
|
Use `useSuspenseFlag` or pass `{ suspend: true }` in the hook options to leverage this functionality.
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
function Content() {
|
function Content() {
|
||||||
|
@ -205,12 +260,12 @@ function Content() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function Message() {
|
function Message() {
|
||||||
// component to render after READY.
|
// component to render after READY, equivalent to useFlag('new-message', false, { suspend: true });
|
||||||
const newMessage = useBooleanFlagValue('new-message', false);
|
const { value: showNewMessage } = useSuspenseFlag('new-message', false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{newMessage ? (
|
{showNewMessage ? (
|
||||||
<p>Welcome to this OpenFeature-enabled React app!</p>
|
<p>Welcome to this OpenFeature-enabled React app!</p>
|
||||||
) : (
|
) : (
|
||||||
<p>Welcome to this plain old React app!</p>
|
<p>Welcome to this plain old React app!</p>
|
||||||
|
@ -225,6 +280,123 @@ function Fallback() {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
This can be disabled in the hook options (or in the [OpenFeatureProvider](#openfeatureprovider-context-provider)).
|
||||||
|
|
||||||
|
#### Tracking
|
||||||
|
|
||||||
|
The tracking API allows you to use OpenFeature abstractions and objects to associate user actions with feature flag evaluations.
|
||||||
|
This is essential for robust experimentation powered by feature flags.
|
||||||
|
For example, a flag enhancing the appearance of a UI component might drive user engagement to a new feature; to test this hypothesis, telemetry collected by a [hook](https://openfeature.dev/docs/reference/technologies/client/web/#hooks) or [provider](https://openfeature.dev/docs/reference/technologies/client/web/#providers) can be associated with telemetry reported in the client's `track` function.
|
||||||
|
|
||||||
|
The React SDK includes a hook for firing tracking events in the `<OpenFeatureProvider>` context in use:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function MyComponent() {
|
||||||
|
// get a tracking function for this <OpenFeatureProvider>.
|
||||||
|
const { track } = useTrack();
|
||||||
|
|
||||||
|
// call the tracking event
|
||||||
|
// can be done in render, useEffect, or in handlers, depending on your use case
|
||||||
|
track(eventName, trackingDetails);
|
||||||
|
|
||||||
|
return <>...</>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
The React SDK includes a built-in context provider for testing.
|
||||||
|
This allows you to easily test components that use evaluation hooks, such as `useFlag`.
|
||||||
|
If you try to test a component (in this case, `MyComponent`) which uses an evaluation hook, you might see an error message like:
|
||||||
|
|
||||||
|
> No OpenFeature client available - components using OpenFeature must be wrapped with an `<OpenFeatureProvider>`.
|
||||||
|
|
||||||
|
You can resolve this by simply wrapping your component under test in the OpenFeatureTestProvider:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// use default values for all evaluations
|
||||||
|
<OpenFeatureTestProvider>
|
||||||
|
<MyComponent />
|
||||||
|
</OpenFeatureTestProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
The basic configuration above will simply use the default value provided in code.
|
||||||
|
If you'd like to control the values returned by the evaluation hooks, you can pass a map of flag keys and values:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// return `true` for all evaluations of `'my-boolean-flag'`
|
||||||
|
<OpenFeatureTestProvider flagValueMap={{ 'my-boolean-flag': true }}>
|
||||||
|
<MyComponent />
|
||||||
|
</OpenFeatureTestProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
Additionally, you can pass an artificial delay for the provider startup to test your suspense boundaries or loaders/spinners impacted by feature flags:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// delay the provider start by 1000ms and then return `true` for all evaluations of `'my-boolean-flag'`
|
||||||
|
<OpenFeatureTestProvider delayMs={1000} flagValueMap={{ 'my-boolean-flag': true }}>
|
||||||
|
<MyComponent />
|
||||||
|
</OpenFeatureTestProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
For maximum control, you can also pass your own mock provider implementation.
|
||||||
|
The type of this option is `Partial<Provider>`, so you can pass an incomplete implementation:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
class MyTestProvider implements Partial<Provider> {
|
||||||
|
// implement the relevant resolver
|
||||||
|
resolveBooleanEvaluation(): ResolutionDetails<boolean> {
|
||||||
|
return {
|
||||||
|
value: true,
|
||||||
|
variant: 'my-variant',
|
||||||
|
reason: 'MY_REASON',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// use your custom testing provider
|
||||||
|
<OpenFeatureTestProvider provider={new MyTestProvider()}>
|
||||||
|
<MyComponent />
|
||||||
|
</OpenFeatureTestProvider>,
|
||||||
|
```
|
||||||
|
|
||||||
|
## FAQ and troubleshooting
|
||||||
|
|
||||||
|
> I get an error that says something like: `A React component suspended while rendering, but no fallback UI was specified.`
|
||||||
|
|
||||||
|
The OpenFeature React SDK features built-in [suspense support](#suspense-support).
|
||||||
|
This means that it will render your loading fallback automatically while your provider starts up and during context reconciliation for any of your components using feature flags!
|
||||||
|
If you use suspense and neglect to create a suspense boundary around any components using feature flags, you will see this error.
|
||||||
|
Add a suspense boundary to resolve this issue.
|
||||||
|
Alternatively, you can disable this suspense (the default) by removing `suspendWhileReconciling=true`, `suspendUntilReady=true` or `suspend=true` in the [evaluation hooks](#evaluation-hooks) or the [OpenFeatureProvider](#openfeatureprovider-context-provider) (which applies to all evaluation hooks in child components).
|
||||||
|
|
||||||
|
> I get odd rendering issues or errors when components mount if I use the suspense features.
|
||||||
|
|
||||||
|
In React 16/17's "Legacy Suspense", when a component suspends, its sibling components initially mount and then are hidden.
|
||||||
|
This can cause surprising effects and inconsistencies if sibling components are rendered while the provider is still getting ready.
|
||||||
|
To fix this, you can upgrade to React 18, which uses "Concurrent Suspense", in which siblings are not mounted until their suspended sibling resolves.
|
||||||
|
Alternatively, if you cannot upgrade to React 18, you can use the `useWhenProviderReady` utility hook in any sibling components to prevent them from mounting until the provider is ready.
|
||||||
|
|
||||||
|
> I am using multiple `OpenFeatureProvider` contexts, but they share the same provider or evaluation context. Why?
|
||||||
|
|
||||||
|
The `OpenFeatureProvider` binds a `client` to all child components, but the provider and context associated with that client is controlled by the `domain` parameter.
|
||||||
|
This is consistent with all OpenFeature SDKs.
|
||||||
|
To scope an OpenFeatureProvider to a particular provider/context, set the `domain` parameter on your `OpenFeatureProvider`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<OpenFeatureProvider domain={'my-domain'}>
|
||||||
|
<Page></Page>
|
||||||
|
</OpenFeatureProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
> I can import things form the `@openfeature/react-sdk`, `@openfeature/web-sdk`, and `@openfeature/core`; which should I use?
|
||||||
|
|
||||||
|
The `@openfeature/react-sdk` re-exports everything from its peers (`@openfeature/web-sdk` and `@openfeature/core`) and adds the React-specific features.
|
||||||
|
You can import everything from the `@openfeature/react-sdk` directly.
|
||||||
|
Avoid importing anything from `@openfeature/web-sdk` or `@openfeature/core`.
|
||||||
|
|
||||||
## Resources
|
## Resources
|
||||||
|
|
||||||
- [Example repo](https://github.com/open-feature/react-test-app)
|
- [Example repo](https://github.com/open-feature/react-test-app)
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@openfeature/react-sdk",
|
"name": "@openfeature/react-sdk",
|
||||||
"version": "0.2.0-experimental",
|
"version": "1.0.1",
|
||||||
"description": "OpenFeature React SDK",
|
"description": "OpenFeature React SDK",
|
||||||
"main": "./dist/cjs/index.js",
|
"main": "./dist/cjs/index.js",
|
||||||
"files": [
|
"files": [
|
||||||
|
@ -16,9 +16,10 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "jest --verbose",
|
"test": "jest --verbose",
|
||||||
"lint": "eslint ./",
|
"lint": "eslint ./",
|
||||||
|
"lint:fix": "eslint ./ --fix",
|
||||||
"clean": "shx rm -rf ./dist",
|
"clean": "shx rm -rf ./dist",
|
||||||
"build:react-esm": "esbuild src/index.ts --bundle --external:react --external:@openfeature/web-sdk --sourcemap --target=es2022 --platform=browser --format=esm --outfile=./dist/esm/index.js --analyze",
|
"build:react-esm": "esbuild src/index.ts --bundle --external:react --external:@openfeature/web-sdk --sourcemap --target=es2015 --platform=browser --format=esm --outfile=./dist/esm/index.js --analyze",
|
||||||
"build:react-cjs": "esbuild src/index.ts --bundle --external:react --external:@openfeature/web-sdk --sourcemap --target=es2022 --platform=browser --format=cjs --outfile=./dist/cjs/index.js --analyze",
|
"build:react-cjs": "esbuild src/index.ts --bundle --external:react --external:@openfeature/web-sdk --sourcemap --target=es2015 --platform=browser --format=cjs --outfile=./dist/cjs/index.js --analyze",
|
||||||
"build:rollup-types": "rollup -c ../../rollup.config.mjs",
|
"build:rollup-types": "rollup -c ../../rollup.config.mjs",
|
||||||
"build": "npm run clean && npm run build:react-esm && npm run build:react-cjs && npm run build:rollup-types",
|
"build": "npm run clean && npm run build:react-esm && npm run build:react-cjs && npm run build:rollup-types",
|
||||||
"postbuild": "shx cp ./../../package.esm.json ./dist/esm/package.json",
|
"postbuild": "shx cp ./../../package.esm.json ./dist/esm/package.json",
|
||||||
|
@ -46,7 +47,7 @@
|
||||||
},
|
},
|
||||||
"homepage": "https://github.com/open-feature/js-sdk#readme",
|
"homepage": "https://github.com/open-feature/js-sdk#readme",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@openfeature/web-sdk": ">=0.4.14",
|
"@openfeature/web-sdk": "^1.5.0",
|
||||||
"react": ">=16.8.0"
|
"react": ">=16.8.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './use-context-mutator';
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { useCallback, useContext, useRef } from 'react';
|
||||||
|
import type { EvaluationContext } from '@openfeature/web-sdk';
|
||||||
|
import { OpenFeature } from '@openfeature/web-sdk';
|
||||||
|
import { Context } from '../internal';
|
||||||
|
|
||||||
|
export type ContextMutationOptions = {
|
||||||
|
/**
|
||||||
|
* Mutate the default context instead of the domain scoped context applied at the `<OpenFeatureProvider/>`.
|
||||||
|
* Note, if the `<OpenFeatureProvider/>` has no domain specified, the default is used.
|
||||||
|
* See the {@link https://openfeature.dev/docs/reference/technologies/client/web/#manage-evaluation-context-for-domains|documentation} for more information.
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
defaultContext?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ContextMutation = {
|
||||||
|
/**
|
||||||
|
* Context-aware function to set the desired context (see: {@link ContextMutationOptions} for details).
|
||||||
|
* There's generally no need to await the result of this function; flag evaluation hooks will re-render when the context is updated.
|
||||||
|
* This promise never rejects.
|
||||||
|
* @param updatedContext
|
||||||
|
* @returns Promise for awaiting the context update
|
||||||
|
*/
|
||||||
|
setContext: (updatedContext: EvaluationContext) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get context-aware tracking function(s) for mutating the evaluation context associated with this domain, or the default context if `defaultContext: true`.
|
||||||
|
* See the {@link https://openfeature.dev/docs/reference/technologies/client/web/#targeting-and-context|documentation} for more information.
|
||||||
|
* @param {ContextMutationOptions} options options for the generated function
|
||||||
|
* @returns {ContextMutation} context-aware function(s) to mutate evaluation context
|
||||||
|
*/
|
||||||
|
export function useContextMutator(options: ContextMutationOptions = { defaultContext: false }): ContextMutation {
|
||||||
|
const { domain } = useContext(Context) || {};
|
||||||
|
const previousContext = useRef<null | EvaluationContext>(null);
|
||||||
|
|
||||||
|
const setContext = useCallback(async (updatedContext: EvaluationContext) => {
|
||||||
|
if (previousContext.current !== updatedContext) {
|
||||||
|
if (!domain || options?.defaultContext) {
|
||||||
|
OpenFeature.setContext(updatedContext);
|
||||||
|
} else {
|
||||||
|
OpenFeature.setContext(domain, updatedContext);
|
||||||
|
}
|
||||||
|
previousContext.current = updatedContext;
|
||||||
|
}
|
||||||
|
}, [domain]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
setContext,
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './use-feature-flag';
|
|
@ -0,0 +1,377 @@
|
||||||
|
import type {
|
||||||
|
Client,
|
||||||
|
ClientProviderEvents,
|
||||||
|
EvaluationDetails,
|
||||||
|
EventHandler,
|
||||||
|
FlagEvaluationOptions,
|
||||||
|
FlagValue,
|
||||||
|
JsonValue,
|
||||||
|
} from '@openfeature/web-sdk';
|
||||||
|
import { ProviderEvents, ProviderStatus } from '@openfeature/web-sdk';
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
DEFAULT_OPTIONS,
|
||||||
|
isEqual,
|
||||||
|
normalizeOptions,
|
||||||
|
suspendUntilInitialized,
|
||||||
|
suspendUntilReconciled,
|
||||||
|
useProviderOptions,
|
||||||
|
} from '../internal';
|
||||||
|
import type { ReactFlagEvaluationNoSuspenseOptions, ReactFlagEvaluationOptions } from '../options';
|
||||||
|
import { useOpenFeatureClient } from '../provider/use-open-feature-client';
|
||||||
|
import { useOpenFeatureClientStatus } from '../provider/use-open-feature-client-status';
|
||||||
|
import { useOpenFeatureProvider } from '../provider/use-open-feature-provider';
|
||||||
|
import type { FlagQuery } from '../query';
|
||||||
|
import { HookFlagQuery } from '../internal/hook-flag-query';
|
||||||
|
|
||||||
|
// This type is a bit wild-looking, but I think we need it.
|
||||||
|
// We have to use the conditional, because otherwise useFlag('key', false) would return false, not boolean (too constrained).
|
||||||
|
// We have a duplicate for the hook return below, this one is just used for casting because the name isn't as clear
|
||||||
|
type ConstrainedFlagQuery<T> = FlagQuery<
|
||||||
|
T extends boolean
|
||||||
|
? boolean
|
||||||
|
: T extends number
|
||||||
|
? number
|
||||||
|
: T extends string
|
||||||
|
? string
|
||||||
|
: T extends JsonValue
|
||||||
|
? T
|
||||||
|
: JsonValue
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates a feature flag generically, returning an react-flavored queryable object.
|
||||||
|
* The resolver method to use is based on the type of the defaultValue.
|
||||||
|
* For type-specific hooks, use {@link useBooleanFlagValue}, {@link useBooleanFlagDetails} and equivalents.
|
||||||
|
* By default, components will re-render when the flag value changes.
|
||||||
|
* @param {string} flagKey the flag identifier
|
||||||
|
* @template {FlagValue} T A optional generic argument constraining the default.
|
||||||
|
* @param {T} defaultValue the default value; used to determine what resolved type should be used.
|
||||||
|
* @param {ReactFlagEvaluationOptions} options for this evaluation
|
||||||
|
* @returns { FlagQuery } a queryable object containing useful information about the flag.
|
||||||
|
*/
|
||||||
|
export function useFlag<T extends FlagValue = FlagValue>(
|
||||||
|
flagKey: string,
|
||||||
|
defaultValue: T,
|
||||||
|
options?: ReactFlagEvaluationOptions,
|
||||||
|
): FlagQuery<
|
||||||
|
T extends boolean
|
||||||
|
? boolean
|
||||||
|
: T extends number
|
||||||
|
? number
|
||||||
|
: T extends string
|
||||||
|
? string
|
||||||
|
: T extends JsonValue
|
||||||
|
? T
|
||||||
|
: JsonValue
|
||||||
|
> {
|
||||||
|
// use the default value to determine the resolver to call
|
||||||
|
const query =
|
||||||
|
typeof defaultValue === 'boolean'
|
||||||
|
? new HookFlagQuery<boolean>(useBooleanFlagDetails(flagKey, defaultValue, options))
|
||||||
|
: typeof defaultValue === 'number'
|
||||||
|
? new HookFlagQuery<number>(useNumberFlagDetails(flagKey, defaultValue, options))
|
||||||
|
: typeof defaultValue === 'string'
|
||||||
|
? new HookFlagQuery<string>(useStringFlagDetails(flagKey, defaultValue, options))
|
||||||
|
: new HookFlagQuery<JsonValue>(useObjectFlagDetails(flagKey, defaultValue, options));
|
||||||
|
// TS sees this as HookFlagQuery<JsonValue>, because the compiler isn't aware of the `typeof` checks above.
|
||||||
|
return query as unknown as ConstrainedFlagQuery<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// alias to the return value of useFlag, used to keep useSuspenseFlag consistent
|
||||||
|
type UseFlagReturn<T extends FlagValue> = ReturnType<typeof useFlag<T>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Equivalent to {@link useFlag} with `options: { suspend: true }`
|
||||||
|
* @experimental Suspense is an experimental feature subject to change in future versions.
|
||||||
|
* @param {string} flagKey the flag identifier
|
||||||
|
* @template {FlagValue} T A optional generic argument constraining the default.
|
||||||
|
* @param {T} defaultValue the default value; used to determine what resolved type should be used.
|
||||||
|
* @param {ReactFlagEvaluationNoSuspenseOptions} options for this evaluation
|
||||||
|
* @returns { UseFlagReturn<T> } a queryable object containing useful information about the flag.
|
||||||
|
*/
|
||||||
|
export function useSuspenseFlag<T extends FlagValue = FlagValue>(
|
||||||
|
flagKey: string,
|
||||||
|
defaultValue: T,
|
||||||
|
options?: ReactFlagEvaluationNoSuspenseOptions,
|
||||||
|
): UseFlagReturn<T> {
|
||||||
|
return useFlag(flagKey, defaultValue, { ...options, suspendUntilReady: true, suspendWhileReconciling: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates a feature flag, returning a boolean.
|
||||||
|
* By default, components will re-render when the flag value changes.
|
||||||
|
* For a generic hook returning a queryable interface, see {@link useFlag}.
|
||||||
|
* @param {string} flagKey the flag identifier
|
||||||
|
* @param {boolean} defaultValue the default value
|
||||||
|
* @param {ReactFlagEvaluationOptions} options options for this evaluation
|
||||||
|
* @returns { boolean} a EvaluationDetails object for this evaluation
|
||||||
|
*/
|
||||||
|
export function useBooleanFlagValue(
|
||||||
|
flagKey: string,
|
||||||
|
defaultValue: boolean,
|
||||||
|
options?: ReactFlagEvaluationOptions,
|
||||||
|
): boolean {
|
||||||
|
return useBooleanFlagDetails(flagKey, defaultValue, options).value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates a feature flag, returning evaluation details.
|
||||||
|
* By default, components will re-render when the flag value changes.
|
||||||
|
* For a generic hook returning a queryable interface, see {@link useFlag}.
|
||||||
|
* @param {string} flagKey the flag identifier
|
||||||
|
* @param {boolean} defaultValue the default value
|
||||||
|
* @param {ReactFlagEvaluationOptions} options options for this evaluation
|
||||||
|
* @returns { EvaluationDetails<boolean>} a EvaluationDetails object for this evaluation
|
||||||
|
*/
|
||||||
|
export function useBooleanFlagDetails(
|
||||||
|
flagKey: string,
|
||||||
|
defaultValue: boolean,
|
||||||
|
options?: ReactFlagEvaluationOptions,
|
||||||
|
): EvaluationDetails<boolean> {
|
||||||
|
return attachHandlersAndResolve(
|
||||||
|
flagKey,
|
||||||
|
defaultValue,
|
||||||
|
(client) => {
|
||||||
|
return client.getBooleanDetails;
|
||||||
|
},
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates a feature flag, returning a string.
|
||||||
|
* By default, components will re-render when the flag value changes.
|
||||||
|
* For a generic hook returning a queryable interface, see {@link useFlag}.
|
||||||
|
* @param {string} flagKey the flag identifier
|
||||||
|
* @template {string} [T=string] A optional generic argument constraining the string
|
||||||
|
* @param {T} defaultValue the default value
|
||||||
|
* @param {ReactFlagEvaluationOptions} options options for this evaluation
|
||||||
|
* @returns { boolean} a EvaluationDetails object for this evaluation
|
||||||
|
*/
|
||||||
|
export function useStringFlagValue<T extends string = string>(
|
||||||
|
flagKey: string,
|
||||||
|
defaultValue: T,
|
||||||
|
options?: ReactFlagEvaluationOptions,
|
||||||
|
): string {
|
||||||
|
return useStringFlagDetails(flagKey, defaultValue, options).value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates a feature flag, returning evaluation details.
|
||||||
|
* By default, components will re-render when the flag value changes.
|
||||||
|
* For a generic hook returning a queryable interface, see {@link useFlag}.
|
||||||
|
* @param {string} flagKey the flag identifier
|
||||||
|
* @template {string} [T=string] A optional generic argument constraining the string
|
||||||
|
* @param {T} defaultValue the default value
|
||||||
|
* @param {ReactFlagEvaluationOptions} options options for this evaluation
|
||||||
|
* @returns { EvaluationDetails<string>} a EvaluationDetails object for this evaluation
|
||||||
|
*/
|
||||||
|
export function useStringFlagDetails<T extends string = string>(
|
||||||
|
flagKey: string,
|
||||||
|
defaultValue: T,
|
||||||
|
options?: ReactFlagEvaluationOptions,
|
||||||
|
): EvaluationDetails<string> {
|
||||||
|
return attachHandlersAndResolve(
|
||||||
|
flagKey,
|
||||||
|
defaultValue,
|
||||||
|
(client) => {
|
||||||
|
return client.getStringDetails<T>;
|
||||||
|
},
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates a feature flag, returning a number.
|
||||||
|
* By default, components will re-render when the flag value changes.
|
||||||
|
* For a generic hook returning a queryable interface, see {@link useFlag}.
|
||||||
|
* @param {string} flagKey the flag identifier
|
||||||
|
* @template {number} [T=number] A optional generic argument constraining the number
|
||||||
|
* @param {T} defaultValue the default value
|
||||||
|
* @param {ReactFlagEvaluationOptions} options options for this evaluation
|
||||||
|
* @returns { boolean} a EvaluationDetails object for this evaluation
|
||||||
|
*/
|
||||||
|
export function useNumberFlagValue<T extends number = number>(
|
||||||
|
flagKey: string,
|
||||||
|
defaultValue: T,
|
||||||
|
options?: ReactFlagEvaluationOptions,
|
||||||
|
): number {
|
||||||
|
return useNumberFlagDetails(flagKey, defaultValue, options).value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates a feature flag, returning evaluation details.
|
||||||
|
* By default, components will re-render when the flag value changes.
|
||||||
|
* For a generic hook returning a queryable interface, see {@link useFlag}.
|
||||||
|
* @param {string} flagKey the flag identifier
|
||||||
|
* @template {number} [T=number] A optional generic argument constraining the number
|
||||||
|
* @param {T} defaultValue the default value
|
||||||
|
* @param {ReactFlagEvaluationOptions} options options for this evaluation
|
||||||
|
* @returns { EvaluationDetails<number>} a EvaluationDetails object for this evaluation
|
||||||
|
*/
|
||||||
|
export function useNumberFlagDetails<T extends number = number>(
|
||||||
|
flagKey: string,
|
||||||
|
defaultValue: T,
|
||||||
|
options?: ReactFlagEvaluationOptions,
|
||||||
|
): EvaluationDetails<number> {
|
||||||
|
return attachHandlersAndResolve(
|
||||||
|
flagKey,
|
||||||
|
defaultValue,
|
||||||
|
(client) => {
|
||||||
|
return client.getNumberDetails<T>;
|
||||||
|
},
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates a feature flag, returning an object.
|
||||||
|
* By default, components will re-render when the flag value changes.
|
||||||
|
* For a generic hook returning a queryable interface, see {@link useFlag}.
|
||||||
|
* @param {string} flagKey the flag identifier
|
||||||
|
* @template {JsonValue} [T=JsonValue] A optional generic argument describing the structure
|
||||||
|
* @param {T} defaultValue the default value
|
||||||
|
* @param {ReactFlagEvaluationOptions} options options for this evaluation
|
||||||
|
* @returns { boolean} a EvaluationDetails object for this evaluation
|
||||||
|
*/
|
||||||
|
export function useObjectFlagValue<T extends JsonValue = JsonValue>(
|
||||||
|
flagKey: string,
|
||||||
|
defaultValue: T,
|
||||||
|
options?: ReactFlagEvaluationOptions,
|
||||||
|
): T {
|
||||||
|
return useObjectFlagDetails<T>(flagKey, defaultValue, options).value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates a feature flag, returning evaluation details.
|
||||||
|
* By default, components will re-render when the flag value changes.
|
||||||
|
* For a generic hook returning a queryable interface, see {@link useFlag}.
|
||||||
|
* @param {string} flagKey the flag identifier
|
||||||
|
* @param {T} defaultValue the default value
|
||||||
|
* @template {JsonValue} [T=JsonValue] A optional generic argument describing the structure
|
||||||
|
* @param {ReactFlagEvaluationOptions} options options for this evaluation
|
||||||
|
* @returns { EvaluationDetails<T>} a EvaluationDetails object for this evaluation
|
||||||
|
*/
|
||||||
|
export function useObjectFlagDetails<T extends JsonValue = JsonValue>(
|
||||||
|
flagKey: string,
|
||||||
|
defaultValue: T,
|
||||||
|
options?: ReactFlagEvaluationOptions,
|
||||||
|
): EvaluationDetails<T> {
|
||||||
|
return attachHandlersAndResolve(
|
||||||
|
flagKey,
|
||||||
|
defaultValue,
|
||||||
|
(client) => {
|
||||||
|
return client.getObjectDetails<T>;
|
||||||
|
},
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// determines if a flag should be re-evaluated based on a list of changed flags
|
||||||
|
function shouldEvaluateFlag(flagKey: string, flagsChanged?: string[]): boolean {
|
||||||
|
// if flagsChange is missing entirely, we don't know what to re-render
|
||||||
|
return !flagsChanged || flagsChanged.includes(flagKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachHandlersAndResolve<T extends FlagValue>(
|
||||||
|
flagKey: string,
|
||||||
|
defaultValue: T,
|
||||||
|
resolver: (
|
||||||
|
client: Client,
|
||||||
|
) => (flagKey: string, defaultValue: T, options?: FlagEvaluationOptions) => EvaluationDetails<T>,
|
||||||
|
options?: ReactFlagEvaluationOptions,
|
||||||
|
): EvaluationDetails<T> {
|
||||||
|
// highest priority > evaluation hook options > provider options > default options > lowest priority
|
||||||
|
const defaultedOptions = { ...DEFAULT_OPTIONS, ...useProviderOptions(), ...normalizeOptions(options) };
|
||||||
|
const client = useOpenFeatureClient();
|
||||||
|
const status = useOpenFeatureClientStatus();
|
||||||
|
const provider = useOpenFeatureProvider();
|
||||||
|
const isFirstRender = useRef(true);
|
||||||
|
|
||||||
|
if (defaultedOptions.suspendUntilReady && status === ProviderStatus.NOT_READY) {
|
||||||
|
suspendUntilInitialized(provider, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (defaultedOptions.suspendWhileReconciling && status === ProviderStatus.RECONCILING) {
|
||||||
|
suspendUntilReconciled(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [evaluationDetails, setEvaluationDetails] = useState<EvaluationDetails<T>>(() =>
|
||||||
|
resolver(client).call(client, flagKey, defaultValue, options),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-evaluate when dependencies change (handles prop changes like flagKey), or if during a re-render, we have detected a change in the evaluated value
|
||||||
|
useEffect(() => {
|
||||||
|
if (isFirstRender.current) {
|
||||||
|
isFirstRender.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newDetails = resolver(client).call(client, flagKey, defaultValue, options);
|
||||||
|
if (!isEqual(newDetails.value, evaluationDetails.value)) {
|
||||||
|
setEvaluationDetails(newDetails);
|
||||||
|
}
|
||||||
|
}, [client, flagKey, defaultValue, options, resolver, evaluationDetails]);
|
||||||
|
|
||||||
|
// Maintain a mutable reference to the evaluation details to have a up-to-date reference in the handlers.
|
||||||
|
const evaluationDetailsRef = useRef<EvaluationDetails<T>>(evaluationDetails);
|
||||||
|
useEffect(() => {
|
||||||
|
evaluationDetailsRef.current = evaluationDetails;
|
||||||
|
}, [evaluationDetails]);
|
||||||
|
|
||||||
|
const updateEvaluationDetailsCallback = useCallback(() => {
|
||||||
|
const updatedEvaluationDetails = resolver(client).call(client, flagKey, defaultValue, options);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Avoid re-rendering if the value hasn't changed. We could expose a means
|
||||||
|
* to define a custom comparison function if users require a more
|
||||||
|
* sophisticated comparison in the future.
|
||||||
|
*/
|
||||||
|
if (!isEqual(updatedEvaluationDetails.value, evaluationDetailsRef.current.value)) {
|
||||||
|
setEvaluationDetails(updatedEvaluationDetails);
|
||||||
|
}
|
||||||
|
}, [client, flagKey, defaultValue, options, resolver]);
|
||||||
|
|
||||||
|
const configurationChangeCallback = useCallback<EventHandler<ClientProviderEvents.ConfigurationChanged>>(
|
||||||
|
(eventDetails) => {
|
||||||
|
if (shouldEvaluateFlag(flagKey, eventDetails?.flagsChanged)) {
|
||||||
|
updateEvaluationDetailsCallback();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[flagKey, updateEvaluationDetailsCallback],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
if (status === ProviderStatus.NOT_READY) {
|
||||||
|
// update when the provider is ready
|
||||||
|
client.addHandler(ProviderEvents.Ready, updateEvaluationDetailsCallback, { signal: controller.signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (defaultedOptions.updateOnContextChanged) {
|
||||||
|
// update when the context changes
|
||||||
|
client.addHandler(ProviderEvents.ContextChanged, updateEvaluationDetailsCallback, { signal: controller.signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (defaultedOptions.updateOnConfigurationChanged) {
|
||||||
|
// update when the provider configuration changes
|
||||||
|
client.addHandler(ProviderEvents.ConfigurationChanged, configurationChangeCallback, {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
// cleanup the handlers
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
client,
|
||||||
|
status,
|
||||||
|
defaultedOptions.updateOnContextChanged,
|
||||||
|
defaultedOptions.updateOnConfigurationChanged,
|
||||||
|
updateEvaluationDetailsCallback,
|
||||||
|
configurationChangeCallback,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return evaluationDetails;
|
||||||
|
}
|
|
@ -1,4 +1,8 @@
|
||||||
export * from './use-feature-flag';
|
export * from './evaluation';
|
||||||
|
export * from './query';
|
||||||
export * from './provider';
|
export * from './provider';
|
||||||
|
export * from './context';
|
||||||
|
export * from './tracking';
|
||||||
|
export * from './options';
|
||||||
// re-export the web-sdk so consumers can access that API from the react-sdk
|
// re-export the web-sdk so consumers can access that API from the react-sdk
|
||||||
export * from '@openfeature/web-sdk';
|
export * from '@openfeature/web-sdk';
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
import type { Client } from '@openfeature/web-sdk';
|
||||||
|
import React from 'react';
|
||||||
|
import type { NormalizedOptions, ReactFlagEvaluationOptions } from '../options';
|
||||||
|
import { normalizeOptions } from '.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The underlying React context.
|
||||||
|
*
|
||||||
|
* **DO NOT EXPORT PUBLICLY**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export const Context = React.createContext<
|
||||||
|
{ client: Client; domain?: string; options: ReactFlagEvaluationOptions } | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a normalized copy of the options used for this OpenFeatureProvider, see {@link normalizeOptions}.
|
||||||
|
*
|
||||||
|
* **DO NOT EXPORT PUBLICLY**
|
||||||
|
* @internal
|
||||||
|
* @returns {NormalizedOptions} normalized options the defaulted options, not defaulted or normalized.
|
||||||
|
*/
|
||||||
|
export function useProviderOptions(): NormalizedOptions {
|
||||||
|
const { options } = React.useContext(Context) || {};
|
||||||
|
return normalizeOptions(options);
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
const context = 'Components using OpenFeature must be wrapped with an <OpenFeatureProvider>.';
|
||||||
|
const tip = 'If you are seeing this in a test, see: https://openfeature.dev/docs/reference/technologies/client/web/react#testing';
|
||||||
|
|
||||||
|
export class MissingContextError extends Error {
|
||||||
|
constructor(reason: string) {
|
||||||
|
super(`${reason}: ${context} ${tip}`);
|
||||||
|
this.name = 'MissingContextError';
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,57 @@
|
||||||
|
import type {
|
||||||
|
EvaluationDetails,
|
||||||
|
FlagValue} from '@openfeature/web-sdk';
|
||||||
|
import {
|
||||||
|
StandardResolutionReasons
|
||||||
|
} from '@openfeature/web-sdk';
|
||||||
|
import type { FlagQuery } from '../query';
|
||||||
|
|
||||||
|
|
||||||
|
// FlagQuery implementation, do not export
|
||||||
|
export class HookFlagQuery<T extends FlagValue = FlagValue> implements FlagQuery {
|
||||||
|
constructor(private _details: EvaluationDetails<T>) {}
|
||||||
|
|
||||||
|
get details() {
|
||||||
|
return this._details;
|
||||||
|
}
|
||||||
|
|
||||||
|
get value() {
|
||||||
|
return this._details?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
get variant() {
|
||||||
|
return this._details.variant;
|
||||||
|
}
|
||||||
|
|
||||||
|
get flagMetadata() {
|
||||||
|
return this._details.flagMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
get reason() {
|
||||||
|
return this._details.reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isError() {
|
||||||
|
return !!this._details?.errorCode || this._details.reason == StandardResolutionReasons.ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
get errorCode() {
|
||||||
|
return this._details?.errorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
get errorMessage() {
|
||||||
|
return this._details?.errorMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isAuthoritative() {
|
||||||
|
return (
|
||||||
|
!this.isError &&
|
||||||
|
this._details.reason != StandardResolutionReasons.STALE &&
|
||||||
|
this._details.reason != StandardResolutionReasons.DISABLED
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get type() {
|
||||||
|
return typeof this._details.value;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
export * from './context';
|
||||||
|
export * from './is-equal';
|
||||||
|
export * from './options';
|
||||||
|
export * from './suspense';
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { type FlagValue } from '@openfeature/web-sdk';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deeply compare two values to determine if they are equal.
|
||||||
|
* Supports primitives and serializable objects.
|
||||||
|
* @param {FlagValue} value First value to compare
|
||||||
|
* @param {FlagValue} other Second value to compare
|
||||||
|
* @returns {boolean} True if the values are equal
|
||||||
|
*/
|
||||||
|
export function isEqual(value: FlagValue, other: FlagValue): boolean {
|
||||||
|
if (value === other) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value !== typeof other) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'object' && value !== null && other !== null) {
|
||||||
|
const valueKeys = Object.keys(value);
|
||||||
|
const otherKeys = Object.keys(other);
|
||||||
|
|
||||||
|
if (valueKeys.length !== otherKeys.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of valueKeys) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
if (!isEqual((value as any)[key], (other as any)[key])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
|
@ -0,0 +1,40 @@
|
||||||
|
import type { ReactFlagEvaluationOptions, NormalizedOptions } from '../options';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default options.
|
||||||
|
* DO NOT EXPORT PUBLICLY
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export const DEFAULT_OPTIONS: ReactFlagEvaluationOptions = {
|
||||||
|
updateOnContextChanged: true,
|
||||||
|
updateOnConfigurationChanged: true,
|
||||||
|
suspendUntilReady: false,
|
||||||
|
suspendWhileReconciling: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns normalization options (all `undefined` fields removed, and `suspend` decomposed to `suspendUntilReady` and `suspendWhileReconciling`).
|
||||||
|
* DO NOT EXPORT PUBLICLY
|
||||||
|
* @internal
|
||||||
|
* @param {ReactFlagEvaluationOptions} options options to normalize
|
||||||
|
* @returns {NormalizedOptions} normalized options
|
||||||
|
*/
|
||||||
|
export const normalizeOptions: (options?: ReactFlagEvaluationOptions) => NormalizedOptions = (
|
||||||
|
options: ReactFlagEvaluationOptions = {},
|
||||||
|
) => {
|
||||||
|
const updateOnContextChanged = options.updateOnContextChanged;
|
||||||
|
const updateOnConfigurationChanged = options.updateOnConfigurationChanged;
|
||||||
|
|
||||||
|
// fall-back the suspense options to the catch-all `suspend` property
|
||||||
|
const suspendUntilReady = 'suspendUntilReady' in options ? options.suspendUntilReady : options.suspend;
|
||||||
|
const suspendWhileReconciling =
|
||||||
|
'suspendWhileReconciling' in options ? options.suspendWhileReconciling : options.suspend;
|
||||||
|
|
||||||
|
return {
|
||||||
|
// only return these if properly set (no undefined to allow overriding with spread)
|
||||||
|
...(typeof suspendUntilReady === 'boolean' && { suspendUntilReady }),
|
||||||
|
...(typeof suspendWhileReconciling === 'boolean' && { suspendWhileReconciling }),
|
||||||
|
...(typeof updateOnContextChanged === 'boolean' && { updateOnContextChanged }),
|
||||||
|
...(typeof updateOnConfigurationChanged === 'boolean' && { updateOnConfigurationChanged }),
|
||||||
|
};
|
||||||
|
};
|
|
@ -0,0 +1,56 @@
|
||||||
|
import type { Client, Provider } from '@openfeature/web-sdk';
|
||||||
|
import { NOOP_PROVIDER, ProviderEvents } from '@openfeature/web-sdk';
|
||||||
|
import { use } from './use';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A weak map is used to store the global suspense status for each provider. It's
|
||||||
|
* important for this to be global to avoid rerender loops. Using useRef won't
|
||||||
|
* work because the value isn't preserved when a promise is thrown in a component,
|
||||||
|
* which is how suspense operates.
|
||||||
|
*/
|
||||||
|
const globalProviderSuspenseStatus = new WeakMap<Provider, Promise<unknown>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suspends until the client is ready to evaluate feature flags.
|
||||||
|
*
|
||||||
|
* **DO NOT EXPORT PUBLICLY**
|
||||||
|
* @internal
|
||||||
|
* @param {Provider} provider the provider to suspend for
|
||||||
|
* @param {Client} client the client to check for readiness
|
||||||
|
*/
|
||||||
|
export function suspendUntilInitialized(provider: Provider, client: Client) {
|
||||||
|
const statusPromiseRef = globalProviderSuspenseStatus.get(provider);
|
||||||
|
if (!statusPromiseRef) {
|
||||||
|
// Noop provider is never ready, so we resolve immediately
|
||||||
|
const statusPromise = provider !== NOOP_PROVIDER ? isProviderReady(client) : Promise.resolve();
|
||||||
|
globalProviderSuspenseStatus.set(provider, statusPromise);
|
||||||
|
// Use will throw the promise and React will trigger a rerender when it's resolved
|
||||||
|
use(statusPromise);
|
||||||
|
} else {
|
||||||
|
// Reuse the existing promise, use won't rethrow if the promise has settled.
|
||||||
|
use(statusPromiseRef);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suspends until the provider has finished reconciling.
|
||||||
|
*
|
||||||
|
* **DO NOT EXPORT PUBLICLY**
|
||||||
|
* @internal
|
||||||
|
* @param {Client} client the client to check for readiness
|
||||||
|
*/
|
||||||
|
export function suspendUntilReconciled(client: Client) {
|
||||||
|
use(isProviderReady(client));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isProviderReady(client: Client) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
try {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
client.addHandler(ProviderEvents.Ready, resolve, { signal: controller.signal });
|
||||||
|
client.addHandler(ProviderEvents.Error, reject, { signal: controller.signal });
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
controller.abort();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,53 @@
|
||||||
|
/// <reference types="react/experimental" />
|
||||||
|
// This function is adopted from https://github.com/vercel/swr
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extends a Promise-like value to include status tracking.
|
||||||
|
* The extra properties are used to manage the lifecycle of the Promise, indicating its current state.
|
||||||
|
* More information can be found in the React RFE for the use hook.
|
||||||
|
* @see https://github.com/reactjs/rfcs/pull/229
|
||||||
|
*/
|
||||||
|
export type UsePromise<T> =
|
||||||
|
Promise<T> & {
|
||||||
|
status?: 'pending' | 'fulfilled' | 'rejected';
|
||||||
|
value?: T;
|
||||||
|
reason?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React.use is a React API that lets you read the value of a resource like a Promise or context.
|
||||||
|
* It was officially added in React 19, so needs to be polyfilled to support older React versions.
|
||||||
|
* @param {UsePromise} thenable A thenable object that represents a Promise-like value.
|
||||||
|
* @returns {unknown} The resolved value of the thenable or throws if it's still pending or rejected.
|
||||||
|
*/
|
||||||
|
export const use =
|
||||||
|
React.use ||
|
||||||
|
// This extra generic is to avoid TypeScript mixing up the generic and JSX syntax
|
||||||
|
// and emitting an error.
|
||||||
|
// We assume that this is only for the `use(thenable)` case, not `use(context)`.
|
||||||
|
// https://github.com/facebook/react/blob/aed00dacfb79d17c53218404c52b1c7aa59c4a89/packages/react-server/src/ReactFizzThenable.js#L45
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
(<T, _>(thenable: UsePromise<T>): T => {
|
||||||
|
switch (thenable.status) {
|
||||||
|
case 'pending':
|
||||||
|
throw thenable;
|
||||||
|
case 'fulfilled':
|
||||||
|
return thenable.value as T;
|
||||||
|
case 'rejected':
|
||||||
|
throw thenable.reason;
|
||||||
|
default:
|
||||||
|
thenable.status = 'pending';
|
||||||
|
thenable.then(
|
||||||
|
(v) => {
|
||||||
|
thenable.status = 'fulfilled';
|
||||||
|
thenable.value = v;
|
||||||
|
},
|
||||||
|
(e) => {
|
||||||
|
thenable.status = 'rejected';
|
||||||
|
thenable.reason = e;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
throw thenable;
|
||||||
|
}
|
||||||
|
});
|
|
@ -0,0 +1,53 @@
|
||||||
|
import type { FlagEvaluationOptions } from '@openfeature/web-sdk';
|
||||||
|
|
||||||
|
export type ReactFlagEvaluationOptions = (
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* Enable or disable all suspense functionality.
|
||||||
|
* Cannot be used in conjunction with `suspendUntilReady` and `suspendWhileReconciling` options.
|
||||||
|
* @experimental Suspense is an experimental feature subject to change in future versions.
|
||||||
|
*/
|
||||||
|
suspend?: boolean;
|
||||||
|
suspendUntilReady?: never;
|
||||||
|
suspendWhileReconciling?: never;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* Suspend flag evaluations while the provider is not ready.
|
||||||
|
* Set to false if you don't want to show suspense fallbacks until the provider is initialized.
|
||||||
|
* Defaults to false.
|
||||||
|
* Cannot be used in conjunction with `suspend` option.
|
||||||
|
* @experimental Suspense is an experimental feature subject to change in future versions.
|
||||||
|
*/
|
||||||
|
suspendUntilReady?: boolean;
|
||||||
|
/**
|
||||||
|
* Suspend flag evaluations while the provider's context is being reconciled.
|
||||||
|
* Set to true if you want to show suspense fallbacks while flags are re-evaluated after context changes.
|
||||||
|
* Defaults to false.
|
||||||
|
* Cannot be used in conjunction with `suspend` option.
|
||||||
|
* @experimental Suspense is an experimental feature subject to change in future versions.
|
||||||
|
*/
|
||||||
|
suspendWhileReconciling?: boolean;
|
||||||
|
suspend?: never;
|
||||||
|
}
|
||||||
|
) & {
|
||||||
|
/**
|
||||||
|
* Update the component if the provider emits a ConfigurationChanged event.
|
||||||
|
* Set to false to prevent components from re-rendering when flag value changes
|
||||||
|
* are received by the associated provider.
|
||||||
|
* Defaults to true.
|
||||||
|
*/
|
||||||
|
updateOnConfigurationChanged?: boolean;
|
||||||
|
/**
|
||||||
|
* Update the component when the OpenFeature context changes.
|
||||||
|
* Set to false to prevent components from re-rendering when attributes which
|
||||||
|
* may be factors in flag evaluation change.
|
||||||
|
* Defaults to true.
|
||||||
|
*/
|
||||||
|
updateOnContextChanged?: boolean;
|
||||||
|
} & FlagEvaluationOptions;
|
||||||
|
|
||||||
|
// suspense options removed for the useSuspenseFlag hooks
|
||||||
|
export type ReactFlagEvaluationNoSuspenseOptions = Omit<ReactFlagEvaluationOptions, 'suspend' | 'suspendUntilReady' | 'suspendWhileReconciling'>;
|
||||||
|
|
||||||
|
export type NormalizedOptions = Omit<ReactFlagEvaluationOptions, 'suspend'>;
|
|
@ -1,45 +0,0 @@
|
||||||
import * as React from 'react';
|
|
||||||
import { Client, OpenFeature } from '@openfeature/web-sdk';
|
|
||||||
|
|
||||||
type ClientOrDomain =
|
|
||||||
| {
|
|
||||||
/**
|
|
||||||
* An identifier which logically binds clients with providers
|
|
||||||
* @see OpenFeature.setProvider() and overloads.
|
|
||||||
*/
|
|
||||||
domain: string;
|
|
||||||
client?: never;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
/**
|
|
||||||
* OpenFeature client to use.
|
|
||||||
*/
|
|
||||||
client: Client;
|
|
||||||
domain?: never;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ProviderProps = {
|
|
||||||
children?: React.ReactNode;
|
|
||||||
} & ClientOrDomain;
|
|
||||||
|
|
||||||
const Context = React.createContext<Client | undefined>(undefined);
|
|
||||||
|
|
||||||
export const OpenFeatureProvider = ({ client, domain, children }: ProviderProps) => {
|
|
||||||
if (!client) {
|
|
||||||
client = OpenFeature.getClient(domain);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Context.Provider value={client}>{children}</Context.Provider>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useOpenFeatureClient = () => {
|
|
||||||
const client = React.useContext(Context);
|
|
||||||
|
|
||||||
if (!client) {
|
|
||||||
throw new Error(
|
|
||||||
'No OpenFeature client available - components using OpenFeature must be wrapped with an <OpenFeatureProvider>'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return client;
|
|
||||||
};
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
export * from './provider';
|
||||||
|
export * from './use-open-feature-client';
|
||||||
|
export * from './use-when-provider-ready';
|
||||||
|
export * from './use-open-feature-client-status';
|
||||||
|
export * from './test-provider';
|
|
@ -0,0 +1,40 @@
|
||||||
|
import type { Client} from '@openfeature/web-sdk';
|
||||||
|
import { OpenFeature } from '@openfeature/web-sdk';
|
||||||
|
import * as React from 'react';
|
||||||
|
import type { ReactFlagEvaluationOptions } from '../options';
|
||||||
|
import { Context } from '../internal';
|
||||||
|
|
||||||
|
type ClientOrDomain =
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* An identifier which logically binds clients with providers
|
||||||
|
* @see OpenFeature.setProvider() and overloads.
|
||||||
|
*/
|
||||||
|
domain?: string;
|
||||||
|
client?: never;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* OpenFeature client to use.
|
||||||
|
*/
|
||||||
|
client?: Client;
|
||||||
|
domain?: never;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProviderProps = {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
} & ClientOrDomain &
|
||||||
|
ReactFlagEvaluationOptions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides a scope for evaluating feature flags by binding a client to all child components.
|
||||||
|
* @param {ProviderProps} properties props for the context provider
|
||||||
|
* @returns {OpenFeatureProvider} context provider
|
||||||
|
*/
|
||||||
|
export function OpenFeatureProvider({ client, domain, children, ...options }: ProviderProps): JSX.Element {
|
||||||
|
if (!client) {
|
||||||
|
client = OpenFeature.getClient(domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Context.Provider value={{ client, options, domain }}>{children}</Context.Provider>;
|
||||||
|
}
|
|
@ -0,0 +1,122 @@
|
||||||
|
import type {
|
||||||
|
JsonValue,
|
||||||
|
Provider} from '@openfeature/web-sdk';
|
||||||
|
import {
|
||||||
|
InMemoryProvider,
|
||||||
|
NOOP_PROVIDER,
|
||||||
|
OpenFeature
|
||||||
|
} from '@openfeature/web-sdk';
|
||||||
|
import React from 'react';
|
||||||
|
import type { NormalizedOptions } from '../options';
|
||||||
|
import { OpenFeatureProvider } from './provider';
|
||||||
|
|
||||||
|
type FlagValueMap = { [flagKey: string]: JsonValue };
|
||||||
|
type FlagConfig = ConstructorParameters<typeof InMemoryProvider>[0];
|
||||||
|
type TestProviderProps = Omit<React.ComponentProps<typeof OpenFeatureProvider>, 'client'> &
|
||||||
|
(
|
||||||
|
| {
|
||||||
|
provider?: never;
|
||||||
|
/**
|
||||||
|
* Optional map of flagKeys to flagValues for this OpenFeatureTestProvider context.
|
||||||
|
* If not supplied, all flag evaluations will default.
|
||||||
|
*/
|
||||||
|
flagValueMap?: FlagValueMap;
|
||||||
|
/**
|
||||||
|
* Optional delay for the underlying test provider's readiness and reconciliation.
|
||||||
|
* Defaults to 0.
|
||||||
|
*/
|
||||||
|
delayMs?: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* An optional partial provider to pass for full control over the flag resolution for this OpenFeatureTestProvider context.
|
||||||
|
* Any un-implemented methods or properties will no-op.
|
||||||
|
*/
|
||||||
|
provider?: Partial<Provider>;
|
||||||
|
flagValueMap?: never;
|
||||||
|
delayMs?: never;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const TEST_VARIANT = 'test-variant';
|
||||||
|
const TEST_PROVIDER = 'test-provider';
|
||||||
|
|
||||||
|
// internal provider which is basically the in-memory provider with a simpler config and some optional fake delays
|
||||||
|
class TestProvider extends InMemoryProvider {
|
||||||
|
|
||||||
|
// initially make this undefined, we still set it if a delay is specified
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore - For maximum compatibility with previous versions, we ignore a possible TS error here,
|
||||||
|
// since "initialize" was previously defined in superclass.
|
||||||
|
// We can safely remove this ts-ignore in a few versions
|
||||||
|
initialize: Provider['initialize'] = undefined;
|
||||||
|
|
||||||
|
// "place-holder" init function which we only assign if want a delay
|
||||||
|
private delayedInitialize = async () => {
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, this.delay));
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
flagValueMap: FlagValueMap,
|
||||||
|
private delay = 0,
|
||||||
|
) {
|
||||||
|
// convert the simple flagValueMap into an in-memory config
|
||||||
|
const flagConfig = Object.entries(flagValueMap).reduce((acc: FlagConfig, flag): FlagConfig => {
|
||||||
|
return {
|
||||||
|
...acc,
|
||||||
|
[flag[0]]: {
|
||||||
|
variants: {
|
||||||
|
[TEST_VARIANT]: flag[1],
|
||||||
|
},
|
||||||
|
defaultVariant: TEST_VARIANT,
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, {});
|
||||||
|
super(flagConfig);
|
||||||
|
// only define and init if there's a non-zero delay specified
|
||||||
|
this.initialize = this.delay ? this.delayedInitialize.bind(this) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async onContextChange() {
|
||||||
|
return new Promise<void>((resolve) => setTimeout(resolve, this.delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A React Context provider based on the {@link InMemoryProvider}, specifically built for testing.
|
||||||
|
* Use this for testing components that use flag evaluation hooks.
|
||||||
|
* @param {TestProviderProps} testProviderOptions options for the OpenFeatureTestProvider
|
||||||
|
* @returns {OpenFeatureProvider} OpenFeatureTestProvider
|
||||||
|
*/
|
||||||
|
export function OpenFeatureTestProvider(testProviderOptions: TestProviderProps) {
|
||||||
|
const { flagValueMap, provider } = testProviderOptions;
|
||||||
|
const effectiveProvider = (
|
||||||
|
flagValueMap ? new TestProvider(flagValueMap, testProviderOptions.delayMs) : mixInNoop(provider) || NOOP_PROVIDER
|
||||||
|
) as Provider;
|
||||||
|
testProviderOptions.domain
|
||||||
|
? OpenFeature.setProvider(testProviderOptions.domain, effectiveProvider)
|
||||||
|
: OpenFeature.setProvider(effectiveProvider);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OpenFeatureProvider {...(testProviderOptions as NormalizedOptions)} domain={testProviderOptions.domain}>
|
||||||
|
{testProviderOptions.children}
|
||||||
|
</OpenFeatureProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// mix in the no-op provider when the partial is passed
|
||||||
|
function mixInNoop(provider: Partial<Provider> = {}) {
|
||||||
|
// fill in any missing methods with no-ops
|
||||||
|
for (const prop of Object.getOwnPropertyNames(Object.getPrototypeOf(NOOP_PROVIDER)).filter(prop => prop !== 'constructor')) {
|
||||||
|
const patchedProvider = provider as {[key: string]: keyof Provider};
|
||||||
|
if (!Object.getPrototypeOf(patchedProvider)[prop] && !patchedProvider[prop]) {
|
||||||
|
patchedProvider[prop] = Object.getPrototypeOf(NOOP_PROVIDER)[prop];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// fill in the metadata if missing
|
||||||
|
if (!provider.metadata || !provider.metadata.name) {
|
||||||
|
(provider.metadata as unknown) = { name: TEST_PROVIDER };
|
||||||
|
}
|
||||||
|
return provider;
|
||||||
|
}
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useOpenFeatureClient } from './use-open-feature-client';
|
||||||
|
import type { ProviderStatus } from '@openfeature/web-sdk';
|
||||||
|
import { ProviderEvents } from '@openfeature/web-sdk';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the {@link ProviderStatus} for the OpenFeatureClient.
|
||||||
|
* @returns {ProviderStatus} status of the client for this scope
|
||||||
|
*/
|
||||||
|
export function useOpenFeatureClientStatus(): ProviderStatus {
|
||||||
|
const client = useOpenFeatureClient();
|
||||||
|
const [status, setStatus] = useState<ProviderStatus>(client.providerStatus);
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateStatus = () => setStatus(client.providerStatus);
|
||||||
|
client.addHandler(ProviderEvents.ConfigurationChanged, updateStatus, { signal: controller.signal });
|
||||||
|
client.addHandler(ProviderEvents.ContextChanged, updateStatus, { signal: controller.signal });
|
||||||
|
client.addHandler(ProviderEvents.Error, updateStatus, { signal: controller.signal });
|
||||||
|
client.addHandler(ProviderEvents.Ready, updateStatus, { signal: controller.signal });
|
||||||
|
client.addHandler(ProviderEvents.Stale, updateStatus, { signal: controller.signal });
|
||||||
|
client.addHandler(ProviderEvents.Reconciling, updateStatus, { signal: controller.signal });
|
||||||
|
return () => {
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, [client]);
|
||||||
|
|
||||||
|
return status;
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Context } from '../internal';
|
||||||
|
import { type Client } from '@openfeature/web-sdk';
|
||||||
|
import { MissingContextError } from '../internal/errors';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the {@link Client} instance for this OpenFeatureProvider context.
|
||||||
|
* Note that the provider to which this is bound is determined by the OpenFeatureProvider's domain.
|
||||||
|
* @returns {Client} client for this scope
|
||||||
|
*/
|
||||||
|
export function useOpenFeatureClient(): Client {
|
||||||
|
const { client } = React.useContext(Context) || {};
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
throw new MissingContextError('No OpenFeature client available');
|
||||||
|
}
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Context } from '../internal';
|
||||||
|
import { OpenFeature } from '@openfeature/web-sdk';
|
||||||
|
import type { Provider } from '@openfeature/web-sdk';
|
||||||
|
import { MissingContextError } from '../internal/errors';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the {@link Provider} bound to the domain specified in the OpenFeatureProvider context.
|
||||||
|
* Note that it isn't recommended to interact with the provider directly, but rather through
|
||||||
|
* an OpenFeature client.
|
||||||
|
* @returns {Provider} provider for this scope
|
||||||
|
*/
|
||||||
|
export function useOpenFeatureProvider(): Provider {
|
||||||
|
const openFeatureContext = React.useContext(Context);
|
||||||
|
|
||||||
|
if (!openFeatureContext) {
|
||||||
|
throw new MissingContextError('No OpenFeature context available');
|
||||||
|
}
|
||||||
|
|
||||||
|
return OpenFeature.getProvider(openFeatureContext.domain);
|
||||||
|
}
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { ProviderStatus } from '@openfeature/web-sdk';
|
||||||
|
import { useOpenFeatureClient } from './use-open-feature-client';
|
||||||
|
import { useOpenFeatureClientStatus } from './use-open-feature-client-status';
|
||||||
|
import type { ReactFlagEvaluationOptions } from '../options';
|
||||||
|
import { DEFAULT_OPTIONS, useProviderOptions, normalizeOptions, suspendUntilInitialized } from '../internal';
|
||||||
|
import { useOpenFeatureProvider } from './use-open-feature-provider';
|
||||||
|
|
||||||
|
type Options = Pick<ReactFlagEvaluationOptions, 'suspendUntilReady'>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility hook that triggers suspense until the provider is {@link ProviderStatus.READY}, without evaluating any flags.
|
||||||
|
* Especially useful for React v16/17 "Legacy Suspense", in which siblings to suspending components are
|
||||||
|
* initially mounted and then hidden (see: https://github.com/reactwg/react-18/discussions/7).
|
||||||
|
* @param {Options} options options for suspense
|
||||||
|
* @returns {boolean} boolean indicating if provider is {@link ProviderStatus.READY}, useful if suspense is disabled and you want to handle loaders on your own
|
||||||
|
*/
|
||||||
|
export function useWhenProviderReady(options?: Options): boolean {
|
||||||
|
// highest priority > evaluation hook options > provider options > default options > lowest priority
|
||||||
|
const defaultedOptions = { ...DEFAULT_OPTIONS, ...useProviderOptions(), ...normalizeOptions(options) };
|
||||||
|
const client = useOpenFeatureClient();
|
||||||
|
const status = useOpenFeatureClientStatus();
|
||||||
|
const provider = useOpenFeatureProvider();
|
||||||
|
|
||||||
|
if (defaultedOptions.suspendUntilReady && status === ProviderStatus.NOT_READY) {
|
||||||
|
suspendUntilInitialized(provider, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
return status === ProviderStatus.READY;
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './query';
|
|
@ -0,0 +1,58 @@
|
||||||
|
import type { ErrorCode, EvaluationDetails, FlagMetadata, FlagValue, StandardResolutionReasons } from '@openfeature/core';
|
||||||
|
|
||||||
|
export interface FlagQuery<T extends FlagValue = FlagValue> {
|
||||||
|
/**
|
||||||
|
* A structure representing the result of the flag evaluation process
|
||||||
|
*/
|
||||||
|
readonly details: EvaluationDetails<T>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The flag value
|
||||||
|
*/
|
||||||
|
readonly value: T;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A variant is a semantic identifier for a value.
|
||||||
|
* Not available from all providers.
|
||||||
|
*/
|
||||||
|
readonly variant: string | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arbitrary data associated with this flag or evaluation.
|
||||||
|
* Not available from all providers.
|
||||||
|
*/
|
||||||
|
readonly flagMetadata: FlagMetadata;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reason the evaluation resolved to the particular value.
|
||||||
|
* Not available from all providers.
|
||||||
|
*/
|
||||||
|
readonly reason: typeof StandardResolutionReasons | string | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates if this flag defaulted due to an error.
|
||||||
|
* Specifically, indicates reason equals {@link StandardResolutionReasons.ERROR} or the errorCode is set, this field is truthy.
|
||||||
|
*/
|
||||||
|
readonly isError: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The error code, see {@link ErrorCode}.
|
||||||
|
*/
|
||||||
|
readonly errorCode: ErrorCode | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A message associated with the error.
|
||||||
|
*/
|
||||||
|
readonly errorMessage: string | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates this flag is up-to-date and in sync with the source of truth.
|
||||||
|
* Specifically, indicates the evaluation did not default due to error, and the reason is neither {@link StandardResolutionReasons.STALE} or {@link StandardResolutionReasons.DISABLED}.
|
||||||
|
*/
|
||||||
|
readonly isAuthoritative: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type of the value, as returned by "typeof" operator.
|
||||||
|
*/
|
||||||
|
readonly type: 'string' | 'number' | 'bigint' | 'boolean' | 'symbol' | 'undefined' | 'object' | 'function';
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './use-track';
|
|
@ -0,0 +1,29 @@
|
||||||
|
import type { Tracking, TrackingEventDetails } from '@openfeature/web-sdk';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useOpenFeatureClient } from '../provider';
|
||||||
|
|
||||||
|
export type Track = {
|
||||||
|
/**
|
||||||
|
* Context-aware tracking function for the parent `<OpenFeatureProvider/>`.
|
||||||
|
* Track a user action or application state, usually representing a business objective or outcome.
|
||||||
|
* @param trackingEventName an identifier for the event
|
||||||
|
* @param trackingEventDetails the details of the tracking event
|
||||||
|
*/
|
||||||
|
track: Tracking['track'];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a context-aware tracking function.
|
||||||
|
* @returns {Track} context-aware tracking
|
||||||
|
*/
|
||||||
|
export function useTrack(): Track {
|
||||||
|
const client = useOpenFeatureClient();
|
||||||
|
|
||||||
|
const track = useCallback((trackingEventName: string, trackingEventDetails?: TrackingEventDetails) => {
|
||||||
|
client.track(trackingEventName, trackingEventDetails);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
track,
|
||||||
|
};
|
||||||
|
}
|
|
@ -1,265 +0,0 @@
|
||||||
import { Client, EvaluationDetails, FlagEvaluationOptions, FlagValue, JsonValue, ProviderEvents, ProviderStatus } from '@openfeature/web-sdk';
|
|
||||||
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
|
|
||||||
import { useOpenFeatureClient } from './provider';
|
|
||||||
|
|
||||||
type ReactFlagEvaluationOptions = {
|
|
||||||
/**
|
|
||||||
* Suspend flag evaluations while the provider is not ready.
|
|
||||||
* Set to false if you don't want to show suspense fallbacks until the provider is initialized.
|
|
||||||
* Defaults to true.
|
|
||||||
*/
|
|
||||||
suspendUntilReady?: boolean,
|
|
||||||
/**
|
|
||||||
* Suspend flag evaluations while the provider's context is being reconciled.
|
|
||||||
* Set to true if you want to show suspense fallbacks while flags are re-evaluated after context changes.
|
|
||||||
* Defaults to false.
|
|
||||||
*/
|
|
||||||
suspendWhileReconciling?: boolean,
|
|
||||||
/**
|
|
||||||
* Update the component if the provider emits a ConfigurationChanged event.
|
|
||||||
* Set to false to prevent components from re-rendering when flag value changes
|
|
||||||
* are received by the associated provider.
|
|
||||||
* Defaults to true.
|
|
||||||
*/
|
|
||||||
updateOnConfigurationChanged?: boolean,
|
|
||||||
/**
|
|
||||||
* Update the component when the OpenFeature context changes.
|
|
||||||
* Set to false to prevent components from re-rendering when attributes which
|
|
||||||
* may be factors in flag evaluation change.
|
|
||||||
* Defaults to true.
|
|
||||||
*/
|
|
||||||
updateOnContextChanged?: boolean,
|
|
||||||
} & FlagEvaluationOptions;
|
|
||||||
|
|
||||||
const DEFAULT_OPTIONS: ReactFlagEvaluationOptions = {
|
|
||||||
updateOnContextChanged: true,
|
|
||||||
updateOnConfigurationChanged: true,
|
|
||||||
suspendUntilReady: true,
|
|
||||||
suspendWhileReconciling: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
enum SuspendState {
|
|
||||||
Pending,
|
|
||||||
Success,
|
|
||||||
Error
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Evaluates a feature flag, returning a boolean.
|
|
||||||
* By default, components will re-render when the flag value changes.
|
|
||||||
* @param {string} flagKey the flag identifier
|
|
||||||
* @param {boolean} defaultValue the default value
|
|
||||||
* @param {ReactFlagEvaluationOptions} options options for this evaluation
|
|
||||||
* @returns { boolean} a EvaluationDetails object for this evaluation
|
|
||||||
*/
|
|
||||||
export function useBooleanFlagValue(flagKey: string, defaultValue: boolean, options?: ReactFlagEvaluationOptions): boolean {
|
|
||||||
return useBooleanFlagDetails(flagKey, defaultValue, options).value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Evaluates a feature flag, returning evaluation details.
|
|
||||||
* By default, components will re-render when the flag value changes.
|
|
||||||
* @param {string} flagKey the flag identifier
|
|
||||||
* @param {boolean} defaultValue the default value
|
|
||||||
* @param {ReactFlagEvaluationOptions} options options for this evaluation
|
|
||||||
* @returns { EvaluationDetails<boolean>} a EvaluationDetails object for this evaluation
|
|
||||||
*/
|
|
||||||
export function useBooleanFlagDetails(flagKey: string, defaultValue: boolean, options?: ReactFlagEvaluationOptions): EvaluationDetails<boolean> {
|
|
||||||
return attachHandlersAndResolve(flagKey, defaultValue, (client) => {
|
|
||||||
return client.getBooleanDetails;
|
|
||||||
}, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Evaluates a feature flag, returning a string.
|
|
||||||
* By default, components will re-render when the flag value changes.
|
|
||||||
* @param {string} flagKey the flag identifier
|
|
||||||
* @template {string} [T=string] A optional generic argument constraining the string
|
|
||||||
* @param {T} defaultValue the default value
|
|
||||||
* @param {ReactFlagEvaluationOptions} options options for this evaluation
|
|
||||||
* @returns { boolean} a EvaluationDetails object for this evaluation
|
|
||||||
*/
|
|
||||||
export function useStringFlagValue<T extends string = string>(flagKey: string, defaultValue: T, options?: ReactFlagEvaluationOptions): T {
|
|
||||||
return useStringFlagDetails(flagKey, defaultValue, options).value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Evaluates a feature flag, returning evaluation details.
|
|
||||||
* By default, components will re-render when the flag value changes.
|
|
||||||
* @param {string} flagKey the flag identifier
|
|
||||||
* @template {string} [T=string] A optional generic argument constraining the string
|
|
||||||
* @param {T} defaultValue the default value
|
|
||||||
* @param {ReactFlagEvaluationOptions} options options for this evaluation
|
|
||||||
* @returns { EvaluationDetails<string>} a EvaluationDetails object for this evaluation
|
|
||||||
*/
|
|
||||||
export function useStringFlagDetails<T extends string = string>(flagKey: string, defaultValue: T, options?: ReactFlagEvaluationOptions): EvaluationDetails<T> {
|
|
||||||
return attachHandlersAndResolve(flagKey, defaultValue, (client) => {
|
|
||||||
return client.getStringDetails<T>;
|
|
||||||
}, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Evaluates a feature flag, returning a number.
|
|
||||||
* By default, components will re-render when the flag value changes.
|
|
||||||
* @param {string} flagKey the flag identifier
|
|
||||||
* @template {number} [T=number] A optional generic argument constraining the number
|
|
||||||
* @param {T} defaultValue the default value
|
|
||||||
* @param {ReactFlagEvaluationOptions} options options for this evaluation
|
|
||||||
* @returns { boolean} a EvaluationDetails object for this evaluation
|
|
||||||
*/
|
|
||||||
export function useNumberFlagValue<T extends number = number>(flagKey: string, defaultValue: T, options?: ReactFlagEvaluationOptions): T {
|
|
||||||
return useNumberFlagDetails(flagKey, defaultValue, options).value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Evaluates a feature flag, returning evaluation details.
|
|
||||||
* By default, components will re-render when the flag value changes.
|
|
||||||
* @param {string} flagKey the flag identifier
|
|
||||||
* @template {number} [T=number] A optional generic argument constraining the number
|
|
||||||
* @param {T} defaultValue the default value
|
|
||||||
* @param {ReactFlagEvaluationOptions} options options for this evaluation
|
|
||||||
* @returns { EvaluationDetails<number>} a EvaluationDetails object for this evaluation
|
|
||||||
*/
|
|
||||||
export function useNumberFlagDetails<T extends number = number>(flagKey: string, defaultValue: T, options?: ReactFlagEvaluationOptions): EvaluationDetails<T> {
|
|
||||||
return attachHandlersAndResolve(flagKey, defaultValue, (client) => {
|
|
||||||
return client.getNumberDetails<T>;
|
|
||||||
}, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Evaluates a feature flag, returning an object.
|
|
||||||
* By default, components will re-render when the flag value changes.
|
|
||||||
* @param {string} flagKey the flag identifier
|
|
||||||
* @template {JsonValue} [T=JsonValue] A optional generic argument describing the structure
|
|
||||||
* @param {T} defaultValue the default value
|
|
||||||
* @param {ReactFlagEvaluationOptions} options options for this evaluation
|
|
||||||
* @returns { boolean} a EvaluationDetails object for this evaluation
|
|
||||||
*/
|
|
||||||
export function useObjectFlagValue<T extends JsonValue = JsonValue>(flagKey: string, defaultValue: T, options?: ReactFlagEvaluationOptions): T {
|
|
||||||
return useObjectFlagDetails<T>(flagKey, defaultValue, options).value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Evaluates a feature flag, returning evaluation details.
|
|
||||||
* By default, components will re-render when the flag value changes.
|
|
||||||
* @param {string} flagKey the flag identifier
|
|
||||||
* @param {T} defaultValue the default value
|
|
||||||
* @template {JsonValue} [T=JsonValue] A optional generic argument describing the structure
|
|
||||||
* @param {ReactFlagEvaluationOptions} options options for this evaluation
|
|
||||||
* @returns { EvaluationDetails<T>} a EvaluationDetails object for this evaluation
|
|
||||||
*/
|
|
||||||
export function useObjectFlagDetails<T extends JsonValue = JsonValue>(flagKey: string, defaultValue: T, options?: ReactFlagEvaluationOptions): EvaluationDetails<T> {
|
|
||||||
return attachHandlersAndResolve(flagKey, defaultValue, (client) => {
|
|
||||||
return client.getObjectDetails<T>;
|
|
||||||
}, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachHandlersAndResolve<T extends FlagValue>(flagKey: string, defaultValue: T, resolver: (client: Client) => (flagKey: string, defaultValue: T) => EvaluationDetails<T>, options?: ReactFlagEvaluationOptions): EvaluationDetails<T> {
|
|
||||||
const defaultedOptions = { ...DEFAULT_OPTIONS, ...options };
|
|
||||||
const [, updateState] = useState<object | undefined>();
|
|
||||||
const client = useOpenFeatureClient();
|
|
||||||
const forceUpdate = () => {
|
|
||||||
updateState({});
|
|
||||||
};
|
|
||||||
const suspendRef = () => {
|
|
||||||
suspend(client, updateState, ProviderEvents.ContextChanged, ProviderEvents.ConfigurationChanged, ProviderEvents.Ready);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (client.providerStatus === ProviderStatus.NOT_READY) {
|
|
||||||
// update when the provider is ready
|
|
||||||
client.addHandler(ProviderEvents.Ready, forceUpdate);
|
|
||||||
if (defaultedOptions.suspendUntilReady) {
|
|
||||||
suspend(client, updateState, ProviderEvents.Ready);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (defaultedOptions.updateOnContextChanged) {
|
|
||||||
// update when the context changes
|
|
||||||
client.addHandler(ProviderEvents.ContextChanged, forceUpdate);
|
|
||||||
if (defaultedOptions.suspendWhileReconciling) {
|
|
||||||
client.addHandler(ProviderEvents.Reconciling, suspendRef);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
// cleanup the handlers
|
|
||||||
client.removeHandler(ProviderEvents.Ready, forceUpdate);
|
|
||||||
client.removeHandler(ProviderEvents.ContextChanged, forceUpdate);
|
|
||||||
client.removeHandler(ProviderEvents.Reconciling, suspendRef);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (defaultedOptions.updateOnConfigurationChanged) {
|
|
||||||
// update when the provider configuration changes
|
|
||||||
client.addHandler(ProviderEvents.ConfigurationChanged, forceUpdate);
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
// cleanup the handlers
|
|
||||||
client.removeHandler(ProviderEvents.ConfigurationChanged, forceUpdate);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return resolver(client).call(client, flagKey, defaultValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Suspend function. If this runs, components using the calling hook will be suspended.
|
|
||||||
* @param {Client} client the OpenFeature client
|
|
||||||
* @param {Function} updateState the state update function
|
|
||||||
* @param {ProviderEvents[]} resumeEvents list of events which will resume the suspend
|
|
||||||
*/
|
|
||||||
function suspend(client: Client, updateState: Dispatch<SetStateAction<object | undefined>>, ...resumeEvents: ProviderEvents[]) {
|
|
||||||
|
|
||||||
let suspendResolver: () => void;
|
|
||||||
|
|
||||||
const suspendPromise = new Promise<void>((resolve) => {
|
|
||||||
suspendResolver = () => {
|
|
||||||
resolve();
|
|
||||||
resumeEvents.forEach((e) => {
|
|
||||||
client.removeHandler(e, suspendResolver); // remove handlers once they've run
|
|
||||||
});
|
|
||||||
client.removeHandler(ProviderEvents.Error, suspendResolver);
|
|
||||||
};
|
|
||||||
resumeEvents.forEach((e) => {
|
|
||||||
client.addHandler(e, suspendResolver);
|
|
||||||
});
|
|
||||||
client.addHandler(ProviderEvents.Error, suspendResolver); // we never want to throw, resolve with errors - we may make this configurable later
|
|
||||||
});
|
|
||||||
updateState(suspenseWrapper(suspendPromise));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Promise wrapper that throws unresolved promises to support React suspense.
|
|
||||||
* @param {Promise<T>} promise to wrap
|
|
||||||
* @template T flag type
|
|
||||||
* @returns {Function} suspense-compliant lambda
|
|
||||||
*/
|
|
||||||
function suspenseWrapper <T>(promise: Promise<T>) {
|
|
||||||
let status: SuspendState = SuspendState.Pending;
|
|
||||||
let result: T;
|
|
||||||
|
|
||||||
const suspended = promise.then(
|
|
||||||
(value) => {
|
|
||||||
status = SuspendState.Success;
|
|
||||||
result = value;
|
|
||||||
},
|
|
||||||
(error) => {
|
|
||||||
status = SuspendState.Error;
|
|
||||||
result = error;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
switch (status) {
|
|
||||||
case SuspendState.Pending:
|
|
||||||
throw suspended;
|
|
||||||
case SuspendState.Success:
|
|
||||||
return result;
|
|
||||||
case SuspendState.Error:
|
|
||||||
throw result;
|
|
||||||
default:
|
|
||||||
throw new Error('Suspending promise is in an unknown state.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,50 @@
|
||||||
|
import { isEqual } from '../src/internal/is-equal';
|
||||||
|
|
||||||
|
describe('isEqual', () => {
|
||||||
|
it('should return true for equal primitive values', () => {
|
||||||
|
expect(isEqual(5, 5)).toBe(true);
|
||||||
|
expect(isEqual('hello', 'hello')).toBe(true);
|
||||||
|
expect(isEqual(true, true)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for different primitive values', () => {
|
||||||
|
expect(isEqual(5, 10)).toBe(false);
|
||||||
|
expect(isEqual('hello', 'world')).toBe(false);
|
||||||
|
expect(isEqual(true, false)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for equal serializable objects', () => {
|
||||||
|
const obj1 = { name: 'John', age: 30 };
|
||||||
|
const obj2 = { name: 'John', age: 30 };
|
||||||
|
expect(isEqual(obj1, obj2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for different serializable objects', () => {
|
||||||
|
const obj1 = { name: 'John', age: 30 };
|
||||||
|
const obj2 = { name: 'Jane', age: 25 };
|
||||||
|
expect(isEqual(obj1, obj2)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for equal deep objects', () => {
|
||||||
|
const obj1 = { name: 'John', age: 30, address: { city: 'New York', country: 'USA' } };
|
||||||
|
const obj2 = { name: 'John', age: 30, address: { city: 'New York', country: 'USA' } };
|
||||||
|
expect(isEqual(obj1, obj2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for equal deep objects with properties in a different order', () => {
|
||||||
|
const obj1 = { name: 'John', age: 30, address: { city: 'New York', country: 'USA' } };
|
||||||
|
const obj2 = { address: { country: 'USA', city: 'New York' }, age: 30, name: 'John' };
|
||||||
|
expect(isEqual(obj1, obj2)).toBe(true);
|
||||||
|
});
|
||||||
|
it('should return true for equal arrays', () => {
|
||||||
|
const arr1 = [1, 2, 3];
|
||||||
|
const arr2 = [1, 2, 3];
|
||||||
|
expect(isEqual(arr1, arr2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for different arrays', () => {
|
||||||
|
const arr1 = [1, 2, 3];
|
||||||
|
const arr2 = [3, 2, 1];
|
||||||
|
expect(isEqual(arr1, arr2)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { normalizeOptions } from '../src/internal/options';
|
||||||
|
|
||||||
|
describe('normalizeOptions', () => {
|
||||||
|
// we spread results from this function, so we never want to return null
|
||||||
|
describe('undefined options', () => {
|
||||||
|
it('should return empty object', () => {
|
||||||
|
const normalized = normalizeOptions();
|
||||||
|
expect(normalized).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// we spread results from this function, so we want to remove anything but explicit booleans
|
||||||
|
describe('undefined removal', () => {
|
||||||
|
it('should remove undefined props and maintain boolean props', () => {
|
||||||
|
const normalized = normalizeOptions({
|
||||||
|
suspendUntilReady: undefined,
|
||||||
|
suspendWhileReconciling: false,
|
||||||
|
updateOnConfigurationChanged: undefined,
|
||||||
|
updateOnContextChanged: true,
|
||||||
|
});
|
||||||
|
expect(normalized).not.toHaveProperty('suspendUntilReady');
|
||||||
|
expect(normalized).toHaveProperty('suspendWhileReconciling');
|
||||||
|
expect(normalized.suspendWhileReconciling).toEqual(false);
|
||||||
|
expect(normalized).not.toHaveProperty('updateOnConfigurationChanged');
|
||||||
|
expect(normalized).toHaveProperty('updateOnContextChanged');
|
||||||
|
expect(normalized.updateOnContextChanged).toEqual(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// we fallback the more specific suspense props (`suspendUntilReady` and `suspendWhileReconciling`) to `suspend`
|
||||||
|
describe('suspend fallback', () => {
|
||||||
|
it('should fallback to true suspend value', () => {
|
||||||
|
const normalized = normalizeOptions({
|
||||||
|
suspend: true,
|
||||||
|
});
|
||||||
|
expect(normalized.suspendUntilReady).toEqual(true);
|
||||||
|
expect(normalized.suspendWhileReconciling).toEqual(true);
|
||||||
|
});
|
||||||
|
it('should fallback to false suspend value', () => {
|
||||||
|
const normalized = normalizeOptions({
|
||||||
|
suspend: false,
|
||||||
|
});
|
||||||
|
expect(normalized.suspendUntilReady).toEqual(false);
|
||||||
|
expect(normalized.suspendWhileReconciling).toEqual(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,294 @@
|
||||||
|
import type { EvaluationContext} from '@openfeature/web-sdk';
|
||||||
|
import { InMemoryProvider, OpenFeature } from '@openfeature/web-sdk';
|
||||||
|
import '@testing-library/jest-dom'; // see: https://testing-library.com/docs/react-testing-library/setup
|
||||||
|
import { render, renderHook, screen, waitFor, fireEvent, act } from '@testing-library/react';
|
||||||
|
import * as React from 'react';
|
||||||
|
import {
|
||||||
|
OpenFeatureProvider,
|
||||||
|
useOpenFeatureClient,
|
||||||
|
useWhenProviderReady,
|
||||||
|
useContextMutator,
|
||||||
|
useStringFlagValue,
|
||||||
|
} from '../src';
|
||||||
|
import { TestingProvider } from './test.utils';
|
||||||
|
|
||||||
|
describe('OpenFeatureProvider', () => {
|
||||||
|
/**
|
||||||
|
* artificial delay for various async operations for our provider,
|
||||||
|
* multiples of it are used in assertions as well
|
||||||
|
*/
|
||||||
|
const DELAY = 100;
|
||||||
|
const SUSPENSE_ON = 'suspense';
|
||||||
|
const SUSPENSE_OFF = 'suspense';
|
||||||
|
const SUSPENSE_FLAG_KEY = 'delayed-flag';
|
||||||
|
const STATIC_FLAG_VALUE = 'hi';
|
||||||
|
const TARGETED_FLAG_VALUE = 'aloha';
|
||||||
|
const FALLBACK = 'fallback';
|
||||||
|
|
||||||
|
const suspendingProvider = () => {
|
||||||
|
return new TestingProvider(
|
||||||
|
{
|
||||||
|
[SUSPENSE_FLAG_KEY]: {
|
||||||
|
disabled: false,
|
||||||
|
variants: {
|
||||||
|
greeting: STATIC_FLAG_VALUE,
|
||||||
|
parting: 'bye',
|
||||||
|
both: TARGETED_FLAG_VALUE,
|
||||||
|
},
|
||||||
|
defaultVariant: 'greeting',
|
||||||
|
contextEvaluator: (context: EvaluationContext) => {
|
||||||
|
if (context.user == 'bob@flags.com') {
|
||||||
|
return 'both';
|
||||||
|
}
|
||||||
|
if (context.done === true) {
|
||||||
|
return 'parting';
|
||||||
|
}
|
||||||
|
return 'greeting';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
DELAY,
|
||||||
|
); // delay init by 100ms
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('useOpenFeatureClient', () => {
|
||||||
|
const DOMAIN = 'useOpenFeatureClient';
|
||||||
|
|
||||||
|
describe('client specified', () => {
|
||||||
|
it('should return client from provider', () => {
|
||||||
|
const client = OpenFeature.getClient(DOMAIN);
|
||||||
|
|
||||||
|
const wrapper = ({ children }: Parameters<typeof OpenFeatureProvider>[0]) => (
|
||||||
|
<OpenFeatureProvider client={client}>{children}</OpenFeatureProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useOpenFeatureClient(), { wrapper });
|
||||||
|
|
||||||
|
expect(result.current).toEqual(client);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('domain specified', () => {
|
||||||
|
it('should return client with domain', () => {
|
||||||
|
const wrapper = ({ children }: Parameters<typeof OpenFeatureProvider>[0]) => (
|
||||||
|
<OpenFeatureProvider domain={DOMAIN}>{children}</OpenFeatureProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useOpenFeatureClient(), { wrapper });
|
||||||
|
|
||||||
|
expect(result.current.metadata.domain).toEqual(DOMAIN);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useWhenProviderReady', () => {
|
||||||
|
describe('suspendUntilReady=true (default)', () => {
|
||||||
|
it('should suspend until ready and then return provider status', async () => {
|
||||||
|
OpenFeature.setProvider(SUSPENSE_ON, suspendingProvider());
|
||||||
|
|
||||||
|
let renderedWhileNotReady = false;
|
||||||
|
|
||||||
|
function TestComponent() {
|
||||||
|
const isReady = useWhenProviderReady({ suspendUntilReady: true });
|
||||||
|
|
||||||
|
if (!isReady) {
|
||||||
|
renderedWhileNotReady = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>{isReady ? '👍' : '👎'}</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(
|
||||||
|
<OpenFeatureProvider domain={SUSPENSE_ON}>
|
||||||
|
<React.Suspense fallback={<div>{FALLBACK}</div>}>
|
||||||
|
<TestComponent></TestComponent>
|
||||||
|
</React.Suspense>
|
||||||
|
</OpenFeatureProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// should see fallback initially
|
||||||
|
expect(renderedWhileNotReady).toBe(false);
|
||||||
|
expect(screen.queryByText('👎')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(FALLBACK)).toBeInTheDocument();
|
||||||
|
// eventually we should the value
|
||||||
|
await waitFor(() => expect(screen.queryByText('👍')).toBeVisible(), { timeout: DELAY * 2 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('suspendUntilReady=false', () => {
|
||||||
|
function TestComponent() {
|
||||||
|
const isReady = useWhenProviderReady({ suspendUntilReady: false });
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>{isReady ? '👍' : '👎'}</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should not suspend, should return provider status', async () => {
|
||||||
|
OpenFeature.setProvider(SUSPENSE_OFF, suspendingProvider());
|
||||||
|
|
||||||
|
render(
|
||||||
|
<OpenFeatureProvider domain={SUSPENSE_OFF}>
|
||||||
|
<React.Suspense fallback={<div>{FALLBACK}</div>}>
|
||||||
|
<TestComponent></TestComponent>
|
||||||
|
</React.Suspense>
|
||||||
|
</OpenFeatureProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// should see falsy value initially
|
||||||
|
expect(screen.queryByText('👎')).toBeInTheDocument();
|
||||||
|
// eventually we should the value
|
||||||
|
await waitFor(() => expect(screen.queryByText('👍')).toBeInTheDocument(), { timeout: DELAY * 2 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('useMutateContext', () => {
|
||||||
|
const MutateButton = () => {
|
||||||
|
const { setContext } = useContextMutator();
|
||||||
|
|
||||||
|
return <button onClick={() => setContext({ user: 'bob@flags.com' })}>Update Context</button>;
|
||||||
|
};
|
||||||
|
const TestComponent = ({ name }: { name: string }) => {
|
||||||
|
const flagValue = useStringFlagValue<'hi' | 'bye' | 'aloha'>(SUSPENSE_FLAG_KEY, 'hi');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<MutateButton />
|
||||||
|
<div>{`${name} says ${flagValue}`}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should update context when a domain is set', async () => {
|
||||||
|
const DOMAIN = 'mutate-context-tests';
|
||||||
|
OpenFeature.setProvider(DOMAIN, suspendingProvider());
|
||||||
|
render(
|
||||||
|
<OpenFeatureProvider domain={DOMAIN}>
|
||||||
|
<React.Suspense fallback={<div>{FALLBACK}</div>}>
|
||||||
|
<TestComponent name="Will" />
|
||||||
|
</React.Suspense>
|
||||||
|
</OpenFeatureProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Will says hi')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fireEvent.click(screen.getByText('Update Context'));
|
||||||
|
});
|
||||||
|
await waitFor(
|
||||||
|
() => {
|
||||||
|
expect(screen.getByText('Will says aloha')).toBeInTheDocument();
|
||||||
|
},
|
||||||
|
{ timeout: DELAY * 4 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update nested contexts', async () => {
|
||||||
|
const DOMAIN1 = 'Wills Domain';
|
||||||
|
const DOMAIN2 = 'Todds Domain';
|
||||||
|
OpenFeature.setProvider(DOMAIN1, suspendingProvider());
|
||||||
|
OpenFeature.setProvider(DOMAIN2, suspendingProvider());
|
||||||
|
render(
|
||||||
|
<OpenFeatureProvider domain={DOMAIN1}>
|
||||||
|
<React.Suspense fallback={<div>{FALLBACK}</div>}>
|
||||||
|
<TestComponent name="Will" />
|
||||||
|
<OpenFeatureProvider domain={DOMAIN2}>
|
||||||
|
<React.Suspense fallback={<div>{FALLBACK}</div>}>
|
||||||
|
<TestComponent name="Todd" />
|
||||||
|
</React.Suspense>
|
||||||
|
</OpenFeatureProvider>
|
||||||
|
</React.Suspense>
|
||||||
|
</OpenFeatureProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Todd says hi')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
// Click the Update context button in Todds domain
|
||||||
|
fireEvent.click(screen.getAllByText('Update Context')[1]);
|
||||||
|
});
|
||||||
|
await waitFor(
|
||||||
|
() => {
|
||||||
|
expect(screen.getByText('Todd says aloha')).toBeInTheDocument();
|
||||||
|
},
|
||||||
|
{ timeout: DELAY * 4 },
|
||||||
|
);
|
||||||
|
await waitFor(
|
||||||
|
() => {
|
||||||
|
expect(screen.getByText('Will says hi')).toBeInTheDocument();
|
||||||
|
},
|
||||||
|
{ timeout: DELAY * 4 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update nested global contexts', async () => {
|
||||||
|
const DOMAIN1 = 'Wills Domain';
|
||||||
|
OpenFeature.setProvider(DOMAIN1, suspendingProvider());
|
||||||
|
OpenFeature.setProvider(new InMemoryProvider({
|
||||||
|
globalFlagsHere: {
|
||||||
|
defaultVariant: 'a',
|
||||||
|
variants: {
|
||||||
|
a: 'Smile',
|
||||||
|
b: 'Frown',
|
||||||
|
},
|
||||||
|
disabled: false,
|
||||||
|
contextEvaluator: (ctx: EvaluationContext) => {
|
||||||
|
if (ctx.user === 'bob@flags.com') {
|
||||||
|
return 'b';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'a';
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
const GlobalComponent = ({ name }: { name: string }) => {
|
||||||
|
const flagValue = useStringFlagValue<'b' | 'a'>('globalFlagsHere', 'a');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<MutateButton />
|
||||||
|
<div>{`${name} likes to ${flagValue}`}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<OpenFeatureProvider domain={DOMAIN1}>
|
||||||
|
<React.Suspense fallback={<div>{FALLBACK}</div>}>
|
||||||
|
<TestComponent name="Will" />
|
||||||
|
<OpenFeatureProvider>
|
||||||
|
<React.Suspense fallback={<div>{FALLBACK}</div>}>
|
||||||
|
<GlobalComponent name="Todd" />
|
||||||
|
</React.Suspense>
|
||||||
|
</OpenFeatureProvider>
|
||||||
|
</React.Suspense>
|
||||||
|
</OpenFeatureProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Todd likes to Smile')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
// Click the Update context button in Todds domain
|
||||||
|
fireEvent.click(screen.getAllByText('Update Context')[1]);
|
||||||
|
});
|
||||||
|
await waitFor(
|
||||||
|
() => {
|
||||||
|
expect(screen.getByText('Todd likes to Frown')).toBeInTheDocument();
|
||||||
|
},
|
||||||
|
{ timeout: DELAY * 4 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Will says aloha')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,131 @@
|
||||||
|
import type { Provider, ResolutionDetails } from '@openfeature/web-sdk';
|
||||||
|
import '@testing-library/jest-dom'; // see: https://testing-library.com/docs/react-testing-library/setup
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { OpenFeatureTestProvider, useFlag } from '../src';
|
||||||
|
|
||||||
|
const FLAG_KEY = 'thumbs';
|
||||||
|
|
||||||
|
function TestComponent(props: { suspend: boolean }) {
|
||||||
|
const { value: thumbs, reason } = useFlag(FLAG_KEY, false, { suspend: props.suspend });
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>{thumbs ? '👍' : '👎'}</div>
|
||||||
|
<div>reason: {`${reason}`}</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('OpenFeatureTestProvider', () => {
|
||||||
|
describe('no args', () => {
|
||||||
|
it('renders default', async () => {
|
||||||
|
render(
|
||||||
|
<OpenFeatureTestProvider>
|
||||||
|
<TestComponent suspend={false} />
|
||||||
|
</OpenFeatureTestProvider>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('👎')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('flagValueMap set', () => {
|
||||||
|
it('renders value from map', async () => {
|
||||||
|
render(
|
||||||
|
<OpenFeatureTestProvider flagValueMap={{ [FLAG_KEY]: true }}>
|
||||||
|
<TestComponent suspend={false} />
|
||||||
|
</OpenFeatureTestProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('👍')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('delay and flagValueMap set', () => {
|
||||||
|
it('renders value after delay', async () => {
|
||||||
|
const delay = 100;
|
||||||
|
render(
|
||||||
|
<OpenFeatureTestProvider delayMs={delay} flagValueMap={{ [FLAG_KEY]: true }}>
|
||||||
|
<TestComponent suspend={false} />
|
||||||
|
</OpenFeatureTestProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// should only be resolved after delay
|
||||||
|
expect(screen.getByText('👎')).toBeInTheDocument();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay * 4));
|
||||||
|
expect(screen.getByText('👍')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('provider set', () => {
|
||||||
|
const reason = 'MY_REASON';
|
||||||
|
|
||||||
|
it('renders provider-returned value', async () => {
|
||||||
|
class MyTestProvider implements Partial<Provider> {
|
||||||
|
resolveBooleanEvaluation(): ResolutionDetails<boolean> {
|
||||||
|
return {
|
||||||
|
value: true,
|
||||||
|
variant: 'test-variant',
|
||||||
|
reason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render(
|
||||||
|
<OpenFeatureTestProvider provider={new MyTestProvider()}>
|
||||||
|
<TestComponent suspend={false} />
|
||||||
|
</OpenFeatureTestProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('👍')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(new RegExp(`${reason}`))).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to no-op for missing methods', async () => {
|
||||||
|
class MyEmptyProvider implements Partial<Provider> {}
|
||||||
|
|
||||||
|
render(
|
||||||
|
<OpenFeatureTestProvider provider={new MyEmptyProvider()}>
|
||||||
|
<TestComponent suspend={false} />
|
||||||
|
</OpenFeatureTestProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('👎')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/No-op/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('component under test suspends', () => {
|
||||||
|
describe('delay non-zero', () => {
|
||||||
|
it('renders fallback then value after delay', async () => {
|
||||||
|
const delay = 100;
|
||||||
|
render(
|
||||||
|
<OpenFeatureTestProvider delayMs={delay} flagValueMap={{ [FLAG_KEY]: true }}>
|
||||||
|
<React.Suspense fallback={<>🕒</>}>
|
||||||
|
<TestComponent suspend={true} />
|
||||||
|
</React.Suspense>
|
||||||
|
</OpenFeatureTestProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// should initially show fallback, then resolve
|
||||||
|
expect(screen.getByText('🕒')).toBeInTheDocument();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay * 4));
|
||||||
|
expect(screen.getByText('👍')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('delay zero', () => {
|
||||||
|
it('renders value immediately', async () => {
|
||||||
|
render(
|
||||||
|
<OpenFeatureTestProvider delayMs={0} flagValueMap={{ [FLAG_KEY]: true }}>
|
||||||
|
<React.Suspense fallback={<>🕒</>}>
|
||||||
|
<TestComponent suspend={true} />
|
||||||
|
</React.Suspense>
|
||||||
|
</OpenFeatureTestProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// should resolve immediately since delay is falsy
|
||||||
|
expect(screen.getByText('👍')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { InMemoryProvider } from '@openfeature/web-sdk';
|
||||||
|
|
||||||
|
export class TestingProvider extends InMemoryProvider {
|
||||||
|
constructor(
|
||||||
|
flagConfiguration: ConstructorParameters<typeof InMemoryProvider>[0],
|
||||||
|
private delay: number,
|
||||||
|
) {
|
||||||
|
super(flagConfiguration);
|
||||||
|
}
|
||||||
|
|
||||||
|
// artificially delay our init (delaying PROVIDER_READY event)
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, this.delay));
|
||||||
|
}
|
||||||
|
|
||||||
|
// artificially delay context changes
|
||||||
|
async onContextChange(): Promise<void> {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, this.delay));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { jest } from '@jest/globals';
|
||||||
|
import '@testing-library/jest-dom'; // see: https://testing-library.com/docs/react-testing-library/setup
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import * as React from 'react';
|
||||||
|
import type { Provider, TrackingEventDetails } from '../src';
|
||||||
|
import {
|
||||||
|
OpenFeature,
|
||||||
|
OpenFeatureProvider,
|
||||||
|
useTrack
|
||||||
|
} from '../src';
|
||||||
|
|
||||||
|
describe('tracking', () => {
|
||||||
|
|
||||||
|
const eventName = 'test-tracking-event';
|
||||||
|
const trackingValue = 1234;
|
||||||
|
const trackingDetails: TrackingEventDetails = {
|
||||||
|
value: trackingValue,
|
||||||
|
};
|
||||||
|
const domain = 'someDomain';
|
||||||
|
|
||||||
|
const mockProvider = () => {
|
||||||
|
const mockProvider: Provider = {
|
||||||
|
metadata: {
|
||||||
|
name: 'mock',
|
||||||
|
},
|
||||||
|
|
||||||
|
track: jest.fn((): void => {
|
||||||
|
return;
|
||||||
|
}),
|
||||||
|
} as unknown as Provider;
|
||||||
|
|
||||||
|
return mockProvider;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('no domain', () => {
|
||||||
|
it('should call default provider', async () => {
|
||||||
|
|
||||||
|
const provider = mockProvider();
|
||||||
|
await OpenFeature.setProviderAndWait(provider);
|
||||||
|
|
||||||
|
function Component() {
|
||||||
|
const { track } = useTrack();
|
||||||
|
track(eventName, trackingDetails);
|
||||||
|
|
||||||
|
return <div></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
render(
|
||||||
|
<OpenFeatureProvider suspend={false} >
|
||||||
|
<Component></Component>
|
||||||
|
</OpenFeatureProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(provider.track).toHaveBeenCalledWith(
|
||||||
|
eventName,
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({ value: trackingValue }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('domain set', () => {
|
||||||
|
it('should call provider for domain', async () => {
|
||||||
|
|
||||||
|
const domainProvider = mockProvider();
|
||||||
|
await OpenFeature.setProviderAndWait(domain, domainProvider);
|
||||||
|
|
||||||
|
function Component() {
|
||||||
|
const { track } = useTrack();
|
||||||
|
track(eventName, trackingDetails);
|
||||||
|
|
||||||
|
return <div></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
render(
|
||||||
|
<OpenFeatureProvider domain={domain} suspend={false} >
|
||||||
|
<Component></Component>
|
||||||
|
</OpenFeatureProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(domainProvider.track).toHaveBeenCalledWith(
|
||||||
|
eventName,
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({ value: trackingValue }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -10,9 +10,9 @@
|
||||||
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||||
/* Language and Environment */
|
/* Language and Environment */
|
||||||
"target": "ES2022", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
"target": "ES2015", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||||
"lib": [
|
"lib": [
|
||||||
"ES2022",
|
"ES2015",
|
||||||
"DOM"
|
"DOM"
|
||||||
], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||||
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||||
|
@ -25,13 +25,13 @@
|
||||||
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||||
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||||
/* Modules */
|
/* Modules */
|
||||||
"module": "ES2022", /* Specify what module code is generated. */
|
"module": "ES2015", /* Specify what module code is generated. */
|
||||||
// "rootDir": "./", /* Specify the root folder within your source files. */
|
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||||
"moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
|
"moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||||
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||||
"paths": {
|
"paths": {
|
||||||
"@openfeature/core": [ "../shared/src" ],
|
"@openfeature/core": [ "../shared/src" ],
|
||||||
"@openfeature/web-sdk": [ "../client/src" ]
|
"@openfeature/web-sdk": [ "../web/src" ]
|
||||||
}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||||
// "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */
|
// "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */
|
||||||
|
|
|
@ -1,5 +1,191 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [1.19.0](https://github.com/open-feature/js-sdk/compare/server-sdk-v1.18.0...server-sdk-v1.19.0) (2025-08-14)
|
||||||
|
|
||||||
|
|
||||||
|
### ✨ New Features
|
||||||
|
|
||||||
|
* add evaluation-scoped hook data ([#1216](https://github.com/open-feature/js-sdk/issues/1216)) ([07af3a9](https://github.com/open-feature/js-sdk/commit/07af3a9eda895e9edb24c7ee1e3c1c4f16e17431))
|
||||||
|
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
* update core dep ([#1228](https://github.com/open-feature/js-sdk/issues/1228)) ([845d24c](https://github.com/open-feature/js-sdk/commit/845d24c5fecc80de3080e49fde839f08ecac6b33))
|
||||||
|
|
||||||
|
|
||||||
|
### 🧹 Chore
|
||||||
|
|
||||||
|
* update node to v20+ ([#1203](https://github.com/open-feature/js-sdk/issues/1203)) ([1f33453](https://github.com/open-feature/js-sdk/commit/1f33453c23df0763cbf0d0b44db8d91216377009))
|
||||||
|
|
||||||
|
|
||||||
|
### 📚 Documentation
|
||||||
|
|
||||||
|
* Clarify the behavior of setProviderAndWait ([#1180](https://github.com/open-feature/js-sdk/issues/1180)) ([4fe8d87](https://github.com/open-feature/js-sdk/commit/4fe8d87a2e5df2cbd4086cc4f4a380e8857ed8ba))
|
||||||
|
|
||||||
|
## [1.18.0](https://github.com/open-feature/js-sdk/compare/server-sdk-v1.17.1...server-sdk-v1.18.0) (2025-04-11)
|
||||||
|
|
||||||
|
|
||||||
|
### ✨ New Features
|
||||||
|
|
||||||
|
* add a top-level method for accessing providers ([#1152](https://github.com/open-feature/js-sdk/issues/1152)) ([ae8fce8](https://github.com/open-feature/js-sdk/commit/ae8fce87530005ed20f7e68dc696ce67053fca31))
|
||||||
|
* add support for abort controllers to event handlers ([#1151](https://github.com/open-feature/js-sdk/issues/1151)) ([6a22483](https://github.com/open-feature/js-sdk/commit/6a224830fa4e62fc30a7802536f6f6fc3f772038))
|
||||||
|
|
||||||
|
## [1.17.1](https://github.com/open-feature/js-sdk/compare/server-sdk-v1.17.0...server-sdk-v1.17.1) (2025-02-07)
|
||||||
|
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
* msg missing when providers return err resolutions ([#1134](https://github.com/open-feature/js-sdk/issues/1134)) ([bc9f6e4](https://github.com/open-feature/js-sdk/commit/bc9f6e44da3f1c0a66659aee2d0316629ac34fbf))
|
||||||
|
|
||||||
|
|
||||||
|
### 🧹 Chore
|
||||||
|
|
||||||
|
* update core peer ([8bbd43e](https://github.com/open-feature/js-sdk/commit/8bbd43e579a0c2e0c5b7eec00f94bbcffce04773))
|
||||||
|
|
||||||
|
|
||||||
|
### 📚 Documentation
|
||||||
|
|
||||||
|
* fix eval context link ([#1132](https://github.com/open-feature/js-sdk/issues/1132)) ([f6bc695](https://github.com/open-feature/js-sdk/commit/f6bc6951a32fbbed9b35c1bdfd023b02874b87a1))
|
||||||
|
|
||||||
|
## [1.17.0](https://github.com/open-feature/js-sdk/compare/server-sdk-v1.16.2...server-sdk-v1.17.0) (2024-12-18)
|
||||||
|
|
||||||
|
### ⚠ BREAKING CHANGES
|
||||||
|
|
||||||
|
The signature of the `finally` hook stage has been changed. The signature now includes the `evaluation details`, as per the [OpenFeature specification](https://openfeature.dev/specification/sections/hooks#requirement-438). Note that since hooks are still `experimental,` this does not constitute a change requiring a new major version. To migrate, update any hook that implements the `finally` stage to accept `evaluation details` as the second argument.
|
||||||
|
|
||||||
|
* add evaluation details to finally hook ([#1087](https://github.com/open-feature/js-sdk/issues/1087)) ([2135254](https://github.com/open-feature/js-sdk/commit/2135254c4bee52b4bcadfbf8b99a896cfd930cca))
|
||||||
|
|
||||||
|
### ✨ New Features
|
||||||
|
|
||||||
|
* add evaluation details to finally hook ([#1087](https://github.com/open-feature/js-sdk/issues/1087)) ([2135254](https://github.com/open-feature/js-sdk/commit/2135254c4bee52b4bcadfbf8b99a896cfd930cca))
|
||||||
|
|
||||||
|
|
||||||
|
### 📚 Documentation
|
||||||
|
|
||||||
|
* fix comment in README for Hook’s after method ([#1103](https://github.com/open-feature/js-sdk/issues/1103)) ([e335615](https://github.com/open-feature/js-sdk/commit/e3356157d5910d9196e8968c20d4c9a46c4de910))
|
||||||
|
|
||||||
|
|
||||||
|
### 🔄 Refactoring
|
||||||
|
|
||||||
|
* improve track interface for providers ([#1100](https://github.com/open-feature/js-sdk/issues/1100)) ([5e5b160](https://github.com/open-feature/js-sdk/commit/5e5b16022122b71760634ac90e3fd962aa831c74))
|
||||||
|
|
||||||
|
## [1.16.2](https://github.com/open-feature/js-sdk/compare/server-sdk-v1.16.1...server-sdk-v1.16.2) (2024-11-07)
|
||||||
|
|
||||||
|
|
||||||
|
### 🧹 Chore
|
||||||
|
|
||||||
|
* loosen peer dependency requirements, remove some ci automation ([#1080](https://github.com/open-feature/js-sdk/issues/1080)) ([ef3ba21](https://github.com/open-feature/js-sdk/commit/ef3ba2167ac95cd0c6a046d206bd60bbcf84e80c))
|
||||||
|
|
||||||
|
|
||||||
|
### 🚀 Performance
|
||||||
|
|
||||||
|
* avoid using exceptions for flow control ([#1074](https://github.com/open-feature/js-sdk/issues/1074)) ([26264d6](https://github.com/open-feature/js-sdk/commit/26264d6d090b2ed31b27d36e71194b9fa911563b))
|
||||||
|
|
||||||
|
## [1.16.1](https://github.com/open-feature/js-sdk/compare/server-sdk-v1.16.0...server-sdk-v1.16.1) (2024-10-29)
|
||||||
|
|
||||||
|
|
||||||
|
### 📚 Documentation
|
||||||
|
|
||||||
|
* add tracking sections ([#1068](https://github.com/open-feature/js-sdk/issues/1068)) ([e131faf](https://github.com/open-feature/js-sdk/commit/e131faffad9025e9c7194f39558bf3b3cec31807))
|
||||||
|
|
||||||
|
## [1.16.0](https://github.com/open-feature/js-sdk/compare/server-sdk-v1.15.1...server-sdk-v1.16.0) (2024-10-29)
|
||||||
|
|
||||||
|
|
||||||
|
### ✨ New Features
|
||||||
|
|
||||||
|
* implement tracking as per spec ([#1020](https://github.com/open-feature/js-sdk/issues/1020)) ([80f182e](https://github.com/open-feature/js-sdk/commit/80f182e1afbd3a705bf3de6a0d9886ccb3424b44))
|
||||||
|
|
||||||
|
|
||||||
|
### 🧹 Chore
|
||||||
|
|
||||||
|
* import type lint rule and fixes ([#1039](https://github.com/open-feature/js-sdk/issues/1039)) ([01fcb93](https://github.com/open-feature/js-sdk/commit/01fcb933d2cbd131a0f4a005173cdd1906087e18))
|
||||||
|
* **main:** release core 1.5.0 ([#1040](https://github.com/open-feature/js-sdk/issues/1040)) ([fe3ad8e](https://github.com/open-feature/js-sdk/commit/fe3ad8eeb9219ff08ba287cab228016da0b88e88))
|
||||||
|
|
||||||
|
## [1.15.1](https://github.com/open-feature/js-sdk/compare/server-sdk-v1.15.0...server-sdk-v1.15.1) (2024-08-28)
|
||||||
|
|
||||||
|
|
||||||
|
### 🧹 Chore
|
||||||
|
|
||||||
|
* add assertion for hook context contents ([#977](https://github.com/open-feature/js-sdk/issues/977)) ([964d65b](https://github.com/open-feature/js-sdk/commit/964d65b7751d1a78a932e928d1ecfd9c071e1109))
|
||||||
|
* **main:** release core 1.4.0 ([#984](https://github.com/open-feature/js-sdk/issues/984)) ([01344b2](https://github.com/open-feature/js-sdk/commit/01344b28c1381d9de3aefde89be841b597a00b70))
|
||||||
|
|
||||||
|
## [1.15.0](https://github.com/open-feature/js-sdk/compare/server-sdk-v1.14.0...server-sdk-v1.15.0) (2024-06-11)
|
||||||
|
|
||||||
|
|
||||||
|
### ✨ New Features
|
||||||
|
|
||||||
|
* lower compilation target to es2015 ([#957](https://github.com/open-feature/js-sdk/issues/957)) ([c2d6c17](https://github.com/open-feature/js-sdk/commit/c2d6c1761ae19f937deaff2f011a0380f8af7350))
|
||||||
|
|
||||||
|
|
||||||
|
### 🧹 Chore
|
||||||
|
|
||||||
|
* **main:** release core 1.3.0 ([#958](https://github.com/open-feature/js-sdk/issues/958)) ([25086c5](https://github.com/open-feature/js-sdk/commit/25086c5456d81fa040ce95ea1a067543408e3150))
|
||||||
|
|
||||||
|
## [1.14.0](https://github.com/open-feature/js-sdk/compare/server-sdk-v1.13.5...server-sdk-v1.14.0) (2024-05-13)
|
||||||
|
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
* remove export of OpenFeatureClient ([#794](https://github.com/open-feature/js-sdk/issues/794)) ([3d197f2](https://github.com/open-feature/js-sdk/commit/3d197f2ea74f00ef904fc6a6960160d0cf4ded9a))
|
||||||
|
* removes exports of OpenFeatureClient class and makes event props readonly ([#918](https://github.com/open-feature/js-sdk/issues/918)) ([e9a25c2](https://github.com/open-feature/js-sdk/commit/e9a25c21cb17c3b5700bca652e3c0ed15e8f49b4))
|
||||||
|
* run error hook when provider returns reason error or error code ([#926](https://github.com/open-feature/js-sdk/issues/926)) ([c6d0b5d](https://github.com/open-feature/js-sdk/commit/c6d0b5da9c7f4c11319422fbe8c668a7613b044d))
|
||||||
|
|
||||||
|
|
||||||
|
### 🧹 Chore
|
||||||
|
|
||||||
|
* remove node 16 ([#875](https://github.com/open-feature/js-sdk/issues/875)) ([c1878e4](https://github.com/open-feature/js-sdk/commit/c1878e4effac3c8c9aa8a34cee4214f628a1e4ca))
|
||||||
|
* **main:** release core 1.2.0 ([#927](https://github.com/open-feature/js-sdk/issues/927)) ([692ad5b](https://github.com/open-feature/js-sdk/commit/692ad5b27a052a4c5abba81fe1caa071edd59ee7))
|
||||||
|
|
||||||
|
|
||||||
|
### 📚 Documentation
|
||||||
|
|
||||||
|
* add tip about supported usage in the install section ([#941](https://github.com/open-feature/js-sdk/issues/941)) ([f0de667](https://github.com/open-feature/js-sdk/commit/f0de66770be778d7a51063e706c9cccbba4b214e))
|
||||||
|
|
||||||
|
## [1.13.5](https://github.com/open-feature/js-sdk/compare/server-sdk-v1.13.4...server-sdk-v1.13.5) (2024-04-18)
|
||||||
|
|
||||||
|
|
||||||
|
### 🧹 Chore
|
||||||
|
|
||||||
|
* bump spec version badge to v0.8.0 ([#910](https://github.com/open-feature/js-sdk/issues/910)) ([a7b2c4b](https://github.com/open-feature/js-sdk/commit/a7b2c4bca09112d49e637735466502adb1438ebe))
|
||||||
|
|
||||||
|
## [1.13.4](https://github.com/open-feature/js-sdk/compare/server-sdk-v1.13.3...server-sdk-v1.13.4) (2024-04-02)
|
||||||
|
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
* return metadata for the bound provider in hookContext ([#883](https://github.com/open-feature/js-sdk/issues/883)) ([fd84025](https://github.com/open-feature/js-sdk/commit/fd84025bdfe30e8d730fa546d01c1ad6c6953189))
|
||||||
|
|
||||||
|
|
||||||
|
### 🧹 Chore
|
||||||
|
|
||||||
|
* **main:** release core 1.1.0 ([#899](https://github.com/open-feature/js-sdk/issues/899)) ([b3e5f7e](https://github.com/open-feature/js-sdk/commit/b3e5f7eb2aac5d5533c51764242e06a6ba508082))
|
||||||
|
|
||||||
|
## [1.13.3](https://github.com/open-feature/js-sdk/compare/server-sdk-v1.13.2...server-sdk-v1.13.3) (2024-03-25)
|
||||||
|
|
||||||
|
|
||||||
|
### 📚 Documentation
|
||||||
|
|
||||||
|
* add peer dep explainer ([#876](https://github.com/open-feature/js-sdk/issues/876)) ([cfd23b9](https://github.com/open-feature/js-sdk/commit/cfd23b90f0ca2673253fbbe30f4db585e746bc63))
|
||||||
|
|
||||||
|
## [1.13.2](https://github.com/open-feature/js-sdk/compare/server-sdk-v1.13.1...server-sdk-v1.13.2) (2024-03-12)
|
||||||
|
|
||||||
|
|
||||||
|
### 🧹 Chore
|
||||||
|
|
||||||
|
* **main:** release core 1.0.0 ([#869](https://github.com/open-feature/js-sdk/issues/869)) ([4191a02](https://github.com/open-feature/js-sdk/commit/4191a02dbc5b66053b63d19e2e9c5bf750aaf4bf))
|
||||||
|
|
||||||
|
|
||||||
|
### 📚 Documentation
|
||||||
|
|
||||||
|
* fixed feature table formatting ([84a26db](https://github.com/open-feature/js-sdk/commit/84a26db4276f1dfbfd2e0554189a1570c06001d4))
|
||||||
|
|
||||||
|
## [1.13.1](https://github.com/open-feature/js-sdk/compare/server-sdk-v1.13.0...server-sdk-v1.13.1) (2024-03-05)
|
||||||
|
|
||||||
|
|
||||||
|
### 🧹 Chore
|
||||||
|
|
||||||
|
* **main:** release core 0.0.28 ([#849](https://github.com/open-feature/js-sdk/issues/849)) ([31b92a9](https://github.com/open-feature/js-sdk/commit/31b92a97c19071334cb7cf10767be9d40be55943))
|
||||||
|
|
||||||
## [1.13.0](https://github.com/open-feature/js-sdk/compare/server-sdk-v1.12.0...server-sdk-v1.13.0) (2024-03-05)
|
## [1.13.0](https://github.com/open-feature/js-sdk/compare/server-sdk-v1.12.0...server-sdk-v1.13.0) (2024-03-05)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -12,12 +12,12 @@
|
||||||
<!-- x-hide-in-docs-end -->
|
<!-- x-hide-in-docs-end -->
|
||||||
<!-- The 'github-badges' class is used in the docs -->
|
<!-- The 'github-badges' class is used in the docs -->
|
||||||
<p align="center" class="github-badges">
|
<p align="center" class="github-badges">
|
||||||
<a href="https://github.com/open-feature/spec/releases/tag/v0.7.0">
|
<a href="https://github.com/open-feature/spec/releases/tag/v0.8.0">
|
||||||
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.7.0&color=yellow&style=for-the-badge" />
|
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
|
||||||
</a>
|
</a>
|
||||||
<!-- x-release-please-start-version -->
|
<!-- x-release-please-start-version -->
|
||||||
<a href="https://github.com/open-feature/js-sdk/releases/tag/server-sdk-v1.13.0">
|
<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.13.0&color=blue&style=for-the-badge" />
|
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v1.19.0&color=blue&style=for-the-badge" />
|
||||||
</a>
|
</a>
|
||||||
<!-- x-release-please-end -->
|
<!-- x-release-please-end -->
|
||||||
<br/>
|
<br/>
|
||||||
|
@ -36,7 +36,7 @@
|
||||||
</p>
|
</p>
|
||||||
<!-- x-hide-in-docs-start -->
|
<!-- x-hide-in-docs-start -->
|
||||||
|
|
||||||
[OpenFeature](https://openfeature.dev) is an open specification that provides a vendor-agnostic, community-driven API for feature flagging that works with your favorite feature flag management tool.
|
[OpenFeature](https://openfeature.dev) is an open specification that provides a vendor-agnostic, community-driven API for feature flagging that works with your favorite feature flag management tool or in-house solution.
|
||||||
|
|
||||||
<!-- x-hide-in-docs-end -->
|
<!-- x-hide-in-docs-end -->
|
||||||
|
|
||||||
|
@ -44,7 +44,7 @@
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
- Node.js version 16+
|
- Node.js version 18+
|
||||||
|
|
||||||
### Install
|
### Install
|
||||||
|
|
||||||
|
@ -54,6 +54,9 @@
|
||||||
npm install --save @openfeature/server-sdk
|
npm install --save @openfeature/server-sdk
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> This SDK is designed to run in Node.JS. If you're interested in browser support, check out the [Web SDK](https://openfeature.dev/docs/reference/technologies/client/web/).
|
||||||
|
|
||||||
#### yarn
|
#### yarn
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
@ -61,13 +64,22 @@ npm install --save @openfeature/server-sdk
|
||||||
yarn add @openfeature/server-sdk @openfeature/core
|
yarn add @openfeature/server-sdk @openfeature/core
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> `@openfeature/core` contains common components used by all OpenFeature JavaScript implementations.
|
||||||
|
> Every SDK version has a requirement on a single, specific version of this dependency.
|
||||||
|
> For more information, and similar implications on libraries developed with OpenFeature see [considerations when extending](#considerations).
|
||||||
|
|
||||||
### Usage
|
### Usage
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { OpenFeature } from '@openfeature/server-sdk';
|
import { OpenFeature } from '@openfeature/server-sdk';
|
||||||
|
|
||||||
// Register your feature flag provider
|
// Register your feature flag provider
|
||||||
await OpenFeature.setProviderAndWait(new YourProviderOfChoice());
|
try {
|
||||||
|
await OpenFeature.setProviderAndWait(new YourProviderOfChoice());
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize provider:', error);
|
||||||
|
}
|
||||||
|
|
||||||
// create a new client
|
// create a new client
|
||||||
const client = OpenFeature.getClient();
|
const client = OpenFeature.getClient();
|
||||||
|
@ -86,17 +98,18 @@ See [here](https://open-feature.github.io/js-sdk/modules/_openfeature_server_sdk
|
||||||
|
|
||||||
## 🌟 Features
|
## 🌟 Features
|
||||||
|
|
||||||
| Status | Features | Description |
|
| Status | Features | Description |
|
||||||
|--------|---------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
| ------ | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| ✅ | [Providers](#providers) | Integrate with a commercial, open source, or in-house feature management tool. |
|
| ✅ | [Providers](#providers) | Integrate with a commercial, open source, or in-house feature management tool. |
|
||||||
| ✅ | [Targeting](#targeting) | Contextually-aware flag evaluation using [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context). |
|
| ✅ | [Targeting](#targeting) | Contextually-aware flag evaluation using [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context). |
|
||||||
| ✅ | [Hooks](#hooks) | Add functionality to various stages of the flag evaluation life-cycle. |
|
| ✅ | [Hooks](#hooks) | Add functionality to various stages of the flag evaluation life-cycle. |
|
||||||
| ✅ | [Logging](#logging) | Integrate with popular logging packages. |
|
| ✅ | [Logging](#logging) | Integrate with popular logging packages. |
|
||||||
| ✅ | [Domains](#domains) | Logically bind clients with providers. |
|
| ✅ | [Domains](#domains) | Logically bind clients with providers. |
|
||||||
| ✅ | [Eventing](#eventing) | React to state changes in the provider or flag management system. |
|
| ✅ | [Eventing](#eventing) | React to state changes in the provider or flag management system. |
|
||||||
| ✅ | [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. |
|
| ✅ | [Transaction Context Propagation](#transaction-context-propagation) | Set a specific [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context) for a transaction (e.g. an HTTP request or a thread) |
|
||||||
| ✅ | [Transaction Context Propagation](#transaction-context-propagation) | Set a specific [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context) for a transaction (e.g. an HTTP request or a thread) | |
|
| ✅ | [Tracking](#tracking) | Associate user actions with feature flag evaluations, particularly for A/B testing. |
|
||||||
| ✅ | [Extending](#extending) | Extend OpenFeature with custom providers and hooks. |
|
| ✅ | [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. |
|
||||||
|
| ✅ | [Extending](#extending) | Extend OpenFeature with custom providers and hooks. |
|
||||||
|
|
||||||
<sub>Implemented: ✅ | In-progress: ⚠️ | Not implemented yet: ❌</sub>
|
<sub>Implemented: ✅ | In-progress: ⚠️ | Not implemented yet: ❌</sub>
|
||||||
|
|
||||||
|
@ -153,6 +166,9 @@ const requestContext = {
|
||||||
const boolValue = await client.getBooleanValue('some-flag', false, requestContext);
|
const boolValue = await client.getBooleanValue('some-flag', false, requestContext);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Context is merged by the SDK before a flag evaluation occurs.
|
||||||
|
The merge order is defined [here](https://openfeature.dev/specification/sections/evaluation-context#requirement-323) in the OpenFeature specification.
|
||||||
|
|
||||||
### Hooks
|
### Hooks
|
||||||
|
|
||||||
[Hooks](https://openfeature.dev/docs/reference/concepts/hooks) allow for custom logic to be added at well-defined points of the flag evaluation life-cycle.
|
[Hooks](https://openfeature.dev/docs/reference/concepts/hooks) allow for custom logic to be added at well-defined points of the flag evaluation life-cycle.
|
||||||
|
@ -278,6 +294,21 @@ app.use((req: Request, res: Response, next: NextFunction) => {
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Tracking
|
||||||
|
|
||||||
|
The tracking API allows you to use OpenFeature abstractions and objects to associate user actions with feature flag evaluations.
|
||||||
|
This is essential for robust experimentation powered by feature flags.
|
||||||
|
For example, a flag enhancing the appearance of a UI component might drive user engagement to a new feature; to test this hypothesis, telemetry collected by a [hook](#hooks) or [provider](#providers) can be associated with telemetry reported in the client's `track` function.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// flag is evaluated
|
||||||
|
await client.getBooleanValue('new-feature', false);
|
||||||
|
|
||||||
|
// new feature is used and track function is called recording the usage
|
||||||
|
useNewFeature();
|
||||||
|
client.track('new-feature-used');
|
||||||
|
```
|
||||||
|
|
||||||
### Shutdown
|
### Shutdown
|
||||||
|
|
||||||
The OpenFeature API provides a close function to perform a cleanup of all registered providers.
|
The OpenFeature API provides a close function to perform a cleanup of all registered providers.
|
||||||
|
@ -356,9 +387,17 @@ import type { Hook, HookContext, EvaluationDetails, FlagValue } from "@openfeatu
|
||||||
|
|
||||||
export class MyHook implements Hook {
|
export class MyHook implements Hook {
|
||||||
after(hookContext: HookContext, evaluationDetails: EvaluationDetails<FlagValue>) {
|
after(hookContext: HookContext, evaluationDetails: EvaluationDetails<FlagValue>) {
|
||||||
// code that runs when there's an error during a flag evaluation
|
// code that runs after flag values are successfully resolved from the provider
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> Built a new hook? [Let us know](https://github.com/open-feature/openfeature.dev/issues/new?assignees=&labels=hook&projects=&template=document-hook.yaml&title=%5BHook%5D%3A+) so we can add it to the docs!
|
> Built a new hook? [Let us know](https://github.com/open-feature/openfeature.dev/issues/new?assignees=&labels=hook&projects=&template=document-hook.yaml&title=%5BHook%5D%3A+) so we can add it to the docs!
|
||||||
|
|
||||||
|
### Considerations
|
||||||
|
|
||||||
|
When developing a library based on OpenFeature components, it's important to list the `@openfeature/server-sdk` as a `peerDependency` of your package.
|
||||||
|
This is a general best-practice when developing JavaScript libraries that have dependencies in common with their consuming application.
|
||||||
|
Failing to do this can result in multiple copies of the OpenFeature SDK in the consumer, which can lead to type errors, and broken singleton behavior.
|
||||||
|
The `@openfeature/core` package itself follows this pattern: the `@openfeature/server-sdk` has a peer dependency on `@openfeature/core`, and uses whatever copy of that module the consumer has installed (note that NPM installs peers automatically unless `--legacy-peer-deps` is set, while yarn does not, and PNPM does so based on its configuration).
|
||||||
|
When developing such libraries, it's NOT necessary to add a `peerDependency` on `@openfeature/core`, since the `@openfeature/server-sdk` establishes that dependency itself transitively.
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import {
|
import type {
|
||||||
EvaluationContext,
|
EvaluationContext,
|
||||||
EvaluationDetails,
|
EvaluationDetails,
|
||||||
JsonObject,
|
JsonObject,
|
||||||
JsonValue,
|
JsonValue,
|
||||||
ResolutionDetails,
|
ResolutionDetails} from '@openfeature/core';
|
||||||
|
import {
|
||||||
StandardResolutionReasons,
|
StandardResolutionReasons,
|
||||||
} from '@openfeature/core';
|
} from '@openfeature/core';
|
||||||
import { defineFeature, loadFeature } from 'jest-cucumber';
|
import { defineFeature, loadFeature } from 'jest-cucumber';
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@openfeature/server-sdk",
|
"name": "@openfeature/server-sdk",
|
||||||
"version": "1.13.0",
|
"version": "1.19.0",
|
||||||
"description": "OpenFeature SDK for JavaScript",
|
"description": "OpenFeature SDK for JavaScript",
|
||||||
"main": "./dist/cjs/index.js",
|
"main": "./dist/cjs/index.js",
|
||||||
"files": [
|
"files": [
|
||||||
|
@ -16,16 +16,16 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "jest --verbose",
|
"test": "jest --verbose",
|
||||||
"lint": "eslint ./",
|
"lint": "eslint ./",
|
||||||
|
"lint:fix": "eslint ./ --fix",
|
||||||
"clean": "shx rm -rf ./dist",
|
"clean": "shx rm -rf ./dist",
|
||||||
"build:esm": "esbuild src/index.ts --bundle --external:@openfeature/core --sourcemap --target=es2022 --platform=node --format=esm --outfile=./dist/esm/index.js --analyze",
|
"build:esm": "esbuild src/index.ts --bundle --external:@openfeature/core --sourcemap --target=es2015 --platform=node --format=esm --outfile=./dist/esm/index.js --analyze",
|
||||||
"build:cjs": "esbuild src/index.ts --bundle --external:@openfeature/core --sourcemap --target=es2022 --platform=node --format=cjs --outfile=./dist/cjs/index.js --analyze",
|
"build:cjs": "esbuild src/index.ts --bundle --external:@openfeature/core --sourcemap --target=es2015 --platform=node --format=cjs --outfile=./dist/cjs/index.js --analyze",
|
||||||
"build:rollup-types": "rollup -c ../../rollup.config.mjs",
|
"build:rollup-types": "rollup -c ../../rollup.config.mjs",
|
||||||
"build": "npm run clean && npm run build:esm && npm run build:cjs && npm run build:rollup-types",
|
"build": "npm run clean && npm run build:esm && npm run build:cjs && npm run build:rollup-types",
|
||||||
"postbuild": "shx cp ./../../package.esm.json ./dist/esm/package.json",
|
"postbuild": "shx cp ./../../package.esm.json ./dist/esm/package.json",
|
||||||
"current-version": "echo $npm_package_version",
|
"current-version": "echo $npm_package_version",
|
||||||
"prepack": "shx cp ./../../LICENSE ./LICENSE",
|
"prepack": "shx cp ./../../LICENSE ./LICENSE",
|
||||||
"publish-if-not-exists": "cp $NPM_CONFIG_USERCONFIG .npmrc && if [ \"$(npm show $npm_package_name@$npm_package_version version)\" = \"$(npm run current-version -s)\" ]; then echo 'already published, skipping'; else npm publish --access public; fi",
|
"publish-if-not-exists": "cp $NPM_CONFIG_USERCONFIG .npmrc && if [ \"$(npm show $npm_package_name@$npm_package_version version)\" = \"$(npm run current-version -s)\" ]; then echo 'already published, skipping'; else npm publish --access public; fi"
|
||||||
"update-core-peer": "npm install --save-peer --save-exact @openfeature/core@$OPENFEATURE_CORE_VERSION"
|
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
@ -45,12 +45,12 @@
|
||||||
},
|
},
|
||||||
"homepage": "https://github.com/open-feature/js-sdk#readme",
|
"homepage": "https://github.com/open-feature/js-sdk#readme",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@openfeature/core": "0.0.27"
|
"@openfeature/core": "^1.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@openfeature/core": "0.0.27"
|
"@openfeature/core": "^1.9.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,19 +1,21 @@
|
||||||
import {
|
import type {
|
||||||
ClientMetadata,
|
ClientMetadata,
|
||||||
EvaluationLifeCycle,
|
EvaluationLifeCycle,
|
||||||
Eventing,
|
Eventing,
|
||||||
ManageContext,
|
ManageContext,
|
||||||
ManageLogger,
|
ManageLogger,
|
||||||
} from '@openfeature/core';
|
} from '@openfeature/core';
|
||||||
import { Features } from '../evaluation';
|
import type { Features } from '../evaluation';
|
||||||
import { ProviderStatus } from '../provider';
|
import type { ProviderStatus } from '../provider';
|
||||||
import { ProviderEvents } from '../events';
|
import type { ProviderEvents } from '../events';
|
||||||
|
import type { Tracking } from '../tracking';
|
||||||
|
|
||||||
export interface Client
|
export interface Client
|
||||||
extends EvaluationLifeCycle<Client>,
|
extends EvaluationLifeCycle<Client>,
|
||||||
Features,
|
Features,
|
||||||
ManageContext<Client>,
|
ManageContext<Client>,
|
||||||
ManageLogger<Client>,
|
ManageLogger<Client>,
|
||||||
|
Tracking,
|
||||||
Eventing<ProviderEvents> {
|
Eventing<ProviderEvents> {
|
||||||
readonly metadata: ClientMetadata;
|
readonly metadata: ClientMetadata;
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -1,2 +1 @@
|
||||||
export * from './client';
|
export * from './client';
|
||||||
export * from './open-feature-client';
|
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import {
|
import type {
|
||||||
ClientMetadata,
|
ClientMetadata,
|
||||||
ErrorCode,
|
|
||||||
EvaluationContext,
|
EvaluationContext,
|
||||||
EvaluationDetails,
|
EvaluationDetails,
|
||||||
EventHandler,
|
EventHandler,
|
||||||
|
@ -9,22 +8,29 @@ import {
|
||||||
HookContext,
|
HookContext,
|
||||||
JsonValue,
|
JsonValue,
|
||||||
Logger,
|
Logger,
|
||||||
ManageContext,
|
TrackingEventDetails,
|
||||||
OpenFeatureError,
|
OpenFeatureError,
|
||||||
|
FlagMetadata,
|
||||||
|
ResolutionDetails,
|
||||||
|
EventOptions,
|
||||||
|
} from '@openfeature/core';
|
||||||
|
import {
|
||||||
|
ErrorCode,
|
||||||
ProviderFatalError,
|
ProviderFatalError,
|
||||||
ProviderNotReadyError,
|
ProviderNotReadyError,
|
||||||
ResolutionDetails,
|
|
||||||
SafeLogger,
|
SafeLogger,
|
||||||
StandardResolutionReasons,
|
StandardResolutionReasons,
|
||||||
|
instantiateErrorByErrorCode,
|
||||||
statusMatchesEvent,
|
statusMatchesEvent,
|
||||||
|
MapHookData,
|
||||||
} from '@openfeature/core';
|
} from '@openfeature/core';
|
||||||
import { FlagEvaluationOptions } from '../evaluation';
|
import type { FlagEvaluationOptions } from '../../evaluation';
|
||||||
import { ProviderEvents } from '../events';
|
import type { ProviderEvents } from '../../events';
|
||||||
import { InternalEventEmitter } from '../events/internal/internal-event-emitter';
|
import type { InternalEventEmitter } from '../../events/internal/internal-event-emitter';
|
||||||
import { Hook } from '../hooks';
|
import type { Hook } from '../../hooks';
|
||||||
import { OpenFeature } from '../open-feature';
|
import type { Provider } from '../../provider';
|
||||||
import { Provider, ProviderStatus } from '../provider';
|
import { ProviderStatus } from '../../provider';
|
||||||
import { Client } from './client';
|
import type { Client } from './../client';
|
||||||
|
|
||||||
type OpenFeatureClientOptions = {
|
type OpenFeatureClientOptions = {
|
||||||
/**
|
/**
|
||||||
|
@ -35,7 +41,12 @@ type OpenFeatureClientOptions = {
|
||||||
version?: string;
|
version?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class OpenFeatureClient implements Client, ManageContext<OpenFeatureClient> {
|
/**
|
||||||
|
* This implementation of the {@link Client} is meant to only be instantiated by the SDK.
|
||||||
|
* It should not be used outside the SDK and so should not be exported.
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export class OpenFeatureClient implements Client {
|
||||||
private _context: EvaluationContext;
|
private _context: EvaluationContext;
|
||||||
private _hooks: Hook[] = [];
|
private _hooks: Hook[] = [];
|
||||||
private _clientLogger?: Logger;
|
private _clientLogger?: Logger;
|
||||||
|
@ -46,6 +57,9 @@ export class OpenFeatureClient implements Client, ManageContext<OpenFeatureClien
|
||||||
private readonly providerAccessor: () => Provider,
|
private readonly providerAccessor: () => Provider,
|
||||||
private readonly providerStatusAccessor: () => ProviderStatus,
|
private readonly providerStatusAccessor: () => ProviderStatus,
|
||||||
private readonly emitterAccessor: () => InternalEventEmitter,
|
private readonly emitterAccessor: () => InternalEventEmitter,
|
||||||
|
private readonly apiContextAccessor: () => EvaluationContext,
|
||||||
|
private readonly apiHooksAccessor: () => Hook[],
|
||||||
|
private readonly transactionContextAccessor: () => EvaluationContext,
|
||||||
private readonly globalLogger: () => Logger,
|
private readonly globalLogger: () => Logger,
|
||||||
private readonly options: OpenFeatureClientOptions,
|
private readonly options: OpenFeatureClientOptions,
|
||||||
context: EvaluationContext = {},
|
context: EvaluationContext = {},
|
||||||
|
@ -67,7 +81,7 @@ export class OpenFeatureClient implements Client, ManageContext<OpenFeatureClien
|
||||||
return this.providerStatusAccessor();
|
return this.providerStatusAccessor();
|
||||||
}
|
}
|
||||||
|
|
||||||
addHandler(eventType: ProviderEvents, handler: EventHandler): void {
|
addHandler(eventType: ProviderEvents, handler: EventHandler, options?: EventOptions): void {
|
||||||
this.emitterAccessor().addHandler(eventType, handler);
|
this.emitterAccessor().addHandler(eventType, handler);
|
||||||
const shouldRunNow = statusMatchesEvent(eventType, this._providerStatus);
|
const shouldRunNow = statusMatchesEvent(eventType, this._providerStatus);
|
||||||
|
|
||||||
|
@ -83,6 +97,12 @@ export class OpenFeatureClient implements Client, ManageContext<OpenFeatureClien
|
||||||
this._logger?.error('Error running event handler:', err);
|
this._logger?.error('Error running event handler:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options?.signal && typeof options.signal.addEventListener === 'function') {
|
||||||
|
options.signal.addEventListener('abort', () => {
|
||||||
|
this.removeHandler(eventType, handler);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
removeHandler(eventType: ProviderEvents, handler: EventHandler) {
|
removeHandler(eventType: ProviderEvents, handler: EventHandler) {
|
||||||
|
@ -216,6 +236,22 @@ export class OpenFeatureClient implements Client, ManageContext<OpenFeatureClien
|
||||||
return this.evaluate<T>(flagKey, this._provider.resolveObjectEvaluation, defaultValue, 'object', context, options);
|
return this.evaluate<T>(flagKey, this._provider.resolveObjectEvaluation, defaultValue, 'object', context, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
track(occurrenceKey: string, context: EvaluationContext = {}, occurrenceDetails: TrackingEventDetails = {}): void {
|
||||||
|
try {
|
||||||
|
this.shortCircuitIfNotReady();
|
||||||
|
|
||||||
|
if (typeof this._provider.track === 'function') {
|
||||||
|
// freeze the merged context
|
||||||
|
const frozenContext = Object.freeze(this.mergeContexts(context));
|
||||||
|
return this._provider.track?.(occurrenceKey, frozenContext, occurrenceDetails);
|
||||||
|
} else {
|
||||||
|
this._logger.debug('Provider does not support the track function; will no-op.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this._logger.debug('Error recording tracking event.', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async evaluate<T extends FlagValue>(
|
private async evaluate<T extends FlagValue>(
|
||||||
flagKey: string,
|
flagKey: string,
|
||||||
resolver: (
|
resolver: (
|
||||||
|
@ -232,106 +268,114 @@ export class OpenFeatureClient implements Client, ManageContext<OpenFeatureClien
|
||||||
// merge global, client, and evaluation context
|
// merge global, client, and evaluation context
|
||||||
|
|
||||||
const allHooks = [
|
const allHooks = [
|
||||||
...OpenFeature.getHooks(),
|
...this.apiHooksAccessor(),
|
||||||
...this.getHooks(),
|
...this.getHooks(),
|
||||||
...(options.hooks || []),
|
...(options.hooks || []),
|
||||||
...(this._provider.hooks || []),
|
...(this._provider.hooks || []),
|
||||||
];
|
];
|
||||||
const allHooksReversed = [...allHooks].reverse();
|
const allHooksReversed = [...allHooks].reverse();
|
||||||
|
|
||||||
// merge global and client contexts
|
const mergedContext = this.mergeContexts(invocationContext);
|
||||||
const mergedContext = {
|
|
||||||
...OpenFeature.getContext(),
|
|
||||||
...OpenFeature.getTransactionContext(),
|
|
||||||
...this._context,
|
|
||||||
...invocationContext,
|
|
||||||
};
|
|
||||||
|
|
||||||
// this reference cannot change during the course of evaluation
|
// Create hook context instances for each hook (stable object references for the entire evaluation)
|
||||||
// it may be used as a key in WeakMaps
|
// This ensures hooks can use WeakMaps with hookContext as keys across lifecycle methods
|
||||||
const hookContext: Readonly<HookContext> = {
|
// NOTE: Uses the reversed order to reduce the number of times we have to calculate the index.
|
||||||
flagKey,
|
const hookContexts = allHooksReversed.map<HookContext>(() =>
|
||||||
defaultValue,
|
Object.freeze({
|
||||||
flagValueType: flagType,
|
flagKey,
|
||||||
clientMetadata: this.metadata,
|
defaultValue,
|
||||||
providerMetadata: OpenFeature.providerMetadata,
|
flagValueType: flagType,
|
||||||
context: mergedContext,
|
clientMetadata: this.metadata,
|
||||||
logger: this._logger,
|
providerMetadata: this._provider.metadata,
|
||||||
};
|
context: mergedContext,
|
||||||
|
logger: this._logger,
|
||||||
|
hookData: new MapHookData(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let evaluationDetails: EvaluationDetails<T>;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const frozenContext = await this.beforeHooks(allHooks, hookContext, options);
|
const frozenContext = await this.beforeHooks(allHooks, hookContexts, mergedContext, options);
|
||||||
|
|
||||||
// short circuit evaluation entirely if provider is in a bad state
|
this.shortCircuitIfNotReady();
|
||||||
if (this.providerStatus === ProviderStatus.NOT_READY) {
|
|
||||||
throw new ProviderNotReadyError('provider has not yet initialized');
|
|
||||||
} else if (this.providerStatus === ProviderStatus.FATAL) {
|
|
||||||
throw new ProviderFatalError('provider is in an irrecoverable error state');
|
|
||||||
}
|
|
||||||
|
|
||||||
// run the referenced resolver, binding the provider.
|
// run the referenced resolver, binding the provider.
|
||||||
const resolution = await resolver.call(this._provider, flagKey, defaultValue, frozenContext, this._logger);
|
const resolution = await resolver.call(this._provider, flagKey, defaultValue, frozenContext, this._logger);
|
||||||
|
|
||||||
const evaluationDetails = {
|
const resolutionDetails = {
|
||||||
...resolution,
|
...resolution,
|
||||||
flagMetadata: Object.freeze(resolution.flagMetadata ?? {}),
|
flagMetadata: Object.freeze(resolution.flagMetadata ?? {}),
|
||||||
flagKey,
|
flagKey,
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.afterHooks(allHooksReversed, hookContext, evaluationDetails, options);
|
if (resolutionDetails.errorCode) {
|
||||||
|
const err = instantiateErrorByErrorCode(resolutionDetails.errorCode, resolutionDetails.errorMessage);
|
||||||
return evaluationDetails;
|
await this.errorHooks(allHooksReversed, hookContexts, err, options);
|
||||||
|
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err, resolutionDetails.flagMetadata);
|
||||||
|
} else {
|
||||||
|
await this.afterHooks(allHooksReversed, hookContexts, resolutionDetails, options);
|
||||||
|
evaluationDetails = resolutionDetails;
|
||||||
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const errorMessage: string = (err as Error)?.message;
|
await this.errorHooks(allHooksReversed, hookContexts, err, options);
|
||||||
const errorCode: ErrorCode = (err as OpenFeatureError)?.code || ErrorCode.GENERAL;
|
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err);
|
||||||
|
|
||||||
await this.errorHooks(allHooksReversed, hookContext, err, options);
|
|
||||||
|
|
||||||
return {
|
|
||||||
errorCode,
|
|
||||||
errorMessage,
|
|
||||||
value: defaultValue,
|
|
||||||
reason: StandardResolutionReasons.ERROR,
|
|
||||||
flagMetadata: Object.freeze({}),
|
|
||||||
flagKey,
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
await this.finallyHooks(allHooksReversed, hookContext, options);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.finallyHooks(allHooksReversed, hookContexts, evaluationDetails, options);
|
||||||
|
return evaluationDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async beforeHooks(hooks: Hook[], hookContext: HookContext, options: FlagEvaluationOptions) {
|
private async beforeHooks(
|
||||||
for (const hook of hooks) {
|
hooks: Hook[],
|
||||||
// freeze the hookContext
|
hookContexts: HookContext[],
|
||||||
Object.freeze(hookContext);
|
mergedContext: EvaluationContext,
|
||||||
|
options: FlagEvaluationOptions,
|
||||||
|
) {
|
||||||
|
let accumulatedContext = mergedContext;
|
||||||
|
|
||||||
// use Object.assign to avoid modification of frozen hookContext
|
for (const [index, hook] of hooks.entries()) {
|
||||||
Object.assign(hookContext.context, {
|
const hookContextIndex = hooks.length - 1 - index; // reverse index for before hooks
|
||||||
...hookContext.context,
|
const hookContext = hookContexts[hookContextIndex];
|
||||||
...(await hook?.before?.(hookContext, Object.freeze(options.hookHints))),
|
|
||||||
});
|
// Update the context on the stable hook context object
|
||||||
|
Object.assign(hookContext.context, accumulatedContext);
|
||||||
|
|
||||||
|
const hookResult = await hook?.before?.(hookContext, Object.freeze(options.hookHints));
|
||||||
|
if (hookResult) {
|
||||||
|
accumulatedContext = {
|
||||||
|
...accumulatedContext,
|
||||||
|
...hookResult,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < hooks.length; i++) {
|
||||||
|
Object.assign(hookContexts[hookContextIndex].context, accumulatedContext);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// after before hooks, freeze the EvaluationContext.
|
// after before hooks, freeze the EvaluationContext.
|
||||||
return Object.freeze(hookContext.context);
|
return Object.freeze(accumulatedContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async afterHooks(
|
private async afterHooks(
|
||||||
hooks: Hook[],
|
hooks: Hook[],
|
||||||
hookContext: HookContext,
|
hookContexts: HookContext[],
|
||||||
evaluationDetails: EvaluationDetails<FlagValue>,
|
evaluationDetails: EvaluationDetails<FlagValue>,
|
||||||
options: FlagEvaluationOptions,
|
options: FlagEvaluationOptions,
|
||||||
) {
|
) {
|
||||||
// run "after" hooks sequentially
|
// run "after" hooks sequentially
|
||||||
for (const hook of hooks) {
|
for (const [index, hook] of hooks.entries()) {
|
||||||
|
const hookContext = hookContexts[index];
|
||||||
await hook?.after?.(hookContext, evaluationDetails, options.hookHints);
|
await hook?.after?.(hookContext, evaluationDetails, options.hookHints);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async errorHooks(hooks: Hook[], hookContext: HookContext, err: unknown, options: FlagEvaluationOptions) {
|
private async errorHooks(hooks: Hook[], hookContexts: HookContext[], err: unknown, options: FlagEvaluationOptions) {
|
||||||
// run "error" hooks sequentially
|
// run "error" hooks sequentially
|
||||||
for (const hook of hooks) {
|
for (const [index, hook] of hooks.entries()) {
|
||||||
try {
|
try {
|
||||||
|
const hookContext = hookContexts[index];
|
||||||
await hook?.error?.(hookContext, err, options.hookHints);
|
await hook?.error?.(hookContext, err, options.hookHints);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this._logger.error(`Unhandled error during 'error' hook: ${err}`);
|
this._logger.error(`Unhandled error during 'error' hook: ${err}`);
|
||||||
|
@ -343,11 +387,17 @@ export class OpenFeatureClient implements Client, ManageContext<OpenFeatureClien
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async finallyHooks(hooks: Hook[], hookContext: HookContext, options: FlagEvaluationOptions) {
|
private async finallyHooks(
|
||||||
|
hooks: Hook[],
|
||||||
|
hookContexts: HookContext[],
|
||||||
|
evaluationDetails: EvaluationDetails<FlagValue>,
|
||||||
|
options: FlagEvaluationOptions,
|
||||||
|
) {
|
||||||
// run "finally" hooks sequentially
|
// run "finally" hooks sequentially
|
||||||
for (const hook of hooks) {
|
for (const [index, hook] of hooks.entries()) {
|
||||||
try {
|
try {
|
||||||
await hook?.finally?.(hookContext, options.hookHints);
|
const hookContext = hookContexts[index];
|
||||||
|
await hook?.finally?.(hookContext, evaluationDetails, options.hookHints);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this._logger.error(`Unhandled error during 'finally' hook: ${err}`);
|
this._logger.error(`Unhandled error during 'finally' hook: ${err}`);
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
|
@ -369,4 +419,42 @@ export class OpenFeatureClient implements Client, ManageContext<OpenFeatureClien
|
||||||
private get _logger() {
|
private get _logger() {
|
||||||
return this._clientLogger || this.globalLogger();
|
return this._clientLogger || this.globalLogger();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private mergeContexts(invocationContext: EvaluationContext) {
|
||||||
|
// merge global and client contexts
|
||||||
|
return {
|
||||||
|
...this.apiContextAccessor(),
|
||||||
|
...this.transactionContextAccessor(),
|
||||||
|
...this._context,
|
||||||
|
...invocationContext,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private shortCircuitIfNotReady() {
|
||||||
|
// short circuit evaluation entirely if provider is in a bad state
|
||||||
|
if (this.providerStatus === ProviderStatus.NOT_READY) {
|
||||||
|
throw new ProviderNotReadyError('provider has not yet initialized');
|
||||||
|
} else if (this.providerStatus === ProviderStatus.FATAL) {
|
||||||
|
throw new ProviderFatalError('provider is in an irrecoverable error state');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getErrorEvaluationDetails<T extends FlagValue>(
|
||||||
|
flagKey: string,
|
||||||
|
defaultValue: T,
|
||||||
|
err: unknown,
|
||||||
|
flagMetadata: FlagMetadata = {},
|
||||||
|
): EvaluationDetails<T> {
|
||||||
|
const errorMessage: string = (err as Error)?.message;
|
||||||
|
const errorCode: ErrorCode = (err as OpenFeatureError)?.code || ErrorCode.GENERAL;
|
||||||
|
|
||||||
|
return {
|
||||||
|
errorCode,
|
||||||
|
errorMessage,
|
||||||
|
value: defaultValue,
|
||||||
|
reason: StandardResolutionReasons.ERROR,
|
||||||
|
flagMetadata: Object.freeze(flagMetadata),
|
||||||
|
flagKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue