diff --git a/packages/node-sdk/README.md b/packages/node-sdk/README.md index 63441612..b673c9f2 100644 --- a/packages/node-sdk/README.md +++ b/packages/node-sdk/README.md @@ -74,13 +74,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. @@ -108,6 +113,34 @@ 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. +### 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, @@ -136,7 +169,13 @@ Note: BUCKET_FEATURES_ENABLED, BUCKET_FEATURES_DISABLED are comma separated list "apiBaseUrl": "https://proxy.slick-demo.com", "featureOverrides": { "huddles": true, - "voiceChat": false + "voiceChat": false, + "aiAssist": { + "key": "gpt-4.0", + "payload": { + "maxTokens": 50000 + } + } } } ``` @@ -162,8 +201,11 @@ 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 }; + "delete-todos": { + isEnabled: boolean, + config: any + }; } } @@ -173,7 +215,52 @@ bucketClient.initialize().then({ console.log("Bucket initialized!") bucketClient.getFeature("invalid-feature") // feature doesn't exist }) +``` + +The following example show how to add strongly typed payloads when using remote configuration: + +```typescript +import { BucketClient } from "@bucketco/node-sdk"; + +type ConfirmationConfig = { + shouldShowConfirmation: boolean; +}; +declare module "@bucketco/node-sdk" { + interface Features { + "delete-todos": { + isEnabled: boolean; + config: { + key: string; + payload: ConfirmationConfig; + }; + }; + } +} + +export const bucketClient = new BucketClient(); + +function deleteTodo(todoId: string) { + // get the feature information + const { + isEnabled, + config: { payload: confirmationConfig }, + } = bucketClient.getFeature("delete-todos"); + + // check that feature is enabled for user + if (!isEnabled) { + return; + } + + // finally, check if we enabled the "confirmation" dialog for this user and only + // show it in that case. + // since we defined `ConfirmationConfig` as the only valid payload for `delete-todos`, + // we have type-safety helping us with the payload value. + if (confirmationConfig.shouldShowConfirmation) { + showMessage("Are you really sure you want to delete this item?"); + // ... rest of the code + } +} ``` ![Type check failed](docs/type-check-failed.png "Type check failed") 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 6aebcfd8..907c4deb 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 }; let host = undefined; 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/src/client.ts b/packages/node-sdk/src/client.ts index 49c3308c..dfe0278b 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -17,9 +17,11 @@ import fetchClient from "./fetch-http-client"; import { newRateLimiter } from "./rate-limiter"; import type { EvaluatedFeaturesAPIResponse, + FeatureAPIResponse, FeatureOverridesFn, IdType, RawFeature, + RawFeatureRemoteConfig, } from "./types"; import { Attributes, @@ -27,7 +29,6 @@ import { ClientOptions, Context, ContextWithTracking, - Feature, FeatureEvent, FeaturesAPIResponse, HttpClient, @@ -102,6 +103,7 @@ export class BucketClient { offline: boolean; configFile?: string; }; + private _initialize = once(async () => { if (!this._config.offline) { await this.getFeaturesCache().refresh(); @@ -140,8 +142,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), @@ -179,18 +182,40 @@ 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, + default: true, + payload: fallback.config.payload, + } + : undefined, + }; + return acc; + }, + {} as Record, + ) + : undefined; this._config = { logger, @@ -439,10 +464,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]; @@ -451,6 +476,7 @@ export class BucketClient { key, isEnabled: feature?.isEnabled ?? false, targetingVersion: feature?.targetingVersion, + config: feature?.config, }); } @@ -486,12 +512,12 @@ export class BucketClient { * @param additionalContext * @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, @@ -811,8 +837,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, ...context } = options; @@ -825,6 +855,31 @@ 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, + }; + } + } + return acc; + }, + {} as Record, + ); + this.warnMissingFeatureContextFields( context, evaluated.map(({ featureKey, missingContextFields }) => ({ @@ -839,7 +894,7 @@ export class BucketClient { await this.sendFeatureEvent({ action: "evaluate", key: res.featureKey, - targetingVersion: keyToVersionMap.get(res.featureKey), + targetingVersion: featureMap[res.featureKey].targeting.version, evalResult: res.value ?? false, evalContext: res.context, evalRuleResults: res.ruleEvaluationResults, @@ -859,7 +914,9 @@ 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], + targetingVersion: featureMap[res.featureKey].targeting.version, + missingContextFields: res.missingContextFields, }; return acc; }, @@ -869,7 +926,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 @@ -878,40 +942,47 @@ export class BucketClient { ...Object.fromEntries(overrides), }; } - this._config.logger?.debug("evaluated features", evaluatedFeatures); return evaluatedFeatures; } - private _wrapRawFeature( + private _wrapRawFeature( options: { enableTracking: boolean } & Context, - { key, isEnabled, targetingVersion }: RawFeature, - ): Feature { + { key, isEnabled, config, targetingVersion }: RawFeature, + ): TypedFeatures[TKey] { // eslint-disable-next-line @typescript-eslint/no-this-alias const client = this; + function sendCheckEvent() { + if (options.enableTracking) { + void client + .sendFeatureEvent({ + action: "check", + key, + targetingVersion, + evalResult: isEnabled, + }) + .catch((err) => { + client._config.logger?.error( + `failed to send check event for "${key}": ${err}`, + err, + ); + }); + } + } + + const simplifiedConfig = config + ? { key: config.key, payload: config.payload } + : { key: undefined, payload: undefined }; + return { get isEnabled() { - if (options.enableTracking) { - void client - .sendFeatureEvent({ - action: "check", - key, - targetingVersion, - evalResult: isEnabled, - }) - .catch((err) => { - client._config.logger?.error( - `failed to send check event for "${key}": ${err}`, - err, - ); - }); - } - + sendCheckEvent(); return isEnabled; }, get config() { - return undefined; + sendCheckEvent(); + return simplifiedConfig as TypedFeatures[TKey]["config"]; }, key, track: async () => { @@ -949,6 +1020,7 @@ export class BucketClient { ...context, enableTracking: true, }; + checkContextWithTracking(contextWithTracking); const params = new URLSearchParams( @@ -968,6 +1040,7 @@ export class BucketClient { context, Object.values(res.features), ); + return Object.fromEntries( Object.entries(res.features).map(([featureKey, feature]) => { return [ @@ -1035,7 +1108,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); } @@ -1045,7 +1118,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 503629c2..b6ec95e2 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"; @@ -21,19 +21,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 d2104832..e8637774 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -56,7 +56,32 @@ 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; + + /** + * Indicates if the config value is the default. + */ + default: boolean; + + /** + * The optional user-supplied payload data. + */ + payload: any; +}; + +/** + * Describes a feature. */ export interface RawFeature { /** @@ -74,16 +99,42 @@ export interface RawFeature { */ targetingVersion?: number; + /** + * The remote configuration value for the feature. + */ + config?: RawFeatureRemoteConfig; + /** * 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 | never = EmptyFeatureRemoteConfig, +> { /** * The key of the feature. */ @@ -94,10 +145,10 @@ export interface Feature { */ isEnabled: boolean; - /** - * Optional user-defined configuration if the feature is enabled. + /* + * Optional user-defined configuration. */ - config: any; + config: TConfig extends never ? EmptyFeatureRemoteConfig : TConfig; /** * Track feature usage in Bucket. @@ -105,6 +156,16 @@ export interface Feature { track(): Promise; } +type FullFeatureOverride = { + isEnabled: boolean; + config?: { + key: string; + payload: any; + }; +}; + +type FeatureOverride = FullFeatureOverride | boolean; + /** * Describes a collection of evaluated features. * @@ -123,48 +184,141 @@ 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; + + /** + * Indicates if the variant is the default variant. + */ + default: boolean; +}; + +/** + * (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 = { /** @@ -335,8 +489,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. @@ -352,16 +512,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 201d4879..bc96e128 100644 --- a/packages/node-sdk/test/client.test.ts +++ b/packages/node-sdk/test/client.test.ts @@ -109,6 +109,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", @@ -146,6 +162,12 @@ const evaluatedFeatures = [ feature: { key: "feature1", version: 1 }, value: true, context: {}, + config: { + key: "config-1", + payload: { something: "else" }, + ruleEvaluationResults: [true], + missingContextFields: [], + }, ruleEvaluationResults: [true], missingContextFields: [], }, @@ -175,6 +197,57 @@ 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: { + default: true, + key: "config1", + payload: { value: true }, + }, + }, + }); + }); + it("should create a client instance with valid options", () => { const client = new BucketClient(validOptions); @@ -287,7 +360,7 @@ describe("BucketClient", () => { fallbackFeatures: "invalid" as any, }; expect(() => new BucketClient(invalidOptions)).toThrow( - "fallbackFeatures must be an object", + "fallbackFeatures must be an array or object", ); }); @@ -905,6 +978,8 @@ describe("BucketClient", () => { describe("getFeature", () => { let client: BucketClient; + let featureEvalSequence: Record; + beforeEach(async () => { httpClient.get.mockResolvedValue({ ok: true, @@ -917,12 +992,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, @@ -940,7 +1030,6 @@ describe("BucketClient", () => { }); it("returns a feature", async () => { - // test that the feature is returned await client.initialize(); const feature = client.getFeature( { @@ -951,9 +1040,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), }); }); @@ -1072,6 +1165,49 @@ describe("BucketClient", () => { ); }); + 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", + key: "feature2", + }), + { + type: "feature-flag-event", + action: "check", + evalResult: true, + targetingVersion: 1, + key: "feature1", + }, + ], + ); + }); + it("everything works for unknown features", async () => { const context: Context = { company, @@ -1136,6 +1272,7 @@ describe("BucketClient", () => { describe("getFeatures", () => { let client: BucketClient; + let featureEvalSequence: Record; beforeEach(async () => { httpClient.get.mockResolvedValue({ @@ -1149,12 +1286,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, @@ -1184,22 +1336,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( @@ -1288,22 +1447,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( @@ -1358,22 +1524,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( @@ -1428,22 +1601,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(); }); @@ -1453,7 +1633,7 @@ describe("BucketClient", () => { await client.flush(); - expect(evaluateFeatureRules).toHaveBeenCalledTimes(2); + expect(evaluateFeatureRules).toHaveBeenCalledTimes(3); expect(httpClient.post).toHaveBeenCalledTimes(1); expect(httpClient.post).toHaveBeenCalledWith( @@ -1605,9 +1785,10 @@ describe("BucketClient", () => { "key", ); - expect(result).toEqual({ + expect(result).toStrictEqual({ key: "key", isEnabled: true, + config: { key: undefined, payload: undefined }, track: expect.any(Function), }); @@ -1648,15 +1829,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), }, }); @@ -1674,10 +1862,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(); @@ -1697,22 +1891,34 @@ describe("BucketClient", () => { feature1: { key: "feature1", isEnabled: true, + config: { + key: "config-1", + payload: { + something: "else", + }, + }, track: expect.any(Function), - config: undefined, }, feature2: { key: "feature2", isEnabled: false, + config: { key: undefined, payload: undefined }, track: expect.any(Function), - config: undefined, }, }); 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); @@ -1721,14 +1927,23 @@ describe("BucketClient", () => { feature1: { key: "feature1", isEnabled: false, + config: { key: undefined, payload: undefined }, track: expect.any(Function), - config: undefined, }, 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), - config: undefined, }, }); }); @@ -1749,6 +1964,12 @@ describe("BucketClient", () => { key: "feature1", targetingVersion: 1, isEnabled: true, + config: { + key: "config-1", + version: 3, + default: true, + payload: { something: "else" }, + }, }, feature2: { key: "feature2", @@ -1772,15 +1993,20 @@ 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), }, }); @@ -1827,6 +2053,12 @@ describe("BucketClient", () => { key: "feature1", targetingVersion: 1, isEnabled: true, + config: { + key: "config-1", + version: 3, + default: true, + payload: { something: "else" }, + }, missingContextFields: ["one", "two"], }, }, @@ -1845,10 +2077,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); @@ -2069,15 +2305,17 @@ describe("BoundBucketClient", () => { const result = await boundClient.getFeaturesRemote(); - expect(result).toEqual({ + expect(result).toStrictEqual({ feature1: { key: "feature1", isEnabled: true, + config: { key: undefined, payload: undefined }, track: expect.any(Function), }, feature2: { key: "feature2", isEnabled: false, + config: { key: undefined, payload: undefined }, track: expect.any(Function), }, }); @@ -2099,9 +2337,10 @@ describe("BoundBucketClient", () => { const result = await boundClient.getFeatureRemote("feature1"); - expect(result).toEqual({ + expect(result).toStrictEqual({ key: "feature1", isEnabled: true, + config: { key: undefined, payload: undefined }, 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,