From 50d0d6c03e341a152b563239c5d5e032bbf0dd2a Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Sun, 23 Mar 2025 11:39:49 +0100 Subject: [PATCH 01/12] feat(node-sdk): add `getFeatureDefinitions` --- packages/node-sdk/src/client.ts | 17 ++++++++++ packages/node-sdk/src/types.ts | 55 +++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index bc081691..aa35ce72 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -19,6 +19,7 @@ import { newRateLimiter } from "./rate-limiter"; import type { EvaluatedFeaturesAPIResponse, FeatureAPIResponse, + FeatureDefinition, FeatureOverridesFn, IdType, RawFeature, @@ -482,6 +483,22 @@ export class BucketClient { await this._config.batchBuffer.flush(); } + /** + * Gets the feature definitions, including all config values. + * To evaluate which features are enabled for a given user/company, use `getFeatures`. + * + * @returns The features definitions. + */ + public async getFeatureDefinitions(): Promise { + const features = this.getFeaturesCache().get()?.features || []; + return features.map((f) => ({ + key: f.key, + description: f.description, + isEnabled: f.targeting, + config: f.config, + })); + } + /** * Gets the evaluated features for the current context which includes the user, company, and custom context. * diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index fb771b50..d9d358b8 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -190,6 +190,56 @@ export type FeatureOverride = }) | boolean; +/** + * Describes a feature definition. + */ +export type FeatureDefinition = { + /** + * The key of the feature. + */ + key: string; + + /** + * Description of the feature. + */ + description: string | null; + + /** + * The targeting rules for the feature. + */ + isEnabled: { + /** + * 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 a collection of evaluated features. * @@ -264,6 +314,11 @@ export type FeatureAPIResponse = { */ key: string; + /** + * Description of the feature. + */ + description: string | null; + /** * The targeting rules for the feature. */ From 76b75982fbcacfdcfdca3ee2a470e35029d4eafb Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Mon, 24 Mar 2025 11:37:18 +0100 Subject: [PATCH 02/12] expose `FeatureDefinition` --- packages/node-sdk/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/node-sdk/src/index.ts b/packages/node-sdk/src/index.ts index 8f1de37b..9d2860f6 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -7,6 +7,7 @@ export type { ContextWithTracking, EmptyFeatureRemoteConfig, Feature, + FeatureDefinition, FeatureOverride, FeatureOverrides, FeatureOverridesFn, From e46b99dc540a3f6fb01c7ca8a6f4f0a01986b75f Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Mon, 24 Mar 2025 15:35:35 +0100 Subject: [PATCH 03/12] fix: expose FeatureConfigVariant as no longer internal --- packages/node-sdk/src/types.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index d9d358b8..e30082f3 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -282,9 +282,7 @@ export type FeatureOverrides = Partial< export type FeatureOverridesFn = (context: Context) => FeatureOverrides; /** - * (Internal) Describes a remote feature config variant. - * - * @internal + * Describes a remote feature config variant. */ export type FeatureConfigVariant = { /** From c7ac4e047354401a40b34f5d201183bcc535a43a Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Mon, 24 Mar 2025 15:46:17 +0100 Subject: [PATCH 04/12] actually expose it --- packages/node-sdk/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/node-sdk/src/index.ts b/packages/node-sdk/src/index.ts index 9d2860f6..11de1c16 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -7,6 +7,7 @@ export type { ContextWithTracking, EmptyFeatureRemoteConfig, Feature, + FeatureConfigVariant, FeatureDefinition, FeatureOverride, FeatureOverrides, From 043e836ae33ffa9488353ae2757d5045063393eb Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Tue, 25 Mar 2025 20:07:22 +0100 Subject: [PATCH 05/12] go with `flag` instead of `isEnabled` when exposing the flag data --- packages/node-sdk/src/client.ts | 2 +- packages/node-sdk/src/types.ts | 2 +- packages/node-sdk/test/client.test.ts | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index aa35ce72..b9dfe2b9 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -494,7 +494,7 @@ export class BucketClient { return features.map((f) => ({ key: f.key, description: f.description, - isEnabled: f.targeting, + flag: f.targeting, config: f.config, })); } diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index e30082f3..002b9a9e 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -207,7 +207,7 @@ export type FeatureDefinition = { /** * The targeting rules for the feature. */ - isEnabled: { + flag: { /** * The version of the targeting rules. */ diff --git a/packages/node-sdk/test/client.test.ts b/packages/node-sdk/test/client.test.ts index 86c1ddfc..9caf2c4e 100644 --- a/packages/node-sdk/test/client.test.ts +++ b/packages/node-sdk/test/client.test.ts @@ -102,6 +102,7 @@ const featureDefinitions: FeaturesAPIResponse = { features: [ { key: "feature1", + description: "Feature 1", targeting: { version: 1, rules: [ @@ -133,6 +134,7 @@ const featureDefinitions: FeaturesAPIResponse = { }, { key: "feature2", + description: "Feature 2", targeting: { version: 2, rules: [ From 7b4417b0b3a598a4d03c58e2396faae7d273e64e Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Wed, 26 Mar 2025 08:47:18 +0100 Subject: [PATCH 06/12] allow bootstrapping feature definitions --- packages/node-sdk/README.md | 21 ++++++++++-- packages/node-sdk/src/cache.ts | 7 +++- packages/node-sdk/src/client.ts | 61 +++++++++++++++++---------------- packages/node-sdk/src/types.ts | 12 +++++++ 4 files changed, 69 insertions(+), 32 deletions(-) diff --git a/packages/node-sdk/README.md b/packages/node-sdk/README.md index 1044c8c0..9bef4686 100644 --- a/packages/node-sdk/README.md +++ b/packages/node-sdk/README.md @@ -146,9 +146,9 @@ const client = new BucketClient({ }); ``` -### Caching +### Feature definitions -Feature definitions are automatically cached and refreshed in the background. The cache behavior is configurable: +Feature definitions include the rules needed to determine which features should be enabled and which config values should be applied to any given user/company. Feature definitions are automatically fetched, cached and refreshed in the background. The cache behavior is configurable: ```typescript const client = new BucketClient({ @@ -157,6 +157,23 @@ const client = new BucketClient({ }); ``` +It's also possible to load the feature definitions from a local file or similar: + +```typescript +import fs from "fs"; + +const client = new BucketClient({ + fetchFeatures: false, +}); + +const featureDefs = fs.readFileSync("featureDefs.json"); + +client.bootstrapFeatureDefinitions(featureDefs); +client.initialize().then(() => { + console.log("Bootstrapped feature definitions"); +}); +``` + ## Error Handling The SDK is designed to fail gracefully and never throw exceptions to the caller. Instead, it logs errors and provides diff --git a/packages/node-sdk/src/cache.ts b/packages/node-sdk/src/cache.ts index 59800b23..02289c28 100644 --- a/packages/node-sdk/src/cache.ts +++ b/packages/node-sdk/src/cache.ts @@ -67,5 +67,10 @@ export default function cache( return get(); }; - return { get, refresh }; + const set = (value: T) => { + cachedValue = value; + lastUpdate = Date.now(); + }; + + return { get, refresh, set }; } diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index b9dfe2b9..64e1bb8b 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -111,17 +111,18 @@ export class BucketClient { staleWarningInterval: number; headers: Record; fallbackFeatures?: Record; - featuresCache?: Cache; + featuresCache: Cache; batchBuffer: BatchBuffer; featureOverrides: FeatureOverridesFn; rateLimiter: ReturnType; offline: boolean; + fetchFeatures: boolean; configFile?: string; }; private _initialize = once(async () => { - if (!this._config.offline) { - await this.getFeaturesCache().refresh(); + if (!this._config.offline && this._config.fetchFeatures) { + await this._config.featuresCache.refresh(); } this._config.logger?.info("Bucket initialized"); }); @@ -256,6 +257,7 @@ export class BucketClient { rateLimiter: newRateLimiter(FEATURE_EVENT_RATE_LIMITER_WINDOW_SIZE_MS), httpClient: options.httpClient || fetchClient, refetchInterval: FEATURES_REFETCH_MS, + fetchFeatures: options.fetchFeatures ?? true, staleWarningInterval: FEATURES_REFETCH_MS * 5, fallbackFeatures: fallbackFeatures, batchBuffer: new BatchBuffer({ @@ -276,6 +278,21 @@ export class BucketClient { if (!new URL(this._config.apiBaseUrl).pathname.endsWith("/")) { this._config.apiBaseUrl += "/"; } + + this._config.featuresCache = cache( + this._config.refetchInterval, + this._config.staleWarningInterval, + this._config.logger, + async () => { + const res = await this.get("features"); + + if (!isObject(res) || !Array.isArray(res?.features)) { + return undefined; + } + + return res; + }, + ); } /** @@ -454,6 +471,18 @@ export class BucketClient { }); } + /** + * Updates the feature definitions cache. + * + * @param features - The features to cache. + * + * @remarks + * Useful when loading feature definitions from a file or other source. + **/ + public bootstrapFeatureDefinitions(features: FeaturesAPIResponse) { + this._config.featuresCache.set(features); + } + /** * Initializes the client by caching the features definitions. * @@ -854,32 +883,6 @@ export class BucketClient { } } - /** - * Gets the features cache. - * - * @returns The features cache. - **/ - private getFeaturesCache() { - if (!this._config.featuresCache) { - this._config.featuresCache = cache( - this._config.refetchInterval, - this._config.staleWarningInterval, - this._config.logger, - async () => { - const res = await this.get("features"); - - if (!isObject(res) || !Array.isArray(res?.features)) { - return undefined; - } - - return res; - }, - ); - } - - return this._config.featuresCache; - } - /** * Warns if any features have targeting rules that require context fields that are missing. * diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 002b9a9e..986c3af5 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -499,6 +499,11 @@ export type Cache = { * @returns The value or `undefined` if the value is not available. **/ refresh: () => Promise; + + /** + * Set the value + **/ + set: (value: T) => void; }; /** @@ -610,6 +615,13 @@ export type ClientOptions = { */ offline?: boolean; + /** + * If set to false, feature definitions will not be fetched from the API when the client is initialized. + * Useful if you want to manage feature definitions manually through `bootstrapFeatureDefinitions`. + * Defaults to true. + */ + fetchFeatures?: boolean; + /** * The path to the config file. If supplied, the config file will be loaded. * Defaults to `bucket.json` when NODE_ENV is not production. Can also be From bdeab03c478454cb51ce9741a262a9b69e48d887 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Wed, 26 Mar 2025 10:07:40 +0100 Subject: [PATCH 07/12] refactor `config` --- packages/node-sdk/src/client.ts | 132 ++++++++++++-------------- packages/node-sdk/test/client.test.ts | 32 +++---- 2 files changed, 76 insertions(+), 88 deletions(-) diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index 64e1bb8b..cc0de5c7 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -104,27 +104,32 @@ type BulkEvent = **/ export class BucketClient { private _config: { - logger?: Logger; apiBaseUrl: string; - httpClient: HttpClient; refetchInterval: number; staleWarningInterval: number; headers: Record; fallbackFeatures?: Record; - featuresCache: Cache; - batchBuffer: BatchBuffer; featureOverrides: FeatureOverridesFn; - rateLimiter: ReturnType; offline: boolean; fetchFeatures: boolean; configFile?: string; }; + httpClient: HttpClient; + + private featuresCache: Cache; + private batchBuffer: BatchBuffer; + private rateLimiter: ReturnType; + + /** + * Gets the logger associated with the client. + */ + public readonly logger: Logger; private _initialize = once(async () => { if (!this._config.offline && this._config.fetchFeatures) { - await this._config.featuresCache.refresh(); + await this.featuresCache.refresh(); } - this._config.logger?.info("Bucket initialized"); + this.logger.info("Bucket initialized"); }); /** @@ -202,7 +207,7 @@ export class BucketClient { } // use the supplied logger or apply the log level to the console logger - const logger = options.logger + this.logger = options.logger ? options.logger : applyLogLevel( decorateLogger(BUCKET_LOG_PREFIX, console), @@ -245,8 +250,17 @@ export class BucketClient { ) : undefined; + this.rateLimiter = newRateLimiter( + FEATURE_EVENT_RATE_LIMITER_WINDOW_SIZE_MS, + ); + this.httpClient = options.httpClient || fetchClient; + this.batchBuffer = new BatchBuffer({ + ...options?.batchOptions, + flushHandler: (items) => this.sendBulkEvents(items), + logger: this.logger, + }); + this._config = { - logger, offline, apiBaseUrl: (config.apiBaseUrl ?? config.host) || API_BASE_URL, headers: { @@ -254,17 +268,10 @@ export class BucketClient { [SDK_VERSION_HEADER_NAME]: SDK_VERSION, ["Authorization"]: `Bearer ${config.secretKey}`, }, - rateLimiter: newRateLimiter(FEATURE_EVENT_RATE_LIMITER_WINDOW_SIZE_MS), - httpClient: options.httpClient || fetchClient, refetchInterval: FEATURES_REFETCH_MS, fetchFeatures: options.fetchFeatures ?? true, staleWarningInterval: FEATURES_REFETCH_MS * 5, fallbackFeatures: fallbackFeatures, - batchBuffer: new BatchBuffer({ - ...options?.batchOptions, - flushHandler: (items) => this.sendBulkEvents(items), - logger, - }), featureOverrides: typeof config.featureOverrides === "function" ? config.featureOverrides @@ -279,10 +286,10 @@ export class BucketClient { this._config.apiBaseUrl += "/"; } - this._config.featuresCache = cache( + this.featuresCache = cache( this._config.refetchInterval, this._config.staleWarningInterval, - this._config.logger, + this.logger, async () => { const res = await this.get("features"); @@ -295,15 +302,6 @@ export class BucketClient { ); } - /** - * Gets the logger associated with the client. - * - * @returns The logger or `undefined` if it is not set. - **/ - public get logger() { - return this._config.logger; - } - /** * Sets the feature overrides. * @@ -369,10 +367,8 @@ export class BucketClient { return; } - if ( - this._config.rateLimiter.isAllowed(hashObject({ ...options, userId })) - ) { - await this._config.batchBuffer.add({ + if (this.rateLimiter.isAllowed(hashObject({ ...options, userId }))) { + await this.batchBuffer.add({ type: "user", userId, attributes: options?.attributes, @@ -415,10 +411,8 @@ export class BucketClient { return; } - if ( - this._config.rateLimiter.isAllowed(hashObject({ ...options, companyId })) - ) { - await this._config.batchBuffer.add({ + if (this.rateLimiter.isAllowed(hashObject({ ...options, companyId }))) { + await this.batchBuffer.add({ type: "company", companyId, userId: options?.userId, @@ -461,7 +455,7 @@ export class BucketClient { return; } - await this._config.batchBuffer.add({ + await this.batchBuffer.add({ type: "event", event, companyId: options?.companyId, @@ -480,7 +474,8 @@ export class BucketClient { * Useful when loading feature definitions from a file or other source. **/ public bootstrapFeatureDefinitions(features: FeaturesAPIResponse) { - this._config.featuresCache.set(features); + this.featuresCache.set(features); + this.logger.info("Bootstrapped feature definitions"); } /** @@ -509,7 +504,7 @@ export class BucketClient { return; } - await this._config.batchBuffer.flush(); + await this.batchBuffer.flush(); } /** @@ -519,7 +514,7 @@ export class BucketClient { * @returns The features definitions. */ public async getFeatureDefinitions(): Promise { - const features = this.getFeaturesCache().get()?.features || []; + const features = this.featuresCache.get()?.features || []; return features.map((f) => ({ key: f.key, description: f.description, @@ -661,15 +656,16 @@ export class BucketClient { const url = this.buildUrl(path); try { - const response = await this._config.httpClient.post< - TBody, - { success: boolean } - >(url, this._config.headers, body); + const response = await this.httpClient.post( + url, + this._config.headers, + body, + ); - this._config.logger?.debug(`post request to "${url}"`, response); + this.logger.debug(`post request to "${url}"`, response); if (!response.ok || !isObject(response.body) || !response.body.success) { - this._config.logger?.warn( + this.logger.warn( `invalid response received from server for "${url}"`, response, ); @@ -677,10 +673,7 @@ export class BucketClient { } return true; } catch (error) { - this._config.logger?.error( - `post request to "${url}" failed with error`, - error, - ); + this.logger.error(`post request to "${url}" failed with error`, error); return false; } } @@ -698,14 +691,14 @@ export class BucketClient { try { const url = this.buildUrl(path); - const response = await this._config.httpClient.get< + const response = await this.httpClient.get< TResponse & { success: boolean } >(url, this._config.headers); - this._config.logger?.debug(`get request to "${url}"`, response); + this.logger.debug(`get request to "${url}"`, response); if (!response.ok || !isObject(response.body) || !response.body.success) { - this._config.logger?.warn( + this.logger.warn( `invalid response received from server for "${url}"`, response, ); @@ -716,10 +709,7 @@ export class BucketClient { const { success: _, ...result } = response.body; return result as TResponse; } catch (error) { - this._config.logger?.error( - `get request to "${path}" failed with error`, - error, - ); + this.logger.error(`get request to "${path}" failed with error`, error); return undefined; } } @@ -812,7 +802,7 @@ export class BucketClient { } if ( - !this._config.rateLimiter.isAllowed( + !this.rateLimiter.isAllowed( hashObject({ action: event.action, key: event.key, @@ -825,7 +815,7 @@ export class BucketClient { return; } - await this._config.batchBuffer.add({ + await this.batchBuffer.add({ type: "feature-flag-event", action: event.action, key: event.key, @@ -850,9 +840,7 @@ export class BucketClient { */ private async syncContext(options: ContextWithTracking) { if (!options.enableTracking) { - this._config.logger?.debug( - "tracking disabled, not updating user/company", - ); + this.logger.debug("tracking disabled, not updating user/company"); return; } @@ -904,7 +892,7 @@ export class BucketClient { (acc, { config, ...feature }) => { if ( feature.missingContextFields?.length && - this._config.rateLimiter.isAllowed( + this.rateLimiter.isAllowed( hashObject({ featureKey: feature.key, missingContextFields: feature.missingContextFields, @@ -917,7 +905,7 @@ export class BucketClient { if ( config?.missingContextFields?.length && - this._config.rateLimiter.isAllowed( + this.rateLimiter.isAllowed( hashObject({ featureKey: feature.key, configKey: config.key, @@ -935,7 +923,7 @@ export class BucketClient { ); if (Object.keys(report).length > 0) { - this._config.logger?.warn( + this.logger.warn( `feature/remote config targeting rules might not be correctly evaluated due to missing context fields.`, report, ); @@ -953,9 +941,9 @@ export class BucketClient { if (this._config.offline) { featureDefinitions = []; } else { - const fetchedFeatures = this.getFeaturesCache().get(); + const fetchedFeatures = this.featuresCache.get(); if (!fetchedFeatures) { - this._config.logger?.warn( + this.logger.warn( "failed to use feature definitions, there are none cached yet. Using fallback features.", ); return this._config.fallbackFeatures || {}; @@ -1059,7 +1047,7 @@ export class BucketClient { ) .filter(Boolean); if (failed.length > 0) { - this._config.logger?.error(`failed to queue some evaluate events.`, { + this.logger.error(`failed to queue some evaluate events.`, { errors: failed, }); } @@ -1129,7 +1117,7 @@ export class BucketClient { evalMissingFields: feature.missingContextFields, }) .catch((err) => { - client._config.logger?.error( + client.logger?.error( `failed to send check event for "${feature.key}": ${err}`, err, ); @@ -1150,7 +1138,7 @@ export class BucketClient { evalMissingFields: config?.missingContextFields, }) .catch((err) => { - client._config.logger?.error( + client.logger?.error( `failed to send check event for "${feature.key}": ${err}`, err, ); @@ -1161,7 +1149,7 @@ export class BucketClient { key: feature.key, track: async () => { if (typeof context.user?.id === "undefined") { - this._config.logger?.warn("no user set, cannot track event"); + this.logger.warn("no user set, cannot track event"); return; } @@ -1170,7 +1158,7 @@ export class BucketClient { companyId: context.company?.id, }); } else { - this._config.logger?.debug("tracking disabled, not tracking event"); + this.logger.debug("tracking disabled, not tracking event"); } }, }; @@ -1224,7 +1212,7 @@ export class BucketClient { }) || [], ); } else { - this._config.logger?.error("failed to fetch evaluated features"); + this.logger.error("failed to fetch evaluated features"); return {}; } } diff --git a/packages/node-sdk/test/client.test.ts b/packages/node-sdk/test/client.test.ts index b800caa5..ddf3a9fe 100644 --- a/packages/node-sdk/test/client.test.ts +++ b/packages/node-sdk/test/client.test.ts @@ -263,10 +263,10 @@ describe("BucketClient", () => { expect(client["_config"].staleWarningInterval).toBe( FEATURES_REFETCH_MS * 5, ); - expect(client["_config"].logger).toBeDefined(); - expect(client["_config"].httpClient).toBe(validOptions.httpClient); + expect(client.logger).toBeDefined(); + expect(client.httpClient).toBe(validOptions.httpClient); expect(client["_config"].headers).toEqual(expectedHeaders); - expect(client["_config"].batchBuffer).toMatchObject({ + expect(client["batchBuffer"]).toMatchObject({ maxSize: 99, intervalMs: 100, }); @@ -282,7 +282,7 @@ describe("BucketClient", () => { it("should route messages to the supplied logger", () => { const client = new BucketClient(validOptions); - const actualLogger = client["_config"].logger!; + const actualLogger = client.logger!; actualLogger.debug("debug message"); actualLogger.info("info message"); actualLogger.warn("warn message"); @@ -312,10 +312,10 @@ describe("BucketClient", () => { expect(client["_config"].staleWarningInterval).toBe( FEATURES_REFETCH_MS * 5, ); - expect(client["_config"].httpClient).toBe(fetchClient); + expect(client.httpClient).toBe(fetchClient); expect(client["_config"].headers).toEqual(expectedHeaders); expect(client["_config"].fallbackFeatures).toBeUndefined(); - expect(client["_config"].batchBuffer).toMatchObject({ + expect(client["batchBuffer"]).toMatchObject({ maxSize: BATCH_MAX_SIZE, intervalMs: BATCH_INTERVAL_MS, }); @@ -373,7 +373,7 @@ describe("BucketClient", () => { it("should create a new feature events rate-limiter", () => { const client = new BucketClient(validOptions); - expect(client["_config"].rateLimiter).toBeDefined(); + expect(client["rateLimiter"]).toBeDefined(); expect(newRateLimiter).toHaveBeenCalledWith( FEATURE_EVENT_RATE_LIMITER_WINDOW_SIZE_MS, ); @@ -444,7 +444,7 @@ describe("BucketClient", () => { beforeEach(() => { vi.mocked(httpClient.post).mockResolvedValue({ body: { success: true } }); - client["_config"].rateLimiter.clear(true); + client["rateLimiter"].clear(true); }); it("should return a new client instance with the `user`, `company` and `other` set", async () => { @@ -565,7 +565,7 @@ describe("BucketClient", () => { const client = new BucketClient(validOptions); beforeEach(() => { - client["_config"].rateLimiter.clear(true); + client["rateLimiter"].clear(true); }); // try with both string and number IDs @@ -650,7 +650,7 @@ describe("BucketClient", () => { const client = new BucketClient(validOptions); beforeEach(() => { - client["_config"].rateLimiter.clear(true); + client["rateLimiter"].clear(true); }); test.each([ @@ -744,7 +744,7 @@ describe("BucketClient", () => { const client = new BucketClient(validOptions); beforeEach(() => { - client["_config"].rateLimiter.clear(true); + client["rateLimiter"].clear(true); }); test.each([ @@ -959,10 +959,10 @@ describe("BucketClient", () => { it("should set up the cache object", async () => { const client = new BucketClient(validOptions); - expect(client["_config"].featuresCache).toBeUndefined(); + expect(client["featuresCache"]).toBeUndefined(); await client.initialize(); - expect(client["_config"].featuresCache).toBeTypeOf("object"); + expect(client["featuresCache"]).toBeTypeOf("object"); }); it("should call the backend to obtain features", async () => { @@ -1409,7 +1409,7 @@ describe("BucketClient", () => { }, ); - client["_config"].rateLimiter.clear(true); + client["rateLimiter"].clear(true); httpClient.post.mockResolvedValue({ ok: true, @@ -1593,7 +1593,7 @@ describe("BucketClient", () => { }); it("should properly define the rate limiter key", async () => { - const isAllowedSpy = vi.spyOn(client["_config"].rateLimiter, "isAllowed"); + const isAllowedSpy = vi.spyOn(client["rateLimiter"], "isAllowed"); await client.initialize(); client.getFeatures({ user, company, other: otherContext }); @@ -2443,7 +2443,7 @@ describe("BoundBucketClient", () => { await client.flush(); vi.mocked(httpClient.post).mockClear(); - client["_config"].rateLimiter.clear(true); + client["rateLimiter"].clear(true); }); it("should create a client instance", () => { From 06ffb819360665de37956a0dde71de09e833ae85 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Wed, 26 Mar 2025 10:50:16 +0100 Subject: [PATCH 08/12] update docs to remove mention of refetchInterval because it's not available --- packages/node-sdk/README.md | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/node-sdk/README.md b/packages/node-sdk/README.md index 9bef4686..d953cd0c 100644 --- a/packages/node-sdk/README.md +++ b/packages/node-sdk/README.md @@ -148,15 +148,8 @@ const client = new BucketClient({ ### Feature definitions -Feature definitions include the rules needed to determine which features should be enabled and which config values should be applied to any given user/company. Feature definitions are automatically fetched, 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) -}); -``` - +Feature definitions include the rules needed to determine which features should be enabled and which config values should be applied to any given user/company. +Feature definitions are automatically fetched, cached and refreshed in the background. It's also possible to load the feature definitions from a local file or similar: ```typescript From ace1b2fabde8ac2e5972c309c2550f8e45aee8db Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Wed, 26 Mar 2025 13:04:15 +0100 Subject: [PATCH 09/12] revert some refactoring changes. Will submit separate PR --- packages/node-sdk/README.md | 6 +++--- packages/node-sdk/src/cache.ts | 7 +------ packages/node-sdk/src/client.ts | 17 +---------------- packages/node-sdk/src/types.ts | 12 ------------ 4 files changed, 5 insertions(+), 37 deletions(-) diff --git a/packages/node-sdk/README.md b/packages/node-sdk/README.md index d953cd0c..e77e4c4b 100644 --- a/packages/node-sdk/README.md +++ b/packages/node-sdk/README.md @@ -207,12 +207,12 @@ fallback behavior: 4. **Offline Mode**: ```typescript - // In offline mode, the SDK uses fallback features + // In offline mode, the SDK uses feature overrides const client = new BucketClient({ offline: true, - fallbackFeatures: { + featureOverrides: () => ({ "my-feature": true, - }, + }), }); ``` diff --git a/packages/node-sdk/src/cache.ts b/packages/node-sdk/src/cache.ts index 02289c28..59800b23 100644 --- a/packages/node-sdk/src/cache.ts +++ b/packages/node-sdk/src/cache.ts @@ -67,10 +67,5 @@ export default function cache( return get(); }; - const set = (value: T) => { - cachedValue = value; - lastUpdate = Date.now(); - }; - - return { get, refresh, set }; + return { get, refresh }; } diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index cc0de5c7..e3896b75 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -111,7 +111,6 @@ export class BucketClient { fallbackFeatures?: Record; featureOverrides: FeatureOverridesFn; offline: boolean; - fetchFeatures: boolean; configFile?: string; }; httpClient: HttpClient; @@ -126,7 +125,7 @@ export class BucketClient { public readonly logger: Logger; private _initialize = once(async () => { - if (!this._config.offline && this._config.fetchFeatures) { + if (!this._config.offline) { await this.featuresCache.refresh(); } this.logger.info("Bucket initialized"); @@ -269,7 +268,6 @@ export class BucketClient { ["Authorization"]: `Bearer ${config.secretKey}`, }, refetchInterval: FEATURES_REFETCH_MS, - fetchFeatures: options.fetchFeatures ?? true, staleWarningInterval: FEATURES_REFETCH_MS * 5, fallbackFeatures: fallbackFeatures, featureOverrides: @@ -465,19 +463,6 @@ export class BucketClient { }); } - /** - * Updates the feature definitions cache. - * - * @param features - The features to cache. - * - * @remarks - * Useful when loading feature definitions from a file or other source. - **/ - public bootstrapFeatureDefinitions(features: FeaturesAPIResponse) { - this.featuresCache.set(features); - this.logger.info("Bootstrapped feature definitions"); - } - /** * Initializes the client by caching the features definitions. * diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 986c3af5..002b9a9e 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -499,11 +499,6 @@ export type Cache = { * @returns The value or `undefined` if the value is not available. **/ refresh: () => Promise; - - /** - * Set the value - **/ - set: (value: T) => void; }; /** @@ -615,13 +610,6 @@ export type ClientOptions = { */ offline?: boolean; - /** - * If set to false, feature definitions will not be fetched from the API when the client is initialized. - * Useful if you want to manage feature definitions manually through `bootstrapFeatureDefinitions`. - * Defaults to true. - */ - fetchFeatures?: boolean; - /** * The path to the config file. If supplied, the config file will be loaded. * Defaults to `bucket.json` when NODE_ENV is not production. Can also be From 85bccce78e21b7f49f7390c53ab2d23095c753c8 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Wed, 26 Mar 2025 13:28:25 +0100 Subject: [PATCH 10/12] update readme --- packages/node-sdk/README.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/node-sdk/README.md b/packages/node-sdk/README.md index e77e4c4b..e1f2cc86 100644 --- a/packages/node-sdk/README.md +++ b/packages/node-sdk/README.md @@ -149,22 +149,22 @@ const client = new BucketClient({ ### Feature definitions Feature definitions include the rules needed to determine which features should be enabled and which config values should be applied to any given user/company. -Feature definitions are automatically fetched, cached and refreshed in the background. -It's also possible to load the feature definitions from a local file or similar: +Feature definitions are automatically fetched when calling `initialize()`. +They are then cached and refreshed in the background. +It's also possible to get the currently in use feature definitions: ```typescript import fs from "fs"; -const client = new BucketClient({ - fetchFeatures: false, -}); +const client = new BucketClient(); -const featureDefs = fs.readFileSync("featureDefs.json"); - -client.bootstrapFeatureDefinitions(featureDefs); -client.initialize().then(() => { - console.log("Bootstrapped feature definitions"); -}); +const featureDefs = await client.getFeatureDefinitions(); +// [{ +// key: "huddle", +// description: "Live voice conversations with colleagues." +// flag: { ... } +// config: { ... } +// }] ``` ## Error Handling From 751f1d7ce5d5c5bb34e3d75e940db7af56566260 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Wed, 26 Mar 2025 14:06:57 +0100 Subject: [PATCH 11/12] update tests --- packages/node-sdk/test/client.test.ts | 28 +++++++++++++-------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/packages/node-sdk/test/client.test.ts b/packages/node-sdk/test/client.test.ts index b5ed7e62..36873fcb 100644 --- a/packages/node-sdk/test/client.test.ts +++ b/packages/node-sdk/test/client.test.ts @@ -945,31 +945,29 @@ describe("BucketClient", () => { it("should initialize the client", async () => { const client = new BucketClient(validOptions); - const cache = { - refresh: vi.fn(), - get: vi.fn(), - }; - - vi.spyOn(client as any, "getFeaturesCache").mockReturnValue(cache); + const get = vi + .spyOn(client["featuresCache"], "get") + .mockReturnValue(undefined); + const refresh = vi + .spyOn(client["featuresCache"], "refresh") + .mockResolvedValue(undefined); await client.initialize(); await client.initialize(); await client.initialize(); - expect(cache.refresh).toHaveBeenCalledTimes(1); - expect(cache.get).not.toHaveBeenCalled(); + expect(refresh).toHaveBeenCalledTimes(1); + expect(get).not.toHaveBeenCalled(); }); - it("should set up the cache object", async () => { + it("should call the backend to obtain features", async () => { const client = new BucketClient(validOptions); - expect(client["featuresCache"]).toBeUndefined(); - await client.initialize(); - expect(client["featuresCache"]).toBeTypeOf("object"); - }); + httpClient.get.mockResolvedValue({ + ok: true, + status: 200, + }); - it("should call the backend to obtain features", async () => { - const client = new BucketClient(validOptions); await client.initialize(); expect(httpClient.get).toHaveBeenCalledWith( From 169cfe5ba4b659cd12196481da780f7adb964913 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Wed, 26 Mar 2025 14:10:43 +0100 Subject: [PATCH 12/12] bump version --- packages/node-sdk/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/node-sdk/package.json b/packages/node-sdk/package.json index 4ee373df..38557154 100644 --- a/packages/node-sdk/package.json +++ b/packages/node-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@bucketco/node-sdk", - "version": "1.7.0", + "version": "1.8.0", "license": "MIT", "repository": { "type": "git",