Compare commits
447 Commits
core-v0.0.
...
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 | |
|
6034df4a4f | |
|
e3a7378347 | |
|
2fc38bd838 | |
|
ff52cb9835 | |
|
2736e55b18 | |
|
53479d1edd | |
|
8109fd17da | |
|
98ba00a28d | |
|
e2f24fc978 | |
|
fb9f31cb3c | |
|
3701ab5eba | |
|
8b6b834f42 | |
|
4764fb785a | |
|
45e1337013 | |
|
1096ae5d46 | |
|
70bd33db72 | |
|
4c73189f62 | |
|
732ea417bc | |
|
6f62eb207d | |
|
82190eb9fa | |
|
8b82ba6250 | |
|
53a89ab0ce | |
|
b47b1dce5b | |
|
7cc1e09a11 | |
|
4114e63a76 | |
|
35a612c4fa | |
|
12b3b35276 | |
|
2170a9fa3a | |
|
1666597839 | |
|
c47c7d9bf8 | |
|
e68559c1a4 | |
|
69c7f05eb4 | |
|
c6a357aa5c | |
|
a452bdd4a8 | |
|
a21634dce9 | |
|
2f59a9f5a8 | |
|
a9e48aa363 | |
|
8e7090e277 | |
|
80a9ba1e51 | |
|
d1d02fa59d | |
|
1e13333819 | |
|
3ad61cc055 | |
|
3e17df4f84 | |
|
8f01ead291 | |
|
2c864e46cc | |
|
58cac6cf3f | |
|
3129e92b48 | |
|
da478cb0b0 | |
|
f4597af79a | |
|
b6adbbac8e | |
|
072d2c7570 | |
|
1fa53c9058 | |
|
a2d4f3e29e | |
|
8cebbf16a1 | |
|
a0cc85546d | |
|
7906bbedbd | |
|
696bf4adb8 | |
|
8503689a2c | |
|
98d56900a0 | |
|
7d1aca4bf8 | |
|
77a584c17e | |
|
ade80220fe | |
|
b243ded167 | |
|
ef874e0365 | |
|
c7098c365a | |
|
23d14aade8 | |
|
954bd61610 | |
|
ca23a21aab | |
|
ecd7cf43ab | |
|
ba74cf4f6c | |
|
44270b82f0 | |
|
eed8934a3f | |
|
3e3f3c862c | |
|
a3799e1b65 | |
|
1748a24215 | |
|
b4e0cd16cd | |
|
95524f4199 | |
|
0e1ff8b258 | |
|
548de72336 | |
|
ece75b7424 | |
|
a4310a2ed0 | |
|
390e328714 | |
|
70863c662d | |
|
5be715e10b | |
|
dba858b454 | |
|
0b9ca1814c | |
|
2d1b8ebfb1 | |
|
85e4b7ccf5 | |
|
cc9fe79c55 | |
|
a53a62d6d4 | |
|
aa898f0aa3 | |
|
b055bc8688 | |
|
9629578369 | |
|
09ff7b4d99 | |
|
5845370045 | |
|
b0054f920d | |
|
a14669f778 | |
|
c80522fbe2 | |
|
69d2c88c5e | |
|
b0cbeb460c | |
|
6051dfd583 | |
|
a7d0b954dc | |
|
00a6d2efb4 | |
|
a6a4cd414f | |
|
b470ac06a1 | |
|
8c7aad2340 | |
|
f41b9382a5 | |
|
98d96b964a | |
|
6250f5d06d | |
|
78607978b6 | |
|
cf7bbf0639 | |
|
f0e2aa617f | |
|
d9b922b2d3 | |
|
8d9644dea6 | |
|
bac321ee42 | |
|
6b3a4e3f93 | |
|
cb7d2be2c8 | |
|
3c9fdd9e4c | |
|
14441b1ad4 | |
|
e72fc19da3 | |
|
aaf25d269b | |
|
0032a81924 | |
|
74d4b23f07 | |
|
eef0fc0a3a | |
|
aafe9768ce | |
|
1b02a4678d | |
|
696aaa7770 |
109
.eslintrc.json
109
.eslintrc.json
|
@ -1,57 +1,54 @@
|
|||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true
|
||||
},
|
||||
"ignorePatterns": ["**/dist/**/*"],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"prettier",
|
||||
"plugin:jsdoc/recommended"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module"
|
||||
},
|
||||
"plugins": [
|
||||
"@typescript-eslint",
|
||||
"check-file",
|
||||
"jsdoc"
|
||||
],
|
||||
"rules": {
|
||||
"jsdoc/check-tag-names": [
|
||||
"warn",
|
||||
{
|
||||
"definedTags": [
|
||||
"experimental"
|
||||
]
|
||||
}
|
||||
],
|
||||
"linebreak-style": [
|
||||
"error",
|
||||
"unix"
|
||||
],
|
||||
"quotes": [
|
||||
"error",
|
||||
"single",
|
||||
{
|
||||
"avoidEscape": true
|
||||
}
|
||||
],
|
||||
"semi": [
|
||||
"error",
|
||||
"always"
|
||||
],
|
||||
"check-file/filename-naming-convention": [
|
||||
"error",
|
||||
{
|
||||
"**/*.{js,ts}": "KEBAB_CASE"
|
||||
},
|
||||
{
|
||||
"ignoreMiddleExtensions": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true
|
||||
},
|
||||
"ignorePatterns": ["**/dist/**/*"],
|
||||
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier", "plugin:jsdoc/recommended"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module"
|
||||
},
|
||||
"plugins": ["@typescript-eslint", "check-file", "jsdoc"],
|
||||
"rules": {
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"error",
|
||||
{
|
||||
"disallowTypeAnnotations": true,
|
||||
"fixStyle": "separate-type-imports",
|
||||
"prefer": "type-imports"
|
||||
}
|
||||
],
|
||||
"jsdoc/require-jsdoc": [
|
||||
"warn",
|
||||
{
|
||||
"publicOnly": true
|
||||
}
|
||||
],
|
||||
"jsdoc/check-tag-names": [
|
||||
"warn",
|
||||
{
|
||||
"definedTags": ["experimental"]
|
||||
}
|
||||
],
|
||||
"linebreak-style": ["error", "unix"],
|
||||
"quotes": [
|
||||
"error",
|
||||
"single",
|
||||
{
|
||||
"avoidEscape": true
|
||||
}
|
||||
],
|
||||
"semi": ["error", "always"],
|
||||
"check-file/filename-naming-convention": [
|
||||
"error",
|
||||
{
|
||||
"**/*.{js,ts}": "KEBAB_CASE"
|
||||
},
|
||||
{
|
||||
"ignoreMiddleExtensions": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,7 +15,7 @@ jobs:
|
|||
with:
|
||||
ref: ${{ github.event.ref }}
|
||||
|
||||
# if this is an SDK release, make sure there's no pending releases for @openfeature/core
|
||||
# if this is an SDK release, make sure there are no pending releases for @openfeature/core
|
||||
- name: Check for Pending Dependency PRs
|
||||
if: ${{ !endsWith(github.ref_name, env.CORE_PACKAGE) }}
|
||||
run: |
|
||||
|
@ -29,21 +29,6 @@ jobs:
|
|||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: 20
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
cache: 'npm'
|
||||
|
||||
# if this is an @openfeature/core release, but the SDKs to use this version as a peer, and commit back
|
||||
- name: Update Peer Version in Dependants
|
||||
if: ${{ endsWith(github.ref_name, env.CORE_PACKAGE) }}
|
||||
run: |
|
||||
npm run update-core-peers && \
|
||||
! git diff-files --quiet && \
|
||||
( echo 'Updated peer dependency in dependants, commiting...'
|
||||
git add --all && \
|
||||
git config user.name "openfeature-peer-update-bot" && \
|
||||
git config user.email "openfeature-peer-update-bot@openfeature.dev" && \
|
||||
git commit -m 'chore: bump @openfeaure/${{ env.CORE_PACKAGE }} peer' -s && \
|
||||
git push ) || echo 'Peer dependency in dependants is already up to date.'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
|
@ -16,7 +16,7 @@ jobs:
|
|||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
node-version: 16
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install
|
||||
|
|
|
@ -7,6 +7,9 @@ on:
|
|||
- edited
|
||||
- synchronize
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
main:
|
||||
name: Validate PR title
|
||||
|
@ -25,11 +28,11 @@ jobs:
|
|||
header: pr-title-lint-error
|
||||
message: |
|
||||
Hey there and thank you for opening this pull request! 👋🏼
|
||||
|
||||
|
||||
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted.
|
||||
|
||||
Details:
|
||||
|
||||
|
||||
```
|
||||
${{ steps.lint_pr_title.outputs.error_message }}
|
||||
```
|
||||
|
@ -37,6 +40,6 @@ jobs:
|
|||
# Delete a previous comment when the issue has been resolved
|
||||
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
with:
|
||||
with:
|
||||
header: pr-title-lint-error
|
||||
delete: true
|
||||
|
|
|
@ -16,9 +16,9 @@ jobs:
|
|||
strategy:
|
||||
matrix:
|
||||
node-version:
|
||||
- 16.x
|
||||
- 18.x
|
||||
- 20.x
|
||||
- 22.x
|
||||
- 24.x
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
@ -37,8 +37,11 @@ jobs:
|
|||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Test
|
||||
run: npm run test
|
||||
- name: Test Jest Projects
|
||||
run: npm run test:jest
|
||||
|
||||
- name: Test Angular SDK
|
||||
run: npm run test:angular
|
||||
|
||||
codecov-and-docs:
|
||||
runs-on: ubuntu-latest
|
||||
|
@ -47,7 +50,7 @@ jobs:
|
|||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install
|
||||
|
@ -65,18 +68,11 @@ jobs:
|
|||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
flagd:
|
||||
image: ghcr.io/open-feature/flagd-testbed:latest
|
||||
ports:
|
||||
- 8013:8013
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
# we need 'fetch' for this test, which is only in 18
|
||||
node-version: 18
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install
|
||||
|
@ -86,4 +82,4 @@ jobs:
|
|||
run: npm run build
|
||||
|
||||
- name: SDK e2e tests
|
||||
run: npm run e2e
|
||||
run: npm run e2e
|
||||
|
|
|
@ -18,11 +18,46 @@ jobs:
|
|||
signoff: "OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com>"
|
||||
outputs:
|
||||
release_created: ${{ steps.release.outputs.releases_created }}
|
||||
all: ${{ toJSON(steps.release.outputs) }}
|
||||
paths_released: ${{ steps.release.outputs.paths_released }}
|
||||
|
||||
sbom:
|
||||
needs: release-please
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ fromJSON(needs.release-please.outputs.paths_released)[0] != null }}
|
||||
# Continues with the release process even if SBOM generation fails.
|
||||
continue-on-error: true
|
||||
strategy:
|
||||
matrix:
|
||||
release: ${{ fromJSON(needs.release-please.outputs.paths_released) }}
|
||||
env:
|
||||
TAG: ${{ fromJSON(needs.release-please.outputs.all)[format('{0}--tag_name', matrix.release)] }}
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Generate SBOM
|
||||
run: |
|
||||
npm install -g npm@^10.2.0
|
||||
npm ci
|
||||
npm sbom --sbom-format=cyclonedx --omit=dev --omit=peer --workspace=${{matrix.release}} > bom.json
|
||||
- name: Attach SBOM to artifact
|
||||
env:
|
||||
GITHUB_TOKEN: ${{secrets.RELEASE_PLEASE_ACTION_TOKEN}}
|
||||
run:
|
||||
gh release upload $TAG bom.json
|
||||
|
||||
npm-release:
|
||||
needs: release-please
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ needs.release-please.outputs.release_created }}
|
||||
environment: publish
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
steps:
|
||||
# The logic below handles the npm publication:
|
||||
- name: Checkout Repository
|
||||
|
@ -30,7 +65,7 @@ jobs:
|
|||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: 20
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
cache: 'npm'
|
||||
- name: Build Packages
|
||||
|
@ -43,6 +78,8 @@ jobs:
|
|||
- name: Publish to NPM
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
|
||||
# https://docs.npmjs.com/generating-provenance-statements
|
||||
NPM_CONFIG_PROVENANCE: true
|
||||
run: npm run publish-all
|
||||
|
||||
- name: Build Docs
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
{
|
||||
"packages/react": "0.0.1-experimental",
|
||||
"packages/client": "0.4.2",
|
||||
"packages/server": "1.6.3",
|
||||
"packages/shared": "0.0.16"
|
||||
"packages/nest": "0.2.5",
|
||||
"packages/react": "1.0.1",
|
||||
"packages/web": "1.6.1",
|
||||
"packages/server": "1.19.0",
|
||||
"packages/shared": "1.9.0",
|
||||
"packages/angular/projects/angular-sdk": "0.0.16"
|
||||
}
|
||||
|
|
|
@ -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
|
||||
#
|
||||
* @open-feature/sdk-javascript-maintainers
|
||||
* @open-feature/sdk-javascript-maintainers @open-feature/maintainers
|
||||
|
|
|
@ -19,7 +19,7 @@ We value having as few runtime dependencies as possible. The addition of any dep
|
|||
### Modules
|
||||
|
||||
This repository uses [NPM workspaces](https://docs.npmjs.com/cli/v9/using-npm/workspaces) to establish a simple monorepo.
|
||||
Within the root project, there is one common project (`packages/shared`) which features common interfaces and code, consumed by the published modules (`packages/server` and `packages/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.
|
||||
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)).
|
||||
|
@ -30,19 +30,15 @@ Run tests with `npm test`.
|
|||
|
||||
### End-to-End Tests
|
||||
|
||||
The continuous integration runs a set of [gherkin e2e tests](https://github.com/open-feature/test-harness/blob/main/features/evaluation.feature) using [`flagd`](https://github.com/open-feature/flagd). These tests run with the "e2e" npm script. If you'd like to run them locally, you can start the flagd testbed with
|
||||
```
|
||||
docker run -p 8013:8013 ghcr.io/open-feature/flagd-testbed:latest
|
||||
```
|
||||
and then run
|
||||
The continuous integration runs a set of [gherkin e2e tests](https://github.com/open-feature/test-harness/blob/main/features/evaluation.feature) using in-memory provider. These tests run with the "e2e" npm script. If you'd like to run them locally, follow the steps below:
|
||||
```
|
||||
npm run e2e-server
|
||||
```
|
||||
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
|
||||
|
||||
|
@ -124,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,
|
||||
the owner should bring it to the OpenFeatures [meeting](README.md#contributing).
|
||||
|
||||
## Releasing
|
||||
|
||||
As with most OpenFeature repos, release-please supports our release process.
|
||||
For this SDK specifically, keep in mind this is a monorepo with dependencies with between components.
|
||||
If there are multiple release PRs open, ensure that you release them in order consistent with their dependency graph, waiting for each to fully complete.
|
||||
For example, if there are pending releases for: `@openfeature/core`, `@openfeature/web-sdk` and `@openfeature/react-sdk`, release them in that order.
|
||||
|
||||
Also ensure that if there are changes in an artifact which depend on changes in a dependency, that you reflect that in the `peerDependencies` field.
|
||||
For example, if a new release of `@openfeature/web-sdk` depends on features added in `@openfeature/core`, update the required minimum version of the `@openfeature/core` peer in the `@openfeature/web-sdk` package.json.
|
||||
|
||||
## Design Choices
|
||||
|
||||
As with other OpenFeature SDKs, js-sdk follows the
|
||||
|
|
|
@ -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.
|
||||
|
||||
- [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.
|
||||
|
||||
|
|
|
@ -120,17 +120,17 @@ export default {
|
|||
preset: 'ts-jest',
|
||||
testMatch: ['<rootDir>/packages/server/test/**/*.spec.ts'],
|
||||
moduleNameMapper: {
|
||||
'@openfeature/core': '<rootDir>/packages/shared/src'
|
||||
'@openfeature/core': '<rootDir>/packages/shared/src',
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'client',
|
||||
displayName: 'web',
|
||||
testEnvironment: 'node',
|
||||
preset: 'ts-jest',
|
||||
testMatch: ['<rootDir>/packages/client/test/**/*.spec.ts'],
|
||||
testMatch: ['<rootDir>/packages/web/test/**/*.spec.ts'],
|
||||
moduleNameMapper: {
|
||||
'@openfeature/core': '<rootDir>/packages/shared/src'
|
||||
}
|
||||
'@openfeature/core': '<rootDir>/packages/shared/src',
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'server-e2e',
|
||||
|
@ -138,28 +138,62 @@ export default {
|
|||
preset: 'ts-jest',
|
||||
testMatch: ['<rootDir>/packages/server/e2e/**/*.spec.ts'],
|
||||
modulePathIgnorePatterns: ['.*/node-modules/'],
|
||||
setupFiles: ['<rootDir>/packages/server/e2e/step-definitions/setup.ts'],
|
||||
moduleNameMapper: {
|
||||
'@openfeature/core': '<rootDir>/packages/shared/src'
|
||||
'@openfeature/core': '<rootDir>/packages/shared/src',
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'client-e2e',
|
||||
displayName: 'web-e2e',
|
||||
testEnvironment: 'node',
|
||||
preset: 'ts-jest',
|
||||
testMatch: ['<rootDir>/packages/client/e2e/**/*.spec.ts'],
|
||||
testMatch: ['<rootDir>/packages/web/e2e/**/*.spec.ts'],
|
||||
modulePathIgnorePatterns: ['.*/node-modules/'],
|
||||
setupFiles: ['<rootDir>/packages/client/e2e/step-definitions/setup.ts'],
|
||||
moduleNameMapper: {
|
||||
'^uuid$': require.resolve('uuid'),
|
||||
'^(.*)\\.js$': ['$1', '$1.js'],
|
||||
'@openfeature/core': '<rootDir>/packages/shared/src'
|
||||
'@openfeature/core': '<rootDir>/packages/shared/src',
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'nest',
|
||||
testEnvironment: 'node',
|
||||
preset: 'ts-jest',
|
||||
testMatch: ['<rootDir>/packages/nest/test/**/*.spec.ts'],
|
||||
moduleNameMapper: {
|
||||
'@openfeature/core': '<rootDir>/packages/shared/src',
|
||||
'@openfeature/server-sdk': '<rootDir>/packages/server/src',
|
||||
},
|
||||
transform: {
|
||||
'^.+\\.ts$': [
|
||||
'ts-jest',
|
||||
{
|
||||
tsconfig: '<rootDir>/packages/nest/tsconfig.spec.json',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
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
|
||||
// reporters: undefined,
|
||||
reporters: ['default', ['github-actions', { silent: false }], 'summary'],
|
||||
|
||||
// Automatically reset mock state before every test
|
||||
// resetMocks: false,
|
||||
|
|
File diff suppressed because it is too large
Load Diff
93
package.json
93
package.json
|
@ -1,20 +1,24 @@
|
|||
{
|
||||
"name": "@openfeature/js",
|
||||
"engines": {
|
||||
"npm": "^10.0.0"
|
||||
},
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "OpenFeature SDK for JavaScript",
|
||||
"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-client": "git submodule update --init --recursive && shx cp test-harness/features/evaluation.feature packages/client/e2e/features && jest --selectProjects=client-e2e --verbose",
|
||||
"e2e": "npm run e2e-server && npm run e2e-client",
|
||||
"lint": "npm run lint --workspace=packages/shared --workspace=packages/server --workspace=packages/client --workspace=packages/react",
|
||||
"e2e-web": "git submodule update --init --recursive && shx cp test-harness/features/evaluation.feature packages/web/e2e/features && jest --selectProjects=web-e2e --verbose",
|
||||
"e2e": "npm run e2e-server && npm run e2e-web",
|
||||
"lint": "npm run lint --workspace=packages/shared --workspace=packages/server --workspace=packages/web --workspace=packages/react --workspace=packages/angular --workspace=packages/nest",
|
||||
"lint:fix": "npm run lint:fix --workspace=packages/shared --workspace=packages/server --workspace=packages/web --workspace=packages/react --workspace=packages/angular --workspace=packages/nest",
|
||||
"clean": "shx rm -rf ./dist",
|
||||
"build": "npm run build --workspace=packages/shared --workspace=packages/server --workspace=packages/client --workspace=packages/react",
|
||||
"publish-all": "npm run publish-if-not-exists --workspace=packages/shared --workspace=packages/server --workspace=packages/client --workspace=packages/react",
|
||||
"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 --workspace=packages/react"
|
||||
"build": "npm run build --workspace=packages/shared --workspace=packages/server --workspace=packages/web --workspace=packages/react --workspace=packages/angular --workspace=packages/nest",
|
||||
"publish-all": "npm run publish-if-not-exists --workspace=packages/shared --workspace=packages/server --workspace=packages/web --workspace=packages/react --workspace=packages/angular --workspace=packages/nest",
|
||||
"docs": "typedoc"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
@ -32,50 +36,49 @@
|
|||
"url": "https://github.com/open-feature/js-sdk/issues"
|
||||
},
|
||||
"homepage": "https://github.com/open-feature/js-sdk#readme",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openfeature/flagd-provider": "^0.9.0",
|
||||
"@openfeature/flagd-web-provider": "^0.4.0",
|
||||
"@rollup/plugin-alias": "^5.0.0",
|
||||
"@rollup/plugin-typescript": "^11.0.0",
|
||||
"@types/events": "^3.0.0",
|
||||
"@types/jest": "^29.0.0",
|
||||
"@types/node": "^18.0.3",
|
||||
"@types/react": "^18.2.20",
|
||||
"@typescript-eslint/eslint-plugin": "^5.23.0",
|
||||
"@typescript-eslint/parser": "^5.23.0",
|
||||
"esbuild": "^0.17.0",
|
||||
"eslint": "^8.14.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"@rollup/plugin-typescript": "^12.0.0",
|
||||
"@testing-library/jest-dom": "^6.4.2",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^18.2.55",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-import-resolver-alias": "^1.1.2",
|
||||
"eslint-plugin-check-file": "^2.0.0",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-jest": "^27.0.1",
|
||||
"eslint-plugin-jsdoc": "^46.0.0",
|
||||
"events": "^3.3.0",
|
||||
"jest": "^29.4.3",
|
||||
"jest-config": "^29.4.3",
|
||||
"jest-cucumber": "^3.0.1",
|
||||
"jest-environment-jsdom": "^29.4.3",
|
||||
"jest-environment-node": "^29.4.3",
|
||||
"eslint-plugin-check-file": "^2.6.2",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"eslint-plugin-jest": "^28.0.0",
|
||||
"eslint-plugin-jsdoc": "^50.0.0",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"jest": "^29.7.0",
|
||||
"jest-config": "^29.7.0",
|
||||
"jest-cucumber": "^4.0.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-environment-node": "^29.7.0",
|
||||
"jest-junit": "^16.0.0",
|
||||
"prettier": "^3.0.0",
|
||||
"prettier": "^3.2.5",
|
||||
"react": "^18.2.0",
|
||||
"rollup": "^3.18.0",
|
||||
"rollup-plugin-dts": "^5.2.0",
|
||||
"shx": "^0.3.4",
|
||||
"ts-jest": "^29.0.5",
|
||||
"ts-node": "^10.8.2",
|
||||
"typedoc": "^0.25.3",
|
||||
"rollup": "^4.0.0",
|
||||
"rollup-plugin-dts": "^6.1.1",
|
||||
"rxjs": "~7.8.0",
|
||||
"shx": "^0.4.0",
|
||||
"ts-jest": "^29.1.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tslib": "^2.3.0",
|
||||
"typedoc": "^0.26.0",
|
||||
"typescript": "^4.7.4",
|
||||
"uuid": "^9.0.0"
|
||||
"uuid": "^11.0.0"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/shared",
|
||||
"packages/server",
|
||||
"packages/client",
|
||||
"packages/react"
|
||||
"packages/web",
|
||||
"packages/react",
|
||||
"packages/angular",
|
||||
"packages/angular/projects/angular-sdk",
|
||||
"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,155 +0,0 @@
|
|||
# Changelog
|
||||
|
||||
|
||||
## [0.4.2](https://github.com/open-feature/js-sdk/compare/web-sdk-v0.4.1...web-sdk-v0.4.2) (2023-10-31)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add support for clearing providers ([#578](https://github.com/open-feature/js-sdk/issues/578)) ([a3a907f](https://github.com/open-feature/js-sdk/commit/a3a907f348d7ff2ac7cd42eca61cd760fdd93048))
|
||||
|
||||
## [0.4.1](https://github.com/open-feature/js-sdk/compare/web-sdk-v0.4.0...web-sdk-v0.4.1) (2023-10-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add provider compatibility check ([#537](https://github.com/open-feature/js-sdk/issues/537)) ([2bc5d63](https://github.com/open-feature/js-sdk/commit/2bc5d63266424a900da523f001f425b95da29ccc))
|
||||
* add support for a blocking setProvider ([#577](https://github.com/open-feature/js-sdk/issues/577)) ([d1f5049](https://github.com/open-feature/js-sdk/commit/d1f50490650da78ff7936641425b1a0614833c63))
|
||||
* STALE state, minor event changes ([#541](https://github.com/open-feature/js-sdk/issues/541)) ([0b5355b](https://github.com/open-feature/js-sdk/commit/0b5355b3cf7e606f9364a110a18e1c6aeca5c230))
|
||||
|
||||
## [0.4.0](https://github.com/open-feature/js-sdk/compare/web-sdk-v0.3.11...web-sdk-v0.4.0) (2023-07-31)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* release first non-experimental version ([#528](https://github.com/open-feature/js-sdk/issues/528)) ([a4ba064](https://github.com/open-feature/js-sdk/commit/a4ba0645500ae82e4052362345e964c7ee226bb2))
|
||||
|
||||
## [0.3.11](https://github.com/open-feature/js-sdk/compare/web-sdk-v0.3.10-experimental...web-sdk-v0.3.11) (2023-07-31)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* only initialize NOT_READY providers ([#507](https://github.com/open-feature/js-sdk/issues/507)) ([5e320ae](https://github.com/open-feature/js-sdk/commit/5e320ae3811e270985e867c1c85a301eacd99a49))
|
||||
|
||||
## [0.3.10-experimental](https://github.com/open-feature/js-sdk/compare/web-sdk-v0.3.9-experimental...web-sdk-v0.3.10-experimental) (2023-07-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* re-release web-sdk ([15491ba](https://github.com/open-feature/js-sdk/commit/15491bada9a62cfa27b028074716c414d364ad96))
|
||||
|
||||
## [0.3.8-experimental](https://github.com/open-feature/js-sdk/compare/web-sdk-v0.3.7-experimental...web-sdk-v0.3.8-experimental) (2023-07-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* typesafe event emitter ([#490](https://github.com/open-feature/js-sdk/issues/490)) ([92e3a72](https://github.com/open-feature/js-sdk/commit/92e3a724bf4e53721644c2155060b2bd44a43c39))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* onContextChanged not running for named providers ([#491](https://github.com/open-feature/js-sdk/issues/491)) ([1ab0cc6](https://github.com/open-feature/js-sdk/commit/1ab0cc6bb250b27dd2cf6462aa3d831fcf8526f3))
|
||||
* race adding handler during init ([#501](https://github.com/open-feature/js-sdk/issues/501)) ([0be9c5d](https://github.com/open-feature/js-sdk/commit/0be9c5dcd9c7f8bc76d28e94b2e80617836323e5))
|
||||
|
||||
## [0.3.7-experimental](https://github.com/open-feature/js-sdk/compare/web-sdk-v0.3.6-experimental...web-sdk-v0.3.7-experimental) (2023-07-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* events on anon provider/client ([#480](https://github.com/open-feature/js-sdk/issues/480)) ([c44b18e](https://github.com/open-feature/js-sdk/commit/c44b18eb9eb6d6e828af61767b9f3e39f2cef1af))
|
||||
|
||||
## [0.3.6-experimental](https://github.com/open-feature/js-sdk/compare/web-sdk-v0.3.5-experimental...web-sdk-v0.3.6-experimental) (2023-07-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* named client events ([#472](https://github.com/open-feature/js-sdk/issues/472)) ([fb69b9d](https://github.com/open-feature/js-sdk/commit/fb69b9d665172de7d79c84b36adbbcf0c315b701))
|
||||
|
||||
## [0.3.5-experimental](https://github.com/open-feature/js-sdk/compare/web-sdk-v0.3.4-experimental...web-sdk-v0.3.5-experimental) (2023-06-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* various event handler issues ([1dd1e17](https://github.com/open-feature/js-sdk/commit/1dd1e17361ef85e89f858d00475830bffec4173b))
|
||||
|
||||
## [0.3.4-experimental](https://github.com/open-feature/js-sdk/compare/web-sdk-v0.3.3-experimental...web-sdk-v0.3.4-experimental) (2023-06-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* updated typedoc config to support monorepos ([#447](https://github.com/open-feature/js-sdk/issues/447)) ([05b100d](https://github.com/open-feature/js-sdk/commit/05b100dca540dfa6317a01cb238af6d9a1c1c2ef))
|
||||
|
||||
## [0.3.3-experimental](https://github.com/open-feature/js-sdk/compare/web-sdk-v0.3.2-experimental...web-sdk-v0.3.3-experimental) (2023-06-06)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add init/shutdown and events ([#436](https://github.com/open-feature/js-sdk/issues/436)) ([5d55ea1](https://github.com/open-feature/js-sdk/commit/5d55ea1d08267a09f36c6b1508298646ee34616c))
|
||||
* add named client support ([#429](https://github.com/open-feature/js-sdk/issues/429)) ([310c6ac](https://github.com/open-feature/js-sdk/commit/310c6ac51ee06de5db75e16b64ace150bcf55fbe))
|
||||
* add support for flag metadata ([#426](https://github.com/open-feature/js-sdk/issues/426)) ([029ec26](https://github.com/open-feature/js-sdk/commit/029ec26eb255a2549abcbeba12f41d4b9e57c100))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* bundlers wrongly resolving server/client modules ([#445](https://github.com/open-feature/js-sdk/issues/445)) ([6acddd5](https://github.com/open-feature/js-sdk/commit/6acddd529703364effa029341496900fc8671f6b))
|
||||
* only shutdown providers that are not attached to any client ([#444](https://github.com/open-feature/js-sdk/issues/444)) ([7e469c4](https://github.com/open-feature/js-sdk/commit/7e469c49cab2a26b3f402eae4807365e08cd7a62))
|
||||
|
||||
## [0.3.2-experimental](https://github.com/open-feature/js-sdk/compare/web-sdk-v0.3.1-experimental...web-sdk-v0.3.2-experimental) (2023-05-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* remove events exports from server ([#413](https://github.com/open-feature/js-sdk/issues/413)) ([7cac0c8](https://github.com/open-feature/js-sdk/commit/7cac0c87abfe6d6962b7f64a58b25d76ed06d4cb))
|
||||
|
||||
## [0.3.1-experimental](https://github.com/open-feature/js-sdk/compare/web-sdk-v0.3.0-experimental...web-sdk-v0.3.1-experimental) (2023-04-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* import cycle ([#395](https://github.com/open-feature/js-sdk/issues/395)) ([ac0f10d](https://github.com/open-feature/js-sdk/commit/ac0f10d04e61d37965fe25bc8d5f7efa0ba717d6))
|
||||
|
||||
## [0.3.0-experimental](https://github.com/open-feature/js-sdk/compare/web-sdk-v0.2.0-experimental...web-sdk-v0.3.0-experimental) (2023-04-03)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* use bundled event emitter type ([#389](https://github.com/open-feature/js-sdk/issues/389))
|
||||
|
||||
### Features
|
||||
|
||||
* use bundled event emitter type ([#389](https://github.com/open-feature/js-sdk/issues/389)) ([47d1634](https://github.com/open-feature/js-sdk/commit/47d16341106a79e86d78a8dc40fd9b9491b7fc5a))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* fix readme typo ([a23f899](https://github.com/open-feature/js-sdk/commit/a23f899d688606f624af3baf93e8eabd1cd26096))
|
||||
|
||||
## [0.2.0-experimental](https://github.com/open-feature/js-sdk/compare/web-sdk-v0.1.0-experimental...web-sdk-v0.2.0-experimental) (2023-03-22)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* use node style events, add initialize ([#379](https://github.com/open-feature/js-sdk/issues/379))
|
||||
|
||||
### Features
|
||||
|
||||
* use node style events, add initialize ([#379](https://github.com/open-feature/js-sdk/issues/379)) ([6625918](https://github.com/open-feature/js-sdk/commit/662591861140cb9b387b3810aa2b2353f7af257e))
|
||||
|
||||
## [0.1.0-experimental](https://github.com/open-feature/js-sdk/compare/web-sdk-v0.0.2-experimental...web-sdk-v0.1.0-experimental) (2023-03-13)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* remove context from client interfaces ([#373](https://github.com/open-feature/js-sdk/issues/373))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* remove context from client interfaces ([#373](https://github.com/open-feature/js-sdk/issues/373)) ([a692a32](https://github.com/open-feature/js-sdk/commit/a692a329ac73f8c9e507dd58b8390533a7648375))
|
||||
|
||||
## Changelog
|
||||
|
||||
### Dependencies
|
||||
|
||||
* The following workspace dependencies were updated
|
||||
* devDependencies
|
||||
* @openfeature/shared bumped from 0.0.1 to 0.0.2
|
|
@ -1,19 +0,0 @@
|
|||
import assert from 'assert';
|
||||
import { OpenFeature } from '../..';
|
||||
import { FlagdWebProvider } from '@openfeature/flagd-web-provider';
|
||||
|
||||
const FLAGD_WEB_NAME = 'flagd-web';
|
||||
|
||||
// register the flagd provider before the tests.
|
||||
console.log('Setting flagd web provider...');
|
||||
OpenFeature.setProvider(new FlagdWebProvider({
|
||||
host: 'localhost',
|
||||
port: 8013,
|
||||
tls: false,
|
||||
maxRetries: -1,
|
||||
}));
|
||||
assert(
|
||||
OpenFeature.providerMetadata.name === FLAGD_WEB_NAME,
|
||||
new Error(`Expected ${FLAGD_WEB_NAME} provider to be configured, instead got: ${OpenFeature.providerMetadata.name}`)
|
||||
);
|
||||
console.log('flagd web provider configured!');
|
|
@ -1,6 +0,0 @@
|
|||
import { ClientMetadata, EvaluationLifeCycle, Eventing, ManageLogger } from '@openfeature/core';
|
||||
import { Features } from '../evaluation';
|
||||
|
||||
export interface Client extends EvaluationLifeCycle<Client>, Features, ManageLogger<Client>, Eventing {
|
||||
readonly metadata: ClientMetadata;
|
||||
}
|
|
@ -1,2 +0,0 @@
|
|||
export * from './client';
|
||||
export * from './open-feature-client';
|
|
@ -1,291 +0,0 @@
|
|||
import {
|
||||
ClientMetadata,
|
||||
ErrorCode,
|
||||
EvaluationContext,
|
||||
EvaluationDetails,
|
||||
EventHandler,
|
||||
FlagValue,
|
||||
FlagValueType,
|
||||
Hook,
|
||||
HookContext,
|
||||
JsonValue,
|
||||
Logger,
|
||||
OpenFeatureError,
|
||||
OpenFeatureEventEmitter,
|
||||
ProviderEvents,
|
||||
ProviderStatus,
|
||||
ResolutionDetails,
|
||||
SafeLogger,
|
||||
StandardResolutionReasons,
|
||||
} from '@openfeature/core';
|
||||
import { FlagEvaluationOptions } from '../evaluation';
|
||||
import { OpenFeature } from '../open-feature';
|
||||
import { Provider } from '../provider';
|
||||
import { Client } from './client';
|
||||
|
||||
type OpenFeatureClientOptions = {
|
||||
name?: string;
|
||||
version?: string;
|
||||
};
|
||||
|
||||
export class OpenFeatureClient implements Client {
|
||||
private _hooks: Hook[] = [];
|
||||
private _clientLogger?: Logger;
|
||||
|
||||
constructor(
|
||||
// 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.
|
||||
private readonly providerAccessor: () => Provider,
|
||||
private readonly emitterAccessor: () => OpenFeatureEventEmitter,
|
||||
private readonly globalLogger: () => Logger,
|
||||
private readonly options: OpenFeatureClientOptions
|
||||
) {}
|
||||
|
||||
get metadata(): ClientMetadata {
|
||||
return {
|
||||
name: this.options.name,
|
||||
version: this.options.version,
|
||||
providerMetadata: this.providerAccessor().metadata,
|
||||
};
|
||||
}
|
||||
|
||||
addHandler<T extends ProviderEvents>(eventType: T, handler: EventHandler<T>): void {
|
||||
this.emitterAccessor().addHandler(eventType, handler);
|
||||
const providerReady = !this._provider.status || this._provider.status === ProviderStatus.READY;
|
||||
|
||||
if (eventType === ProviderEvents.Ready && providerReady) {
|
||||
// run immediately, we're ready.
|
||||
try {
|
||||
handler({ clientName: this.metadata.name, providerName: this._provider.metadata.name });
|
||||
} catch (err) {
|
||||
this._logger?.error('Error running event handler:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeHandler<T extends ProviderEvents>(notificationType: T, handler: EventHandler<T>): void {
|
||||
this.emitterAccessor().removeHandler(notificationType, handler);
|
||||
}
|
||||
|
||||
getHandlers(eventType: ProviderEvents) {
|
||||
return this.emitterAccessor().getHandlers(eventType);
|
||||
}
|
||||
|
||||
setLogger(logger: Logger): this {
|
||||
this._clientLogger = new SafeLogger(logger);
|
||||
return this;
|
||||
}
|
||||
|
||||
addHooks(...hooks: Hook<FlagValue>[]): this {
|
||||
this._hooks = [...this._hooks, ...hooks];
|
||||
return this;
|
||||
}
|
||||
|
||||
getHooks(): Hook<FlagValue>[] {
|
||||
return this._hooks;
|
||||
}
|
||||
|
||||
clearHooks(): this {
|
||||
this._hooks = [];
|
||||
return this;
|
||||
}
|
||||
|
||||
getBooleanValue(flagKey: string, defaultValue: boolean, options?: FlagEvaluationOptions): boolean {
|
||||
return this.getBooleanDetails(flagKey, defaultValue, options).value;
|
||||
}
|
||||
|
||||
getBooleanDetails(
|
||||
flagKey: string,
|
||||
defaultValue: boolean,
|
||||
options?: FlagEvaluationOptions
|
||||
): EvaluationDetails<boolean> {
|
||||
return this.evaluate<boolean>(flagKey, this._provider.resolveBooleanEvaluation, defaultValue, 'boolean', options);
|
||||
}
|
||||
|
||||
getStringValue<T extends string = string>(flagKey: string, defaultValue: T, options?: FlagEvaluationOptions): T {
|
||||
return this.getStringDetails<T>(flagKey, defaultValue, options).value;
|
||||
}
|
||||
|
||||
getStringDetails<T extends string = string>(
|
||||
flagKey: string,
|
||||
defaultValue: T,
|
||||
options?: FlagEvaluationOptions
|
||||
): EvaluationDetails<T> {
|
||||
return this.evaluate<T>(
|
||||
flagKey,
|
||||
// this isolates providers from our restricted string generic argument.
|
||||
this._provider.resolveStringEvaluation as () => EvaluationDetails<T>,
|
||||
defaultValue,
|
||||
'string',
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
getNumberValue<T extends number = number>(flagKey: string, defaultValue: T, options?: FlagEvaluationOptions): T {
|
||||
return this.getNumberDetails(flagKey, defaultValue, options).value;
|
||||
}
|
||||
|
||||
getNumberDetails<T extends number = number>(
|
||||
flagKey: string,
|
||||
defaultValue: T,
|
||||
options?: FlagEvaluationOptions
|
||||
): EvaluationDetails<T> {
|
||||
return this.evaluate<T>(
|
||||
flagKey,
|
||||
// this isolates providers from our restricted number generic argument.
|
||||
this._provider.resolveNumberEvaluation as () => EvaluationDetails<T>,
|
||||
defaultValue,
|
||||
'number',
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
getObjectValue<T extends JsonValue = JsonValue>(
|
||||
flagKey: string,
|
||||
defaultValue: T,
|
||||
options?: FlagEvaluationOptions
|
||||
): T {
|
||||
return this.getObjectDetails(flagKey, defaultValue, options).value;
|
||||
}
|
||||
|
||||
getObjectDetails<T extends JsonValue = JsonValue>(
|
||||
flagKey: string,
|
||||
defaultValue: T,
|
||||
options?: FlagEvaluationOptions
|
||||
): EvaluationDetails<T> {
|
||||
return this.evaluate<T>(flagKey, this._provider.resolveObjectEvaluation, defaultValue, 'object', options);
|
||||
}
|
||||
|
||||
private evaluate<T extends FlagValue>(
|
||||
flagKey: string,
|
||||
resolver: (flagKey: string, defaultValue: T, context: EvaluationContext, logger: Logger) => ResolutionDetails<T>,
|
||||
defaultValue: T,
|
||||
flagType: FlagValueType,
|
||||
options: FlagEvaluationOptions = {}
|
||||
): EvaluationDetails<T> {
|
||||
// merge global, client, and evaluation context
|
||||
|
||||
const allHooks = [
|
||||
...OpenFeature.getHooks(),
|
||||
...this.getHooks(),
|
||||
...(options.hooks || []),
|
||||
...(this._provider.hooks || []),
|
||||
];
|
||||
const allHooksReversed = [...allHooks].reverse();
|
||||
|
||||
const context = {
|
||||
...OpenFeature.getContext(),
|
||||
};
|
||||
|
||||
// this reference cannot change during the course of evaluation
|
||||
// it may be used as a key in WeakMaps
|
||||
const hookContext: Readonly<HookContext> = {
|
||||
flagKey,
|
||||
defaultValue,
|
||||
flagValueType: flagType,
|
||||
clientMetadata: this.metadata,
|
||||
providerMetadata: OpenFeature.providerMetadata,
|
||||
context,
|
||||
logger: this._logger,
|
||||
};
|
||||
|
||||
try {
|
||||
this.beforeHooks(allHooks, hookContext, options);
|
||||
|
||||
// run the referenced resolver, binding the provider.
|
||||
const resolution = resolver.call(this._provider, flagKey, defaultValue, context, this._logger);
|
||||
|
||||
const evaluationDetails = {
|
||||
...resolution,
|
||||
flagMetadata: Object.freeze(resolution.flagMetadata ?? {}),
|
||||
flagKey,
|
||||
};
|
||||
|
||||
this.afterHooks(allHooksReversed, hookContext, evaluationDetails, options);
|
||||
|
||||
return evaluationDetails;
|
||||
} catch (err: unknown) {
|
||||
const errorMessage: string = (err as Error)?.message;
|
||||
const errorCode: ErrorCode = (err as OpenFeatureError)?.code || ErrorCode.GENERAL;
|
||||
|
||||
this.errorHooks(allHooksReversed, hookContext, err, options);
|
||||
|
||||
return {
|
||||
errorCode,
|
||||
errorMessage,
|
||||
value: defaultValue,
|
||||
reason: StandardResolutionReasons.ERROR,
|
||||
flagMetadata: Object.freeze({}),
|
||||
flagKey,
|
||||
};
|
||||
} finally {
|
||||
this.finallyHooks(allHooksReversed, hookContext, options);
|
||||
}
|
||||
}
|
||||
|
||||
private beforeHooks(hooks: Hook[], hookContext: HookContext, options: FlagEvaluationOptions) {
|
||||
for (const hook of hooks) {
|
||||
// freeze the hookContext
|
||||
Object.freeze(hookContext);
|
||||
|
||||
// use Object.assign to avoid modification of frozen hookContext
|
||||
Object.assign(hookContext.context, {
|
||||
...hookContext.context,
|
||||
...hook?.before?.(hookContext, Object.freeze(options.hookHints)),
|
||||
});
|
||||
}
|
||||
|
||||
// after before hooks, freeze the EvaluationContext.
|
||||
return Object.freeze(hookContext.context);
|
||||
}
|
||||
|
||||
private afterHooks(
|
||||
hooks: Hook[],
|
||||
hookContext: HookContext,
|
||||
evaluationDetails: EvaluationDetails<FlagValue>,
|
||||
options: FlagEvaluationOptions
|
||||
) {
|
||||
// run "after" hooks sequentially
|
||||
for (const hook of hooks) {
|
||||
hook?.after?.(hookContext, evaluationDetails, options.hookHints);
|
||||
}
|
||||
}
|
||||
|
||||
private errorHooks(hooks: Hook[], hookContext: HookContext, err: unknown, options: FlagEvaluationOptions) {
|
||||
// run "error" hooks sequentially
|
||||
for (const hook of hooks) {
|
||||
try {
|
||||
hook?.error?.(hookContext, err, options.hookHints);
|
||||
} catch (err) {
|
||||
this._logger.error(`Unhandled error during 'error' hook: ${err}`);
|
||||
if (err instanceof Error) {
|
||||
this._logger.error(err.stack);
|
||||
}
|
||||
this._logger.error((err as Error)?.stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private finallyHooks(hooks: Hook[], hookContext: HookContext, options: FlagEvaluationOptions) {
|
||||
// run "finally" hooks sequentially
|
||||
for (const hook of hooks) {
|
||||
try {
|
||||
hook?.finally?.(hookContext, options.hookHints);
|
||||
} catch (err) {
|
||||
this._logger.error(`Unhandled error during 'finally' hook: ${err}`);
|
||||
if (err instanceof Error) {
|
||||
this._logger.error(err.stack);
|
||||
}
|
||||
this._logger.error((err as Error)?.stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private get _provider(): Provider {
|
||||
return this.providerAccessor();
|
||||
}
|
||||
|
||||
private get _logger() {
|
||||
return this._clientLogger || this.globalLogger();
|
||||
}
|
||||
}
|
|
@ -1,92 +0,0 @@
|
|||
import { EvaluationContext, ManageContext, OpenFeatureCommonAPI } from '@openfeature/core';
|
||||
import { Client, OpenFeatureClient } from './client';
|
||||
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;
|
||||
};
|
||||
const _globalThis = globalThis as OpenFeatureGlobal;
|
||||
|
||||
export class OpenFeatureAPI extends OpenFeatureCommonAPI<Provider> implements ManageContext<Promise<void>> {
|
||||
protected _defaultProvider: Provider = NOOP_PROVIDER;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
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;
|
||||
}
|
||||
|
||||
async setContext(context: EvaluationContext): Promise<void> {
|
||||
const oldContext = this._context;
|
||||
this._context = context;
|
||||
|
||||
const allProviders = [this._defaultProvider, ...this._clientProviders.values()];
|
||||
await Promise.all(
|
||||
allProviders.map(async (provider) => {
|
||||
try {
|
||||
return await provider.onContextChange?.(oldContext, context);
|
||||
} catch (err) {
|
||||
this._logger?.error(`Error running context change handler of provider ${provider.metadata.name}:`, err);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getContext(): EvaluationContext {
|
||||
return this._context;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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} name The name of the client
|
||||
* @param {string} version The version of the client (only used for metadata)
|
||||
* @returns {Client} OpenFeature Client
|
||||
*/
|
||||
getClient(name?: 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(name),
|
||||
() => this.buildAndCacheEventEmitterForClient(name),
|
||||
() => this._logger,
|
||||
{ name, version }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all registered providers and resets the default provider.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
clearProviders(): Promise<void> {
|
||||
return super.clearProvidersAndSetDefault(NOOP_PROVIDER);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A singleton instance of the OpenFeature API.
|
||||
* @returns {OpenFeatureAPI} OpenFeature API
|
||||
*/
|
||||
export const OpenFeature = OpenFeatureAPI.getInstance();
|
|
@ -1,99 +0,0 @@
|
|||
import { EvaluationContext, JsonValue, OpenFeature, Provider, ProviderMetadata, ResolutionDetails } from '../src';
|
||||
|
||||
class MockProvider implements Provider {
|
||||
readonly metadata: ProviderMetadata;
|
||||
|
||||
constructor(options?: { name?: string }) {
|
||||
this.metadata = { name: options?.name ?? 'mock-provider' };
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
onContextChange(oldContext: EvaluationContext, newContext: EvaluationContext): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
resolveBooleanEvaluation(): ResolutionDetails<boolean> {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
resolveNumberEvaluation(): ResolutionDetails<number> {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
resolveObjectEvaluation<T extends JsonValue>(): ResolutionDetails<T> {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
resolveStringEvaluation(): ResolutionDetails<string> {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
}
|
||||
|
||||
describe('Evaluation Context', () => {
|
||||
describe('Requirement 3.2.2', () => {
|
||||
it('the API MUST have a method for setting the global evaluation context', () => {
|
||||
const context: EvaluationContext = { property1: false };
|
||||
OpenFeature.setContext(context);
|
||||
expect(OpenFeature.getContext()).toEqual(context);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Requirement 3.2.4', () => {
|
||||
describe('when the global evaluation context is set, the on context changed handler MUST run', () => {
|
||||
it('on all registered providers', async () => {
|
||||
// Set initial context
|
||||
const context: EvaluationContext = { property1: false };
|
||||
await OpenFeature.setContext(context);
|
||||
|
||||
// Set some providers
|
||||
const defaultProvider = new MockProvider();
|
||||
const provider1 = new MockProvider();
|
||||
const provider2 = new MockProvider();
|
||||
|
||||
OpenFeature.setProvider(defaultProvider);
|
||||
OpenFeature.setProvider('client1', provider1);
|
||||
OpenFeature.setProvider('client2', provider2);
|
||||
|
||||
// Spy on context changed handlers of all providers
|
||||
const contextChangedSpys = [defaultProvider, provider1, provider2].map((provider) =>
|
||||
jest.spyOn(provider, 'onContextChange')
|
||||
);
|
||||
|
||||
// Change context
|
||||
const newContext: EvaluationContext = { property1: true, property2: 'prop2' };
|
||||
await OpenFeature.setContext(newContext);
|
||||
|
||||
contextChangedSpys.forEach((spy) => expect(spy).toHaveBeenCalledWith(context, newContext));
|
||||
});
|
||||
|
||||
it('on all registered providers even if one fails', async () => {
|
||||
// Set initial context
|
||||
const context: EvaluationContext = { property1: false };
|
||||
await OpenFeature.setContext(context);
|
||||
|
||||
// Set some providers
|
||||
const defaultProvider = new MockProvider();
|
||||
const provider1 = new MockProvider();
|
||||
const provider2 = new MockProvider();
|
||||
|
||||
OpenFeature.setProvider(defaultProvider);
|
||||
OpenFeature.setProvider('client1', provider1);
|
||||
OpenFeature.setProvider('client2', provider2);
|
||||
|
||||
// Spy on context changed handlers of all providers
|
||||
const contextChangedSpys = [defaultProvider, provider1, provider2].map((provider) =>
|
||||
jest.spyOn(provider, 'onContextChange')
|
||||
);
|
||||
|
||||
// Let first handler fail
|
||||
contextChangedSpys[0].mockImplementation(() => Promise.reject(new Error('Error')));
|
||||
|
||||
// Change context
|
||||
const newContext: EvaluationContext = { property1: true, property2: 'prop2' };
|
||||
await OpenFeature.setContext(newContext);
|
||||
|
||||
contextChangedSpys.forEach((spy) => expect(spy).toHaveBeenCalledWith(context, newContext));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,533 +0,0 @@
|
|||
import { v4 as uuid } from 'uuid';
|
||||
import {
|
||||
JsonValue,
|
||||
NOOP_PROVIDER,
|
||||
OpenFeature,
|
||||
OpenFeatureEventEmitter,
|
||||
Provider,
|
||||
ProviderEvents,
|
||||
ProviderMetadata,
|
||||
ProviderStatus,
|
||||
ResolutionDetails,
|
||||
StaleEvent,
|
||||
} from '../src';
|
||||
|
||||
const TIMEOUT = 1000;
|
||||
|
||||
class MockProvider implements Provider {
|
||||
readonly metadata: ProviderMetadata;
|
||||
readonly events?: OpenFeatureEventEmitter;
|
||||
readonly runsOn = 'client';
|
||||
private hasInitialize: boolean;
|
||||
private failOnInit: boolean;
|
||||
private initDelay?: number;
|
||||
private enableEvents: boolean;
|
||||
status?: ProviderStatus = undefined;
|
||||
|
||||
constructor(options?: {
|
||||
hasInitialize?: boolean;
|
||||
initialStatus?: ProviderStatus;
|
||||
initDelay?: number;
|
||||
enableEvents?: boolean;
|
||||
failOnInit?: boolean;
|
||||
name?: string;
|
||||
}) {
|
||||
this.metadata = { name: options?.name ?? 'mock-provider' };
|
||||
this.hasInitialize = options?.hasInitialize ?? true;
|
||||
this.status = options?.initialStatus ?? ProviderStatus.NOT_READY;
|
||||
this.initDelay = options?.initDelay ?? 0;
|
||||
this.enableEvents = options?.enableEvents ?? true;
|
||||
this.failOnInit = options?.failOnInit ?? false;
|
||||
|
||||
if (this.enableEvents) {
|
||||
this.events = new OpenFeatureEventEmitter();
|
||||
}
|
||||
|
||||
if (this.hasInitialize) {
|
||||
this.initialize = jest.fn(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, this.initDelay));
|
||||
if (this.failOnInit) {
|
||||
throw new Error('Provider initialization failed');
|
||||
}
|
||||
|
||||
this.status = ProviderStatus.READY;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
initialize: jest.Mock<Promise<void>, []> | undefined;
|
||||
|
||||
resolveBooleanEvaluation(): ResolutionDetails<boolean> {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
resolveNumberEvaluation(): ResolutionDetails<number> {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
resolveObjectEvaluation<T extends JsonValue>(): ResolutionDetails<T> {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
resolveStringEvaluation(): ResolutionDetails<string> {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
}
|
||||
|
||||
describe('Events', () => {
|
||||
// set timeouts short for this suite.
|
||||
jest.setTimeout(TIMEOUT);
|
||||
let clientId = uuid();
|
||||
|
||||
afterEach(async () => {
|
||||
await OpenFeature.clearProviders();
|
||||
jest.clearAllMocks();
|
||||
clientId = uuid();
|
||||
// hacky, but it's helpful to clear the handlers between tests
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
(OpenFeature as any)._clientEventHandlers = new Map();
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
(OpenFeature as any)._clientEvents = new Map();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
OpenFeature.setProvider(NOOP_PROVIDER);
|
||||
});
|
||||
|
||||
describe('Requirement 5.1.1', () => {
|
||||
describe('provider implements events', () => {
|
||||
it('The provider defines a mechanism for signalling the occurrence of an event`PROVIDER_READY`', (done) => {
|
||||
const provider = new MockProvider();
|
||||
const client = OpenFeature.getClient(clientId);
|
||||
client.addHandler(ProviderEvents.Ready, () => {
|
||||
try {
|
||||
expect(client.metadata.providerMetadata.name).toBe(provider.metadata.name);
|
||||
expect(provider.initialize).toHaveBeenCalled();
|
||||
done();
|
||||
} catch (err) {
|
||||
done(err);
|
||||
}
|
||||
});
|
||||
OpenFeature.setProvider(clientId, provider);
|
||||
});
|
||||
|
||||
it('It defines a mechanism for signalling `PROVIDER_ERROR`', (done) => {
|
||||
//make sure an error event is fired when initialize promise reject
|
||||
const provider = new MockProvider({ failOnInit: true });
|
||||
const client = OpenFeature.getClient(clientId);
|
||||
|
||||
client.addHandler(ProviderEvents.Error, () => {
|
||||
try {
|
||||
expect(client.metadata.providerMetadata.name).toBe(provider.metadata.name);
|
||||
expect(provider.initialize).toHaveBeenCalled();
|
||||
done();
|
||||
} catch (err) {
|
||||
done(err);
|
||||
}
|
||||
});
|
||||
|
||||
OpenFeature.setProvider(clientId, provider);
|
||||
});
|
||||
});
|
||||
|
||||
describe('provider does not implement events', () => {
|
||||
it('The provider defines a mechanism for signalling the occurrence of an event`PROVIDER_READY`', (done) => {
|
||||
const provider = new MockProvider({ enableEvents: false });
|
||||
const client = OpenFeature.getClient(clientId);
|
||||
|
||||
client.addHandler(ProviderEvents.Ready, () => {
|
||||
try {
|
||||
expect(client.metadata.providerMetadata.name).toBe(provider.metadata.name);
|
||||
done();
|
||||
} catch (err) {
|
||||
done(err);
|
||||
}
|
||||
});
|
||||
|
||||
OpenFeature.setProvider(clientId, provider);
|
||||
});
|
||||
|
||||
it('It defines a mechanism for signalling `PROVIDER_ERROR`', (done) => {
|
||||
const provider = new MockProvider({ enableEvents: false, failOnInit: true });
|
||||
const client = OpenFeature.getClient(clientId);
|
||||
|
||||
client.addHandler(ProviderEvents.Error, () => {
|
||||
try {
|
||||
expect(client.metadata.providerMetadata.name).toBe(provider.metadata.name);
|
||||
expect(provider.initialize).toHaveBeenCalled();
|
||||
done();
|
||||
} catch (err) {
|
||||
done(err);
|
||||
}
|
||||
});
|
||||
|
||||
OpenFeature.setProvider(clientId, provider);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Requirement 5.1.2', () => {
|
||||
it('When a provider signals the occurrence of a particular event, the associated client and API event handlers run', (done) => {
|
||||
const provider = new MockProvider();
|
||||
const client = OpenFeature.getClient(clientId);
|
||||
|
||||
Promise.all([
|
||||
new Promise<void>((resolve) => {
|
||||
client.addHandler(ProviderEvents.Error, () => {
|
||||
resolve();
|
||||
});
|
||||
}),
|
||||
new Promise<void>((resolve) => {
|
||||
OpenFeature.addHandler(ProviderEvents.Error, () => {
|
||||
resolve();
|
||||
});
|
||||
}),
|
||||
]).then(() => {
|
||||
done();
|
||||
});
|
||||
|
||||
OpenFeature.setProvider(clientId, provider);
|
||||
provider.events?.emit(ProviderEvents.Error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Requirement 5.1.3', () => {
|
||||
it('When a provider signals the occurrence of a particular event, event handlers on clients which are not associated with that provider do not run', (done) => {
|
||||
const provider = new MockProvider();
|
||||
const client0 = OpenFeature.getClient(clientId);
|
||||
const client1 = OpenFeature.getClient(clientId + '1');
|
||||
|
||||
const client1Handler = jest.fn();
|
||||
const client0Handler = () => {
|
||||
expect(client1Handler).not.toHaveBeenCalled();
|
||||
done();
|
||||
};
|
||||
|
||||
client0.addHandler(ProviderEvents.Ready, client0Handler);
|
||||
client1.addHandler(ProviderEvents.Ready, client1Handler);
|
||||
|
||||
OpenFeature.setProvider(clientId, provider);
|
||||
});
|
||||
|
||||
it('anonymous provider with anonymous client should run non-init events', (done) => {
|
||||
const defaultProvider = new MockProvider({
|
||||
failOnInit: false,
|
||||
initialStatus: ProviderStatus.NOT_READY,
|
||||
name: 'default',
|
||||
});
|
||||
|
||||
// get a anon client
|
||||
const anonClient = OpenFeature.getClient();
|
||||
anonClient.addHandler(ProviderEvents.ConfigurationChanged, () => {
|
||||
done();
|
||||
});
|
||||
|
||||
// set the default provider
|
||||
OpenFeature.setProvider(defaultProvider);
|
||||
|
||||
// fire events
|
||||
defaultProvider.events?.emit(ProviderEvents.ConfigurationChanged);
|
||||
});
|
||||
|
||||
it('anonymous provider with anonymous client should run init events', (done) => {
|
||||
const defaultProvider = new MockProvider({
|
||||
failOnInit: false,
|
||||
initialStatus: ProviderStatus.NOT_READY,
|
||||
name: 'default',
|
||||
});
|
||||
|
||||
// get a anon client
|
||||
const anonClient = OpenFeature.getClient();
|
||||
anonClient.addHandler(ProviderEvents.Ready, () => {
|
||||
done();
|
||||
});
|
||||
|
||||
// set the default provider
|
||||
OpenFeature.setProvider(defaultProvider);
|
||||
});
|
||||
|
||||
it('anonymous provider with named client should run non-init events', (done) => {
|
||||
const defaultProvider = new MockProvider({
|
||||
failOnInit: false,
|
||||
initialStatus: ProviderStatus.NOT_READY,
|
||||
name: 'default',
|
||||
});
|
||||
const unboundName = 'some-new-unbound-name';
|
||||
|
||||
// get a client using the default because it has not other mapping
|
||||
const unBoundClient = OpenFeature.getClient(unboundName);
|
||||
unBoundClient.addHandler(ProviderEvents.ConfigurationChanged, () => {
|
||||
done();
|
||||
});
|
||||
|
||||
// set the default provider
|
||||
OpenFeature.setProvider(defaultProvider);
|
||||
|
||||
// fire events
|
||||
defaultProvider.events?.emit(ProviderEvents.ConfigurationChanged);
|
||||
});
|
||||
|
||||
it('anonymous provider with named client should run init events', (done) => {
|
||||
const defaultProvider = new MockProvider({
|
||||
failOnInit: false,
|
||||
initialStatus: ProviderStatus.NOT_READY,
|
||||
name: 'default',
|
||||
});
|
||||
const unboundName = 'some-other-unbound-name';
|
||||
|
||||
// get a client using the default because it has not other mapping
|
||||
const unBoundClient = OpenFeature.getClient(unboundName);
|
||||
unBoundClient.addHandler(ProviderEvents.Ready, () => {
|
||||
done();
|
||||
});
|
||||
|
||||
// set the default provider
|
||||
OpenFeature.setProvider(defaultProvider);
|
||||
});
|
||||
|
||||
it('un-bound client event handlers still run after new provider set', (done) => {
|
||||
const defaultProvider = new MockProvider({ name: 'default' });
|
||||
const namedProvider = new MockProvider();
|
||||
const unboundName = 'unboundName';
|
||||
const boundName = 'boundName';
|
||||
|
||||
// set the default provider
|
||||
OpenFeature.setProvider(defaultProvider);
|
||||
|
||||
// get a client using the default because it has not other mapping
|
||||
const unBoundClient = OpenFeature.getClient(unboundName);
|
||||
unBoundClient.addHandler(ProviderEvents.ConfigurationChanged, () => {
|
||||
done();
|
||||
});
|
||||
|
||||
// get a client and assign a provider to it
|
||||
OpenFeature.setProvider(boundName, namedProvider);
|
||||
OpenFeature.getClient(boundName);
|
||||
|
||||
// fire events
|
||||
defaultProvider.events?.emit(ProviderEvents.ConfigurationChanged);
|
||||
});
|
||||
|
||||
it('handler added while while provider initializing runs', (done) => {
|
||||
const provider = new MockProvider({
|
||||
name: 'race',
|
||||
initialStatus: ProviderStatus.NOT_READY,
|
||||
initDelay: TIMEOUT / 2,
|
||||
});
|
||||
|
||||
// set the default provider
|
||||
OpenFeature.setProvider(provider);
|
||||
const client = OpenFeature.getClient();
|
||||
|
||||
// add a handler while the provider is starting
|
||||
client.addHandler(ProviderEvents.Ready, () => {
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('PROVIDER_ERROR events populates the message field', (done) => {
|
||||
const provider = new MockProvider({ failOnInit: true });
|
||||
const client = OpenFeature.getClient(clientId);
|
||||
|
||||
client.addHandler(ProviderEvents.Error, (details) => {
|
||||
expect(details?.message).toBeDefined();
|
||||
done();
|
||||
});
|
||||
|
||||
OpenFeature.setProvider(clientId, provider);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Requirement 5.2.1,', () => {
|
||||
it('The client provides a function for associating handler functions with a particular provider event type', () => {
|
||||
const client = OpenFeature.getClient(clientId);
|
||||
expect(client.addHandler).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Requirement 5.2.2,', () => {
|
||||
it('The API provides a function for associating handler functions with a particular provider event type', () => {
|
||||
expect(OpenFeature.addHandler).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Requirement 5.2.3,', () => {
|
||||
it('The event details MUST contain the provider name associated with the event.', (done) => {
|
||||
const providerName = '5.2.3';
|
||||
const provider = new MockProvider({ name: providerName });
|
||||
const client = OpenFeature.getClient(clientId);
|
||||
|
||||
client.addHandler(ProviderEvents.Ready, (details) => {
|
||||
expect(details?.providerName).toEqual(providerName);
|
||||
expect(details?.clientName).toEqual(clientId);
|
||||
done();
|
||||
});
|
||||
|
||||
OpenFeature.setProvider(clientId, provider);
|
||||
});
|
||||
|
||||
it('The event details contain the client name associated with the event in the client', (done) => {
|
||||
const provider = new MockProvider();
|
||||
const client = OpenFeature.getClient(clientId);
|
||||
|
||||
client.addHandler(ProviderEvents.Ready, (details) => {
|
||||
expect(details?.clientName).toEqual(clientId);
|
||||
done();
|
||||
});
|
||||
|
||||
OpenFeature.setProvider(clientId, provider);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Requirement 5.2.4', () => {
|
||||
it('The handler function accepts a event details parameter.', (done) => {
|
||||
const details: StaleEvent = { message: 'message' };
|
||||
const provider = new MockProvider();
|
||||
const client = OpenFeature.getClient(clientId);
|
||||
|
||||
client.addHandler(ProviderEvents.Stale, (givenDetails) => {
|
||||
expect(givenDetails?.message).toEqual(details.message);
|
||||
done();
|
||||
});
|
||||
|
||||
OpenFeature.setProvider(clientId, provider);
|
||||
provider.events?.emit(ProviderEvents.Stale, details);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Requirement 5.2.5', () => {
|
||||
it('If a handler function terminates abnormally, other handler functions run', (done) => {
|
||||
const provider = new MockProvider();
|
||||
const client = OpenFeature.getClient(clientId);
|
||||
|
||||
const handler0 = jest.fn(() => {
|
||||
throw new Error('Error during initialization');
|
||||
});
|
||||
|
||||
const handler1 = () => {
|
||||
expect(handler0).toHaveBeenCalled();
|
||||
done();
|
||||
};
|
||||
|
||||
client.addHandler(ProviderEvents.Ready, handler0);
|
||||
client.addHandler(ProviderEvents.Ready, handler1);
|
||||
|
||||
OpenFeature.setProvider(clientId, provider);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Requirement 5.2.6 ', () => {
|
||||
it('Event handlers MUST persist across `provider` changes.', (done) => {
|
||||
const provider1 = new MockProvider({ name: 'provider-1' });
|
||||
const provider2 = new MockProvider({ name: 'provider-2' });
|
||||
const client = OpenFeature.getClient(clientId);
|
||||
|
||||
let counter = 0;
|
||||
client.addHandler(ProviderEvents.Ready, () => {
|
||||
if (client.metadata.providerMetadata.name === provider1.metadata.name) {
|
||||
OpenFeature.setProvider(clientId, provider2);
|
||||
counter++;
|
||||
} else {
|
||||
expect(counter).toBeGreaterThan(0);
|
||||
expect(client.metadata.providerMetadata.name).toBe(provider2.metadata.name);
|
||||
if (counter == 1) {
|
||||
done();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
OpenFeature.setProvider(clientId, provider1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Requirement 5.2.7 ', () => {
|
||||
it('The API provides a function allowing the removal of event handlers', () => {
|
||||
const handler = jest.fn();
|
||||
const eventType = ProviderEvents.Stale;
|
||||
|
||||
OpenFeature.addHandler(eventType, handler);
|
||||
expect(OpenFeature.getHandlers(eventType)).toHaveLength(1);
|
||||
OpenFeature.removeHandler(eventType, handler);
|
||||
expect(OpenFeature.getHandlers(eventType)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('The API provides a function allowing the removal of event handlers', () => {
|
||||
const client = OpenFeature.getClient(clientId);
|
||||
const handler = jest.fn();
|
||||
const eventType = ProviderEvents.Stale;
|
||||
|
||||
client.addHandler(eventType, handler);
|
||||
expect(client.getHandlers(eventType)).toHaveLength(1);
|
||||
client.removeHandler(eventType, handler);
|
||||
expect(client.getHandlers(eventType)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Requirement 5.3.1', () => {
|
||||
it('If the provider `initialize` function terminates normally, `PROVIDER_READY` handlers MUST run', (done) => {
|
||||
const provider = new MockProvider();
|
||||
const client = OpenFeature.getClient(clientId);
|
||||
|
||||
client.addHandler(ProviderEvents.Ready, () => {
|
||||
done();
|
||||
});
|
||||
|
||||
OpenFeature.setProvider(clientId, provider);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Requirement 5.3.2', () => {
|
||||
it('If the provider `initialize` function terminates abnormally, `PROVIDER_ERROR` handlers MUST run.', (done) => {
|
||||
const provider = new MockProvider({ failOnInit: true });
|
||||
const client = OpenFeature.getClient(clientId);
|
||||
|
||||
client.addHandler(ProviderEvents.Error, () => {
|
||||
done();
|
||||
});
|
||||
|
||||
OpenFeature.setProvider(clientId, provider);
|
||||
});
|
||||
|
||||
it('It defines a mechanism for signalling `PROVIDER_CONFIGURATION_CHANGED`', (done) => {
|
||||
const provider = new MockProvider();
|
||||
const client = OpenFeature.getClient(clientId);
|
||||
|
||||
client.addHandler(ProviderEvents.ConfigurationChanged, () => {
|
||||
done();
|
||||
});
|
||||
|
||||
OpenFeature.setProvider(clientId, provider);
|
||||
// emit a change event from the mock provider
|
||||
provider.events?.emit(ProviderEvents.ConfigurationChanged);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Requirement 5.3.3', () => {
|
||||
describe('API', () => {
|
||||
it('Handlers attached after the provider is already in the associated state, MUST run immediately.', (done) => {
|
||||
const provider = new MockProvider({ initialStatus: ProviderStatus.ERROR });
|
||||
|
||||
OpenFeature.setProvider(clientId, provider);
|
||||
expect(provider.initialize).not.toHaveBeenCalled();
|
||||
|
||||
OpenFeature.addHandler(ProviderEvents.Error, () => {
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('client', () => {
|
||||
it('Handlers attached after the provider is already in the associated state, MUST run immediately.', (done) => {
|
||||
const provider = new MockProvider({ initialStatus: ProviderStatus.READY });
|
||||
const client = OpenFeature.getClient(clientId);
|
||||
|
||||
OpenFeature.setProvider(clientId, provider);
|
||||
expect(provider.initialize).not.toHaveBeenCalled();
|
||||
|
||||
client.addHandler(ProviderEvents.Ready, () => {
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,178 @@
|
|||
# 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)
|
||||
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
* adds ErrorOptions to Error constructor ([#765](https://github.com/open-feature/js-sdk/issues/765)) ([2f59a9f](https://github.com/open-feature/js-sdk/commit/2f59a9f5a81135d81d3c6cd7a14863dc21b012b4))
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
* **nest:** add peer deps for nestjs-core and rxjs 9 ([#784](https://github.com/open-feature/js-sdk/issues/784)) ([a452bdd](https://github.com/open-feature/js-sdk/commit/a452bdd4a86884417099a8c8ee7d77c53b16eaa7))
|
||||
* removed duped core types ([#800](https://github.com/open-feature/js-sdk/issues/800)) ([7cc1e09](https://github.com/open-feature/js-sdk/commit/7cc1e09a1118d0c541aeb5e43da74eb3983950a3))
|
||||
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
* Nest SDK ([#750](https://github.com/open-feature/js-sdk/issues/750)) ([a21634d](https://github.com/open-feature/js-sdk/commit/a21634dce97100ab8a79710dc03d35fa99491032))
|
||||
* update nestjs readme examples ([#787](https://github.com/open-feature/js-sdk/issues/787)) ([c6a357a](https://github.com/open-feature/js-sdk/commit/c6a357aa5ced91a464a861b4c5a2de0aadeb4e66))
|
||||
|
||||
## [0.0.4-experimental](https://github.com/open-feature/js-sdk/compare/nestjs-sdk-v0.0.3-experimental...nestjs-sdk-v0.0.4-experimental) (2024-01-18)
|
||||
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
* add logger, event handlers and hooks to nest sdk ([#761](https://github.com/open-feature/js-sdk/issues/761)) ([80a9ba1](https://github.com/open-feature/js-sdk/commit/80a9ba1e5120f1adaadb7b98f5f88b9f03b02682))
|
||||
|
||||
## [0.0.3-experimental](https://github.com/open-feature/js-sdk/compare/nestjs-sdk-v0.0.2-experimental...nestjs-sdk-v0.0.3-experimental) (2024-01-08)
|
||||
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
* context propagation for nestjs ([#736](https://github.com/open-feature/js-sdk/issues/736)) ([1fa53c9](https://github.com/open-feature/js-sdk/commit/1fa53c9058904664b95ecf96178e414f1d70b845))
|
||||
|
||||
## [0.0.2-experimental](https://github.com/open-feature/js-sdk/compare/nestjs-sdk-v0.0.1-experimental...nestjs-sdk-v0.0.2-experimental) (2023-12-13)
|
||||
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
* implement draft for a Nest.js SDK ([#718](https://github.com/open-feature/js-sdk/issues/718)) ([ef874e0](https://github.com/open-feature/js-sdk/commit/ef874e0365bdd96a7baf0447103554ff6176f28e))
|
||||
|
||||
|
||||
### Dependencies
|
||||
|
||||
* The following workspace dependencies were updated
|
||||
* devDependencies
|
||||
* @openfeature/core bumped from * to 0.0.21
|
||||
* @openfeature/server-sdk bumped from * to 1.8.0
|
|
@ -0,0 +1,179 @@
|
|||
<!-- 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 NestJS 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/nestjs-sdk-v0.2.5">
|
||||
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.2.5&color=blue&style=for-the-badge" />
|
||||
</a>
|
||||
<!-- 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/nestjs-sdk">
|
||||
<img alt="NPM Download" src="https://img.shields.io/npm/dm/%40openfeature%2Fnestjs-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 NestJS SDK is a package that provides a NestJS wrapper for the [OpenFeature Server SDK](https://openfeature.dev/docs/reference/technologies/server/javascript/).
|
||||
|
||||
Capabilities include:
|
||||
|
||||
- Providing a NestJS global module to simplify OpenFeature configuration and usage within NestJS
|
||||
- Setting up logging, event handling, hooks and providers directly when registering the module
|
||||
- Injecting feature flags directly into controller route handlers by using decorators
|
||||
- Injecting transaction evaluation context for flag evaluations directly from [execution context](https://docs.nestjs.com/fundamentals/execution-context) (HTTP header values, client IPs, etc.)
|
||||
- Injecting OpenFeature clients into NestJS services and controllers by using decorators
|
||||
|
||||
## 🚀 Quick start
|
||||
|
||||
### Requirements
|
||||
|
||||
- Node.js version 20+
|
||||
- NestJS version 8+
|
||||
|
||||
### Install
|
||||
|
||||
#### npm
|
||||
|
||||
```sh
|
||||
npm install --save @openfeature/nestjs-sdk
|
||||
```
|
||||
|
||||
#### yarn
|
||||
|
||||
```sh
|
||||
# yarn requires manual installation of the peer dependencies (see below)
|
||||
yarn add @openfeature/nestjs-sdk @openfeature/server-sdk @openfeature/core
|
||||
```
|
||||
|
||||
#### Required peer dependencies
|
||||
|
||||
The following list contains the peer dependencies of `@openfeature/nestjs-sdk` with its expected and compatible versions:
|
||||
|
||||
- `@openfeature/server-sdk`: >=1.7.5
|
||||
- `@nestjs/common`: ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
|
||||
- `@nestjs/core`: ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
|
||||
- `rxjs`: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
|
||||
The minimum required version of `@openfeature/server-sdk` currently is `1.7.5`.
|
||||
|
||||
### Usage
|
||||
|
||||
The example below shows how to use the `OpenFeatureModule` with OpenFeature's `InMemoryProvider`.
|
||||
|
||||
```ts
|
||||
import { Module } from '@nestjs/common';
|
||||
import { OpenFeatureModule, InMemoryProvider } from '@openfeature/nestjs-sdk';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
OpenFeatureModule.forRoot({
|
||||
defaultProvider: new InMemoryProvider({
|
||||
testBooleanFlag: {
|
||||
defaultVariant: 'default',
|
||||
variants: { default: true },
|
||||
disabled: false,
|
||||
},
|
||||
}),
|
||||
providers: {
|
||||
differentProvider: new InMemoryProvider(),
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
```
|
||||
|
||||
With the `OpenFeatureModule` configured, it's possible to inject flag evaluation details into route handlers like in the following code snippet.
|
||||
|
||||
```ts
|
||||
import { Controller, ExecutionContext, Get } from '@nestjs/common';
|
||||
import { map, Observable } from 'rxjs';
|
||||
import { BooleanFeatureFlag, EvaluationDetails } from '@openfeature/nestjs-sdk';
|
||||
import { Request } from 'express';
|
||||
|
||||
@Controller()
|
||||
export class OpenFeatureController {
|
||||
@Get('/welcome')
|
||||
public async welcome(
|
||||
@BooleanFeatureFlag({
|
||||
flagKey: 'testBooleanFlag',
|
||||
defaultValue: false,
|
||||
})
|
||||
feature: Observable<EvaluationDetails<boolean>>,
|
||||
) {
|
||||
return feature.pipe(
|
||||
map((details) =>
|
||||
details.value ? 'Welcome to this OpenFeature-enabled NestJS app!' : 'Welcome to this NestJS app!',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
It is also possible to inject the default or domain scoped OpenFeature clients into a service via Nest dependency injection system.
|
||||
|
||||
```ts
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { OpenFeatureClient, Client } from '@openfeature/nestjs-sdk';
|
||||
|
||||
@Injectable()
|
||||
export class OpenFeatureTestService {
|
||||
constructor(
|
||||
@OpenFeatureClient() private defaultClient: Client,
|
||||
@OpenFeatureClient({ domain: 'my-domain' }) private scopedClient: Client,
|
||||
) {}
|
||||
|
||||
public async getBoolean() {
|
||||
return await this.defaultClient.getBooleanValue('testBooleanFlag', false);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Managing Controller or Route Access via Feature Flags
|
||||
|
||||
The `RequireFlagsEnabled` decorator can be used to manage access to a controller or route based on the enabled state of a feature flag. The decorator will throw an exception if the required feature flag(s) are not enabled.
|
||||
|
||||
```ts
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { RequireFlagsEnabled } from '@openfeature/nestjs-sdk';
|
||||
|
||||
@Controller()
|
||||
export class OpenFeatureController {
|
||||
@RequireFlagsEnabled({ flags: [{ flagKey: 'testBooleanFlag' }] })
|
||||
@Get('/welcome')
|
||||
public async welcome() {
|
||||
return 'Welcome to this OpenFeature-enabled NestJS app!';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Module additional information
|
||||
|
||||
### Flag evaluation context injection
|
||||
|
||||
Whenever a flag evaluation occurs, context can be provided with information like user e-mail, role, targeting key, etc. in order to trigger specific evaluation rules or logic. The `OpenFeatureModule` provides a way to configure context for each request using the `contextFactory` option.
|
||||
The `contextFactory` is run in a NestJS interceptor scope to configure the evaluation context, and then it is used in every flag evaluation related to this request.
|
||||
By default, the interceptor is configured globally, but it can be changed by setting the `useGlobalInterceptor` to `false`. In this case, it is still possible to configure a `contextFactory` that can be injected into route, module or controller bound interceptors.
|
|
@ -0,0 +1,64 @@
|
|||
{
|
||||
"name": "@openfeature/nestjs-sdk",
|
||||
"version": "0.2.5",
|
||||
"description": "OpenFeature Nest.js SDK",
|
||||
"main": "./dist/cjs/index.js",
|
||||
"files": [
|
||||
"dist/"
|
||||
],
|
||||
"exports": {
|
||||
"types": "./dist/types.d.ts",
|
||||
"import": "./dist/esm/index.js",
|
||||
"require": "./dist/cjs/index.js",
|
||||
"default": "./dist/cjs/index.js"
|
||||
},
|
||||
"types": "./dist/types.d.ts",
|
||||
"scripts": {
|
||||
"test": "jest --verbose",
|
||||
"lint": "eslint ./",
|
||||
"lint:fix": "eslint ./ --fix",
|
||||
"clean": "shx rm -rf ./dist",
|
||||
"build:esm": "esbuild src/index.ts --bundle --external:@nestjs/* --external:@openfeature/server-sdk --sourcemap --target=es2015 --platform=node --format=esm --outfile=./dist/esm/index.js --analyze",
|
||||
"build:cjs": "esbuild src/index.ts --bundle --external:@nestjs/* --external:@openfeature/server-sdk --sourcemap --target=es2015 --platform=node --format=cjs --outfile=./dist/cjs/index.js --analyze",
|
||||
"build:rollup-types": "rollup -c ../../rollup.config.mjs",
|
||||
"build": "npm run clean && npm run build:esm && npm run build:cjs && npm run build:rollup-types",
|
||||
"postbuild": "shx cp ./../../package.esm.json ./dist/esm/package.json",
|
||||
"current-version": "echo $npm_package_version",
|
||||
"prepack": "shx cp ./../../LICENSE ./LICENSE",
|
||||
"publish-if-not-exists": "cp $NPM_CONFIG_USERCONFIG .npmrc && if [ \"$(npm show $npm_package_name@$npm_package_version version)\" = \"$(npm run current-version -s)\" ]; then echo 'already published, skipping'; else npm publish --access public; fi"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/open-feature/js-sdk.git"
|
||||
},
|
||||
"keywords": [
|
||||
"openfeature",
|
||||
"feature",
|
||||
"flags",
|
||||
"toggles",
|
||||
"server",
|
||||
"nest"
|
||||
],
|
||||
"author": "",
|
||||
"license": "Apache-2.0",
|
||||
"bugs": {
|
||||
"url": "https://github.com/open-feature/js-sdk/issues"
|
||||
},
|
||||
"homepage": "https://github.com/open-feature/js-sdk#readme",
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
|
||||
"@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
|
||||
"rxjs": "^6.0.0 || ^7.0.0 || 8.0.0",
|
||||
"@openfeature/server-sdk": "^1.17.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/common": "^11.0.20",
|
||||
"@nestjs/core": "^11.0.20",
|
||||
"@nestjs/platform-express": "^11.0.20",
|
||||
"@nestjs/testing": "^11.0.20",
|
||||
"@openfeature/core": "*",
|
||||
"@openfeature/server-sdk": "1.18.0",
|
||||
"@types/supertest": "^6.0.0",
|
||||
"supertest": "^7.0.0"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
import type { EvaluationContext } from '@openfeature/core';
|
||||
import type { ExecutionContext} from '@nestjs/common';
|
||||
import { Inject } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* A factory function for creating an OpenFeature {@link EvaluationContext} from Nest {@link ExecutionContext}.
|
||||
* This can be used e.g. to get header info from an HTTP request or information from a gRPC call.
|
||||
*
|
||||
* Example getting an HTTP header value:
|
||||
* ```typescript
|
||||
* async function(context: ExecutionContext) {
|
||||
* const request = await context.switchToHttp().getRequest();
|
||||
*
|
||||
* const userId = request.header('x-user-id');
|
||||
*
|
||||
* if (userId) {
|
||||
* return {
|
||||
* targetingKey: userId,
|
||||
* };
|
||||
* }
|
||||
*
|
||||
* return undefined;
|
||||
* }
|
||||
* ```
|
||||
* @param {ExecutionContext} request The {@link ExecutionContext} to get the information from.
|
||||
* @returns {(Promise<EvaluationContext | undefined> | EvaluationContext | undefined)} The {@link EvaluationContext} new.
|
||||
*/
|
||||
export type ContextFactory = (
|
||||
request: ExecutionContext,
|
||||
) => Promise<EvaluationContext | undefined> | EvaluationContext | undefined;
|
||||
|
||||
/**
|
||||
* InjectionToken for a {@link ContextFactory}.
|
||||
* @see {@link Inject}
|
||||
*/
|
||||
export const ContextFactoryToken = Symbol('CONTEXT_FACTORY');
|
|
@ -0,0 +1,49 @@
|
|||
import type { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { ContextFactory} from './context-factory';
|
||||
import { ContextFactoryToken } from './context-factory';
|
||||
import { Observable } from 'rxjs';
|
||||
import { OpenFeature } from '@openfeature/server-sdk';
|
||||
import { OpenFeatureModule } from './open-feature.module';
|
||||
|
||||
/**
|
||||
* NestJS interceptor used in {@link OpenFeatureModule}
|
||||
* to configure flag evaluation context.
|
||||
*
|
||||
* This interceptor is configured globally by default.
|
||||
* If `useGlobalInterceptor` is set to `false` in {@link OpenFeatureModule} it needs to be configured for the specific controllers or routes.
|
||||
*
|
||||
* If just the interceptor class is passed to the `UseInterceptors` like below, the `contextFactory` provided in the {@link OpenFeatureModule} will be injected and used in order to create the context.
|
||||
* ```ts
|
||||
* //route interceptor
|
||||
* @UseInterceptors(EvaluationContextInterceptor)
|
||||
* @Get('/user-info')
|
||||
* getUserInfo(){}
|
||||
* ```
|
||||
*
|
||||
* A different `contextFactory` can also be provided, but the interceptor instance has to be instantiated like in the following example.
|
||||
* ```ts
|
||||
* //route interceptor
|
||||
* @UseInterceptors(new EvaluationContextInterceptor(<context factory>))
|
||||
* @Get('/user-info')
|
||||
* getUserInfo(){}
|
||||
* ```
|
||||
*/
|
||||
@Injectable()
|
||||
export class EvaluationContextInterceptor implements NestInterceptor {
|
||||
constructor(@Inject(ContextFactoryToken) private contextFactory?: ContextFactory) {}
|
||||
|
||||
async intercept(executionContext: ExecutionContext, next: CallHandler) {
|
||||
const context = await this.contextFactory?.(executionContext);
|
||||
|
||||
return new Observable((subscriber) => {
|
||||
OpenFeature.setTransactionContext(context ?? {}, async () => {
|
||||
next.handle().subscribe({
|
||||
next: (res) => subscriber.next(res),
|
||||
error: (err) => subscriber.error(err),
|
||||
complete: () => subscriber.complete(),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,147 @@
|
|||
import { createParamDecorator, Inject } from '@nestjs/common';
|
||||
import type { EvaluationContext, EvaluationDetails, FlagValue, JsonValue } from '@openfeature/server-sdk';
|
||||
import { Client } from '@openfeature/server-sdk';
|
||||
import { getOpenFeatureClientToken } from './open-feature.module';
|
||||
import type { Observable } from 'rxjs';
|
||||
import { from } from 'rxjs';
|
||||
import { getClientForEvaluation } from './utils';
|
||||
|
||||
/**
|
||||
* Options for injecting an OpenFeature client into a constructor.
|
||||
*/
|
||||
interface FeatureClientProps {
|
||||
/**
|
||||
* The domain of the OpenFeature client, if a domain scoped client should be used.
|
||||
* @see {@link Client.getBooleanDetails}
|
||||
*/
|
||||
domain?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects a feature client into a constructor or property of a class.
|
||||
* @param {FeatureClientProps} [props] The options for injecting the client.
|
||||
* @returns {PropertyDecorator & ParameterDecorator} The decorator function.
|
||||
*/
|
||||
export const OpenFeatureClient = (props?: FeatureClientProps) => Inject(getOpenFeatureClientToken(props?.domain));
|
||||
|
||||
/**
|
||||
* Options for injecting a feature flag into a route handler.
|
||||
*/
|
||||
interface FeatureProps<T extends FlagValue> {
|
||||
/**
|
||||
* The domain of the OpenFeature client, if a domain scoped client should be used.
|
||||
* @see {@link OpenFeature#getClient}
|
||||
*/
|
||||
domain?: string;
|
||||
/**
|
||||
* The key of the feature flag.
|
||||
* @see {@link Client#getBooleanDetails}
|
||||
*/
|
||||
flagKey: string;
|
||||
/**
|
||||
* The default value for the feature flag.
|
||||
* @see {@link Client#getBooleanDetails}
|
||||
*/
|
||||
defaultValue: T;
|
||||
/**
|
||||
* The {@link EvaluationContext} for evaluating the feature flag.
|
||||
* @see {@link OpenFeature#getClient}
|
||||
*/
|
||||
context?: EvaluationContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route handler parameter decorator.
|
||||
*
|
||||
* 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}.
|
||||
*
|
||||
* For example:
|
||||
* ```typescript
|
||||
* @Get('/')
|
||||
* public async handleBooleanRequest(
|
||||
* @BooleanFeatureFlag({ flagKey: 'flagName', defaultValue: false })
|
||||
* feature: Observable<EvaluationDetails<boolean>>,
|
||||
* )
|
||||
* ```
|
||||
* @param {FeatureProps<boolean>} options The options for injecting the feature flag.
|
||||
* @returns {ParameterDecorator}
|
||||
*/
|
||||
export const BooleanFeatureFlag = createParamDecorator(
|
||||
({ domain, flagKey, defaultValue, context }: FeatureProps<boolean>): Observable<EvaluationDetails<boolean>> => {
|
||||
const client = getClientForEvaluation(domain, context);
|
||||
return from(client.getBooleanDetails(flagKey, defaultValue));
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Route handler parameter decorator.
|
||||
*
|
||||
* 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}.
|
||||
*
|
||||
* For example:
|
||||
* ```typescript
|
||||
* @Get('/')
|
||||
* public async handleStringRequest(
|
||||
* @StringFeatureFlag({ flagKey: 'flagName', defaultValue: "default" })
|
||||
* feature: Observable<EvaluationDetails<string>>,
|
||||
* )
|
||||
* ```
|
||||
* @param {FeatureProps<string>} options The options for injecting the feature flag.
|
||||
* @returns {ParameterDecorator}
|
||||
*/
|
||||
export const StringFeatureFlag = createParamDecorator(
|
||||
({ domain, flagKey, defaultValue, context }: FeatureProps<string>): Observable<EvaluationDetails<string>> => {
|
||||
const client = getClientForEvaluation(domain, context);
|
||||
return from(client.getStringDetails(flagKey, defaultValue));
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Route handler parameter decorator.
|
||||
*
|
||||
* 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}.
|
||||
*
|
||||
* For example:
|
||||
* ```typescript
|
||||
* @Get('/')
|
||||
* public async handleNumberRequest(
|
||||
* @NumberFeatureFlag({ flagKey: 'flagName', defaultValue: 0 })
|
||||
* feature: Observable<EvaluationDetails<number>>,
|
||||
* )
|
||||
* ```
|
||||
* @param {FeatureProps<number>} options The options for injecting the feature flag.
|
||||
* @returns {ParameterDecorator}
|
||||
*/
|
||||
export const NumberFeatureFlag = createParamDecorator(
|
||||
({ domain, flagKey, defaultValue, context }: FeatureProps<number>): Observable<EvaluationDetails<number>> => {
|
||||
const client = getClientForEvaluation(domain, context);
|
||||
return from(client.getNumberDetails(flagKey, defaultValue));
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Route handler parameter decorator.
|
||||
*
|
||||
* 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}.
|
||||
*
|
||||
* For example:
|
||||
* ```typescript
|
||||
* @Get('/')
|
||||
* public async handleObjectRequest(
|
||||
* @ObjectFeatureFlag({ flagKey: 'flagName', defaultValue: {} })
|
||||
* feature: Observable<EvaluationDetails<JsonValue>>,
|
||||
* )
|
||||
* ```
|
||||
* @param {FeatureProps<JsonValue>} options The options for injecting the feature flag.
|
||||
* @returns {ParameterDecorator}
|
||||
*/
|
||||
export const ObjectFeatureFlag = createParamDecorator(
|
||||
({ domain, flagKey, defaultValue, context }: FeatureProps<JsonValue>): Observable<EvaluationDetails<JsonValue>> => {
|
||||
const client = getClientForEvaluation(domain, context);
|
||||
return from(client.getObjectDetails(flagKey, defaultValue));
|
||||
},
|
||||
);
|
|
@ -0,0 +1,7 @@
|
|||
export * from './open-feature.module';
|
||||
export * from './feature.decorator';
|
||||
export * from './evaluation-context-interceptor';
|
||||
export * from './context-factory';
|
||||
export * from './require-flags-enabled.decorator';
|
||||
// re-export the server-sdk so consumers can access that API from the nestjs-sdk
|
||||
export * from '@openfeature/server-sdk';
|
|
@ -0,0 +1,161 @@
|
|||
import type {
|
||||
DynamicModule,
|
||||
FactoryProvider as NestFactoryProvider,
|
||||
ValueProvider,
|
||||
ClassProvider,
|
||||
Provider as NestProvider} from '@nestjs/common';
|
||||
import {
|
||||
Module,
|
||||
ExecutionContext,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
Client,
|
||||
Hook,
|
||||
Provider,
|
||||
EvaluationContext,
|
||||
ServerProviderEvents,
|
||||
EventHandler,
|
||||
Logger} from '@openfeature/server-sdk';
|
||||
import {
|
||||
OpenFeature,
|
||||
AsyncLocalStorageTransactionContextPropagator,
|
||||
} from '@openfeature/server-sdk';
|
||||
import type { ContextFactory} from './context-factory';
|
||||
import { ContextFactoryToken } from './context-factory';
|
||||
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { EvaluationContextInterceptor } from './evaluation-context-interceptor';
|
||||
import { ShutdownService } from './shutdown.service';
|
||||
|
||||
/**
|
||||
* OpenFeatureModule is a NestJS wrapper for OpenFeature Server-SDK.
|
||||
*/
|
||||
@Module({})
|
||||
export class OpenFeatureModule {
|
||||
static forRoot({ useGlobalInterceptor = true, ...options }: OpenFeatureModuleOptions): DynamicModule {
|
||||
OpenFeature.setTransactionContextPropagator(new AsyncLocalStorageTransactionContextPropagator());
|
||||
|
||||
if (options.logger) {
|
||||
OpenFeature.setLogger(options.logger);
|
||||
}
|
||||
|
||||
if (options.hooks) {
|
||||
OpenFeature.addHooks(...options.hooks);
|
||||
}
|
||||
|
||||
options.handlers?.forEach(([event, handler]) => {
|
||||
OpenFeature.addHandler(event, handler);
|
||||
});
|
||||
|
||||
const clientValueProviders: NestFactoryProvider<Client>[] = [
|
||||
{
|
||||
provide: getOpenFeatureClientToken(),
|
||||
useFactory: () => OpenFeature.getClient(),
|
||||
},
|
||||
];
|
||||
|
||||
if (options?.defaultProvider) {
|
||||
OpenFeature.setProvider(options.defaultProvider);
|
||||
}
|
||||
|
||||
if (options?.providers) {
|
||||
Object.entries(options.providers).forEach(([domain, provider]) => {
|
||||
OpenFeature.setProvider(domain, provider);
|
||||
clientValueProviders.push({
|
||||
provide: getOpenFeatureClientToken(domain),
|
||||
useFactory: () => OpenFeature.getClient(domain),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const nestProviders: NestProvider[] = [ShutdownService];
|
||||
nestProviders.push(...clientValueProviders);
|
||||
|
||||
const contextFactoryProvider: ValueProvider = {
|
||||
provide: ContextFactoryToken,
|
||||
useValue: options?.contextFactory,
|
||||
};
|
||||
nestProviders.push(contextFactoryProvider);
|
||||
|
||||
if (useGlobalInterceptor) {
|
||||
const interceptorProvider: ClassProvider = {
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: EvaluationContextInterceptor,
|
||||
};
|
||||
nestProviders.push(interceptorProvider);
|
||||
}
|
||||
|
||||
return {
|
||||
global: true,
|
||||
module: OpenFeatureModule,
|
||||
providers: nestProviders,
|
||||
exports: [...clientValueProviders, ContextFactoryToken],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for the {@link OpenFeatureModule}.
|
||||
*/
|
||||
export interface OpenFeatureModuleOptions {
|
||||
/**
|
||||
* The provider to be set as OpenFeature default provider.
|
||||
* @see {@link OpenFeature#setProvider}
|
||||
*/
|
||||
defaultProvider?: Provider;
|
||||
/**
|
||||
* Domain scoped providers to set to OpenFeature.
|
||||
* @see {@link OpenFeature#setProvider}
|
||||
*/
|
||||
providers?: {
|
||||
[domain: string]: Provider;
|
||||
};
|
||||
/**
|
||||
* Global {@link Logger} for OpenFeature.
|
||||
* @see {@link OpenFeature#setLogger}
|
||||
*/
|
||||
logger?: Logger;
|
||||
/**
|
||||
* Global {@link EvaluationContext} for OpenFeature.
|
||||
* @see {@link OpenFeature#setContext}
|
||||
*/
|
||||
context?: EvaluationContext;
|
||||
/**
|
||||
* Global {@link Hook Hooks} for OpenFeature.
|
||||
* @see {@link OpenFeature#addHooks}
|
||||
*/
|
||||
hooks?: Hook[];
|
||||
/**
|
||||
* Global {@link EventHandler EventHandlers} for OpenFeature.
|
||||
* @see {@link OpenFeature#addHandler}
|
||||
*/
|
||||
handlers?: [ServerProviderEvents, EventHandler][];
|
||||
/**
|
||||
* The {@link ContextFactory} for creating an {@link EvaluationContext} from Nest {@link ExecutionContext} information.
|
||||
* This could be header values of a request or something similar.
|
||||
* The context is automatically used for all feature flag evaluations during this request.
|
||||
* @see {@link AsyncLocalStorageTransactionContextPropagator}
|
||||
*/
|
||||
contextFactory?: ContextFactory;
|
||||
/**
|
||||
* If set to false, the global {@link EvaluationContextInterceptor} is disabled.
|
||||
* This means that automatic propagation of the {@link EvaluationContext} created by the {@link this#contextFactory} is not working.
|
||||
*
|
||||
* To enable it again for specific routes, the interceptor can be added for specific controllers or request handlers like seen below:
|
||||
* ```typescript
|
||||
* @Controller()
|
||||
* @UseInterceptors(EvaluationContextInterceptor)
|
||||
* export class Controller {}
|
||||
* ```
|
||||
* @default true
|
||||
*/
|
||||
useGlobalInterceptor?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an injection token for a (domain scoped) OpenFeature client.
|
||||
* @param {string} domain The domain of the OpenFeature client.
|
||||
* @returns {Client} The injection token.
|
||||
*/
|
||||
export function getOpenFeatureClientToken(domain?: string): string {
|
||||
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);
|
||||
};
|
|
@ -0,0 +1,10 @@
|
|||
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { OpenFeature } from '@openfeature/server-sdk';
|
||||
|
||||
@Injectable()
|
||||
export class ShutdownService implements OnApplicationShutdown {
|
||||
async onApplicationShutdown() {
|
||||
await OpenFeature.close();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -0,0 +1,85 @@
|
|||
import { InMemoryProvider } from '@openfeature/server-sdk';
|
||||
import type { EvaluationContext } from '@openfeature/server-sdk';
|
||||
import type { ExecutionContext } from '@nestjs/common';
|
||||
import { OpenFeatureModule } from '../src';
|
||||
|
||||
export const defaultProvider = new InMemoryProvider({
|
||||
testBooleanFlag: {
|
||||
defaultVariant: 'default',
|
||||
variants: { default: true },
|
||||
disabled: false,
|
||||
},
|
||||
testStringFlag: {
|
||||
defaultVariant: 'default',
|
||||
variants: { default: 'expected-string-value-default' },
|
||||
disabled: false,
|
||||
},
|
||||
testNumberFlag: {
|
||||
defaultVariant: 'default',
|
||||
variants: { default: 10 },
|
||||
disabled: false,
|
||||
},
|
||||
testObjectFlag: {
|
||||
defaultVariant: 'default',
|
||||
variants: { default: { client: 'default' } },
|
||||
disabled: false,
|
||||
},
|
||||
testBooleanFlag2: {
|
||||
defaultVariant: 'default',
|
||||
variants: { default: false, enabled: true },
|
||||
disabled: false,
|
||||
contextEvaluator: (ctx: EvaluationContext) => {
|
||||
if (ctx.targetingKey === '123') {
|
||||
return 'enabled';
|
||||
}
|
||||
return 'default';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const providers = {
|
||||
domainScopedClient: new InMemoryProvider({
|
||||
testBooleanFlag: {
|
||||
defaultVariant: 'default',
|
||||
variants: { default: true },
|
||||
disabled: false,
|
||||
},
|
||||
testStringFlag: {
|
||||
defaultVariant: 'default',
|
||||
variants: { default: 'expected-string-value-scoped' },
|
||||
disabled: false,
|
||||
},
|
||||
testNumberFlag: {
|
||||
defaultVariant: 'default',
|
||||
variants: { default: 10 },
|
||||
disabled: false,
|
||||
},
|
||||
testObjectFlag: {
|
||||
defaultVariant: 'default',
|
||||
variants: { default: { client: 'scoped' } },
|
||||
disabled: false,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
export const exampleContextFactory = async (context: ExecutionContext) => {
|
||||
const request = await context.switchToHttp().getRequest();
|
||||
|
||||
const userId = request.header('x-user-id');
|
||||
|
||||
if (userId) {
|
||||
return {
|
||||
targetingKey: userId,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getOpenFeatureDefaultTestModule = () => {
|
||||
return OpenFeatureModule.forRoot({
|
||||
contextFactory: exampleContextFactory,
|
||||
defaultProvider,
|
||||
providers,
|
||||
});
|
||||
};
|
|
@ -0,0 +1,251 @@
|
|||
import type { TestingModule } from '@nestjs/testing';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import supertest from 'supertest';
|
||||
import {
|
||||
OpenFeatureController,
|
||||
OpenFeatureContextScopedController,
|
||||
OpenFeatureRequireFlagsEnabledController,
|
||||
OpenFeatureTestService,
|
||||
} from './test-app';
|
||||
import { exampleContextFactory, getOpenFeatureDefaultTestModule } from './fixtures';
|
||||
import { OpenFeatureModule } from '../src';
|
||||
import { defaultProvider, providers } from './fixtures';
|
||||
|
||||
describe('OpenFeature SDK', () => {
|
||||
describe('With global context interceptor', () => {
|
||||
let moduleRef: TestingModule;
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
moduleRef = await Test.createTestingModule({
|
||||
imports: [getOpenFeatureDefaultTestModule()],
|
||||
providers: [OpenFeatureTestService],
|
||||
controllers: [OpenFeatureController, OpenFeatureRequireFlagsEnabledController],
|
||||
}).compile();
|
||||
app = moduleRef.createNestApplication();
|
||||
app = await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await moduleRef.close();
|
||||
});
|
||||
|
||||
describe('openfeature client decorator', () => {
|
||||
it('should inject the correct open feature clients', async () => {
|
||||
const testService = moduleRef.get(OpenFeatureTestService);
|
||||
expect(testService.defaultClient).toBeDefined();
|
||||
expect(await testService.defaultClient.getStringValue('testStringFlag', 'wrong-value')).toEqual(
|
||||
'expected-string-value-default',
|
||||
);
|
||||
|
||||
expect(testService.domainScopedClient).toBeDefined();
|
||||
expect(await testService.domainScopedClient.getStringValue('testStringFlag', 'wrong-value')).toEqual(
|
||||
'expected-string-value-scoped',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('feature flag decorators', () => {
|
||||
it('should inject the correct boolean feature flag evaluation details', async () => {
|
||||
const testService = app.get(OpenFeatureTestService);
|
||||
const testServiceSpy = jest.spyOn(testService, 'serviceMethod');
|
||||
|
||||
await supertest(app.getHttpServer()).get('/boolean').expect(200).expect('true');
|
||||
|
||||
expect(testServiceSpy).toHaveBeenCalledWith({
|
||||
flagKey: 'testBooleanFlag',
|
||||
flagMetadata: {},
|
||||
reason: 'STATIC',
|
||||
value: true,
|
||||
variant: 'default',
|
||||
});
|
||||
});
|
||||
|
||||
it('should inject the correct string feature flag evaluation details', async () => {
|
||||
const testService = app.get(OpenFeatureTestService);
|
||||
const testServiceSpy = jest.spyOn(testService, 'serviceMethod');
|
||||
|
||||
await supertest(app.getHttpServer()).get('/string').expect(200).expect('expected-string-value-default');
|
||||
|
||||
expect(testServiceSpy).toHaveBeenCalledWith({
|
||||
flagKey: 'testStringFlag',
|
||||
flagMetadata: {},
|
||||
reason: 'STATIC',
|
||||
value: 'expected-string-value-default',
|
||||
variant: 'default',
|
||||
});
|
||||
});
|
||||
|
||||
it('should inject the correct number feature flag evaluation details', async () => {
|
||||
const testService = app.get(OpenFeatureTestService);
|
||||
const testServiceSpy = jest.spyOn(testService, 'serviceMethod');
|
||||
|
||||
await supertest(app.getHttpServer()).get('/number').expect(200).expect('10');
|
||||
|
||||
expect(testServiceSpy).toHaveBeenCalledWith({
|
||||
flagKey: 'testNumberFlag',
|
||||
flagMetadata: {},
|
||||
reason: 'STATIC',
|
||||
value: 10,
|
||||
variant: 'default',
|
||||
});
|
||||
});
|
||||
|
||||
it('should inject the correct object feature flag evaluation details', async () => {
|
||||
const testService = app.get(OpenFeatureTestService);
|
||||
const testServiceSpy = jest.spyOn(testService, 'serviceMethod');
|
||||
|
||||
await supertest(app.getHttpServer()).get('/object').expect(200).expect({ client: 'default' });
|
||||
|
||||
expect(testServiceSpy).toHaveBeenCalledWith({
|
||||
flagKey: 'testObjectFlag',
|
||||
flagMetadata: {},
|
||||
reason: 'STATIC',
|
||||
value: { client: 'default' },
|
||||
variant: 'default',
|
||||
});
|
||||
});
|
||||
|
||||
it('should use the execution context from contextFactory', async () => {
|
||||
const evaluationSpy = jest.spyOn(defaultProvider, 'resolveBooleanEvaluation');
|
||||
await supertest(app.getHttpServer()).get('/dynamic-context').set('x-user-id', '123').expect(200).expect('true');
|
||||
expect(evaluationSpy).toHaveBeenCalledWith('testBooleanFlag', false, { targetingKey: '123' }, {});
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluation context service should', () => {
|
||||
it('inject the evaluation context from contex factory', async function () {
|
||||
const evaluationSpy = jest.spyOn(defaultProvider, 'resolveBooleanEvaluation');
|
||||
await supertest(app.getHttpServer())
|
||||
.get('/dynamic-context-in-service')
|
||||
.set('x-user-id', 'dynamic-user')
|
||||
.expect(200)
|
||||
.expect('true');
|
||||
expect(evaluationSpy).toHaveBeenCalledWith('testBooleanFlag', false, { targetingKey: 'dynamic-user' }, {});
|
||||
});
|
||||
});
|
||||
|
||||
describe('require flags enabled decorator', () => {
|
||||
describe('OpenFeatureController', () => {
|
||||
it('should sucessfully return the response if the flag is enabled', async () => {
|
||||
await supertest(app.getHttpServer()).get('/flags-enabled').expect(200).expect('Get Boolean Flag Success!');
|
||||
});
|
||||
|
||||
it('should throw an exception if the flag is disabled', async () => {
|
||||
jest.spyOn(defaultProvider, 'resolveBooleanEvaluation').mockResolvedValueOnce({
|
||||
value: false,
|
||||
reason: 'DISABLED',
|
||||
});
|
||||
await supertest(app.getHttpServer()).get('/flags-enabled').expect(404);
|
||||
});
|
||||
|
||||
it('should throw a custom exception if the flag is disabled', async () => {
|
||||
jest.spyOn(defaultProvider, 'resolveBooleanEvaluation').mockResolvedValueOnce({
|
||||
value: false,
|
||||
reason: 'DISABLED',
|
||||
});
|
||||
await supertest(app.getHttpServer()).get('/flags-enabled-custom-exception').expect(403);
|
||||
});
|
||||
|
||||
it('should throw a custom exception if the flag is disabled with context', async () => {
|
||||
await supertest(app.getHttpServer())
|
||||
.get('/flags-enabled-custom-exception-with-context')
|
||||
.set('x-user-id', '123')
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenFeatureControllerRequireFlagsEnabled', () => {
|
||||
it('should allow access to the RequireFlagsEnabled controller with global context interceptor', async () => {
|
||||
await supertest(app.getHttpServer())
|
||||
.get('/require-flags-enabled')
|
||||
.set('x-user-id', '123')
|
||||
.expect(200)
|
||||
.expect('Hello, world!');
|
||||
});
|
||||
|
||||
it('should throw a 403 - Forbidden exception if user does not match targeting requirements', async () => {
|
||||
await supertest(app.getHttpServer()).get('/require-flags-enabled').set('x-user-id', 'not-123').expect(403);
|
||||
});
|
||||
|
||||
it('should throw a 403 - Forbidden exception if one of the flags is disabled', async () => {
|
||||
jest.spyOn(defaultProvider, 'resolveBooleanEvaluation').mockResolvedValueOnce({
|
||||
value: false,
|
||||
reason: 'DISABLED',
|
||||
});
|
||||
await supertest(app.getHttpServer()).get('/require-flags-enabled').set('x-user-id', '123').expect(403);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Without global context interceptor', () => {
|
||||
let moduleRef: TestingModule;
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
moduleRef = await Test.createTestingModule({
|
||||
imports: [
|
||||
OpenFeatureModule.forRoot({
|
||||
contextFactory: exampleContextFactory,
|
||||
defaultProvider,
|
||||
providers,
|
||||
useGlobalInterceptor: false,
|
||||
}),
|
||||
],
|
||||
providers: [OpenFeatureTestService],
|
||||
controllers: [OpenFeatureController, OpenFeatureContextScopedController],
|
||||
}).compile();
|
||||
app = moduleRef.createNestApplication();
|
||||
app = await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await moduleRef.close();
|
||||
});
|
||||
|
||||
it('should not use context if global context interceptor is not configured', async () => {
|
||||
const evaluationSpy = jest.spyOn(defaultProvider, 'resolveBooleanEvaluation');
|
||||
await supertest(app.getHttpServer()).get('/dynamic-context').set('x-user-id', '123').expect(200).expect('true');
|
||||
expect(evaluationSpy).toHaveBeenCalledWith('testBooleanFlag', false, {}, {});
|
||||
});
|
||||
|
||||
describe('evaluation context service should', () => {
|
||||
it('inject empty context if no context interceptor is configured', async function () {
|
||||
const evaluationSpy = jest.spyOn(defaultProvider, 'resolveBooleanEvaluation');
|
||||
await supertest(app.getHttpServer())
|
||||
.get('/dynamic-context-in-service')
|
||||
.set('x-user-id', 'dynamic-user')
|
||||
.expect(200)
|
||||
.expect('true');
|
||||
expect(evaluationSpy).toHaveBeenCalledWith('testBooleanFlag', false, {}, {});
|
||||
});
|
||||
});
|
||||
|
||||
describe('With Controller bound Context interceptor', () => {
|
||||
it('should not use context if global context interceptor is not configured', async () => {
|
||||
const evaluationSpy = jest.spyOn(defaultProvider, 'resolveBooleanEvaluation');
|
||||
await supertest(app.getHttpServer())
|
||||
.get('/controller-context')
|
||||
.set('x-user-id', '123')
|
||||
.expect(200)
|
||||
.expect('true');
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,94 @@
|
|||
import type { TestingModule } from '@nestjs/testing';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { getOpenFeatureClientToken, OpenFeatureModule, ServerProviderEvents } from '../src';
|
||||
import type { Client} from '@openfeature/server-sdk';
|
||||
import { OpenFeature } from '@openfeature/server-sdk';
|
||||
import { getOpenFeatureDefaultTestModule } from './fixtures';
|
||||
|
||||
describe('OpenFeatureModule', () => {
|
||||
let moduleRef: TestingModule;
|
||||
|
||||
describe('client injection', () => {
|
||||
beforeAll(async () => {
|
||||
moduleRef = await Test.createTestingModule({
|
||||
imports: [getOpenFeatureDefaultTestModule()],
|
||||
}).compile();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await moduleRef.close();
|
||||
});
|
||||
|
||||
describe('without configured providers', () => {
|
||||
let moduleWithoutProvidersRef: TestingModule;
|
||||
beforeAll(async () => {
|
||||
moduleWithoutProvidersRef = await Test.createTestingModule({
|
||||
imports: [OpenFeatureModule.forRoot({})],
|
||||
}).compile();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await moduleWithoutProvidersRef.close();
|
||||
});
|
||||
|
||||
it('should return the SDKs default provider and not throw', async () => {
|
||||
expect(() => {
|
||||
moduleWithoutProvidersRef.get<Client>(getOpenFeatureClientToken());
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return the default provider', async () => {
|
||||
const client = moduleRef.get<Client>(getOpenFeatureClientToken());
|
||||
expect(client).toBeDefined();
|
||||
expect(await client.getStringValue('testStringFlag', '')).toEqual('expected-string-value-default');
|
||||
});
|
||||
|
||||
it('should inject the client with the given scope', async () => {
|
||||
const client = moduleRef.get<Client>(getOpenFeatureClientToken('domainScopedClient'));
|
||||
expect(client).toBeDefined();
|
||||
expect(await client.getStringValue('testStringFlag', '')).toEqual('expected-string-value-scoped');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handlers', () => {
|
||||
let moduleWithoutProvidersRef: TestingModule;
|
||||
const handlerSpy = jest.fn();
|
||||
|
||||
beforeAll(async () => {
|
||||
moduleWithoutProvidersRef = await Test.createTestingModule({
|
||||
imports: [OpenFeatureModule.forRoot({ handlers: [[ServerProviderEvents.Ready, handlerSpy]] })],
|
||||
}).compile();
|
||||
});
|
||||
|
||||
it('should add event handlers to OpenFeature', async () => {
|
||||
expect(OpenFeature.getHandlers(ServerProviderEvents.ConfigurationChanged)).toHaveLength(0);
|
||||
expect(OpenFeature.getHandlers(ServerProviderEvents.Stale)).toHaveLength(0);
|
||||
expect(OpenFeature.getHandlers(ServerProviderEvents.Error)).toHaveLength(0);
|
||||
expect(OpenFeature.getHandlers(ServerProviderEvents.Ready)).toHaveLength(1);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await moduleWithoutProvidersRef.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hooks', () => {
|
||||
let moduleWithoutProvidersRef: TestingModule;
|
||||
const hook = { before: jest.fn() };
|
||||
|
||||
beforeAll(async () => {
|
||||
moduleWithoutProvidersRef = await Test.createTestingModule({
|
||||
imports: [OpenFeatureModule.forRoot({ hooks: [hook] })],
|
||||
}).compile();
|
||||
});
|
||||
|
||||
it('should add hooks to OpenFeature', async () => {
|
||||
expect(OpenFeature.getHooks()).toEqual([hook]);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await moduleWithoutProvidersRef.close();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,163 @@
|
|||
import { Controller, ForbiddenException, Get, Injectable, UseInterceptors } from '@nestjs/common';
|
||||
import type { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs';
|
||||
import {
|
||||
BooleanFeatureFlag,
|
||||
ObjectFeatureFlag,
|
||||
NumberFeatureFlag,
|
||||
OpenFeatureClient,
|
||||
StringFeatureFlag,
|
||||
RequireFlagsEnabled,
|
||||
} from '../src';
|
||||
import type { Client, EvaluationDetails, FlagValue } from '@openfeature/server-sdk';
|
||||
import { EvaluationContextInterceptor } from '../src';
|
||||
|
||||
@Injectable()
|
||||
export class OpenFeatureTestService {
|
||||
constructor(
|
||||
@OpenFeatureClient() public defaultClient: Client,
|
||||
@OpenFeatureClient({ domain: 'domainScopedClient' }) public domainScopedClient: Client,
|
||||
) {}
|
||||
|
||||
public async serviceMethod(flag: EvaluationDetails<FlagValue>) {
|
||||
return flag.value;
|
||||
}
|
||||
|
||||
public async serviceMethodWithDynamicContext(flagKey: string): Promise<boolean> {
|
||||
return this.defaultClient.getBooleanValue(flagKey, false);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller()
|
||||
export class OpenFeatureController {
|
||||
constructor(private testService: OpenFeatureTestService) {}
|
||||
|
||||
@Get('/welcome')
|
||||
public async welcome(
|
||||
@BooleanFeatureFlag({ flagKey: 'testBooleanFlag', defaultValue: false })
|
||||
feature: Observable<EvaluationDetails<boolean>>,
|
||||
) {
|
||||
return feature.pipe(
|
||||
map((details) =>
|
||||
details.value ? 'Welcome to this OpenFeature-enabled Nest.js app!' : 'Welcome to this Nest.js app!',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Get('/boolean')
|
||||
public async handleBooleanRequest(
|
||||
@BooleanFeatureFlag({ flagKey: 'testBooleanFlag', defaultValue: false })
|
||||
feature: Observable<EvaluationDetails<boolean>>,
|
||||
) {
|
||||
return feature.pipe(map((details) => this.testService.serviceMethod(details)));
|
||||
}
|
||||
|
||||
@Get('/string')
|
||||
public async handleStringRequest(
|
||||
@StringFeatureFlag({ flagKey: 'testStringFlag', defaultValue: 'default-value' })
|
||||
feature: Observable<EvaluationDetails<string>>,
|
||||
) {
|
||||
return feature.pipe(map((details) => this.testService.serviceMethod(details)));
|
||||
}
|
||||
|
||||
@Get('/number')
|
||||
public async handleNumberRequest(
|
||||
@NumberFeatureFlag({ flagKey: 'testNumberFlag', defaultValue: 0 })
|
||||
feature: Observable<EvaluationDetails<number>>,
|
||||
) {
|
||||
return feature.pipe(map((details) => this.testService.serviceMethod(details)));
|
||||
}
|
||||
|
||||
@Get('/object')
|
||||
public async handleObjectRequest(
|
||||
@ObjectFeatureFlag({ flagKey: 'testObjectFlag', defaultValue: {} })
|
||||
feature: Observable<EvaluationDetails<number>>,
|
||||
) {
|
||||
return feature.pipe(map((details) => this.testService.serviceMethod(details)));
|
||||
}
|
||||
|
||||
@Get('/dynamic-context')
|
||||
public async handleDynamicContextRequest(
|
||||
@BooleanFeatureFlag({
|
||||
flagKey: 'testBooleanFlag',
|
||||
defaultValue: false,
|
||||
})
|
||||
feature: Observable<EvaluationDetails<number>>,
|
||||
) {
|
||||
return feature.pipe(map((details) => this.testService.serviceMethod(details)));
|
||||
}
|
||||
|
||||
@Get('/dynamic-context-in-service')
|
||||
public async handleDynamicContextInServiceRequest() {
|
||||
return this.testService.serviceMethodWithDynamicContext('testBooleanFlag');
|
||||
}
|
||||
|
||||
@RequireFlagsEnabled({
|
||||
flags: [{ flagKey: 'testBooleanFlag' }],
|
||||
})
|
||||
@Get('/flags-enabled')
|
||||
public async handleGuardedBooleanRequest() {
|
||||
return 'Get Boolean Flag Success!';
|
||||
}
|
||||
|
||||
@RequireFlagsEnabled({
|
||||
flags: [{ flagKey: 'testBooleanFlag' }],
|
||||
exception: new ForbiddenException(),
|
||||
})
|
||||
@Get('/flags-enabled-custom-exception')
|
||||
public async handleBooleanRequestWithCustomException() {
|
||||
return 'Get Boolean Flag Success!';
|
||||
}
|
||||
|
||||
@RequireFlagsEnabled({
|
||||
flags: [{ flagKey: 'testBooleanFlag2' }],
|
||||
exception: new ForbiddenException(),
|
||||
context: {
|
||||
targetingKey: 'user-id',
|
||||
},
|
||||
})
|
||||
@Get('/flags-enabled-custom-exception-with-context')
|
||||
public async handleBooleanRequestWithCustomExceptionAndContext() {
|
||||
return 'Get Boolean Flag Success!';
|
||||
}
|
||||
}
|
||||
|
||||
@Controller()
|
||||
@UseInterceptors(EvaluationContextInterceptor)
|
||||
export class OpenFeatureContextScopedController {
|
||||
constructor(private testService: OpenFeatureTestService) {}
|
||||
|
||||
@Get('/controller-context')
|
||||
public async handleDynamicContextRequest(
|
||||
@BooleanFeatureFlag({
|
||||
flagKey: 'testBooleanFlag',
|
||||
defaultValue: false,
|
||||
})
|
||||
feature: Observable<EvaluationDetails<number>>,
|
||||
) {
|
||||
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!';
|
||||
}
|
||||
}
|
|
@ -0,0 +1,120 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig.json to read more about this file */
|
||||
/* Projects */
|
||||
// "incremental": true, /* Enable incremental compilation */
|
||||
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||
// "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */
|
||||
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */
|
||||
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||
/* Language and Environment */
|
||||
"target": "ES2015",
|
||||
/* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||
"lib": [
|
||||
"ES2015"
|
||||
],
|
||||
/* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||
"experimentalDecorators": true,
|
||||
/* Enable experimental support for TC39 stage 2 draft decorators. */
|
||||
"emitDecoratorMetadata": true,
|
||||
/* Emit design-type metadata for decorated declarations in source files. */
|
||||
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
|
||||
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */
|
||||
// "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */
|
||||
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||
/* Modules */
|
||||
"module": "ES2015",
|
||||
/* Specify what module code is generated. */
|
||||
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||
"moduleResolution": "node",
|
||||
/* Specify how TypeScript looks up a file from a given module specifier. */
|
||||
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||
// "paths": {}, /* 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. */
|
||||
// "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */
|
||||
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
// "resolveJsonModule": true, /* Enable importing .json files */
|
||||
// "noResolve": true, /* Disallow `import`s, `require`s or `<reference>`s from expanding the number of files TypeScript should add to a project. */
|
||||
/* JavaScript Support */
|
||||
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */
|
||||
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
||||
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */
|
||||
/* Emit */
|
||||
"declaration": true,
|
||||
/* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
||||
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
||||
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */
|
||||
"outDir": "./dist/esm",
|
||||
/* Specify an output folder for all emitted files. */
|
||||
// "removeComments": true, /* Disable emitting comments. */
|
||||
// "noEmit": true, /* Disable emitting files from a compilation. */
|
||||
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
||||
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */
|
||||
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSourceMap": true, /* Include sourcemap files 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. */
|
||||
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||
"stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
|
||||
// "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */
|
||||
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||
// "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */
|
||||
"declarationDir": "./dist/types",
|
||||
/* Specify the output directory for generated declaration files. */
|
||||
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
|
||||
/* Interop Constraints */
|
||||
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||
"esModuleInterop": true,
|
||||
/* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */
|
||||
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
/* Ensure that casing is correct in imports. */
|
||||
/* Type Checking */
|
||||
"strict": true,
|
||||
/* Enable all strict type-checking options. */
|
||||
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */
|
||||
// "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */
|
||||
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||
// "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */
|
||||
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||
// "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */
|
||||
// "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */
|
||||
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||
// "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */
|
||||
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */
|
||||
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||
// "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
|
||||
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */
|
||||
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
||||
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
||||
/* Completeness */
|
||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||
"skipLibCheck": true,
|
||||
/* Skip type checking all .d.ts files. */
|
||||
"resolveJsonModule": true,
|
||||
"paths": {
|
||||
"@openfeature/core": [ "../shared/src" ],
|
||||
"@openfeature/server-sdk": [ "../server/src" ]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"./src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"**/*.test.js"
|
||||
]
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": {}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/tests",
|
||||
"types": ["jest", "node"],
|
||||
"rootDir": ".",
|
||||
"resolveJsonModule": true,
|
||||
},
|
||||
"include": [
|
||||
"./test/**/*"
|
||||
]
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://typedoc.org/schema.json",
|
||||
"name": "@openfeature/nestjs-sdk",
|
||||
"includeVersion": true,
|
||||
"entryPoints": [
|
||||
"src/index.ts",
|
||||
]
|
||||
}
|
|
@ -0,0 +1,316 @@
|
|||
# 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)
|
||||
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
* adds ErrorOptions to Error constructor ([#765](https://github.com/open-feature/js-sdk/issues/765)) ([2f59a9f](https://github.com/open-feature/js-sdk/commit/2f59a9f5a81135d81d3c6cd7a14863dc21b012b4))
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
* removed duped core types ([#800](https://github.com/open-feature/js-sdk/issues/800)) ([7cc1e09](https://github.com/open-feature/js-sdk/commit/7cc1e09a1118d0c541aeb5e43da74eb3983950a3))
|
||||
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
* update react readme ([#792](https://github.com/open-feature/js-sdk/issues/792)) ([1666597](https://github.com/open-feature/js-sdk/commit/16665978394718558e3c43601737358098305a40))
|
||||
|
||||
## [0.1.0-experimental](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.0.6-experimental...react-sdk-v0.1.0-experimental) (2024-01-18)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* remove generic hook, add specific type hooks ([#766](https://github.com/open-feature/js-sdk/issues/766))
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
* remove generic hook, add specific type hooks ([#766](https://github.com/open-feature/js-sdk/issues/766)) ([d1d02fa](https://github.com/open-feature/js-sdk/commit/d1d02fa59de5b5b1b8866c0b5d3de1a5bc0c5a04))
|
||||
|
||||
|
||||
### 🧹 Chore
|
||||
|
||||
* fix react-sdk REAMDE example, add missing `EvaluationContext` ([#762](https://github.com/open-feature/js-sdk/issues/762)) ([1e13333](https://github.com/open-feature/js-sdk/commit/1e1333381909b790d0c4fc7590613b2ae6f1aa2e))
|
||||
|
||||
## [0.0.6-experimental](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.0.5-experimental...react-sdk-v0.0.6-experimental) (2024-01-11)
|
||||
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
* suspense support, client scoping, context-sensitive re-rendering ([#759](https://github.com/open-feature/js-sdk/issues/759)) ([8f01ead](https://github.com/open-feature/js-sdk/commit/8f01ead29104d122b8126d2fee97c98556091344))
|
||||
|
||||
|
||||
### 🧹 Chore
|
||||
|
||||
* fix React SDK README.md to use the resolved value instead of the resolution details ([#691](https://github.com/open-feature/js-sdk/issues/691)) ([2d1b8eb](https://github.com/open-feature/js-sdk/commit/2d1b8ebfb187db92db02ea36fa6d6ca291591b18))
|
||||
* react-sdk | downgrading react peer dependency | react 18.0.0 -> 16.8.0 ([#742](https://github.com/open-feature/js-sdk/issues/742)) ([2c864e4](https://github.com/open-feature/js-sdk/commit/2c864e46ccecd6d8825738f30a2d098dc66e26cf))
|
||||
|
||||
## [0.0.5-experimental](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.0.4-experimental...react-sdk-v0.0.5-experimental) (2023-11-27)
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
* rm NodeJS type from core, rm react core peer ([#681](https://github.com/open-feature/js-sdk/issues/681)) ([09ff7b4](https://github.com/open-feature/js-sdk/commit/09ff7b4d99ec2bfa4ef9c18cb1845af1ca14d7b9))
|
||||
|
||||
## [0.0.4-experimental](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.0.3-experimental...react-sdk-v0.0.4-experimental) (2023-11-21)
|
||||
|
||||
|
||||
### 🧹 Chore
|
||||
|
||||
* **main:** release core 0.0.19 ([#676](https://github.com/open-feature/js-sdk/issues/676)) ([b0cbeb4](https://github.com/open-feature/js-sdk/commit/b0cbeb460cfb210d258cb7978e77f306353037d2))
|
||||
|
||||
## [0.0.3-experimental](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.0.2-experimental...react-sdk-v0.0.3-experimental) (2023-11-09)
|
||||
|
||||
|
||||
### 🧹 Chore
|
||||
|
||||
* **main:** release core 0.0.17 ([#651](https://github.com/open-feature/js-sdk/issues/651)) ([3c9fdd9](https://github.com/open-feature/js-sdk/commit/3c9fdd9e4c6b487f25494d03ed1f413d14b2ccfb))
|
||||
* **main:** release core 0.0.18 ([#661](https://github.com/open-feature/js-sdk/issues/661)) ([cf7bbf0](https://github.com/open-feature/js-sdk/commit/cf7bbf063916c639878de16e54e974607a2cd7ed))
|
||||
* update spec version link ([0032a81](https://github.com/open-feature/js-sdk/commit/0032a81924012a3b464e577e4505028d6a52cf82))
|
||||
|
||||
## [0.0.2-experimental](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.0.1-experimental...react-sdk-v0.0.2-experimental) (2023-10-31)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* extract and publish core package ([#629](https://github.com/open-feature/js-sdk/issues/629)) ([c3ee90b](https://github.com/open-feature/js-sdk/commit/c3ee90b2e0fdcec235069960e7ec03e63028b08c))
|
|
@ -12,37 +12,116 @@
|
|||
<!-- 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/tree/v0.6.0">
|
||||
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.6.0&color=yellow&style=for-the-badge" />
|
||||
<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/react-sdk-v1.0.1">
|
||||
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v1.0.1&color=blue&style=for-the-badge" />
|
||||
</a>
|
||||
<!-- 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/react-sdk">
|
||||
<img alt="NPM Download" src="https://img.shields.io/npm/dm/%40openfeature%2Freact-sdk" />
|
||||
</a>
|
||||
</p>
|
||||
<!-- x-hide-in-docs-start -->
|
||||
|
||||
[OpenFeature](https://openfeature.dev) is an open standard 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.
|
||||
|
||||
🧪 This is SDK is experimental.
|
||||
<!-- x-hide-in-docs-end -->
|
||||
|
||||
## Overview
|
||||
|
||||
Here's a basic example of how ot use the current API with flagd:
|
||||
The OpenFeature React SDK adds React-specific functionality to the [OpenFeature Web SDK](https://openfeature.dev/docs/reference/technologies/client/web).
|
||||
|
||||
```js
|
||||
import logo from './logo.svg';
|
||||
import './App.css';
|
||||
import { OpenFeatureProvider, useFeatureFlag, OpenFeature } from '@openfeature/react-sdk';
|
||||
import { FlagdWebProvider } from '@openfeature/flagd-web-provider';
|
||||
In addition to the feature provided by the [web sdk](https://openfeature.dev/docs/reference/technologies/client/web), capabilities include:
|
||||
|
||||
const provider = new FlagdWebProvider({
|
||||
host: 'localhost',
|
||||
port: 8013,
|
||||
tls: false,
|
||||
maxRetries: 0,
|
||||
});
|
||||
OpenFeature.setProvider(provider)
|
||||
- [Overview](#overview)
|
||||
- [Quick start](#quick-start)
|
||||
- [Requirements](#requirements)
|
||||
- [Install](#install)
|
||||
- [npm](#npm)
|
||||
- [yarn](#yarn)
|
||||
- [Required peer dependencies](#required-peer-dependencies)
|
||||
- [Usage](#usage)
|
||||
- [OpenFeatureProvider context provider](#openfeatureprovider-context-provider)
|
||||
- [Evaluation hooks](#evaluation-hooks)
|
||||
- [Multiple Providers and Domains](#multiple-providers-and-domains)
|
||||
- [Re-rendering with Context Changes](#re-rendering-with-context-changes)
|
||||
- [Re-rendering with Flag Configuration Changes](#re-rendering-with-flag-configuration-changes)
|
||||
- [Suspense Support](#suspense-support)
|
||||
- [Tracking](#tracking)
|
||||
- [Testing](#testing)
|
||||
- [FAQ and troubleshooting](#faq-and-troubleshooting)
|
||||
- [Resources](#resources)
|
||||
|
||||
## Quick start
|
||||
|
||||
### Requirements
|
||||
|
||||
- ES2015-compatible web browser (Chrome, Edge, Firefox, etc)
|
||||
- React version 16.8+
|
||||
|
||||
### Install
|
||||
|
||||
#### npm
|
||||
|
||||
```sh
|
||||
npm install --save @openfeature/react-sdk
|
||||
```
|
||||
|
||||
#### yarn
|
||||
|
||||
```sh
|
||||
# yarn requires manual installation of the peer dependencies (see below)
|
||||
yarn add @openfeature/react-sdk @openfeature/web-sdk @openfeature/core
|
||||
```
|
||||
|
||||
#### Required peer dependencies
|
||||
|
||||
The following list contains the peer dependencies of `@openfeature/react-sdk`.
|
||||
See the [package.json](./package.json) for the required versions.
|
||||
|
||||
* `@openfeature/web-sdk`
|
||||
* `react`
|
||||
|
||||
### Usage
|
||||
|
||||
#### OpenFeatureProvider context provider
|
||||
|
||||
The `OpenFeatureProvider` is a [React context provider](https://react.dev/reference/react/createContext#provider) which represents a scope for feature flag evaluations within a React application.
|
||||
It binds an OpenFeature client to all evaluations within child components, and allows the use of evaluation hooks.
|
||||
The example below shows how to use the `OpenFeatureProvider` with OpenFeature's `InMemoryProvider`.
|
||||
|
||||
```tsx
|
||||
import { EvaluationContext, OpenFeatureProvider, useFlag, OpenFeature, InMemoryProvider } from '@openfeature/react-sdk';
|
||||
|
||||
const flagConfig = {
|
||||
'new-message': {
|
||||
disabled: false,
|
||||
variants: {
|
||||
on: true,
|
||||
off: false,
|
||||
},
|
||||
defaultVariant: "on",
|
||||
contextEvaluator: (context: EvaluationContext) => {
|
||||
if (context.silly) {
|
||||
return 'on';
|
||||
}
|
||||
return 'off'
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Instantiate and set our provider (be sure this only happens once)!
|
||||
// Note: there's no need to await its initialization, the React SDK handles re-rendering and suspense for you!
|
||||
OpenFeature.setProvider(new InMemoryProvider(flagConfig));
|
||||
|
||||
// Enclose your content in the configured provider
|
||||
function App() {
|
||||
return (
|
||||
<OpenFeatureProvider>
|
||||
|
@ -50,18 +129,274 @@ function App() {
|
|||
</OpenFeatureProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### Evaluation hooks
|
||||
|
||||
Within the provider, you can use the various evaluation hooks to evaluate flags.
|
||||
|
||||
```tsx
|
||||
function Page() {
|
||||
const booleanFlag = useFeatureFlag('new-welcome-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 (
|
||||
<div className="App">
|
||||
<header className="App-header">
|
||||
<img src={logo} className="App-logo" alt="logo" />
|
||||
{booleanFlag ? <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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App;
|
||||
```
|
||||
|
||||
You can use the strongly typed flag value and flag evaluation detail hooks as well if you prefer.
|
||||
|
||||
```tsx
|
||||
import { useBooleanFlagValue } from '@openfeature/react-sdk';
|
||||
|
||||
// boolean flag evaluation
|
||||
const value = useBooleanFlagValue('new-message', false);
|
||||
```
|
||||
|
||||
```tsx
|
||||
import { useBooleanFlagDetails } from '@openfeature/react-sdk';
|
||||
|
||||
// "detailed" boolean flag evaluation
|
||||
const {
|
||||
value,
|
||||
variant,
|
||||
reason,
|
||||
flagMetadata
|
||||
} = useBooleanFlagDetails('new-message', false);
|
||||
```
|
||||
|
||||
#### Multiple Providers and Domains
|
||||
|
||||
Multiple providers can be used by passing a `domain` to the `OpenFeatureProvider`:
|
||||
|
||||
```tsx
|
||||
// Flags within this domain will use the client/provider associated with `my-domain`,
|
||||
function App() {
|
||||
return (
|
||||
<OpenFeatureProvider domain={'my-domain'}>
|
||||
<Page></Page>
|
||||
</OpenFeatureProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
This is analogous to:
|
||||
|
||||
```ts
|
||||
OpenFeature.getClient('my-domain');
|
||||
```
|
||||
|
||||
For more information about `domains`, refer to the [web SDK](https://github.com/open-feature/js-sdk/blob/main/packages/web/README.md).
|
||||
|
||||
#### Re-rendering with Context Changes
|
||||
|
||||
By default, if the OpenFeature [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context) is modified, components will be re-rendered.
|
||||
This is useful in cases where flag values are dependant on user-attributes or other application state (user logged in, items in card, etc).
|
||||
You can disable this feature in the hook options (or in the [OpenFeatureProvider](#openfeatureprovider-context-provider)):
|
||||
|
||||
```tsx
|
||||
function Page() {
|
||||
const { value: showNewMessage } = useFlag('new-message', false, { updateOnContextChanged: false });
|
||||
return (
|
||||
<div className="App">
|
||||
<header className="App-header">
|
||||
{showNewMessage ? <p>Welcome to this OpenFeature-enabled React app!</p> : <p>Welcome to this React app.</p>}
|
||||
</header>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
By default, if the underlying provider emits a `ConfigurationChanged` event, components will be re-rendered.
|
||||
This is useful if you want your UI to immediately reflect changes in the backend flag configuration.
|
||||
You can disable this feature in the hook options (or in the [OpenFeatureProvider](#openfeatureprovider-context-provider)):
|
||||
|
||||
```tsx
|
||||
function Page() {
|
||||
const { value: showNewMessage } = useFlag('new-message', false, { updateOnConfigurationChanged: false });
|
||||
return (
|
||||
<div className="App">
|
||||
<header className="App-header">
|
||||
{showNewMessage ? <p>Welcome to this OpenFeature-enabled React app!</p> : <p>Welcome to this React app.</p>}
|
||||
</header>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
If your provider doesn't support updates, this configuration has no impact.
|
||||
|
||||
> [!NOTE]
|
||||
> If your provider includes a list of [flags changed](https://open-feature.github.io/js-sdk/types/_openfeature_server_sdk.ConfigChangeEvent.html) in its `PROVIDER_CONFIGURATION_CHANGED` event, that list of flags is used to decide which flag evaluation hooks should re-run by diffing the latest value of these flags with the previous render.
|
||||
> If your provider event does not the include the `flags changed` list, then the SDK diffs all flags with the previous render to determine which hooks should re-run.
|
||||
|
||||
#### Suspense Support
|
||||
|
||||
> [!NOTE]
|
||||
> React suspense is an experimental feature and is subject to change in future versions.
|
||||
|
||||
Frequently, providers need to perform some initial startup tasks.
|
||||
It may be desirable not to display components with feature flags until this is complete or when the context changes.
|
||||
Built-in [suspense](https://react.dev/reference/react/Suspense) support makes this easy.
|
||||
Use `useSuspenseFlag` or pass `{ suspend: true }` in the hook options to leverage this functionality.
|
||||
|
||||
```tsx
|
||||
function Content() {
|
||||
// cause the "fallback" to be displayed if the component uses feature flags and the provider is not ready
|
||||
return (
|
||||
<Suspense fallback={<Fallback />}>
|
||||
<Message />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function Message() {
|
||||
// component to render after READY, equivalent to useFlag('new-message', false, { suspend: true });
|
||||
const { value: showNewMessage } = useSuspenseFlag('new-message', false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showNewMessage ? (
|
||||
<p>Welcome to this OpenFeature-enabled React app!</p>
|
||||
) : (
|
||||
<p>Welcome to this plain old React app!</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Fallback() {
|
||||
// component to render before READY.
|
||||
return <p>Waiting for provider to be ready...</p>;
|
||||
}
|
||||
```
|
||||
|
||||
This can be disabled in the hook options (or in the [OpenFeatureProvider](#openfeatureprovider-context-provider)).
|
||||
|
||||
#### Tracking
|
||||
|
||||
The tracking API allows you to use OpenFeature abstractions and objects to associate user actions with feature flag evaluations.
|
||||
This is essential for robust experimentation powered by feature flags.
|
||||
For example, a flag enhancing the appearance of a UI component might drive user engagement to a new feature; to test this hypothesis, telemetry collected by a [hook](https://openfeature.dev/docs/reference/technologies/client/web/#hooks) or [provider](https://openfeature.dev/docs/reference/technologies/client/web/#providers) can be associated with telemetry reported in the client's `track` function.
|
||||
|
||||
The React SDK includes a hook for firing tracking events in the `<OpenFeatureProvider>` context in use:
|
||||
|
||||
```tsx
|
||||
function MyComponent() {
|
||||
// get a tracking function for this <OpenFeatureProvider>.
|
||||
const { track } = useTrack();
|
||||
|
||||
// call the tracking event
|
||||
// can be done in render, useEffect, or in handlers, depending on your use case
|
||||
track(eventName, trackingDetails);
|
||||
|
||||
return <>...</>;
|
||||
}
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
The React SDK includes a built-in context provider for testing.
|
||||
This allows you to easily test components that use evaluation hooks, such as `useFlag`.
|
||||
If you try to test a component (in this case, `MyComponent`) which uses an evaluation hook, you might see an error message like:
|
||||
|
||||
> No OpenFeature client available - components using OpenFeature must be wrapped with an `<OpenFeatureProvider>`.
|
||||
|
||||
You can resolve this by simply wrapping your component under test in the OpenFeatureTestProvider:
|
||||
|
||||
```tsx
|
||||
// use default values for all evaluations
|
||||
<OpenFeatureTestProvider>
|
||||
<MyComponent />
|
||||
</OpenFeatureTestProvider>
|
||||
```
|
||||
|
||||
The basic configuration above will simply use the default value provided in code.
|
||||
If you'd like to control the values returned by the evaluation hooks, you can pass a map of flag keys and values:
|
||||
|
||||
```tsx
|
||||
// return `true` for all evaluations of `'my-boolean-flag'`
|
||||
<OpenFeatureTestProvider flagValueMap={{ 'my-boolean-flag': true }}>
|
||||
<MyComponent />
|
||||
</OpenFeatureTestProvider>
|
||||
```
|
||||
|
||||
Additionally, you can pass an artificial delay for the provider startup to test your suspense boundaries or loaders/spinners impacted by feature flags:
|
||||
|
||||
```tsx
|
||||
// delay the provider start by 1000ms and then return `true` for all evaluations of `'my-boolean-flag'`
|
||||
<OpenFeatureTestProvider delayMs={1000} flagValueMap={{ 'my-boolean-flag': true }}>
|
||||
<MyComponent />
|
||||
</OpenFeatureTestProvider>
|
||||
```
|
||||
|
||||
For maximum control, you can also pass your own mock provider implementation.
|
||||
The type of this option is `Partial<Provider>`, so you can pass an incomplete implementation:
|
||||
|
||||
```tsx
|
||||
class MyTestProvider implements Partial<Provider> {
|
||||
// implement the relevant resolver
|
||||
resolveBooleanEvaluation(): ResolutionDetails<boolean> {
|
||||
return {
|
||||
value: true,
|
||||
variant: 'my-variant',
|
||||
reason: 'MY_REASON',
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// use your custom testing provider
|
||||
<OpenFeatureTestProvider provider={new MyTestProvider()}>
|
||||
<MyComponent />
|
||||
</OpenFeatureTestProvider>,
|
||||
```
|
||||
|
||||
## FAQ and troubleshooting
|
||||
|
||||
> I get an error that says something like: `A React component suspended while rendering, but no fallback UI was specified.`
|
||||
|
||||
The OpenFeature React SDK features built-in [suspense support](#suspense-support).
|
||||
This means that it will render your loading fallback automatically while your provider starts up and during context reconciliation for any of your components using feature flags!
|
||||
If you use suspense and neglect to create a suspense boundary around any components using feature flags, you will see this error.
|
||||
Add a suspense boundary to resolve this issue.
|
||||
Alternatively, you can disable this suspense (the default) by removing `suspendWhileReconciling=true`, `suspendUntilReady=true` or `suspend=true` in the [evaluation hooks](#evaluation-hooks) or the [OpenFeatureProvider](#openfeatureprovider-context-provider) (which applies to all evaluation hooks in child components).
|
||||
|
||||
> I get odd rendering issues or errors when components mount if I use the suspense features.
|
||||
|
||||
In React 16/17's "Legacy Suspense", when a component suspends, its sibling components initially mount and then are hidden.
|
||||
This can cause surprising effects and inconsistencies if sibling components are rendered while the provider is still getting ready.
|
||||
To fix this, you can upgrade to React 18, which uses "Concurrent Suspense", in which siblings are not mounted until their suspended sibling resolves.
|
||||
Alternatively, if you cannot upgrade to React 18, you can use the `useWhenProviderReady` utility hook in any sibling components to prevent them from mounting until the provider is ready.
|
||||
|
||||
> I am using multiple `OpenFeatureProvider` contexts, but they share the same provider or evaluation context. Why?
|
||||
|
||||
The `OpenFeatureProvider` binds a `client` to all child components, but the provider and context associated with that client is controlled by the `domain` parameter.
|
||||
This is consistent with all OpenFeature SDKs.
|
||||
To scope an OpenFeatureProvider to a particular provider/context, set the `domain` parameter on your `OpenFeatureProvider`:
|
||||
|
||||
```tsx
|
||||
<OpenFeatureProvider domain={'my-domain'}>
|
||||
<Page></Page>
|
||||
</OpenFeatureProvider>
|
||||
```
|
||||
|
||||
> I can import things form the `@openfeature/react-sdk`, `@openfeature/web-sdk`, and `@openfeature/core`; which should I use?
|
||||
|
||||
The `@openfeature/react-sdk` re-exports everything from its peers (`@openfeature/web-sdk` and `@openfeature/core`) and adds the React-specific features.
|
||||
You can import everything from the `@openfeature/react-sdk` directly.
|
||||
Avoid importing anything from `@openfeature/web-sdk` or `@openfeature/core`.
|
||||
|
||||
## Resources
|
||||
|
||||
- [Example repo](https://github.com/open-feature/react-test-app)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openfeature/react-sdk",
|
||||
"version": "0.0.1-experimental",
|
||||
"version": "1.0.1",
|
||||
"description": "OpenFeature React SDK",
|
||||
"main": "./dist/cjs/index.js",
|
||||
"files": [
|
||||
|
@ -16,16 +16,16 @@
|
|||
"scripts": {
|
||||
"test": "jest --verbose",
|
||||
"lint": "eslint ./",
|
||||
"lint:fix": "eslint ./ --fix",
|
||||
"clean": "shx rm -rf ./dist",
|
||||
"build:react-esm": "esbuild src/index.ts --bundle --external:react --external:@openfeature/web-sdk --sourcemap --target=es2016 --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=es2016 --platform=browser --format=cjs --outfile=./dist/cjs/index.js --analyze",
|
||||
"build:react-esm": "esbuild src/index.ts --bundle --external:react --external:@openfeature/web-sdk --sourcemap --target=es2015 --platform=browser --format=esm --outfile=./dist/esm/index.js --analyze",
|
||||
"build:react-cjs": "esbuild src/index.ts --bundle --external:react --external:@openfeature/web-sdk --sourcemap --target=es2015 --platform=browser --format=cjs --outfile=./dist/cjs/index.js --analyze",
|
||||
"build:rollup-types": "rollup -c ../../rollup.config.mjs",
|
||||
"build": "npm run clean && npm run build:react-esm && npm run build:react-cjs && npm run build:rollup-types",
|
||||
"postbuild": "shx cp ./../../package.esm.json ./dist/esm/package.json",
|
||||
"current-version": "echo $npm_package_version",
|
||||
"prepack": "shx cp ./../../LICENSE ./LICENSE",
|
||||
"publish-if-not-exists": "cp $NPM_CONFIG_USERCONFIG .npmrc && if [ \"$(npm show $npm_package_name@$npm_package_version version)\" = \"$(npm run current-version -s)\" ]; then echo 'already published, skipping'; else npm publish --access public; fi",
|
||||
"update-core-peer": "npm install --save-peer --save-exact @openfeature/core@$OPENFEATURE_CORE_VERSION"
|
||||
"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"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
@ -47,12 +47,11 @@
|
|||
},
|
||||
"homepage": "https://github.com/open-feature/js-sdk#readme",
|
||||
"peerDependencies": {
|
||||
"@openfeature/core": "0.0.16",
|
||||
"@openfeature/web-sdk": ">=0.4.0",
|
||||
"react": ">=18.0.0"
|
||||
"@openfeature/web-sdk": "^1.5.0",
|
||||
"react": ">=16.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openfeature/core": "0.0.16",
|
||||
"@openfeature/core": "*",
|
||||
"@openfeature/web-sdk": "*"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 './context';
|
||||
export * from './tracking';
|
||||
export * from './options';
|
||||
// re-export the web-sdk so consumers can access that API from the react-sdk
|
||||
export * from '@openfeature/web-sdk';
|
||||
|
|
|
@ -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,29 +0,0 @@
|
|||
import * as React from 'react';
|
||||
import { Client, OpenFeature } from '@openfeature/web-sdk';
|
||||
|
||||
type ProviderProps = {
|
||||
client?: Client;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const Context = React.createContext<Client | undefined>(undefined);
|
||||
|
||||
export const OpenFeatureProvider = ({ client, children }: ProviderProps) => {
|
||||
if (!client) {
|
||||
client = OpenFeature.getClient();
|
||||
}
|
||||
|
||||
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,36 +0,0 @@
|
|||
import { Client, EvaluationDetails, FlagValue, ProviderEvents } from '@openfeature/web-sdk';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useOpenFeatureClient } from './provider';
|
||||
|
||||
export function useFeatureFlag<T extends FlagValue>(flagKey: string, defaultValue: T): EvaluationDetails<T> {
|
||||
const [, setForceUpdateState] = useState({});
|
||||
|
||||
const client = useOpenFeatureClient();
|
||||
|
||||
useEffect(() => {
|
||||
const forceUpdate = () => setForceUpdateState({});
|
||||
|
||||
// adding handlers here means that an update is triggered, which leads to the change directly reflecting in the UI
|
||||
client.addHandler(ProviderEvents.Ready, forceUpdate);
|
||||
client.addHandler(ProviderEvents.ConfigurationChanged, forceUpdate);
|
||||
return () => {
|
||||
// be sure to cleanup the handlers
|
||||
client.removeHandler(ProviderEvents.Ready, forceUpdate);
|
||||
client.removeHandler(ProviderEvents.ConfigurationChanged, forceUpdate);
|
||||
};
|
||||
}, [client]);
|
||||
|
||||
return getFlag(client, flagKey, defaultValue);
|
||||
}
|
||||
|
||||
function getFlag<T extends FlagValue>(client: Client, flagKey: string, defaultValue: T): EvaluationDetails<T> {
|
||||
if (typeof defaultValue === 'boolean') {
|
||||
return client.getBooleanDetails(flagKey, defaultValue) as EvaluationDetails<T>;
|
||||
} else if (typeof defaultValue === 'string') {
|
||||
return client.getStringDetails(flagKey, defaultValue) as EvaluationDetails<T>;
|
||||
} else if (typeof defaultValue === 'number') {
|
||||
return client.getNumberDetails(flagKey, defaultValue) as EvaluationDetails<T>;
|
||||
} else {
|
||||
return client.getObjectDetails(flagKey, defaultValue) as EvaluationDetails<T>;
|
||||
}
|
||||
}
|
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));
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue