Compare commits

..

No commits in common. "main" and "config-cat-web-provider-v0.1.1" have entirely different histories.

342 changed files with 7837 additions and 22326 deletions

View File

@ -2,19 +2,14 @@
"root": true,
"ignorePatterns": ["**/*"],
"plugins": ["@nx"],
"extends": ["eslint:recommended", "plugin:prettier/recommended"],
"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 +28,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/*", "**/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
@ -32,8 +26,6 @@ components:
- markphelps
libs/providers/flipt-web:
- markphelps
libs/providers/unleash-web:
- jarebudev
ignored-authors:
- renovate-bot

View File

@ -14,14 +14,14 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x, 22.x, 24.x]
node-version: [18.x, 20.x]
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: recursive
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
@ -30,22 +30,24 @@ jobs:
# 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: 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
- 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
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: recursive
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@v4
with:
node-version: 20
# we need 'fetch' for this test, which is only in 18+
node-version: 18
cache: 'npm'
- name: Install

View File

@ -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@v4
with:
fetch-depth: 0
submodules: recursive
- uses: bufbuild/buf-setup-action@a47c93e0b1648d5651a065437926377d060baa99 # v1.50.0
- uses: bufbuild/buf-setup-action@v1.35.0
with:
github_token: ${{ github.token }}
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
uses: actions/setup-node@v4
if: ${{ steps.release.outputs.releases_created }}
with:
node-version: 20
node-version: 18
registry-url: "https://registry.npmjs.org"
- name: Build Packages
if: ${{ steps.release.outputs.releases_created }}

4
.gitignore vendored
View File

@ -46,7 +46,3 @@ proto
# yalc stuff
.yalc
yalc.lock
# Generated by @nx/js
.verdaccio

21
.gitmodules vendored
View File

@ -1,19 +1,18 @@
[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
url = https://github.com/open-feature/schemas
[submodule "libs/providers/flagd/spec"]
path = libs/providers/flagd/spec
url = https://github.com/open-feature/spec.git
path = libs/providers/flagd/spec
url = https://github.com/open-feature/spec.git
[submodule "libs/providers/flagd-web/spec"]
path = libs/providers/flagd-web/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.8.0
[submodule "libs/shared/flagd-core/spec"]
path = libs/shared/flagd-core/spec
url = https://github.com/open-feature/spec
[submodule "libs/providers/flagd/flagd-testbed"]
path = libs/providers/flagd/flagd-testbed
url = https://github.com/open-feature/flagd-testbed.git

View File

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

View File

@ -1,25 +1,22 @@
{
"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/go-feature-flag": "0.7.3",
"libs/providers/flagd": "0.13.1",
"libs/providers/flagd-web": "0.7.2",
"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/providers/config-cat": "0.7.1",
"libs/providers/launchdarkly-client": "0.3.0",
"libs/providers/go-feature-flag-web": "0.2.0",
"libs/shared/flagd-core": "0.2.3",
"libs/shared/ofrep-core": "0.2.0",
"libs/providers/ofrep": "0.2.0",
"libs/providers/ofrep-web": "0.3.1",
"libs/providers/flipt": "0.1.0",
"libs/providers/flagsmith-client": "0.1.2",
"libs/providers/flipt-web": "0.1.0",
"libs/providers/multi-provider": "0.1.1",
"libs/providers/multi-provider-web": "0.0.2",
"libs/providers/growthbook-client": "0.1.1",
"libs/providers/config-cat-web": "0.1.1",
"libs/shared/config-cat-core": "0.1.0"
}

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

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,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,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",
"repository": {
"type": "git",
"url": "https://github.com/open-feature/js-sdk-contrib.git",
@ -17,5 +16,6 @@
"peerDependencies": {
"@openfeature/server-sdk": "^1.13.0",
"@opentelemetry/api": ">=1.3.0"
}
}
},
"license": "Apache-2.0"
}

View File

@ -6,7 +6,9 @@
"targets": {
"package": {
"executor": "@nx/rollup:rollup",
"outputs": ["{options.outputPath}"],
"outputs": [
"{options.outputPath}"
],
"options": {
"project": "libs/hooks/open-telemetry/package.json",
"outputPath": "dist/libs/hooks/open-telemetry",
@ -16,7 +18,10 @@
"generateExportsField": true,
"umdName": "OpenTelemetry",
"external": "all",
"format": ["cjs", "esm"],
"format": [
"cjs",
"esm"
],
"assets": [
{
"glob": "package.json",
@ -33,17 +38,23 @@
"input": "./libs/hooks/open-telemetry",
"output": "./"
}
]
],
"updateBuildableProjectDepsInPackageJson": true
}
},
"build": {
"executor": "@nx/js:tsc",
"outputs": ["{options.outputPath}"],
"outputs": [
"{options.outputPath}"
],
"options": {
"outputPath": "dist/libs/hooks/open-telemetry",
"main": "libs/hooks/open-telemetry/src/index.ts",
"tsConfig": "libs/hooks/open-telemetry/tsconfig.lib.json",
"assets": ["libs/hooks/open-telemetry/*.md"]
"assets": [
"libs/hooks/open-telemetry/*.md"
],
"updateBuildableProjectDepsInPackageJson": true
}
},
"publish": {
@ -59,14 +70,24 @@
]
},
"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": [
"{workspaceRoot}/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,8 +1,6 @@
import type { BeforeHookContext, EvaluationDetails, HookContext } from '@openfeature/server-sdk';
import { StandardResolutionReasons } from '@openfeature/server-sdk';
import { BeforeHookContext, EvaluationDetails, HookContext, StandardResolutionReasons } from '@openfeature/server-sdk';
import opentelemetry from '@opentelemetry/api';
import type { DataPoint, ScopeMetrics } from '@opentelemetry/sdk-metrics';
import { MeterProvider, MetricReader } from '@opentelemetry/sdk-metrics';
import { DataPoint, MeterProvider, MetricReader, ScopeMetrics } from '@opentelemetry/sdk-metrics';
import {
ACTIVE_COUNT_NAME,
ERROR_TOTAL_NAME,
@ -14,7 +12,7 @@ import {
VARIANT_ATTR,
} from '../conventions';
import { MetricsHook } from './metrics-hook';
import type { AttributeMapper } from '../otel-hook';
import { AttributeMapper } from '../otel-hook';
// no-op "in-memory" reader
class InMemoryMetricReader extends MetricReader {

View File

@ -1,18 +1,19 @@
import type { BeforeHookContext, Logger } from '@openfeature/server-sdk';
import {
BeforeHookContext,
Logger,
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';
import { Attributes, Counter, UpDownCounter, ValueType, metrics } from '@opentelemetry/api';
import {
ACTIVE_COUNT_NAME,
ERROR_TOTAL_NAME,
EXCEPTION_ATTR,
EvaluationAttributes,
ExceptionAttributes,
KEY_ATTR,
PROVIDER_NAME_ATTR,
REASON_ATTR,
@ -20,8 +21,7 @@ import {
SUCCESS_TOTAL_NAME,
VARIANT_ATTR,
} from '../conventions';
import type { OpenTelemetryHookOptions } from '../otel-hook';
import { OpenTelemetryHook } from '../otel-hook';
import { OpenTelemetryHook, OpenTelemetryHookOptions } from '../otel-hook';
type ErrorEvaluationAttributes = EvaluationAttributes & ExceptionAttributes;

View File

@ -1,5 +1,5 @@
import type { FlagMetadata, Logger } from '@openfeature/server-sdk';
import type { Attributes } from '@opentelemetry/api';
import { FlagMetadata, Logger } from '@openfeature/server-sdk';
import { Attributes } from '@opentelemetry/api';
export type AttributeMapper = (flagMetadata: FlagMetadata) => Attributes;

View File

@ -1,4 +1,4 @@
import type { EvaluationDetails, HookContext } from '@openfeature/server-sdk';
import { EvaluationDetails, HookContext } from '@openfeature/server-sdk';
const addEvent = jest.fn();
const recordException = jest.fn();

View File

@ -1,8 +1,7 @@
import type { Hook, HookContext, EvaluationDetails, FlagValue, Logger } from '@openfeature/server-sdk';
import { Hook, HookContext, EvaluationDetails, FlagValue, Logger } from '@openfeature/server-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';
import { OpenTelemetryHook, OpenTelemetryHookOptions } from '../otel-hook';
export type TracingHookOptions = OpenTelemetryHookOptions;

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,3 +0,0 @@
{
"presets": [["minify", { "builtIns": false }]]
}

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,46 +1,5 @@
# 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)

View File

@ -1,6 +1,6 @@
# ConfigCat Web Provider
This is an OpenFeature provider implementation for using [ConfigCat](https://configcat.com), a managed feature flag service in JavaScript frontend applications.
This provider is an implementation for [ConfigCat](https://configcat.com) a managed feature flag service.
## Installation
@ -14,17 +14,17 @@ 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`.
The minimum required version of `configcat-js-ssr` currently is `8.4.1`.
```
$ npm install @openfeature/web-sdk configcat-js-ssr
$ npm install @openfeature/client-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```.
It can be created by passing the ConfigCat SDK options to ```ConfigCatProvider.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.
@ -32,45 +32,29 @@ The ConfigCat Web Provider only supports the `AutoPolling` mode because it cache
### Example using the default configuration
```javascript
import { OpenFeature } from "@openfeature/web-sdk";
import { ConfigCatWebProvider } from '@openfeature/config-cat-web-provider';
import { ConfigCatProvider } 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();
const provider = ConfigCatProvider.create('<sdk_key>');
OpenFeature.setProvider(provider);
```
### Example using custom configuration
### Example using different polling options and a setupHook
```javascript
import { OpenFeature } from "@openfeature/web-sdk";
import { ConfigCatWebProvider } from '@openfeature/config-cat-web-provider';
import { createConsoleLogger, LogLevel } from 'configcat-js-ssr';
import { ConfigCatProvider } from '@openfeature/config-cat-web-provider';
// Create and set the provider.
const provider = ConfigCatWebProvider.create('<sdk_key>', {
logger: createConsoleLogger(LogLevel.Info),
const provider = ConfigCatProvider.create('<sdk_key>', {
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/).
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,
The [ConfigCat user object](https://configcat.com/docs/advanced/user-object/) has three known attributes,
and allows for additional attributes.
The following shows how the attributes are mapped:

View File

@ -1,15 +1,15 @@
{
"name": "@openfeature/config-cat-web-provider",
"version": "0.1.6",
"version": "0.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openfeature/config-cat-web-provider",
"version": "0.1.6",
"version": "0.1.1",
"peerDependencies": {
"@openfeature/web-sdk": "^1.0.0",
"configcat-js-ssr": "^8.4.3"
"configcat-js-ssr": "^8.4.1"
}
},
"node_modules/@openfeature/core": {
@ -34,9 +34,9 @@
"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==",
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz",
"integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==",
"peer": true,
"dependencies": {
"follow-redirects": "^1.15.6",
@ -57,22 +57,22 @@
}
},
"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==",
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/configcat-common/-/configcat-common-9.3.0.tgz",
"integrity": "sha512-WgpCanLPsT0ig4eLEo2BCZvo0sqtGIkRREnxNAX3Hubw0FzyQ7JUbiliw7ZlBNgda5jaO2nvcs3man+PDdfyLQ==",
"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==",
"version": "8.4.1",
"resolved": "https://registry.npmjs.org/configcat-js-ssr/-/configcat-js-ssr-8.4.1.tgz",
"integrity": "sha512-MWYgtaBkWzAbIPy0hA0M1UV1JSqzXhk1m25f1HFbQMoP/ybX/lDrGUiCMyDosPjcV82qjul8MTiDWIIgOfabPw==",
"peer": true,
"dependencies": {
"axios": "^1.7.4",
"configcat-common": "9.3.1",
"axios": "^1.6.8",
"configcat-common": "9.3.0",
"tslib": "^2.4.1"
}
},
@ -147,9 +147,9 @@
"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==",
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==",
"peer": true
}
}

View File

@ -1,14 +1,12 @@
{
"name": "@openfeature/config-cat-web-provider",
"version": "0.1.6",
"license": "Apache-2.0",
"version": "0.1.1",
"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"
"configcat-js-ssr": "^8.4.1"
}
}

View File

@ -17,19 +17,37 @@
]
},
"lint": {
"executor": "@nx/eslint:lint",
"outputs": ["{options.outputFile}"]
"executor": "@nx/linter:eslint",
"outputs": [
"{options.outputFile}"
],
"options": {
"lintFilePatterns": [
"libs/providers/config-cat-web/**/*.ts"
]
}
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"outputs": [
"{workspaceRoot}/coverage/{projectRoot}"
],
"options": {
"jestConfig": "libs/providers/config-cat-web/jest.config.ts"
"jestConfig": "libs/providers/config-cat-web/jest.config.ts",
"passWithNoTests": true
},
"configurations": {
"ci": {
"ci": true,
"codeCoverage": true
}
}
},
"package": {
"executor": "@nx/rollup:rollup",
"outputs": ["{options.outputPath}"],
"outputs": [
"{options.outputPath}"
],
"options": {
"project": "libs/providers/config-cat-web/package.json",
"outputPath": "dist/libs/providers/config-cat-web",
@ -39,7 +57,10 @@
"generateExportsField": true,
"umdName": "config-cat",
"external": "all",
"format": ["cjs", "esm"],
"format": [
"cjs",
"esm"
],
"assets": [
{
"glob": "package.json",
@ -56,7 +77,9 @@
"input": "./libs/providers/config-cat-web",
"output": "./"
}
]
],
"buildableProjectDepsInPackageJsonType": "dependencies",
"updateBuildableProjectDepsInPackageJson": true
}
}
},

View File

@ -1,7 +1,13 @@
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 {
createConsoleLogger,
createFlagOverridesFromMap,
HookEvents,
ISettingUnion,
LogLevel,
OverrideBehaviour,
} from 'configcat-js-ssr';
import { EventEmitter } from 'events';
import { ProviderEvents, ParseError, FlagNotFoundError, TypeMismatchError } from '@openfeature/web-sdk';
describe('ConfigCatWebProvider', () => {
@ -75,47 +81,30 @@ describe('ConfigCatWebProvider', () => {
});
});
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 = ConfigCatWebProvider.create('configcat-sdk-1/1234567890123456789012/1234567890123456789012', {
cache: fakeSharedCache,
logger: createConsoleLogger(LogLevel.Off),
offline: true,
maxInitWaitTimeSeconds: 1,
expect(handler).toHaveBeenCalledWith({
message: eventData[0],
metadata: eventData[1],
});
});
it('should emit PROVIDER_READY event after successful evaluation during ERROR condition', async () => {
const errorHandler = jest.fn();
provider.events.addHandler(ProviderEvents.Error, errorHandler);
configCatEmitter.emit('clientError', 'error', { error: 'error' });
expect(errorHandler).toHaveBeenCalled();
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));
await provider.resolveBooleanEvaluation('booleanTrue', false, { targetingKey });
expect(readyHandler).toHaveBeenCalled();
});
});
@ -183,9 +172,8 @@ describe('ConfigCatWebProvider', () => {
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));
it('should throw TypeMismatchError if string is only a JSON primitive', () => {
expect(() => provider.resolveObjectEvaluation('jsonPrimitive', {}, { targetingKey })).toThrow(TypeMismatchError);
});
});
});

View File

@ -1,20 +1,29 @@
import type { EvaluationContext, JsonValue, Paradigm, Provider, ResolutionDetails } from '@openfeature/web-sdk';
import {
EvaluationContext,
FlagNotFoundError,
JsonValue,
OpenFeatureEventEmitter,
Paradigm,
ParseError,
Provider,
ProviderEvents,
ProviderNotReadyError,
ResolutionDetails,
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';
import {
isType,
PrimitiveType,
PrimitiveTypeName,
toResolutionDetails,
transformContext,
} from '@openfeature/config-cat-core';
import { getClient, IConfig, IConfigCatClient, OptionsForPollingMode, 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 _hasError = false;
private _client?: IConfigCatClient;
public runsOn: Paradigm = 'client';
@ -37,11 +46,19 @@ export class ConfigCatWebProvider implements Provider {
options.setupHooks = (hooks) => {
oldSetupHooks?.(hooks);
hooks.on('configChanged', (config: IConfig) =>
hooks.on('configChanged', (projectConfig: IConfig | undefined) =>
provider.events.emit(ProviderEvents.ConfigurationChanged, {
flagsChanged: Object.keys(config.settings),
flagsChanged: projectConfig ? Object.keys(projectConfig.settings) : undefined,
}),
);
hooks.on('clientError', (message: string, error) => {
provider._hasError = true;
provider.events.emit(ProviderEvents.Error, {
message: message,
metadata: error,
});
});
};
return getClient(sdkKey, PollingMode.AutoPoll, options);
@ -50,19 +67,8 @@ export class ConfigCatWebProvider implements Provider {
public async initialize(): Promise<void> {
const client = this._clientFactory(this);
const clientCacheState = await client.waitForReady();
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() {
@ -78,7 +84,7 @@ export class ConfigCatWebProvider implements Provider {
defaultValue: boolean,
context: EvaluationContext,
): ResolutionDetails<boolean> {
return this.evaluate(flagKey, 'boolean', defaultValue, context);
return this.evaluate(flagKey, 'boolean', context);
}
public resolveStringEvaluation(
@ -86,7 +92,7 @@ export class ConfigCatWebProvider implements Provider {
defaultValue: string,
context: EvaluationContext,
): ResolutionDetails<string> {
return this.evaluate(flagKey, 'string', defaultValue, context);
return this.evaluate(flagKey, 'string', context);
}
public resolveNumberEvaluation(
@ -94,7 +100,7 @@ export class ConfigCatWebProvider implements Provider {
defaultValue: number,
context: EvaluationContext,
): ResolutionDetails<number> {
return this.evaluate(flagKey, 'number', defaultValue, context);
return this.evaluate(flagKey, 'number', context);
}
public resolveObjectEvaluation<U extends JsonValue>(
@ -102,66 +108,47 @@ export class ConfigCatWebProvider implements Provider {
defaultValue: U,
context: EvaluationContext,
): ResolutionDetails<U> {
const objectValue = this.evaluate(flagKey, 'object', defaultValue, context);
const objectValue = this.evaluate(flagKey, 'object', 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 { value, ...evaluationData } = this._client
.snapshot()
.getValueDetails(flagKey, undefined, transformContext(context));
if (this._hasError && !evaluationData.errorMessage && !evaluationData.errorException) {
this._hasError = false;
this.events.emit(ProviderEvents.Ready);
}
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 (typeof value === 'undefined') {
throw new FlagNotFoundError();
}
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);
return toResolutionDetails(flagType, value, evaluationData);
}
if (!isType('string', value)) {
throw new TypeMismatchError();
}
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);
json = JSON.parse(value);
} catch (e) {
throw new ParseError(`Unable to parse "${value}" as JSON`);
}
return toResolutionDetails(json as PrimitiveType<T>, evaluationData);
return toResolutionDetails(flagType, json, evaluationData);
}
}

View File

@ -6,7 +6,7 @@
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
"noFallthroughCasesInSwitch": true,
},
"files": [],
"include": [],

View File

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

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
@ -14,7 +14,7 @@ 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 `configcat-node` currently is `11.3.1`.
The minimum required version of `configcat-node` currently is `11.0.0`.
```
$ npm install @openfeature/server-sdk configcat-node
@ -31,45 +31,29 @@ The available options can be found in the [ConfigCat Node.js SDK](https://config
### 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/).
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,
The [ConfigCat user object](https://configcat.com/docs/advanced/user-object/) has three known attributes,
and allows for additional attributes.
The following shows how the attributes are mapped:

View File

@ -1,15 +1,15 @@
{
"name": "@openfeature/config-cat-provider",
"version": "0.7.6",
"version": "0.7.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openfeature/config-cat-provider",
"version": "0.7.6",
"version": "0.7.1",
"peerDependencies": {
"@openfeature/server-sdk": "^1.13.5",
"configcat-node": "^11.3.1"
"configcat-node": "^11.0.0"
}
},
"node_modules/@openfeature/core": {
@ -31,21 +31,21 @@
}
},
"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==",
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/configcat-common/-/configcat-common-9.3.0.tgz",
"integrity": "sha512-WgpCanLPsT0ig4eLEo2BCZvo0sqtGIkRREnxNAX3Hubw0FzyQ7JUbiliw7ZlBNgda5jaO2nvcs3man+PDdfyLQ==",
"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==",
"version": "11.3.0",
"resolved": "https://registry.npmjs.org/configcat-node/-/configcat-node-11.3.0.tgz",
"integrity": "sha512-Mo2K6WzsT8ggesdxgL1a9cwX5VSqns7u4TcumAP11UuIEFpaB8I5utNmOircVT7pZeyT6aFTP7kRhKdSU6C+/A==",
"peer": true,
"dependencies": {
"configcat-common": "9.3.1",
"configcat-common": "9.3.0",
"tslib": "^2.4.1",
"tunnel": "0.0.6"
},
@ -54,9 +54,9 @@
}
},
"node_modules/tslib": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz",
"integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==",
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==",
"peer": true
},
"node_modules/tunnel": {

View File

@ -1,15 +1,12 @@
{
"name": "@openfeature/config-cat-provider",
"version": "0.7.6",
"license": "Apache-2.0",
"version": "0.7.1",
"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"
"configcat-node": "^11.0.0"
}
}

View File

@ -17,19 +17,37 @@
]
},
"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}"],
"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": {
"executor": "@nx/rollup:rollup",
"outputs": ["{options.outputPath}"],
"outputs": [
"{options.outputPath}"
],
"options": {
"project": "libs/providers/config-cat/package.json",
"outputPath": "dist/libs/providers/config-cat",
@ -39,7 +57,10 @@
"generateExportsField": true,
"umdName": "config-cat",
"external": "all",
"format": ["cjs", "esm"],
"format": [
"cjs",
"esm"
],
"assets": [
{
"glob": "package.json",
@ -56,7 +77,9 @@
"input": "./libs/providers/config-cat",
"output": "./"
}
]
],
"buildableProjectDepsInPackageJsonType": "dependencies",
"updateBuildableProjectDepsInPackageJson": true
}
}
},

View File

@ -1,14 +1,15 @@
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 {
createConsoleLogger,
createFlagOverridesFromMap,
HookEvents,
ISettingUnion,
LogLevel,
OverrideBehaviour,
PollingMode,
} from 'configcat-js-ssr';
import type { EventEmitter } from 'events';
import { EventEmitter } from 'events';
describe('ConfigCatProvider', () => {
const targetingKey = 'abc';
@ -81,51 +82,30 @@ 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,
},
);
expect(handler).toHaveBeenCalledWith({
message: eventData[0],
metadata: eventData[1],
});
});
it('should emit PROVIDER_READY event after successful evaluation during ERROR condition', async () => {
const errorHandler = jest.fn();
provider.events.addHandler(ProviderEvents.Error, errorHandler);
configCatEmitter.emit('clientError', 'error', { error: 'error' });
expect(errorHandler).toHaveBeenCalled();
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));
await provider.resolveBooleanEvaluation('booleanTrue', false, { targetingKey });
expect(readyHandler).toHaveBeenCalled();
});
});
@ -203,9 +183,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,23 +1,30 @@
import type { EvaluationContext, JsonValue, Provider, ResolutionDetails, Paradigm } from '@openfeature/server-sdk';
import {
EvaluationContext,
JsonValue,
OpenFeatureEventEmitter,
Provider,
ProviderEvents,
ResolutionDetails,
Paradigm,
ProviderNotReadyError,
TypeMismatchError,
FlagNotFoundError,
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';
import {
isType,
PrimitiveType,
PrimitiveTypeName,
toResolutionDetails,
transformContext,
} from '@openfeature/config-cat-core';
import { PollingMode } from 'configcat-common';
import { IConfigCatClient, getClient, IConfig, OptionsForPollingMode } from 'configcat-node';
export class ConfigCatProvider implements Provider {
public readonly events = new OpenFeatureEventEmitter();
private readonly _clientFactory: (provider: ConfigCatProvider) => IConfigCatClient;
private readonly _pollingMode: PollingMode;
private _isProviderReady = false;
private _hasError = false;
private _client?: IConfigCatClient;
public runsOn: Paradigm = 'server';
@ -26,9 +33,8 @@ export class ConfigCatProvider implements Provider {
name: ConfigCatProvider.name,
};
protected constructor(clientFactory: (provider: ConfigCatProvider) => IConfigCatClient, pollingMode: PollingMode) {
protected constructor(clientFactory: (provider: ConfigCatProvider) => IConfigCatClient) {
this._clientFactory = clientFactory;
this._pollingMode = pollingMode;
}
public static create<TMode extends PollingMode>(
@ -44,32 +50,29 @@ export class ConfigCatProvider implements Provider {
options.setupHooks = (hooks) => {
oldSetupHooks?.(hooks);
hooks.on('configChanged', (config: IConfig) =>
hooks.on('configChanged', (projectConfig: IConfig | undefined) =>
provider.events.emit(ProviderEvents.ConfigurationChanged, {
flagsChanged: Object.keys(config.settings),
flagsChanged: projectConfig ? Object.keys(projectConfig.settings) : undefined,
}),
);
hooks.on('clientError', (message: string, error) => {
provider._hasError = true;
provider.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();
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.');
}
}
public get configCatClient() {
@ -85,7 +88,7 @@ export class ConfigCatProvider implements Provider {
defaultValue: boolean,
context: EvaluationContext,
): Promise<ResolutionDetails<boolean>> {
return this.evaluate(flagKey, 'boolean', defaultValue, context);
return this.evaluate(flagKey, 'boolean', context);
}
public async resolveStringEvaluation(
@ -93,7 +96,7 @@ export class ConfigCatProvider implements Provider {
defaultValue: string,
context: EvaluationContext,
): Promise<ResolutionDetails<string>> {
return this.evaluate(flagKey, 'string', defaultValue, context);
return this.evaluate(flagKey, 'string', context);
}
public async resolveNumberEvaluation(
@ -101,7 +104,7 @@ export class ConfigCatProvider implements Provider {
defaultValue: number,
context: EvaluationContext,
): Promise<ResolutionDetails<number>> {
return this.evaluate(flagKey, 'number', defaultValue, context);
return this.evaluate(flagKey, 'number', context);
}
public async resolveObjectEvaluation<U extends JsonValue>(
@ -109,64 +112,49 @@ export class ConfigCatProvider implements Provider {
defaultValue: U,
context: EvaluationContext,
): Promise<ResolutionDetails<U>> {
const objectValue = await this.evaluate(flagKey, 'object', defaultValue, context);
const objectValue = await this.evaluate(flagKey, 'object', 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');
}
// 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(
flagKey,
configCatDefaultValue,
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 (this._hasError && !evaluationData.errorMessage && !evaluationData.errorException) {
this._hasError = false;
this.events.emit(ProviderEvents.Ready);
}
if (evaluationData.isDefaultValue) {
throw parseError(evaluationData.errorMessage);
if (typeof value === 'undefined') {
throw new FlagNotFoundError();
}
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);
return toResolutionDetails(flagType, value, evaluationData);
}
if (!isType('string', value)) {
throw new TypeMismatchError();
}
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);
json = JSON.parse(value);
} catch (e) {
throw new ParseError(`Unable to parse "${value}" as JSON`);
}
return toResolutionDetails(json as PrimitiveType<T>, evaluationData);
return toResolutionDetails(flagType, json, evaluationData);
}
}

View File

@ -6,7 +6,7 @@
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
"noFallthroughCasesInSwitch": true,
},
"files": [],
"include": [],

View File

@ -1,7 +1,6 @@
{
"name": "@openfeature/env-var-provider",
"version": "0.3.1",
"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"
@ -9,4 +8,4 @@
"peerDependencies": {
"@openfeature/server-sdk": "^1.13.0"
}
}
}

View File

@ -17,29 +17,51 @@
]
},
"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}"],
"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": {
"executor": "@nx/rollup:rollup",
"outputs": ["{options.outputPath}"],
"outputs": [
"{options.outputPath}"
],
"options": {
"project": "libs/providers/env-var/package.json",
"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",
"external": "all",
"format": ["cjs", "esm"],
"format": [
"cjs",
"esm"
],
"assets": [
{
"glob": "package.json",
@ -56,7 +78,8 @@
"input": "./libs/providers/env-var",
"output": "./"
}
]
],
"updateBuildableProjectDepsInPackageJson": true
}
}
},

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/server-sdk';
import { constantCase } from './constant-case';
export type Config = {

View File

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

View File

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

View File

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

View File

@ -1,18 +1,11 @@
{
"name": "@openfeature/flagd-web-provider",
"version": "0.7.3",
"license": "Apache-2.0",
"version": "0.7.2",
"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": {
"@openfeature/flagd-core": "1.1.0",
"@connectrpc/connect": "^1.4.0",
"@connectrpc/connect-web": "^1.4.0",
"@bufbuild/protobuf": "^1.2.0"
}
}

View File

@ -28,8 +28,15 @@
}
},
"lint": {
"executor": "@nx/eslint:lint",
"outputs": ["{options.outputFile}"],
"executor": "@nx/linter:eslint",
"outputs": [
"{options.outputFile}"
],
"options": {
"lintFilePatterns": [
"libs/providers/flagd-web/**/*.ts"
]
},
"dependsOn": [
{
"target": "generate"
@ -38,9 +45,12 @@
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/libs/providers/flagd-web"],
"outputs": [
"{workspaceRoot}/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,11 +58,25 @@
}
]
},
"e2e": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/libs/providers/flagd-web"],
"pullTestHarness": {
"executor": "nx:run-commands",
"options": {
"jestConfig": "libs/providers/flagd-web/src/e2e/jest.config.ts",
"commands": [
"git submodule update --init spec",
"rm -f -r ./src/e2e/features/*",
"cp -v ./spec/specification/assets/gherkin/evaluation.feature ./src/e2e/features/"
],
"cwd": "libs/providers/flagd-web",
"parallel": false
}
},
"e2e": {
"executor": "nx:run-commands",
"options": {
"commands": [
"npx jest"
],
"cwd": "libs/providers/flagd-web/src/e2e",
"parallel": false
},
"dependsOn": [
@ -60,13 +84,15 @@
"target": "generate"
},
{
"target": "flagd-core:pullTestHarness"
"target": "pullTestHarness"
}
]
},
"package": {
"executor": "@nx/rollup:rollup",
"outputs": ["{options.outputPath}"],
"outputs": [
"{options.outputPath}"
],
"options": {
"project": "libs/providers/flagd-web/package.json",
"outputPath": "dist/libs/providers/flagd-web",
@ -74,9 +100,13 @@
"tsConfig": "libs/providers/flagd-web/tsconfig.lib.json",
"compiler": "tsc",
"generateExportsField": true,
"buildableProjectDepsInPackageJsonType": "dependencies",
"umdName": "flagd-web",
"external": "all",
"format": ["cjs", "esm"],
"format": [
"cjs",
"esm"
],
"assets": [
{
"glob": "package.json",
@ -93,7 +123,8 @@
"input": "./libs/providers/flagd-web",
"output": "./"
}
]
],
"updateBuildableProjectDepsInPackageJson": true
},
"dependsOn": [
{

@ -1 +1 @@
Subproject commit 2852d7772e6b8674681a6ee6b88db10dbe3f6899
Subproject commit 1ba4b0324771273e6db7d4a808f6dc87a0b3c717

@ -0,0 +1 @@
Subproject commit eaa44da2110d97376d3292ea3963c54bea498a77

View File

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

View File

@ -0,0 +1 @@
*.feature

View File

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

View File

@ -1,14 +1,15 @@
import type { Config } from 'jest';
const config: Config = {
export default {
displayName: 'providers-flagd-web-e2e',
clearMocks: true,
preset: 'ts-jest',
moduleNameMapper: {
'@openfeature/flagd-core': ['<rootDir>/../../../../shared/flagd-core/src'],
'(.+)\\.js$': '$1',
transform: {
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: './tsconfig.lib.json' }],
},
moduleNameMapper: {
'^(.*)\\.js$': ['$1.js', '$1.ts', '$1'],
},
testEnvironment: 'node',
preset: 'ts-jest',
clearMocks: true,
setupFiles: [],
verbose: true,
silent: false,
};
export default config;

View File

@ -0,0 +1,339 @@
import {
EvaluationContext,
EvaluationDetails,
JsonObject,
JsonValue,
OpenFeature,
ProviderEvents,
ResolutionDetails,
StandardResolutionReasons,
} from '@openfeature/web-sdk';
import { defineFeature, loadFeature } from 'jest-cucumber';
export function evaluation() {
// load the feature file.
const feature = loadFeature('features/evaluation.feature');
// get a client (flagd provider registered in setup)
const client = OpenFeature.getClient();
const givenAnOpenfeatureClientIsRegistered = (
given: (stepMatcher: string, stepDefinitionCallback: () => void) => void,
) => {
given('a provider is registered', () => undefined);
};
defineFeature(feature, (test) => {
beforeAll((done) => {
client.addHandler(ProviderEvents.Ready, async () => {
done();
});
});
test('Resolves boolean value', ({ given, when, then }) => {
let value: boolean;
givenAnOpenfeatureClientIsRegistered(given);
when(
/^a boolean flag with key "(.*)" is evaluated with default value "(.*)"$/,
(key: string, defaultValue: string) => {
value = client.getBooleanValue(key, defaultValue === 'true');
},
);
then(/^the resolved boolean value should be "(.*)"$/, (expectedValue: string) => {
expect(value).toEqual(expectedValue === 'true');
});
});
test('Resolves string value', ({ given, when, then }) => {
let value: string;
givenAnOpenfeatureClientIsRegistered(given);
when(
/^a string flag with key "(.*)" is evaluated with default value "(.*)"$/,
(key: string, defaultValue: string) => {
value = client.getStringValue(key, defaultValue);
},
);
then(/^the resolved string value should be "(.*)"$/, (expectedValue: string) => {
expect(value).toEqual(expectedValue);
});
});
test('Resolves integer value', ({ given, when, then }) => {
let value: number;
givenAnOpenfeatureClientIsRegistered(given);
when(
/^an integer flag with key "(.*)" is evaluated with default value (\d+)$/,
(key: string, defaultValue: number) => {
value = client.getNumberValue(key, defaultValue);
},
);
then(/^the resolved integer value should be (\d+)$/, (expectedStringValue: string) => {
const expectedNumberValue = parseInt(expectedStringValue);
expect(value).toEqual(expectedNumberValue);
});
});
test('Resolves float value', ({ given, when, then }) => {
let value: number;
givenAnOpenfeatureClientIsRegistered(given);
when(
/^a float flag with key "(.*)" is evaluated with default value (\d+\.?\d*)$/,
(key: string, defaultValue: string) => {
value = client.getNumberValue(key, Number.parseFloat(defaultValue));
},
);
then(/^the resolved float value should be (\d+\.?\d*)$/, (expectedValue: string) => {
expect(value).toEqual(Number.parseFloat(expectedValue));
});
});
test('Resolves object value', ({ given, when, then }) => {
let value: JsonValue;
givenAnOpenfeatureClientIsRegistered(given);
when(/^an object flag with key "(.*)" is evaluated with a null default value$/, (key: string) => {
value = client.getObjectValue<JsonValue>(key, {});
});
then(
/^the resolved object value should be contain fields "(.*)", "(.*)", and "(.*)", with values "(.*)", "(.*)" and (\d+), respectively$/,
(field1: string, field2: string, field3: string, boolVal: string, strVal: string, intVal: string) => {
const jsonObject = value as JsonObject;
expect(jsonObject[field1]).toEqual(boolVal === 'true');
expect(jsonObject[field2]).toEqual(strVal);
expect(jsonObject[field3]).toEqual(Number.parseInt(intVal));
},
);
});
test('Resolves boolean details', ({ given, when, then }) => {
let details: EvaluationDetails<boolean>;
givenAnOpenfeatureClientIsRegistered(given);
when(
/^a boolean flag with key "(.*)" is evaluated with details and default value "(.*)"$/,
(key: string, defaultValue: string) => {
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.value).toEqual(expectedValue === 'true');
expect(details.variant).toEqual(expectedVariant);
expect(details.reason).toEqual(expectedReason);
},
);
});
test('Resolves string details', ({ given, when, then }) => {
let details: EvaluationDetails<string>;
givenAnOpenfeatureClientIsRegistered(given);
when(
/^a string flag with key "(.*)" is evaluated with details and default value "(.*)"$/,
(key: string, defaultValue: string) => {
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.value).toEqual(expectedValue);
expect(details.variant).toEqual(expectedVariant);
expect(details.reason).toEqual(expectedReason);
},
);
});
test('Resolves integer details', ({ given, when, then }) => {
let details: EvaluationDetails<number>;
givenAnOpenfeatureClientIsRegistered(given);
when(
/^an integer flag with key "(.*)" is evaluated with details and default value (\d+)$/,
(key: string, defaultValue: string) => {
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.value).toEqual(Number.parseInt(expectedValue));
expect(details.variant).toEqual(expectedVariant);
expect(details.reason).toEqual(expectedReason);
},
);
});
test('Resolves float details', ({ given, when, then }) => {
let details: EvaluationDetails<number>;
givenAnOpenfeatureClientIsRegistered(given);
when(
/^a float flag with key "(.*)" is evaluated with details and default value (\d+\.?\d*)$/,
(key: string, defaultValue: string) => {
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.value).toEqual(Number.parseFloat(expectedValue));
expect(details.variant).toEqual(expectedVariant);
expect(details.reason).toEqual(expectedReason);
},
);
});
test('Resolves object details', ({ given, when, then, and }) => {
let details: EvaluationDetails<JsonValue>; // update this after merge
givenAnOpenfeatureClientIsRegistered(given);
when(/^an object flag with key "(.*)" is evaluated with details and a null default value$/, (key: string) => {
details = client.getObjectDetails(key, {}); // update this after merge
});
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) => {
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.variant).toEqual(expectedVariant);
expect(details.reason).toEqual(expectedReason);
},
);
});
test('Resolves based on context', ({ given, when, and, then }) => {
const context: EvaluationContext = {};
let value: string;
let flagKey: string;
givenAnOpenfeatureClientIsRegistered(given);
when(
/^context contains keys "(.*)", "(.*)", "(.*)", "(.*)" with values "(.*)", "(.*)", (\d+), "(.*)"$/,
async (
stringField1: string,
stringField2: string,
intField: string,
boolField: string,
stringValue1: string,
stringValue2: string,
intValue: string,
boolValue: string,
) => {
context[stringField1] = stringValue1;
context[stringField2] = stringValue2;
context[intField] = Number.parseInt(intValue);
context[boolField] = boolValue === 'true';
await OpenFeature.setContext(context);
},
);
and(/^a flag with key "(.*)" is evaluated with default value "(.*)"$/, (key: string, defaultValue: string) => {
flagKey = key;
value = client.getStringValue(flagKey, defaultValue);
});
then(/^the resolved string response should be "(.*)"$/, (expectedValue: string) => {
expect(value).toEqual(expectedValue);
});
and(/^the resolved flag value is "(.*)" when the context is empty$/, async (expectedValue) => {
await OpenFeature.setContext({});
const emptyContextValue = client.getStringValue(flagKey, 'nope', {});
expect(emptyContextValue).toEqual(expectedValue);
});
});
test('Flag not found', ({ given, when, then, and }) => {
let flagKey: string;
let fallbackValue: string;
let details: ResolutionDetails<string>;
givenAnOpenfeatureClientIsRegistered(given);
when(
/^a non-existent string flag with key "(.*)" is evaluated with details and a default value "(.*)"$/,
(key: string, defaultValue: string) => {
flagKey = key;
fallbackValue = defaultValue;
details = client.getStringDetails(flagKey, defaultValue);
},
);
then(/^the default string value should be returned$/, () => {
expect(details.value).toEqual(fallbackValue);
});
and(
/^the reason should indicate an error and the error code should indicate a missing flag with "(.*)"$/,
(errorCode: string) => {
expect(details.reason).toEqual(StandardResolutionReasons.ERROR);
expect(details.errorCode).toEqual(errorCode);
},
);
});
test('Type error', ({ given, when, then, and }) => {
let flagKey: string;
let fallbackValue: number;
let details: ResolutionDetails<number>;
givenAnOpenfeatureClientIsRegistered(given);
when(
/^a string flag with key "(.*)" is evaluated as an integer, with details and a default value (\d+)$/,
(key: string, defaultValue: string) => {
flagKey = key;
fallbackValue = Number.parseInt(defaultValue);
details = client.getNumberDetails(flagKey, Number.parseInt(defaultValue));
},
);
then(/^the default integer value should be returned$/, () => {
expect(details.value).toEqual(fallbackValue);
});
and(
/^the reason should indicate an error and the error code should indicate a type mismatch with "(.*)"$/,
(errorCode: string) => {
expect(details.reason).toEqual(StandardResolutionReasons.ERROR);
expect(details.errorCode).toEqual(errorCode);
},
);
});
});
}

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,12 +1,9 @@
import assert from 'assert';
import { OpenFeature } from '@openfeature/web-sdk';
import type { StartedTestContainer } from 'testcontainers';
import { GenericContainer } from 'testcontainers';
import { FLAGD_NAME, IMAGE_VERSION } from '../constants';
import { GenericContainer, StartedTestContainer } 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';
import { evaluation } from '../step-definitions/evaluation';
// register the flagd provider before the tests.
async function setup() {
@ -18,18 +15,17 @@ async function setup() {
.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);
OpenFeature.setProvider(
new FlagdWebProvider({
host: stable.getHost(),
port: stable.getMappedPort(8013),
tls: false,
maxRetries: -1,
}),
);
assert(
OpenFeature.getProviderMetadata(E2E_CLIENT_NAME).name === FLAGD_NAME,
new Error(
`Expected ${E2E_CLIENT_NAME} provider to be configured, instead got: ${OpenFeature.providerMetadata.name}`,
),
OpenFeature.providerMetadata.name === FLAGD_NAME,
new Error(`Expected ${FLAGD_NAME} provider to be configured, instead got: ${OpenFeature.providerMetadata.name}`),
);
console.log('flagd provider configured!');
return containers;
@ -46,7 +42,5 @@ describe('web provider', () => {
container.stop();
}
});
const features = [loadFeature(GHERKIN_EVALUATION_FEATURE)];
autoBindSteps(features, [flagStepDefinitions]);
evaluation();
});

View File

@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"lib": ["ES2015", "DOM"],
"outDir": "../../../dist/out-tsc",
"declaration": true,
"types": ["jest"],
"allowSyntheticDefaultImports": true,
"allowJs" :true
}
}

View File

@ -1,11 +1,17 @@
import type { CallbackClient, ConnectError, PromiseClient } from '@connectrpc/connect';
import { Code } from '@connectrpc/connect';
import { CallbackClient, Code, ConnectError, PromiseClient } from '@connectrpc/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,
ProviderStatus,
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/flagd/evaluation/v1/evaluation_connect';
import { AnyFlag, EventStreamResponse, ResolveAllResponse } from '../proto/ts/flagd/evaluation/v1/evaluation_pb';
import { FlagdWebProvider } from './flagd-web-provider';
const EVENT_CONFIGURATION_CHANGE = 'configuration_change';
@ -187,12 +193,21 @@ describe(FlagdWebProvider.name, () => {
describe(ProviderEvents.Ready, () => {
it('should fire as soon as client subscribes, if ready', (done) => {
try {
// should start NOT_READY
expect(provider.status).toEqual(ProviderStatus.NOT_READY);
done();
} catch (err) {
done(err);
}
mockCallbackClient.mockMessage({
type: EVENT_PROVIDER_READY,
});
client.addHandler(ProviderEvents.Ready, () => {
try {
expect(provider.status).toEqual(ProviderStatus.READY);
done();
} catch (err) {
done(err);
@ -201,14 +216,22 @@ describe(FlagdWebProvider.name, () => {
});
it('should fire and be ready if message received', (done) => {
try {
// should start NOT_READY
expect(provider.status).toEqual(ProviderStatus.NOT_READY);
done();
} catch (err) {
done(err);
}
client.addHandler(ProviderEvents.Ready, () => {
try {
expect(provider.status).toEqual(ProviderStatus.READY);
done();
} catch (err) {
done(err);
}
});
mockCallbackClient.mockMessage({
type: EVENT_PROVIDER_READY,
});
@ -246,9 +269,9 @@ describe(FlagdWebProvider.name, () => {
describe(ProviderEvents.Error, () => {
it('should fire if message received', (done) => {
client.addHandler(ProviderEvents.Error, (event) => {
client.addHandler(ProviderEvents.Error, () => {
try {
expect(event?.providerName).toBe('flagd');
expect(provider.status).toEqual(ProviderStatus.ERROR);
done();
} catch (err) {
done(err);

View File

@ -1,27 +1,24 @@
import type { CallbackClient, PromiseClient } from '@connectrpc/connect';
import { createCallbackClient, createPromiseClient } from '@connectrpc/connect';
import { CallbackClient, createCallbackClient, createPromiseClient, PromiseClient } from '@connectrpc/connect';
import { createConnectTransport } from '@connectrpc/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,
ProviderStatus,
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 { AnyFlag } from '../proto/ts/flagd/evaluation/v1/evaluation_pb';
import { FlagdProviderOptions, getOptions } from './options';
export const ERROR_DISABLED = 'DISABLED';
@ -34,12 +31,10 @@ 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 _status = ProviderStatus.NOT_READY;
private _connected = false;
private _promiseClient: PromiseClient<typeof Service>;
private _callbackClient: CallbackClient<typeof Service>;
@ -69,6 +64,12 @@ export class FlagdWebProvider implements Provider {
this._logger = logger;
}
get status() {
return this._status;
}
events = new OpenFeatureEventEmitter();
async onContextChange(oldContext: EvaluationContext, newContext: EvaluationContext): Promise<void> {
await this.fetchAll(newContext);
}
@ -111,7 +112,6 @@ export class FlagdWebProvider implements Provider {
reason: this._connected ? resolved.reason : StandardResolutionReasons.CACHED,
variant: resolved.variant,
value: resolved.value as T,
flagMetadata: resolved.flagMetadata,
};
}
@ -129,6 +129,7 @@ export class FlagdWebProvider implements Provider {
case EVENT_PROVIDER_READY:
this.fetchAll(currentContext).then(() => {
this.resetConnectionState();
this._status = ProviderStatus.READY;
resolve();
});
return;
@ -141,7 +142,8 @@ export class FlagdWebProvider implements Provider {
}
},
(err) => {
this._logger?.error(`${FlagdWebProvider.name}: could not establish connection, ${err?.message}`);
this._status = ProviderStatus.ERROR;
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++;
@ -158,12 +160,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

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

View File

@ -38,7 +38,6 @@ Options can be defined in the constructor or as environment variables. Construct
| 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 |
#### Resolver type-specific Defaults
@ -62,7 +61,7 @@ In the above example, the provider expects flagd to be available at `localhost:8
Alternatively, you can use socket paths to connect to flagd.
```ts
```
OpenFeature.setProvider(new FlagdProvider({
socketPath: "/tmp/flagd.socks",
}))
@ -73,7 +72,7 @@ Alternatively, you can use socket paths to connect to flagd.
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',
}))
@ -84,7 +83,7 @@ In the above example, the provider expects a flag sync service implementation to
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',
@ -94,18 +93,6 @@ To enable this mode, you should provide a valid flag configuration file with the
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.
@ -120,8 +107,9 @@ For general information on events, see the [official documentation](https://open
### 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.
| Field | Type | Value |
| ------- | ------ | ------------------------------------------------- |
| `scope` | string | "selector" set for the associated source in flagd |
## Building

View File

@ -0,0 +1,17 @@
services:
flagd:
image: ghcr.io/open-feature/flagd-testbed:v0.5.6
ports:
- 8013:8013
flagd-unstable:
image: ghcr.io/open-feature/flagd-testbed-unstable:v0.5.6
ports:
- 8014:8013
flagd-sync:
image: ghcr.io/open-feature/sync-testbed:v0.5.6
ports:
- 9090:9090
flagd-sync-unstable:
image: ghcr.io/open-feature/sync-testbed-unstable:v0.5.6
ports:
- 9091:9090

@ -0,0 +1 @@
Subproject commit ed7e0ba660b01e1a22849e1b28ec37453921552e

View File

@ -1,17 +1,12 @@
{
"name": "@openfeature/flagd-provider",
"version": "0.13.3",
"license": "Apache-2.0",
"version": "0.13.1",
"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": {
"@grpc/grpc-js": "~1.8.0 || ~1.9.0 || ~1.10.0 || ~1.11.0 || ~1.12.0 || ~1.13.0",
"@openfeature/server-sdk": "^1.17.0"
},
"dependencies": {
"lru-cache": "^11.0.0",
"@openfeature/flagd-core": "^1.0.0"
"@grpc/grpc-js": "~1.8.0 || ~1.9.0 || ~1.10.0",
"@openfeature/server-sdk": "^1.13.0"
}
}

View File

@ -28,8 +28,15 @@
}
},
"lint": {
"executor": "@nx/eslint:lint",
"outputs": ["{options.outputFile}"],
"executor": "@nx/linter:eslint",
"outputs": [
"{options.outputFile}"
],
"options": {
"lintFilePatterns": [
"libs/providers/flagd/**/*.ts"
]
},
"dependsOn": [
{
"target": "generate"
@ -38,9 +45,12 @@
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/libs/providers/flagd"],
"outputs": [
"{workspaceRoot}/coverage/libs/providers/flagd"
],
"options": {
"jestConfig": "libs/providers/flagd/jest.config.ts"
"jestConfig": "libs/providers/flagd/jest.config.ts",
"passWithNoTests": true
},
"dependsOn": [
{
@ -48,11 +58,27 @@
}
]
},
"e2e": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/libs/providers/flagd"],
"pullTestHarness": {
"executor": "nx:run-commands",
"options": {
"jestConfig": "libs/providers/flagd/src/e2e/jest.config.ts",
"commands": [
"git submodule update --init spec",
"git submodule update --init flagd-testbed",
"rm -f -r ./src/e2e/features/*",
"cp -v ./spec/specification/assets/gherkin/evaluation.feature ./src/e2e/features/",
"cp -v ./flagd-testbed/gherkin/*.feature ./src/e2e/features/"
],
"cwd": "libs/providers/flagd",
"parallel": false
}
},
"e2e": {
"executor": "nx:run-commands",
"options": {
"commands": [
"npx jest --runInBand --detectOpenHandles"
],
"cwd": "libs/providers/flagd/src/e2e",
"parallel": false
},
"dependsOn": [
@ -60,13 +86,15 @@
"target": "generate"
},
{
"target": "flagd-core:pullTestHarness"
"target": "pullTestHarness"
}
]
},
"package": {
"executor": "@nx/rollup:rollup",
"outputs": ["{options.outputPath}"],
"outputs": [
"{options.outputPath}"
],
"options": {
"project": "libs/providers/flagd/package.json",
"outputPath": "dist/libs/providers/flagd",
@ -74,9 +102,13 @@
"tsConfig": "libs/providers/flagd/tsconfig.lib.json",
"compiler": "tsc",
"generateExportsField": true,
"buildableProjectDepsInPackageJsonType": "dependencies",
"umdName": "flagd",
"external": "all",
"format": ["cjs", "esm"],
"format": [
"cjs",
"esm"
],
"assets": [
{
"glob": "package.json",
@ -93,7 +125,8 @@
"input": "./libs/providers/flagd",
"output": "./"
}
]
],
"updateBuildableProjectDepsInPackageJson": true
},
"dependsOn": [
{

@ -1 +1 @@
Subproject commit 2852d7772e6b8674681a6ee6b88db10dbe3f6899
Subproject commit e72b08b71ad8654e8a31ec6f75a9c8b4d47db8ca

@ -1 +1 @@
Subproject commit a3678719bf9f62880375ba835ebe1bb7a77de409
Subproject commit eaa44da2110d97376d3292ea3963c54bea498a77

View File

@ -1,19 +1,6 @@
import { getGherkinTestPath } from '@openfeature/flagd-core';
export const FLAGD_NAME = 'flagd';
export const FLAGD_NAME = 'flagd Provider';
export const E2E_CLIENT_NAME = 'e2e';
export const UNSTABLE_CLIENT_NAME = 'unstable';
export const UNAVAILABLE_CLIENT_NAME = 'unavailable';
export const GHERKIN_FLAGD = getGherkinTestPath('*.feature');
export const CONNECTION_FEATURE = getGherkinTestPath('connection.feature');
export const CONTEXT_ENRICHMENT_FEATURE = getGherkinTestPath('contextEnrichment.feature');
export const EVALUATION_FEATURE = getGherkinTestPath('evaluation.feature');
export const EVENTS_FEATURE = getGherkinTestPath('events.feature');
export const METADATA_FEATURE = getGherkinTestPath('metadata.feature');
export const RPC_CACHING_FEATURE = getGherkinTestPath('rpc-caching.feature');
export const SELECTOR_FEATURE = getGherkinTestPath('selector.feature');
export const TARGETING_FEATURE = getGherkinTestPath('targeting.feature');
export const GHERKIN_EVALUATION_FEATURE = getGherkinTestPath(
'evaluation.feature',
'spec/specification/assets/gherkin/',
);
export const IMAGE_VERSION = 'v0.5.6';

View File

@ -0,0 +1 @@
*.feature

View File

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

View File

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

View File

@ -1,47 +0,0 @@
import type { StepsDefinitionCallbackOptions } from 'jest-cucumber/dist/src/feature-definition-creation';
import type { State, Steps } from './state';
import { CacheOption, getConfig, ResolverType } from '../../lib/configuration';
import { mapValueToType } from './utils';
export const configSteps: Steps = (state: State) => {
function mapName(name: string): string {
switch (name) {
case 'resolver':
return 'resolverType';
default:
return name;
}
}
return ({ given, when, then }: StepsDefinitionCallbackOptions) => {
beforeEach(() => {
state.options = {};
});
given(/^an option "(.*)" of type "(.*)" with value "(.*)"$/, (name: string, type: string, value: string) => {
state.options[mapName(name)] = mapValueToType(value, type);
});
given(/^an environment variable "(.*)" with value "(.*)"$/, (name, value) => {
process.env[name] = value;
});
when('a config was initialized', () => {
state.config = getConfig(state.options);
});
then(
/^the option "(.*)" of type "(.*)" should have the value "(.*)"$/,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
(_name: string, _type: string, _value: string) => {
// TODO: implement with configuration unification, see: https://github.com/open-feature/js-sdk-contrib/issues/1096
// const expected = mapValueToType(value, type);
// const propertyName = mapName(name);
// // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// // @ts-ignore
// const configElement = state.config[propertyName];
// expect(configElement).toBe(expected);
},
);
// eslint-disable-next-line @typescript-eslint/no-empty-function
then('we should have an error', () => {});
};
};

View File

@ -1,33 +0,0 @@
import type { JsonObject } from '@openfeature/server-sdk';
import type { State, Steps } from './state';
import { mapValueToType } from './utils';
export const contextSteps: Steps =
(state: State) =>
({ given, when, then }) => {
beforeEach(() => (state.context = undefined));
given(
/^a context containing a key "(.*)", with type "(.*)" and with value "(.*)"$/,
(key: string, type: string, value: string) => {
if (state.context == undefined) {
state.context = {};
}
state.context[key] = mapValueToType(value, type);
},
);
given(
/^a context containing a nested property with outer key "(.*)" and inner key "(.*)", with value "(.*)"$/,
(outer: string, inner: string, value) => {
if (state.context == undefined) {
state.context = {};
}
state.context[outer] = { ...(state.context[outer] as JsonObject), [inner]: value };
},
);
given(/^a context containing a targeting key with value "(.*)"$/, (key) => {
if (state.context == undefined) {
state.context = {};
}
state.context.targetingKey = key;
});
};

View File

@ -0,0 +1,364 @@
import {
EvaluationContext,
EvaluationDetails,
JsonObject,
JsonValue,
OpenFeature,
ProviderEvents,
ResolutionDetails,
StandardResolutionReasons,
} from '@openfeature/server-sdk';
import { defineFeature, loadFeature } from 'jest-cucumber';
import { E2E_CLIENT_NAME } from '../constants';
export function evaluation() {
// load the feature file.
const feature = loadFeature('features/evaluation.feature');
// get a client (flagd provider registered in setup)
const client = OpenFeature.getClient(E2E_CLIENT_NAME);
const givenAnOpenfeatureClientIsRegistered = (
given: (stepMatcher: string, stepDefinitionCallback: () => void) => void,
) => {
given('a provider is registered', () => undefined);
};
defineFeature(feature, (test) => {
beforeAll((done) => {
client.addHandler(ProviderEvents.Ready, async () => {
done();
});
});
test('Resolves boolean value', ({ given, when, then }) => {
let value: boolean;
let flagKey: string;
givenAnOpenfeatureClientIsRegistered(given);
when(
/^a boolean flag with key "(.*)" is evaluated with default value "(.*)"$/,
async (key: string, defaultValue: string) => {
flagKey = key;
value = await client.getBooleanValue(flagKey, defaultValue === 'true');
},
);
then(/^the resolved boolean value should be "(.*)"$/, (expectedValue: string) => {
expect(value).toEqual(expectedValue === 'true');
});
});
test('Resolves string value', ({ given, when, then }) => {
let value: string;
let flagKey: string;
givenAnOpenfeatureClientIsRegistered(given);
when(
/^a string flag with key "(.*)" is evaluated with default value "(.*)"$/,
async (key: string, defaultValue: string) => {
flagKey = key;
value = await client.getStringValue(flagKey, defaultValue);
},
);
then(/^the resolved string value should be "(.*)"$/, (expectedValue: string) => {
expect(value).toEqual(expectedValue);
});
});
test('Resolves integer value', ({ given, when, then }) => {
let value: number;
let flagKey: string;
givenAnOpenfeatureClientIsRegistered(given);
when(
/^an integer flag with key "(.*)" is evaluated with default value (\d+)$/,
async (key: string, defaultValue: string) => {
flagKey = key;
value = await client.getNumberValue(flagKey, Number.parseInt(defaultValue));
},
);
then(/^the resolved integer value should be (\d+)$/, (expectedValue: string) => {
expect(value).toEqual(Number.parseInt(expectedValue));
});
});
test('Resolves float value', ({ given, when, then }) => {
let value: number;
let flagKey: string;
givenAnOpenfeatureClientIsRegistered(given);
when(
/^a float flag with key "(.*)" is evaluated with default value (\d+\.?\d*)$/,
async (key: string, defaultValue: string) => {
flagKey = key;
value = await client.getNumberValue(flagKey, Number.parseFloat(defaultValue));
},
);
then(/^the resolved float value should be (\d+\.?\d*)$/, (expectedValue: string) => {
expect(value).toEqual(Number.parseFloat(expectedValue));
});
});
test('Resolves object value', ({ given, when, then }) => {
let value: JsonValue;
let flagKey: string;
givenAnOpenfeatureClientIsRegistered(given);
when(/^an object flag with key "(.*)" is evaluated with a null default value$/, async (key: string) => {
flagKey = key;
value = await client.getObjectValue(flagKey, {});
});
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));
},
);
});
test('Resolves boolean details', ({ given, when, then }) => {
let details: EvaluationDetails<boolean>;
let flagKey: string;
givenAnOpenfeatureClientIsRegistered(given);
when(
/^a boolean flag with key "(.*)" is evaluated with details and default value "(.*)"$/,
async (key: string, defaultValue: string) => {
flagKey = key;
details = await client.getBooleanDetails(flagKey, 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.value).toEqual(expectedValue === 'true');
expect(details.variant).toEqual(expectedVariant);
expect(details.reason).toEqual(expectedReason);
},
);
});
test('Resolves string details', ({ given, when, then }) => {
let details: EvaluationDetails<string>;
let flagKey: string;
givenAnOpenfeatureClientIsRegistered(given);
when(
/^a string flag with key "(.*)" is evaluated with details and default value "(.*)"$/,
async (key: string, defaultValue: string) => {
flagKey = key;
details = await client.getStringDetails(flagKey, 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.value).toEqual(expectedValue);
expect(details.variant).toEqual(expectedVariant);
expect(details.reason).toEqual(expectedReason);
},
);
});
test('Resolves integer details', ({ given, when, then }) => {
let details: EvaluationDetails<number>;
let flagKey: string;
givenAnOpenfeatureClientIsRegistered(given);
when(
/^an integer flag with key "(.*)" is evaluated with details and default value (\d+)$/,
async (key: string, defaultValue: string) => {
flagKey = key;
details = await client.getNumberDetails(flagKey, 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.value).toEqual(Number.parseInt(expectedValue));
expect(details.variant).toEqual(expectedVariant);
expect(details.reason).toEqual(expectedReason);
},
);
});
test('Resolves float details', ({ given, when, then }) => {
let details: EvaluationDetails<number>;
let flagKey: string;
givenAnOpenfeatureClientIsRegistered(given);
when(
/^a float flag with key "(.*)" is evaluated with details and default value (\d+\.?\d*)$/,
async (key: string, defaultValue: string) => {
flagKey = key;
details = await client.getNumberDetails(flagKey, 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.value).toEqual(Number.parseFloat(expectedValue));
expect(details.variant).toEqual(expectedVariant);
expect(details.reason).toEqual(expectedReason);
},
);
});
test('Resolves object details', ({ given, when, then, and }) => {
let details: EvaluationDetails<JsonValue>; // update this after merge
let flagKey: string;
givenAnOpenfeatureClientIsRegistered(given);
when(
/^an object flag with key "(.*)" is evaluated with details and a null default value$/,
async (key: string) => {
flagKey = key;
details = await client.getObjectDetails(flagKey, {}); // update this after merge
},
);
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) => {
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.variant).toEqual(expectedVariant);
expect(details.reason).toEqual(expectedReason);
},
);
});
test('Resolves based on context', ({ given, when, and, then }) => {
const context: EvaluationContext = {};
let value: string;
let flagKey: string;
givenAnOpenfeatureClientIsRegistered(given);
when(
/^context contains keys "(.*)", "(.*)", "(.*)", "(.*)" with values "(.*)", "(.*)", (\d+), "(.*)"$/,
(
stringField1: string,
stringField2: string,
intField: string,
boolField: string,
stringValue1: string,
stringValue2: string,
intValue: string,
boolValue: string,
) => {
context[stringField1] = stringValue1;
context[stringField2] = stringValue2;
context[intField] = Number.parseInt(intValue);
context[boolField] = boolValue === 'true';
},
);
and(
/^a flag with key "(.*)" is evaluated with default value "(.*)"$/,
async (key: string, defaultValue: string) => {
flagKey = key;
value = await client.getStringValue(flagKey, defaultValue, context);
},
);
then(/^the resolved string response should be "(.*)"$/, (expectedValue: string) => {
expect(value).toEqual(expectedValue);
});
and(/^the resolved flag value is "(.*)" when the context is empty$/, async (expectedValue) => {
const emptyContextValue = await client.getStringValue(flagKey, 'nope', {});
expect(emptyContextValue).toEqual(expectedValue);
});
});
test('Flag not found', ({ given, when, then, and }) => {
let flagKey: string;
let fallbackValue: string;
let details: ResolutionDetails<string>;
givenAnOpenfeatureClientIsRegistered(given);
when(
/^a non-existent string flag with key "(.*)" is evaluated with details and a default value "(.*)"$/,
async (key: string, defaultValue: string) => {
flagKey = key;
fallbackValue = defaultValue;
details = await client.getStringDetails(flagKey, defaultValue);
},
);
then(/^the default string value should be returned$/, () => {
expect(details.value).toEqual(fallbackValue);
});
and(
/^the reason should indicate an error and the error code should indicate a missing flag with "(.*)"$/,
(errorCode: string) => {
expect(details.reason).toEqual(StandardResolutionReasons.ERROR);
expect(details.errorCode).toEqual(errorCode);
},
);
});
test('Type error', ({ given, when, then, and }) => {
let flagKey: string;
let fallbackValue: number;
let details: ResolutionDetails<number>;
givenAnOpenfeatureClientIsRegistered(given);
when(
/^a string flag with key "(.*)" is evaluated as an integer, with details and a default value (\d+)$/,
async (key: string, defaultValue: string) => {
flagKey = key;
fallbackValue = Number.parseInt(defaultValue);
details = await client.getNumberDetails(flagKey, Number.parseInt(defaultValue));
},
);
then(/^the default integer value should be returned$/, () => {
expect(details.value).toEqual(fallbackValue);
});
and(
/^the reason should indicate an error and the error code should indicate a type mismatch with "(.*)"$/,
(errorCode: string) => {
expect(details.reason).toEqual(StandardResolutionReasons.ERROR);
expect(details.errorCode).toEqual(errorCode);
},
);
});
});
}

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