diff --git a/packages/node-sdk/README.md b/packages/node-sdk/README.md index 1044c8c0..e1f2cc86 100644 --- a/packages/node-sdk/README.md +++ b/packages/node-sdk/README.md @@ -146,15 +146,25 @@ 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 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 -const client = new BucketClient({ - refetchInterval: 30000, // How often to refresh features (ms) - staleWarningInterval: 150000, // When to warn about stale features (ms) -}); +import fs from "fs"; + +const client = new BucketClient(); + +const featureDefs = await client.getFeatureDefinitions(); +// [{ +// key: "huddle", +// description: "Live voice conversations with colleagues." +// flag: { ... } +// config: { ... } +// }] ``` ## Error Handling @@ -197,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/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", diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index cf2810cc..a72b5488 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -20,6 +20,7 @@ import { newRateLimiter } from "./rate-limiter"; import type { EvaluatedFeaturesAPIResponse, FeatureAPIResponse, + FeatureDefinition, FeatureOverridesFn, IdType, RawFeature, @@ -104,28 +105,33 @@ 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; configFile?: string; featuresFetchRetries: number; fetchTimeoutMs: number; }; + 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) { - await this.getFeaturesCache().refresh(); + await this.featuresCache.refresh(); } - this._config.logger?.info("Bucket initialized"); + this.logger.info("Bucket initialized"); }); /** @@ -218,7 +224,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), @@ -261,8 +267,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: { @@ -270,16 +285,9 @@ 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, 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 @@ -295,15 +303,24 @@ export class BucketClient { if (!new URL(this._config.apiBaseUrl).pathname.endsWith("/")) { this._config.apiBaseUrl += "/"; } - } - /** - * Gets the logger associated with the client. - * - * @returns The logger or `undefined` if it is not set. - **/ - public get logger() { - return this._config.logger; + this.featuresCache = cache( + this._config.refetchInterval, + this._config.staleWarningInterval, + this.logger, + async () => { + const res = await this.get( + "features", + this._config.featuresFetchRetries, + ); + + if (!isObject(res) || !Array.isArray(res?.features)) { + return undefined; + } + + return res; + }, + ); } /** @@ -371,10 +388,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, @@ -417,10 +432,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, @@ -463,7 +476,7 @@ export class BucketClient { return; } - await this._config.batchBuffer.add({ + await this.batchBuffer.add({ type: "event", event, companyId: options?.companyId, @@ -499,7 +512,23 @@ export class BucketClient { return; } - await this._config.batchBuffer.flush(); + await this.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.featuresCache.get()?.features || []; + return features.map((f) => ({ + key: f.key, + description: f.description, + flag: f.targeting, + config: f.config, + })); } /** @@ -635,15 +664,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, ); @@ -651,10 +681,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; } } @@ -675,18 +702,18 @@ export class BucketClient { const url = this.buildUrl(path); return await withRetry( async () => { - const response = await this._config.httpClient.get< + const response = await this.httpClient.get< TResponse & { success: boolean } >(url, this._config.headers, this._config.fetchTimeoutMs); - 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, ); @@ -700,7 +727,7 @@ export class BucketClient { 10000, ); } catch (error) { - this._config.logger?.error( + this.logger.error( `get request to "${path}" failed with error after ${retries} retries`, error, ); @@ -796,7 +823,7 @@ export class BucketClient { } if ( - !this._config.rateLimiter.isAllowed( + !this.rateLimiter.isAllowed( hashObject({ action: event.action, key: event.key, @@ -809,7 +836,7 @@ export class BucketClient { return; } - await this._config.batchBuffer.add({ + await this.batchBuffer.add({ type: "feature-flag-event", action: event.action, key: event.key, @@ -834,9 +861,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; } @@ -867,35 +892,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", - this._config.featuresFetchRetries, - ); - - 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. * @@ -917,7 +913,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, @@ -930,7 +926,7 @@ export class BucketClient { if ( config?.missingContextFields?.length && - this._config.rateLimiter.isAllowed( + this.rateLimiter.isAllowed( hashObject({ featureKey: feature.key, configKey: config.key, @@ -948,7 +944,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, ); @@ -966,9 +962,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 || {}; @@ -1072,7 +1068,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, }); } @@ -1142,7 +1138,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, ); @@ -1163,7 +1159,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, ); @@ -1174,7 +1170,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; } @@ -1183,7 +1179,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"); } }, }; @@ -1237,7 +1233,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/src/index.ts b/packages/node-sdk/src/index.ts index 5cd5ec16..11de1c16 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -8,6 +8,7 @@ export type { EmptyFeatureRemoteConfig, Feature, FeatureConfigVariant, + FeatureDefinition, FeatureOverride, FeatureOverrides, FeatureOverridesFn, diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index a5bc7c58..f6d4f2b6 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. + */ + flag: { + /** + * 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. * @@ -262,6 +312,11 @@ export type FeatureAPIResponse = { */ key: string; + /** + * Description of the feature. + */ + description: string | null; + /** * The targeting rules for the feature. */ diff --git a/packages/node-sdk/test/client.test.ts b/packages/node-sdk/test/client.test.ts index aafd26a3..36873fcb 100644 --- a/packages/node-sdk/test/client.test.ts +++ b/packages/node-sdk/test/client.test.ts @@ -104,6 +104,7 @@ const featureDefinitions: FeaturesAPIResponse = { features: [ { key: "feature1", + description: "Feature 1", targeting: { version: 1, rules: [ @@ -135,6 +136,7 @@ const featureDefinitions: FeaturesAPIResponse = { }, { key: "feature2", + description: "Feature 2", targeting: { version: 2, rules: [ @@ -263,10 +265,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, }); @@ -283,7 +285,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"); @@ -313,10 +315,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, }); @@ -374,7 +376,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, ); @@ -445,7 +447,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 () => { @@ -566,7 +568,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 @@ -651,7 +653,7 @@ describe("BucketClient", () => { const client = new BucketClient(validOptions); beforeEach(() => { - client["_config"].rateLimiter.clear(true); + client["rateLimiter"].clear(true); }); test.each([ @@ -745,7 +747,7 @@ describe("BucketClient", () => { const client = new BucketClient(validOptions); beforeEach(() => { - client["_config"].rateLimiter.clear(true); + client["rateLimiter"].clear(true); }); test.each([ @@ -943,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["_config"].featuresCache).toBeUndefined(); - await client.initialize(); - expect(client["_config"].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( @@ -1411,7 +1411,7 @@ describe("BucketClient", () => { }, ); - client["_config"].rateLimiter.clear(true); + client["rateLimiter"].clear(true); httpClient.post.mockResolvedValue({ ok: true, @@ -1595,7 +1595,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 }); @@ -2449,7 +2449,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", () => {