Compare commits

..

No commits in common. "main" and "open-telemetry-hooks-v0.2.2" have entirely different histories.

481 changed files with 7897 additions and 42332 deletions

View File

@ -2,19 +2,10 @@
"root": true,
"ignorePatterns": ["**/*"],
"plugins": ["@nx"],
"extends": ["eslint:recommended", "plugin:prettier/recommended"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {
"@typescript-eslint/consistent-type-imports": [
"error",
{
"disallowTypeAnnotations": true,
"fixStyle": "separate-type-imports",
"prefer": "type-imports"
}
],
"@nx/enforce-module-boundaries": [
"error",
{
@ -33,41 +24,17 @@
{
"files": ["*.ts", "*.tsx"],
"extends": ["plugin:@nx/typescript"],
"rules": {
"@typescript-eslint/no-extra-semi": "error",
"no-extra-semi": "off"
}
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"extends": ["plugin:@nx/javascript"],
"rules": {
"@typescript-eslint/no-extra-semi": "error",
"no-extra-semi": "off"
}
"rules": {}
},
{
"files": "*.json",
"parser": "jsonc-eslint-parser",
"rules": {}
},
{
"files": ["*.json"],
"parser": "jsonc-eslint-parser",
"rules": {
"@nx/dependency-checks": [
"error",
{
"buildTargets": ["lint"],
"includeTransitiveDependencies": false,
"checkMissingDependencies": true,
"checkObsoleteDependencies": true,
"checkVersionMismatches": true,
"ignoredDependencies": ["jest-cucumber", "jest"],
"ignoredFiles": ["**/test/**", "**/tests/**", "**/e2e/**", "**/spec/**", "**/*.spec.ts", "**/*.spec.js", "**/*.test.ts", "**/*.test.js", "**/jest.*"]
}
]
}
}
]
}

View File

@ -3,14 +3,8 @@ components:
libs/hooks/open-telemetry:
- beeme1mr
- toddbaert
libs/providers/aws-ssm:
- gdegiorgio
libs/providers/config-cat:
- lukas-reining
- adams85
libs/providers/config-cat-web:
- lukas-reining
- adams85
libs/providers/env-var:
- beeme1mr
- toddbaert
@ -22,18 +16,14 @@ components:
- toddbaert
libs/providers/go-feature-flag:
- thomaspoignant
libs/providers/go-feature-flag-web:
- thomaspoignant
libs/providers/in-memory:
- moredip
- beeme1mr
- toddbaert
libs/providers/launchdarkly-client:
- kinyoklion
- mateoc
- sago2k8
libs/providers/flipt:
- markphelps
libs/providers/flipt-web:
- markphelps
libs/providers/unleash-web:
- jarebudev
ignored-authors:
- renovate-bot
- renovate-bot

View File

@ -6,50 +6,29 @@ on:
branches: ['main']
jobs:
lint-test-build:
# Needed for nx-set-shas when run on the main branch
permissions:
actions: read
contents: read
main:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x, 22.x, 24.x]
node-version: [16.x, 18.x, 20.x]
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/checkout@v3
with:
fetch-depth: 0
submodules: recursive
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- uses: bufbuild/buf-setup-action@v1.25.0
with:
github_token: ${{ github.token }}
- uses: nrwl/nx-set-shas@v3
# This line is needed for nx affected to work when CI is running on a PR
- run: git branch --track main origin/main || true
- run: npm ci
- run: npx nx workspace-lint
- run: if ! npx nx format:check ; then echo "Format check failed. Please run 'npx nx format:write'."; fi
- run: npx nx affected --target=lint --parallel=3 --exclude=js-sdk-contrib
- run: npx nx affected --target=test --parallel=3 --ci --code-coverage --exclude=js-sdk-contrib
- run: npx nx affected --target=build --parallel=3 --exclude=js-sdk-contrib
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
fetch-depth: 0
submodules: recursive
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
cache: 'npm'
- name: Install
run: npm ci
- name: e2e
run: npm run e2e
- run: npx nx affected --target=lint --parallel=3
- run: npx nx affected --target=test --parallel=3 --ci --code-coverage
- run: npx nx affected --target=build --parallel=3

View File

@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
name: Auto Assign Owners
steps:
- uses: dyladan/component-owners@58bd86e9814d23f1525d0a970682cead459fa783
- uses: dyladan/component-owners@cdaadffde64c918909ee081e3fe044b8910f56c2
with:
config-file: .github/component_owners.yml
repo-token: ${{ secrets.GITHUB_TOKEN }}

View File

@ -12,12 +12,12 @@ jobs:
name: Validate PR title
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5
- uses: amannn/action-semantic-pull-request@v5
id: lint_pr_title
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2
- uses: marocchino/sticky-pull-request-comment@v2
# When the previous steps fails, the workflow would stop. By adding this
# condition you can continue the execution with the populated error message.
if: always() && (steps.lint_pr_title.outputs.error_message != null)
@ -34,7 +34,7 @@ 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@52423e01640425a022ef5fd42c6fb5f633a02728 # v2
uses: marocchino/sticky-pull-request-comment@v2
with:
header: pr-title-lint-error
delete: true

View File

@ -5,12 +5,11 @@ on:
name: Run Release Please
jobs:
release-please:
environment: publish
runs-on: ubuntu-latest
# Release-please creates a PR that tracks all changes
steps:
- uses: google-github-actions/release-please-action@db8f2c60ee802b3748b512940dde88eabd7b7e01 # v3
- uses: google-github-actions/release-please-action@v3
id: release
with:
command: manifest
@ -20,19 +19,19 @@ jobs:
# The logic below handles the npm publication:
- name: Checkout Repository
if: ${{ steps.release.outputs.releases_created }}
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@v3
with:
fetch-depth: 0
submodules: recursive
- uses: bufbuild/buf-setup-action@a47c93e0b1648d5651a065437926377d060baa99 # v1.50.0
- uses: bufbuild/buf-setup-action@v1.25.0
with:
github_token: ${{ github.token }}
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
uses: actions/setup-node@v3
if: ${{ steps.release.outputs.releases_created }}
with:
node-version: 20
node-version: 16
registry-url: "https://registry.npmjs.org"
- name: Build Packages
if: ${{ steps.release.outputs.releases_created }}

9
.gitignore vendored
View File

@ -33,7 +33,6 @@ npm-debug.log
yarn-error.log
testem.log
/typings
.nx
# System Files
.DS_Store
@ -41,12 +40,8 @@ Thumbs.db
# generated files
proto
.nx
# yalc stuff
.yalc
yalc.lock
# Generated by @nx/js
.verdaccio
yalc.lock

17
.gitmodules vendored
View File

@ -1,19 +1,6 @@
[submodule "libs/providers/flagd/schemas"]
path = libs/providers/flagd/schemas
url = https://github.com/open-feature/flagd-schemas.git
url = https://github.com/open-feature/schemas.git
[submodule "libs/providers/flagd-web/schemas"]
path = libs/providers/flagd-web/schemas
url = https://github.com/open-feature/flagd-schemas.git
[submodule "libs/providers/flagd/spec"]
path = libs/providers/flagd/spec
url = https://github.com/open-feature/spec.git
[submodule "libs/shared/flagd-core/flagd-schemas"]
path = libs/shared/flagd-core/flagd-schemas
url = https://github.com/open-feature/flagd-schemas.git
[submodule "libs/shared/flagd-core/test-harness"]
path = libs/shared/flagd-core/test-harness
url = https://github.com/open-feature/flagd-testbed
branch = v2.10.2
[submodule "libs/shared/flagd-core/spec"]
path = libs/shared/flagd-core/spec
url = https://github.com/open-feature/spec
url = https://github.com/open-feature/schemas

2
.nvmrc
View File

@ -1 +1 @@
20
16

View File

@ -2,6 +2,3 @@
/dist
/coverage
/.nx/cache
/.nx/workspace-data

View File

@ -1,25 +1,10 @@
{
"libs/hooks/open-telemetry": "0.4.0",
"libs/providers/go-feature-flag": "0.7.8",
"libs/providers/flagd": "0.13.3",
"libs/providers/flagd-web": "0.7.3",
"libs/providers/env-var": "0.3.1",
"libs/providers/config-cat": "0.7.6",
"libs/providers/launchdarkly-client": "0.3.2",
"libs/providers/go-feature-flag-web": "0.2.6",
"libs/shared/flagd-core": "1.1.0",
"libs/shared/ofrep-core": "1.0.1",
"libs/providers/ofrep": "0.2.1",
"libs/providers/ofrep-web": "0.3.3",
"libs/providers/flipt": "0.1.3",
"libs/providers/flagsmith-client": "0.1.3",
"libs/providers/flipt-web": "0.1.5",
"libs/providers/multi-provider": "0.1.2",
"libs/providers/multi-provider-web": "0.0.3",
"libs/providers/growthbook-client": "0.1.2",
"libs/providers/config-cat-web": "0.1.6",
"libs/shared/config-cat-core": "0.1.1",
"libs/providers/unleash-web": "0.1.1",
"libs/providers/growthbook": "0.1.2",
"libs/providers/aws-ssm": "0.1.3"
"libs/hooks/open-telemetry": "0.2.2",
"libs/providers/go-feature-flag": "0.5.12",
"libs/providers/flagd": "0.7.7",
"libs/providers/flagd-web": "0.3.4",
"libs/providers/env-var": "0.1.1",
"libs/providers/in-memory": "0.2.0",
"libs/providers/config-cat": "0.2.0",
"libs/providers/launchdarkly-client": "0.1.2"
}

View File

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

View File

@ -31,7 +31,6 @@ The NX scaffolding will generate stub tests for you when you create your project
Use `npm run test` to test the entire project.
Use `npx nx test {MODULE NAME}` to test just a single module.
Module names can be listed using `npx nx show projects`.
## Releases

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

View File

@ -1,3 +0,0 @@
{
"babelrcRoots": ["*"]
}

View File

@ -1,5 +1,5 @@
import { getJestProjectsAsync } from '@nx/jest';
import { getJestProjects } from '@nx/jest';
export default async () => ({
projects: await getJestProjectsAsync(),
});
export default {
projects: getJestProjects(),
};

View File

@ -1,53 +1,5 @@
# Changelog
## [0.4.0](https://github.com/open-feature/js-sdk-contrib/compare/open-telemetry-hooks-v0.3.0...open-telemetry-hooks-v0.4.0) (2024-03-25)
### ⚠ BREAKING CHANGES
* update OpenFeature SDK peer ([#798](https://github.com/open-feature/js-sdk-contrib/issues/798))
### ✨ New Features
* update OpenFeature SDK peer ([#798](https://github.com/open-feature/js-sdk-contrib/issues/798)) ([ebd16b9](https://github.com/open-feature/js-sdk-contrib/commit/ebd16b9630bcc6b253a7061a144e8d476cd8b586))
### 🧹 Chore
* address lint issues ([#642](https://github.com/open-feature/js-sdk-contrib/issues/642)) ([bbd9aee](https://github.com/open-feature/js-sdk-contrib/commit/bbd9aee896dc4a0817f379b799a1b8d331ee76c6))
* fix lint issues and bump server sdk version ([#715](https://github.com/open-feature/js-sdk-contrib/issues/715)) ([bd57177](https://github.com/open-feature/js-sdk-contrib/commit/bd571770f3a1a01bd62663dc3473273449f96c5c))
## [0.3.0](https://github.com/open-feature/js-sdk-contrib/compare/open-telemetry-hooks-v0.2.4...open-telemetry-hooks-v0.3.0) (2023-10-11)
### ⚠ BREAKING CHANGES
* use @openfeature/server-sdk peer ([#608](https://github.com/open-feature/js-sdk-contrib/issues/608))
### 🐛 Bug Fixes
* packaging issues impacting babel/react ([#596](https://github.com/open-feature/js-sdk-contrib/issues/596)) ([0446eab](https://github.com/open-feature/js-sdk-contrib/commit/0446eab5cf9b45ce7de251b4f5feb8df1d499b9d))
### 🧹 Chore
* update nx, run migrations ([#552](https://github.com/open-feature/js-sdk-contrib/issues/552)) ([a88d8fc](https://github.com/open-feature/js-sdk-contrib/commit/a88d8fc097789fd7f56011e6ebb66070f52c6e56))
* use @openfeature/server-sdk peer ([#608](https://github.com/open-feature/js-sdk-contrib/issues/608)) ([ae3732a](https://github.com/open-feature/js-sdk-contrib/commit/ae3732a9068f684517db28ea1ae27b29a35e6b16))
## [0.2.4](https://github.com/open-feature/js-sdk-contrib/compare/open-telemetry-hooks-v0.2.3...open-telemetry-hooks-v0.2.4) (2023-08-03)
### ✨ New Features
* add custom attrs to traces ([#520](https://github.com/open-feature/js-sdk-contrib/issues/520)) ([28fbd12](https://github.com/open-feature/js-sdk-contrib/commit/28fbd12f206202ab626d30bdfbbe5b04e75626af))
## [0.2.3](https://github.com/open-feature/js-sdk-contrib/compare/open-telemetry-hooks-v0.2.2...open-telemetry-hooks-v0.2.3) (2023-07-28)
### ✨ New Features
* custom attribute support ([#499](https://github.com/open-feature/js-sdk-contrib/issues/499)) ([c2deddf](https://github.com/open-feature/js-sdk-contrib/commit/c2deddf288e1eb9e55d56ea58eba5f8afb8cccc5))
## [0.2.2](https://github.com/open-feature/js-sdk-contrib/compare/open-telemetry-hooks-v0.2.1...open-telemetry-hooks-v0.2.2) (2023-07-19)

View File

@ -15,7 +15,7 @@ $ npm install @openfeature/open-telemetry-hooks
Confirm that the following peer dependencies are installed.
```
$ npm install @openfeature/server-sdk @opentelemetry/api
$ npm install @openfeature/js-sdk @opentelemetry/api
```
## Hooks
@ -47,7 +47,7 @@ The `TracingHook` and `MetricsHook` can both be set on the OpenFeature singleton
This will ensure that every flag evaluation will always generate the applicable telemetry signals.
```typescript
import { OpenFeature } from '@openfeature/server-sdk';
import { OpenFeature } from '@openfeature/js-sdk';
import { TracingHook } from '@openfeature/open-telemetry-hooks';
OpenFeature.addHooks(new TracingHook());
@ -59,31 +59,13 @@ OpenFeature.addHooks(new TracingHook());
Setting the hook on the client will ensure that every flag evaluation performed by this client will always generate the applicable telemetry signals.
```typescript
import { OpenFeature } from '@openfeature/server-sdk';
import { OpenFeature } from '@openfeature/js-sdk';
import { MetricsHook } from '@openfeature/open-telemetry-hooks';
const client = OpenFeature.getClient('my-app');
client.addHooks(new MetricsHook());
```
### Custom Attributes
Custom attributes can be extracted from [flag metadata](https://openfeature.dev/specification/types#flag-metadata) by supplying a `attributeMapper` in the `MetricsHookOptions` or `TracingHookOptions`.
In the case of the `MetricsHook`, these will be added to the `feature_flag.evaluation_success_total` metric.
The `TracingHook` adds them as [span event attributes](https://opentelemetry.io/docs/instrumentation/js/manual/#span-events).
```typescript
// configure an attributeMapper function for a custom property
const attributeMapper: AttributeMapper = (flagMetadata) => {
return {
myCustomAttribute: flagMetadata.someFlagMetadataField,
};
};
const metricsHook = new MetricsHook({ attributeMapper });
const tracingHook = new TracingHook({ attributeMapper });
```
## Development
### Building

View File

@ -1,3 +1,3 @@
{
"presets": [["minify", { "builtIns": false }]]
}
}

View File

@ -1,7 +1,6 @@
{
"name": "@openfeature/open-telemetry-hooks",
"version": "0.4.0",
"license": "Apache-2.0",
"version": "0.2.2",
"repository": {
"type": "git",
"url": "https://github.com/open-feature/js-sdk-contrib.git",
@ -15,7 +14,8 @@
"current-version": "echo $npm_package_version"
},
"peerDependencies": {
"@openfeature/server-sdk": "^1.13.0",
"@openfeature/js-sdk": "^1.0.0",
"@opentelemetry/api": ">=1.3.0"
}
},
"license": "Apache-2.0"
}

View File

@ -59,14 +59,18 @@
]
},
"lint": {
"executor": "@nx/eslint:lint",
"outputs": ["{options.outputFile}"]
"executor": "@nx/linter:eslint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": ["libs/hooks/open-telemetry/**/*.ts"]
}
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/libs/hooks/open-telemetry"],
"outputs": ["coverage/libs/hooks/open-telemetry"],
"options": {
"jestConfig": "libs/hooks/open-telemetry/jest.config.ts",
"passWithNoTests": true,
"codeCoverage": true,
"coverageDirectory": "coverage/libs/hooks/open-telemetry"
}

View File

@ -1,2 +1,2 @@
export * from './lib/traces';
export * from './lib/metrics';
export * from './lib/metrics';

View File

@ -1,16 +1,16 @@
// see: https://opentelemetry.io/docs/specs/otel/logs/semantic_conventions/feature-flags/
export const FEATURE_FLAG = 'feature_flag';
export const EXCEPTION_ATTR = 'exception';
export const EXCEPTION_ATTR = 'exception'
export const ACTIVE_COUNT_NAME = `${FEATURE_FLAG}.evaluation_active_count`;
export const REQUESTS_TOTAL_NAME = `${FEATURE_FLAG}.evaluation_requests_total`;
export const SUCCESS_TOTAL_NAME = `${FEATURE_FLAG}.evaluation_success_total`;
export const ERROR_TOTAL_NAME = `${FEATURE_FLAG}.evaluation_error_total`;
export type EvaluationAttributes = { [key: `${typeof FEATURE_FLAG}.${string}`]: string | undefined };
export type EvaluationAttributes = {[key: `${typeof FEATURE_FLAG}.${string}`]: string | undefined };
export type ExceptionAttributes = { [EXCEPTION_ATTR]: string };
export const KEY_ATTR: keyof EvaluationAttributes = `${FEATURE_FLAG}.key`;
export const PROVIDER_NAME_ATTR: keyof EvaluationAttributes = `${FEATURE_FLAG}.provider_name`;
export const VARIANT_ATTR: keyof EvaluationAttributes = `${FEATURE_FLAG}.variant`;
export const REASON_ATTR: keyof EvaluationAttributes = `${FEATURE_FLAG}.reason`;
export const REASON_ATTR: keyof EvaluationAttributes = `${FEATURE_FLAG}.reason`;

View File

@ -1 +1 @@
export * from './metrics-hook';
export * from './metrics-hook';

View File

@ -1,20 +1,13 @@
import type { BeforeHookContext, EvaluationDetails, HookContext } from '@openfeature/server-sdk';
import { StandardResolutionReasons } from '@openfeature/server-sdk';
import { BeforeHookContext, EvaluationDetails, HookContext, StandardResolutionReasons } from '@openfeature/js-sdk';
import opentelemetry from '@opentelemetry/api';
import type { DataPoint, ScopeMetrics } from '@opentelemetry/sdk-metrics';
import { MeterProvider, MetricReader } from '@opentelemetry/sdk-metrics';
import {
ACTIVE_COUNT_NAME,
ERROR_TOTAL_NAME,
KEY_ATTR,
PROVIDER_NAME_ATTR,
REASON_ATTR,
REQUESTS_TOTAL_NAME,
SUCCESS_TOTAL_NAME,
VARIANT_ATTR,
} from '../conventions';
DataPoint,
MeterProvider,
MetricReader,
ScopeMetrics,
} from '@opentelemetry/sdk-metrics';
import { ACTIVE_COUNT_NAME, ERROR_TOTAL_NAME, KEY_ATTR, PROVIDER_NAME_ATTR, REASON_ATTR, REQUESTS_TOTAL_NAME, SUCCESS_TOTAL_NAME, VARIANT_ATTR } from '../conventions';
import { MetricsHook } from './metrics-hook';
import type { AttributeMapper } from '../otel-hook';
// no-op "in-memory" reader
class InMemoryMetricReader extends MetricReader {
@ -40,10 +33,11 @@ describe(MetricsHook.name, () => {
expect(successful).toBeTruthy();
});
describe('before stage', () => {
describe(MetricsHook.prototype.before, () => {
it('should increment evaluation_active_count and evaluation_requests_total and set attrs', async () => {
const FLAG_KEY = 'before-test-key';
const PROVIDER_NAME = 'before-provider-name';
const hook = new MetricsHook();
const mockHookContext: BeforeHookContext = {
flagKey: FLAG_KEY,
providerMetadata: {
@ -51,7 +45,6 @@ describe(MetricsHook.name, () => {
},
} as BeforeHookContext;
const hook = new MetricsHook();
hook.before(mockHookContext);
const result = await reader.collect();
expect(
@ -60,10 +53,8 @@ describe(MetricsHook.name, () => {
ACTIVE_COUNT_NAME,
0,
(point) =>
point.value === 1 &&
point.attributes[KEY_ATTR] === FLAG_KEY &&
point.attributes[PROVIDER_NAME_ATTR] === PROVIDER_NAME,
),
point.value === 1 && point.attributes[KEY_ATTR] === FLAG_KEY && point.attributes[PROVIDER_NAME_ATTR] === PROVIDER_NAME
)
).toBeTruthy();
expect(
hasDataPointMatching(
@ -71,21 +62,20 @@ describe(MetricsHook.name, () => {
REQUESTS_TOTAL_NAME,
0,
(point) =>
point.value === 1 &&
point.attributes[KEY_ATTR] === FLAG_KEY &&
point.attributes[PROVIDER_NAME_ATTR] === PROVIDER_NAME,
),
point.value === 1 && point.attributes[KEY_ATTR] === FLAG_KEY && point.attributes[PROVIDER_NAME_ATTR] === PROVIDER_NAME
)
).toBeTruthy();
});
});
describe('after stage', () => {
describe(MetricsHook.prototype.after, () => {
describe('variant set', () => {
it('should increment evaluation_success_total and set attrs with variant = variant', async () => {
const FLAG_KEY = 'after-test-key';
const PROVIDER_NAME = 'after-provider-name';
const VARIANT = 'one';
const VALUE = 1;
const hook = new MetricsHook();
const mockHookContext: HookContext = {
flagKey: FLAG_KEY,
providerMetadata: {
@ -98,7 +88,6 @@ describe(MetricsHook.name, () => {
reason: StandardResolutionReasons.STATIC,
} as EvaluationDetails<number>;
const hook = new MetricsHook();
hook.after(mockHookContext, evaluationDetails);
const result = await reader.collect();
expect(
@ -111,8 +100,8 @@ describe(MetricsHook.name, () => {
point.attributes[KEY_ATTR] === FLAG_KEY &&
point.attributes[PROVIDER_NAME_ATTR] === PROVIDER_NAME &&
point.attributes[VARIANT_ATTR] === VARIANT &&
point.attributes[REASON_ATTR] === StandardResolutionReasons.STATIC,
),
point.attributes[REASON_ATTR] === StandardResolutionReasons.STATIC
)
).toBeTruthy();
});
@ -120,6 +109,7 @@ describe(MetricsHook.name, () => {
const FLAG_KEY = 'after-test-key';
const PROVIDER_NAME = 'after-provider-name';
const VALUE = 1;
const hook = new MetricsHook();
const mockHookContext: HookContext = {
flagKey: FLAG_KEY,
providerMetadata: {
@ -131,7 +121,6 @@ describe(MetricsHook.name, () => {
reason: StandardResolutionReasons.STATIC,
} as EvaluationDetails<number>;
const hook = new MetricsHook();
hook.after(mockHookContext, evaluationDetails);
const result = await reader.collect();
expect(
@ -144,117 +133,18 @@ describe(MetricsHook.name, () => {
point.attributes[KEY_ATTR] === FLAG_KEY &&
point.attributes[PROVIDER_NAME_ATTR] === PROVIDER_NAME &&
point.attributes[VARIANT_ATTR] === VALUE.toString() &&
point.attributes[REASON_ATTR] === StandardResolutionReasons.STATIC,
),
).toBeTruthy();
});
});
describe('attributeMapper defined', () => {
it('should run attribute mapper', async () => {
const FLAG_KEY = 'after-test-key';
const PROVIDER_NAME = 'after-provider-name';
const VARIANT = 'two';
const VALUE = 2;
const CUSTOM_ATTR_KEY_1 = 'custom1';
const CUSTOM_ATTR_KEY_2 = 'custom2';
const CUSTOM_ATTR_VALUE_1 = 'value1';
const CUSTOM_ATTR_VALUE_2 = 500;
const mockHookContext: HookContext = {
flagKey: FLAG_KEY,
providerMetadata: {
name: PROVIDER_NAME,
},
} as HookContext;
const evaluationDetails: EvaluationDetails<number> = {
flagKey: FLAG_KEY,
variant: VARIANT,
value: VALUE,
reason: StandardResolutionReasons.STATIC,
flagMetadata: {
[CUSTOM_ATTR_KEY_1]: CUSTOM_ATTR_VALUE_1,
[CUSTOM_ATTR_KEY_2]: CUSTOM_ATTR_VALUE_2,
},
} as EvaluationDetails<number>;
// configure a mapper for our custom properties
const attributeMapper: AttributeMapper = (flagMetadata) => {
return {
[CUSTOM_ATTR_KEY_1]: flagMetadata[CUSTOM_ATTR_KEY_1],
[CUSTOM_ATTR_KEY_2]: flagMetadata[CUSTOM_ATTR_KEY_2],
};
};
const hook = new MetricsHook({ attributeMapper });
hook.after(mockHookContext, evaluationDetails);
const result = await reader.collect();
expect(
hasDataPointMatching(
result.resourceMetrics.scopeMetrics,
SUCCESS_TOTAL_NAME,
2,
(point) =>
point.value === 1 &&
point.attributes[KEY_ATTR] === FLAG_KEY &&
point.attributes[PROVIDER_NAME_ATTR] === PROVIDER_NAME &&
point.attributes[VARIANT_ATTR] === VARIANT &&
point.attributes[REASON_ATTR] === StandardResolutionReasons.STATIC &&
// custom attributes should be present
point.attributes[CUSTOM_ATTR_KEY_1] === CUSTOM_ATTR_VALUE_1 &&
point.attributes[CUSTOM_ATTR_KEY_2] === CUSTOM_ATTR_VALUE_2,
),
).toBeTruthy();
});
});
describe('attributeMapper throws', () => {
it('should no-op', async () => {
const FLAG_KEY = 'after-test-key';
const PROVIDER_NAME = 'after-provider-name';
const VARIANT = 'three';
const VALUE = 3;
const mockHookContext: HookContext = {
flagKey: FLAG_KEY,
providerMetadata: {
name: PROVIDER_NAME,
},
} as HookContext;
const evaluationDetails: EvaluationDetails<number> = {
flagKey: FLAG_KEY,
variant: VARIANT,
value: VALUE,
reason: StandardResolutionReasons.STATIC,
} as EvaluationDetails<number>;
// configure a mapper that throws
const attributeMapper: AttributeMapper = () => {
throw new Error('fake error');
};
const hook = new MetricsHook({ attributeMapper });
hook.after(mockHookContext, evaluationDetails);
const result = await reader.collect();
expect(
hasDataPointMatching(
result.resourceMetrics.scopeMetrics,
SUCCESS_TOTAL_NAME,
3,
(point) =>
point.value === 1 &&
point.attributes[KEY_ATTR] === FLAG_KEY &&
point.attributes[PROVIDER_NAME_ATTR] === PROVIDER_NAME &&
point.attributes[VARIANT_ATTR] === VARIANT &&
point.attributes[REASON_ATTR] === StandardResolutionReasons.STATIC,
),
point.attributes[REASON_ATTR] === StandardResolutionReasons.STATIC
)
).toBeTruthy();
});
});
});
describe('finally stage', () => {
describe(MetricsHook.prototype.finally, () => {
it('should decrement evaluation_success_total and set attrs', async () => {
const FLAG_KEY = 'finally-test-key';
const PROVIDER_NAME = 'finally-provider-name';
const hook = new MetricsHook();
const mockHookContext: HookContext = {
flagKey: FLAG_KEY,
providerMetadata: {
@ -262,7 +152,6 @@ describe(MetricsHook.name, () => {
},
} as HookContext;
const hook = new MetricsHook();
hook.finally(mockHookContext);
const result = await reader.collect();
expect(
@ -271,20 +160,19 @@ describe(MetricsHook.name, () => {
ACTIVE_COUNT_NAME,
1,
(point) =>
point.value === -1 &&
point.attributes[KEY_ATTR] === FLAG_KEY &&
point.attributes[PROVIDER_NAME_ATTR] === PROVIDER_NAME,
),
point.value === -1 && point.attributes[KEY_ATTR] === FLAG_KEY && point.attributes[PROVIDER_NAME_ATTR] === PROVIDER_NAME
)
).toBeTruthy();
});
});
describe('error stage', () => {
describe(MetricsHook.prototype.error, () => {
it('should decrement evaluation_success_total and set attrs', async () => {
const FLAG_KEY = 'error-test-key';
const PROVIDER_NAME = 'error-provider-name';
const ERROR_MESSAGE = 'error message';
const error = new Error(ERROR_MESSAGE);
const hook = new MetricsHook();
const mockHookContext: HookContext = {
flagKey: FLAG_KEY,
providerMetadata: {
@ -292,7 +180,6 @@ describe(MetricsHook.name, () => {
},
} as HookContext;
const hook = new MetricsHook();
hook.error(mockHookContext, error);
const result = await reader.collect();
expect(
@ -301,10 +188,8 @@ describe(MetricsHook.name, () => {
ERROR_TOTAL_NAME,
0,
(point) =>
point.value === 1 &&
point.attributes[KEY_ATTR] === FLAG_KEY &&
point.attributes[PROVIDER_NAME_ATTR] === PROVIDER_NAME,
),
point.value === 1 && point.attributes[KEY_ATTR] === FLAG_KEY && point.attributes[PROVIDER_NAME_ATTR] === PROVIDER_NAME
)
).toBeTruthy();
});
});
@ -314,7 +199,7 @@ const hasDataPointMatching = (
scopeMetrics: ScopeMetrics[],
metricName: string,
dataPointIndex: number,
dataPointMatcher: (dataPoint: DataPoint<number>) => boolean,
dataPointMatcher: (dataPoint: DataPoint<number>) => boolean
) => {
const found = scopeMetrics.find((sm) =>
sm.metrics.find((m) => {
@ -322,7 +207,7 @@ const hasDataPointMatching = (
if (point) {
return m.descriptor.name === metricName && dataPointMatcher(point);
}
}),
})
);
if (!found) {
throw Error('Unable to find matching datapoint');

View File

@ -1,32 +1,28 @@
import type { BeforeHookContext, Logger } from '@openfeature/server-sdk';
import {
BeforeHookContext,
StandardResolutionReasons,
type EvaluationDetails,
type FlagValue,
type Hook,
type HookContext,
} from '@openfeature/server-sdk';
import type { Attributes, Counter, UpDownCounter } from '@opentelemetry/api';
import { ValueType, metrics } from '@opentelemetry/api';
import type { EvaluationAttributes, ExceptionAttributes } from '../conventions';
} from '@openfeature/js-sdk';
import { Counter, UpDownCounter, ValueType, metrics } from '@opentelemetry/api';
import {
ACTIVE_COUNT_NAME,
ERROR_TOTAL_NAME,
EXCEPTION_ATTR,
ERROR_TOTAL_NAME,
EvaluationAttributes,
ExceptionAttributes,
KEY_ATTR,
PROVIDER_NAME_ATTR,
REASON_ATTR,
REQUESTS_TOTAL_NAME,
SUCCESS_TOTAL_NAME,
VARIANT_ATTR,
VARIANT_ATTR
} from '../conventions';
import type { OpenTelemetryHookOptions } from '../otel-hook';
import { OpenTelemetryHook } from '../otel-hook';
type ErrorEvaluationAttributes = EvaluationAttributes & ExceptionAttributes;
export type MetricsHookOptions = OpenTelemetryHookOptions;
const METER_NAME = 'js.openfeature.dev';
const ACTIVE_DESCRIPTION = 'active flag evaluations counter';
@ -34,23 +30,13 @@ const REQUESTS_DESCRIPTION = 'feature flag evaluation request counter';
const SUCCESS_DESCRIPTION = 'feature flag evaluation success counter';
const ERROR_DESCRIPTION = 'feature flag evaluation error counter';
/**
* A hook that adds conventionally-compliant metrics to feature flag evaluations.
*
* See {@link https://opentelemetry.io/docs/reference/specification/trace/semantic_conventions/feature-flags/}
*/
export class MetricsHook extends OpenTelemetryHook implements Hook {
protected name = MetricsHook.name;
export class MetricsHook implements Hook {
private readonly evaluationActiveUpDownCounter: UpDownCounter<EvaluationAttributes>;
private readonly evaluationRequestCounter: Counter<EvaluationAttributes>;
private readonly evaluationSuccessCounter: Counter<EvaluationAttributes | Attributes>;
private readonly evaluationSuccessCounter: Counter<EvaluationAttributes>;
private readonly evaluationErrorCounter: Counter<ErrorEvaluationAttributes>;
constructor(
options?: MetricsHookOptions,
private readonly logger?: Logger,
) {
super(options, logger);
constructor() {
const meter = metrics.getMeter(METER_NAME);
this.evaluationActiveUpDownCounter = meter.createUpDownCounter(ACTIVE_COUNT_NAME, {
description: ACTIVE_DESCRIPTION,
@ -85,7 +71,6 @@ export class MetricsHook extends OpenTelemetryHook implements Hook {
[PROVIDER_NAME_ATTR]: hookContext.providerMetadata.name,
[VARIANT_ATTR]: evaluationDetails.variant ?? evaluationDetails.value?.toString(),
[REASON_ATTR]: evaluationDetails.reason ?? StandardResolutionReasons.UNKNOWN,
...this.safeAttributeMapper(evaluationDetails?.flagMetadata || {}),
});
}

View File

@ -1,30 +0,0 @@
import type { FlagMetadata, Logger } from '@openfeature/server-sdk';
import type { Attributes } from '@opentelemetry/api';
export type AttributeMapper = (flagMetadata: FlagMetadata) => Attributes;
export type OpenTelemetryHookOptions = {
/**
* A function that maps OpenFeature flag metadata values to OpenTelemetry attributes.
*/
attributeMapper?: AttributeMapper;
};
/**
* Base class that does some logging and safely wraps the AttributeMapper.
*/
export abstract class OpenTelemetryHook {
protected safeAttributeMapper: AttributeMapper;
protected abstract name: string;
constructor(options?: OpenTelemetryHookOptions, logger?: Logger) {
this.safeAttributeMapper = (flagMetadata: FlagMetadata) => {
try {
return options?.attributeMapper?.(flagMetadata) || {};
} catch (err) {
logger?.debug(`${this.name}: error in attributeMapper, ${err.message}, ${err.stack}`);
return {};
}
};
}
}

View File

@ -1 +1 @@
export * from './tracing-hook';
export * from './tracing-hook';

View File

@ -1,4 +1,4 @@
import type { EvaluationDetails, HookContext } from '@openfeature/server-sdk';
import { EvaluationDetails, HookContext } from '@openfeature/js-sdk';
const addEvent = jest.fn();
const recordException = jest.fn();
@ -19,7 +19,7 @@ describe('OpenTelemetry Hooks', () => {
flagKey: 'testFlagKey',
clientMetadata: {
providerMetadata: {
name: 'fake',
name: "fake"
},
name: 'testClient',
},
@ -32,163 +32,90 @@ describe('OpenTelemetry Hooks', () => {
logger: console,
};
let tracingHook: TracingHook;
let otelHook: TracingHook;
beforeEach(() => {
otelHook = new TracingHook();
});
afterEach(() => {
jest.clearAllMocks();
});
describe('after stage', () => {
describe('no attribute mapper', () => {
beforeEach(() => {
tracingHook = new TracingHook();
});
describe('after hook', () => {
it('should use the variant value on the span event', () => {
const evaluationDetails: EvaluationDetails<boolean> = {
flagKey: hookContext.flagKey,
value: true,
variant: 'enabled',
flagMetadata: {}
};
it('should use the variant value on the span event', () => {
const evaluationDetails: EvaluationDetails<boolean> = {
flagKey: hookContext.flagKey,
value: true,
variant: 'enabled',
flagMetadata: {},
};
otelHook.after(hookContext, evaluationDetails);
tracingHook.after(hookContext, evaluationDetails);
expect(addEvent).toBeCalledWith('feature_flag', {
'feature_flag.key': 'testFlagKey',
'feature_flag.provider_name': 'testProvider',
'feature_flag.variant': 'enabled',
});
});
it('should use a stringified value as the variant value on the span event', () => {
const evaluationDetails: EvaluationDetails<boolean> = {
flagKey: hookContext.flagKey,
value: true,
flagMetadata: {},
};
tracingHook.after(hookContext, evaluationDetails);
expect(addEvent).toBeCalledWith('feature_flag', {
'feature_flag.key': 'testFlagKey',
'feature_flag.provider_name': 'testProvider',
'feature_flag.variant': 'true',
});
});
it('should set the value without extra quotes if value is already a string', () => {
const evaluationDetails: EvaluationDetails<string> = {
flagKey: hookContext.flagKey,
value: 'already-string',
flagMetadata: {},
};
tracingHook.after(hookContext, evaluationDetails);
expect(addEvent).toBeCalledWith('feature_flag', {
'feature_flag.key': 'testFlagKey',
'feature_flag.provider_name': 'testProvider',
'feature_flag.variant': 'already-string',
});
});
it('should not call addEvent because there is no active span', () => {
getActiveSpan.mockReturnValueOnce(undefined);
const evaluationDetails: EvaluationDetails<boolean> = {
flagKey: hookContext.flagKey,
value: true,
variant: 'enabled',
flagMetadata: {},
};
tracingHook.after(hookContext, evaluationDetails);
expect(addEvent).not.toBeCalled();
expect(addEvent).toBeCalledWith('feature_flag', {
'feature_flag.key': 'testFlagKey',
'feature_flag.provider_name': 'testProvider',
'feature_flag.variant': 'enabled',
});
});
describe('attribute mapper configured', () => {
describe('no error in mapper', () => {
beforeEach(() => {
tracingHook = new TracingHook({
attributeMapper: (flagMetadata) => {
return {
customAttr1: flagMetadata.metadata1,
customAttr2: flagMetadata.metadata2,
customAttr3: flagMetadata.metadata3,
};
},
});
});
it('should use a stringified value as the variant value on the span event', () => {
const evaluationDetails: EvaluationDetails<boolean> = {
flagKey: hookContext.flagKey,
value: true,
flagMetadata: {},
};
it('should run the attribute mapper to add custom attributes, if set', () => {
const evaluationDetails: EvaluationDetails<boolean> = {
flagKey: hookContext.flagKey,
value: true,
variant: 'enabled',
flagMetadata: {
metadata1: 'one',
metadata2: 2,
metadata3: true,
},
};
otelHook.after(hookContext, evaluationDetails);
tracingHook.after(hookContext, evaluationDetails);
expect(addEvent).toBeCalledWith('feature_flag', {
'feature_flag.key': 'testFlagKey',
'feature_flag.provider_name': 'testProvider',
'feature_flag.variant': 'enabled',
customAttr1: 'one',
customAttr2: 2,
customAttr3: true,
});
});
expect(addEvent).toBeCalledWith('feature_flag', {
'feature_flag.key': 'testFlagKey',
'feature_flag.provider_name': 'testProvider',
'feature_flag.variant': 'true',
});
});
describe('error in mapper', () => {
beforeEach(() => {
tracingHook = new TracingHook({
attributeMapper: () => {
throw new Error('fake error');
},
});
});
it('should set the value without extra quotes if value is already a string', () => {
const evaluationDetails: EvaluationDetails<string> = {
flagKey: hookContext.flagKey,
value: 'already-string',
flagMetadata: {},
};
otelHook.after(hookContext, evaluationDetails);
it('should no-op', () => {
const evaluationDetails: EvaluationDetails<boolean> = {
flagKey: hookContext.flagKey,
value: true,
variant: 'enabled',
flagMetadata: {
metadata1: 'one',
metadata2: 2,
metadata3: true,
},
};
tracingHook.after(hookContext, evaluationDetails);
expect(addEvent).toBeCalledWith('feature_flag', {
'feature_flag.key': 'testFlagKey',
'feature_flag.provider_name': 'testProvider',
'feature_flag.variant': 'enabled',
});
});
expect(addEvent).toBeCalledWith('feature_flag', {
'feature_flag.key': 'testFlagKey',
'feature_flag.provider_name': 'testProvider',
'feature_flag.variant': 'already-string',
});
});
it('should not call addEvent because there is no active span', () => {
getActiveSpan.mockReturnValueOnce(undefined);
const evaluationDetails: EvaluationDetails<boolean> = {
flagKey: hookContext.flagKey,
value: true,
variant: 'enabled',
flagMetadata: {},
};
otelHook.after(hookContext, evaluationDetails);
expect(addEvent).not.toBeCalled();
});
});
describe('error stage', () => {
describe('error hook', () => {
const testError = new Error();
it('should call recordException with a test error', () => {
tracingHook.error(hookContext, testError);
otelHook.error(hookContext, testError);
expect(recordException).toBeCalledWith(testError);
});
it('should not call recordException because there is no active span', () => {
getActiveSpan.mockReturnValueOnce(undefined);
tracingHook.error(hookContext, testError);
otelHook.error(hookContext, testError);
expect(recordException).not.toBeCalled();
});
});

View File

@ -1,23 +1,8 @@
import type { Hook, HookContext, EvaluationDetails, FlagValue, Logger } from '@openfeature/server-sdk';
import { Hook, HookContext, EvaluationDetails, FlagValue } from '@openfeature/js-sdk';
import { trace } from '@opentelemetry/api';
import { FEATURE_FLAG, KEY_ATTR, PROVIDER_NAME_ATTR, VARIANT_ATTR } from '../conventions';
import type { OpenTelemetryHookOptions } from '../otel-hook';
import { OpenTelemetryHook } from '../otel-hook';
export type TracingHookOptions = OpenTelemetryHookOptions;
/**
* A hook that adds conventionally-compliant span events to feature flag evaluations.
*
* See {@link https://opentelemetry.io/docs/reference/specification/trace/semantic_conventions/feature-flags/}
*/
export class TracingHook extends OpenTelemetryHook implements Hook {
protected name = TracingHook.name;
constructor(options?: TracingHookOptions, logger?: Logger) {
super(options, logger);
}
export class TracingHook implements Hook {
after(hookContext: HookContext, evaluationDetails: EvaluationDetails<FlagValue>) {
const currentTrace = trace.getActiveSpan();
if (currentTrace) {
@ -35,7 +20,6 @@ export class TracingHook extends OpenTelemetryHook implements Hook {
[KEY_ATTR]: hookContext.flagKey,
[PROVIDER_NAME_ATTR]: hookContext.providerMetadata.name,
[VARIANT_ATTR]: variant,
...this.safeAttributeMapper(evaluationDetails.flagMetadata),
});
}
}

View File

@ -1,30 +0,0 @@
{
"extends": ["../../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.json"],
"parser": "jsonc-eslint-parser",
"rules": {
"@nx/dependency-checks": [
"error",
{
"ignoredFiles": ["{projectRoot}/eslint.config.{js,cjs,mjs}"]
}
]
}
}
]
}

View File

@ -1,23 +0,0 @@
# Changelog
## [0.1.3](https://github.com/open-feature/js-sdk-contrib/compare/aws-ssm-provider-v0.1.2...aws-ssm-provider-v0.1.3) (2025-06-04)
### 🐛 Bug Fixes
* **deps:** update dependency @aws-sdk/client-ssm to v3.787.0 ([#1278](https://github.com/open-feature/js-sdk-contrib/issues/1278)) ([afae82c](https://github.com/open-feature/js-sdk-contrib/commit/afae82c1a1472d33b884105edaac2976c19e7423))
* **deps:** update dependency lru-cache to v11.1.0 ([#1279](https://github.com/open-feature/js-sdk-contrib/issues/1279)) ([a80f5ce](https://github.com/open-feature/js-sdk-contrib/commit/a80f5ce3d7a6e74e762a75ba8fa9f5b70ca2a179))
## [0.1.2](https://github.com/open-feature/js-sdk-contrib/compare/aws-ssm-provider-v0.1.1...aws-ssm-provider-v0.1.2) (2025-03-27)
### ✨ New Features
* **aws-ssm:** add decryption support for `SecureString` parameters ([#1241](https://github.com/open-feature/js-sdk-contrib/issues/1241)) ([043be44](https://github.com/open-feature/js-sdk-contrib/commit/043be44de1442b89876e9857478afe619fcf0b04))
## [0.1.1](https://github.com/open-feature/js-sdk-contrib/compare/aws-ssm-provider-v0.1.0...aws-ssm-provider-v0.1.1) (2025-03-20)
### ✨ New Features
* **aws-ssm:** implement AWS SSM provider ([#1221](https://github.com/open-feature/js-sdk-contrib/issues/1221)) ([819a247](https://github.com/open-feature/js-sdk-contrib/commit/819a247c41112c2873aa025ac0abd3c62eb53aca))

View File

@ -1,73 +0,0 @@
# AWS SSM Provider
## What is AWS SSM?
AWS Systems Manager (SSM) is a service provided by Amazon Web Services (AWS) that enables users to manage and automate operational tasks across their AWS infrastructure. One of its key components is AWS Systems Manager Parameter Store, which allows users to store, retrieve, and manage configuration data and secrets securely.
SSM Parameter Store can be used to manage application configuration settings, database connection strings, API keys, and other sensitive information. It provides integration with AWS Identity and Access Management (IAM) to control access and encryption through AWS Key Management Service (KMS).
The aws-ssm provider for OpenFeature allows applications to fetch feature flag configurations from AWS SSM Parameter Store, enabling centralized and dynamic configuration management.
## Installation
```
$ npm install @openfeature/aws-ssm-provider
```
## Set AWS Provider
```
OpenFeature.setProvider(
new AwsSsmProvider({
ssmClientConfig: {
region: 'eu-west-1', // Change this to your desired AWS region
// You can setup your aws credentials here or it will be automatically retrieved from env vars
// See https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/setting-credentials-node.html
},
// Use an LRUCache for improve performance and optimize AWS SDK Calls to SSM (cost awareness)
cacheOpts: {
enabled: true, // Enable caching
size: 1, // Cache size
ttl: 10, // Time-to-live in seconds
},
})
);
```
# AWS SSM Provider Configuration
## AwsSsmProviderConfig
| Property | Type | Description | Default |
|-----------------|--------------------|----------------------------------------------|---------|
| `ssmClientConfig` | `SSMClientConfig` | AWS SSM Client configuration options. | See [here](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/ssm/) |
| `enableDecryption` | `boolean` | Enable decryption for SecureString parameters | false |
| `cacheOpts` | `LRUCacheConfig` | Configuration for the local LRU cache. | See below |
## LRUCacheConfig
| Property | Type | Description | Default |
|-----------|--------|------------------------------------------------|---------|
| `enabled` | `boolean` | Whether caching is enabled. | `false` |
| `ttl` | `number` | Time-to-live (TTL) for cached items (in ms). | `300000` (5 minutes) |
| `size` | `number` | Maximum number of items in the cache. | `1000` |
## Retrieve Feature Flag!
Open your AWS Management Console and go to AWS System Manager service
![SSM-Menu](../../../assets/aws-ssm/search.png)
Go to Parameter Store
![Parameter-Store](../../../assets/aws-ssm/ssm-menu.png)
Create a new SSM Param called 'my-feature-flag' in your AWS Account and then retrieve it via OpenFeature Client!
![Create-Param](../../../assets/aws-ssm/create-param.png)
```
const featureFlags = OpenFeature.getClient();
const flagValue = await featureFlags.getBooleanValue('my-feature-flag', false);
console.log(`Feature flag value: ${flagValue}`);
```

View File

@ -1,16 +0,0 @@
/* eslint-disable */
export default {
displayName: 'aws-ssm',
preset: '../../../jest.preset.js',
globals: {
'ts-jest': {
tsconfig: '<rootDir>/tsconfig.spec.json',
},
},
transform: {
'^.+\\.[tj]s$': 'ts-jest',
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../../coverage/libs/providers/aws-ssm',
testEnvironment: 'node',
};

File diff suppressed because it is too large Load Diff

View File

@ -1,23 +0,0 @@
{
"name": "@openfeature/aws-ssm-provider",
"version": "0.1.3",
"dependencies": {
"@aws-sdk/client-ssm": "^3.759.0",
"lru-cache": "^11.0.2",
"tslib": "^2.3.0"
},
"main": "./src/index.js",
"typings": "./src/index.d.ts",
"scripts": {
"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",
"current-version": "echo $npm_package_version"
},
"license": "Apache-2.0",
"peerDependencies": {
"@openfeature/server-sdk": "^1.17.0"
},
"devDependencies": {
"@smithy/types": "^4.1.0",
"aws-sdk-client-mock": "^4.1.0"
}
}

View File

@ -1,64 +0,0 @@
{
"name": "providers-aws-ssm",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/providers/aws-ssm/src",
"projectType": "library",
"targets": {
"publish": {
"executor": "nx:run-commands",
"options": {
"command": "npm run publish-if-not-exists",
"cwd": "dist/libs/providers/aws-ssm"
},
"dependsOn": [
{
"target": "package"
}
]
},
"lint": {
"executor": "@nx/eslint:lint",
"outputs": ["{options.outputFile}"]
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/providers/aws-ssm/jest.config.ts"
}
},
"package": {
"executor": "@nx/rollup:rollup",
"outputs": ["{options.outputPath}"],
"options": {
"project": "libs/providers/aws-ssm/package.json",
"outputPath": "dist/libs/providers/aws-ssm",
"entryFile": "libs/providers/aws-ssm/src/index.ts",
"tsConfig": "libs/providers/aws-ssm/tsconfig.lib.json",
"compiler": "tsc",
"generateExportsField": true,
"umdName": "aws-ssm",
"external": "all",
"format": ["cjs", "esm"],
"assets": [
{
"glob": "package.json",
"input": "./assets",
"output": "./src/"
},
{
"glob": "LICENSE",
"input": "./",
"output": "./"
},
{
"glob": "README.md",
"input": "./libs/providers/aws-ssm",
"output": "./"
}
]
}
}
},
"tags": []
}

View File

@ -1 +0,0 @@
export * from './lib/aws-ssm-provider';

View File

@ -1,86 +0,0 @@
import { OpenFeature } from '@openfeature/server-sdk';
import { AwsSsmProvider } from '../lib/aws-ssm-provider';
import type { GetParameterCommandOutput } from '@aws-sdk/client-ssm';
import { GetParameterCommand, SSMClient } from '@aws-sdk/client-ssm';
import { mockClient } from 'aws-sdk-client-mock';
const ssmMock = mockClient(SSMClient);
describe('AWS SSM Provider E2E', () => {
const featureFlags = OpenFeature.getClient();
OpenFeature.setProvider(
new AwsSsmProvider({
ssmClientConfig: {
region: 'eu-west-1',
},
cacheOpts: {
enabled: true,
size: 1,
ttl: 10,
},
}),
);
describe('when using OpenFeature with AWS SSM Provider to retrieve a boolean', () => {
it('should use AWS SSM in order to retrieve the value', async () => {
const res: GetParameterCommandOutput = {
Parameter: {
Name: '/lambda/loggingEnabled',
Value: 'true',
},
$metadata: {},
};
ssmMock.on(GetParameterCommand).resolves(res);
const flagValue = await featureFlags.getBooleanValue('/lambda/loggingEnabled', false);
expect(flagValue).toBe(true);
});
});
describe('when using OpenFeature with AWS SSM Provider to retrieve a string', () => {
it('should use AWS SSM in order to retrieve the value', async () => {
const res: GetParameterCommandOutput = {
Parameter: {
Name: '/lambda/logLevel',
Value: 'ERROR',
},
$metadata: {},
};
ssmMock.on(GetParameterCommand).resolves(res);
const flagValue = await featureFlags.getStringValue('/lambda/logLevel', 'INFO');
expect(flagValue).toBe('ERROR');
});
});
describe('when using OpenFeature with AWS SSM Provider to retrieve a number', () => {
it('should use AWS SSM in order to retrieve the value', async () => {
const res: GetParameterCommandOutput = {
Parameter: {
Name: '/lambda/logRetentionInDays',
Value: '3',
},
$metadata: {},
};
ssmMock.on(GetParameterCommand).resolves(res);
const flagValue = await featureFlags.getNumberValue('/lambda/logRetentionInDays', 14);
expect(flagValue).toBe(3);
});
});
describe('when using OpenFeature with AWS SSM Provider to retrieve an object', () => {
it('should use AWS SSM in order to retrieve the value', async () => {
const res: GetParameterCommandOutput = {
Parameter: {
Name: '/lambda/env',
Value: JSON.stringify({
PROCESS_NUMBER: 3,
SOME_ENV_VAR: 4,
}),
},
$metadata: {},
};
ssmMock.on(GetParameterCommand).resolves(res);
const flagValue = await featureFlags.getObjectValue('/lambda/env', {});
expect(flagValue).toStrictEqual({
PROCESS_NUMBER: 3,
SOME_ENV_VAR: 4,
});
});
});
});

View File

@ -1,203 +0,0 @@
import type { SSMClientConfig } from '@aws-sdk/client-ssm';
import { AwsSsmProvider } from './aws-ssm-provider';
import { ErrorCode, StandardResolutionReasons } from '@openfeature/core';
const MOCK_SSM_CLIENT_CONFIG: SSMClientConfig = {
region: 'us-east-1',
credentials: {
accessKeyId: 'accessKeyId',
secretAccessKey: 'secretAccessKey',
},
};
const provider: AwsSsmProvider = new AwsSsmProvider({
ssmClientConfig: MOCK_SSM_CLIENT_CONFIG,
cacheOpts: {
enabled: true,
ttl: 1000,
size: 100,
},
});
describe(AwsSsmProvider.name, () => {
describe(AwsSsmProvider.prototype.resolveBooleanEvaluation.name, () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('when flag is cached', () => {
afterAll(() => {
provider.cache.clear();
});
it('should return cached value', async () => {
provider.cache.set('test', {
value: true,
reason: StandardResolutionReasons.STATIC,
});
await expect(provider.resolveBooleanEvaluation('test', false, {})).resolves.toEqual({
value: true,
reason: StandardResolutionReasons.CACHED,
});
});
});
describe('when flag is not cached', () => {
describe('when getBooleanValue rejects', () => {
it('should return default value', async () => {
jest.spyOn(provider.service, 'getBooleanValue').mockRejectedValue(new Error());
await expect(provider.resolveBooleanEvaluation('test', false, {})).resolves.toEqual({
value: false,
reason: StandardResolutionReasons.ERROR,
errorMessage: 'An unknown error occurred',
errorCode: ErrorCode.GENERAL,
});
});
});
describe('when getBooleanValue resolves', () => {
it('should resolve with expected value', async () => {
jest.spyOn(provider.service, 'getBooleanValue').mockResolvedValue({
value: true,
reason: StandardResolutionReasons.STATIC,
});
await expect(provider.resolveBooleanEvaluation('test', false, {})).resolves.toEqual({
value: true,
reason: StandardResolutionReasons.STATIC,
});
});
});
});
});
describe(AwsSsmProvider.prototype.resolveStringEvaluation.name, () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('when flag is cached', () => {
afterAll(() => {
provider.cache.clear();
});
it('should return cached value', async () => {
provider.cache.set('test', {
value: 'somestring',
reason: StandardResolutionReasons.STATIC,
});
await expect(provider.resolveStringEvaluation('test', 'default', {})).resolves.toEqual({
value: 'somestring',
reason: StandardResolutionReasons.CACHED,
});
});
});
describe('when flag is not cached', () => {
describe('when getStringValue rejects', () => {
it('should return default value', async () => {
jest.spyOn(provider.service, 'getStringValue').mockRejectedValue(new Error());
await expect(provider.resolveStringEvaluation('test', 'default', {})).resolves.toEqual({
value: 'default',
reason: StandardResolutionReasons.ERROR,
errorMessage: 'An unknown error occurred',
errorCode: ErrorCode.GENERAL,
});
});
});
describe('when getStringValue resolves', () => {
it('should resolve with expected value', async () => {
jest.spyOn(provider.service, 'getStringValue').mockResolvedValue({
value: 'somestring',
reason: StandardResolutionReasons.STATIC,
});
await expect(provider.resolveStringEvaluation('test', 'default', {})).resolves.toEqual({
value: 'somestring',
reason: StandardResolutionReasons.STATIC,
});
});
});
});
});
describe(AwsSsmProvider.prototype.resolveNumberEvaluation.name, () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('when flag is cached', () => {
afterAll(() => {
provider.cache.clear();
});
it('should return cached value', async () => {
provider.cache.set('test', {
value: 489,
reason: StandardResolutionReasons.STATIC,
});
await expect(provider.resolveNumberEvaluation('test', -1, {})).resolves.toEqual({
value: 489,
reason: StandardResolutionReasons.CACHED,
});
});
});
describe('when flag is not cached', () => {
describe('when getNumberValue rejects', () => {
it('should return default value', async () => {
jest.spyOn(provider.service, 'getNumberValue').mockRejectedValue(new Error());
await expect(provider.resolveNumberEvaluation('test', -1, {})).resolves.toEqual({
value: -1,
reason: StandardResolutionReasons.ERROR,
errorMessage: 'An unknown error occurred',
errorCode: ErrorCode.GENERAL,
});
});
});
describe('when getNumberValue resolves', () => {
it('should resolve with expected value', async () => {
jest.spyOn(provider.service, 'getNumberValue').mockResolvedValue({
value: 489,
reason: StandardResolutionReasons.STATIC,
});
await expect(provider.resolveNumberEvaluation('test', -1, {})).resolves.toEqual({
value: 489,
reason: StandardResolutionReasons.STATIC,
});
});
});
});
});
describe(AwsSsmProvider.prototype.resolveObjectEvaluation.name, () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('when flag is cached', () => {
afterAll(() => {
provider.cache.clear();
});
it('should return cached value', async () => {
provider.cache.set('test', {
value: { default: false },
reason: StandardResolutionReasons.STATIC,
});
await expect(provider.resolveObjectEvaluation('test', { default: true }, {})).resolves.toEqual({
value: { default: false },
reason: StandardResolutionReasons.CACHED,
});
});
});
describe('when flag is not cached', () => {
describe('when getObjectValue rejects', () => {
it('should return default value', async () => {
jest.spyOn(provider.service, 'getObjectValue').mockRejectedValue(new Error());
await expect(provider.resolveObjectEvaluation('test', { default: true }, {})).resolves.toEqual({
value: { default: true },
reason: StandardResolutionReasons.ERROR,
errorMessage: 'An unknown error occurred',
errorCode: ErrorCode.GENERAL,
});
});
});
describe('when getObjectValue resolves', () => {
it('should resolve with expected value', async () => {
jest.spyOn(provider.service, 'getObjectValue').mockResolvedValue({
value: { default: true },
reason: StandardResolutionReasons.STATIC,
});
await expect(provider.resolveObjectEvaluation('test', -1, {})).resolves.toEqual({
value: { default: true },
reason: StandardResolutionReasons.STATIC,
});
});
});
});
});
});

View File

@ -1,146 +0,0 @@
import type { EvaluationContext, Provider, JsonValue, ResolutionDetails } from '@openfeature/server-sdk';
import { StandardResolutionReasons, ErrorCode } from '@openfeature/server-sdk';
import { InternalServerError } from '@aws-sdk/client-ssm';
import type { AwsSsmProviderConfig } from './types';
import { SSMService } from './ssm-service';
import { Cache } from './cache';
export class AwsSsmProvider implements Provider {
metadata = {
name: AwsSsmProvider.name,
};
readonly runsOn = 'server';
readonly service: SSMService;
hooks = [];
cache: Cache;
constructor(config: AwsSsmProviderConfig) {
this.service = new SSMService(config.ssmClientConfig, config.enableDecryption);
this.cache = new Cache(config.cacheOpts);
}
async resolveBooleanEvaluation(
flagKey: string,
defaultValue: boolean,
context: EvaluationContext,
): Promise<ResolutionDetails<boolean>> {
const cachedValue = this.cache.get(flagKey);
if (cachedValue) {
return {
value: cachedValue.value,
reason: StandardResolutionReasons.CACHED,
};
}
try {
const res = await this.service.getBooleanValue(flagKey);
this.cache.set(flagKey, res);
return res;
} catch (err) {
let errMsg = 'An unknown error occurred';
if (err instanceof InternalServerError) {
errMsg = err.message;
}
return {
value: defaultValue,
reason: StandardResolutionReasons.ERROR,
errorCode: ErrorCode.GENERAL,
errorMessage: errMsg,
};
}
}
async resolveStringEvaluation(
flagKey: string,
defaultValue: string,
context: EvaluationContext,
): Promise<ResolutionDetails<string>> {
const cachedValue = this.cache.get(flagKey);
if (cachedValue) {
return {
value: cachedValue.value,
reason: StandardResolutionReasons.CACHED,
};
}
try {
const res = await this.service.getStringValue(flagKey);
this.cache.set(flagKey, res);
return res;
} catch (err) {
let errMsg = 'An unknown error occurred';
if (err instanceof InternalServerError) {
errMsg = err.message;
}
return {
value: defaultValue,
reason: StandardResolutionReasons.ERROR,
errorCode: ErrorCode.GENERAL,
errorMessage: errMsg,
};
}
}
async resolveNumberEvaluation(
flagKey: string,
defaultValue: number,
context: EvaluationContext,
): Promise<ResolutionDetails<number>> {
const cachedValue = this.cache.get(flagKey);
if (cachedValue) {
return {
value: cachedValue.value,
reason: StandardResolutionReasons.CACHED,
};
}
try {
return await this.service.getNumberValue(flagKey);
} catch (err) {
let errMsg = 'An unknown error occurred';
if (err instanceof InternalServerError) {
errMsg = err.message;
}
return {
value: defaultValue,
reason: StandardResolutionReasons.ERROR,
errorCode: ErrorCode.GENERAL,
errorMessage: errMsg,
};
}
}
async resolveObjectEvaluation<U extends JsonValue>(
flagKey: string,
defaultValue: U,
context: EvaluationContext,
): Promise<ResolutionDetails<U>> {
const cachedValue = this.cache.get(flagKey);
if (cachedValue) {
return {
value: cachedValue.value,
reason: StandardResolutionReasons.CACHED,
};
}
try {
return await this.service.getObjectValue(flagKey);
} catch (err) {
let errMsg = 'An unknown error occurred';
if (err instanceof InternalServerError) {
errMsg = err.message;
}
return {
value: defaultValue,
reason: StandardResolutionReasons.ERROR,
errorCode: ErrorCode.GENERAL,
errorMessage: errMsg,
};
}
}
}

View File

@ -1,59 +0,0 @@
import { Cache } from './cache';
describe(Cache.name, () => {
describe(Cache.prototype.get.name, () => {
describe('when cache is disabled', () => {
it('should return undefined', () => {
const cache = new Cache({ enabled: false, size: 1, ttl: 1 });
expect(cache.get('test')).toBeUndefined();
});
});
describe('when cache is enabled', () => {
describe('when key is not in cache', () => {
it('should return undefined', () => {
const cache = new Cache({ enabled: true, size: 1, ttl: 1 });
expect(cache.get('test')).toBeUndefined();
});
});
describe('when key is in cache', () => {
it('should return the value', () => {
const cache = new Cache({ enabled: true, size: 1, ttl: 1 });
cache.set('test', { value: true, reason: 'test' });
expect(cache.get('test')).toEqual({ value: true, reason: 'test' });
});
});
});
});
describe(Cache.prototype.set.name, () => {
describe('when cache is disabled', () => {
it('should not set the value', () => {
const spy = jest.spyOn(Cache.prototype, 'set');
expect(spy).not.toHaveBeenCalled();
});
});
describe('when cache is enabled', () => {
it('should set the value', () => {
const cache = new Cache({ enabled: true, size: 1, ttl: 1 });
cache.set('test', { value: true, reason: 'test' });
expect(cache.get('test')).toEqual({ value: true, reason: 'test' });
});
});
});
describe(Cache.prototype.clear.name, () => {
describe('when cache is disabled', () => {
it('should not clear the cache', () => {
const spy = jest.spyOn(Cache.prototype, 'clear');
expect(spy).not.toHaveBeenCalled();
});
});
describe('when cache is enabled', () => {
it('should clear the cache', () => {
const cache = new Cache({ enabled: true, size: 1, ttl: 1 });
cache.set('test', { value: true, reason: 'test' });
cache.clear();
expect(cache.get('test')).toBeUndefined();
});
});
});
});

View File

@ -1,38 +0,0 @@
import type { ResolutionDetails } from '@openfeature/core';
import type { LRUCacheConfig } from './types';
import { LRUCache } from 'lru-cache';
export class Cache {
private cache: LRUCache<string, ResolutionDetails<any>>;
private ttl: number;
private enabled: boolean;
constructor(opts: LRUCacheConfig) {
this.cache = new LRUCache({
maxSize: opts.size ?? 1000,
sizeCalculation: () => 1,
});
this.ttl = opts.ttl ?? 300000;
this.enabled = opts.enabled;
}
get(key: string): ResolutionDetails<any> | undefined {
if (!this.enabled) {
return undefined;
}
return this.cache.get(key);
}
set(key: string, value: ResolutionDetails<any>): void {
if (!this.enabled) {
return;
}
this.cache.set(key, value, { ttl: this.ttl });
}
clear() {
if (!this.enabled) {
return;
}
this.cache.clear();
}
}

View File

@ -1,122 +0,0 @@
import { ParseError, StandardResolutionReasons, TypeMismatchError } from '@openfeature/core';
import { SSMService } from './ssm-service';
describe(SSMService.name, () => {
describe(SSMService.prototype.getBooleanValue.name, () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe(`when _getParamFromSSM returns "true"`, () => {
it(`should return a ResolutionDetails with value true`, async () => {
jest
.spyOn(SSMService.prototype, '_getValueFromSSM')
.mockResolvedValue({ val: 'true', metadata: { httpStatusCode: 200 } });
const service = new SSMService({});
const result = await service.getBooleanValue('test');
expect(result).toEqual({
value: true,
reason: StandardResolutionReasons.STATIC,
flagMetadata: { httpStatusCode: 200 },
});
});
});
describe(`when _getParamFromSSM returns "false"`, () => {
it(`should return a ResolutionDetails with value true`, async () => {
jest
.spyOn(SSMService.prototype, '_getValueFromSSM')
.mockResolvedValue({ val: 'false', metadata: { httpStatusCode: 200 } });
const service = new SSMService({});
const result = await service.getBooleanValue('test');
expect(result).toEqual({
value: false,
reason: StandardResolutionReasons.STATIC,
flagMetadata: { httpStatusCode: 200 },
});
});
});
describe(`when _getParamFromSSM returns an invalid value`, () => {
it('should throw a TypeMismatchError', () => {
jest
.spyOn(SSMService.prototype, '_getValueFromSSM')
.mockResolvedValue({ val: 'invalid boolean', metadata: { httpStatusCode: 400 } });
const service = new SSMService({});
expect(() => service.getBooleanValue('test')).rejects.toThrow(TypeMismatchError);
});
});
});
describe(SSMService.prototype.getStringValue.name, () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe(`when _getParamFromSSM returns a valid value`, () => {
it(`should return a ResolutionDetails with that value`, async () => {
jest
.spyOn(SSMService.prototype, '_getValueFromSSM')
.mockResolvedValue({ val: 'example', metadata: { httpStatusCode: 200 } });
const service = new SSMService({});
const result = await service.getStringValue('example');
expect(result).toEqual({
value: 'example',
reason: StandardResolutionReasons.STATIC,
flagMetadata: { httpStatusCode: 200 },
});
});
});
});
describe(SSMService.prototype.getNumberValue.name, () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe(`when _getParamFromSSM returns a valid number`, () => {
it(`should return a ResolutionDetails with value true`, async () => {
jest
.spyOn(SSMService.prototype, '_getValueFromSSM')
.mockResolvedValue({ val: '1478', metadata: { httpStatusCode: 200 } });
const service = new SSMService({});
const result = await service.getNumberValue('test');
expect(result).toEqual({
value: 1478,
reason: StandardResolutionReasons.STATIC,
flagMetadata: { httpStatusCode: 200 },
});
});
});
describe(`when _getParamFromSSM returns a value that is not a number`, () => {
it(`should return a TypeMismatchError`, async () => {
jest
.spyOn(SSMService.prototype, '_getValueFromSSM')
.mockResolvedValue({ val: 'invalid number', metadata: { httpStatusCode: 400 } });
const service = new SSMService({});
expect(() => service.getNumberValue('test')).rejects.toThrow(TypeMismatchError);
});
});
});
describe(SSMService.prototype.getObjectValue.name, () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe(`when _getParamFromSSM returns a valid object`, () => {
it(`should return a ResolutionDetails with that object`, async () => {
jest
.spyOn(SSMService.prototype, '_getValueFromSSM')
.mockResolvedValue({ val: JSON.stringify({ test: true }), metadata: { httpStatusCode: 400 } });
const service = new SSMService({});
const result = await service.getObjectValue('test');
expect(result).toEqual({
value: { test: true },
reason: StandardResolutionReasons.STATIC,
flagMetadata: { httpStatusCode: 400 },
});
});
});
describe(`when _getParamFromSSM returns an invalid object`, () => {
it(`should return a ParseError`, async () => {
jest
.spyOn(SSMService.prototype, '_getValueFromSSM')
.mockResolvedValue({ val: 'invalid object', metadata: { httpStatusCode: 400 } });
const service = new SSMService({});
expect(() => service.getObjectValue('test')).rejects.toThrow(ParseError);
});
});
});
});

View File

@ -1,118 +0,0 @@
import type { SSMClientConfig, GetParameterCommandInput } from '@aws-sdk/client-ssm';
import { GetParameterCommand, SSMClient, DescribeParametersCommand } from '@aws-sdk/client-ssm';
import type { ResponseMetadata } from '@smithy/types';
import type { JsonValue, ResolutionDetails } from '@openfeature/core';
import { FlagNotFoundError, TypeMismatchError, ParseError, StandardResolutionReasons } from '@openfeature/core';
export class SSMService {
client: SSMClient;
enableDecryption: boolean;
constructor(config: SSMClientConfig, enableDecryption?: boolean) {
this.client = new SSMClient(config);
this.enableDecryption = enableDecryption ?? false;
}
async getBooleanValue(name: string): Promise<ResolutionDetails<boolean>> {
const res = await this._getValueFromSSM(name);
const { val, metadata } = res;
let result: boolean;
switch (val) {
case 'true':
result = true;
break;
case 'false':
result = false;
break;
default:
throw new TypeMismatchError(`${val} is not a valid boolean value`);
}
return {
value: result,
reason: StandardResolutionReasons.STATIC,
flagMetadata: { ...metadata },
};
}
async getStringValue(name: string): Promise<ResolutionDetails<string>> {
const res = await this._getValueFromSSM(name);
const { val, metadata } = res;
return {
value: val,
reason: StandardResolutionReasons.STATIC,
flagMetadata: { ...metadata },
};
}
async getNumberValue(name: string): Promise<ResolutionDetails<number>> {
const res = await this._getValueFromSSM(name);
const { val, metadata } = res;
if (Number.isNaN(Number(val))) {
throw new TypeMismatchError(`${val} is not a number`);
}
return {
value: Number(val),
reason: StandardResolutionReasons.STATIC,
flagMetadata: { ...metadata },
};
}
async getObjectValue<U extends JsonValue>(name: string): Promise<ResolutionDetails<U>> {
const res = await this._getValueFromSSM(name);
const { val, metadata } = res;
try {
return {
value: JSON.parse(val),
reason: StandardResolutionReasons.STATIC,
flagMetadata: { ...metadata },
};
} catch (e) {
throw new ParseError(`Unable to parse value as JSON: ${e}`);
}
}
async _isSecureString(name: string): Promise<boolean> {
const res = await this.client.send(
new DescribeParametersCommand({
ParameterFilters: [
{
Key: 'Name',
Values: [name],
},
],
}),
);
if (!res.Parameters) {
throw new FlagNotFoundError(`Unable to find an SSM Parameter with key ${name}`);
}
return res.Parameters[0].Type === 'SecureString';
}
async _getValueFromSSM(name: string): Promise<{ val: string; metadata: ResponseMetadata }> {
const param: GetParameterCommandInput = {
Name: name,
};
if (this.enableDecryption) {
param.WithDecryption = await this._isSecureString(name);
}
const command: GetParameterCommand = new GetParameterCommand(param);
const res = await this.client.send(command);
if (!res.Parameter) {
throw new FlagNotFoundError(`Unable to find an SSM Parameter with key ${name}`);
}
if (!res.Parameter.Value) {
throw new ParseError(`Value is empty`);
}
return { val: res.Parameter.Value, metadata: res.$metadata };
}
}

View File

@ -1,13 +0,0 @@
import type { SSMClientConfig } from '@aws-sdk/client-ssm';
export type AwsSsmProviderConfig = {
ssmClientConfig: SSMClientConfig;
cacheOpts: LRUCacheConfig;
enableDecryption?: boolean;
};
export type LRUCacheConfig = {
enabled: boolean;
ttl?: number;
size?: number;
};

View File

@ -1,22 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"module": "ES6",
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@ -1,10 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../dist/out-tsc",
"declaration": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"]
}

View File

@ -1,10 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../dist/out-tsc",
"module": "commonjs",
"moduleResolution": "node10",
"types": ["jest", "node"]
},
"include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"]
}

View File

@ -1,60 +0,0 @@
# Changelog
## [0.1.6](https://github.com/open-feature/js-sdk-contrib/compare/config-cat-web-provider-v0.1.5...config-cat-web-provider-v0.1.6) (2025-04-09)
### 🐛 Bug Fixes
* **config-cat:** Rework error reporting ([#1242](https://github.com/open-feature/js-sdk-contrib/issues/1242)) ([0425619](https://github.com/open-feature/js-sdk-contrib/commit/04256197bf6e7da70afd4ac1c31bdaf55ce4b789))
## [0.1.5](https://github.com/open-feature/js-sdk-contrib/compare/config-cat-web-provider-v0.1.4...config-cat-web-provider-v0.1.5) (2025-03-14)
### 🧹 Chore
* bump the required core version ([1408397](https://github.com/open-feature/js-sdk-contrib/commit/140839777b5cff8e624b23fc9eb2f8d2f4a977cb))
## [0.1.4](https://github.com/open-feature/js-sdk-contrib/compare/config-cat-web-provider-v0.1.3...config-cat-web-provider-v0.1.4) (2025-03-04)
### 🐛 Bug Fixes
* **config-cat:** Forward default value to underlying client ([#1214](https://github.com/open-feature/js-sdk-contrib/issues/1214)) ([9d14173](https://github.com/open-feature/js-sdk-contrib/commit/9d14173cf08da3030fc58fea8786b24bafd80403))
### 🧹 Chore
* update nx packages ([#1147](https://github.com/open-feature/js-sdk-contrib/issues/1147)) ([7f310fe](https://github.com/open-feature/js-sdk-contrib/commit/7f310fe87101b8aa793e1436e63c7602ccc202e3))
## [0.1.3](https://github.com/open-feature/js-sdk-contrib/compare/config-cat-web-provider-v0.1.2...config-cat-web-provider-v0.1.3) (2024-09-20)
### 🐛 Bug Fixes
* **config-cat-web:** Fix code examples in README.md ([#1050](https://github.com/open-feature/js-sdk-contrib/issues/1050)) ([0b6179b](https://github.com/open-feature/js-sdk-contrib/commit/0b6179b9cb16cce592be6c2fbe86dbacce5adc1f))
* **config-cat:** Revise readme ([#1054](https://github.com/open-feature/js-sdk-contrib/issues/1054)) ([7e1dd72](https://github.com/open-feature/js-sdk-contrib/commit/7e1dd72a1450a9982b340afda62d34379d1b3f16))
## [0.1.2](https://github.com/open-feature/js-sdk-contrib/compare/config-cat-web-provider-v0.1.1...config-cat-web-provider-v0.1.2) (2024-08-22)
### 🐛 Bug Fixes
* **config-cat-web:** Update dependency configcat-js-ssr to v8.4.2 ([#1041](https://github.com/open-feature/js-sdk-contrib/issues/1041)) ([55e554d](https://github.com/open-feature/js-sdk-contrib/commit/55e554d9fc9966d7d2b364da4776c478a2ba9bb1))
## [0.1.1](https://github.com/open-feature/js-sdk-contrib/compare/config-cat-web-provider-v0.1.0...config-cat-web-provider-v0.1.1) (2024-07-28)
### 📚 Documentation
* A few corrections to ConfigCat providers' README.md ([#1014](https://github.com/open-feature/js-sdk-contrib/issues/1014)) ([3b24653](https://github.com/open-feature/js-sdk-contrib/commit/3b24653854643c827bddccb12aeb59e61204202d))
## 0.1.0 (2024-07-21)
### ⚠ BREAKING CHANGES
* implement config cat web provider ([#918](https://github.com/open-feature/js-sdk-contrib/issues/918))
### ✨ New Features
* implement config cat web provider ([#918](https://github.com/open-feature/js-sdk-contrib/issues/918)) ([e280014](https://github.com/open-feature/js-sdk-contrib/commit/e280014f8998dd2e5f2b7700f0d24842eeafab5f))

View File

@ -1,160 +0,0 @@
# ConfigCat Web Provider
This is an OpenFeature provider implementation for using [ConfigCat](https://configcat.com), a managed feature flag service in JavaScript frontend applications.
## Installation
```
$ npm install @openfeature/config-cat-web-provider
```
#### Required peer dependencies
The OpenFeature SDK is required as peer dependency.
The minimum required version of `@openfeature/web-sdk` currently is `1.0.0`.
The minimum required version of `configcat-js-ssr` currently is `8.4.3`.
```
$ npm install @openfeature/web-sdk configcat-js-ssr
```
## Usage
The ConfigCat provider uses the [ConfigCat JavaScript SSR SDK](https://configcat.com/docs/sdk-reference/js-ssr/).
It can be created by passing the ConfigCat SDK options to ```ConfigCatWebProvider.create```.
The available options can be found in the [ConfigCat JavaScript SSR SDK](https://configcat.com/docs/sdk-reference/js-ssr/#creating-the-configcat-client).
The ConfigCat Web Provider only supports the `AutoPolling` mode because it caches all evaluation data to support synchronous evaluation of feature flags.
### Example using the default configuration
```javascript
import { OpenFeature } from "@openfeature/web-sdk";
import { ConfigCatWebProvider } from '@openfeature/config-cat-web-provider';
// Create and set the provider.
const provider = ConfigCatWebProvider.create('<sdk_key>');
await OpenFeature.setProviderAndWait(provider);
// Create a client instance to evaluate feature flags.
const client = OpenFeature.getClient();
const value = await client.getBooleanValue('isAwesomeFeatureEnabled', false);
console.log(`isAwesomeFeatureEnabled: ${value}`);
// On application shutdown, clean up the OpenFeature provider and the underlying ConfigCat client.
await OpenFeature.clearProviders();
```
### Example using custom configuration
```javascript
import { OpenFeature } from "@openfeature/web-sdk";
import { ConfigCatWebProvider } from '@openfeature/config-cat-web-provider';
import { createConsoleLogger, LogLevel } from 'configcat-js-ssr';
// Create and set the provider.
const provider = ConfigCatWebProvider.create('<sdk_key>', {
logger: createConsoleLogger(LogLevel.Info),
setupHooks: (hooks) => hooks.on('clientReady', () => console.log('Client is ready!')),
});
await OpenFeature.setProviderAndWait(provider);
// ...
```
## Evaluation Context
The OpenFeature Evaluation Context is mapped to the [ConfigCat User Object](https://configcat.com/docs/advanced/user-object/).
The [ConfigCat User Object](https://configcat.com/docs/advanced/user-object/) has three predefined attributes,
and allows for additional attributes.
The following shows how the attributes are mapped:
| OpenFeature EvaluationContext Field | ConfigCat User Field | Required |
|-------------------------------------|----------------------|----------|
| targetingKey | identifier | yes |
| email | email | no |
| country | country | no |
| _Any Other_ | custom | no |
The custom types are mapped the following way:
| OpenFeature EvaluationContext Field Type | ConfigCat User Field Type |
|------------------------------------------|---------------------------|
| string | string |
| number | number |
| boolean | string |
| Array<string> | Array<string> |
| Array | Array |
| object | string |
The following example shows the conversion between an OpenFeature Evaluation Context and the corresponding ConfigCat
User:
#### OpenFeature
```json
{
"targetingKey": "test",
"email": "email",
"country": "country",
"customString": "customString",
"customNumber": 1,
"customBoolean": true,
"customObject": {
"prop1": "1",
"prop2": 2
},
"customStringArray": [
"one",
"two"
],
"customArray": [
1,
"2",
false
]
}
```
#### ConfigCat
```json
{
"identifier": "test",
"email": "email",
"country": "country",
"custom": {
"customString": "customString",
"customBoolean": "true",
"customNumber": 1,
"customObject": "{\"prop1\":\"1\",\"prop2\":2}",
"customStringArray": [
"one",
"two"
],
"customArray": "[1,\"2\",false]"
}
}
```
## Events
The ConfigCat provider emits the
following [OpenFeature events](https://openfeature.dev/specification/types#provider-events):
- PROVIDER_READY
- PROVIDER_ERROR
- PROVIDER_CONFIGURATION_CHANGED
## Building
Run `nx package providers-config-cat-web` to build the library.
## Running unit tests
Run `nx test providers-config-cat-web` to execute the unit tests via [Jest](https://jestjs.io).

View File

@ -1,3 +0,0 @@
{
"presets": [["minify", { "builtIns": false }]]
}

View File

@ -1,10 +0,0 @@
/* eslint-disable */
export default {
displayName: 'providers-config-cat-web',
preset: '../../../jest.preset.js',
transform: {
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../../coverage/libs/providers/config-cat',
};

View File

@ -1,156 +0,0 @@
{
"name": "@openfeature/config-cat-web-provider",
"version": "0.1.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openfeature/config-cat-web-provider",
"version": "0.1.6",
"peerDependencies": {
"@openfeature/web-sdk": "^1.0.0",
"configcat-js-ssr": "^8.4.3"
}
},
"node_modules/@openfeature/core": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@openfeature/core/-/core-1.2.0.tgz",
"integrity": "sha512-JyIiije5f+8Big1xz7UAmxqVmHBuFUI9Dh8DEFG2D1ocgjMm1tEzYXJDr3urCQGNnX9M/cYtNhEcGfyontIgJw==",
"peer": true
},
"node_modules/@openfeature/web-sdk": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@openfeature/web-sdk/-/web-sdk-1.1.0.tgz",
"integrity": "sha512-qfJBWVN0AzYGoZZUE4w4LrQc3Oq3MWaUys+bkBjkgyFFDJM4TrgRz+wz/f3TwRVKj2Bc0EZ0ouyfupdWjR7bsQ==",
"peer": true,
"peerDependencies": {
"@openfeature/core": "1.2.0"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"peer": true
},
"node_modules/axios": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz",
"integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==",
"peer": true,
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"peer": true,
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/configcat-common": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/configcat-common/-/configcat-common-9.3.1.tgz",
"integrity": "sha512-yVkIbluksD/kZfVyKjLIOpwLrq3/ZRM7Lwrsz89JmbpQ6VtbnelrTQynSPElTtKjrPRZx56v3IZYk3nWTnnM6A==",
"peer": true,
"dependencies": {
"tslib": "^2.4.1"
}
},
"node_modules/configcat-js-ssr": {
"version": "8.4.3",
"resolved": "https://registry.npmjs.org/configcat-js-ssr/-/configcat-js-ssr-8.4.3.tgz",
"integrity": "sha512-9tNM61cgJOE9C1MO8wBK1QglrnlT8VpiAW/KgGdFdOuIPs3ky62EThgAE+HYSRYEv4JrRNB4i7G0v1Qgbf18Hw==",
"peer": true,
"dependencies": {
"axios": "^1.7.4",
"configcat-common": "9.3.1",
"tslib": "^2.4.1"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"peer": true,
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/follow-redirects": {
"version": "1.15.6",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz",
"integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"peer": true,
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"peer": true,
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"peer": true,
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"peer": true,
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"peer": true
},
"node_modules/tslib": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz",
"integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==",
"peer": true
}
}
}

View File

@ -1,14 +0,0 @@
{
"name": "@openfeature/config-cat-web-provider",
"version": "0.1.6",
"license": "Apache-2.0",
"scripts": {
"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",
"current-version": "echo $npm_package_version"
},
"peerDependencies": {
"@openfeature/web-sdk": "^1.0.0",
"configcat-js-ssr": "^8.4.3",
"@openfeature/config-cat-core": "0.1.1"
}
}

View File

@ -1,64 +0,0 @@
{
"name": "providers-config-cat-web",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/providers/config-cat-web/src",
"projectType": "library",
"targets": {
"publish": {
"executor": "nx:run-commands",
"options": {
"command": "npm run publish-if-not-exists",
"cwd": "dist/libs/providers/config-cat-web"
},
"dependsOn": [
{
"target": "package"
}
]
},
"lint": {
"executor": "@nx/eslint:lint",
"outputs": ["{options.outputFile}"]
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/providers/config-cat-web/jest.config.ts"
}
},
"package": {
"executor": "@nx/rollup:rollup",
"outputs": ["{options.outputPath}"],
"options": {
"project": "libs/providers/config-cat-web/package.json",
"outputPath": "dist/libs/providers/config-cat-web",
"entryFile": "libs/providers/config-cat-web/src/index.ts",
"tsConfig": "libs/providers/config-cat-web/tsconfig.lib.json",
"compiler": "tsc",
"generateExportsField": true,
"umdName": "config-cat",
"external": "all",
"format": ["cjs", "esm"],
"assets": [
{
"glob": "package.json",
"input": "./assets",
"output": "./src/"
},
{
"glob": "LICENSE",
"input": "./",
"output": "./"
},
{
"glob": "README.md",
"input": "./libs/providers/config-cat-web",
"output": "./"
}
]
}
}
},
"tags": []
}

View File

@ -1 +0,0 @@
export * from './lib/config-cat-web-provider';

View File

@ -1,191 +0,0 @@
import { ConfigCatWebProvider } from './config-cat-web-provider';
import type { HookEvents, IConfigCatCache, ISettingUnion } from 'configcat-js-ssr';
import { createConsoleLogger, createFlagOverridesFromMap, LogLevel, OverrideBehaviour } from 'configcat-js-ssr';
import type { EventEmitter } from 'events';
import { ProviderEvents, ParseError, FlagNotFoundError, TypeMismatchError } from '@openfeature/web-sdk';
describe('ConfigCatWebProvider', () => {
const targetingKey = 'abc';
let provider: ConfigCatWebProvider;
let configCatEmitter: EventEmitter<HookEvents>;
const values = {
booleanFalse: false,
booleanTrue: true,
number1: 1,
number2: 2,
stringTest: 'Test',
jsonValid: JSON.stringify({ valid: true }),
jsonInvalid: '{test:123',
jsonPrimitive: JSON.stringify(123),
};
beforeAll(async () => {
provider = ConfigCatWebProvider.create('__key__', {
logger: createConsoleLogger(LogLevel.Off),
offline: true,
flagOverrides: createFlagOverridesFromMap(values, OverrideBehaviour.LocalOnly),
});
await provider.initialize();
// Currently there is no option to get access to the event emitter
// eslint-disable-next-line @typescript-eslint/no-explicit-any
configCatEmitter = (provider.configCatClient as any).options.hooks;
});
afterAll(async () => {
await provider.onClose();
});
it('should be an instance of ConfigCatWebProvider', () => {
expect(provider).toBeInstanceOf(ConfigCatWebProvider);
});
it('should dispose the configcat client on provider closing', async () => {
const newProvider = ConfigCatWebProvider.create('__another_key__', {
logger: createConsoleLogger(LogLevel.Off),
offline: true,
flagOverrides: createFlagOverridesFromMap(values, OverrideBehaviour.LocalOnly),
});
await newProvider.initialize();
if (!newProvider.configCatClient) {
throw Error('No ConfigCat client');
}
const clientDisposeSpy = jest.spyOn(newProvider.configCatClient, 'dispose');
await newProvider.onClose();
expect(clientDisposeSpy).toHaveBeenCalled();
});
describe('events', () => {
it('should emit PROVIDER_CONFIGURATION_CHANGED event', () => {
const handler = jest.fn();
const eventData = { settings: { myFlag: {} as ISettingUnion }, salt: undefined, segments: [] };
provider.events.addHandler(ProviderEvents.ConfigurationChanged, handler);
configCatEmitter.emit('configChanged', eventData);
expect(handler).toHaveBeenCalledWith({
flagsChanged: ['myFlag'],
});
});
it("should emit PROVIDER_READY event when underlying client is initialized after provider's initialize", async () => {
const cacheValue = '253370761200000\nW/"12345678-90a"\n{"f":{"booleanTrue":{"t":0,"v":{"b":true}}}}';
const fakeSharedCache = new (class implements IConfigCatCache {
private _value?: string;
get(key: string) {
return this._value;
}
set(key: string, value: string) {
this._value = value;
}
})();
const provider = ConfigCatWebProvider.create('configcat-sdk-1/1234567890123456789012/1234567890123456789012', {
cache: fakeSharedCache,
logger: createConsoleLogger(LogLevel.Off),
offline: true,
maxInitWaitTimeSeconds: 1,
});
const readyHandler = jest.fn();
provider.events.addHandler(ProviderEvents.Ready, readyHandler);
try {
await provider.initialize();
} catch (err) {
expect((err as Error).message).toContain('underlying ConfigCat client could not initialize');
}
expect(readyHandler).toHaveBeenCalledTimes(0);
fakeSharedCache.set('', cacheValue);
// Make sure that the internal cache is refreshed.
await provider.configCatClient?.forceRefreshAsync();
provider.resolveBooleanEvaluation('booleanTrue', false, { targetingKey });
// Wait a little while for the Ready event to be emitted.
await new Promise((resolve) => setTimeout(resolve, 100));
expect(readyHandler).toHaveBeenCalled();
});
});
describe('method resolveBooleanEvaluation', () => {
it('should throw FlagNotFoundError if type is different than expected', () => {
expect(() => provider.resolveBooleanEvaluation('nonExistent', false, { targetingKey })).toThrow(
FlagNotFoundError,
);
});
it('should return right value if key exists', () => {
const value = provider.resolveBooleanEvaluation('booleanTrue', false, { targetingKey });
expect(value).toHaveProperty('value', values.booleanTrue);
});
it('should throw TypeMismatchError if type is different than expected', () => {
expect(() => provider.resolveBooleanEvaluation('number1', false, { targetingKey })).toThrow(TypeMismatchError);
});
});
describe('method resolveStringEvaluation', () => {
it('should throw FlagNotFoundError if type is different than expected', async () => {
expect(() => provider.resolveStringEvaluation('nonExistent', 'nonExistent', { targetingKey })).toThrow(
FlagNotFoundError,
);
});
it('should return right value if key exists', () => {
const value = provider.resolveStringEvaluation('stringTest', 'default', { targetingKey });
expect(value).toHaveProperty('value', values.stringTest);
});
it('should throw TypeMismatchError if type is different than expected', async () => {
expect(() => provider.resolveStringEvaluation('number1', 'default', { targetingKey })).toThrow(TypeMismatchError);
});
});
describe('method resolveNumberEvaluation', () => {
it('should throw FlagNotFoundError if type is different than expected', async () => {
expect(() => provider.resolveNumberEvaluation('nonExistent', 0, { targetingKey })).toThrow(FlagNotFoundError);
});
it('should return right value if key exists', () => {
const value = provider.resolveNumberEvaluation('number1', 0, { targetingKey });
expect(value).toHaveProperty('value', values.number1);
});
it('should throw TypeMismatchError if type is different than expected', () => {
expect(() => provider.resolveNumberEvaluation('stringTest', 0, { targetingKey })).toThrow(TypeMismatchError);
});
});
describe('method resolveObjectEvaluation', () => {
it('should throw FlagNotFoundError if type is different than expected', () => {
expect(() => provider.resolveObjectEvaluation('nonExistent', false, { targetingKey })).toThrow(FlagNotFoundError);
});
it('should return right value if key exists', () => {
const value = provider.resolveObjectEvaluation('jsonValid', {}, { targetingKey });
expect(value).toHaveProperty('value', JSON.parse(values.jsonValid));
});
it('should throw ParseError if string is not valid JSON', () => {
expect(() => provider.resolveObjectEvaluation('jsonInvalid', {}, { targetingKey })).toThrow(ParseError);
});
it('should return right value if key exists and value is only a JSON primitive', () => {
const value = provider.resolveObjectEvaluation('jsonPrimitive', {}, { targetingKey });
expect(value).toHaveProperty('value', JSON.parse(values.jsonPrimitive));
});
});
});

View File

@ -1,167 +0,0 @@
import type { EvaluationContext, JsonValue, Paradigm, Provider, ResolutionDetails } from '@openfeature/web-sdk';
import {
OpenFeatureEventEmitter,
ParseError,
ProviderEvents,
ProviderNotReadyError,
TypeMismatchError,
} from '@openfeature/web-sdk';
import type { PrimitiveType, PrimitiveTypeName } from '@openfeature/config-cat-core';
import { isType, parseError, toResolutionDetails, transformContext } from '@openfeature/config-cat-core';
import type { IConfig, IConfigCatClient, OptionsForPollingMode, SettingValue } from 'configcat-js-ssr';
import { ClientCacheState, getClient, PollingMode } from 'configcat-js-ssr';
export class ConfigCatWebProvider implements Provider {
public readonly events = new OpenFeatureEventEmitter();
private readonly _clientFactory: (provider: ConfigCatWebProvider) => IConfigCatClient;
private _isProviderReady = false;
private _client?: IConfigCatClient;
public runsOn: Paradigm = 'client';
public metadata = {
name: ConfigCatWebProvider.name,
};
protected constructor(clientFactory: (provider: ConfigCatWebProvider) => IConfigCatClient) {
this._clientFactory = clientFactory;
}
public static create(sdkKey: string, options?: OptionsForPollingMode<PollingMode.AutoPoll>) {
// Let's create a shallow copy to not mess up caller's options object.
options = options ? { ...options } : {};
return new ConfigCatWebProvider((provider) => {
const oldSetupHooks = options?.setupHooks;
options.setupHooks = (hooks) => {
oldSetupHooks?.(hooks);
hooks.on('configChanged', (config: IConfig) =>
provider.events.emit(ProviderEvents.ConfigurationChanged, {
flagsChanged: Object.keys(config.settings),
}),
);
};
return getClient(sdkKey, PollingMode.AutoPoll, options);
});
}
public async initialize(): Promise<void> {
const client = this._clientFactory(this);
const clientCacheState = await client.waitForReady();
this._client = client;
if (clientCacheState !== ClientCacheState.NoFlagData) {
this._isProviderReady = true;
} else {
// OpenFeature provider defines ready state like this: "The provider is ready to resolve flags."
// However, ConfigCat client's behavior is different: in some cases ready state may be reached
// even if the client's internal, in-memory cache hasn't been populated yet, that is,
// the client is not able to evaluate feature flags yet. In such cases we throw an error to
// prevent the provider from being set ready right away, and check for the ready state later.
throw Error('The underlying ConfigCat client could not initialize within maxInitWaitTimeSeconds.');
}
}
public get configCatClient() {
return this._client;
}
public async onClose(): Promise<void> {
this._client?.dispose();
}
public resolveBooleanEvaluation(
flagKey: string,
defaultValue: boolean,
context: EvaluationContext,
): ResolutionDetails<boolean> {
return this.evaluate(flagKey, 'boolean', defaultValue, context);
}
public resolveStringEvaluation(
flagKey: string,
defaultValue: string,
context: EvaluationContext,
): ResolutionDetails<string> {
return this.evaluate(flagKey, 'string', defaultValue, context);
}
public resolveNumberEvaluation(
flagKey: string,
defaultValue: number,
context: EvaluationContext,
): ResolutionDetails<number> {
return this.evaluate(flagKey, 'number', defaultValue, context);
}
public resolveObjectEvaluation<U extends JsonValue>(
flagKey: string,
defaultValue: U,
context: EvaluationContext,
): ResolutionDetails<U> {
const objectValue = this.evaluate(flagKey, 'object', defaultValue, context);
return objectValue as ResolutionDetails<U>;
}
protected evaluate<T extends PrimitiveTypeName>(
flagKey: string,
flagType: T,
defaultValue: PrimitiveType<T>,
context: EvaluationContext,
): ResolutionDetails<PrimitiveType<T>> {
if (!this._client) {
throw new ProviderNotReadyError('Provider is not initialized');
}
// Make sure that the user-provided `defaultValue` is compatible with `flagType` as there is
// no guarantee that it actually is. (User may bypass type checking or may not use TypeScript at all.)
if (!isType(flagType, defaultValue)) {
throw new TypeMismatchError();
}
const configCatDefaultValue = flagType !== 'object' ? (defaultValue as SettingValue) : JSON.stringify(defaultValue);
const snapshot = this._client.snapshot();
const { value, ...evaluationData } = snapshot.getValueDetails(
flagKey,
configCatDefaultValue,
transformContext(context),
);
if (!this._isProviderReady && snapshot.cacheState !== ClientCacheState.NoFlagData) {
// Ideally, we would check ConfigCat client's initialization state in its "background" polling loop.
// This is not possible at the moment, so as a workaround, we do the check on feature flag evaluation.
// There are plans to improve this situation, so let's revise this
// as soon as ConfigCat SDK implements the necessary event.
this._isProviderReady = true;
setTimeout(() => this.events.emit(ProviderEvents.Ready), 0);
}
if (evaluationData.isDefaultValue) {
throw parseError(evaluationData.errorMessage);
}
if (flagType !== 'object') {
// When `flagType` (more precisely, `configCatDefaultValue`) is boolean, string or number,
// ConfigCat SDK guarantees that the returned `value` is compatible with `PrimitiveType<T>`.
// See also: https://configcat.com/docs/sdk-reference/js-ssr/#setting-type-mapping
return toResolutionDetails(value as PrimitiveType<T>, evaluationData);
}
let json: JsonValue;
try {
// In this case we can be sure that `value` is string since `configCatDefaultValue` is string,
// which means that ConfigCat SDK is guaranteed to return a string value.
json = JSON.parse(value as string);
} catch (e) {
throw new ParseError(`Unable to parse "${value}" as JSON`);
}
return toResolutionDetails(json as PrimitiveType<T>, evaluationData);
}
}

View File

@ -1,21 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"module": "ES6",
"forceConsistentCasingInFileNames": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@ -1,134 +1,5 @@
# Changelog
## [0.7.6](https://github.com/open-feature/js-sdk-contrib/compare/config-cat-provider-v0.7.5...config-cat-provider-v0.7.6) (2025-07-04)
### 🐛 Bug Fixes
* **security:** update dependency configcat-common to v9.4.0 ([#1348](https://github.com/open-feature/js-sdk-contrib/issues/1348)) ([601e7de](https://github.com/open-feature/js-sdk-contrib/commit/601e7de19948bc826778a076f27b46a8cb1fabca))
## [0.7.5](https://github.com/open-feature/js-sdk-contrib/compare/config-cat-provider-v0.7.4...config-cat-provider-v0.7.5) (2025-04-09)
### 🐛 Bug Fixes
* **config-cat:** Rework error reporting ([#1242](https://github.com/open-feature/js-sdk-contrib/issues/1242)) ([0425619](https://github.com/open-feature/js-sdk-contrib/commit/04256197bf6e7da70afd4ac1c31bdaf55ce4b789))
## [0.7.4](https://github.com/open-feature/js-sdk-contrib/compare/config-cat-provider-v0.7.3...config-cat-provider-v0.7.4) (2025-03-14)
### 🧹 Chore
* bump the required core version ([d8fc42f](https://github.com/open-feature/js-sdk-contrib/commit/d8fc42f5d23f30f011a697610e65d83144c19fca))
## [0.7.3](https://github.com/open-feature/js-sdk-contrib/compare/config-cat-provider-v0.7.2...config-cat-provider-v0.7.3) (2025-03-04)
### 🐛 Bug Fixes
* **config-cat:** Forward default value to underlying client ([#1214](https://github.com/open-feature/js-sdk-contrib/issues/1214)) ([9d14173](https://github.com/open-feature/js-sdk-contrib/commit/9d14173cf08da3030fc58fea8786b24bafd80403))
### 🧹 Chore
* update nx packages ([#1147](https://github.com/open-feature/js-sdk-contrib/issues/1147)) ([7f310fe](https://github.com/open-feature/js-sdk-contrib/commit/7f310fe87101b8aa793e1436e63c7602ccc202e3))
## [0.7.2](https://github.com/open-feature/js-sdk-contrib/compare/config-cat-provider-v0.7.1...config-cat-provider-v0.7.2) (2024-09-20)
### 🐛 Bug Fixes
* **config-cat:** Revise readme ([#1054](https://github.com/open-feature/js-sdk-contrib/issues/1054)) ([7e1dd72](https://github.com/open-feature/js-sdk-contrib/commit/7e1dd72a1450a9982b340afda62d34379d1b3f16))
## [0.7.1](https://github.com/open-feature/js-sdk-contrib/compare/config-cat-provider-v0.7.0...config-cat-provider-v0.7.1) (2024-07-23)
### 📚 Documentation
* A few corrections to ConfigCat providers' README.md ([#1014](https://github.com/open-feature/js-sdk-contrib/issues/1014)) ([3b24653](https://github.com/open-feature/js-sdk-contrib/commit/3b24653854643c827bddccb12aeb59e61204202d))
## [0.7.0](https://github.com/open-feature/js-sdk-contrib/compare/config-cat-provider-v0.6.1...config-cat-provider-v0.7.0) (2024-07-21)
### ⚠ BREAKING CHANGES
* make interface similar to config-cat-web ([#918](https://github.com/open-feature/js-sdk-contrib/issues/918))
### ✨ New Features
* make interface similar to config-cat-web ([#918](https://github.com/open-feature/js-sdk-contrib/issues/918)) ([e280014](https://github.com/open-feature/js-sdk-contrib/commit/e280014f8998dd2e5f2b7700f0d24842eeafab5f))
## [0.6.1](https://github.com/open-feature/js-sdk-contrib/compare/config-cat-provider-v0.6.0...config-cat-provider-v0.6.1) (2024-04-05)
### 🐛 Bug Fixes
* **config-cat:** remove event emitter type import from config-cat ([#830](https://github.com/open-feature/js-sdk-contrib/issues/830)) ([1c76f63](https://github.com/open-feature/js-sdk-contrib/commit/1c76f63db0c8992325ac645d308e90337529e049))
### 🧹 Chore
* Lint Fix Providers ([#837](https://github.com/open-feature/js-sdk-contrib/issues/837)) ([8c6c46b](https://github.com/open-feature/js-sdk-contrib/commit/8c6c46b5f8f72c5a292af7e5ff8ad8d710982554))
* lock configcat dep ([#827](https://github.com/open-feature/js-sdk-contrib/issues/827)) ([28f25a2](https://github.com/open-feature/js-sdk-contrib/commit/28f25a25cfc6ba3262472c7bad061ae3b256aba3))
## [0.6.0](https://github.com/open-feature/js-sdk-contrib/compare/config-cat-provider-v0.5.0...config-cat-provider-v0.6.0) (2024-03-16)
### ⚠ BREAKING CHANGES
* update OpenFeature SDK peer ([#798](https://github.com/open-feature/js-sdk-contrib/issues/798))
### ✨ New Features
* update OpenFeature SDK peer ([#798](https://github.com/open-feature/js-sdk-contrib/issues/798)) ([ebd16b9](https://github.com/open-feature/js-sdk-contrib/commit/ebd16b9630bcc6b253a7061a144e8d476cd8b586))
## [0.5.0](https://github.com/open-feature/js-sdk-contrib/compare/config-cat-provider-v0.4.0...config-cat-provider-v0.5.0) (2024-03-14)
### ⚠ BREAKING CHANGES
* Allow ConfigCat provider to be used in server applications ([#796](https://github.com/open-feature/js-sdk-contrib/issues/796))
### 🐛 Bug Fixes
* Allow ConfigCat provider to be used in server applications ([#796](https://github.com/open-feature/js-sdk-contrib/issues/796)) ([190946f](https://github.com/open-feature/js-sdk-contrib/commit/190946ff83ede64f513d43a1791cc4dc274b0d37))
* **deps:** update dependency configcat-js to v9 ([#664](https://github.com/open-feature/js-sdk-contrib/issues/664)) ([6fdf552](https://github.com/open-feature/js-sdk-contrib/commit/6fdf55256cc3238fdeb9bd2bf0cde0bf494a78f9))
### 🧹 Chore
* address lint issues ([#642](https://github.com/open-feature/js-sdk-contrib/issues/642)) ([bbd9aee](https://github.com/open-feature/js-sdk-contrib/commit/bbd9aee896dc4a0817f379b799a1b8d331ee76c6))
* fix lint issues and bump server sdk version ([#715](https://github.com/open-feature/js-sdk-contrib/issues/715)) ([bd57177](https://github.com/open-feature/js-sdk-contrib/commit/bd571770f3a1a01bd62663dc3473273449f96c5c))
## [0.4.0](https://github.com/open-feature/js-sdk-contrib/compare/config-cat-provider-v0.3.0...config-cat-provider-v0.4.0) (2023-10-11)
### ⚠ BREAKING CHANGES
* use @openfeature/server-sdk peer ([#608](https://github.com/open-feature/js-sdk-contrib/issues/608))
### 🐛 Bug Fixes
* packaging issues impacting babel/react ([#596](https://github.com/open-feature/js-sdk-contrib/issues/596)) ([0446eab](https://github.com/open-feature/js-sdk-contrib/commit/0446eab5cf9b45ce7de251b4f5feb8df1d499b9d))
### 🧹 Chore
* update nx, run migrations ([#552](https://github.com/open-feature/js-sdk-contrib/issues/552)) ([a88d8fc](https://github.com/open-feature/js-sdk-contrib/commit/a88d8fc097789fd7f56011e6ebb66070f52c6e56))
* use @openfeature/server-sdk peer ([#608](https://github.com/open-feature/js-sdk-contrib/issues/608)) ([ae3732a](https://github.com/open-feature/js-sdk-contrib/commit/ae3732a9068f684517db28ea1ae27b29a35e6b16))
## [0.3.0](https://github.com/open-feature/js-sdk-contrib/compare/config-cat-provider-v0.2.0...config-cat-provider-v0.3.0) (2023-07-27)
### ⚠ BREAKING CHANGES
* remove configcat 7 peer dependency ([#435](https://github.com/open-feature/js-sdk-contrib/issues/435))
### 🐛 Bug Fixes
* **config-cat:** add status flag to provider ([#491](https://github.com/open-feature/js-sdk-contrib/issues/491)) ([f599c31](https://github.com/open-feature/js-sdk-contrib/commit/f599c3145881b81107c9a65b2c4cfe2a8b4111f1))
* remove configcat 7 peer dependency ([#435](https://github.com/open-feature/js-sdk-contrib/issues/435)) ([da5d212](https://github.com/open-feature/js-sdk-contrib/commit/da5d21208e8929f7cdfc805e256cb892968bcd95))
## [0.2.0](https://github.com/open-feature/js-sdk-contrib/compare/config-cat-provider-v0.1.1...config-cat-provider-v0.2.0) (2023-06-27)

View File

@ -1,6 +1,6 @@
# ConfigCat Provider
This is an OpenFeature provider implementation for using [ConfigCat](https://configcat.com), a managed feature flag service in Node.js applications.
This provider is an implementation for [ConfigCat](https://configcat.com) a managed feature flag service.
## Installation
@ -12,65 +12,53 @@ $ npm install @openfeature/config-cat-provider
The OpenFeature SDK is required as peer dependency.
The minimum required version of `@openfeature/server-sdk` currently is `1.13.5`.
The minimum required version of `@openfeature/js-sdk` currently is `1.3.0`.
The minimum required version of `configcat-node` currently is `11.3.1`.
The minimum required version of `configcat-js` currently is `8.0.0`.
```
$ npm install @openfeature/server-sdk configcat-node
$ npm install @openfeature/js-sdk configcat-js
```
## Usage
The ConfigCat provider uses the [ConfigCat Node.js SDK](https://configcat.com/docs/sdk-reference/node/).
The ConfigCat provider uses the [ConfigCat Javascript SDK](https://configcat.com/docs/sdk-reference/js/).
It can be created by passing the ConfigCat SDK options to ```ConfigCatProvider.create```.
It can either be created by passing the ConfigCat SDK options to ```ConfigCatProvider.create``` or
the ```ConfigCatProvider``` constructor.
The available options can be found in the [ConfigCat Node.js SDK](https://configcat.com/docs/sdk-reference/node/#creating-the-configcat-client).
The available options can be found in the [ConfigCat Javascript SDK docs](https://configcat.com/docs/sdk-reference/js/).
### Example using the default configuration
```javascript
import { OpenFeature } from "@openfeature/server-sdk";
import { ConfigCatProvider } from '@openfeature/config-cat-provider';
// Create and set the provider.
const provider = ConfigCatProvider.create('<sdk_key>');
await OpenFeature.setProviderAndWait(provider);
// Obtain a client instance and evaluate feature flags.
const client = OpenFeature.getClient();
const value = await client.getBooleanValue('isAwesomeFeatureEnabled', false);
console.log(`isAwesomeFeatureEnabled: ${value}`);
// On application shutdown, clean up the OpenFeature provider and the underlying ConfigCat client.
await OpenFeature.clearProviders();
OpenFeature.setProvider(provider);
```
### Example using a different polling mode and custom configuration
### Example using different polling options and a setupHook
```javascript
import { OpenFeature } from "@openfeature/server-sdk";
import { ConfigCatProvider } from '@openfeature/config-cat-provider';
import { createConsoleLogger, LogLevel, PollingMode } from 'configcat-node';
// Create and set the provider.
const provider = ConfigCatProvider.create('<sdk_key>', PollingMode.LazyLoad, {
logger: createConsoleLogger(LogLevel.Info),
setupHooks: (hooks) => hooks.on('clientReady', () => console.log('Client is ready!')),
});
await OpenFeature.setProviderAndWait(provider);
// ...
OpenFeature.setProvider(provider);
```
## Evaluation Context
The OpenFeature Evaluation Context is mapped to the [ConfigCat User Object](https://configcat.com/docs/advanced/user-object/).
ConfigCat only supports string values in its "evaluation
context", [there known as user](https://configcat.com/docs/advanced/user-object/).
The [ConfigCat User Object](https://configcat.com/docs/advanced/user-object/) has three predefined attributes,
and allows for additional attributes.
This means that every value is converted to a string. This is trivial for numbers and booleans. Objects and arrays are
converted to JSON strings that can be interpreted in ConfigCat.
ConfigCat has three known attributes, and allows for additional attributes.
The following shows how the attributes are mapped:
| OpenFeature EvaluationContext Field | ConfigCat User Field | Required |
@ -80,17 +68,6 @@ The following shows how the attributes are mapped:
| country | country | no |
| _Any Other_ | custom | no |
The custom types are mapped the following way:
| OpenFeature EvaluationContext Field Type | ConfigCat User Field Type |
|-----------------------------------------------|---------------------------|
| string | string |
| number | number |
| boolean | string |
| Array<string> | Array<string> |
| Array | Array |
| object | string |
The following example shows the conversion between an OpenFeature Evaluation Context and the corresponding ConfigCat
User:
@ -108,7 +85,6 @@ User:
"prop1": "1",
"prop2": 2
},
"customStringArray": ["one", "two"],
"customArray": [
1,
"2",
@ -127,9 +103,8 @@ User:
"custom": {
"customString": "customString",
"customBoolean": "true",
"customNumber": 1,
"customNumber": "1",
"customObject": "{\"prop1\":\"1\",\"prop2\":2}",
"customStringArray": ["one", "two"],
"customArray": "[1,\"2\",false]"
}
}

View File

@ -1,72 +0,0 @@
{
"name": "@openfeature/config-cat-provider",
"version": "0.7.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openfeature/config-cat-provider",
"version": "0.7.6",
"peerDependencies": {
"@openfeature/server-sdk": "^1.13.5",
"configcat-node": "^11.3.1"
}
},
"node_modules/@openfeature/core": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@openfeature/core/-/core-1.2.0.tgz",
"integrity": "sha512-JyIiije5f+8Big1xz7UAmxqVmHBuFUI9Dh8DEFG2D1ocgjMm1tEzYXJDr3urCQGNnX9M/cYtNhEcGfyontIgJw==",
"peer": true
},
"node_modules/@openfeature/server-sdk": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/@openfeature/server-sdk/-/server-sdk-1.14.0.tgz",
"integrity": "sha512-PGPI6OZdyAy2FZVUiH1suw/WuWZJsIlK2xd1KbRl5rlMLawYk2bKGBGgZYX9rcozsGKOZM6/vaFjCSB6QCjCfw==",
"peer": true,
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@openfeature/core": "1.2.0"
}
},
"node_modules/configcat-common": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/configcat-common/-/configcat-common-9.3.1.tgz",
"integrity": "sha512-yVkIbluksD/kZfVyKjLIOpwLrq3/ZRM7Lwrsz89JmbpQ6VtbnelrTQynSPElTtKjrPRZx56v3IZYk3nWTnnM6A==",
"peer": true,
"dependencies": {
"tslib": "^2.4.1"
}
},
"node_modules/configcat-node": {
"version": "11.3.1",
"resolved": "https://registry.npmjs.org/configcat-node/-/configcat-node-11.3.1.tgz",
"integrity": "sha512-7XJbgBpcxlwzlRLmvCtHTkO247Ban2ZkBqlmk+T0wVEt5tXfltgd53SYLYpw7RBWX0ma/QyP5E+/k/UDdMrOCw==",
"peer": true,
"dependencies": {
"configcat-common": "9.3.1",
"tslib": "^2.4.1",
"tunnel": "0.0.6"
},
"engines": {
"node": ">=14"
}
},
"node_modules/tslib": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz",
"integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==",
"peer": true
},
"node_modules/tunnel": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz",
"integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==",
"peer": true,
"engines": {
"node": ">=0.6.11 <=0.7.0 || >=0.7.3"
}
}
}
}

View File

@ -1,15 +1,13 @@
{
"name": "@openfeature/config-cat-provider",
"version": "0.7.6",
"license": "Apache-2.0",
"version": "0.2.0",
"type": "commonjs",
"scripts": {
"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",
"current-version": "echo $npm_package_version"
},
"peerDependencies": {
"@openfeature/server-sdk": "^1.13.5",
"configcat-node": "^11.3.1",
"@openfeature/config-cat-core": "0.1.1",
"configcat-common": "9.4.0"
"@openfeature/js-sdk": "^1.3.0",
"configcat-js": "^8.0.0"
}
}

View File

@ -17,14 +17,24 @@
]
},
"lint": {
"executor": "@nx/eslint:lint",
"outputs": ["{options.outputFile}"]
"executor": "@nx/linter:eslint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": ["libs/providers/config-cat/**/*.ts"]
}
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/providers/config-cat/jest.config.ts"
"jestConfig": "libs/providers/config-cat/jest.config.ts",
"passWithNoTests": true
},
"configurations": {
"ci": {
"ci": true,
"codeCoverage": true
}
}
},
"package": {
@ -35,6 +45,7 @@
"outputPath": "dist/libs/providers/config-cat",
"entryFile": "libs/providers/config-cat/src/index.ts",
"tsConfig": "libs/providers/config-cat/tsconfig.lib.json",
"buildableProjectDepsInPackageJsonType": "dependencies",
"compiler": "tsc",
"generateExportsField": true,
"umdName": "config-cat",

View File

@ -1,20 +1,22 @@
import { ConfigCatProvider } from './config-cat-provider';
import { ProviderEvents, ParseError, FlagNotFoundError, TypeMismatchError } from '@openfeature/web-sdk';
import type { HookEvents, IConfigCatCache, ISettingUnion } from 'configcat-js-ssr';
import { ParseError, ProviderEvents, TypeMismatchError } from '@openfeature/js-sdk';
import {
createConsoleLogger,
createFlagOverridesFromMap,
HookEvents,
ISetting,
LogLevel,
OverrideBehaviour,
PollingMode,
} from 'configcat-js-ssr';
import type { EventEmitter } from 'events';
} from 'configcat-js';
import { IEventEmitter } from 'configcat-common/lib/EventEmitter';
describe('ConfigCatProvider', () => {
const targetingKey = 'abc';
let provider: ConfigCatProvider;
let configCatEmitter: EventEmitter<HookEvents>;
let configCatEmitter: IEventEmitter<HookEvents>;
const values = {
booleanFalse: false,
@ -37,12 +39,11 @@ describe('ConfigCatProvider', () => {
await provider.initialize();
// Currently there is no option to get access to the event emitter
// eslint-disable-next-line @typescript-eslint/no-explicit-any
configCatEmitter = (provider.configCatClient as any).options.hooks;
});
afterAll(async () => {
await provider.onClose();
afterAll(() => {
provider.onClose();
});
it('should be an instance of ConfigCatProvider', () => {
@ -69,9 +70,40 @@ describe('ConfigCatProvider', () => {
});
describe('events', () => {
it('should emit PROVIDER_READY event', () => {
const handler = jest.fn();
provider.events.addHandler(ProviderEvents.Ready, handler);
configCatEmitter.emit('clientReady');
expect(handler).toHaveBeenCalled();
});
it('should emit PROVIDER_READY event on initialization', async () => {
const newProvider = ConfigCatProvider.create('__another_key__', PollingMode.ManualPoll, {
logger: createConsoleLogger(LogLevel.Off),
offline: true,
flagOverrides: createFlagOverridesFromMap(values, OverrideBehaviour.LocalOnly),
});
const handler = jest.fn();
newProvider.events.addHandler(ProviderEvents.Ready, handler);
await newProvider.initialize();
expect(handler).toHaveBeenCalled();
});
it('should emit PROVIDER_READY event without options', async () => {
const newProvider = ConfigCatProvider.create('__yet_another_key__', PollingMode.ManualPoll);
const handler = jest.fn();
newProvider.events.addHandler(ProviderEvents.Ready, handler);
await newProvider.initialize();
expect(handler).toHaveBeenCalled();
});
it('should emit PROVIDER_CONFIGURATION_CHANGED event', () => {
const handler = jest.fn();
const eventData = { settings: { myFlag: {} as ISettingUnion }, salt: undefined, segments: [] };
const eventData = { settings: { myFlag: {} as ISetting } };
provider.events.addHandler(ProviderEvents.ConfigurationChanged, handler);
configCatEmitter.emit('configChanged', eventData);
@ -81,60 +113,24 @@ describe('ConfigCatProvider', () => {
});
});
it("should emit PROVIDER_READY event when underlying client is initialized after provider's initialize", async () => {
const cacheValue = '253370761200000\nW/"12345678-90a"\n{"f":{"booleanTrue":{"t":0,"v":{"b":true}}}}';
it('should emit PROVIDER_ERROR event', () => {
const handler = jest.fn();
const eventData: [string, unknown] = ['error', { error: 'error' }];
const fakeSharedCache = new (class implements IConfigCatCache {
private _value?: string;
get(key: string) {
return this._value;
}
set(key: string, value: string) {
this._value = value;
}
})();
provider.events.addHandler(ProviderEvents.Error, handler);
configCatEmitter.emit('clientError', ...eventData);
const provider = ConfigCatProvider.create(
'configcat-sdk-1/1234567890123456789012/1234567890123456789012',
PollingMode.AutoPoll,
{
cache: fakeSharedCache,
logger: createConsoleLogger(LogLevel.Off),
offline: true,
maxInitWaitTimeSeconds: 1,
},
);
const readyHandler = jest.fn();
provider.events.addHandler(ProviderEvents.Ready, readyHandler);
try {
await provider.initialize();
} catch (err) {
expect((err as Error).message).toContain('underlying ConfigCat client could not initialize');
}
expect(readyHandler).toHaveBeenCalledTimes(0);
fakeSharedCache.set('', cacheValue);
// Make sure that the internal cache is refreshed.
await provider.configCatClient?.forceRefreshAsync();
provider.resolveBooleanEvaluation('booleanTrue', false, { targetingKey });
// Wait a little while for the Ready event to be emitted.
await new Promise((resolve) => setTimeout(resolve, 100));
expect(readyHandler).toHaveBeenCalled();
expect(handler).toHaveBeenCalledWith({
message: eventData[0],
metadata: eventData[1],
});
});
});
describe('method resolveBooleanEvaluation', () => {
it('should throw FlagNotFoundError if type is different than expected', async () => {
await expect(provider.resolveBooleanEvaluation('nonExistent', false, { targetingKey })).rejects.toThrow(
FlagNotFoundError,
);
it('should return default value for missing value', async () => {
const value = await provider.resolveBooleanEvaluation('nonExistent', false, { targetingKey });
expect(value).toHaveProperty('value', false);
});
it('should return right value if key exists', async () => {
@ -144,16 +140,15 @@ describe('ConfigCatProvider', () => {
it('should throw TypeMismatchError if type is different than expected', async () => {
await expect(provider.resolveBooleanEvaluation('number1', false, { targetingKey })).rejects.toThrow(
TypeMismatchError,
TypeMismatchError
);
});
});
describe('method resolveStringEvaluation', () => {
it('should throw FlagNotFoundError if type is different than expected', async () => {
await expect(provider.resolveStringEvaluation('nonExistent', 'nonExistent', { targetingKey })).rejects.toThrow(
FlagNotFoundError,
);
it('should return default value for missing value', async () => {
const value = await provider.resolveStringEvaluation('nonExistent', 'default', { targetingKey });
expect(value).toHaveProperty('value', 'default');
});
it('should return right value if key exists', async () => {
@ -163,16 +158,15 @@ describe('ConfigCatProvider', () => {
it('should throw TypeMismatchError if type is different than expected', async () => {
await expect(provider.resolveStringEvaluation('number1', 'default', { targetingKey })).rejects.toThrow(
TypeMismatchError,
TypeMismatchError
);
});
});
describe('method resolveNumberEvaluation', () => {
it('should throw FlagNotFoundError if type is different than expected', async () => {
await expect(provider.resolveNumberEvaluation('nonExistent', 0, { targetingKey })).rejects.toThrow(
FlagNotFoundError,
);
it('should return default value for missing value', async () => {
const value = await provider.resolveNumberEvaluation('nonExistent', 0, { targetingKey });
expect(value).toHaveProperty('value', 0);
});
it('should return right value if key exists', async () => {
@ -182,16 +176,15 @@ describe('ConfigCatProvider', () => {
it('should throw TypeMismatchError if type is different than expected', async () => {
await expect(provider.resolveNumberEvaluation('stringTest', 0, { targetingKey })).rejects.toThrow(
TypeMismatchError,
TypeMismatchError
);
});
});
describe('method resolveObjectEvaluation', () => {
it('should throw FlagNotFoundError if type is different than expected', async () => {
await expect(provider.resolveObjectEvaluation('nonExistent', false, { targetingKey })).rejects.toThrow(
FlagNotFoundError,
);
it('should return default value for missing value', async () => {
const value = await provider.resolveObjectEvaluation('nonExistent', {}, { targetingKey });
expect(value).toHaveProperty('value', {});
});
it('should return right value if key exists', async () => {
@ -203,9 +196,10 @@ describe('ConfigCatProvider', () => {
await expect(provider.resolveObjectEvaluation('jsonInvalid', {}, { targetingKey })).rejects.toThrow(ParseError);
});
it('should return right value if key exists and value is only a JSON primitive', async () => {
const value = await provider.resolveObjectEvaluation('jsonPrimitive', {}, { targetingKey });
expect(value).toHaveProperty('value', JSON.parse(values.jsonPrimitive));
it('should throw TypeMismatchError if string is only a JSON primitive', async () => {
await expect(provider.resolveObjectEvaluation('jsonPrimitive', {}, { targetingKey })).rejects.toThrow(
TypeMismatchError
);
});
});
});

View File

@ -1,172 +1,224 @@
import type { EvaluationContext, JsonValue, Provider, ResolutionDetails, Paradigm } from '@openfeature/server-sdk';
import {
EvaluationContext,
GeneralError,
JsonValue,
OpenFeatureEventEmitter,
ProviderEvents,
ProviderNotReadyError,
TypeMismatchError,
ParseError,
} from '@openfeature/server-sdk';
import type { PrimitiveType, PrimitiveTypeName } from '@openfeature/config-cat-core';
import { isType, parseError, toResolutionDetails, transformContext } from '@openfeature/config-cat-core';
import type { SettingValue } from 'configcat-common';
import { ClientCacheState, PollingMode } from 'configcat-common';
import type { IConfigCatClient, IConfig, OptionsForPollingMode } from 'configcat-node';
import { getClient } from 'configcat-node';
Provider,
ProviderEvents,
ResolutionDetails,
ResolutionReason,
StandardResolutionReasons,
TypeMismatchError,
} from '@openfeature/js-sdk';
import { getClient, IConfigCatClient, IEvaluationDetails, SettingValue } from 'configcat-js';
import { transformContext } from './context-transformer';
export class ConfigCatProvider implements Provider {
private readonly clientParameters: Parameters<typeof getClient>;
public readonly events = new OpenFeatureEventEmitter();
private readonly _clientFactory: (provider: ConfigCatProvider) => IConfigCatClient;
private readonly _pollingMode: PollingMode;
private _isProviderReady = false;
private _client?: IConfigCatClient;
public runsOn: Paradigm = 'server';
private client?: IConfigCatClient;
public metadata = {
name: ConfigCatProvider.name,
};
protected constructor(clientFactory: (provider: ConfigCatProvider) => IConfigCatClient, pollingMode: PollingMode) {
this._clientFactory = clientFactory;
this._pollingMode = pollingMode;
constructor(...params: Parameters<typeof getClient>) {
this.clientParameters = params;
}
public static create<TMode extends PollingMode>(
sdkKey: string,
pollingMode?: TMode,
options?: OptionsForPollingMode<TMode>,
): ConfigCatProvider {
// Let's create a shallow copy to not mess up caller's options object.
options = options ? { ...options } : ({} as OptionsForPollingMode<TMode>);
return new ConfigCatProvider((provider) => {
const oldSetupHooks = options?.setupHooks;
public static create(...params: Parameters<typeof getClient>) {
return new ConfigCatProvider(...params);
}
public async initialize(): Promise<void> {
return new Promise((resolve) => {
const originalParameters = this.clientParameters;
originalParameters[2] ??= {};
const options = originalParameters[2];
const oldSetupHooks = options.setupHooks;
options.setupHooks = (hooks) => {
oldSetupHooks?.(hooks);
hooks.on('configChanged', (config: IConfig) =>
provider.events.emit(ProviderEvents.ConfigurationChanged, {
flagsChanged: Object.keys(config.settings),
}),
// After resolving, once, we can simply emit events the next time
hooks.once('clientReady', () => {
hooks.on('clientReady', () => this.events.emit(ProviderEvents.Ready));
this.events.emit(ProviderEvents.Ready);
resolve();
});
hooks.on('configChanged', (projectConfig) =>
this.events.emit(ProviderEvents.ConfigurationChanged, {
flagsChanged: Object.keys(projectConfig.settings),
})
);
hooks.on('clientError', (message: string, error) =>
this.events.emit(ProviderEvents.Error, {
message: message,
metadata: error,
})
);
};
return getClient(sdkKey, pollingMode, options);
}, pollingMode ?? PollingMode.AutoPoll);
}
public async initialize(): Promise<void> {
const client = this._clientFactory(this);
const clientCacheState = await client.waitForReady();
this._client = client;
if (this._pollingMode !== PollingMode.AutoPoll || clientCacheState !== ClientCacheState.NoFlagData) {
this._isProviderReady = true;
} else {
// OpenFeature provider defines ready state like this: "The provider is ready to resolve flags."
// However, ConfigCat client's behavior is different: in some cases ready state may be reached
// even if the client's internal, in-memory cache hasn't been populated yet, that is,
// the client is not able to evaluate feature flags yet. In such cases we throw an error to
// prevent the provider from being set ready right away, and check for the ready state later.
throw Error('The underlying ConfigCat client could not initialize within maxInitWaitTimeSeconds.');
}
this.client = getClient(...originalParameters);
});
}
public get configCatClient() {
return this._client;
return this.client;
}
public async onClose(): Promise<void> {
this._client?.dispose();
await this.client?.dispose();
}
async resolveBooleanEvaluation(
flagKey: string,
defaultValue: boolean,
context: EvaluationContext,
context: EvaluationContext
): Promise<ResolutionDetails<boolean>> {
return this.evaluate(flagKey, 'boolean', defaultValue, context);
if (!this.client) {
throw new GeneralError('Provider is not initialized');
}
const { value, ...evaluationData } = await this.client.getValueDetailsAsync<SettingValue>(
flagKey,
undefined,
transformContext(context)
);
const validatedValue = validateFlagType('boolean', value);
return validatedValue
? toResolutionDetails(validatedValue, evaluationData)
: toResolutionDetails(defaultValue, evaluationData, StandardResolutionReasons.DEFAULT);
}
public async resolveStringEvaluation(
flagKey: string,
defaultValue: string,
context: EvaluationContext,
context: EvaluationContext
): Promise<ResolutionDetails<string>> {
return this.evaluate(flagKey, 'string', defaultValue, context);
if (!this.client) {
throw new GeneralError('Provider is not initialized');
}
const { value, ...evaluationData } = await this.client.getValueDetailsAsync<SettingValue>(
flagKey,
undefined,
transformContext(context)
);
const validatedValue = validateFlagType('string', value);
return validatedValue
? toResolutionDetails(validatedValue, evaluationData)
: toResolutionDetails(defaultValue, evaluationData, StandardResolutionReasons.DEFAULT);
}
public async resolveNumberEvaluation(
flagKey: string,
defaultValue: number,
context: EvaluationContext,
context: EvaluationContext
): Promise<ResolutionDetails<number>> {
return this.evaluate(flagKey, 'number', defaultValue, context);
if (!this.client) {
throw new GeneralError('Provider is not initialized');
}
const { value, ...evaluationData } = await this.client.getValueDetailsAsync<SettingValue>(
flagKey,
undefined,
transformContext(context)
);
const validatedValue = validateFlagType('number', value);
return validatedValue
? toResolutionDetails(validatedValue, evaluationData)
: toResolutionDetails(defaultValue, evaluationData, StandardResolutionReasons.DEFAULT);
}
public async resolveObjectEvaluation<U extends JsonValue>(
flagKey: string,
defaultValue: U,
context: EvaluationContext,
context: EvaluationContext
): Promise<ResolutionDetails<U>> {
const objectValue = await this.evaluate(flagKey, 'object', defaultValue, context);
return objectValue as ResolutionDetails<U>;
}
protected async evaluate<T extends PrimitiveTypeName>(
flagKey: string,
flagType: T,
defaultValue: PrimitiveType<T>,
context: EvaluationContext,
): Promise<ResolutionDetails<PrimitiveType<T>>> {
if (!this._client) {
throw new ProviderNotReadyError('Provider is not initialized');
if (!this.client) {
throw new GeneralError('Provider is not initialized');
}
// Make sure that the user-provided `defaultValue` is compatible with `flagType` as there is
// no guarantee that it actually is. (User may bypass type checking or may not use TypeScript at all.)
if (!isType(flagType, defaultValue)) {
throw new TypeMismatchError();
}
const configCatDefaultValue = flagType !== 'object' ? (defaultValue as SettingValue) : JSON.stringify(defaultValue);
const { value, ...evaluationData } = await this._client.getValueDetailsAsync(
const { value, ...evaluationData } = await this.client.getValueDetailsAsync(
flagKey,
configCatDefaultValue,
transformContext(context),
undefined,
transformContext(context)
);
if (!this._isProviderReady && this._client.snapshot().cacheState !== ClientCacheState.NoFlagData) {
// Ideally, we would check ConfigCat client's initialization state in its "background" polling loop.
// This is not possible at the moment, so as a workaround, we do the check on feature flag evaluation.
// There are plans to improve this situation, so let's revise this
// as soon as ConfigCat SDK implements the necessary event.
this._isProviderReady = true;
setTimeout(() => this.events.emit(ProviderEvents.Ready), 0);
if (typeof value === 'undefined') {
return toResolutionDetails(defaultValue, evaluationData, StandardResolutionReasons.DEFAULT);
}
if (evaluationData.isDefaultValue) {
throw parseError(evaluationData.errorMessage);
if (!isType('string', value)) {
throw new TypeMismatchError(`Requested object flag but the actual value is not a JSON string`);
}
if (flagType !== 'object') {
// When `flagType` (more precisely, `configCatDefaultValue`) is boolean, string or number,
// ConfigCat SDK guarantees that the returned `value` is compatible with `PrimitiveType<T>`.
// See also: https://configcat.com/docs/sdk-reference/node/#setting-type-mapping
return toResolutionDetails(value as PrimitiveType<T>, evaluationData);
}
let json: JsonValue;
try {
// In this case we can be sure that `value` is string since `configCatDefaultValue` is string,
// which means that ConfigCat SDK is guaranteed to return a string value.
json = JSON.parse(value as string);
} catch (e) {
throw new ParseError(`Unable to parse "${value}" as JSON`);
}
const object = JSON.parse(value);
return toResolutionDetails(json as PrimitiveType<T>, evaluationData);
if (typeof object !== 'object') {
throw new TypeMismatchError(`Requested object flag but the actual value is ${typeof value}`);
}
return toResolutionDetails(object, evaluationData);
} catch (e) {
if (e instanceof TypeMismatchError) {
throw e;
}
throw new ParseError(`Unable to parse '${value}' as JSON`);
}
}
}
function toResolutionDetails<U extends JsonValue>(
value: U,
data: Omit<IEvaluationDetails, 'value'>,
reason?: ResolutionReason
): ResolutionDetails<U> {
const matchedRule = Boolean(data.matchedEvaluationRule || data.matchedEvaluationPercentageRule);
const evaluatedReason = matchedRule ? StandardResolutionReasons.TARGETING_MATCH : StandardResolutionReasons.STATIC;
return {
value,
reason: reason ?? evaluatedReason,
errorMessage: data.errorMessage,
variant: data.variationId ?? undefined,
};
}
type PrimitiveTypeName = 'string' | 'boolean' | 'number' | 'object' | 'undefined';
type PrimitiveType<T> = T extends 'string'
? string
: T extends 'boolean'
? boolean
: T extends 'number'
? number
: T extends 'object'
? object
: T extends 'undefined'
? undefined
: unknown;
function isType<T extends PrimitiveTypeName>(type: T, value: unknown): value is PrimitiveType<T> {
return typeof value === type;
}
function validateFlagType<T extends PrimitiveTypeName>(type: T, value: unknown): PrimitiveType<T> | undefined {
if (typeof value !== 'undefined' && !isType(type, value)) {
throw new TypeMismatchError(`Requested ${type} flag but the actual value is ${typeof value}`);
}
return value;
}

View File

@ -1,8 +1,17 @@
import type { EvaluationContext } from '@openfeature/core';
import { EvaluationContext, TargetingKeyMissingError } from '@openfeature/js-sdk';
import { transformContext } from './context-transformer';
describe('context-transformer', () => {
describe('transformContext', () => {
it('throw TargetingKeyMissingError if targeting key is missing', () => {
const context: EvaluationContext = {
customProp: 'test',
};
expect(() => transformContext(context)).toThrow(TargetingKeyMissingError);
});
it('map targeting key to identifier', () => {
const context: EvaluationContext = {
targetingKey: 'test',
@ -10,9 +19,6 @@ describe('context-transformer', () => {
const user = {
identifier: context['targetingKey'],
country: undefined,
custom: {},
email: undefined,
};
expect(transformContext(context)).toEqual(user);
@ -29,7 +35,6 @@ describe('context-transformer', () => {
identifier: context['targetingKey'],
email: context['email'],
country: context['country'],
custom: {},
};
expect(transformContext(context)).toEqual(user);
@ -51,7 +56,7 @@ describe('context-transformer', () => {
expect(transformContext(context)).toEqual(user);
});
it('map custom property with number to number', () => {
it('map custom property with number to string', () => {
const context: EvaluationContext = {
targetingKey: 'test',
customNumber: 1,
@ -60,7 +65,7 @@ describe('context-transformer', () => {
const user = {
identifier: context['targetingKey'],
custom: {
customNumber: 1,
customNumber: '1',
},
};
@ -102,23 +107,7 @@ describe('context-transformer', () => {
expect(transformContext(context)).toEqual(user);
});
it('map custom property with string array to string array', () => {
const context: EvaluationContext = {
targetingKey: 'test',
customArray: ['one', 'two', 'three'],
};
const user = {
identifier: context['targetingKey'],
custom: {
customArray: ['one', 'two', 'three'],
},
};
expect(transformContext(context)).toEqual(user);
});
it('map custom property with mixed array to JSON string', () => {
it('map custom property with array to JSON string', () => {
const context: EvaluationContext = {
targetingKey: 'test',
customArray: [1, '2', false],
@ -137,8 +126,8 @@ describe('context-transformer', () => {
it('map several custom properties correctly', () => {
const context: EvaluationContext = {
targetingKey: 'test',
email: 'email',
country: 'country',
email: "email",
country: "country",
customString: 'customString',
customNumber: 1,
customBoolean: true,
@ -151,12 +140,12 @@ describe('context-transformer', () => {
const user = {
identifier: 'test',
email: 'email',
country: 'country',
email: "email",
country: "country",
custom: {
customString: 'customString',
customBoolean: 'true',
customNumber: 1,
customNumber: '1',
customObject: '{"prop1":"1","prop2":2}',
customArray: '[1,"2",false]',
},

View File

@ -0,0 +1,65 @@
import { EvaluationContext, EvaluationContextValue, TargetingKeyMissingError } from '@openfeature/js-sdk';
import { User as ConfigCatUser } from 'configcat-common/lib/RolloutEvaluator';
function contextValueToString(contextValue: EvaluationContextValue): string | undefined {
if (typeof contextValue === 'string') {
return contextValue;
}
if (typeof contextValue === 'boolean' || typeof contextValue === 'number' || contextValue === null) {
return String(contextValue);
}
if (typeof contextValue === 'undefined') {
return contextValue;
}
if (contextValue instanceof Date) {
return contextValue.toISOString();
}
return JSON.stringify(contextValue);
}
function transformContextValues(contextValue: EvaluationContextValue): ConfigCatUser['custom'] | undefined {
if (contextValue === null) {
return undefined;
}
if (typeof contextValue !== 'object' || Array.isArray(contextValue)) {
const value = contextValueToString(contextValue);
return value ? { value } : undefined;
}
if (contextValue instanceof Date) {
return { value: contextValue.toISOString() };
}
return Object.entries(contextValue).reduce<ConfigCatUser['custom']>((context, [key, value]) => {
const transformedValue = contextValueToString(value);
return transformedValue ? { ...context, [key]: transformedValue } : context;
}, undefined);
}
function stringOrUndefined(param?: unknown): string | undefined {
if (typeof param === 'string') {
return param;
}
return undefined;
}
export function transformContext(context: EvaluationContext): ConfigCatUser | never {
const { targetingKey, email, country, ...attributes } = context;
if (!targetingKey) {
throw new TargetingKeyMissingError('ConfigCat evaluation context can only be used if a targetingKey is given');
}
return {
identifier: targetingKey,
email: stringOrUndefined(email),
country: stringOrUndefined(country),
custom: transformContextValues(attributes),
};
}

View File

@ -3,6 +3,7 @@
"compilerOptions": {
"module": "ES6",
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,

View File

@ -1,48 +1,5 @@
# Changelog
## [0.3.1](https://github.com/open-feature/js-sdk-contrib/compare/env-var-provider-v0.3.0...env-var-provider-v0.3.1) (2024-07-10)
### 🐛 Bug Fixes
* **env-var:** set runs on property to server ([#981](https://github.com/open-feature/js-sdk-contrib/issues/981)) ([919761d](https://github.com/open-feature/js-sdk-contrib/commit/919761d8926fc102c84b11288d4c6d1ff3e3fc05))
## [0.3.0](https://github.com/open-feature/js-sdk-contrib/compare/env-var-provider-v0.2.0...env-var-provider-v0.3.0) (2024-03-25)
### ⚠ BREAKING CHANGES
* update OpenFeature SDK peer ([#798](https://github.com/open-feature/js-sdk-contrib/issues/798))
### ✨ New Features
* update OpenFeature SDK peer ([#798](https://github.com/open-feature/js-sdk-contrib/issues/798)) ([ebd16b9](https://github.com/open-feature/js-sdk-contrib/commit/ebd16b9630bcc6b253a7061a144e8d476cd8b586))
### 🧹 Chore
* address lint issues ([#642](https://github.com/open-feature/js-sdk-contrib/issues/642)) ([bbd9aee](https://github.com/open-feature/js-sdk-contrib/commit/bbd9aee896dc4a0817f379b799a1b8d331ee76c6))
* fix lint issues and bump server sdk version ([#715](https://github.com/open-feature/js-sdk-contrib/issues/715)) ([bd57177](https://github.com/open-feature/js-sdk-contrib/commit/bd571770f3a1a01bd62663dc3473273449f96c5c))
## [0.2.0](https://github.com/open-feature/js-sdk-contrib/compare/env-var-provider-v0.1.1...env-var-provider-v0.2.0) (2023-10-11)
### ⚠ BREAKING CHANGES
* use @openfeature/server-sdk peer ([#608](https://github.com/open-feature/js-sdk-contrib/issues/608))
### 🐛 Bug Fixes
* packaging issues impacting babel/react ([#596](https://github.com/open-feature/js-sdk-contrib/issues/596)) ([0446eab](https://github.com/open-feature/js-sdk-contrib/commit/0446eab5cf9b45ce7de251b4f5feb8df1d499b9d))
### 🧹 Chore
* correct publish executor ([#378](https://github.com/open-feature/js-sdk-contrib/issues/378)) ([395ed18](https://github.com/open-feature/js-sdk-contrib/commit/395ed186de8811ae249f087821fdbdf8899c19f2))
* migrate to nx 16 ([#366](https://github.com/open-feature/js-sdk-contrib/issues/366)) ([7a9c201](https://github.com/open-feature/js-sdk-contrib/commit/7a9c201d16fd7f070a1bcd2e359487ba6e7b78d7))
* update nx, run migrations ([#552](https://github.com/open-feature/js-sdk-contrib/issues/552)) ([a88d8fc](https://github.com/open-feature/js-sdk-contrib/commit/a88d8fc097789fd7f56011e6ebb66070f52c6e56))
* use @openfeature/server-sdk peer ([#608](https://github.com/open-feature/js-sdk-contrib/issues/608)) ([ae3732a](https://github.com/open-feature/js-sdk-contrib/commit/ae3732a9068f684517db28ea1ae27b29a35e6b16))
## [0.1.1](https://github.com/open-feature/js-sdk-contrib/compare/env-var-provider-v0.1.0...env-var-provider-v0.1.1) (2023-03-02)

View File

@ -15,7 +15,7 @@ $ npm install @openfeature/env-var-provider
Required peer dependencies
```
$ npm install @openfeature/server-sdk
$ npm install @openfeature/js-sdk
```
## Usage

View File

@ -1,12 +1,12 @@
{
"name": "@openfeature/env-var-provider",
"version": "0.3.1",
"license": "Apache-2.0",
"version": "0.1.1",
"type": "commonjs",
"scripts": {
"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",
"current-version": "echo $npm_package_version"
},
"peerDependencies": {
"@openfeature/server-sdk": "^1.13.0"
"@openfeature/js-sdk": "^1.0.0"
}
}

View File

@ -17,14 +17,24 @@
]
},
"lint": {
"executor": "@nx/eslint:lint",
"outputs": ["{options.outputFile}"]
"executor": "@nx/linter:eslint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": ["libs/providers/env-var/**/*.ts"]
}
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/providers/env-var/jest.config.ts"
"jestConfig": "libs/providers/env-var/jest.config.ts",
"passWithNoTests": true
},
"configurations": {
"ci": {
"ci": true,
"codeCoverage": true
}
}
},
"package": {
@ -35,6 +45,7 @@
"outputPath": "dist/libs/providers/env-var",
"entryFile": "libs/providers/env-var/src/index.ts",
"tsConfig": "libs/providers/env-var/tsconfig.lib.json",
"buildableProjectDepsInPackageJsonType": "dependencies",
"compiler": "tsc",
"generateExportsField": true,
"umdName": "Env Var",

View File

@ -1,4 +1,4 @@
import { FlagNotFoundError, ParseError } from '@openfeature/server-sdk';
import { FlagNotFoundError, ParseError } from '@openfeature/js-sdk';
import { EnvVarProvider } from './env-var-provider';
describe('Environment Variable Provider', () => {

View File

@ -1,5 +1,11 @@
import type { JsonValue, Provider, ResolutionDetails } from '@openfeature/server-sdk';
import { FlagNotFoundError, ParseError, StandardResolutionReasons } from '@openfeature/server-sdk';
import {
FlagNotFoundError,
JsonValue,
ParseError,
Provider,
ResolutionDetails,
StandardResolutionReasons,
} from '@openfeature/js-sdk';
import { constantCase } from './constant-case';
export type Config = {
@ -19,8 +25,6 @@ export class EnvVarProvider implements Provider {
name: 'environment variable',
};
readonly runsOn = 'server';
private readonly options: Config;
// use the constructor for provider-specific configuration
@ -71,7 +75,7 @@ export class EnvVarProvider implements Provider {
private evaluateEnvironmentVariable<T extends JsonValue>(
key: string,
parse: (value: string) => T,
parse: (value: string) => T
): ResolutionDetails<T> {
const envVarKey = this.options.disableConstantCase ? key : constantCase(key);
const value = process.env[envVarKey];

View File

@ -1,6 +1,6 @@
{
"extends": ["../../../.eslintrc.json"],
"ignorePatterns": ["!**/*", "schemas/**"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],

View File

@ -1,122 +1,5 @@
# Changelog
## [0.7.3](https://github.com/open-feature/js-sdk-contrib/compare/flagd-web-provider-v0.7.2...flagd-web-provider-v0.7.3) (2025-03-19)
### ✨ New Features
* **flagd:** add flag metadata ([#1151](https://github.com/open-feature/js-sdk-contrib/issues/1151)) ([b1c6d23](https://github.com/open-feature/js-sdk-contrib/commit/b1c6d235565f6cce02519d7c08bb6ad2dd791332))
### 🧹 Chore
* **deps:** update libs/providers/flagd-web/schemas digest to 37baa2c ([#1148](https://github.com/open-feature/js-sdk-contrib/issues/1148)) ([36ec82a](https://github.com/open-feature/js-sdk-contrib/commit/36ec82a5581e436b699d4b8238908fa4d5817deb))
* **deps:** update libs/providers/flagd-web/schemas digest to b81a56e ([#1131](https://github.com/open-feature/js-sdk-contrib/issues/1131)) ([828145a](https://github.com/open-feature/js-sdk-contrib/commit/828145a89da13bbd90bca352a6488aecb62b764b))
* **deps:** update libs/providers/flagd-web/schemas digest to bb76343 ([#1167](https://github.com/open-feature/js-sdk-contrib/issues/1167)) ([cb7bdb1](https://github.com/open-feature/js-sdk-contrib/commit/cb7bdb12a19ea403eb834d0eb552f30b42921b6b))
* removing build dependencies and using testcontainers for container spin up ([#982](https://github.com/open-feature/js-sdk-contrib/issues/982)) ([2d64331](https://github.com/open-feature/js-sdk-contrib/commit/2d6433101b76ba9ad266095fe31b58314f82a105))
* update nx packages ([#1147](https://github.com/open-feature/js-sdk-contrib/issues/1147)) ([7f310fe](https://github.com/open-feature/js-sdk-contrib/commit/7f310fe87101b8aa793e1436e63c7602ccc202e3))
* various gherkin improvements for e2e tests ([#1008](https://github.com/open-feature/js-sdk-contrib/issues/1008)) ([40abd8e](https://github.com/open-feature/js-sdk-contrib/commit/40abd8eca76b47bb5c084b377302821968acd19c))
## [0.7.2](https://github.com/open-feature/js-sdk-contrib/compare/flagd-web-provider-v0.7.1...flagd-web-provider-v0.7.2) (2024-07-08)
### ✨ New Features
* Change fractional custom op from percentage-based to relative weighting. [#946](https://github.com/open-feature/js-sdk-contrib/issues/946) ([#954](https://github.com/open-feature/js-sdk-contrib/issues/954)) ([0e9bc84](https://github.com/open-feature/js-sdk-contrib/commit/0e9bc842cf09de12e8445dcb4e0e8b3623c66099))
## [0.7.1](https://github.com/open-feature/js-sdk-contrib/compare/flagd-web-provider-v0.7.0...flagd-web-provider-v0.7.1) (2024-04-26)
### ✨ New Features
* Add interceptors to flagd options. ([#894](https://github.com/open-feature/js-sdk-contrib/issues/894)) ([878b7b6](https://github.com/open-feature/js-sdk-contrib/commit/878b7b6e11853a8dcc2952e5767b7d275de72313))
## [0.7.0](https://github.com/open-feature/js-sdk-contrib/compare/flagd-web-provider-v0.6.0...flagd-web-provider-v0.7.0) (2024-04-18)
### ⚠ BREAKING CHANGES
* allow overrides for fractional seed ([#870](https://github.com/open-feature/js-sdk-contrib/issues/870))
### ✨ New Features
* allow overrides for fractional seed ([#870](https://github.com/open-feature/js-sdk-contrib/issues/870)) ([6c376b2](https://github.com/open-feature/js-sdk-contrib/commit/6c376b2f525be04c15b5c3bd32d89cc9c4c66729))
### 🧹 Chore
* migrate from bufbuild to connectrpc ([#891](https://github.com/open-feature/js-sdk-contrib/issues/891)) ([df7b89d](https://github.com/open-feature/js-sdk-contrib/commit/df7b89d519da793f64bb6cba0984a7bf4764bafa))
## [0.6.0](https://github.com/open-feature/js-sdk-contrib/compare/flagd-web-provider-v0.5.1...flagd-web-provider-v0.6.0) (2024-03-19)
### ⚠ BREAKING CHANGES
* update OpenFeature SDK peer ([#798](https://github.com/open-feature/js-sdk-contrib/issues/798))
### ✨ New Features
* update OpenFeature SDK peer ([#798](https://github.com/open-feature/js-sdk-contrib/issues/798)) ([ebd16b9](https://github.com/open-feature/js-sdk-contrib/commit/ebd16b9630bcc6b253a7061a144e8d476cd8b586))
## [0.5.1](https://github.com/open-feature/js-sdk-contrib/compare/flagd-web-provider-v0.5.0...flagd-web-provider-v0.5.1) (2024-02-15)
### ✨ New Features
* use updated proto ([#773](https://github.com/open-feature/js-sdk-contrib/issues/773)) ([437bbe4](https://github.com/open-feature/js-sdk-contrib/commit/437bbe4334ef8104d27bb40e9c109164f2a25ca5))
## [0.5.0](https://github.com/open-feature/js-sdk-contrib/compare/flagd-web-provider-v0.4.1...flagd-web-provider-v0.5.0) (2024-02-14)
### ⚠ BREAKING CHANGES
* use new eval/sync protos (requires flagd v0.7.3+) ([#762](https://github.com/open-feature/js-sdk-contrib/issues/762))
### ✨ New Features
* use new eval/sync protos (requires flagd v0.7.3+) ([#762](https://github.com/open-feature/js-sdk-contrib/issues/762)) ([4da9deb](https://github.com/open-feature/js-sdk-contrib/commit/4da9deb48c6bd0c106b176fc7e3730cf50e60b6d))
## [0.4.1](https://github.com/open-feature/js-sdk-contrib/compare/flagd-web-provider-v0.4.0...flagd-web-provider-v0.4.1) (2023-12-15)
### 🐛 Bug Fixes
* packaging issues impacting babel/react ([#596](https://github.com/open-feature/js-sdk-contrib/issues/596)) ([0446eab](https://github.com/open-feature/js-sdk-contrib/commit/0446eab5cf9b45ce7de251b4f5feb8df1d499b9d))
* tsc issue with flagd-web proto output ([#695](https://github.com/open-feature/js-sdk-contrib/issues/695)) ([65e448c](https://github.com/open-feature/js-sdk-contrib/commit/65e448ce852bcdb06d76c412dd4577be07a165ce))
### 🧹 Chore
* add e2e tests for flagd ([#554](https://github.com/open-feature/js-sdk-contrib/issues/554)) ([9ecdcdf](https://github.com/open-feature/js-sdk-contrib/commit/9ecdcdf1660fe27afb4b0c58160c7ba687e29be2))
* address lint issues ([#642](https://github.com/open-feature/js-sdk-contrib/issues/642)) ([bbd9aee](https://github.com/open-feature/js-sdk-contrib/commit/bbd9aee896dc4a0817f379b799a1b8d331ee76c6))
* update nx, run migrations ([#552](https://github.com/open-feature/js-sdk-contrib/issues/552)) ([a88d8fc](https://github.com/open-feature/js-sdk-contrib/commit/a88d8fc097789fd7f56011e6ebb66070f52c6e56))
* use spec submodule ([#568](https://github.com/open-feature/js-sdk-contrib/issues/568)) ([3feb18e](https://github.com/open-feature/js-sdk-contrib/commit/3feb18e0ffa77b87e799a2b5250413f03a4c69e9))
## [0.4.0](https://github.com/open-feature/js-sdk-contrib/compare/flagd-web-provider-v0.3.5...flagd-web-provider-v0.4.0) (2023-07-31)
### ⚠ BREAKING CHANGES
* update required web-sdk peer ([#514](https://github.com/open-feature/js-sdk-contrib/issues/514))
### 🧹 Chore
* fix submodule race ([#509](https://github.com/open-feature/js-sdk-contrib/issues/509)) ([a427a00](https://github.com/open-feature/js-sdk-contrib/commit/a427a0006ada4d54f5d83ae2d3167a87f6635e81))
* update required web-sdk peer ([#514](https://github.com/open-feature/js-sdk-contrib/issues/514)) ([8d45c02](https://github.com/open-feature/js-sdk-contrib/commit/8d45c0245472ddb196ef846a14829d18131d23d0))
## [0.3.5](https://github.com/open-feature/js-sdk-contrib/compare/flagd-web-provider-v0.3.4...flagd-web-provider-v0.3.5) (2023-07-27)
### 🐛 Bug Fixes
* add status to flagd web ([#497](https://github.com/open-feature/js-sdk-contrib/issues/497)) ([276a740](https://github.com/open-feature/js-sdk-contrib/commit/276a740ba4c8320f6633fe104bd0d3e6f2a87d0d))
### 🧹 Chore
* migrate buf ([#456](https://github.com/open-feature/js-sdk-contrib/issues/456)) ([8568af1](https://github.com/open-feature/js-sdk-contrib/commit/8568af1e26f92f4d0e9a942b9fc3e001d919ef03))
* migrate to nx 16 ([#366](https://github.com/open-feature/js-sdk-contrib/issues/366)) ([7a9c201](https://github.com/open-feature/js-sdk-contrib/commit/7a9c201d16fd7f070a1bcd2e359487ba6e7b78d7))
## [0.3.4](https://github.com/open-feature/js-sdk-contrib/compare/flagd-web-provider-v0.3.3...flagd-web-provider-v0.3.4) (2023-04-24)

View File

@ -2,14 +2,15 @@
export default {
displayName: 'providers-flagd-web',
preset: '../../../jest.preset.js',
globals: {
'ts-jest': {
tsconfig: '<rootDir>/tsconfig.spec.json',
},
},
transform: {
'^.+\\.[tj]s$': ['ts-jest', {
tsconfig: '<rootDir>/tsconfig.spec.json'
}]
'^.+\\.[tj]s$': 'ts-jest',
},
testEnvironment: 'jsdom',
moduleFileExtensions: ['ts', 'js', 'html'],
// ignore e2e path
testPathIgnorePatterns: ["/e2e/"],
coverageDirectory: '../../../coverage/libs/providers/flagd-web',
};

View File

@ -1,12 +1,12 @@
{
"name": "@openfeature/flagd-web-provider",
"version": "0.7.3",
"version": "0.3.4",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@openfeature/flagd-web-provider",
"version": "0.7.3"
"version": "0.3.4"
}
}
}

View File

@ -1,17 +1,12 @@
{
"name": "@openfeature/flagd-web-provider",
"version": "0.7.3",
"license": "Apache-2.0",
"version": "0.3.4",
"type": "commonjs",
"scripts": {
"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",
"current-version": "echo $npm_package_version"
},
"peerDependencies": {
"@openfeature/web-sdk": "^1.0.0"
},
"dependencies": {
"@connectrpc/connect": "^1.4.0",
"@connectrpc/connect-web": "^1.4.0",
"@bufbuild/protobuf": "^1.2.0"
"@openfeature/web-sdk": "*"
}
}

View File

@ -20,16 +20,21 @@
"executor": "nx:run-commands",
"options": {
"commands": [
"git submodule update --init schemas",
"npx buf generate buf.build/open-feature/flagd --template schemas/protobuf/buf.gen.ts-connect.yaml --output ./src/lib"
"git submodule update --init --recursive",
"rm -f -r ./src/proto",
"cd schemas && buf generate buf.build/open-feature/flagd --template protobuf/buf.gen.ts-connect.yaml",
"mv -v ./proto ./src"
],
"cwd": "libs/providers/flagd-web",
"parallel": false
}
},
"lint": {
"executor": "@nx/eslint:lint",
"executor": "@nx/linter:eslint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": ["libs/providers/flagd-web/**/*.ts"]
},
"dependsOn": [
{
"target": "generate"
@ -38,9 +43,10 @@
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/libs/providers/flagd-web"],
"outputs": ["coverage/libs/providers/flagd-web"],
"options": {
"jestConfig": "libs/providers/flagd-web/jest.config.ts"
"jestConfig": "libs/providers/flagd-web/jest.config.ts",
"passWithNoTests": true
},
"dependsOn": [
{
@ -48,22 +54,6 @@
}
]
},
"e2e": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/libs/providers/flagd-web"],
"options": {
"jestConfig": "libs/providers/flagd-web/src/e2e/jest.config.ts",
"parallel": false
},
"dependsOn": [
{
"target": "generate"
},
{
"target": "flagd-core:pullTestHarness"
}
]
},
"package": {
"executor": "@nx/rollup:rollup",
"outputs": ["{options.outputPath}"],
@ -74,6 +64,7 @@
"tsConfig": "libs/providers/flagd-web/tsconfig.lib.json",
"compiler": "tsc",
"generateExportsField": true,
"buildableProjectDepsInPackageJsonType": "dependencies",
"umdName": "flagd-web",
"external": "all",
"format": ["cjs", "esm"],

@ -1 +1 @@
Subproject commit 2852d7772e6b8674681a6ee6b88db10dbe3f6899
Subproject commit 803fde1e131c20e8e69e2b88eed2b9793e8df903

View File

@ -1,8 +0,0 @@
import { getGherkinTestPath } from '@openfeature/flagd-core';
export const FLAGD_NAME = 'flagd';
export const GHERKIN_EVALUATION_FEATURE = getGherkinTestPath(
'evaluation.feature',
'spec/specification/assets/gherkin/',
);

View File

@ -1,2 +0,0 @@
export * from './constants';
export * from './step-definitions';

View File

@ -1,14 +0,0 @@
import type { Config } from 'jest';
const config: Config = {
displayName: 'providers-flagd-web-e2e',
clearMocks: true,
preset: 'ts-jest',
moduleNameMapper: {
'@openfeature/flagd-core': ['<rootDir>/../../../../shared/flagd-core/src'],
'(.+)\\.js$': '$1',
},
verbose: true,
};
export default config;

View File

@ -1,364 +0,0 @@
import type { StepDefinitions } from 'jest-cucumber';
import type { EvaluationContext, EvaluationDetails, FlagValue, JsonObject } from '@openfeature/web-sdk';
import { OpenFeature, ProviderEvents, StandardResolutionReasons } from '@openfeature/web-sdk';
import { E2E_CLIENT_NAME } from '@openfeature/flagd-core';
export const flagStepDefinitions: StepDefinitions = ({ given, and, when, then }) => {
let flagKey: string;
let value: FlagValue;
let details: EvaluationDetails<FlagValue>;
let fallback: FlagValue;
let context: EvaluationContext;
const client = OpenFeature.getClient(E2E_CLIENT_NAME);
beforeAll((done) => {
client.addHandler(ProviderEvents.Ready, () => {
done();
});
});
given('a stable provider', () => undefined);
given('a flagd provider is set', () => undefined);
when(
/^a boolean flag with key "(.*)" is evaluated with default value "(.*)"$/,
(key: string, defaultValue: string) => {
flagKey = key;
fallback = defaultValue;
value = client.getBooleanValue(key, defaultValue === 'true');
},
);
then(/^the resolved boolean value should be "(.*)"$/, (expectedValue: string) => {
expect(value).toEqual(expectedValue === 'true');
});
when(
/^a string flag with key "(.*)" is evaluated with default value "(.*)"$/,
(key: string, defaultValue: string) => {
flagKey = key;
fallback = defaultValue;
value = client.getStringValue(key, defaultValue);
},
);
then(/^the resolved string value should be "(.*)"$/, (expectedValue: string) => {
expect(value).toEqual(expectedValue);
});
when(
/^an integer flag with key "(.*)" is evaluated with default value (\d+)$/,
async (key: string, defaultValue: string) => {
flagKey = key;
fallback = Number(defaultValue);
value = client.getNumberValue(key, Number.parseInt(defaultValue));
},
);
then(/^the resolved integer value should be (\d+)$/, (expectedValue: string) => {
expect(value).toEqual(Number.parseInt(expectedValue));
});
when(
/^a float flag with key "(.*)" is evaluated with default value (\d+\.?\d*)$/,
async (key: string, defaultValue: string) => {
flagKey = key;
fallback = Number(defaultValue);
value = client.getNumberValue(key, Number.parseFloat(defaultValue));
},
);
then(/^the resolved float value should be (\d+\.?\d*)$/, (expectedValue: string) => {
expect(value).toEqual(Number.parseFloat(expectedValue));
});
when(/^an object flag with key "(.*)" is evaluated with a null default value$/, async (key: string) => {
const defaultValue = {};
flagKey = key;
fallback = '';
value = client.getObjectValue(key, defaultValue);
});
and(/^a flag with key "(.*)" is evaluated with default value "(.*)"$/, async (key, defaultValue) => {
await OpenFeature.setContext(context);
flagKey = key;
fallback = defaultValue;
value = client.getStringValue(flagKey, fallback as string);
});
when(
/^context contains keys "(.*)", "(.*)", "(.*)", "(.*)" with values "(.*)", "(.*)", (\d+), "(.*)"$/,
(key0, key1, key2, key3, stringVal1, stringVal2, intVal, boolVal) => {
context = {
[key0]: stringVal1,
[key1]: stringVal2,
[key2]: Number.parseInt(intVal),
[key3]: boolVal === true,
};
},
);
and(/^the resolved flag value is "(.*)" when the context is empty$/, async (expectedValue) => {
context = {};
await OpenFeature.setContext(context);
value = client.getStringValue(flagKey, fallback as string);
expect(value).toEqual(expectedValue);
});
then(
/^the resolved object value should be contain fields "(.*)", "(.*)", and "(.*)", with values "(.*)", "(.*)" and (\d+), respectively$/,
(field1: string, field2: string, field3: string, boolValue: string, stringValue: string, intValue: string) => {
const jsonObject = value as JsonObject;
expect(jsonObject[field1]).toEqual(boolValue === 'true');
expect(jsonObject[field2]).toEqual(stringValue);
expect(jsonObject[field3]).toEqual(Number.parseInt(intValue));
},
);
when(
/^a boolean flag with key "(.*)" is evaluated with details and default value "(.*)"$/,
async (key: string, defaultValue: string) => {
flagKey = key;
fallback = defaultValue;
details = client.getBooleanDetails(key, defaultValue === 'true');
},
);
then(
/^the resolved boolean details value should be "(.*)", the variant should be "(.*)", and the reason should be "(.*)"$/,
(expectedValue: string, expectedVariant: string, expectedReason: string) => {
expect(details).toBeDefined();
expect(details.value).toEqual(expectedValue === 'true');
expect(details.variant).toEqual(expectedVariant);
expect(details.reason).toEqual(expectedReason);
},
);
when(
/^a string flag with key "(.*)" is evaluated with details and default value "(.*)"$/,
(key: string, defaultValue: string) => {
flagKey = key;
fallback = defaultValue;
details = client.getStringDetails(key, defaultValue);
},
);
then(
/^the resolved string details value should be "(.*)", the variant should be "(.*)", and the reason should be "(.*)"$/,
(expectedValue: string, expectedVariant: string, expectedReason: string) => {
expect(details).toBeDefined();
expect(details.value).toEqual(expectedValue);
expect(details.variant).toEqual(expectedVariant);
expect(details.reason).toEqual(expectedReason);
},
);
when(
/^an integer flag with key "(.*)" is evaluated with details and default value (\d+)$/,
(key: string, defaultValue: string) => {
flagKey = key;
fallback = defaultValue;
details = client.getNumberDetails(key, Number.parseInt(defaultValue));
},
);
then(
/^the resolved integer details value should be (\d+), the variant should be "(.*)", and the reason should be "(.*)"$/,
(expectedValue: string, expectedVariant: string, expectedReason: string) => {
expect(details).toBeDefined();
expect(details.value).toEqual(Number.parseInt(expectedValue));
expect(details.variant).toEqual(expectedVariant);
expect(details.reason).toEqual(expectedReason);
},
);
when(
/^a float flag with key "(.*)" is evaluated with details and default value (\d+\.?\d*)$/,
(key: string, defaultValue: string) => {
flagKey = key;
fallback = defaultValue;
details = client.getNumberDetails(key, Number.parseFloat(defaultValue));
},
);
then(
/^the resolved float details value should be (\d+\.?\d*), the variant should be "(.*)", and the reason should be "(.*)"$/,
(expectedValue: string, expectedVariant: string, expectedReason: string) => {
expect(details).toBeDefined();
expect(details.value).toEqual(Number.parseFloat(expectedValue));
expect(details.variant).toEqual(expectedVariant);
expect(details.reason).toEqual(expectedReason);
},
);
when(/^an object flag with key "(.*)" is evaluated with details and a null default value$/, (key: string) => {
flagKey = key;
fallback = {};
details = client.getObjectDetails(key, {});
});
then(
/^the resolved object details value should be contain fields "(.*)", "(.*)", and "(.*)", with values "(.*)", "(.*)" and (\d+), respectively$/,
(field1: string, field2: string, field3: string, boolValue: string, stringValue: string, intValue: string) => {
expect(details).toBeDefined();
const jsonObject = details.value as JsonObject;
expect(jsonObject[field1]).toEqual(boolValue === 'true');
expect(jsonObject[field2]).toEqual(stringValue);
expect(jsonObject[field3]).toEqual(Number.parseInt(intValue));
},
);
and(
/^the variant should be "(.*)", and the reason should be "(.*)"$/,
(expectedVariant: string, expectedReason: string) => {
expect(details).toBeDefined();
expect(details.variant).toEqual(expectedVariant);
expect(details.reason).toEqual(expectedReason);
},
);
then(/^the resolved string response should be "(.*)"$/, (expectedValue: string) => {
expect(value).toEqual(expectedValue);
});
when(
/^a non-existent string flag with key "(.*)" is evaluated with details and a default value "(.*)"$/,
(key: string, defaultValue: string) => {
flagKey = key;
fallback = defaultValue;
details = client.getStringDetails(flagKey, defaultValue);
},
);
then(/^the default string value should be returned$/, () => {
expect(details).toBeDefined();
expect(details.value).toEqual(fallback);
});
and(
/^the reason should indicate an error and the error code should indicate a missing flag with "(.*)"$/,
(errorCode: string) => {
expect(details).toBeDefined();
expect(details.reason).toEqual(StandardResolutionReasons.ERROR);
expect(details.errorCode).toEqual(errorCode);
},
);
when(
/^a string flag with key "(.*)" is evaluated as an integer, with details and a default value (\d+)$/,
(key: string, defaultValue: string) => {
flagKey = key;
fallback = Number.parseInt(defaultValue);
details = client.getNumberDetails(flagKey, Number.parseInt(defaultValue));
},
);
then(/^the default integer value should be returned$/, () => {
expect(details).toBeDefined();
expect(details.value).toEqual(fallback);
});
and(
/^the reason should indicate an error and the error code should indicate a type mismatch with "(.*)"$/,
(errorCode: string) => {
expect(details).toBeDefined();
expect(details.reason).toEqual(StandardResolutionReasons.ERROR);
expect(details.errorCode).toEqual(errorCode);
},
);
let ran: boolean;
when('a PROVIDER_READY handler is added', () => {
client.addHandler(ProviderEvents.Ready, async () => {
ran = true;
});
});
then('the PROVIDER_READY handler must run', () => {
expect(ran).toBeTruthy();
});
when('a PROVIDER_CONFIGURATION_CHANGED handler is added', () => {
client.addHandler(ProviderEvents.ConfigurationChanged, async () => {
// file writes are not atomic, so we get a few events in quick succession from the testbed
// some will not contain changes, this tolerates that; at least 1 should have our change
// TODO: enable this for testing of issue
//if (details?.flagsChanged?.length) {
// flagsChanged = details?.flagsChanged;
// ran = true;
//}
// TODO: remove this for testing of issue
ran = true;
});
});
and(/^a flag with key "(.*)" is modified$/, async () => {
// this happens every 1s in the associated container, so wait 3s
await new Promise((resolve) => setTimeout(resolve, 3000));
});
then('the PROVIDER_CONFIGURATION_CHANGED handler must run', async () => {
expect(ran).toBeTruthy();
});
and(/^the event details must indicate "(.*)" was altered$/, () => {
// TODO: enable this for testing of issue
//expect(flagsChanged).toContain(flagName);
});
when(
/^a zero-value boolean flag with key "(.*)" is evaluated with default value "(.*)"$/,
(key, defaultVal: string) => {
flagKey = key;
fallback = defaultVal === 'true';
},
);
then(/^the resolved boolean zero-value should be "(.*)"$/, (expectedVal: string) => {
const expectedValue = expectedVal === 'true';
const value = client.getBooleanValue(flagKey, fallback as boolean);
expect(value).toEqual(expectedValue);
});
when(/^a zero-value string flag with key "(.*)" is evaluated with default value "(.*)"$/, (key, defaultVal) => {
flagKey = key;
fallback = defaultVal;
});
then('the resolved string zero-value should be ""', () => {
const value = client.getStringValue(flagKey, fallback as string);
expect(value).toEqual('');
});
when(/^a zero-value integer flag with key "(.*)" is evaluated with default value (\d+)$/, (key, defaultVal) => {
flagKey = key;
fallback = defaultVal;
});
then(/^the resolved integer zero-value should be (\d+)$/, (expectedValueString) => {
const expectedValue = Number.parseInt(expectedValueString);
const value = client.getNumberValue(flagKey, fallback as number);
expect(value).toEqual(expectedValue);
});
when(
/^a zero-value float flag with key "(.*)" is evaluated with default value (\d+\.\d+)$/,
(key, defaultValueString) => {
flagKey = key;
fallback = Number.parseFloat(defaultValueString);
},
);
then(/^the resolved float zero-value should be (\d+\.\d+)$/, (expectedValueString) => {
const expectedValue = Number.parseFloat(expectedValueString);
const value = client.getNumberValue(flagKey, fallback as number);
expect(value).toEqual(expectedValue);
});
then(/^the returned reason should be "(.*)"$/, (expectedReason) => {
expect(details.reason).toEqual(expectedReason);
});
};

View File

@ -1 +0,0 @@
export * from './flag';

View File

@ -1,52 +0,0 @@
import assert from 'assert';
import { OpenFeature } from '@openfeature/web-sdk';
import type { StartedTestContainer } from 'testcontainers';
import { GenericContainer } from 'testcontainers';
import { FlagdWebProvider } from '../../lib/flagd-web-provider';
import { autoBindSteps, loadFeature } from 'jest-cucumber';
import { FLAGD_NAME, GHERKIN_EVALUATION_FEATURE } from '../constants';
import { flagStepDefinitions } from '../step-definitions';
import { E2E_CLIENT_NAME, IMAGE_VERSION } from '@openfeature/flagd-core';
// register the flagd provider before the tests.
async function setup() {
const containers: StartedTestContainer[] = [];
console.log('Setting flagd provider...');
const stable = await new GenericContainer(`ghcr.io/open-feature/flagd-testbed:${IMAGE_VERSION}`)
.withExposedPorts(8013)
.start();
containers.push(stable);
const flagdWebProvider = new FlagdWebProvider({
host: stable.getHost(),
port: stable.getMappedPort(8013),
tls: false,
maxRetries: -1,
});
await OpenFeature.setProviderAndWait(E2E_CLIENT_NAME, flagdWebProvider);
assert(
OpenFeature.getProviderMetadata(E2E_CLIENT_NAME).name === FLAGD_NAME,
new Error(
`Expected ${E2E_CLIENT_NAME} provider to be configured, instead got: ${OpenFeature.providerMetadata.name}`,
),
);
console.log('flagd provider configured!');
return containers;
}
describe('web provider', () => {
let containers: StartedTestContainer[] = [];
beforeAll(async () => {
containers = await setup();
}, 60000);
afterAll(async () => {
await OpenFeature.close();
for (const container of containers) {
container.stop();
}
});
const features = [loadFeature(GHERKIN_EVALUATION_FEATURE)];
autoBindSteps(features, [flagStepDefinitions]);
});

View File

@ -1,11 +1,9 @@
import type { CallbackClient, ConnectError, PromiseClient } from '@connectrpc/connect';
import { Code } from '@connectrpc/connect';
import { CallbackClient, Code, ConnectError, PromiseClient } from '@bufbuild/connect';
import { Struct } from '@bufbuild/protobuf';
import type { Client, JsonValue } from '@openfeature/web-sdk';
import { ErrorCode, OpenFeature, ProviderEvents, StandardResolutionReasons } from '@openfeature/web-sdk';
import { Client, ErrorCode, JsonValue, OpenFeature, ProviderEvents, StandardResolutionReasons } from '@openfeature/web-sdk';
import fetchMock from 'jest-fetch-mock';
import type { Service } from '../proto/ts/flagd/evaluation/v1/evaluation_connect';
import type { AnyFlag, EventStreamResponse, ResolveAllResponse } from '../proto/ts/flagd/evaluation/v1/evaluation_pb';
import { Service } from '../proto/ts/schema/v1/schema_connect';
import { AnyFlag, EventStreamResponse, ResolveAllResponse } from '../proto/ts/schema/v1/schema_pb';
import { FlagdWebProvider } from './flagd-web-provider';
const EVENT_CONFIGURATION_CHANGE = 'configuration_change';
@ -54,7 +52,7 @@ class MockCallbackClient implements Partial<CallbackClient<typeof Service>> {
(
_,
messageCallback: (response: EventStreamResponse) => void,
closeCallback: (error: ConnectError) => void,
closeCallback: (error: ConnectError) => void
): (() => void) => {
this.messageCallback = messageCallback;
this.closeCallback = closeCallback;
@ -65,7 +63,7 @@ class MockCallbackClient implements Partial<CallbackClient<typeof Service>> {
}
return this.cancelFunction;
},
}
);
}
@ -124,9 +122,9 @@ describe(FlagdWebProvider.name, () => {
{ host: 'fake.com' },
console,
new MockPromiseClient() as unknown as PromiseClient<typeof Service>,
mockCallbackClient as unknown as CallbackClient<typeof Service>,
mockCallbackClient as unknown as CallbackClient<typeof Service>
);
OpenFeature.setProvider('resolution functionality test', provider);
OpenFeature.setProvider(provider);
client = OpenFeature.getClient('resolution functionality test');
client.addHandler(ProviderEvents.Ready, () => {
@ -167,7 +165,6 @@ describe(FlagdWebProvider.name, () => {
});
describe('events', () => {
let provider: FlagdWebProvider;
let client: Client;
let mockCallbackClient: MockCallbackClient;
const mockPromiseClient = new MockPromiseClient() as unknown as PromiseClient<typeof Service>;
@ -175,40 +172,32 @@ describe(FlagdWebProvider.name, () => {
beforeEach(() => {
mockCallbackClient = new MockCallbackClient();
provider = new FlagdWebProvider(
{ host: 'fake.com', maxRetries: -1 },
console,
mockPromiseClient,
mockCallbackClient as unknown as CallbackClient<typeof Service>,
OpenFeature.setProvider(
new FlagdWebProvider(
{ host: 'fake.com', maxRetries: -1 },
console,
mockPromiseClient,
mockCallbackClient as unknown as CallbackClient<typeof Service>
)
);
OpenFeature.setProvider('events-test', provider);
client = OpenFeature.getClient('events-test');
});
describe(ProviderEvents.Ready, () => {
it('should fire as soon as client subscribes, if ready', (done) => {
it('should be fired as soon as client subscribes, if ready', (done) => {
mockCallbackClient.mockMessage({
type: EVENT_PROVIDER_READY,
});
client.addHandler(ProviderEvents.Ready, () => {
try {
done();
} catch (err) {
done(err);
}
done();
});
});
it('should fire and be ready if message received', (done) => {
it('should fire if message received', (done) => {
client.addHandler(ProviderEvents.Ready, () => {
try {
done();
} catch (err) {
done(err);
}
done();
});
mockCallbackClient.mockMessage({
type: EVENT_PROVIDER_READY,
});
@ -226,13 +215,12 @@ describe(FlagdWebProvider.name, () => {
});
it('should trigger call to resolveAll with current context', (done) => {
client.addHandler(ProviderEvents.ConfigurationChanged, () => {
try {
expect(mockPromiseClient.resolveAll).toHaveBeenLastCalledWith({
context: Struct.fromJson(context as JsonValue),
});
expect(mockPromiseClient.resolveAll).toHaveBeenLastCalledWith({context: Struct.fromJson(context as JsonValue)});
done();
} catch (err) {
} catch(err) {
done(err);
}
});
@ -246,13 +234,8 @@ describe(FlagdWebProvider.name, () => {
describe(ProviderEvents.Error, () => {
it('should fire if message received', (done) => {
client.addHandler(ProviderEvents.Error, (event) => {
try {
expect(event?.providerName).toBe('flagd');
done();
} catch (err) {
done(err);
}
client.addHandler(ProviderEvents.Error, () => {
done();
});
mockCallbackClient.mockClose({
code: Code.Unavailable,
@ -268,20 +251,19 @@ describe(FlagdWebProvider.name, () => {
beforeEach(() => {
mockCallbackClient = new MockCallbackClient();
OpenFeature.setProvider(
'shutdown test',
new FlagdWebProvider(
{ host: 'fake.com', maxRetries: -1 },
console,
mockPromiseClient,
mockCallbackClient as unknown as CallbackClient<typeof Service>,
),
mockCallbackClient as unknown as CallbackClient<typeof Service>
)
);
});
describe('API close', () => {
it('should call cancel function on provider', async () => {
it('should call cancel function on provider', () => {
expect(mockCallbackClient.cancelFunction).not.toHaveBeenCalled();
await OpenFeature.close();
OpenFeature.close();
expect(mockCallbackClient.cancelFunction).toHaveBeenCalled();
});
});
@ -292,13 +274,12 @@ describe(FlagdWebProvider.name, () => {
it('should attempt reconnect many times', (done) => {
const mockCallbackClient = new MockCallbackClient();
OpenFeature.setProvider(
'should attempt many reconnect test',
new FlagdWebProvider(
{ host: 'fake.com' },
console,
undefined,
mockCallbackClient as unknown as CallbackClient<typeof Service>,
),
mockCallbackClient as unknown as CallbackClient<typeof Service>
)
);
mockCallbackClient.fail = true;
mockCallbackClient.mockClose({
@ -319,13 +300,12 @@ describe(FlagdWebProvider.name, () => {
it('should attempt reconnect if maxRetries (1) times', (done) => {
const mockCallbackClient = new MockCallbackClient();
OpenFeature.setProvider(
'should connect test',
new FlagdWebProvider(
{ host: 'fake.com', maxRetries: 1 },
console,
undefined,
mockCallbackClient as unknown as CallbackClient<typeof Service>,
),
mockCallbackClient as unknown as CallbackClient<typeof Service>
)
);
mockCallbackClient.fail = true;
@ -345,13 +325,12 @@ describe(FlagdWebProvider.name, () => {
it('should NOT attempt reconnect if maxRetries (-1) times', (done) => {
const mockCallbackClient = new MockCallbackClient();
OpenFeature.setProvider(
'should not reconnect test',
new FlagdWebProvider(
{ host: 'fake.com', maxRetries: -1 },
console,
undefined,
mockCallbackClient as unknown as CallbackClient<typeof Service>,
),
mockCallbackClient as unknown as CallbackClient<typeof Service>
)
);
mockCallbackClient.fail = true;
@ -378,9 +357,9 @@ describe(FlagdWebProvider.name, () => {
{ host: 'fake.com' },
console,
new MockPromiseClient() as unknown as PromiseClient<typeof Service>,
mockCallbackClient as unknown as CallbackClient<typeof Service>,
mockCallbackClient as unknown as CallbackClient<typeof Service>
);
OpenFeature.setProvider('resolution functionality test', provider);
OpenFeature.setProvider(provider);
client = OpenFeature.getClient('resolution functionality test');
client.addHandler(ProviderEvents.Ready, () => {

View File

@ -1,27 +1,23 @@
import type { CallbackClient, PromiseClient } from '@connectrpc/connect';
import { createCallbackClient, createPromiseClient } from '@connectrpc/connect';
import { createConnectTransport } from '@connectrpc/connect-web';
import { CallbackClient, createCallbackClient, createPromiseClient, PromiseClient } from '@bufbuild/connect';
import { createConnectTransport } from '@bufbuild/connect-web';
import { Struct } from '@bufbuild/protobuf';
import type {
import {
EvaluationContext,
FlagNotFoundError,
FlagValue,
JsonValue,
Logger,
Provider,
ResolutionDetails,
} from '@openfeature/web-sdk';
import {
FlagNotFoundError,
OpenFeature,
OpenFeatureEventEmitter,
Provider,
ProviderEvents,
ResolutionDetails,
StandardResolutionReasons,
TypeMismatchError,
} from '@openfeature/web-sdk';
import { Service } from '../proto/ts/flagd/evaluation/v1/evaluation_connect';
import type { AnyFlag } from '../proto/ts/flagd/evaluation/v1/evaluation_pb';
import type { FlagdProviderOptions } from './options';
import { getOptions } from './options';
import { Service } from '../proto/ts/schema/v1/schema_connect';
import { AnyFlag } from '../proto/ts/schema/v1/schema_pb';
import { FlagdProviderOptions, getOptions } from './options';
export const ERROR_DISABLED = 'DISABLED';
@ -34,12 +30,9 @@ type AnyFlagResolutionType = typeof AnyFlag.prototype.value.case;
export class FlagdWebProvider implements Provider {
metadata = {
name: 'flagd',
name: 'flagd-web',
};
readonly runsOn = 'client';
readonly events = new OpenFeatureEventEmitter();
private _connected = false;
private _promiseClient: PromiseClient<typeof Service>;
private _callbackClient: CallbackClient<typeof Service>;
@ -55,12 +48,11 @@ export class FlagdWebProvider implements Provider {
options: FlagdProviderOptions,
logger?: Logger,
promiseClient?: PromiseClient<typeof Service>,
callbackClient?: CallbackClient<typeof Service>,
callbackClient?: CallbackClient<typeof Service>
) {
const { host, port, tls, maxRetries, maxDelay, pathPrefix } = getOptions(options);
const transport = createConnectTransport({
baseUrl: `${tls ? 'https' : 'http'}://${host}:${port}/${pathPrefix}`,
interceptors: options.interceptors,
});
this._promiseClient = promiseClient ? promiseClient : createPromiseClient(Service, transport);
this._callbackClient = callbackClient ? callbackClient : createCallbackClient(Service, transport);
@ -69,6 +61,8 @@ export class FlagdWebProvider implements Provider {
this._logger = logger;
}
events = new OpenFeatureEventEmitter();
async onContextChange(oldContext: EvaluationContext, newContext: EvaluationContext): Promise<void> {
await this.fetchAll(newContext);
}
@ -111,7 +105,6 @@ export class FlagdWebProvider implements Provider {
reason: this._connected ? resolved.reason : StandardResolutionReasons.CACHED,
variant: resolved.variant,
value: resolved.value as T,
flagMetadata: resolved.flagMetadata,
};
}
@ -141,7 +134,7 @@ export class FlagdWebProvider implements Provider {
}
},
(err) => {
this._logger?.error(`${FlagdWebProvider.name}: could not establish connection, ${err?.message}`);
this._logger?.error(`${FlagdWebProvider.name}: could not establish connection to flagd, ${err?.message}`);
this._logger?.debug(err?.stack);
if (this._retry < this._maxRetries) {
this._retry++;
@ -150,7 +143,7 @@ export class FlagdWebProvider implements Provider {
this._logger?.warn(`${FlagdWebProvider.name}: max retries reached`);
this.events.emit(ProviderEvents.Error);
}
},
}
);
});
}
@ -158,12 +151,12 @@ export class FlagdWebProvider implements Provider {
private async fetchAll(context: EvaluationContext) {
const transformedContext = this.transformContext(context);
const allResolved = await this._promiseClient.resolveAll({ context: transformedContext });
this._flags = Object.keys(allResolved.flags).reduce((accumulated, currentKey) => {
this._flags = Object.keys(allResolved.flags).reduce((accumuated, currentKey) => {
const resolved = allResolved.flags[currentKey];
// reducer to store the resolved bulk response in a map of ResolutionDetails,
// with an addition annotation for the type (typeof AnyFlag.prototype.value.case)
return {
...accumulated,
...accumuated,
[currentKey]: {
type: resolved.value.case,
reason: resolved.reason,

View File

@ -1,5 +1,3 @@
import type { Interceptor } from '@connectrpc/connect';
export interface Options {
/**
* The domain name or IP address of flagd.
@ -15,7 +13,7 @@ export interface Options {
/**
* The path at which the flagd gRPC service is available, for example: /flagd-api (optional).
*
*
* @default ""
*/
pathPrefix: string;
@ -41,11 +39,6 @@ export interface Options {
* @default 0
*/
maxRetries: number;
/**
* Connect interceptors applied to all calls.
*/
interceptors?: Interceptor[];
}
export type FlagdProviderOptions = Partial<Options> & Pick<Options, 'host'>;
@ -59,7 +52,7 @@ export function getOptions(options: FlagdProviderOptions): Options {
tls: true,
maxRetries: 0,
maxDelay: DEFAULT_MAX_DELAY,
pathPrefix: '',
pathPrefix: ""
},
...options,
};

View File

@ -9,17 +9,8 @@
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true
"allowSyntheticDefaultImports": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
"include": []
}

View File

@ -5,8 +5,9 @@
"outDir": "../../../dist/out-tsc",
"declaration": true,
"types": [],
"allowSyntheticDefaultImports": true
"allowSyntheticDefaultImports": true,
},
"include": ["**/*.ts"],
"exclude": ["jest.config.ts", "**/*.spec.ts", "**/*.test.ts", "src/e2e/"]
"exclude": ["jest.config.ts", "**/*.spec.ts", "**/*.test.ts"]
}

View File

@ -4,7 +4,7 @@
"outDir": "../../../dist/out-tsc",
"module": "commonjs",
"types": ["jest"],
"allowJs": true
"allowJs": true,
},
"include": ["jest.config.ts", "**/*.test.ts", "**/*.spec.ts", "**/*.d.ts", "./src/e2e/"]
"include": ["jest.config.ts", "**/*.test.ts", "**/*.spec.ts", "**/*.d.ts"]
}

View File

@ -1,6 +1,6 @@
{
"extends": ["../../../.eslintrc.json"],
"ignorePatterns": ["!**/*", "node_modules", "src/proto/**", "schemas/**", "spec/**"],
"ignorePatterns": ["!**/*", "node_modules", "src/proto/**"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],

View File

@ -1,257 +1,5 @@
# Changelog
## [0.13.3](https://github.com/open-feature/js-sdk-contrib/compare/flagd-provider-v0.13.2...flagd-provider-v0.13.3) (2025-02-07)
### ✨ New Features
* support proxy routing via gRPC default_authority ([#1202](https://github.com/open-feature/js-sdk-contrib/issues/1202)) ([4e0db2a](https://github.com/open-feature/js-sdk-contrib/commit/4e0db2abda828151603ab943c0046536c8d2aa8d))
### 🧹 Chore
* **deps:** update libs/providers/flagd/schemas digest to bb76343 ([#1168](https://github.com/open-feature/js-sdk-contrib/issues/1168)) ([2304efe](https://github.com/open-feature/js-sdk-contrib/commit/2304efebe4c05672b479e983adba1526239b1a1d))
* **deps:** update libs/providers/flagd/spec digest to 5b07065 ([#1189](https://github.com/open-feature/js-sdk-contrib/issues/1189)) ([215c983](https://github.com/open-feature/js-sdk-contrib/commit/215c983d7c71c0f9d4e711d3e359171a36b7a59a))
## [0.13.2](https://github.com/open-feature/js-sdk-contrib/compare/flagd-provider-v0.13.1...flagd-provider-v0.13.2) (2025-01-10)
### ✨ New Features
* **flagd:** add flag metadata ([#1151](https://github.com/open-feature/js-sdk-contrib/issues/1151)) ([b1c6d23](https://github.com/open-feature/js-sdk-contrib/commit/b1c6d235565f6cce02519d7c08bb6ad2dd791332))
### 🧹 Chore
* **deps:** update dependency @grpc/grpc-js to ~1.8.0 || ~1.9.0 || ~1.10.0 || ~1.11.0 ([#1023](https://github.com/open-feature/js-sdk-contrib/issues/1023)) ([f2247d3](https://github.com/open-feature/js-sdk-contrib/commit/f2247d3adfa33b0b5bcc7d07184d6c6bca534ee6))
* **deps:** update dependency @grpc/grpc-js to ~1.8.0 || ~1.9.0 || ~1.10.0 || ~1.11.0 || ~1.12.0 ([#1064](https://github.com/open-feature/js-sdk-contrib/issues/1064)) ([1e8eca8](https://github.com/open-feature/js-sdk-contrib/commit/1e8eca8409e4e09196b9044a97268972ddd66c2f))
* **deps:** update ghcr.io/open-feature/flagd-testbed docker tag to v0.5.13 ([#1068](https://github.com/open-feature/js-sdk-contrib/issues/1068)) ([75c5b10](https://github.com/open-feature/js-sdk-contrib/commit/75c5b10feec2165c6f2f176bcde011e78f9791d0))
* **deps:** update ghcr.io/open-feature/flagd-testbed docker tag to v0.5.20 ([#1137](https://github.com/open-feature/js-sdk-contrib/issues/1137)) ([f5f110c](https://github.com/open-feature/js-sdk-contrib/commit/f5f110c1ac41cd1a556de5aaa4c198f4fcba3ccf))
* **deps:** update ghcr.io/open-feature/flagd-testbed docker tag to v0.5.21 ([#1155](https://github.com/open-feature/js-sdk-contrib/issues/1155)) ([1150c9b](https://github.com/open-feature/js-sdk-contrib/commit/1150c9b818fc9e9a2fac542bca08377cdb90211e))
* **deps:** update ghcr.io/open-feature/flagd-testbed-unstable docker tag to v0.5.13 ([#1073](https://github.com/open-feature/js-sdk-contrib/issues/1073)) ([bfadf65](https://github.com/open-feature/js-sdk-contrib/commit/bfadf65eebf8002bf4761a7ae223027295cc545c))
* **deps:** update ghcr.io/open-feature/flagd-testbed-unstable docker tag to v0.5.20 ([#1138](https://github.com/open-feature/js-sdk-contrib/issues/1138)) ([d54c9a9](https://github.com/open-feature/js-sdk-contrib/commit/d54c9a938c5456d7e1bde684cf6bfed8b08a759d))
* **deps:** update ghcr.io/open-feature/flagd-testbed-unstable docker tag to v0.5.21 ([#1159](https://github.com/open-feature/js-sdk-contrib/issues/1159)) ([4e0c983](https://github.com/open-feature/js-sdk-contrib/commit/4e0c9839cc631f62d29778005bb5751b5e770f9e))
* **deps:** update ghcr.io/open-feature/sync-testbed docker tag to v0.5.13 ([#1074](https://github.com/open-feature/js-sdk-contrib/issues/1074)) ([194bafb](https://github.com/open-feature/js-sdk-contrib/commit/194bafbabcdea7e02001754377ba72c16d10d759))
* **deps:** update ghcr.io/open-feature/sync-testbed docker tag to v0.5.20 ([#1139](https://github.com/open-feature/js-sdk-contrib/issues/1139)) ([401b310](https://github.com/open-feature/js-sdk-contrib/commit/401b3102ffe9803584344ad50ff77ceed7ecf472))
* **deps:** update ghcr.io/open-feature/sync-testbed docker tag to v0.5.21 ([#1160](https://github.com/open-feature/js-sdk-contrib/issues/1160)) ([45a9b03](https://github.com/open-feature/js-sdk-contrib/commit/45a9b030e15efe8121fa05aca58fec76b2c63b84))
* **deps:** update ghcr.io/open-feature/sync-testbed-unstable docker tag to v0.5.13 ([#1075](https://github.com/open-feature/js-sdk-contrib/issues/1075)) ([3a97b36](https://github.com/open-feature/js-sdk-contrib/commit/3a97b36bf77b813effbf80d00e4a87b54e7de320))
* **deps:** update libs/providers/flagd/schemas digest to 37baa2c ([#1149](https://github.com/open-feature/js-sdk-contrib/issues/1149)) ([f3c2c8e](https://github.com/open-feature/js-sdk-contrib/commit/f3c2c8efc30dc3c55c87446b30b4bb462b0220a0))
* **deps:** update libs/providers/flagd/schemas digest to b81a56e ([#1132](https://github.com/open-feature/js-sdk-contrib/issues/1132)) ([cd069da](https://github.com/open-feature/js-sdk-contrib/commit/cd069da13aecec9abe44113802d70a44dfebc8d6))
* **deps:** update libs/providers/flagd/spec digest to d261f68 ([#1150](https://github.com/open-feature/js-sdk-contrib/issues/1150)) ([4366710](https://github.com/open-feature/js-sdk-contrib/commit/43667107c1e9202b0dbcb2e44abbf5de0feb1e06))
* **deps:** update libs/providers/flagd/spec digest to ed0f9ef ([#1133](https://github.com/open-feature/js-sdk-contrib/issues/1133)) ([938d3a1](https://github.com/open-feature/js-sdk-contrib/commit/938d3a108488016df14d82abaa8a2c400bbc75f3))
* removing build dependencies and using testcontainers for container spin up ([#982](https://github.com/open-feature/js-sdk-contrib/issues/982)) ([2d64331](https://github.com/open-feature/js-sdk-contrib/commit/2d6433101b76ba9ad266095fe31b58314f82a105))
* update nx packages ([#1147](https://github.com/open-feature/js-sdk-contrib/issues/1147)) ([7f310fe](https://github.com/open-feature/js-sdk-contrib/commit/7f310fe87101b8aa793e1436e63c7602ccc202e3))
* various gherkin improvements for e2e tests ([#1008](https://github.com/open-feature/js-sdk-contrib/issues/1008)) ([40abd8e](https://github.com/open-feature/js-sdk-contrib/commit/40abd8eca76b47bb5c084b377302821968acd19c))
## [0.13.1](https://github.com/open-feature/js-sdk-contrib/compare/flagd-provider-v0.13.0...flagd-provider-v0.13.1) (2024-07-08)
### 🐛 Bug Fixes
* **deps:** update dependency @openfeature/flagd-core to ~0.2.0 ([#880](https://github.com/open-feature/js-sdk-contrib/issues/880)) ([f15909f](https://github.com/open-feature/js-sdk-contrib/commit/f15909ff6fb32fd423233ad77d57f21b265ec61a))
### ✨ New Features
* Change fractional custom op from percentage-based to relative weighting. [#946](https://github.com/open-feature/js-sdk-contrib/issues/946) ([#954](https://github.com/open-feature/js-sdk-contrib/issues/954)) ([0e9bc84](https://github.com/open-feature/js-sdk-contrib/commit/0e9bc842cf09de12e8445dcb4e0e8b3623c66099))
* Default port to 8015 if in-process resolver is used. [#936](https://github.com/open-feature/js-sdk-contrib/issues/936) ([#937](https://github.com/open-feature/js-sdk-contrib/issues/937)) ([53c4077](https://github.com/open-feature/js-sdk-contrib/commit/53c4077f84a1976d69c3846a0049619a1dfa6607))
### 🧹 Chore
* fix e2e test ([#977](https://github.com/open-feature/js-sdk-contrib/issues/977)) ([29a6735](https://github.com/open-feature/js-sdk-contrib/commit/29a673553f93ecae1adcec0d3d23a6e77363d3f5))
* fix fractional tests ([#984](https://github.com/open-feature/js-sdk-contrib/issues/984)) ([6a54935](https://github.com/open-feature/js-sdk-contrib/commit/6a54935f3bbff99d1abc8599f667cda7b0a6efe4))
* loosen some test assertions, fix e2e matcher ([#933](https://github.com/open-feature/js-sdk-contrib/issues/933)) ([8def607](https://github.com/open-feature/js-sdk-contrib/commit/8def6072c5d29eaf81d7262b6878cb3d6ff40483))
* remove explicit dep, use root ([#917](https://github.com/open-feature/js-sdk-contrib/issues/917)) ([a8c0be1](https://github.com/open-feature/js-sdk-contrib/commit/a8c0be1810a4baef62fcd453a57acd3edd3155d0))
## [0.13.0](https://github.com/open-feature/js-sdk-contrib/compare/flagd-provider-v0.12.0...flagd-provider-v0.13.0) (2024-04-17)
### ⚠ BREAKING CHANGES
* allow overrides for fractional seed ([#870](https://github.com/open-feature/js-sdk-contrib/issues/870))
### ✨ New Features
* allow overrides for fractional seed ([#870](https://github.com/open-feature/js-sdk-contrib/issues/870)) ([6c376b2](https://github.com/open-feature/js-sdk-contrib/commit/6c376b2f525be04c15b5c3bd32d89cc9c4c66729))
### 🧹 Chore
* Lint Fix Providers ([#837](https://github.com/open-feature/js-sdk-contrib/issues/837)) ([8c6c46b](https://github.com/open-feature/js-sdk-contrib/commit/8c6c46b5f8f72c5a292af7e5ff8ad8d710982554))
## [0.12.0](https://github.com/open-feature/js-sdk-contrib/compare/flagd-provider-v0.11.1...flagd-provider-v0.12.0) (2024-03-16)
### ⚠ BREAKING CHANGES
* update OpenFeature SDK peer ([#798](https://github.com/open-feature/js-sdk-contrib/issues/798))
### ✨ New Features
* update OpenFeature SDK peer ([#798](https://github.com/open-feature/js-sdk-contrib/issues/798)) ([ebd16b9](https://github.com/open-feature/js-sdk-contrib/commit/ebd16b9630bcc6b253a7061a144e8d476cd8b586))
### 📚 Documentation
* fix resolve type environment variable ([eaf7788](https://github.com/open-feature/js-sdk-contrib/commit/eaf7788e028a0c91cab4d6bf5b5645456aef0904))
## [0.11.1](https://github.com/open-feature/js-sdk-contrib/compare/flagd-provider-v0.11.0...flagd-provider-v0.11.1) (2024-02-15)
### ✨ New Features
* use updated proto ([#770](https://github.com/open-feature/js-sdk-contrib/issues/770)) ([5405af5](https://github.com/open-feature/js-sdk-contrib/commit/5405af57d0ecaa64796dc87c90e98d83fe246e6c))
## [0.11.0](https://github.com/open-feature/js-sdk-contrib/compare/flagd-provider-v0.10.5...flagd-provider-v0.11.0) (2024-02-14)
### ⚠ BREAKING CHANGES
* use new eval/sync protos (requires flagd v0.7.3+) ([#762](https://github.com/open-feature/js-sdk-contrib/issues/762))
### 🐛 Bug Fixes
* init in-process error, throw on invalid rules ([#767](https://github.com/open-feature/js-sdk-contrib/issues/767)) ([e9f9e74](https://github.com/open-feature/js-sdk-contrib/commit/e9f9e74d66e9f8666eebb8d06141fce713c7914c))
### ✨ New Features
* use new eval/sync protos (requires flagd v0.7.3+) ([#762](https://github.com/open-feature/js-sdk-contrib/issues/762)) ([4da9deb](https://github.com/open-feature/js-sdk-contrib/commit/4da9deb48c6bd0c106b176fc7e3730cf50e60b6d))
### 🧹 Chore
* **deps:** update dependency @grpc/grpc-js to ~1.8.0 || ~1.9.0 || ~1.10.0 ([#764](https://github.com/open-feature/js-sdk-contrib/issues/764)) ([c05bf9d](https://github.com/open-feature/js-sdk-contrib/commit/c05bf9d8b5980f60611e92a2bab024306e397ec0))
## [0.10.5](https://github.com/open-feature/js-sdk-contrib/compare/flagd-provider-v0.10.4...flagd-provider-v0.10.5) (2024-01-30)
### ✨ New Features
* add offline mode file path env ([#751](https://github.com/open-feature/js-sdk-contrib/issues/751)) ([4ff73e7](https://github.com/open-feature/js-sdk-contrib/commit/4ff73e787693cd2e783200e6c165352a2906185b))
## [0.10.4](https://github.com/open-feature/js-sdk-contrib/compare/flagd-provider-v0.10.3...flagd-provider-v0.10.4) (2024-01-10)
### ✨ New Features
* add flag metadata to in-process evaluator ([#709](https://github.com/open-feature/js-sdk-contrib/issues/709)) ([2a4c50b](https://github.com/open-feature/js-sdk-contrib/commit/2a4c50b9675ca01d2c1976ddfa1b2b080bb90488))
* add offline mode, fix in-process connection edge cases ([#708](https://github.com/open-feature/js-sdk-contrib/issues/708)) ([3d56225](https://github.com/open-feature/js-sdk-contrib/commit/3d5622594befde03e74fafc7857cd7cd49ceeb59))
### 🧹 Chore
* fix lint issues and bump server sdk version ([#715](https://github.com/open-feature/js-sdk-contrib/issues/715)) ([bd57177](https://github.com/open-feature/js-sdk-contrib/commit/bd571770f3a1a01bd62663dc3473273449f96c5c))
## [0.10.3](https://github.com/open-feature/js-sdk-contrib/compare/flagd-provider-v0.10.2...flagd-provider-v0.10.3) (2023-12-13)
### 🧹 Chore
* improve logger, parsing and add helpers ([#689](https://github.com/open-feature/js-sdk-contrib/issues/689)) ([fa0a238](https://github.com/open-feature/js-sdk-contrib/commit/fa0a238bc4533e431e2c2969303866e74f4f181f))
* update min flagd core to ~0.1.4 ([9afcc19](https://github.com/open-feature/js-sdk-contrib/commit/9afcc194e473fcc37efca5c6eb2d21a1dc71f567))
## [0.10.2](https://github.com/open-feature/js-sdk-contrib/compare/flagd-provider-v0.10.1...flagd-provider-v0.10.2) (2023-12-12)
### 🐛 Bug Fixes
* hanging grpc handles after shutdown ([#683](https://github.com/open-feature/js-sdk-contrib/issues/683)) ([848d7ae](https://github.com/open-feature/js-sdk-contrib/commit/848d7ae844ced7938531c9606bdbddb8fa68a2d7))
## [0.10.1](https://github.com/open-feature/js-sdk-contrib/compare/flagd-provider-v0.10.0...flagd-provider-v0.10.1) (2023-12-06)
### 🐛 Bug Fixes
* "in" op error, update core ([#675](https://github.com/open-feature/js-sdk-contrib/issues/675)) ([69944a8](https://github.com/open-feature/js-sdk-contrib/commit/69944a8117625b83704284e35a2ad807c63f8420))
## [0.10.0](https://github.com/open-feature/js-sdk-contrib/compare/flagd-provider-v0.9.0...flagd-provider-v0.10.0) (2023-11-28)
### ⚠ BREAKING CHANGES
* reconnect, missing and duped events, remove max reconnect ([#660](https://github.com/open-feature/js-sdk-contrib/issues/660))
### 🐛 Bug Fixes
* orphaned grpc connection, semver ~, change events ([#654](https://github.com/open-feature/js-sdk-contrib/issues/654)) ([5afbea7](https://github.com/open-feature/js-sdk-contrib/commit/5afbea754983f95858bf1bdfd15ab51793b0b72e))
* reconnect, missing and duped events, remove max reconnect ([#660](https://github.com/open-feature/js-sdk-contrib/issues/660)) ([8489c2f](https://github.com/open-feature/js-sdk-contrib/commit/8489c2f47ea3a619c3b430edffb00f3cabeb2e1e))
### ✨ New Features
* flagd in-process provider ([#633](https://github.com/open-feature/js-sdk-contrib/issues/633)) ([2213946](https://github.com/open-feature/js-sdk-contrib/commit/2213946d9aa69c9e86325543c8ac60fbc5319d08))
### 🧹 Chore
* **deps:** update dependency @grpc/grpc-js to ~1.8.0 || ~1.9.0 ([#662](https://github.com/open-feature/js-sdk-contrib/issues/662)) ([2b977c2](https://github.com/open-feature/js-sdk-contrib/commit/2b977c266cbb874e0c245e7200237acfceafbb9e))
## [0.9.0](https://github.com/open-feature/js-sdk-contrib/compare/flagd-provider-v0.8.3...flagd-provider-v0.9.0) (2023-10-11)
### ⚠ BREAKING CHANGES
* use @openfeature/server-sdk peer ([#608](https://github.com/open-feature/js-sdk-contrib/issues/608))
### 🐛 Bug Fixes
* packaging issues impacting babel/react ([#596](https://github.com/open-feature/js-sdk-contrib/issues/596)) ([0446eab](https://github.com/open-feature/js-sdk-contrib/commit/0446eab5cf9b45ce7de251b4f5feb8df1d499b9d))
### 🧹 Chore
* add e2e tests for flagd ([#554](https://github.com/open-feature/js-sdk-contrib/issues/554)) ([9ecdcdf](https://github.com/open-feature/js-sdk-contrib/commit/9ecdcdf1660fe27afb4b0c58160c7ba687e29be2))
* remove un-needed zero-value handling ([#539](https://github.com/open-feature/js-sdk-contrib/issues/539)) ([552be83](https://github.com/open-feature/js-sdk-contrib/commit/552be8303892c027623ccbb43548568e00c315a6))
* update nx, run migrations ([#552](https://github.com/open-feature/js-sdk-contrib/issues/552)) ([a88d8fc](https://github.com/open-feature/js-sdk-contrib/commit/a88d8fc097789fd7f56011e6ebb66070f52c6e56))
* use @openfeature/server-sdk peer ([#608](https://github.com/open-feature/js-sdk-contrib/issues/608)) ([ae3732a](https://github.com/open-feature/js-sdk-contrib/commit/ae3732a9068f684517db28ea1ae27b29a35e6b16))
* use spec submodule ([#568](https://github.com/open-feature/js-sdk-contrib/issues/568)) ([3feb18e](https://github.com/open-feature/js-sdk-contrib/commit/3feb18e0ffa77b87e799a2b5250413f03a4c69e9))
## [0.8.3](https://github.com/open-feature/js-sdk-contrib/compare/flagd-provider-v0.8.2...flagd-provider-v0.8.3) (2023-08-12)
### 🐛 Bug Fixes
* unhandled rejection on init ([#534](https://github.com/open-feature/js-sdk-contrib/issues/534)) ([b24b580](https://github.com/open-feature/js-sdk-contrib/commit/b24b580b20f942f192e7bbd68cc8baf3147d8137))
### 🧹 Chore
* fix submodule race ([#509](https://github.com/open-feature/js-sdk-contrib/issues/509)) ([a427a00](https://github.com/open-feature/js-sdk-contrib/commit/a427a0006ada4d54f5d83ae2d3167a87f6635e81))
## [0.8.2](https://github.com/open-feature/js-sdk-contrib/compare/flagd-provider-v0.8.1...flagd-provider-v0.8.2) (2023-07-28)
### ✨ New Features
* add flag metadata ([#502](https://github.com/open-feature/js-sdk-contrib/issues/502)) ([c8a80c6](https://github.com/open-feature/js-sdk-contrib/commit/c8a80c6317779d61808adb75088d6d6710c6d8ea))
## [0.8.1](https://github.com/open-feature/js-sdk-contrib/compare/flagd-provider-v0.8.0...flagd-provider-v0.8.1) (2023-07-27)
### 🐛 Bug Fixes
* issue with flagd not disconnecting ([#495](https://github.com/open-feature/js-sdk-contrib/issues/495)) ([ff61206](https://github.com/open-feature/js-sdk-contrib/commit/ff61206f7f51fd5ec30fc85ea2742c0933384330))
## [0.8.0](https://github.com/open-feature/js-sdk-contrib/compare/flagd-provider-v0.7.7...flagd-provider-v0.8.0) (2023-07-27)
### ⚠ BREAKING CHANGES
* events, init, shutdown ([#484](https://github.com/open-feature/js-sdk-contrib/issues/484))
* constructor arg order changed
* 1.3.0 minimum js-sdk version
### ✨ New Features
* events, init, shutdown ([#484](https://github.com/open-feature/js-sdk-contrib/issues/484)) ([a73fc76](https://github.com/open-feature/js-sdk-contrib/commit/a73fc7670c66b2108cef8132a94433d75dea3622))
### 🧹 Chore
* migrate buf ([#456](https://github.com/open-feature/js-sdk-contrib/issues/456)) ([8568af1](https://github.com/open-feature/js-sdk-contrib/commit/8568af1e26f92f4d0e9a942b9fc3e001d919ef03))
## [0.7.7](https://github.com/open-feature/js-sdk-contrib/compare/flagd-provider-v0.7.6...flagd-provider-v0.7.7) (2023-07-03)

View File

@ -1,134 +1,61 @@
# Server-Side flagd Provider
# Server-side flagd Provider
This provider is designed to use flagd's [evaluation protocol](https://github.com/open-feature/schemas/blob/main/protobuf/schema/v1/schema.proto), or locally evaluate flags defined in a flagd [flag definition](https://github.com/open-feature/schemas/blob/main/json/flagd-definitions.json).
Flagd is a simple daemon for evaluating feature flags.
It is designed to conform to OpenFeature schema for flag definitions.
This repository and package provides the client code for interacting with it via the OpenFeature server-side JavaScript SDK.
## Installation
### npm
```sh
npm install @openfeature/flagd-provider
```
$ npm install @openfeature/flagd-provider
```
### yarn
Required peer dependencies
```sh
yarn add @openfeature/server-sdk @grpc/grpc-js @openfeature/flagd-core
```
$ npm install @openfeature/js-sdk
```
> [!NOTE]
> yarn requires manual installation of peer dependencies
## Usage
## Configurations and Usage
The `FlagdProvider` supports multiple configuration options that determine now the SDK communicates with flagd.
Options can be defined in the constructor or as environment variables, with constructor options having the highest precedence.
The `FlagdProvider` supports multiple configuration options and has the ability to resolve flags remotely over RPC or in-process.
Options can be defined in the constructor or as environment variables. Constructor options having the highest precedence.
### Available options
### Available Configuration Options
| Option name | Environment variable name | Type | Default | Values |
| --------------------- | ------------------------------- | ------- | --------- | ------------- |
| host | FLAGD_HOST | string | localhost | |
| port | FLAGD_PORT | number | 8013 | |
| tls | FLAGD_TLS | boolean | false | |
| socketPath | FLAGD_SOCKET_PATH | string | - | |
| cache | FLAGD_CACHE | string | lru | lru,disabled |
| maxCacheSize | FLAGD_MAX_CACHE_SIZE | int | 1000 | |
| maxEventStreamRetries | FLAGD_MAX_EVENT_STREAM_RETRIES | int | 5 | |
| Option name | Environment variable name | Type | Default | Supported values |
| -------------------------------------- | ------------------------------ | ------- |----------------------------------------------------------------| ---------------- |
| host | FLAGD_HOST | string | localhost | |
| port | FLAGD_PORT | number | [resolver specific defaults](#resolver-type-specific-defaults) | |
| tls | FLAGD_TLS | boolean | false | |
| socketPath | FLAGD_SOCKET_PATH | string | - | |
| resolverType | FLAGD_RESOLVER | string | rpc | rpc, in-process |
| offlineFlagSourcePath | FLAGD_OFFLINE_FLAG_SOURCE_PATH | string | - | |
| selector | FLAGD_SOURCE_SELECTOR | string | - | |
| cache | FLAGD_CACHE | string | lru | lru, disabled |
| maxCacheSize | FLAGD_MAX_CACHE_SIZE | int | 1000 | |
| defaultAuthority | FLAGD_DEFAULT_AUTHORITY | string | - | rpc, in-process |
### Example using TCP
#### Resolver type-specific Defaults
| Option name | Environment variable name | in-process | rpc | default |
| -------------------------------------- | ------------------------------ |-------------|------|---------|
| port | FLAGD_PORT | 8015 | 8013 | 8013 |
Below are examples of usage patterns.
### Remote flag resolving over RPC
This is the default mode of operation of the provider.
In this mode, FlagdProvider communicates with flagd via the gRPC protocol.
Flag evaluations take place remotely on the connected [flagd](https://flagd.dev/) instance.
```ts
OpenFeature.setProvider(new FlagdProvider())
```
OpenFeature.setProvider(new FlagdProvider({
host: 'localhost',
port: 8013,
}))
```
In the above example, the provider expects flagd to be available at `localhost:8013` (default host and port).
### Example using a Unix socket
Alternatively, you can use socket paths to connect to flagd.
```ts
```
OpenFeature.setProvider(new FlagdProvider({
socketPath: "/tmp/flagd.socks",
}))
```
### In-process resolver
This mode performs flag evaluations locally (in-process).
Flag configurations for evaluation are obtained via gRPC protocol using [sync protobuf schema](https://buf.build/open-feature/flagd/file/main:sync/v1/sync_service.proto) service definition.
```ts
OpenFeature.setProvider(new FlagdProvider({
resolverType: 'in-process',
}))
```
In the above example, the provider expects a flag sync service implementation to be available at `localhost:8015` (default host and port).
In-process resolver can also work in an offline mode.
To enable this mode, you should provide a valid flag configuration file with the option `offlineFlagSourcePath`.
```ts
OpenFeature.setProvider(new FlagdProvider({
resolverType: 'in-process',
offlineFlagSourcePath: './flags.json',
}))
```
Offline mode uses `fs.watchFile` and polls every 5 seconds for changes to the file.
This mode is useful for local development, test cases, and for offline applications.
### Default Authority usage (optional)
This is useful for complex routing or service-discovery use cases that involve a proxy (e.g., Envoy).
Please refer to this [GitHub issue](https://github.com/open-feature/js-sdk-contrib/issues/1187) for more information.
```ts
OpenFeature.setProvider(new FlagdProvider({
resolverType: 'in-process',
defaultAuthority: 'b-target-api.service',
}))
```
### Supported Events
The flagd provider emits `PROVIDER_READY`, `PROVIDER_ERROR` and `PROVIDER_CONFIGURATION_CHANGED` events.
| SDK event | Originating action in flagd |
| -------------------------------- | ------------------------------------------------------------------------------- |
| `PROVIDER_READY` | The streaming connection with flagd has been established. |
| `PROVIDER_ERROR` | The streaming connection with flagd has been broken. |
| `PROVIDER_CONFIGURATION_CHANGED` | A flag configuration (default value, targeting rule, etc) in flagd has changed. |
For general information on events, see the [official documentation](https://openfeature.dev/docs/reference/concepts/events).
### Flag Metadata
[Flag metadata](https://flagd.dev/reference/flag-definitions/#metadata) is a set of key-value pairs that can be associated with a flag.
The values come from the flag definition in flagd.
## Building
Run `nx package providers-flagd` to build the library.
> NOTE: [Buf](https://docs.buf.build/installation) must be installed to build locally.
## Running Unit Tests
## Running unit tests
Run `nx test providers-flagd` to execute the unit tests via [Jest](https://jestjs.io).

Some files were not shown because too many files have changed in this diff Show More