diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bf50c87b..54de58ef 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - v* jobs: release: diff --git a/.vscode/settings.json b/.vscode/settings.json index b1230452..4944fbf8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -39,7 +39,5 @@ "**/*.lock": true }, "typescript.tsdk": "node_modules/typescript/lib", - "cSpell.words": [ - "bucketco" - ] + "cSpell.words": ["bucketco", "openfeature"] } diff --git a/docs.sh b/docs.sh index e67f36f5..d5ecc670 100755 --- a/docs.sh +++ b/docs.sh @@ -15,11 +15,46 @@ typedoc # We can fix this by removing the number at the end of the anchor. SEDCOMMAND='s/globals.md#(.*)-[0-9]+/globals.md#\1/g' -FILES=$(find dist/docs/@bucketco -name "globals.md") +# Find all markdown files including globals.md +FILES=$(find dist/docs/@bucketco -name "*.md") +echo "Processing markdown files..." for file in $FILES do - sed -r $SEDCOMMAND $file > $file.fixed - rm $file - mv $file.fixed $file + echo "Processing $file..." + + # Fix anchor links in globals.md files + if [[ "$file" == *"globals.md" ]]; then + sed -r "$SEDCOMMAND" "$file" > "$file.fixed" + rm "$file" + mv "$file.fixed" "$file" + fi + + # Create a temporary file for processing + tmp_file="${file}.tmp" + + # Process NOTE blocks - handle multi-line + awk ' + BEGIN { in_block = 0; content = ""; } + /^> \[!NOTE\]/ { in_block = 1; print "{% hint style=\"info\" %}"; next; } + /^> \[!TIP\]/ { in_block = 1; print "{% hint style=\"success\" %}"; next; } + /^> \[!IMPORTANT\]/ { in_block = 1; print "{% hint style=\"warning\" %}"; next; } + /^> \[!WARNING\]/ { in_block = 1; print "{% hint style=\"warning\" %}"; next; } + /^> \[!CAUTION\]/ { in_block = 1; print "{% hint style=\"danger\" %}"; next; } + in_block && /^>/ { + content = content substr($0, 3) "\n"; + next; + } + in_block && !/^>/ { + printf "%s", content; + print "{% endhint %}"; + in_block = 0; + content = ""; + } + !in_block { print; } + ' "$file" > "$tmp_file" + + mv "$tmp_file" "$file" done + +echo "Processing complete!" diff --git a/package.json b/package.json index 0ff279ab..050c213f 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "build": "lerna run build --stream", "test:ci": "lerna run test:ci --stream", "test": "lerna run test --stream", + "format": "lerna run format --stream", "prettier": "lerna run prettier --stream", "prettier:fix": "lerna run prettier -- --write", "lint": "lerna run lint --stream", @@ -26,7 +27,8 @@ "prettier": "^3.3.3", "typedoc": "0.27.6", "typedoc-plugin-frontmatter": "^1.1.2", - "typedoc-plugin-markdown": "^4.4.2", - "typedoc-plugin-mdn-links": "^4.0.7" + "typedoc-plugin-markdown": "^4.4.1", + "typedoc-plugin-mdn-links": "^4.0.7", + "typescript": "^5.7.3" } } diff --git a/packages/node-sdk/README.md b/packages/node-sdk/README.md index cff744dc..263fbbde 100644 --- a/packages/node-sdk/README.md +++ b/packages/node-sdk/README.md @@ -20,12 +20,9 @@ To get started you need to obtain your secret key from the [environment settings](https://app.bucket.co/envs/current/settings/app-environments) in Bucket. -{% hint style="danger" %} -Secret keys are meant for use in server side SDKs only. -Secret keys offer the users the ability to obtain -information that is often sensitive and thus should not be used in -client-side applications. -{% endhint %} +> [!CAUTION] +> Secret keys are meant for use in server side SDKs only. Secret keys offer the users the ability to obtain +> information that is often sensitive and thus should not be used in client-side applications. Bucket will load settings through the various environment variables automatically (see [Configuring](#configuring) below). @@ -54,7 +51,8 @@ bucketClient.initialize().then({ Once the client is initialized, you can obtain features along with the `isEnabled` status to indicate whether the feature is targeted for this user/company: -_Note_: If `user.id` or `company.id` is not given, the whole `user` or `company` object is ignored. +> [!IMPORTANT] +> If `user.id` or `company.id` is not given, the whole `user` or `company` object is ignored. ```typescript // configure the client @@ -74,13 +72,18 @@ const boundClient = bucketClient.bindClient({ // get the huddle feature using company, user and custom context to // evaluate the targeting. -const { isEnabled, track } = boundClient.getFeature("huddle"); +const { isEnabled, track, config } = boundClient.getFeature("huddle"); if (isEnabled) { // this is your feature gated code ... // send an event when the feature is used: track(); + if (config?.key === "zoom") { + // this code will run if a given remote configuration + // is set up. + } + // CAUTION: if you plan to use the event for automated feedback surveys // call `flush` immediately after `track`. It can optionally be awaited // to guarantee the sent happened. @@ -100,7 +103,7 @@ const bothEnabled = ## High performance feature targeting -The Bucket Node SDK contacts the Bucket servers when you call `initialize()` +The SDK contacts the Bucket servers when you call `initialize()` and downloads the features with their targeting rules. These rules are then matched against the user/company information you provide to `getFeatures()` (or through `bindClient(..).getFeatures()`). That means the @@ -108,6 +111,147 @@ to `getFeatures()` (or through `bindClient(..).getFeatures()`). That means the `initialize()` has completed. `BucketClient` will continue to periodically download the targeting rules from the Bucket servers in the background. +### Batch Operations + +The SDK automatically batches operations like user/company updates and feature tracking events to minimize API calls. +The batch buffer is configurable through the client options: + +```typescript +const client = new BucketClient({ + batchOptions: { + maxSize: 100, // Maximum number of events to batch + intervalMs: 1000, // Flush interval in milliseconds + }, +}); +``` + +You can manually flush the batch buffer at any time: + +```typescript +await client.flush(); +``` + +> [!TIP] +> It's recommended to call `flush()` before your application shuts down to ensure all events are sent. + +### Rate Limiting + +The SDK includes automatic rate limiting for feature events to prevent overwhelming the API. Rate limiting is applied per +unique combination of feature key and context. The rate limiter window size is configurable: + +```typescript +const client = new BucketClient({ + rateLimiterOptions: { + windowSizeMs: 60000, // Rate limiting window size in milliseconds + }, +}); +``` + +### Caching + +Feature definitions are automatically cached and refreshed in the background. The cache behavior is configurable: + +```typescript +const client = new BucketClient({ + refetchInterval: 30000, // How often to refresh features (ms) + staleWarningInterval: 150000, // When to warn about stale features (ms) +}); +``` + +## Error Handling + +The SDK is designed to fail gracefully and never throw exceptions to the caller. Instead, it logs errors and provides +fallback behavior: + +1. **Feature Evaluation Failures**: + + ```typescript + const { isEnabled } = client.getFeature("my-feature"); + // If feature evaluation fails, isEnabled will be false + ``` + +2. **Network Errors**: + + ```typescript + // Network errors during tracking are logged but don't affect your application + const { track } = client.getFeature("my-feature"); + if (isEnabled) { + try { + await track(); + } catch (error) { + // The SDK already logged this error + // Your application can continue normally + } + } + ``` + +3. **Missing Context**: + + ```typescript + // The SDK tracks missing context fields but continues operation + const features = client.getFeatures({ + user: { id: "user123" }, + // Missing company context will be logged but won't cause errors + }); + ``` + +4. **Offline Mode**: + + ```typescript + // In offline mode, the SDK uses fallback features + const client = new BucketClient({ + offline: true, + fallbackFeatures: { + "my-feature": true, + }, + }); + ``` + +The SDK logs all errors with appropriate severity levels. You can customize logging by providing your own logger: + +```typescript +const client = new BucketClient({ + logger: { + debug: (msg) => console.debug(msg), + info: (msg) => console.info(msg), + warn: (msg) => console.warn(msg), + error: (msg, error) => { + console.error(msg, error); + // Send to your error tracking service + errorTracker.capture(error); + }, + }, +}); +``` + +### Remote config + +Similar to `isEnabled`, each feature has a `config` property. This configuration is managed from within Bucket. +It is managed similar to the way access to features is managed, but instead of the binary `isEnabled` you can have +multiple configuration values which are given to different user/companies. + +```ts +const features = bucketClient.getFeatures(); +// { +// huddle: { +// isEnabled: true, +// targetingVersion: 42, +// config: { +// key: "gpt-3.5", +// payload: { maxTokens: 10000, model: "gpt-3.5-beta1" } +// } +// } +// } +``` + +The `key` is always present while the `payload` is a optional JSON value for arbitrary configuration needs. +If feature has no configuration or, no configuration value was matched against the context, the `config` object +will be empty, thus, `key` will be `undefined`. Make sure to check against this case when trying to use the +configuration in your application. + +Just as `isEnabled`, accessing `config` on the object returned by `getFeatures` does not automatically +generate a `check` event, contrary to the `config` property on the object returned by `getFeature`. + ## Configuring The Bucket `Node.js` SDK can be configured through environment variables, @@ -124,7 +268,7 @@ current working directory. | `featureOverrides` | Record | An object specifying feature overrides for testing or local development. See [example/app.test.ts](https://github.com/bucketco/bucket-javascript-sdk/tree/main/packages/browser-sdk/example/app.test.ts) for how to use `featureOverrides` in tests. | BUCKET_FEATURES_ENABLED, BUCKET_FEATURES_DISABLED | | `configFile` | string | Load this config file from disk. Default: `bucketConfig.json` | BUCKET_CONFIG_FILE | -Note: BUCKET_FEATURES_ENABLED, BUCKET_FEATURES_DISABLED are comma separated lists of features which will be enabled or disabled respectively. +> [!NOTE] > `BUCKET_FEATURES_ENABLED` and `BUCKET_FEATURES_DISABLED` are comma separated lists of features which will be enabled or disabled respectively. `bucketConfig.json` example: @@ -136,7 +280,16 @@ Note: BUCKET_FEATURES_ENABLED, BUCKET_FEATURES_DISABLED are comma separated list "apiBaseUrl": "https://proxy.slick-demo.com", "featureOverrides": { "huddles": true, - "voiceChat": false + "voiceChat": { "isEnabled": false }, + "aiAssist": { + "isEnabled": true, + "config": { + "key": "gpt-4.0", + "payload": { + "maxTokens": 50000 + } + } + } } } ``` @@ -162,22 +315,157 @@ import { BucketClient } from "@bucketco/node-sdk"; declare module "@bucketco/node-sdk" { interface Features { "show-todos": boolean; - "create-todos": boolean; - "delete-todos": boolean; + "create-todos": { + isEnabled: boolean; + config: { + key: string; + payload: { + minimumLength: number; + }; + }; + }; + "delete-todos": { + isEnabled: boolean; + config: { + key: string; + payload: { + requireConfirmation: boolean; + maxDeletionsPerDay: number; + }; + }; + }; } } export const bucketClient = new BucketClient(); -bucketClient.initialize().then({ - console.log("Bucket initialized!") - bucketClient.getFeature("invalid-feature") // feature doesn't exist -}) +bucketClient.initialize().then(() => { + console.log("Bucket initialized!"); + + // TypeScript will catch this error: "invalid-feature" doesn't exist + bucketClient.getFeature("invalid-feature"); + const { + isEnabled, + config: { payload }, + } = bucketClient.getFeature("create-todos"); +}); ``` ![Type check failed](docs/type-check-failed.png "Type check failed") +```typescript +bucketClient.initialize().then(() => { + // TypeScript will catch this error as well: "minLength" is not part of the payload. + if (isEnabled && todo.length > config.payload.minLength) { + // ... + } +}); +``` + +![Config type check failed](docs/type-check-payload-failed.png "Remote config type check failed") + +## Feature Overrides + +Feature overrides allow you to override feature flags and their configurations locally. This is particularly useful for development and testing. You can specify overrides in three ways: + +1. Through environment variables: + +```bash +BUCKET_FEATURES_ENABLED=feature1,feature2 +BUCKET_FEATURES_DISABLED=feature3,feature4 +``` + +1. Through `bucketConfig.json`: + +```json +{ + "featureOverrides": { + "delete-todos": { + "isEnabled": true, + "config": { + "key": "dev-config", + "payload": { + "requireConfirmation": true, + "maxDeletionsPerDay": 5 + } + } + } + } +} +``` + +1. Programmatically through the client options: + +```typescript +import { BucketClient, Context } from "@bucketco/node-sdk"; + +const featureOverrides = (context: Context) => ({ + "delete-todos": { + isEnabled: true, + config: { + key: "dev-config", + payload: { + requireConfirmation: true, + maxDeletionsPerDay: 5, + }, + }, + }, +}); + +const client = new BucketClient({ + featureOverrides, +}); +``` + +## Remote Feature Evaluation + +In addition to local feature evaluation, Bucket supports remote evaluation using stored context. This is useful when you want to evaluate features using user/company attributes that were previously sent to Bucket: + +```typescript +// First, update user and company attributes +await client.updateUser("user123", { + attributes: { + role: "admin", + subscription: "premium", + }, +}); + +await client.updateCompany("company456", { + attributes: { + tier: "enterprise", + employees: 1000, + }, +}); + +// Later, evaluate features remotely using stored context +const features = await client.getFeaturesRemote("company456", "user123"); +// Or evaluate a single feature +const feature = await client.getFeatureRemote( + "create-todos", + "company456", + "user123", +); + +// You can also provide additional context +const featuresWithContext = await client.getFeaturesRemote( + "company456", + "user123", + { + other: { + location: "US", + platform: "mobile", + }, + }, +); +``` + +Remote evaluation is particularly useful when: + +- You want to use the most up-to-date user/company attributes stored in Bucket +- You don't want to pass all context attributes with every evaluation +- You need to ensure consistent feature evaluation across different services + ## Using with Express A popular way to integrate the Bucket Node.js SDK is through an express middleware. @@ -271,9 +559,10 @@ client.updateCompany("acme_inc", { const features = await client.getFeaturesRemote("acme_inc", "john_doe"); ``` -NOTE: User and company attribute updates are processed asynchronously, so there might -be a small delay between when attributes are updated and when they are available -for evaluation. +> [!IMPORTANT] +> User and company attribute updates are processed asynchronously, so there might +> be a small delay between when attributes are updated and when they are available +> for evaluation. ## Opting out of tracking @@ -302,7 +591,7 @@ Another way way to disable tracking without employing a bound client is to call or `getFeatures()` by supplying `enableTracking: false` in the arguments passed to these functions. -> [!NOTE] +> [!IMPORTANT] > Note, however, that calling `track()`, `updateCompany()` or `updateUser()` in the `BucketClient` > will still send tracking data. As such, it is always recommended to use `bindClient()` > when using this SDK. @@ -373,7 +662,7 @@ Some attributes are used by Bucket to improve the UI, and are recommended to provide for easier navigation: - `name` -- display name for `user`/`company`, -- `email` -- the email of the user. +- `email` -- the email of the user, - `avatar` -- the URL for `user`/`company` avatar image. Attributes cannot be nested (multiple levels) and must be either strings, diff --git a/packages/node-sdk/docs/type-check-payload-failed.png b/packages/node-sdk/docs/type-check-payload-failed.png new file mode 100644 index 00000000..6f7a4860 Binary files /dev/null and b/packages/node-sdk/docs/type-check-payload-failed.png differ diff --git a/packages/node-sdk/example/app.ts b/packages/node-sdk/example/app.ts index 37812f17..31fca265 100644 --- a/packages/node-sdk/example/app.ts +++ b/packages/node-sdk/example/app.ts @@ -65,10 +65,18 @@ app.post("/todos", (req, res) => { return res.status(400).json({ error: "Invalid todo" }); } - const { track, isEnabled } = res.locals.bucketUser.getFeature("create-todos"); + const { track, isEnabled, config } = + res.locals.bucketUser.getFeature("create-todos"); // Check if the user has the "create-todos" feature enabled if (isEnabled) { + // Check if the todo is at least N characters long + if (todo.length < config.payload.minimumLength) { + return res + .status(400) + .json({ error: "Todo must be at least 5 characters long" }); + } + // Track the feature usage track(); todos.push(todo); diff --git a/packages/node-sdk/example/bucket.ts b/packages/node-sdk/example/bucket.ts index 37add14d..87e4ab8a 100644 --- a/packages/node-sdk/example/bucket.ts +++ b/packages/node-sdk/example/bucket.ts @@ -1,17 +1,38 @@ import { BucketClient, Context } from "../src"; import { FeatureOverrides } from "../src/types"; +type CreateConfig = { + minimumLength: number; +}; + // Extending the Features interface to define the available features declare module "../src/types" { interface Features { "show-todos": boolean; - "create-todos": boolean; + "create-todos": { + isEnabled: boolean; + config: { + key: string; + payload: CreateConfig; + }; + }; "delete-todos": boolean; + "some-else": {}; } } -let featureOverrides = (context: Context): FeatureOverrides => { - return { "delete-todos": true }; // feature keys checked at compile time +let featureOverrides = (_: Context): FeatureOverrides => { + return { + "create-todos": { + isEnabled: true, + config: { + key: "short", + payload: { + minimumLength: 10, + }, + }, + }, + }; // feature keys checked at compile time }; // Create a new BucketClient instance with the secret key and default features diff --git a/packages/node-sdk/example/bucketConfig.json b/packages/node-sdk/example/bucketConfig.json index e7c2bf24..b4f55d97 100644 --- a/packages/node-sdk/example/bucketConfig.json +++ b/packages/node-sdk/example/bucketConfig.json @@ -1,6 +1,6 @@ { "overrides": { - "myFeature": true, - "myFeatureFalse": false + "show-todos": true, + "create-todos": true } } diff --git a/packages/node-sdk/example/yarn.lock b/packages/node-sdk/example/yarn.lock index a9df8931..03f1c516 100644 --- a/packages/node-sdk/example/yarn.lock +++ b/packages/node-sdk/example/yarn.lock @@ -2479,8 +2479,8 @@ __metadata: linkType: hard "vite@npm:^5.0.0": - version: 5.4.10 - resolution: "vite@npm:5.4.10" + version: 5.4.14 + resolution: "vite@npm:5.4.14" dependencies: esbuild: "npm:^0.21.3" fsevents: "npm:~2.3.3" @@ -2517,7 +2517,7 @@ __metadata: optional: true bin: vite: bin/vite.js - checksum: 10c0/4ef4807d2fd166a920de244dbcec791ba8a903b017a7d8e9f9b4ac40d23f8152c1100610583d08f542b47ca617a0505cfc5f8407377d610599d58296996691ed + checksum: 10c0/8842933bd70ca6a98489a0bb9c8464bec373de00f9a97c8c7a4e64b24d15c88bfaa8c1acb38a68c3e5eb49072ffbccb146842c2d4edcdd036a9802964cffe3d1 languageName: node linkType: hard diff --git a/packages/node-sdk/package.json b/packages/node-sdk/package.json index 3ba99cea..3d2da969 100644 --- a/packages/node-sdk/package.json +++ b/packages/node-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@bucketco/node-sdk", - "version": "1.5.3", + "version": "1.6.0", "license": "MIT", "repository": { "type": "git", diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index 967fc211..a09c123d 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -18,9 +18,11 @@ import { subscribe as triggerOnExit } from "./flusher"; import { newRateLimiter } from "./rate-limiter"; import type { EvaluatedFeaturesAPIResponse, + FeatureAPIResponse, FeatureOverridesFn, IdType, RawFeature, + RawFeatureRemoteConfig, } from "./types"; import { Attributes, @@ -28,7 +30,6 @@ import { ClientOptions, Context, ContextWithTracking, - Feature, FeatureEvent, FeaturesAPIResponse, HttpClient, @@ -66,10 +67,13 @@ type BulkEvent = } | { type: "feature-flag-event"; - action: "check" | "evaluate"; + action: "check" | "evaluate" | "check-config" | "evaluate-config"; key: string; targetingVersion?: number; - evalResult: boolean; + evalResult: + | boolean + | { key: string; payload: any } + | { key: undefined; payload: undefined }; evalContext?: Record; evalRuleResults?: boolean[]; evalMissingFields?: string[]; @@ -164,8 +168,9 @@ export class BucketClient { ); ok( options.fallbackFeatures === undefined || - Array.isArray(options.fallbackFeatures), - "fallbackFeatures must be an object", + Array.isArray(options.fallbackFeatures) || + isObject(options.fallbackFeatures), + "fallbackFeatures must be an array or object", ); ok( options.batchOptions === undefined || isObject(options.batchOptions), @@ -203,18 +208,39 @@ export class BucketClient { // todo: deprecate fallback features in favour of a more operationally // friendly way of setting fall backs. - const fallbackFeatures = - options.fallbackFeatures && - options.fallbackFeatures.reduce( - (acc, key) => { - acc[key as keyof TypedFeatures] = { - isEnabled: true, - key, - }; - return acc; - }, - {} as Record, - ); + const fallbackFeatures = Array.isArray(options.fallbackFeatures) + ? options.fallbackFeatures.reduce( + (acc, key) => { + acc[key as keyof TypedFeatures] = { + isEnabled: true, + key, + }; + return acc; + }, + {} as Record, + ) + : isObject(options.fallbackFeatures) + ? Object.entries(options.fallbackFeatures).reduce( + (acc, [key, fallback]) => { + acc[key as keyof TypedFeatures] = { + isEnabled: + typeof fallback === "object" + ? fallback.isEnabled + : !!fallback, + key, + config: + typeof fallback === "object" && fallback.config + ? { + key: fallback.config.key, + payload: fallback.config.payload, + } + : undefined, + }; + return acc; + }, + {} as Record, + ) + : undefined; this._config = { logger, @@ -500,10 +526,10 @@ export class BucketClient { * @remarks * Call `initialize` before calling this method to ensure the feature definitions are cached, no features will be returned otherwise. **/ - public getFeature( + public getFeature( { enableTracking = true, ...context }: ContextWithTracking, - key: keyof TypedFeatures, - ) { + key: TKey, + ): TypedFeatures[TKey] { const options = { enableTracking, ...context }; const features = this._getFeatures(options); const feature = features[key]; @@ -512,6 +538,9 @@ export class BucketClient { key, isEnabled: feature?.isEnabled ?? false, targetingVersion: feature?.targetingVersion, + config: feature?.config, + ruleEvaluationResults: feature?.ruleEvaluationResults, + missingContextFields: feature?.missingContextFields, }); } @@ -549,12 +578,12 @@ export class BucketClient { * * @returns evaluated feature */ - public async getFeatureRemote( - key: string, + public async getFeatureRemote( + key: TKey, userId?: IdType, companyId?: IdType, additionalContext?: Context, - ): Promise { + ): Promise { const features = await this._getFeaturesRemote( key, userId, @@ -697,7 +726,10 @@ export class BucketClient { ok(typeof event === "object", "event must be an object"); ok( typeof event.action === "string" && - (event.action === "evaluate" || event.action === "check"), + (event.action === "evaluate" || + event.action === "evaluate-config" || + event.action === "check" || + event.action === "check-config"), "event must have an action", ); ok( @@ -710,7 +742,7 @@ export class BucketClient { "event must have a targeting version", ); ok( - typeof event.evalResult === "boolean", + typeof event.evalResult === "boolean" || isObject(event.evalResult), "event must have an evaluation result", ); ok( @@ -837,36 +869,60 @@ export class BucketClient { /** * Warns if any features have targeting rules that require context fields that are missing. * - * @param options - The options. + * @param context - The context. * @param features - The features to check. */ private warnMissingFeatureContextFields( - options: Context, - features: { key: string; missingContextFields?: string[] }[], + context: Context, + features: { + key: string; + missingContextFields?: string[]; + config?: { + key: string; + missingContextFields?: string[]; + }; + }[], ) { - features.forEach(({ key, missingContextFields }) => { - if (missingContextFields?.length) { + const report = features.reduce( + (acc, { config, ...feature }) => { if ( - !this._config.rateLimiter.isAllowed( + feature.missingContextFields?.length && + this._config.rateLimiter.isAllowed( hashObject({ - key, - missingContextFields, - options, + featureKey: feature.key, + missingContextFields: feature.missingContextFields, + context, }), ) ) { - return; + acc[feature.key] = feature.missingContextFields; } - const missingFieldsStr = missingContextFields - .map((field) => `"${field}"`) - .join(", "); + if ( + config?.missingContextFields?.length && + this._config.rateLimiter.isAllowed( + hashObject({ + featureKey: feature.key, + configKey: config.key, + missingContextFields: config.missingContextFields, + context, + }), + ) + ) { + acc[`${feature.key}.config`] = config.missingContextFields; + } - this._config.logger?.warn( - `feature "${key}" has targeting rules that require the following context fields: ${missingFieldsStr}`, - ); - } - }); + return acc; + }, + {} as Record, + ); + + if (Object.keys(report).length > 0) { + this._config.logger?.warn( + `feature/remote config targeting rules might not be correctly evaluated due to missing context fields.`, + report, + ); + } } private _getFeatures( @@ -891,8 +947,12 @@ export class BucketClient { featureDefinitions = fetchedFeatures.features; } - const keyToVersionMap = new Map( - featureDefinitions.map((f) => [f.key, f.targeting.version]), + const featureMap = featureDefinitions.reduce( + (acc, f) => { + acc[f.key] = f; + return acc; + }, + {} as Record, ); const { enableTracking = true, meta: _, ...context } = options; @@ -905,6 +965,33 @@ export class BucketClient { }), ); + const evaluatedConfigs = evaluated.reduce( + (acc, { featureKey }) => { + const feature = featureMap[featureKey]; + if (feature.config) { + const variant = evaluateFeatureRules({ + featureKey, + rules: feature.config.variants.map(({ filter, ...rest }) => ({ + filter, + value: rest, + })), + context, + }); + + if (variant.value) { + acc[featureKey] = { + ...variant.value, + targetingVersion: feature.config.version, + ruleEvaluationResults: variant.ruleEvaluationResults, + missingContextFields: variant.missingContextFields, + }; + } + } + return acc; + }, + {} as Record, + ); + this.warnMissingFeatureContextFields( context, evaluated.map(({ featureKey, missingContextFields }) => ({ @@ -914,22 +1001,50 @@ export class BucketClient { ); if (enableTracking) { - evaluated.forEach(async (res) => { - try { - await this.sendFeatureEvent({ - action: "evaluate", - key: res.featureKey, - targetingVersion: keyToVersionMap.get(res.featureKey), - evalResult: res.value ?? false, - evalContext: res.context, - evalRuleResults: res.ruleEvaluationResults, - evalMissingFields: res.missingContextFields, - }); - } catch (err) { - this._config.logger?.error( - `failed to send evaluate event for "${res.featureKey}"`, - err, + const promises = evaluated + .map((res) => { + const outPromises: Promise[] = []; + outPromises.push( + this.sendFeatureEvent({ + action: "evaluate", + key: res.featureKey, + targetingVersion: featureMap[res.featureKey].targeting.version, + evalResult: res.value ?? false, + evalContext: res.context, + evalRuleResults: res.ruleEvaluationResults, + evalMissingFields: res.missingContextFields, + }), ); + + const config = evaluatedConfigs[res.featureKey]; + if (config) { + outPromises.push( + this.sendFeatureEvent({ + action: "evaluate-config", + key: res.featureKey, + targetingVersion: config.targetingVersion, + evalResult: { key: config.key, payload: config.payload }, + evalContext: res.context, + evalRuleResults: config.ruleEvaluationResults, + evalMissingFields: config.missingContextFields, + }), + ); + } + + return outPromises; + }) + .flat(); + + void Promise.allSettled(promises).then((results) => { + const failed = results + .map((result) => + result.status === "rejected" ? result.reason : undefined, + ) + .filter(Boolean); + if (failed.length > 0) { + this._config.logger?.error(`failed to queue some evaluate events.`, { + errors: failed, + }); } }); } @@ -939,7 +1054,10 @@ export class BucketClient { acc[res.featureKey as keyof TypedFeatures] = { key: res.featureKey, isEnabled: res.value ?? false, - targetingVersion: keyToVersionMap.get(res.featureKey), + config: evaluatedConfigs[res.featureKey], + ruleEvaluationResults: res.ruleEvaluationResults, + missingContextFields: res.missingContextFields, + targetingVersion: featureMap[res.featureKey].targeting.version, }; return acc; }, @@ -949,7 +1067,14 @@ export class BucketClient { // apply feature overrides const overrides = Object.entries( this._config.featureOverrides(context), - ).map(([key, isEnabled]) => [key, { key, isEnabled }]); + ).map(([key, override]) => [ + key, + { + key, + isEnabled: isObject(override) ? override.isEnabled : !!override, + config: isObject(override) ? override.config : undefined, + }, + ]); if (overrides.length > 0) { // merge overrides into evaluated features @@ -958,48 +1083,74 @@ export class BucketClient { ...Object.fromEntries(overrides), }; } - this._config.logger?.debug("evaluated features", evaluatedFeatures); return evaluatedFeatures; } - private _wrapRawFeature( - options: { enableTracking: boolean } & Context, - { key, isEnabled, targetingVersion }: RawFeature, - ): Feature { + private _wrapRawFeature( + { enableTracking, ...context }: { enableTracking: boolean } & Context, + { config, ...feature }: RawFeature, + ): TypedFeatures[TKey] { // eslint-disable-next-line @typescript-eslint/no-this-alias const client = this; + const simplifiedConfig = config + ? { key: config.key, payload: config.payload } + : { key: undefined, payload: undefined }; + return { get isEnabled() { - if (options.enableTracking) { + if (enableTracking) { void client .sendFeatureEvent({ action: "check", - key, - targetingVersion, - evalResult: isEnabled, + key: feature.key, + targetingVersion: feature.targetingVersion, + evalResult: feature.isEnabled, + evalContext: context, + evalRuleResults: feature.ruleEvaluationResults, + evalMissingFields: feature.missingContextFields, }) .catch((err) => { client._config.logger?.error( - `failed to send check event for "${key}": ${err}`, + `failed to send check event for "${feature.key}": ${err}`, err, ); }); } - - return isEnabled; + return feature.isEnabled; }, - key, + get config() { + if (enableTracking) { + void client + .sendFeatureEvent({ + action: "check-config", + key: feature.key, + targetingVersion: config?.targetingVersion, + evalResult: simplifiedConfig, + evalContext: context, + evalRuleResults: config?.ruleEvaluationResults, + evalMissingFields: config?.missingContextFields, + }) + .catch((err) => { + client._config.logger?.error( + `failed to send check event for "${feature.key}": ${err}`, + err, + ); + }); + } + return simplifiedConfig as TypedFeatures[TKey]["config"]; + }, + key: feature.key, track: async () => { - if (typeof options.user?.id === "undefined") { + if (typeof context.user?.id === "undefined") { this._config.logger?.warn("no user set, cannot track event"); return; } - if (options.enableTracking) { - await this.track(options.user.id, key, { - companyId: options.company?.id, + if (enableTracking) { + await this.track(context.user.id, feature.key, { + companyId: context.company?.id, }); } else { this._config.logger?.debug("tracking disabled, not tracking event"); @@ -1026,6 +1177,7 @@ export class BucketClient { ...context, enableTracking: true, }; + checkContextWithTracking(contextWithTracking); const params = new URLSearchParams( @@ -1045,6 +1197,7 @@ export class BucketClient { context, Object.values(res.features), ); + return Object.fromEntries( Object.entries(res.features).map(([featureKey, feature]) => { return [ @@ -1122,7 +1275,7 @@ export class BoundBucketClient { * * @returns Features for the given user/company and whether each one is enabled or not */ - public getFeatures() { + public getFeatures(): TypedFeatures { return this._client.getFeatures(this._options); } @@ -1134,7 +1287,9 @@ export class BoundBucketClient { * * @returns Features for the given user/company and whether each one is enabled or not */ - public getFeature(key: keyof TypedFeatures) { + public getFeature( + key: TKey, + ): TypedFeatures[TKey] { return this._client.getFeature(this._options, key); } diff --git a/packages/node-sdk/src/config.ts b/packages/node-sdk/src/config.ts index c470300e..eb4ece38 100644 --- a/packages/node-sdk/src/config.ts +++ b/packages/node-sdk/src/config.ts @@ -3,7 +3,7 @@ import { readFileSync } from "fs"; import { version } from "../package.json"; import { LOG_LEVELS } from "./types"; -import { ok } from "./utils"; +import { isObject, ok } from "./utils"; export const API_BASE_URL = "https://front.bucket.co"; export const SDK_VERSION_HEADER_NAME = "bucket-sdk-version"; @@ -22,19 +22,33 @@ export const BATCH_INTERVAL_MS = 10 * 1000; function parseOverrides(config: object | undefined) { if (!config) return {}; - if ( - "featureOverrides" in config && - typeof config.featureOverrides === "object" - ) { - const overrides = config.featureOverrides as object; - Object.entries(overrides).forEach(([key, value]) => { + if ("featureOverrides" in config && isObject(config.featureOverrides)) { + Object.entries(config.featureOverrides).forEach(([key, value]) => { ok( - typeof value === "boolean", - `invalid type "${typeof value}" for key ${key}, expected boolean`, + typeof value === "boolean" || isObject(value), + `invalid type "${typeof value}" for key ${key}, expected boolean or object`, ); + if (isObject(value)) { + ok( + "isEnabled" in value && typeof value.isEnabled === "boolean", + `invalid type "${typeof value.isEnabled}" for key ${key}.isEnabled, expected boolean`, + ); + ok( + value.config === undefined || isObject(value.config), + `invalid type "${typeof value.config}" for key ${key}.config, expected object or undefined`, + ); + if (isObject(value.config)) { + ok( + "key" in value.config && typeof value.config.key === "string", + `invalid type "${typeof value.config.key}" for key ${key}.config.key, expected string`, + ); + } + } }); - return overrides; + + return config.featureOverrides; } + return {}; } diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 291cb978..5d448b87 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -22,7 +22,7 @@ export type FeatureEvent = { /** * The action that was performed. **/ - action: "evaluate" | "check"; + action: "evaluate" | "evaluate-config" | "check" | "check-config"; /** * The feature key. @@ -37,7 +37,10 @@ export type FeatureEvent = { /** * The result of targeting evaluation. **/ - evalResult: boolean; + evalResult: + | boolean + | { key: string; payload: any } + | { key: undefined; payload: undefined }; /** * The context that was used for evaluation. @@ -56,7 +59,37 @@ export type FeatureEvent = { }; /** - * Describes a feature + * A remotely managed configuration value for a feature. + */ +export type RawFeatureRemoteConfig = { + /** + * The key of the matched configuration value. + */ + key: string; + + /** + * The version of the targeting rules used to select the config value. + */ + targetingVersion?: number; + + /** + * The optional user-supplied payload data. + */ + payload: any; + + /** + * The rule results of the evaluation (optional). + */ + ruleEvaluationResults?: boolean[]; + + /** + * The missing fields in the evaluation context (optional). + */ + missingContextFields?: string[]; +}; + +/** + * Describes a feature. */ export interface RawFeature { /** @@ -74,16 +107,47 @@ export interface RawFeature { */ targetingVersion?: number; + /** + * The remote configuration value for the feature. + */ + config?: RawFeatureRemoteConfig; + + /** + * The rule results of the evaluation (optional). + */ + ruleEvaluationResults?: boolean[]; + /** * The missing fields in the evaluation context (optional). */ missingContextFields?: string[]; } +type EmptyFeatureRemoteConfig = { key: undefined; payload: undefined }; + +/** + * A remotely managed configuration value for a feature. + */ +export type FeatureRemoteConfig = + | { + /** + * The key of the matched configuration value. + */ + key: string; + + /** + * The optional user-supplied payload data. + */ + payload: any; + } + | EmptyFeatureRemoteConfig; + /** * Describes a feature */ -export interface Feature { +export interface Feature< + TConfig extends FeatureRemoteConfig | undefined = EmptyFeatureRemoteConfig, +> { /** * The key of the feature. */ @@ -94,12 +158,27 @@ export interface Feature { */ isEnabled: boolean; + /* + * Optional user-defined configuration. + */ + config: TConfig extends undefined ? EmptyFeatureRemoteConfig : TConfig; + /** * Track feature usage in Bucket. */ track(): Promise; } +type FullFeatureOverride = { + isEnabled: boolean; + config?: { + key: string; + payload: any; + }; +}; + +type FeatureOverride = FullFeatureOverride | boolean; + /** * Describes a collection of evaluated features. * @@ -118,48 +197,136 @@ export interface Features {} */ export type TypedFeatures = keyof Features extends never ? Record - : Record; + : { + [FeatureKey in keyof Features]: Features[FeatureKey] extends FullFeatureOverride + ? Feature + : Feature; + }; + +type TypedFeatureKey = keyof TypedFeatures; /** * Describes the feature overrides. */ -export type FeatureOverrides = Partial>; +export type FeatureOverrides = Partial< + keyof Features extends never + ? Record + : { + [FeatureKey in keyof Features]: Features[FeatureKey] extends FullFeatureOverride + ? Features[FeatureKey] + : Exclude; + } +>; + export type FeatureOverridesFn = (context: Context) => FeatureOverrides; /** - * Describes a specific feature in the API response + * (Internal) Describes a remote feature config variant. + * + * @internal */ -type FeatureAPIResponse = { +export type FeatureConfigVariant = { + /** + * The filter for the variant. + */ + filter: RuleFilter; + + /** + * The optional user-supplied payload data. + */ + payload: any; + + /** + * The key of the variant. + */ + key: string; +}; + +/** + * (Internal) Describes a specific feature in the API response. + * + * @internal + */ +export type FeatureAPIResponse = { + /** + * The key of the feature. + */ key: string; + + /** + * The targeting rules for the feature. + */ targeting: { + /** + * The version of the targeting rules. + */ version: number; + + /** + * The targeting rules. + */ rules: { + /** + * The filter for the rule. + */ filter: RuleFilter; }[]; }; + + /** + * The remote configuration for the feature. + */ + config?: { + /** + * The version of the remote configuration. + */ + version: number; + + /** + * The variants of the remote configuration. + */ + variants: FeatureConfigVariant[]; + }; }; /** - * Describes the response of the features endpoint + * (Internal) Describes the response of the features endpoint. + * + * @internal */ export type FeaturesAPIResponse = { - /** The feature definitions */ + /** + * The feature definitions. + */ features: FeatureAPIResponse[]; }; +/** + * (Internal) Describes the response of the evaluated features endpoint. + * + * @internal + */ export type EvaluatedFeaturesAPIResponse = { - /** True if request successful */ + /** + * True if request successful. + */ success: boolean; - /** True if additional context for user or company was found and used for evaluation on the remote server */ + + /** + * True if additional context for user or company was found and used for evaluation on the remote server. + */ remoteContextUsed: boolean; - /** The feature definitions */ + + /** + * The feature definitions. + */ features: RawFeature[]; }; /** * Describes the response of a HTTP client. - * @typeParam TResponse - The type of the response body. * + * @typeParam TResponse - The type of the response body. */ export type HttpClientResponse = { /** @@ -341,8 +508,14 @@ export type ClientOptions = { /** * The features to "enable" as fallbacks when the API is unavailable (optional). + * Can be an array of feature keys, or a record of feature keys and boolean or object values. + * + * If a record is supplied instead of array, the values of each key are either the + * configuration values or the boolean value `true`. **/ - fallbackFeatures?: (keyof TypedFeatures)[]; + fallbackFeatures?: + | TypedFeatureKey[] + | Record>; /** * The HTTP client to use for sending requests (optional). Default is the built-in fetch client. @@ -358,16 +531,14 @@ export type ClientOptions = { /** * If a filename is specified, feature targeting results be overridden with * the values from this file. The file should be a JSON object with feature - * keys as keys and boolean values as values. + * keys as keys, and boolean or object as values. * * If a function is specified, the function will be called with the context - * and should return a record of feature keys and boolean values. + * and should return a record of feature keys and boolean or object values. * * Defaults to "bucketFeatures.json". **/ - featureOverrides?: - | string - | ((context: Context) => Partial>); + featureOverrides?: string | ((context: Context) => FeatureOverrides); /** * In offline mode, no data is sent or fetched from the the Bucket API. diff --git a/packages/node-sdk/test/client.test.ts b/packages/node-sdk/test/client.test.ts index aab0be77..7ca7613f 100644 --- a/packages/node-sdk/test/client.test.ts +++ b/packages/node-sdk/test/client.test.ts @@ -115,6 +115,22 @@ const featureDefinitions: FeaturesAPIResponse = { }, ], }, + config: { + version: 1, + variants: [ + { + filter: { + type: "context", + field: "company.id", + operator: "IS", + values: ["company123"], + }, + key: "config-1", + default: true, + payload: { something: "else" }, + }, + ], + }, }, { key: "feature2", @@ -152,6 +168,12 @@ const evaluatedFeatures = [ feature: { key: "feature1", version: 1 }, value: true, context: {}, + config: { + key: "config-1", + payload: { something: "else" }, + ruleEvaluationResults: [true], + missingContextFields: [], + }, ruleEvaluationResults: [true], missingContextFields: [], }, @@ -181,6 +203,56 @@ describe("BucketClient", () => { } }); + it("should accept fallback features as an array", async () => { + const bucketInstance = new BucketClient({ + secretKey: "validSecretKeyWithMoreThan22Chars", + fallbackFeatures: ["feature1", "feature2"], + }); + + expect(bucketInstance["_config"].fallbackFeatures).toEqual({ + feature1: { + isEnabled: true, + key: "feature1", + }, + feature2: { + isEnabled: true, + key: "feature2", + }, + }); + }); + + it("should accept fallback features as an object", async () => { + const bucketInstance = new BucketClient({ + secretKey: "validSecretKeyWithMoreThan22Chars", + fallbackFeatures: { + feature1: true, + feature2: { + isEnabled: true, + config: { + key: "config1", + payload: { value: true }, + }, + }, + }, + }); + + expect(bucketInstance["_config"].fallbackFeatures).toStrictEqual({ + feature1: { + key: "feature1", + config: undefined, + isEnabled: true, + }, + feature2: { + key: "feature2", + isEnabled: true, + config: { + key: "config1", + payload: { value: true }, + }, + }, + }); + }); + it("should create a client instance with valid options", () => { const client = new BucketClient(validOptions); @@ -293,7 +365,7 @@ describe("BucketClient", () => { fallbackFeatures: "invalid" as any, }; expect(() => new BucketClient(invalidOptions)).toThrow( - "fallbackFeatures must be an object", + "fallbackFeatures must be an array or object", ); }); @@ -951,6 +1023,8 @@ describe("BucketClient", () => { describe("getFeature", () => { let client: BucketClient; + let featureEvalSequence: Record; + beforeEach(async () => { httpClient.get.mockResolvedValue({ ok: true, @@ -963,12 +1037,27 @@ describe("BucketClient", () => { client = new BucketClient(validOptions); + featureEvalSequence = {}; vi.mocked(evaluateFeatureRules).mockImplementation( ({ featureKey, context }) => { const evalFeature = evaluatedFeatures.find( (f) => f.feature.key === featureKey, )!; + if (featureEvalSequence[featureKey]) { + return { + value: evalFeature.config && { + key: evalFeature.config.key, + payload: evalFeature.config.payload, + }, + featureKey, + context: context, + ruleEvaluationResults: evalFeature.config?.ruleEvaluationResults, + missingContextFields: evalFeature.config?.missingContextFields, + }; + } + + featureEvalSequence[featureKey] = true; return { value: evalFeature.value, featureKey, @@ -986,7 +1075,6 @@ describe("BucketClient", () => { }); it("returns a feature", async () => { - // test that the feature is returned await client.initialize(); const feature = client.getFeature( { @@ -997,9 +1085,13 @@ describe("BucketClient", () => { "feature1", ); - expect(feature).toEqual({ + expect(feature).toStrictEqual({ key: "feature1", isEnabled: true, + config: { + key: "config-1", + payload: { something: "else" }, + }, track: expect.any(Function), }); }); @@ -1010,6 +1102,7 @@ describe("BucketClient", () => { user, other: otherContext, }; + // test that the feature is returned await client.initialize(); const feature = client.getFeature( @@ -1022,6 +1115,7 @@ describe("BucketClient", () => { }, "feature1", ); + await feature.track(); await client.flush(); @@ -1061,6 +1155,21 @@ describe("BucketClient", () => { evalRuleResults: [true], evalMissingFields: [], }, + { + type: "feature-flag-event", + action: "evaluate-config", + key: "feature1", + targetingVersion: 1, + evalContext: context, + evalResult: { + key: "config-1", + payload: { + something: "else", + }, + }, + evalRuleResults: [true], + evalMissingFields: [], + }, { type: "feature-flag-event", action: "evaluate", @@ -1108,6 +1217,11 @@ describe("BucketClient", () => { action: "evaluate", key: "feature1", }), + expect.objectContaining({ + type: "feature-flag-event", + action: "evaluate-config", + key: "feature1", + }), expect.objectContaining({ type: "feature-flag-event", action: "evaluate", @@ -1116,9 +1230,68 @@ describe("BucketClient", () => { { type: "feature-flag-event", action: "check", - evalResult: true, + key: "feature1", targetingVersion: 1, + evalResult: true, + evalContext: context, + evalRuleResults: [true], + evalMissingFields: [], + }, + ], + ); + }); + + it("`config` sends `check` event", async () => { + const context = { + company, + user, + other: otherContext, + }; + + // test that the feature is returned + await client.initialize(); + const feature = client.getFeature(context, "feature1"); + + // trigger `check` event + expect(feature.config).toBeDefined(); + + await client.flush(); + + expect(httpClient.post).toHaveBeenCalledWith( + BULK_ENDPOINT, + expectedHeaders, + [ + expect.objectContaining({ type: "company" }), + expect.objectContaining({ type: "user" }), + expect.objectContaining({ + type: "feature-flag-event", + action: "evaluate", + key: "feature1", + }), + expect.objectContaining({ + type: "feature-flag-event", + action: "evaluate-config", + key: "feature1", + }), + expect.objectContaining({ + type: "feature-flag-event", + action: "evaluate", + key: "feature2", + }), + { + type: "feature-flag-event", + action: "check-config", key: "feature1", + evalResult: { + key: "config-1", + payload: { + something: "else", + }, + }, + targetingVersion: 1, + evalContext: context, + evalRuleResults: [true], + evalMissingFields: [], }, ], ); @@ -1130,6 +1303,7 @@ describe("BucketClient", () => { user, other: otherContext, }; + // test that the feature is returned await client.initialize(); const feature = client.getFeature(context, "unknown-feature"); @@ -1149,31 +1323,30 @@ describe("BucketClient", () => { expect.objectContaining({ type: "user", }), - { + expect.objectContaining({ type: "feature-flag-event", action: "evaluate", key: "feature1", - targetingVersion: 1, - evalContext: context, - evalResult: true, - evalRuleResults: [true], - evalMissingFields: [], - }, - { + }), + expect.objectContaining({ + type: "feature-flag-event", + action: "evaluate-config", + key: "feature1", + }), + expect.objectContaining({ type: "feature-flag-event", action: "evaluate", key: "feature2", - targetingVersion: 2, - evalContext: context, - evalResult: false, - evalRuleResults: [false], - evalMissingFields: ["something"], - }, + }), { type: "feature-flag-event", action: "check", - evalResult: false, key: "unknown-feature", + targetingVersion: undefined, + evalContext: context, + evalResult: false, + evalRuleResults: undefined, + evalMissingFields: undefined, }, { type: "event", @@ -1188,6 +1361,7 @@ describe("BucketClient", () => { describe("getFeatures", () => { let client: BucketClient; + let featureEvalSequence: Record; beforeEach(async () => { httpClient.get.mockResolvedValue({ @@ -1201,12 +1375,27 @@ describe("BucketClient", () => { client = new BucketClient(validOptions); + featureEvalSequence = {}; vi.mocked(evaluateFeatureRules).mockImplementation( ({ featureKey, context }) => { const evalFeature = evaluatedFeatures.find( (f) => f.feature.key === featureKey, )!; + if (featureEvalSequence[featureKey]) { + return { + value: evalFeature.config && { + key: evalFeature.config.key, + payload: evalFeature.config.payload, + }, + featureKey, + context: context, + ruleEvaluationResults: evalFeature.config?.ruleEvaluationResults, + missingContextFields: evalFeature.config?.missingContextFields, + }; + } + + featureEvalSequence[featureKey] = true; return { value: evalFeature.value, featureKey, @@ -1236,22 +1425,29 @@ describe("BucketClient", () => { other: otherContext, }); - expect(result).toEqual({ + expect(result).toStrictEqual({ feature1: { key: "feature1", isEnabled: true, + config: { + key: "config-1", + payload: { + something: "else", + }, + }, track: expect.any(Function), }, feature2: { key: "feature2", isEnabled: false, + config: { key: undefined, payload: undefined }, track: expect.any(Function), }, }); await client.flush(); - expect(evaluateFeatureRules).toHaveBeenCalledTimes(2); + expect(evaluateFeatureRules).toHaveBeenCalledTimes(3); expect(httpClient.post).toHaveBeenCalledTimes(1); expect(httpClient.post).toHaveBeenCalledWith( @@ -1274,6 +1470,25 @@ describe("BucketClient", () => { evalRuleResults: [true], evalMissingFields: [], }, + { + type: "feature-flag-event", + action: "evaluate-config", + key: "feature1", + targetingVersion: 1, + evalContext: { + company, + user, + other: otherContext, + }, + evalResult: { + key: "config-1", + payload: { + something: "else", + }, + }, + evalRuleResults: [true], + evalMissingFields: [], + }, { type: "feature-flag-event", action: "evaluate", @@ -1288,19 +1503,69 @@ describe("BucketClient", () => { evalRuleResults: [false], evalMissingFields: ["something"], }, + { + action: "check-config", + evalContext: { + company, + user, + other: otherContext, + }, + evalResult: { + key: undefined, + payload: undefined, + }, + key: "feature2", + type: "feature-flag-event", + targetingVersion: undefined, + evalRuleResults: undefined, + evalMissingFields: undefined, + }, { action: "check", + evalContext: { + company, + user, + other: otherContext, + }, evalResult: false, key: "feature2", targetingVersion: 2, type: "feature-flag-event", + evalMissingFields: ["something"], + evalRuleResults: [false], + }, + { + action: "check-config", + evalContext: { + company, + user, + other: otherContext, + }, + evalResult: { + key: "config-1", + payload: { + something: "else", + }, + }, + key: "feature1", + targetingVersion: 1, + type: "feature-flag-event", + evalRuleResults: [true], + evalMissingFields: [], }, { action: "check", + evalContext: { + company, + user, + other: otherContext, + }, evalResult: true, key: "feature1", targetingVersion: 1, type: "feature-flag-event", + evalRuleResults: [true], + evalMissingFields: [], }, ], ); @@ -1317,9 +1582,10 @@ describe("BucketClient", () => { }); expect(logger.warn).toHaveBeenCalledWith( - expect.stringMatching( - 'feature "feature2" has targeting rules that require the following context fields: "something"', - ), + "feature/remote config targeting rules might not be correctly evaluated due to missing context fields.", + { + feature2: ["something"], + }, ); }); @@ -1340,22 +1606,29 @@ describe("BucketClient", () => { await client.initialize(); const features = client.getFeatures({ user }); - expect(features).toEqual({ + expect(features).toStrictEqual({ feature1: { isEnabled: true, key: "feature1", + config: { + key: "config-1", + payload: { + something: "else", + }, + }, track: expect.any(Function), }, feature2: { key: "feature2", isEnabled: false, + config: { key: undefined, payload: undefined }, track: expect.any(Function), }, }); await client.flush(); - expect(evaluateFeatureRules).toHaveBeenCalledTimes(2); + expect(evaluateFeatureRules).toHaveBeenCalledTimes(3); expect(httpClient.post).toHaveBeenCalledTimes(1); expect(httpClient.post).toHaveBeenCalledWith( @@ -1363,44 +1636,62 @@ describe("BucketClient", () => { expectedHeaders, [ expect.objectContaining({ type: "user" }), - { + expect.objectContaining({ type: "feature-flag-event", action: "evaluate", key: "feature1", - targetingVersion: 1, evalContext: { user, }, - evalResult: true, - evalRuleResults: [true], - evalMissingFields: [], - }, - { + }), + expect.objectContaining({ + type: "feature-flag-event", + action: "evaluate-config", + key: "feature1", + evalContext: { + user, + }, + }), + expect.objectContaining({ type: "feature-flag-event", action: "evaluate", key: "feature2", - targetingVersion: 2, evalContext: { user, }, - evalResult: false, - evalRuleResults: [false], - evalMissingFields: ["something"], - }, - { - action: "check", - evalResult: false, + }), + expect.objectContaining({ + type: "feature-flag-event", + action: "check-config", + evalContext: { + user, + }, key: "feature2", - targetingVersion: 2, + }), + expect.objectContaining({ type: "feature-flag-event", - }, - { action: "check", - evalResult: true, + evalContext: { + user, + }, + key: "feature2", + }), + expect.objectContaining({ + type: "feature-flag-event", + action: "check-config", + evalContext: { + user, + }, key: "feature1", - targetingVersion: 1, + }), + expect.objectContaining({ type: "feature-flag-event", - }, + action: "check", + evalContext: { + user, + }, + key: "feature1", + }), ], ); }); @@ -1410,22 +1701,29 @@ describe("BucketClient", () => { const features = client.getFeatures({ company }); // expect will trigger the `isEnabled` getter and send a `check` event - expect(features).toEqual({ + expect(features).toStrictEqual({ feature1: { isEnabled: true, key: "feature1", + config: { + key: "config-1", + payload: { + something: "else", + }, + }, track: expect.any(Function), }, feature2: { key: "feature2", isEnabled: false, + config: { key: undefined, payload: undefined }, track: expect.any(Function), }, }); await client.flush(); - expect(evaluateFeatureRules).toHaveBeenCalledTimes(2); + expect(evaluateFeatureRules).toHaveBeenCalledTimes(3); expect(httpClient.post).toHaveBeenCalledTimes(1); expect(httpClient.post).toHaveBeenCalledWith( @@ -1433,44 +1731,62 @@ describe("BucketClient", () => { expectedHeaders, [ expect.objectContaining({ type: "company" }), - { + expect.objectContaining({ type: "feature-flag-event", action: "evaluate", key: "feature1", - targetingVersion: 1, evalContext: { company, }, - evalResult: true, - evalRuleResults: [true], - evalMissingFields: [], - }, - { + }), + expect.objectContaining({ + type: "feature-flag-event", + action: "evaluate-config", + key: "feature1", + evalContext: { + company, + }, + }), + expect.objectContaining({ type: "feature-flag-event", action: "evaluate", key: "feature2", - targetingVersion: 2, evalContext: { company, }, - evalResult: false, - evalRuleResults: [false], - evalMissingFields: ["something"], - }, - { - action: "check", - evalResult: false, + }), + expect.objectContaining({ + type: "feature-flag-event", + action: "check-config", key: "feature2", - targetingVersion: 2, + evalContext: { + company, + }, + }), + expect.objectContaining({ type: "feature-flag-event", - }, - { action: "check", - evalResult: true, + key: "feature2", + evalContext: { + company, + }, + }), + expect.objectContaining({ + type: "feature-flag-event", + action: "check-config", key: "feature1", - targetingVersion: 1, + evalContext: { + company, + }, + }), + expect.objectContaining({ type: "feature-flag-event", - }, + action: "check", + key: "feature1", + evalContext: { + company, + }, + }), ], ); }); @@ -1480,22 +1796,29 @@ describe("BucketClient", () => { const features = client.getFeatures({ company, enableTracking: false }); // expect will trigger the `isEnabled` getter and send a `check` event - expect(features).toEqual({ + expect(features).toStrictEqual({ feature1: { isEnabled: true, key: "feature1", + config: { + key: "config-1", + payload: { + something: "else", + }, + }, track: expect.any(Function), }, feature2: { key: "feature2", isEnabled: false, + config: { key: undefined, payload: undefined }, track: expect.any(Function), }, }); await client.flush(); - expect(evaluateFeatureRules).toHaveBeenCalledTimes(2); + expect(evaluateFeatureRules).toHaveBeenCalledTimes(3); expect(httpClient.post).not.toHaveBeenCalled(); }); @@ -1505,7 +1828,7 @@ describe("BucketClient", () => { await client.flush(); - expect(evaluateFeatureRules).toHaveBeenCalledTimes(2); + expect(evaluateFeatureRules).toHaveBeenCalledTimes(3); expect(httpClient.post).toHaveBeenCalledTimes(1); expect(httpClient.post).toHaveBeenCalledWith( @@ -1524,6 +1847,23 @@ describe("BucketClient", () => { evalRuleResults: [true], evalMissingFields: [], }, + { + type: "feature-flag-event", + action: "evaluate-config", + evalContext: { + other: otherContext, + }, + evalResult: { + key: "config-1", + payload: { + something: "else", + }, + }, + key: "feature1", + targetingVersion: 1, + evalMissingFields: [], + evalRuleResults: [true], + }, { type: "feature-flag-event", action: "evaluate", @@ -1543,12 +1883,14 @@ describe("BucketClient", () => { it("should send `track` with user and company if provided", async () => { await client.initialize(); const feature1 = client.getFeature({ company, user }, "feature1"); + await client.flush(); await feature1.track(); await client.flush(); - expect(httpClient.post).toHaveBeenCalledTimes(1); - expect(httpClient.post).toHaveBeenCalledWith( + expect(httpClient.post).toHaveBeenCalledTimes(2); + expect(httpClient.post).toHaveBeenNthCalledWith( + 1, BULK_ENDPOINT, expectedHeaders, [ @@ -1561,6 +1903,16 @@ describe("BucketClient", () => { expect.objectContaining({ type: "feature-flag-event", action: "evaluate", + key: "feature1", + evalContext: { + company, + user, + }, + }), + expect.objectContaining({ + type: "feature-flag-event", + action: "evaluate-config", + key: "feature1", evalContext: { company, user, @@ -1569,7 +1921,15 @@ describe("BucketClient", () => { expect.objectContaining({ type: "feature-flag-event", action: "evaluate", + key: "feature2", }), + ], + ); + expect(httpClient.post).toHaveBeenNthCalledWith( + 2, + BULK_ENDPOINT, + expectedHeaders, + [ { companyId: "company123", event: "feature1", @@ -1584,11 +1944,13 @@ describe("BucketClient", () => { await client.initialize(); const feature = client.getFeature({ user }, "feature1"); + await client.flush(); await feature.track(); await client.flush(); - expect(httpClient.post).toHaveBeenCalledTimes(1); - expect(httpClient.post).toHaveBeenCalledWith( + expect(httpClient.post).toHaveBeenCalledTimes(2); + expect(httpClient.post).toHaveBeenNthCalledWith( + 1, BULK_ENDPOINT, expectedHeaders, [ @@ -1598,14 +1960,31 @@ describe("BucketClient", () => { expect.objectContaining({ type: "feature-flag-event", action: "evaluate", + key: "feature1", + evalContext: { + user, + }, + }), + expect.objectContaining({ + type: "feature-flag-event", + action: "evaluate-config", + key: "feature1", evalContext: { user, }, }), expect.objectContaining({ type: "feature-flag-event", + key: "feature2", action: "evaluate", }), + ], + ); + expect(httpClient.post).toHaveBeenNthCalledWith( + 2, + BULK_ENDPOINT, + expectedHeaders, + [ { event: "feature1", type: "event", @@ -1638,6 +2017,13 @@ describe("BucketClient", () => { company, }, }), + expect.objectContaining({ + type: "feature-flag-event", + action: "evaluate-config", + evalContext: { + company, + }, + }), expect.objectContaining({ type: "feature-flag-event", action: "evaluate", @@ -1657,9 +2043,10 @@ describe("BucketClient", () => { "key", ); - expect(result).toEqual({ + expect(result).toStrictEqual({ key: "key", isEnabled: true, + config: { key: undefined, payload: undefined }, track: expect.any(Function), }); @@ -1677,12 +2064,17 @@ describe("BucketClient", () => { expectedHeaders, [ expect.objectContaining({ type: "user" }), - { + expect.objectContaining({ + type: "feature-flag-event", + action: "check-config", + key: "key", + }), + expect.objectContaining({ type: "feature-flag-event", action: "check", key: "key", evalResult: true, - }, + }), ], ); }); @@ -1700,15 +2092,22 @@ describe("BucketClient", () => { expect.any(Error), ); - expect(features).toEqual({ + expect(features).toStrictEqual({ feature1: { key: "feature1", isEnabled: true, + config: { + key: "config-1", + payload: { + something: "else", + }, + }, track: expect.any(Function), }, feature2: { key: "feature2", isEnabled: false, + config: { key: undefined, payload: undefined }, track: expect.any(Function), }, }); @@ -1726,10 +2125,16 @@ describe("BucketClient", () => { const result = client.getFeatures({}); // Trigger a feature check - expect(result.feature1).toEqual({ + expect(result.feature1).toStrictEqual({ key: "feature1", isEnabled: true, track: expect.any(Function), + config: { + key: "config-1", + payload: { + something: "else", + }, + }, }); await client.flush(); @@ -1749,20 +2154,34 @@ describe("BucketClient", () => { feature1: { key: "feature1", isEnabled: true, + config: { + key: "config-1", + payload: { + something: "else", + }, + }, track: expect.any(Function), }, feature2: { key: "feature2", isEnabled: false, + config: { key: undefined, payload: undefined }, track: expect.any(Function), }, }); client.featureOverrides = (_context: Context) => { - expect(context).toEqual(context); + expect(context).toStrictEqual(context); return { - feature1: false, + feature1: { isEnabled: false }, feature2: true, + feature3: { + isEnabled: true, + config: { + key: "config-1", + payload: { something: "else" }, + }, + }, }; }; const features = client.getFeatures(context); @@ -1771,11 +2190,22 @@ describe("BucketClient", () => { feature1: { key: "feature1", isEnabled: false, + config: { key: undefined, payload: undefined }, track: expect.any(Function), }, feature2: { key: "feature2", isEnabled: true, + config: { key: undefined, payload: undefined }, + track: expect.any(Function), + }, + feature3: { + key: "feature3", + isEnabled: true, + config: { + key: "config-1", + payload: { something: "else" }, + }, track: expect.any(Function), }, }); @@ -1797,12 +2227,25 @@ describe("BucketClient", () => { key: "feature1", targetingVersion: 1, isEnabled: true, + config: { + key: "config-1", + version: 3, + default: true, + payload: { something: "else" }, + missingContextFields: ["funny"], + }, + missingContextFields: ["something", "funny"], }, feature2: { key: "feature2", targetingVersion: 2, isEnabled: false, - missingContextFields: ["something"], + missingContextFields: ["another"], + }, + feature3: { + key: "feature3", + targetingVersion: 5, + isEnabled: true, }, }, }, @@ -1820,15 +2263,26 @@ describe("BucketClient", () => { other: otherContext, }); - expect(result).toEqual({ + expect(result).toStrictEqual({ feature1: { key: "feature1", isEnabled: true, + config: { + key: "config-1", + payload: { something: "else" }, + }, track: expect.any(Function), }, feature2: { key: "feature2", isEnabled: false, + config: { key: undefined, payload: undefined }, + track: expect.any(Function), + }, + feature3: { + key: "feature3", + isEnabled: true, + config: { key: undefined, payload: undefined }, track: expect.any(Function), }, }); @@ -1855,7 +2309,12 @@ describe("BucketClient", () => { it("should warn if missing context fields", async () => { await client.getFeaturesRemote(); expect(logger.warn).toHaveBeenCalledWith( - 'feature "feature2" has targeting rules that require the following context fields: "something"', + "feature/remote config targeting rules might not be correctly evaluated due to missing context fields.", + { + feature1: ["something", "funny"], + "feature1.config": ["funny"], + feature2: ["another"], + }, ); }); }); @@ -1875,6 +2334,13 @@ describe("BucketClient", () => { key: "feature1", targetingVersion: 1, isEnabled: true, + config: { + key: "config-1", + version: 3, + default: true, + payload: { something: "else" }, + missingContextFields: ["two"], + }, missingContextFields: ["one", "two"], }, }, @@ -1893,10 +2359,14 @@ describe("BucketClient", () => { other: otherContext, }); - expect(result).toEqual({ + expect(result).toStrictEqual({ key: "feature1", isEnabled: true, track: expect.any(Function), + config: { + key: "config-1", + payload: { something: "else" }, + }, }); expect(httpClient.get).toHaveBeenCalledTimes(1); @@ -1919,7 +2389,11 @@ describe("BucketClient", () => { it("should warn if missing context fields", async () => { await client.getFeatureRemote("feature1"); expect(logger.warn).toHaveBeenCalledWith( - 'feature "feature1" has targeting rules that require the following context fields: "one", "two"', + "feature/remote config targeting rules might not be correctly evaluated due to missing context fields.", + { + feature1: ["one", "two"], + "feature1.config": ["two"], + }, ); }); }); @@ -2096,6 +2570,13 @@ describe("BoundBucketClient", () => { key: "feature1", targetingVersion: 1, isEnabled: true, + config: { + key: "config-1", + version: 3, + default: true, + payload: { something: "else" }, + missingContextFields: ["else"], + }, }, feature2: { key: "feature2", @@ -2117,15 +2598,17 @@ describe("BoundBucketClient", () => { const result = await boundClient.getFeaturesRemote(); - expect(result).toEqual({ + expect(result).toStrictEqual({ feature1: { key: "feature1", isEnabled: true, + config: { key: "config-1", payload: { something: "else" } }, track: expect.any(Function), }, feature2: { key: "feature2", isEnabled: false, + config: { key: undefined, payload: undefined }, track: expect.any(Function), }, }); @@ -2147,9 +2630,10 @@ describe("BoundBucketClient", () => { const result = await boundClient.getFeatureRemote("feature1"); - expect(result).toEqual({ + expect(result).toStrictEqual({ key: "feature1", isEnabled: true, + config: { key: "config-1", payload: { something: "else" } }, track: expect.any(Function), }); diff --git a/packages/node-sdk/test/config.test.ts b/packages/node-sdk/test/config.test.ts index 2e31670c..653cfe9a 100644 --- a/packages/node-sdk/test/config.test.ts +++ b/packages/node-sdk/test/config.test.ts @@ -8,8 +8,17 @@ describe("config tests", () => { expect(config).toEqual({ featureOverrides: { - myFeature: true, + myFeature: { + isEnabled: true, + }, myFeatureFalse: false, + myFeatureWithConfig: { + isEnabled: true, + config: { + key: "config-1", + payload: { something: "else" }, + }, + }, }, secretKey: "mySecretKey", offline: true, @@ -27,10 +36,19 @@ describe("config tests", () => { const config = loadConfig("test/testConfig.json"); expect(config).toEqual({ featureOverrides: { - myFeature: true, + myFeature: { + isEnabled: true, + }, myFeatureFalse: false, myNewFeature: true, myNewFeatureFalse: false, + myFeatureWithConfig: { + isEnabled: true, + config: { + key: "config-1", + payload: { something: "else" }, + }, + }, }, secretKey: "mySecretKeyFromEnv", offline: true, diff --git a/packages/node-sdk/test/testConfig.json b/packages/node-sdk/test/testConfig.json index 311bf194..c6986a13 100644 --- a/packages/node-sdk/test/testConfig.json +++ b/packages/node-sdk/test/testConfig.json @@ -1,7 +1,14 @@ { "featureOverrides": { - "myFeature": true, - "myFeatureFalse": false + "myFeature": { "isEnabled": true }, + "myFeatureFalse": false, + "myFeatureWithConfig": { + "isEnabled": true, + "config": { + "key": "config-1", + "payload": { "something": "else" } + } + } }, "secretKey": "mySecretKey", "offline": true, diff --git a/packages/openfeature-node-provider/README.md b/packages/openfeature-node-provider/README.md index f9183534..75699165 100644 --- a/packages/openfeature-node-provider/README.md +++ b/packages/openfeature-node-provider/README.md @@ -4,26 +4,23 @@ The official OpenFeature Node.js provider for [Bucket](https://bucket.co) featur ## Installation -``` -$ npm install @bucketco/openfeature-node-provider +```shell +npm install @bucketco/openfeature-node-provider ``` -#### Required peer dependencies +### Required peer dependencies 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 `@bucketco/node-sdk` currently is `2.0.0`. -``` -$ npm install @openfeature/server-sdk @bucketco/node-sdk +```shell +npm install @openfeature/server-sdk @bucketco/node-sdk ``` ## Usage The provider uses the [Bucket Node.js SDK](https://docs.bucket.co/quickstart/supported-languages-frameworks/node.js-sdk). - The available options can be found in the [Bucket Node.js SDK](https://github.com/bucketco/bucket-javascript-sdk/tree/main/packages/node-sdk#initialization-options). ### Example using the default configuration @@ -56,6 +53,59 @@ const enterpriseFeatureEnabled = await client.getBooleanValue( ); ``` +## Feature resolution methods + +The Bucket OpenFeature Provider implements the OpenFeature evaluation interface for different value types. Each method handles the resolution of feature flags according to the OpenFeature specification. + +### Common behavior + +All resolution methods share these behaviors: + +- Return default value with `PROVIDER_NOT_READY` if client is not initialized, +- Return default value with `FLAG_NOT_FOUND` if flag doesn't exist, +- Return default value with `ERROR` if there was a type mismatch, +- Return evaluated value with `TARGETING_MATCH` on successful resolution. + +### Type-Specific Methods + +#### Boolean Resolution + +```ts +client.getBooleanValue("my-flag", false); +``` + +Returns the feature's enabled state. This is the most common use case for feature flags. + +#### String Resolution + +```ts +client.getStringValue("my-flag", "default"); +``` + +Returns the feature's remote config key (also known as "variant"). Useful for multi-variate use cases. + +#### Number Resolution + +```ts +client.getNumberValue("my-flag", 0); +``` + +Not directly supported by Bucket. Use `getObjectValue` instead for numeric configurations. + +#### Object Resolution + +```ts +// works for any type: +client.getObjectValue("my-flag", { defaultValue: true }); +client.getObjectValue("my-flag", "string-value"); +client.getObjectValue("my-flag", 199); +``` + +Returns the feature's remote config payload with type validation. This is the most flexible method, +allowing for complex configuration objects or simple types. + +The object resolution performs runtime type checking between the default value and the feature payload to ensure type safety. + ## Translating Evaluation Context Bucket uses a context object of the following shape: @@ -69,11 +119,19 @@ export type BucketContext = { /** * The user context. If the user is set, the user ID is required. */ - user?: { id: string; [k: string]: any }; + user?: { + id: string; + name?: string; + email?: string; + avatar?: string; + [k: string]: any; + }; + /** * The company context. If the company is set, the company ID is required. */ - company?: { id: string; [k: string]: any }; + company?: { id: string; name?: string; avatar?: string; [k: string]: any }; + /** * The other context. This is used for any additional context that is not related to user or company. */ @@ -91,14 +149,17 @@ import { BucketNodeProvider } from "@openfeature/bucket-node-provider"; const contextTranslator = (context: EvaluationContext): BucketContext => { return { user: { - id: context.targetingKey, + id: context.targetingKey ?? context["userId"]?.toString(), name: context["name"]?.toString(), email: context["email"]?.toString(), + avatar: context["avatar"]?.toString(), country: context["country"]?.toString(), }, company: { - id: context["companyId"], - name: context["companyName"], + id: context["companyId"]?.toString(), + name: context["companyName"]?.toString(), + avatar: context["companyAvatar"]?.toString(), + plan: context["companyPlan"]?.toString(), }, }; }; @@ -115,7 +176,7 @@ It's straight forward to start sending tracking events through OpenFeature. Simply call the "track" method on the OpenFeature client: -```ts +```typescript import { BucketNodeProvider } from "@bucketco/openfeature-node-provider"; import { OpenFeature } from "@openfeature/server-sdk"; @@ -132,8 +193,7 @@ const enterpriseFeatureEnabled = await client.track( ); ``` -# License - -MIT License +## License -Copyright (c) 2025 Bucket ApS +> MIT License +> Copyright (c) 2025 Bucket ApS diff --git a/packages/openfeature-node-provider/example/app.ts b/packages/openfeature-node-provider/example/app.ts index ea5b72cf..fdfcdd8b 100644 --- a/packages/openfeature-node-provider/example/app.ts +++ b/packages/openfeature-node-provider/example/app.ts @@ -1,15 +1,15 @@ import express from "express"; import "./bucket"; import { EvaluationContext, OpenFeature } from "@openfeature/server-sdk"; -import provider from "./bucket"; +import provider, { CreateTodosConfig } from "./bucket"; -// In the following, we assume that targetingKey is a unique identifier for the user +// In the following, we assume that targetingKey is a unique identifier for the user. type Context = EvaluationContext & { targetingKey: string; companyId: string; }; -// Augment the Express types to include the some context property on the `res.locals` object +// Augment the Express types to include the some context property on the `res.locals` object. declare global { namespace Express { interface Locals { @@ -36,6 +36,7 @@ const todos = ["Buy milk", "Walk the dog"]; app.get("/", (_req, res) => { const ofClient = OpenFeature.getClient(); ofClient.track("front-page-viewed", res.locals.context); + res.json({ message: "Ready to manage some TODOs!" }); }); @@ -46,7 +47,7 @@ app.get("/todos", async (req, res) => { // and that the indexing for feature name below is type-checked at compile time. const ofClient = OpenFeature.getClient(); const isEnabled = await ofClient.getBooleanValue( - "show-todo", + "show-todos", false, res.locals.context, ); @@ -75,10 +76,23 @@ app.post("/todos", async (req, res) => { res.locals.context, ); - // Check if the user has the "create-todos" feature enabled + // Check if the user has the "create-todos" feature enabled. if (isEnabled) { + // Get the configuration for the "create-todos" feature. + // We expect the configuration to be a JSON object with a `maxLength` property. + const config = await ofClient.getObjectValue( + "create-todos", + { maxLength: 100 }, + res.locals.context, + ); + + // Check if the todo is too long. + if (todo.length > config.maxLength) { + return res.status(400).json({ error: "Todo is too long" }); + } + // Track the feature usage - ofClient.track("create-todo", res.locals.context); + ofClient.track("create-todos", res.locals.context); todos.push(todo); return res.status(201).json({ todo }); @@ -98,7 +112,7 @@ app.delete("/todos/:idx", async (req, res) => { const ofClient = OpenFeature.getClient(); const isEnabled = await ofClient.getBooleanValue( - "delete-todo", + "delete-todos", false, res.locals.context, ); @@ -106,7 +120,7 @@ app.delete("/todos/:idx", async (req, res) => { if (isEnabled) { todos.splice(idx, 1); - ofClient.track("delete-todo", res.locals.context); + ofClient.track("delete-todos", res.locals.context); return res.json({}); } diff --git a/packages/openfeature-node-provider/example/bucket.ts b/packages/openfeature-node-provider/example/bucket.ts index 3462a72b..1121ee00 100644 --- a/packages/openfeature-node-provider/example/bucket.ts +++ b/packages/openfeature-node-provider/example/bucket.ts @@ -5,11 +5,29 @@ if (!process.env.BUCKET_SECRET_KEY) { throw new Error("BUCKET_SECRET_KEY is required"); } +export type CreateTodosConfig = { + maxLength: number; +}; + const provider = new BucketNodeProvider({ secretKey: process.env.BUCKET_SECRET_KEY!, - fallbackFeatures: ["show-todos"], + fallbackFeatures: { + "show-todos": { + isEnabled: true, + }, + "create-todos": { + isEnabled: true, + config: { + key: "default", + payload: { + maxLength: 100, + }, + }, + }, + }, logger: console, }); + OpenFeature.setProvider(provider); export default provider; diff --git a/packages/openfeature-node-provider/package.json b/packages/openfeature-node-provider/package.json index 06eca2f5..c3ded48c 100644 --- a/packages/openfeature-node-provider/package.json +++ b/packages/openfeature-node-provider/package.json @@ -1,6 +1,6 @@ { "name": "@bucketco/openfeature-node-provider", - "version": "0.2.1", + "version": "0.3.0-alpha.0", "license": "MIT", "repository": { "type": "git", @@ -44,7 +44,7 @@ "vitest": "~1.6.0" }, "dependencies": { - "@bucketco/node-sdk": ">=1.4.2" + "@bucketco/node-sdk": "1.6.0-alpha.4" }, "peerDependencies": { "@openfeature/server-sdk": ">=1.16.1" diff --git a/packages/openfeature-node-provider/src/index.test.ts b/packages/openfeature-node-provider/src/index.test.ts index 8f506df1..9d2b63b9 100644 --- a/packages/openfeature-node-provider/src/index.test.ts +++ b/packages/openfeature-node-provider/src/index.test.ts @@ -1,9 +1,9 @@ -import { ErrorCode } from "@openfeature/core"; -import { beforeAll, beforeEach, describe, expect, it, Mock, vi } from "vitest"; +import { ProviderStatus } from "@openfeature/server-sdk"; +import { beforeEach, describe, expect, it, Mock, vi } from "vitest"; import { BucketClient } from "@bucketco/node-sdk"; -import { BucketNodeProvider } from "./index"; +import { BucketNodeProvider, defaultContextTranslator } from "./index"; vi.mock("@bucketco/node-sdk", () => { const actualModule = vi.importActual("@bucketco/node-sdk"); @@ -17,6 +17,7 @@ vi.mock("@bucketco/node-sdk", () => { const bucketClientMock = { getFeatures: vi.fn(), + getFeature: vi.fn(), initialize: vi.fn().mockResolvedValue({}), flush: vi.fn(), track: vi.fn(), @@ -35,6 +36,8 @@ const bucketContext = { company: { id: "99" }, }; +const testFlagKey = "a-key"; + beforeEach(() => { vi.clearAllMocks(); }); @@ -42,121 +45,305 @@ beforeEach(() => { describe("BucketNodeProvider", () => { let provider: BucketNodeProvider; - const newBucketClient = BucketClient as Mock; - newBucketClient.mockReturnValue(bucketClientMock); + const mockBucketClient = BucketClient as Mock; + mockBucketClient.mockReturnValue(bucketClientMock); + + let mockTranslatorFn: Mock; + + function mockFeature( + enabled: boolean, + configKey?: string | null, + configPayload?: any, + ) { + const config = { + key: configKey, + payload: configPayload, + }; + + bucketClientMock.getFeature = vi.fn().mockReturnValue({ + isEnabled: enabled, + config, + }); + + bucketClientMock.getFeatures = vi.fn().mockReturnValue({ + [testFlagKey]: { + isEnabled: enabled, + config: { + key: "key", + payload: configPayload, + }, + }, + }); + } - const translatorFn = vi.fn().mockReturnValue(bucketContext); + beforeEach(async () => { + mockTranslatorFn = vi.fn().mockReturnValue(bucketContext); - beforeAll(async () => { provider = new BucketNodeProvider({ secretKey, - contextTranslator: translatorFn, + contextTranslator: mockTranslatorFn, }); + await provider.initialize(); }); - it("calls the constructor", () => { - provider = new BucketNodeProvider({ - secretKey, - contextTranslator: translatorFn, + describe("contextTranslator", () => { + it("defaultContextTranslator provides the correct context", async () => { + expect( + defaultContextTranslator({ + userId: 123, + name: "John Doe", + email: "ron@bucket.co", + avatar: "https://bucket.co/avatar.png", + companyId: "456", + companyName: "Acme, Inc.", + companyAvatar: "https://acme.com/company-avatar.png", + companyPlan: "pro", + }), + ).toEqual({ + user: { + id: "123", + name: "John Doe", + email: "ron@bucket.co", + avatar: "https://bucket.co/avatar.png", + }, + company: { + id: "456", + name: "Acme, Inc.", + plan: "pro", + avatar: "https://acme.com/company-avatar.png", + }, + }); + }); + + it("defaultContextTranslator uses targetingKey if provided", async () => { + expect( + defaultContextTranslator({ + targetingKey: "123", + }), + ).toMatchObject({ + user: { + id: "123", + }, + company: { + id: undefined, + }, + }); }); - expect(newBucketClient).toHaveBeenCalledTimes(1); - expect(newBucketClient).toHaveBeenCalledWith({ secretKey }); }); - it("uses the contextTranslator function", async () => { - const track = vi.fn(); - bucketClientMock.getFeatures.mockReturnValue({ - booleanTrue: { - isEnabled: true, - key: "booleanTrue", - track, - }, + describe("lifecycle", () => { + it("calls the constructor of BucketClient", () => { + mockBucketClient.mockClear(); + + provider = new BucketNodeProvider({ + secretKey, + contextTranslator: mockTranslatorFn, + }); + + expect(mockBucketClient).toHaveBeenCalledTimes(1); + expect(mockBucketClient).toHaveBeenCalledWith({ secretKey }); }); - await provider.resolveBooleanEvaluation("booleanTrue", false, context); - expect(translatorFn).toHaveBeenCalledTimes(1); - expect(translatorFn).toHaveBeenCalledWith(context); - expect(bucketClientMock.getFeatures).toHaveBeenCalledTimes(1); - expect(bucketClientMock.getFeatures).toHaveBeenCalledWith(bucketContext); + it("should set the status to READY if initialization succeeds", async () => { + provider = new BucketNodeProvider({ + secretKey, + contextTranslator: mockTranslatorFn, + }); + + await provider.initialize(); + + expect(provider.status).toBe(ProviderStatus.READY); + }); + + it("should keep the status as READY after closing", async () => { + provider = new BucketNodeProvider({ + secretKey: "invalid", + contextTranslator: mockTranslatorFn, + }); + + await provider.initialize(); + await provider.onClose(); + + expect(provider.status).toBe(ProviderStatus.READY); + }); + + it("calls flush when provider is closed", async () => { + await provider.onClose(); + expect(bucketClientMock.flush).toHaveBeenCalledTimes(1); + }); + + it("uses the contextTranslator function", async () => { + mockFeature(true); + + await provider.resolveBooleanEvaluation(testFlagKey, false, context); + + expect(mockTranslatorFn).toHaveBeenCalledTimes(1); + expect(mockTranslatorFn).toHaveBeenCalledWith(context); + + expect(bucketClientMock.getFeatures).toHaveBeenCalledTimes(1); + expect(bucketClientMock.getFeatures).toHaveBeenCalledWith(bucketContext); + }); }); - describe("method resolveBooleanEvaluation", () => { - it("should return right value if key exists", async () => { - const result = await provider.resolveBooleanEvaluation( - "booleanTrue", - false, - context, - ); - expect(result.value).toEqual(true); - expect(result.errorCode).toBeUndefined(); + describe("resolving flags", () => { + beforeEach(async () => { + await provider.initialize(); }); - it("should return the default value if key does not exists", async () => { - const result = await provider.resolveBooleanEvaluation( - "non-existent", + it("returns error if provider is not initialized", async () => { + provider = new BucketNodeProvider({ + secretKey: "invalid", + contextTranslator: mockTranslatorFn, + }); + + const val = await provider.resolveBooleanEvaluation( + testFlagKey, true, context, ); - expect(result.value).toEqual(true); - expect(result.errorCode).toEqual(ErrorCode.FLAG_NOT_FOUND); + + expect(val).toMatchObject({ + reason: "ERROR", + errorCode: "PROVIDER_NOT_READY", + value: true, + }); }); - }); - describe("method resolveNumberEvaluation", () => { - it("should return the default value and an error message", async () => { - const result = await provider.resolveNumberEvaluation("number1", 42); - expect(result.value).toEqual(42); - expect(result.errorCode).toEqual(ErrorCode.GENERAL); - expect(result.errorMessage).toEqual( - `Bucket doesn't support number flags`, + it("returns error if flag is not found", async () => { + mockFeature(true, "key", true); + const val = await provider.resolveBooleanEvaluation( + "missing-key", + true, + context, ); + + expect(val).toMatchObject({ + reason: "ERROR", + errorCode: "FLAG_NOT_FOUND", + value: true, + }); }); - }); - describe("method resolveStringEvaluation", () => { - it("should return the default value and an error message", async () => { - const result = await provider.resolveStringEvaluation( - "number1", - "defaultValue", + it("calls the client correctly when evaluating", async () => { + mockFeature(true, "key", true); + + const val = await provider.resolveBooleanEvaluation( + testFlagKey, + false, + context, ); - expect(result.value).toEqual("defaultValue"); - expect(result.errorCode).toEqual(ErrorCode.GENERAL); - expect(result.errorMessage).toEqual( - `Bucket doesn't support string flags`, + + expect(val).toMatchObject({ + reason: "TARGETING_MATCH", + value: true, + }); + + expect(bucketClientMock.getFeatures).toHaveBeenCalled(); + expect(bucketClientMock.getFeature).toHaveBeenCalledWith( + bucketContext, + testFlagKey, ); }); - }); - describe("method resolveObjectEvaluation", () => { - it("should return the default value and an error message", async () => { - const defaultValue = { key: "value" }; - const result = await provider.resolveObjectEvaluation( - "number1", - defaultValue, - ); - expect(result.value).toEqual(defaultValue); - expect(result.errorCode).toEqual(ErrorCode.GENERAL); - expect(result.errorMessage).toEqual( - `Bucket doesn't support object flags`, - ); + + it.each([ + [true, false, true, "TARGETING_MATCH", undefined], + [undefined, true, true, "ERROR", "FLAG_NOT_FOUND"], + [undefined, false, false, "ERROR", "FLAG_NOT_FOUND"], + ])( + "should return the correct result when evaluating boolean. enabled: %s, value: %s, default: %s, expected: %s, reason: %s, errorCode: %s`", + async (enabled, def, expected, reason, errorCode) => { + const configKey = enabled !== undefined ? "variant-1" : undefined; + + mockFeature(enabled ?? false, configKey); + const flagKey = enabled ? testFlagKey : "missing-key"; + + expect( + await provider.resolveBooleanEvaluation(flagKey, def, context), + ).toMatchObject({ + reason, + value: expected, + ...(configKey ? { variant: configKey } : {}), + ...(errorCode ? { errorCode } : {}), + }); + }, + ); + + it("should return error when context is missing user ID", async () => { + mockTranslatorFn.mockReturnValue({ user: {} }); + + expect( + await provider.resolveBooleanEvaluation(testFlagKey, true, context), + ).toMatchObject({ + reason: "ERROR", + errorCode: "INVALID_CONTEXT", + value: true, + }); }); - }); - describe("onClose", () => { - it("calls flush", async () => { - await provider.onClose(); - expect(bucketClientMock.flush).toHaveBeenCalledTimes(1); + it("should return error when evaluating number", async () => { + expect( + await provider.resolveNumberEvaluation(testFlagKey, 1), + ).toMatchObject({ + reason: "ERROR", + errorCode: "GENERAL", + value: 1, + }); }); + + it.each([ + ["key-1", "default", "key-1", "TARGETING_MATCH"], + [null, "default", "default", "DEFAULT"], + [undefined, "default", "default", "DEFAULT"], + ])( + "should return the correct result when evaluating string. variant: %s, def: %s, expected: %s, reason: %s, errorCode: %s`", + async (variant, def, expected, reason) => { + mockFeature(true, variant, {}); + expect( + await provider.resolveStringEvaluation(testFlagKey, def, context), + ).toMatchObject({ + reason, + value: expected, + ...(variant ? { variant } : {}), + }); + }, + ); + + it.each([ + [{}, { a: 1 }, {}, "TARGETING_MATCH", undefined], + ["string", "default", "string", "TARGETING_MATCH", undefined], + [15, -15, 15, "TARGETING_MATCH", undefined], + [true, false, true, "TARGETING_MATCH", undefined], + [null, { a: 2 }, { a: 2 }, "ERROR", "TYPE_MISMATCH"], + [100, "string", "string", "ERROR", "TYPE_MISMATCH"], + [true, 1337, 1337, "ERROR", "TYPE_MISMATCH"], + ["string", 1337, 1337, "ERROR", "TYPE_MISMATCH"], + [undefined, "default", "default", "ERROR", "TYPE_MISMATCH"], + ])( + "should return the correct result when evaluating object. payload: %s, default: %s, expected: %s, reason: %s, errorCode: %s`", + async (value, def, expected, reason, errorCode) => { + const configKey = value === undefined ? undefined : "config-key"; + mockFeature(true, configKey, value); + expect( + await provider.resolveObjectEvaluation(testFlagKey, def, context), + ).toMatchObject({ + reason, + value: expected, + ...(errorCode ? { errorCode, variant: configKey } : {}), + }); + }, + ); }); describe("track", () => { it("should track", async () => { - expect(translatorFn).toHaveBeenCalledTimes(0); + expect(mockTranslatorFn).toHaveBeenCalledTimes(0); provider.track("event", context, { action: "click", }); - expect(translatorFn).toHaveBeenCalledTimes(1); - expect(translatorFn).toHaveBeenCalledWith(context); + + expect(mockTranslatorFn).toHaveBeenCalledTimes(1); + expect(mockTranslatorFn).toHaveBeenCalledWith(context); expect(bucketClientMock.track).toHaveBeenCalledTimes(1); expect(bucketClientMock.track).toHaveBeenCalledWith("42", "event", { attributes: { action: "click" }, diff --git a/packages/openfeature-node-provider/src/index.ts b/packages/openfeature-node-provider/src/index.ts index 7f2f512a..ad771095 100644 --- a/packages/openfeature-node-provider/src/index.ts +++ b/packages/openfeature-node-provider/src/index.ts @@ -21,17 +21,22 @@ type ProviderOptions = ClientOptions & { contextTranslator?: (context: EvaluationContext) => BucketContext; }; -const defaultTranslator = (context: EvaluationContext): BucketContext => { +export const defaultContextTranslator = ( + context: EvaluationContext, +): BucketContext => { const user = { - id: context.targetingKey ?? context["id"]?.toString(), + id: context.targetingKey ?? context["userId"]?.toString(), name: context["name"]?.toString(), email: context["email"]?.toString(), + avatar: context["avatar"]?.toString(), country: context["country"]?.toString(), }; const company = { id: context["companyId"]?.toString(), name: context["companyName"]?.toString(), + avatar: context["companyAvatar"]?.toString(), + plan: context["companyPlan"]?.toString(), }; return { @@ -61,7 +66,7 @@ export class BucketNodeProvider implements Provider { constructor({ contextTranslator, ...opts }: ProviderOptions) { this._client = new BucketClient(opts); - this.contextTranslator = contextTranslator ?? defaultTranslator; + this.contextTranslator = contextTranslator ?? defaultContextTranslator; } public async initialize(): Promise { @@ -69,42 +74,90 @@ export class BucketNodeProvider implements Provider { this.status = ServerProviderStatus.READY; } - resolveBooleanEvaluation( + private resolveFeature( flagKey: string, - defaultValue: boolean, - context: EvaluationContext, - ): Promise> { - const features = this._client.getFeatures(this.contextTranslator(context)); + defaultValue: T, + context: BucketContext, + resolveFn: ( + feature: ReturnType, + ) => Promise>, + ): Promise> { + if (this.status !== ServerProviderStatus.READY) { + return Promise.resolve({ + value: defaultValue, + reason: StandardResolutionReasons.ERROR, + errorCode: ErrorCode.PROVIDER_NOT_READY, + errorMessage: "Bucket client not initialized", + }); + } - const feature = features[flagKey]; - if (!feature) { + if (!context.user?.id) { return Promise.resolve({ value: defaultValue, - source: "bucket-node", - flagKey, - errorCode: ErrorCode.FLAG_NOT_FOUND, reason: StandardResolutionReasons.ERROR, + errorCode: ErrorCode.INVALID_CONTEXT, + errorMessage: "At least a user ID is required", }); } + const features = this._client.getFeatures(context); + if (flagKey in features) { + return resolveFn(this._client.getFeature(context, flagKey)); + } + return Promise.resolve({ - value: feature.isEnabled, - source: "bucket-node", - flagKey, - reason: StandardResolutionReasons.TARGETING_MATCH, + value: defaultValue, + reason: StandardResolutionReasons.ERROR, + errorCode: ErrorCode.FLAG_NOT_FOUND, + errorMessage: `Flag ${flagKey} not found`, }); } + + resolveBooleanEvaluation( + flagKey: string, + defaultValue: boolean, + context: EvaluationContext, + ): Promise> { + return this.resolveFeature( + flagKey, + defaultValue, + this.contextTranslator(context), + (feature) => { + return Promise.resolve({ + value: feature.isEnabled, + variant: feature.config?.key, + reason: StandardResolutionReasons.TARGETING_MATCH, + }); + }, + ); + } + resolveStringEvaluation( - _flagKey: string, + flagKey: string, defaultValue: string, + context: EvaluationContext, ): Promise> { - return Promise.resolve({ - value: defaultValue, - reason: StandardResolutionReasons.ERROR, - errorCode: ErrorCode.GENERAL, - errorMessage: "Bucket doesn't support string flags", - }); + return this.resolveFeature( + flagKey, + defaultValue, + this.contextTranslator(context), + (feature) => { + if (!feature.config.key) { + return Promise.resolve({ + value: defaultValue, + reason: StandardResolutionReasons.DEFAULT, + }); + } + + return Promise.resolve({ + value: feature.config.key as string, + variant: feature.config.key, + reason: StandardResolutionReasons.TARGETING_MATCH, + }); + }, + ); } + resolveNumberEvaluation( _flagKey: string, defaultValue: number, @@ -113,19 +166,45 @@ export class BucketNodeProvider implements Provider { value: defaultValue, reason: StandardResolutionReasons.ERROR, errorCode: ErrorCode.GENERAL, - errorMessage: "Bucket doesn't support number flags", + errorMessage: + "Bucket doesn't support this method. Use `resolveObjectEvaluation` instead.", }); } + resolveObjectEvaluation( - _flagKey: string, + flagKey: string, defaultValue: T, + context: EvaluationContext, ): Promise> { - return Promise.resolve({ - value: defaultValue, - reason: StandardResolutionReasons.ERROR, - errorCode: ErrorCode.GENERAL, - errorMessage: "Bucket doesn't support object flags", - }); + return this.resolveFeature( + flagKey, + defaultValue, + this.contextTranslator(context), + (feature) => { + const expType = typeof defaultValue; + const payloadType = typeof feature.config.payload; + + if ( + feature.config.payload === undefined || + feature.config.payload === null || + payloadType !== expType + ) { + return Promise.resolve({ + value: defaultValue, + variant: feature.config.key, + reason: StandardResolutionReasons.ERROR, + errorCode: ErrorCode.TYPE_MISMATCH, + errorMessage: `Expected remote config payload of type \`${expType}\` but got \`${payloadType}\`.`, + }); + } + + return Promise.resolve({ + value: feature.config.payload, + variant: feature.config.key, + reason: StandardResolutionReasons.TARGETING_MATCH, + }); + }, + ); } track( diff --git a/yarn.lock b/yarn.lock index 5bb873e2..e75d1c8d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -948,7 +948,7 @@ __metadata: languageName: unknown linkType: soft -"@bucketco/node-sdk@npm:>=1.4.2, @bucketco/node-sdk@workspace:packages/node-sdk": +"@bucketco/node-sdk@npm:1.6.0-alpha.4, @bucketco/node-sdk@workspace:packages/node-sdk": version: 0.0.0-use.local resolution: "@bucketco/node-sdk@workspace:packages/node-sdk" dependencies: @@ -998,7 +998,7 @@ __metadata: dependencies: "@babel/core": "npm:~7.24.7" "@bucketco/eslint-config": "npm:~0.0.2" - "@bucketco/node-sdk": "npm:>=1.4.2" + "@bucketco/node-sdk": "npm:1.6.0-alpha.4" "@bucketco/tsconfig": "npm:~0.0.2" "@openfeature/core": "npm:^1.5.0" "@openfeature/server-sdk": "npm:>=1.16.1" @@ -15554,8 +15554,8 @@ __metadata: linkType: hard "vite@npm:^5.0.0, vite@npm:^5.0.13, vite@npm:^5.3.5": - version: 5.4.6 - resolution: "vite@npm:5.4.6" + version: 5.4.14 + resolution: "vite@npm:5.4.14" dependencies: esbuild: "npm:^0.21.3" fsevents: "npm:~2.3.3" @@ -15592,7 +15592,7 @@ __metadata: optional: true bin: vite: bin/vite.js - checksum: 10c0/5f87be3a10e970eaf9ac52dfab39cf9fff583036685252fb64570b6d7bfa749f6d221fb78058f5ef4b5664c180d45a8e7a7ff68d7f3770e69e24c7c68b958bde + checksum: 10c0/8842933bd70ca6a98489a0bb9c8464bec373de00f9a97c8c7a4e64b24d15c88bfaa8c1acb38a68c3e5eb49072ffbccb146842c2d4edcdd036a9802964cffe3d1 languageName: node linkType: hard @@ -16113,6 +16113,7 @@ __metadata: typedoc-plugin-frontmatter: "npm:^1.1.2" typedoc-plugin-markdown: "npm:^4.4.2" typedoc-plugin-mdn-links: "npm:^4.0.7" + typescript: "npm:^5.7.3" languageName: unknown linkType: soft