Compare commits
320 Commits
web-sdk-v0
...
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 | |
|
a37a33cbf0 | |
|
21f72b98be | |
|
b3d7f37203 | |
|
ccbb1f9c97 | |
|
f451e255bf | |
|
b1abef1a2b | |
|
8101ff197f | |
|
411c7b4265 | |
|
33508f15d7 | |
|
cfb0a69c42 | |
|
016908cd08 | |
|
427ba883f5 | |
|
5637cb2776 | |
|
5dbcac4324 | |
|
22447eeccb | |
|
8a5cb3a6f9 | |
|
840d7acaa3 |
113
.eslintrc.json
113
.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
|
||||||
|
|
|
@ -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.0.5-experimental",
|
"packages/nest": "0.2.5",
|
||||||
"packages/react": "0.1.1-experimental",
|
"packages/react": "1.0.1",
|
||||||
"packages/client": "0.4.13",
|
"packages/web": "1.6.1",
|
||||||
"packages/server": "1.12.0",
|
"packages/server": "1.19.0",
|
||||||
"packages/shared": "0.0.26"
|
"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,10 +172,28 @@ 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
|
||||||
reporters: [['github-actions', { silent: false }], 'summary'],
|
reporters: ['default', ['github-actions', { silent: false }], 'summary'],
|
||||||
|
|
||||||
// Automatically reset mock state before every test
|
// Automatically reset mock state before every test
|
||||||
// resetMocks: false,
|
// resetMocks: false,
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
61
package.json
61
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 --verbose",
|
"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,10 +0,0 @@
|
||||||
import { ClientMetadata, EvaluationLifeCycle, Eventing, ManageLogger, ProviderStatus } from '@openfeature/core';
|
|
||||||
import { Features } from '../evaluation';
|
|
||||||
|
|
||||||
export interface Client extends EvaluationLifeCycle<Client>, Features, ManageLogger<Client>, Eventing {
|
|
||||||
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,236 +0,0 @@
|
||||||
import {
|
|
||||||
EvaluationContext,
|
|
||||||
GenericEventEmitter,
|
|
||||||
ManageContext,
|
|
||||||
OpenFeatureCommonAPI,
|
|
||||||
objectOrUndefined,
|
|
||||||
stringOrUndefined,
|
|
||||||
} from '@openfeature/core';
|
|
||||||
import { Client, OpenFeatureClient } from './client';
|
|
||||||
import { OpenFeatureEventEmitter, ProviderEvents } from './events';
|
|
||||||
import { Hook } from './hooks';
|
|
||||||
import { NOOP_PROVIDER, Provider } from './provider';
|
|
||||||
|
|
||||||
// use a symbol as a key for the global singleton
|
|
||||||
const GLOBAL_OPENFEATURE_API_KEY = Symbol.for('@openfeature/web-sdk/api');
|
|
||||||
|
|
||||||
type OpenFeatureGlobal = {
|
|
||||||
[GLOBAL_OPENFEATURE_API_KEY]?: OpenFeatureAPI;
|
|
||||||
};
|
|
||||||
type DomainRecord = {
|
|
||||||
domain?: string;
|
|
||||||
provider: Provider;
|
|
||||||
};
|
|
||||||
|
|
||||||
const _globalThis = globalThis as OpenFeatureGlobal;
|
|
||||||
|
|
||||||
export class OpenFeatureAPI extends OpenFeatureCommonAPI<Provider, Hook> implements ManageContext<Promise<void>> {
|
|
||||||
protected _events: GenericEventEmitter<ProviderEvents> = new OpenFeatureEventEmitter();
|
|
||||||
protected _defaultProvider: Provider = NOOP_PROVIDER;
|
|
||||||
protected _createEventEmitter = () => new OpenFeatureEventEmitter();
|
|
||||||
|
|
||||||
private constructor() {
|
|
||||||
super('client');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets a singleton instance of the OpenFeature API.
|
|
||||||
* @ignore
|
|
||||||
* @returns {OpenFeatureAPI} OpenFeature API
|
|
||||||
*/
|
|
||||||
static getInstance(): OpenFeatureAPI {
|
|
||||||
const globalApi = _globalThis[GLOBAL_OPENFEATURE_API_KEY];
|
|
||||||
if (globalApi) {
|
|
||||||
return globalApi;
|
|
||||||
}
|
|
||||||
|
|
||||||
const instance = new OpenFeatureAPI();
|
|
||||||
_globalThis[GLOBAL_OPENFEATURE_API_KEY] = instance;
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets the evaluation context globally.
|
|
||||||
* This will be used by all providers that have not bound to a domain.
|
|
||||||
* @param {EvaluationContext} context Evaluation context
|
|
||||||
* @example
|
|
||||||
* await OpenFeature.setContext({ region: "us" });
|
|
||||||
*/
|
|
||||||
async setContext(context: EvaluationContext): Promise<void>;
|
|
||||||
/**
|
|
||||||
* Sets the evaluation context for a specific provider.
|
|
||||||
* This will only affect providers bound to a domain.
|
|
||||||
* @param {string} domain An identifier which logically binds clients with providers
|
|
||||||
* @param {EvaluationContext} context Evaluation context
|
|
||||||
* @example
|
|
||||||
* await OpenFeature.setContext("test", { scope: "provider" });
|
|
||||||
* OpenFeature.setProvider(new MyProvider()) // Uses the default context
|
|
||||||
* OpenFeature.setProvider("test", new MyProvider()) // Uses context: { scope: "provider" }
|
|
||||||
*/
|
|
||||||
async setContext(domain: string, context: EvaluationContext): Promise<void>;
|
|
||||||
async setContext<T extends EvaluationContext>(domainOrContext: T | string, contextOrUndefined?: T): Promise<void> {
|
|
||||||
const domain = stringOrUndefined(domainOrContext);
|
|
||||||
const context = objectOrUndefined<T>(domainOrContext) ?? objectOrUndefined(contextOrUndefined) ?? {};
|
|
||||||
|
|
||||||
if (domain) {
|
|
||||||
const provider = this._domainScopedProviders.get(domain);
|
|
||||||
if (provider) {
|
|
||||||
const oldContext = this.getContext(domain);
|
|
||||||
this._domainScopedContext.set(domain, context);
|
|
||||||
await this.runProviderContextChangeHandler(domain, provider, oldContext, context);
|
|
||||||
} else {
|
|
||||||
this._domainScopedContext.set(domain, context);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const oldContext = this._context;
|
|
||||||
this._context = context;
|
|
||||||
|
|
||||||
// collect all providers that are using the default context (not bound to a domain)
|
|
||||||
const unboundProviders: DomainRecord[] = Array.from(this._domainScopedProviders.entries())
|
|
||||||
.filter(([domain]) => !this._domainScopedContext.has(domain))
|
|
||||||
.reduce<DomainRecord[]>((acc, [domain, provider]) => {
|
|
||||||
acc.push({ domain, provider });
|
|
||||||
return acc;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const allProviders: DomainRecord[] = [
|
|
||||||
// add in the default (no domain)
|
|
||||||
{ domain: undefined, provider: this._defaultProvider },
|
|
||||||
...unboundProviders,
|
|
||||||
];
|
|
||||||
await Promise.all(
|
|
||||||
allProviders.map((tuple) =>
|
|
||||||
this.runProviderContextChangeHandler(tuple.domain, tuple.provider, oldContext, context),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Access the global evaluation context.
|
|
||||||
* @returns {EvaluationContext} Evaluation context
|
|
||||||
*/
|
|
||||||
getContext(): EvaluationContext;
|
|
||||||
/**
|
|
||||||
* Access the evaluation context for a specific named client.
|
|
||||||
* The global evaluation context is returned if a matching named client is not found.
|
|
||||||
* @param {string} domain An identifier which logically binds clients with providers
|
|
||||||
* @returns {EvaluationContext} Evaluation context
|
|
||||||
*/
|
|
||||||
getContext(domain?: string): EvaluationContext;
|
|
||||||
getContext(domainOrUndefined?: string): EvaluationContext {
|
|
||||||
const domain = stringOrUndefined(domainOrUndefined);
|
|
||||||
if (domain) {
|
|
||||||
const context = this._domainScopedContext.get(domain);
|
|
||||||
if (context) {
|
|
||||||
return context;
|
|
||||||
} else {
|
|
||||||
this._logger.debug(`Unable to find context for '${domain}'.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return this._context;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resets the global evaluation context to an empty object.
|
|
||||||
*/
|
|
||||||
clearContext(): Promise<void>;
|
|
||||||
/**
|
|
||||||
* Removes the evaluation context for a specific named client.
|
|
||||||
* @param {string} domain An identifier which logically binds clients with providers
|
|
||||||
*/
|
|
||||||
clearContext(domain: string): Promise<void>;
|
|
||||||
async clearContext(domainOrUndefined?: string): Promise<void> {
|
|
||||||
const domain = stringOrUndefined(domainOrUndefined);
|
|
||||||
if (domain) {
|
|
||||||
const provider = this._domainScopedProviders.get(domain);
|
|
||||||
if (provider) {
|
|
||||||
const oldContext = this.getContext(domain);
|
|
||||||
this._domainScopedContext.delete(domain);
|
|
||||||
const newContext = this.getContext();
|
|
||||||
await this.runProviderContextChangeHandler(domain, provider, oldContext, newContext);
|
|
||||||
} else {
|
|
||||||
this._domainScopedContext.delete(domain);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return this.setContext({});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resets the global evaluation context and removes the evaluation context for
|
|
||||||
* all domains.
|
|
||||||
*/
|
|
||||||
async clearContexts(): Promise<void> {
|
|
||||||
// Default context must be cleared first to avoid calling the onContextChange
|
|
||||||
// handler multiple times for clients bound to a domain.
|
|
||||||
await this.clearContext();
|
|
||||||
|
|
||||||
// Use allSettled so a promise rejection doesn't affect others
|
|
||||||
await Promise.allSettled(Array.from(this._domainScopedProviders.keys()).map((domain) => this.clearContext(domain)));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A factory function for creating new named OpenFeature clients. Clients can contain
|
|
||||||
* their own state (e.g. logger, hook, context). Multiple clients can be used
|
|
||||||
* to segment feature flag configuration.
|
|
||||||
*
|
|
||||||
* If there is already a provider bound to this name via {@link this.setProvider setProvider}, this provider will be used.
|
|
||||||
* Otherwise, the default provider is used until a provider is assigned to that name.
|
|
||||||
* @param {string} domain An identifier which logically binds clients with providers
|
|
||||||
* @param {string} version The version of the client (only used for metadata)
|
|
||||||
* @returns {Client} OpenFeature Client
|
|
||||||
*/
|
|
||||||
getClient(domain?: string, version?: string): Client {
|
|
||||||
return new OpenFeatureClient(
|
|
||||||
// functions are passed here to make sure that these values are always up to date,
|
|
||||||
// and so we don't have to make these public properties on the API class.
|
|
||||||
() => this.getProviderForClient(domain),
|
|
||||||
() => this.buildAndCacheEventEmitterForClient(domain),
|
|
||||||
() => this._logger,
|
|
||||||
{ domain, version },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clears all registered providers and resets the default provider.
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
async clearProviders(): Promise<void> {
|
|
||||||
await super.clearProvidersAndSetDefault(NOOP_PROVIDER);
|
|
||||||
this._domainScopedContext.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async runProviderContextChangeHandler(
|
|
||||||
domain: string | undefined,
|
|
||||||
provider: Provider,
|
|
||||||
oldContext: EvaluationContext,
|
|
||||||
newContext: EvaluationContext,
|
|
||||||
): Promise<void> {
|
|
||||||
const providerName = provider.metadata.name;
|
|
||||||
try {
|
|
||||||
await provider.onContextChange?.(oldContext, newContext);
|
|
||||||
|
|
||||||
// only run the event handlers if the onContextChange method succeeded
|
|
||||||
this.getAssociatedEventEmitters(domain).forEach((emitter) => {
|
|
||||||
emitter?.emit(ProviderEvents.ContextChanged, { clientName: domain, domain, providerName });
|
|
||||||
});
|
|
||||||
this._events?.emit(ProviderEvents.ContextChanged, { clientName: domain, domain, providerName });
|
|
||||||
} catch (err) {
|
|
||||||
// run error handlers instead
|
|
||||||
const error = err as Error | undefined;
|
|
||||||
const message = `Error running ${provider?.metadata?.name}'s context change handler: ${error?.message}`;
|
|
||||||
this._logger?.error(`${message}`, err);
|
|
||||||
this.getAssociatedEventEmitters(domain).forEach((emitter) => {
|
|
||||||
emitter?.emit(ProviderEvents.Error, { clientName: domain, domain, providerName, message });
|
|
||||||
});
|
|
||||||
this._events?.emit(ProviderEvents.Error, { clientName: domain, domain, providerName, message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A singleton instance of the OpenFeature API.
|
|
||||||
* @returns {OpenFeatureAPI} OpenFeature API
|
|
||||||
*/
|
|
||||||
export const OpenFeature = OpenFeatureAPI.getInstance();
|
|
|
@ -1,5 +1,134 @@
|
||||||
# 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)
|
||||||
|
|
||||||
|
|
||||||
|
### ✨ New Features
|
||||||
|
|
||||||
|
* context propagation ([#837](https://github.com/open-feature/js-sdk/issues/837)) ([b1abef1](https://github.com/open-feature/js-sdk/commit/b1abef1a2bc2bf27de48a09b44167a2644b62943))
|
||||||
|
* 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))
|
||||||
|
|
||||||
|
## [0.1.0-experimental](https://github.com/open-feature/js-sdk/compare/nestjs-sdk-v0.0.5-experimental...nestjs-sdk-v0.1.0-experimental) (2024-02-25)
|
||||||
|
|
||||||
|
|
||||||
|
### ⚠ BREAKING CHANGES
|
||||||
|
|
||||||
|
* **nestjs:** add domains ([#831](https://github.com/open-feature/js-sdk/issues/831))
|
||||||
|
|
||||||
|
### ✨ New Features
|
||||||
|
|
||||||
|
* **nestjs:** add domains ([#831](https://github.com/open-feature/js-sdk/issues/831)) ([840d7ac](https://github.com/open-feature/js-sdk/commit/840d7acaa3725bade9e8458ad9ced8e20fae1a5e))
|
||||||
|
|
||||||
|
|
||||||
|
### 🧹 Chore
|
||||||
|
|
||||||
|
* **deps:** update dependency @types/supertest to v6 ([#782](https://github.com/open-feature/js-sdk/issues/782)) ([53479d1](https://github.com/open-feature/js-sdk/commit/53479d1edd3aad40f3d3fc3662cef6a317f78bbe))
|
||||||
|
|
||||||
## [0.0.5-experimental](https://github.com/open-feature/js-sdk/compare/nestjs-sdk-v0.0.4-experimental...nestjs-sdk-v0.0.5-experimental) (2024-01-31)
|
## [0.0.5-experimental](https://github.com/open-feature/js-sdk/compare/nestjs-sdk-v0.0.4-experimental...nestjs-sdk-v0.0.5-experimental) (2024-01-31)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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.0.5-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.0.5-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`.
|
||||||
|
|
||||||
|
@ -126,17 +133,17 @@ export class OpenFeatureController {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
It is also possible to inject the default or named OpenFeature clients into a service via Nest dependency injection system.
|
It is also possible to inject the default or domain scoped OpenFeature clients into a service via Nest dependency injection system.
|
||||||
|
|
||||||
```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({ name: 'differentProvider' }) private namedClient: 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.0.5-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,13 +0,0 @@
|
||||||
import { TransactionContextPropagator, EvaluationContext } from '@openfeature/server-sdk';
|
|
||||||
import { AsyncLocalStorage } from 'async_hooks';
|
|
||||||
|
|
||||||
export class AsyncLocalStorageTransactionContext implements TransactionContextPropagator {
|
|
||||||
private asyncLocalStorage = new AsyncLocalStorage<EvaluationContext>();
|
|
||||||
|
|
||||||
getTransactionContext(): EvaluationContext {
|
|
||||||
return this.asyncLocalStorage.getStore() ?? {};
|
|
||||||
}
|
|
||||||
setTransactionContext(context: EvaluationContext, callback: () => void): void {
|
|
||||||
this.asyncLocalStorage.run(context, callback);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,24 +1,20 @@
|
||||||
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.
|
||||||
*/
|
*/
|
||||||
interface FeatureClientProps {
|
interface FeatureClientProps {
|
||||||
/**
|
/**
|
||||||
* The name of the OpenFeature client, if a named client should be used.
|
* The domain of the OpenFeature client, if a domain scoped client should be used.
|
||||||
* @see {@link Client.getBooleanDetails}
|
* @see {@link Client.getBooleanDetails}
|
||||||
*/
|
*/
|
||||||
name?: string;
|
domain?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -26,17 +22,17 @@ 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?.name));
|
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.
|
||||||
*/
|
*/
|
||||||
interface FeatureProps<T extends FlagValue> {
|
interface FeatureProps<T extends FlagValue> {
|
||||||
/**
|
/**
|
||||||
* The name of the OpenFeature client, if a named client should be used.
|
* The domain of the OpenFeature client, if a domain scoped client should be used.
|
||||||
* @see {@link OpenFeature#getClient}
|
* @see {@link OpenFeature#getClient}
|
||||||
*/
|
*/
|
||||||
clientName?: string;
|
domain?: string;
|
||||||
/**
|
/**
|
||||||
* The key of the feature flag.
|
* The key of the feature flag.
|
||||||
* @see {@link Client#getBooleanDetails}
|
* @see {@link Client#getBooleanDetails}
|
||||||
|
@ -54,20 +50,10 @@ interface FeatureProps<T extends FlagValue> {
|
||||||
context?: EvaluationContext;
|
context?: EvaluationContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a named or unnamed OpenFeature client with the given context.
|
|
||||||
* @param {string} clientName The name of the OpenFeature client.
|
|
||||||
* @param {EvaluationContext} context The evaluation context of the client.
|
|
||||||
* @returns {Client} The OpenFeature client.
|
|
||||||
*/
|
|
||||||
function getClientForEvaluation(clientName?: string, context?: EvaluationContext) {
|
|
||||||
return clientName ? OpenFeature.getClient(clientName, context) : OpenFeature.getClient(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route handler parameter decorator.
|
* Route handler parameter decorator.
|
||||||
*
|
*
|
||||||
* Gets the {@link EvaluationDetails} for given feature flag from a named or unnamed OpenFeature
|
* Gets the {@link EvaluationDetails} for given feature flag from a domain scoped or the default OpenFeature
|
||||||
* client and populates the annotated parameter with the {@link EvaluationDetails} wrapped in an {@link Observable}.
|
* client and populates the annotated parameter with the {@link EvaluationDetails} wrapped in an {@link Observable}.
|
||||||
*
|
*
|
||||||
* For example:
|
* For example:
|
||||||
|
@ -82,8 +68,8 @@ function getClientForEvaluation(clientName?: string, context?: EvaluationContext
|
||||||
* @returns {ParameterDecorator}
|
* @returns {ParameterDecorator}
|
||||||
*/
|
*/
|
||||||
export const BooleanFeatureFlag = createParamDecorator(
|
export const BooleanFeatureFlag = createParamDecorator(
|
||||||
({ clientName, flagKey, defaultValue, context }: FeatureProps<boolean>): Observable<EvaluationDetails<boolean>> => {
|
({ domain, flagKey, defaultValue, context }: FeatureProps<boolean>): Observable<EvaluationDetails<boolean>> => {
|
||||||
const client = getClientForEvaluation(clientName, context);
|
const client = getClientForEvaluation(domain, context);
|
||||||
return from(client.getBooleanDetails(flagKey, defaultValue));
|
return from(client.getBooleanDetails(flagKey, defaultValue));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -91,7 +77,7 @@ export const BooleanFeatureFlag = createParamDecorator(
|
||||||
/**
|
/**
|
||||||
* Route handler parameter decorator.
|
* Route handler parameter decorator.
|
||||||
*
|
*
|
||||||
* Gets the {@link EvaluationDetails} for given feature flag from a named or unnamed OpenFeature
|
* Gets the {@link EvaluationDetails} for given feature flag from a domain scoped or the default OpenFeature
|
||||||
* client and populates the annotated parameter with the {@link EvaluationDetails} wrapped in an {@link Observable}.
|
* client and populates the annotated parameter with the {@link EvaluationDetails} wrapped in an {@link Observable}.
|
||||||
*
|
*
|
||||||
* For example:
|
* For example:
|
||||||
|
@ -106,8 +92,8 @@ export const BooleanFeatureFlag = createParamDecorator(
|
||||||
* @returns {ParameterDecorator}
|
* @returns {ParameterDecorator}
|
||||||
*/
|
*/
|
||||||
export const StringFeatureFlag = createParamDecorator(
|
export const StringFeatureFlag = createParamDecorator(
|
||||||
({ clientName, flagKey, defaultValue, context }: FeatureProps<string>): Observable<EvaluationDetails<string>> => {
|
({ domain, flagKey, defaultValue, context }: FeatureProps<string>): Observable<EvaluationDetails<string>> => {
|
||||||
const client = getClientForEvaluation(clientName, context);
|
const client = getClientForEvaluation(domain, context);
|
||||||
return from(client.getStringDetails(flagKey, defaultValue));
|
return from(client.getStringDetails(flagKey, defaultValue));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -115,7 +101,7 @@ export const StringFeatureFlag = createParamDecorator(
|
||||||
/**
|
/**
|
||||||
* Route handler parameter decorator.
|
* Route handler parameter decorator.
|
||||||
*
|
*
|
||||||
* Gets the {@link EvaluationDetails} for given feature flag from a named or unnamed OpenFeature
|
* Gets the {@link EvaluationDetails} for given feature flag from a domain scoped or the default OpenFeature
|
||||||
* client and populates the annotated parameter with the {@link EvaluationDetails} wrapped in an {@link Observable}.
|
* client and populates the annotated parameter with the {@link EvaluationDetails} wrapped in an {@link Observable}.
|
||||||
*
|
*
|
||||||
* For example:
|
* For example:
|
||||||
|
@ -130,8 +116,8 @@ export const StringFeatureFlag = createParamDecorator(
|
||||||
* @returns {ParameterDecorator}
|
* @returns {ParameterDecorator}
|
||||||
*/
|
*/
|
||||||
export const NumberFeatureFlag = createParamDecorator(
|
export const NumberFeatureFlag = createParamDecorator(
|
||||||
({ clientName, flagKey, defaultValue, context }: FeatureProps<number>): Observable<EvaluationDetails<number>> => {
|
({ domain, flagKey, defaultValue, context }: FeatureProps<number>): Observable<EvaluationDetails<number>> => {
|
||||||
const client = getClientForEvaluation(clientName, context);
|
const client = getClientForEvaluation(domain, context);
|
||||||
return from(client.getNumberDetails(flagKey, defaultValue));
|
return from(client.getNumberDetails(flagKey, defaultValue));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -139,7 +125,7 @@ export const NumberFeatureFlag = createParamDecorator(
|
||||||
/**
|
/**
|
||||||
* Route handler parameter decorator.
|
* Route handler parameter decorator.
|
||||||
*
|
*
|
||||||
* Gets the {@link EvaluationDetails} for given feature flag from a named or unnamed OpenFeature
|
* Gets the {@link EvaluationDetails} for given feature flag from a domain scoped or the default OpenFeature
|
||||||
* client and populates the annotated parameter with the {@link EvaluationDetails} wrapped in an {@link Observable}.
|
* client and populates the annotated parameter with the {@link EvaluationDetails} wrapped in an {@link Observable}.
|
||||||
*
|
*
|
||||||
* For example:
|
* For example:
|
||||||
|
@ -154,13 +140,8 @@ export const NumberFeatureFlag = createParamDecorator(
|
||||||
* @returns {ParameterDecorator}
|
* @returns {ParameterDecorator}
|
||||||
*/
|
*/
|
||||||
export const ObjectFeatureFlag = createParamDecorator(
|
export const ObjectFeatureFlag = createParamDecorator(
|
||||||
({
|
({ domain, flagKey, defaultValue, context }: FeatureProps<JsonValue>): Observable<EvaluationDetails<JsonValue>> => {
|
||||||
clientName,
|
const client = getClientForEvaluation(domain, context);
|
||||||
flagKey,
|
|
||||||
defaultValue,
|
|
||||||
context,
|
|
||||||
}: FeatureProps<JsonValue>): Observable<EvaluationDetails<JsonValue>> => {
|
|
||||||
const client = getClientForEvaluation(clientName, context);
|
|
||||||
return from(client.getObjectDetails(flagKey, defaultValue));
|
return from(client.getObjectDetails(flagKey, defaultValue));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
@ -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,25 +1,28 @@
|
||||||
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,
|
||||||
} 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 { AsyncLocalStorageTransactionContext } from './evaluation-context-propagator';
|
|
||||||
import { EvaluationContextInterceptor } from './evaluation-context-interceptor';
|
import { EvaluationContextInterceptor } from './evaluation-context-interceptor';
|
||||||
import { ShutdownService } from './shutdown.service';
|
import { ShutdownService } from './shutdown.service';
|
||||||
|
|
||||||
|
@ -29,7 +32,7 @@ import { ShutdownService } from './shutdown.service';
|
||||||
@Module({})
|
@Module({})
|
||||||
export class OpenFeatureModule {
|
export class OpenFeatureModule {
|
||||||
static forRoot({ useGlobalInterceptor = true, ...options }: OpenFeatureModuleOptions): DynamicModule {
|
static forRoot({ useGlobalInterceptor = true, ...options }: OpenFeatureModuleOptions): DynamicModule {
|
||||||
OpenFeature.setTransactionContextPropagator(new AsyncLocalStorageTransactionContext());
|
OpenFeature.setTransactionContextPropagator(new AsyncLocalStorageTransactionContextPropagator());
|
||||||
|
|
||||||
if (options.logger) {
|
if (options.logger) {
|
||||||
OpenFeature.setLogger(options.logger);
|
OpenFeature.setLogger(options.logger);
|
||||||
|
@ -55,11 +58,11 @@ export class OpenFeatureModule {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options?.providers) {
|
if (options?.providers) {
|
||||||
Object.entries(options.providers).forEach(([name, provider]) => {
|
Object.entries(options.providers).forEach(([domain, provider]) => {
|
||||||
OpenFeature.setProvider(name, provider);
|
OpenFeature.setProvider(domain, provider);
|
||||||
clientValueProviders.push({
|
clientValueProviders.push({
|
||||||
provide: getOpenFeatureClientToken(name),
|
provide: getOpenFeatureClientToken(domain),
|
||||||
useFactory: () => OpenFeature.getClient(name),
|
useFactory: () => OpenFeature.getClient(domain),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -100,11 +103,11 @@ export interface OpenFeatureModuleOptions {
|
||||||
*/
|
*/
|
||||||
defaultProvider?: Provider;
|
defaultProvider?: Provider;
|
||||||
/**
|
/**
|
||||||
* Named providers to set to OpenFeature.
|
* Domain scoped providers to set to OpenFeature.
|
||||||
* @see {@link OpenFeature#setProvider}
|
* @see {@link OpenFeature#setProvider}
|
||||||
*/
|
*/
|
||||||
providers?: {
|
providers?: {
|
||||||
[providerName: string]: Provider;
|
[domain: string]: Provider;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Global {@link Logger} for OpenFeature.
|
* Global {@link Logger} for OpenFeature.
|
||||||
|
@ -130,7 +133,7 @@ export interface OpenFeatureModuleOptions {
|
||||||
* The {@link ContextFactory} for creating an {@link EvaluationContext} from Nest {@link ExecutionContext} information.
|
* The {@link ContextFactory} for creating an {@link EvaluationContext} from Nest {@link ExecutionContext} information.
|
||||||
* This could be header values of a request or something similar.
|
* This could be header values of a request or something similar.
|
||||||
* The context is automatically used for all feature flag evaluations during this request.
|
* The context is automatically used for all feature flag evaluations during this request.
|
||||||
* @see {@link AsyncLocalStorageTransactionContext}
|
* @see {@link AsyncLocalStorageTransactionContextPropagator}
|
||||||
*/
|
*/
|
||||||
contextFactory?: ContextFactory;
|
contextFactory?: ContextFactory;
|
||||||
/**
|
/**
|
||||||
|
@ -149,10 +152,10 @@ export interface OpenFeatureModuleOptions {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns an injection token for a (named) OpenFeature client.
|
* Returns an injection token for a (domain scoped) OpenFeature client.
|
||||||
* @param {string} name The name of the OpenFeature client.
|
* @param {string} domain The domain of the OpenFeature client.
|
||||||
* @returns {Client} The injection token.
|
* @returns {Client} The injection token.
|
||||||
*/
|
*/
|
||||||
export function getOpenFeatureClientToken(name?: string): string {
|
export function getOpenFeatureClientToken(domain?: string): string {
|
||||||
return name ? `OpenFeatureClient_${name}` : 'OpenFeatureClient_default';
|
return domain ? `OpenFeatureClient_${domain}` : 'OpenFeatureClient_default';
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,11 +24,21 @@ 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 = {
|
||||||
namedClient: new InMemoryProvider({
|
domainScopedClient: new InMemoryProvider({
|
||||||
testBooleanFlag: {
|
testBooleanFlag: {
|
||||||
defaultVariant: 'default',
|
defaultVariant: 'default',
|
||||||
variants: { default: true },
|
variants: { default: true },
|
||||||
|
@ -35,7 +46,7 @@ export const providers = {
|
||||||
},
|
},
|
||||||
testStringFlag: {
|
testStringFlag: {
|
||||||
defaultVariant: 'default',
|
defaultVariant: 'default',
|
||||||
variants: { default: 'expected-string-value-named' },
|
variants: { default: 'expected-string-value-scoped' },
|
||||||
disabled: false,
|
disabled: false,
|
||||||
},
|
},
|
||||||
testNumberFlag: {
|
testNumberFlag: {
|
||||||
|
@ -45,13 +56,12 @@ export const providers = {
|
||||||
},
|
},
|
||||||
testObjectFlag: {
|
testObjectFlag: {
|
||||||
defaultVariant: 'default',
|
defaultVariant: 'default',
|
||||||
variants: { default: { client: 'named' } },
|
variants: { default: { client: 'scoped' } },
|
||||||
disabled: false,
|
disabled: false,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export const exampleContextFactory = async (context: ExecutionContext) => {
|
export const exampleContextFactory = async (context: ExecutionContext) => {
|
||||||
const request = await context.switchToHttp().getRequest();
|
const request = await context.switchToHttp().getRequest();
|
||||||
|
|
||||||
|
@ -70,6 +80,6 @@ export const getOpenFeatureDefaultTestModule = () => {
|
||||||
return OpenFeatureModule.forRoot({
|
return OpenFeatureModule.forRoot({
|
||||||
contextFactory: exampleContextFactory,
|
contextFactory: exampleContextFactory,
|
||||||
defaultProvider,
|
defaultProvider,
|
||||||
providers
|
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();
|
||||||
|
@ -35,9 +39,9 @@ describe('OpenFeature SDK', () => {
|
||||||
'expected-string-value-default',
|
'expected-string-value-default',
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(testService.namedClient).toBeDefined();
|
expect(testService.domainScopedClient).toBeDefined();
|
||||||
expect(await testService.namedClient.getStringValue('testStringFlag', 'wrong-value')).toEqual(
|
expect(await testService.domainScopedClient.getStringValue('testStringFlag', 'wrong-value')).toEqual(
|
||||||
'expected-string-value-named',
|
'expected-string-value-scoped',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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,21 +33,21 @@ 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 name', async () => {
|
it('should inject the client with the given scope', async () => {
|
||||||
const client = moduleRef.get<OpenFeatureClient>(getOpenFeatureClientToken('namedClient'));
|
const client = moduleRef.get<Client>(getOpenFeatureClientToken('domainScopedClient'));
|
||||||
expect(client).toBeDefined();
|
expect(client).toBeDefined();
|
||||||
expect(await client.getStringValue('testStringFlag', '')).toEqual('expected-string-value-named');
|
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({ name: 'namedClient' }) public namedClient: 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",
|
||||||
|
@ -64,7 +64,7 @@
|
||||||
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||||
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||||
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||||
// "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
|
"stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
|
||||||
// "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */
|
// "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */
|
||||||
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||||
// "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */
|
// "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */
|
||||||
|
|
|
@ -1,5 +1,244 @@
|
||||||
# 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)
|
||||||
|
|
||||||
|
|
||||||
|
### ⚠ BREAKING CHANGES
|
||||||
|
|
||||||
|
* use "domain" instead of "clientName" ([#826](https://github.com/open-feature/js-sdk/issues/826))
|
||||||
|
|
||||||
|
### ✨ New Features
|
||||||
|
|
||||||
|
* use "domain" instead of "clientName" ([#826](https://github.com/open-feature/js-sdk/issues/826)) ([427ba88](https://github.com/open-feature/js-sdk/commit/427ba883f5b3d38e40ed3dd493c6208f2f74691e))
|
||||||
|
|
||||||
## [0.1.1-experimental](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.1.0-experimental...react-sdk-v0.1.1-experimental) (2024-01-31)
|
## [0.1.1-experimental](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.1.0-experimental...react-sdk-v0.1.1-experimental) (2024-01-31)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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.1.1-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.1.1-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 Scoping](#multiple-providers-and-scoping)
|
- [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,15 +170,15 @@ const {
|
||||||
} = useBooleanFlagDetails('new-message', false);
|
} = useBooleanFlagDetails('new-message', false);
|
||||||
```
|
```
|
||||||
|
|
||||||
### Multiple Providers and Scoping
|
#### Multiple Providers and Domains
|
||||||
|
|
||||||
Multiple providers and scoped clients can be configured by passing a `clientName` to the `OpenFeatureProvider`:
|
Multiple providers can be used by passing a `domain` to the `OpenFeatureProvider`:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// Flags within this scope will use the a client/provider associated with `myClient`,
|
// Flags within this domain will use the client/provider associated with `my-domain`,
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<OpenFeatureProvider clientName={'myClient'}>
|
<OpenFeatureProvider domain={'my-domain'}>
|
||||||
<Page></Page>
|
<Page></Page>
|
||||||
</OpenFeatureProvider>
|
</OpenFeatureProvider>
|
||||||
);
|
);
|
||||||
|
@ -148,48 +188,66 @@ function App() {
|
||||||
This is analogous to:
|
This is analogous to:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
OpenFeature.getClient('myClient');
|
OpenFeature.getClient('my-domain');
|
||||||
```
|
```
|
||||||
|
|
||||||
### Re-rendering with Context Changes
|
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
|
||||||
|
|
||||||
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() {
|
||||||
|
@ -202,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>
|
||||||
|
@ -222,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.1.1-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.10",
|
"@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,52 +0,0 @@
|
||||||
import * as React from 'react';
|
|
||||||
import { Client, OpenFeature } from '@openfeature/web-sdk';
|
|
||||||
|
|
||||||
type ClientOrClientName =
|
|
||||||
| {
|
|
||||||
/**
|
|
||||||
* The name of the client.
|
|
||||||
* @see OpenFeature.setProvider() and overloads.
|
|
||||||
*/
|
|
||||||
clientName: string;
|
|
||||||
/**
|
|
||||||
* OpenFeature client to use.
|
|
||||||
*/
|
|
||||||
client?: never;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
/**
|
|
||||||
* OpenFeature client to use.
|
|
||||||
*/
|
|
||||||
client: Client;
|
|
||||||
/**
|
|
||||||
* The name of the client.
|
|
||||||
* @see OpenFeature.setProvider() and overloads.
|
|
||||||
*/
|
|
||||||
clientName?: never;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ProviderProps = {
|
|
||||||
children?: React.ReactNode;
|
|
||||||
} & ClientOrClientName;
|
|
||||||
|
|
||||||
const Context = React.createContext<Client | undefined>(undefined);
|
|
||||||
|
|
||||||
export const OpenFeatureProvider = ({ client, clientName, children }: ProviderProps) => {
|
|
||||||
if (!client) {
|
|
||||||
client = OpenFeature.getClient(clientName);
|
|
||||||
}
|
|
||||||
|
|
||||||
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,244 +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 use React Suspense API.
|
|
||||||
* Defaults to true.
|
|
||||||
*/
|
|
||||||
suspend?: 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,
|
|
||||||
suspend: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
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 forceUpdate = () => {
|
|
||||||
updateState({});
|
|
||||||
};
|
|
||||||
const client = useOpenFeatureClient();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
|
|
||||||
if (client.providerStatus !== ProviderStatus.READY) {
|
|
||||||
// update when the provider is ready
|
|
||||||
client.addHandler(ProviderEvents.Ready, forceUpdate);
|
|
||||||
if (defaultedOptions.suspend) {
|
|
||||||
suspend(client, updateState);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (defaultedOptions.updateOnContextChanged) {
|
|
||||||
// update when the context changes
|
|
||||||
client.addHandler(ProviderEvents.ContextChanged, forceUpdate);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (defaultedOptions.updateOnConfigurationChanged) {
|
|
||||||
// update when the provider configuration changes
|
|
||||||
client.addHandler(ProviderEvents.ConfigurationChanged, forceUpdate);
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
// cleanup the handlers (we can do this unconditionally with no impact)
|
|
||||||
client.removeHandler(ProviderEvents.Ready, forceUpdate);
|
|
||||||
client.removeHandler(ProviderEvents.ContextChanged, forceUpdate);
|
|
||||||
client.removeHandler(ProviderEvents.ConfigurationChanged, forceUpdate);
|
|
||||||
};
|
|
||||||
}, [client]);
|
|
||||||
|
|
||||||
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
|
|
||||||
*/
|
|
||||||
function suspend(client: Client, updateState: Dispatch<SetStateAction<object | undefined>>) {
|
|
||||||
let suspendResolver: () => void;
|
|
||||||
let suspendRejecter: () => void;
|
|
||||||
const suspendPromise = new Promise<void>((resolve) => {
|
|
||||||
suspendResolver = () => {
|
|
||||||
resolve();
|
|
||||||
client.removeHandler(ProviderEvents.Ready, suspendResolver); // remove handler once it's run
|
|
||||||
};
|
|
||||||
suspendRejecter = () => {
|
|
||||||
resolve(); // we still resolve here, since we don't want to throw errors
|
|
||||||
client.removeHandler(ProviderEvents.Error, suspendRejecter); // remove handler once it's run
|
|
||||||
};
|
|
||||||
client.addHandler(ProviderEvents.Ready, suspendResolver);
|
|
||||||
client.addHandler(ProviderEvents.Error, suspendRejecter);
|
|
||||||
});
|
|
||||||
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`. */
|
||||||
|
@ -61,7 +61,7 @@
|
||||||
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||||
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||||
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||||
// "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
|
"stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
|
||||||
// "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */
|
// "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */
|
||||||
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||||
// "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */
|
// "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */
|
||||||
|
|
|
@ -1,5 +1,210 @@
|
||||||
# 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)
|
||||||
|
|
||||||
|
|
||||||
|
### ✨ New Features
|
||||||
|
|
||||||
|
* context propagation ([#837](https://github.com/open-feature/js-sdk/issues/837)) ([b1abef1](https://github.com/open-feature/js-sdk/commit/b1abef1a2bc2bf27de48a09b44167a2644b62943))
|
||||||
|
* 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))
|
||||||
|
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
* allow iteration over all event types ([#844](https://github.com/open-feature/js-sdk/issues/844)) ([411c7b4](https://github.com/open-feature/js-sdk/commit/411c7b4265f2029df09219028995d621bb57ad97))
|
||||||
|
* correct rollup to bundle all but core ([#846](https://github.com/open-feature/js-sdk/issues/846)) ([f451e25](https://github.com/open-feature/js-sdk/commit/f451e255bf97e9636fbb801acc0da6f6d40ad2b8))
|
||||||
|
|
||||||
|
|
||||||
|
### 🧹 Chore
|
||||||
|
|
||||||
|
* **main:** release core 0.0.27 ([#839](https://github.com/open-feature/js-sdk/issues/839)) ([ccbb1f9](https://github.com/open-feature/js-sdk/commit/ccbb1f9c9746af73bc17b43808072a678d05c371))
|
||||||
|
|
||||||
## [1.12.0](https://github.com/open-feature/js-sdk/compare/server-sdk-v1.11.0...server-sdk-v1.12.0) (2024-02-22)
|
## [1.12.0](https://github.com/open-feature/js-sdk/compare/server-sdk-v1.11.0...server-sdk-v1.12.0) (2024-02-22)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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.12.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.12.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,16 +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) |
|
||||||
| ✅ | [Extending](#extending) | Extend OpenFeature with custom providers and hooks. |
|
| ✅ | [Tracking](#tracking) | Associate user actions with feature flag evaluations, particularly for A/B testing. |
|
||||||
|
| ✅ | [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. |
|
||||||
|
| ✅ | [Extending](#extending) | Extend OpenFeature with custom providers and hooks. |
|
||||||
|
|
||||||
<sub>Implemented: ✅ | In-progress: ⚠️ | Not implemented yet: ❌</sub>
|
<sub>Implemented: ✅ | In-progress: ⚠️ | Not implemented yet: ❌</sub>
|
||||||
|
|
||||||
|
@ -152,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.
|
||||||
|
@ -251,6 +268,47 @@ client.addHandler(ProviderEvents.Error, (eventDetails) => {
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Transaction Context Propagation
|
||||||
|
|
||||||
|
Transaction context is a container for transaction-specific evaluation context (e.g. user id, user agent, IP).
|
||||||
|
Transaction context can be set where specific data is available (e.g. an auth service or request handler) and by using the transaction context propagator it will automatically be applied to all flag evaluations within a transaction (e.g. a request or thread).
|
||||||
|
|
||||||
|
The following example shows an Express middleware using transaction context propagation to propagate the request ip and user id into request scoped transaction context.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import express, { Request, Response, NextFunction } from "express";
|
||||||
|
import { OpenFeature, AsyncLocalStorageTransactionContextPropagator } from '@openfeature/server-sdk';
|
||||||
|
|
||||||
|
OpenFeature.setTransactionContextPropagator(new AsyncLocalStorageTransactionContextPropagator())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This example is based on an express middleware.
|
||||||
|
*/
|
||||||
|
const app = express();
|
||||||
|
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const ip = res.headers.get("X-Forwarded-For")
|
||||||
|
OpenFeature.setTransactionContext({ targetingKey: req.user.id, ipAddress: ip }, () => {
|
||||||
|
// The transaction context is used in any flag evaluation throughout the whole call chain of next
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
@ -279,7 +337,6 @@ import {
|
||||||
Logger,
|
Logger,
|
||||||
Provider,
|
Provider,
|
||||||
ProviderEventEmitter,
|
ProviderEventEmitter,
|
||||||
ProviderStatus,
|
|
||||||
ResolutionDetails
|
ResolutionDetails
|
||||||
} from '@openfeature/server-sdk';
|
} from '@openfeature/server-sdk';
|
||||||
|
|
||||||
|
@ -305,8 +362,6 @@ class MyProvider implements Provider {
|
||||||
// code to evaluate an object
|
// code to evaluate an object
|
||||||
}
|
}
|
||||||
|
|
||||||
status?: ProviderStatus | undefined;
|
|
||||||
|
|
||||||
// implement with "new OpenFeatureEventEmitter()", and use "emit()" to emit events
|
// implement with "new OpenFeatureEventEmitter()", and use "emit()" to emit events
|
||||||
events?: ProviderEventEmitter<AnyProviderEvent> | undefined;
|
events?: ProviderEventEmitter<AnyProviderEvent> | undefined;
|
||||||
|
|
||||||
|
@ -332,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,13 +1,14 @@
|
||||||
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';
|
||||||
import { InMemoryProvider, OpenFeature, ProviderEvents } from '../../src';
|
import { InMemoryProvider, OpenFeature } from '../../src';
|
||||||
import flagConfiguration from './flags-config';
|
import flagConfiguration from './flags-config';
|
||||||
|
|
||||||
// load the feature file.
|
// load the feature file.
|
||||||
|
@ -19,17 +20,12 @@ const client = OpenFeature.getClient();
|
||||||
const givenAnOpenfeatureClientIsRegisteredWithCacheDisabled = (
|
const givenAnOpenfeatureClientIsRegisteredWithCacheDisabled = (
|
||||||
given: (stepMatcher: string, stepDefinitionCallback: () => void) => void,
|
given: (stepMatcher: string, stepDefinitionCallback: () => void) => void,
|
||||||
) => {
|
) => {
|
||||||
OpenFeature.setProvider(
|
|
||||||
new InMemoryProvider(flagConfiguration),
|
|
||||||
);
|
|
||||||
given('a provider is registered with cache disabled', () => undefined);
|
given('a provider is registered with cache disabled', () => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
defineFeature(feature, (test) => {
|
defineFeature(feature, (test) => {
|
||||||
beforeAll((done) => {
|
beforeAll(async () => {
|
||||||
client.addHandler(ProviderEvents.Ready, async () => {
|
await OpenFeature.setProvider(new InMemoryProvider(flagConfiguration));
|
||||||
done();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@openfeature/server-sdk",
|
"name": "@openfeature/server-sdk",
|
||||||
"version": "1.12.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.26"
|
"@openfeature/core": "^1.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@openfeature/core": "0.0.26"
|
"@openfeature/core": "^1.9.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,25 @@
|
||||||
import { ClientMetadata, EvaluationLifeCycle, Eventing, ManageContext, ManageLogger } from '@openfeature/core';
|
import type {
|
||||||
import { Features } from '../evaluation';
|
ClientMetadata,
|
||||||
|
EvaluationLifeCycle,
|
||||||
|
Eventing,
|
||||||
|
ManageContext,
|
||||||
|
ManageLogger,
|
||||||
|
} from '@openfeature/core';
|
||||||
|
import type { Features } from '../evaluation';
|
||||||
|
import type { ProviderStatus } from '../provider';
|
||||||
|
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>,
|
||||||
Eventing {
|
Tracking,
|
||||||
|
Eventing<ProviderEvents> {
|
||||||
readonly metadata: ClientMetadata;
|
readonly metadata: ClientMetadata;
|
||||||
|
/**
|
||||||
|
* Returns the status of the associated provider.
|
||||||
|
*/
|
||||||
|
readonly providerStatus: ProviderStatus;
|
||||||
}
|
}
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue