From 4f6ccb61132e80045c1d998c448742462d7e316e Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Fri, 10 Jan 2025 15:43:54 +0000 Subject: [PATCH 01/37] Add support for feature-specific config payloads This update introduces a new `config` property for features, allowing optional, user-defined configuration payloads. The change includes implementation within SDKs, unit tests, and updates to version numbers. It maintains backward compatibility while enabling new configuration flexibility. --- packages/browser-sdk/package.json | 2 +- packages/browser-sdk/src/client.ts | 49 +++--- .../browser-sdk/src/feature/featureCache.ts | 11 +- packages/browser-sdk/src/feature/features.ts | 8 + packages/browser-sdk/test/features.test.ts | 9 +- packages/browser-sdk/test/mocks/handlers.ts | 18 +- packages/browser-sdk/test/usage.test.ts | 155 +++++++++++------- packages/node-sdk/src/types.ts | 5 + packages/react-sdk/package.json | 4 +- packages/react-sdk/src/index.tsx | 26 ++- packages/react-sdk/test/usage.test.tsx | 23 +++ yarn.lock | 4 +- 12 files changed, 206 insertions(+), 108 deletions(-) diff --git a/packages/browser-sdk/package.json b/packages/browser-sdk/package.json index bf9bcc07f..1159884b4 100644 --- a/packages/browser-sdk/package.json +++ b/packages/browser-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@bucketco/browser-sdk", - "version": "2.5.0", + "version": "2.6.0", "packageManager": "yarn@4.1.1", "license": "MIT", "repository": { diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index 5048a9120..a5fc83911 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -94,6 +94,7 @@ const defaultConfig: Config = { export interface Feature { isEnabled: boolean; + config: any; track: () => Promise; requestFeedback: ( options: Omit, @@ -232,7 +233,7 @@ export class BucketClient { * Performs a shallow merge with the existing company context. * Attempting to update the company ID will log a warning and be ignored. * - * @param company + * @param company The company details. */ async updateCompany(company: { [key: string]: string | number | undefined }) { if (company.id && company.id !== this.context.company?.id) { @@ -255,7 +256,7 @@ export class BucketClient { * Performs a shallow merge with the existing company context. * Updates to the company ID will be ignored. * - * @param company + * @param otherContext Additional context. */ async updateOtherContext(otherContext: { [key: string]: string | number | undefined; @@ -273,8 +274,7 @@ export class BucketClient { * * Calling `client.stop()` will remove all listeners added here. * - * @param callback this will be called when the features are updated. - * @param options passed as-is to addEventListener + * @param cb The callback to call when the update completes. */ onFeaturesUpdated(cb: () => void) { return this.featuresClient.onUpdated(cb); @@ -283,8 +283,8 @@ export class BucketClient { /** * Track an event in Bucket. * - * @param eventName The name of the event - * @param attributes Any attributes you want to attach to the event + * @param eventName The name of the event. + * @param attributes Any attributes you want to attach to the event. */ async track(eventName: string, attributes?: Record | null) { if (!this.context.user) { @@ -312,8 +312,8 @@ export class BucketClient { /** * Submit user feedback to Bucket. Must include either `score` or `comment`, or both. * - * @returns - * @param payload + * @param payload The feedback details to submit. + * @returns The server response. */ async feedback(payload: Feedback) { const userId = @@ -407,35 +407,44 @@ export class BucketClient { * Returns a map of enabled features. * Accessing a feature will *not* send a check event * - * @returns Map of features + * @returns Map of features. */ getFeatures(): RawFeatures { return this.featuresClient.getFeatures(); } /** - * Return a feature. Accessing `isEnabled` will automatically send a `check` event. - * @returns A feature + * Return a feature. Accessing `isEnabled` or `config` will automatically send a `check` event. + * @returns A feature. */ getFeature(key: string): Feature { const f = this.getFeatures()[key]; const fClient = this.featuresClient; const value = f?.isEnabled ?? false; + const config = f?.config?.payload; + + function sendCheckEvent() { + fClient + .sendCheckEvent({ + key: key, + version: f?.targetingVersion, + value, + }) + .catch(() => { + // ignore + }); + } return { get isEnabled() { - fClient - .sendCheckEvent({ - key: key, - version: f?.targetingVersion, - value, - }) - .catch(() => { - // ignore - }); + sendCheckEvent(); return value; }, + get config() { + sendCheckEvent(); + return config; + }, track: () => this.track(key), requestFeedback: ( options: Omit, diff --git a/packages/browser-sdk/src/feature/featureCache.ts b/packages/browser-sdk/src/feature/featureCache.ts index 1a66c4412..7be356111 100644 --- a/packages/browser-sdk/src/feature/featureCache.ts +++ b/packages/browser-sdk/src/feature/featureCache.ts @@ -22,19 +22,24 @@ export function parseAPIFeaturesResponse( const features: RawFeatures = {}; for (const key in featuresInput) { const feature = featuresInput[key]; + if ( typeof feature.isEnabled !== "boolean" || feature.key !== key || - typeof feature.targetingVersion !== "number" + typeof feature.targetingVersion !== "number" || + (feature.config && typeof feature.config !== "object") ) { return; } + features[key] = { isEnabled: feature.isEnabled, targetingVersion: feature.targetingVersion, key, + config: feature.config, }; } + return features; } @@ -45,8 +50,8 @@ export interface CacheResult { export class FeatureCache { private storage: StorageItem; - private staleTimeMs: number; - private expireTimeMs: number; + private readonly staleTimeMs: number; + private readonly expireTimeMs: number; constructor({ storage, diff --git a/packages/browser-sdk/src/feature/features.ts b/packages/browser-sdk/src/feature/features.ts index b2d0c2fcf..4d4477b59 100644 --- a/packages/browser-sdk/src/feature/features.ts +++ b/packages/browser-sdk/src/feature/features.ts @@ -13,6 +13,11 @@ export type RawFeature = { key: string; isEnabled: boolean; targetingVersion?: number; + config?: { + name: string | null; + version: number; + payload: any; + }; }; const FEATURES_UPDATED_EVENT = "features-updated"; @@ -53,7 +58,9 @@ export function validateFeaturesResponse(response: any) { if (typeof response.success !== "boolean" || !isObject(response.features)) { return; } + const features = parseAPIFeaturesResponse(response.features); + if (!features) { return; } @@ -198,6 +205,7 @@ export class FeaturesClient { JSON.stringify(errorBody), ); } + const typeRes = validateFeaturesResponse(await res.json()); if (!typeRes || !typeRes.success) { throw new Error("unable to validate response"); diff --git a/packages/browser-sdk/test/features.test.ts b/packages/browser-sdk/test/features.test.ts index 6a8ae8237..19be98127 100644 --- a/packages/browser-sdk/test/features.test.ts +++ b/packages/browser-sdk/test/features.test.ts @@ -27,8 +27,10 @@ function featuresClientFactory() { const httpClient = new HttpClient("pk", { baseUrl: "https://front.bucket.co", }); + vi.spyOn(httpClient, "get"); vi.spyOn(httpClient, "post"); + return { cache, httpClient, @@ -54,7 +56,7 @@ function featuresClientFactory() { }; } -describe("FeaturesClient unit tests", () => { +describe("FeaturesClient", () => { test("fetches features", async () => { const { newFeaturesClient, httpClient } = featuresClientFactory(); const featuresClient = newFeaturesClient(); @@ -69,8 +71,9 @@ describe("FeaturesClient unit tests", () => { expect(updated).toBe(true); expect(httpClient.get).toBeCalledTimes(1); - const calls = vi.mocked(httpClient.get).mock.calls.at(0); - const { params, path, timeoutMs } = calls![0]; + + const calls = vi.mocked(httpClient.get).mock.calls.at(0)!; + const { params, path, timeoutMs } = calls[0]; const paramsObj = Object.fromEntries(new URLSearchParams(params)); expect(paramsObj).toEqual({ diff --git a/packages/browser-sdk/test/mocks/handlers.ts b/packages/browser-sdk/test/mocks/handlers.ts index 0c575745f..1e34e8c5d 100644 --- a/packages/browser-sdk/test/mocks/handlers.ts +++ b/packages/browser-sdk/test/mocks/handlers.ts @@ -9,16 +9,20 @@ export const featureResponse: FeaturesResponse = { success: true, features: { featureA: { isEnabled: true, key: "featureA", targetingVersion: 1 }, + featureB: { + isEnabled: true, + targetingVersion: 11, + key: "featureB", + config: { + version: 12, + name: "gpt3", + payload: { model: "gpt-something", temperature: 0.5 }, + }, + }, }, }; -export const featuresResult: Features = { - featureA: { - isEnabled: true, - key: "featureA", - targetingVersion: 1, - }, -}; +export const featuresResult: Features = featureResponse.features; function checkRequest(request: StrictRequest) { const url = new URL(request.url); diff --git a/packages/browser-sdk/test/usage.test.ts b/packages/browser-sdk/test/usage.test.ts index 905d27f8f..64986ce1f 100644 --- a/packages/browser-sdk/test/usage.test.ts +++ b/packages/browser-sdk/test/usage.test.ts @@ -79,6 +79,7 @@ describe("usage", () => { isEnabled: false, track: expect.any(Function), requestFeedback: expect.any(Function), + config: undefined, }); }); @@ -393,82 +394,114 @@ describe(`sends "check" events `, () => { ).toHaveBeenCalledTimes(0); }); - it(`getFeature() sends check event when accessing "isEnabled"`, async () => { - vi.spyOn(FeaturesClient.prototype, "sendCheckEvent"); - vi.spyOn(HttpClient.prototype, "post"); - - const client = new BucketClient({ - publishableKey: KEY, - user: { id: "uid" }, - company: { id: "cid" }, + describe("getFeature", async () => { + afterEach(() => { + vi.clearAllMocks(); }); - await client.initialize(); - const featureA = client.getFeature("featureA"); + it(`sends check event when accessing "isEnabled"`, async () => { + const sendCheckEventSpy = vi.spyOn( + FeaturesClient.prototype, + "sendCheckEvent", + ); + const postSpy = vi.spyOn(HttpClient.prototype, "post"); + + const client = new BucketClient({ + publishableKey: KEY, + user: { id: "uid" }, + company: { id: "cid" }, + }); + await client.initialize(); - expect( - vi.mocked(FeaturesClient.prototype.sendCheckEvent), - ).toHaveBeenCalledTimes(0); - expect(featureA.isEnabled).toBe(true); + const featureA = client.getFeature("featureA"); - expect( - vi.mocked(FeaturesClient.prototype.sendCheckEvent), - ).toHaveBeenCalledTimes(1); - expect( - vi.mocked(FeaturesClient.prototype.sendCheckEvent), - ).toHaveBeenCalledWith({ - key: "featureA", - value: true, - version: 1, - }); + expect(sendCheckEventSpy).toHaveBeenCalledTimes(0); + expect(featureA.isEnabled).toBe(true); + expect(sendCheckEventSpy).toHaveBeenCalledTimes(1); + expect(sendCheckEventSpy).toHaveBeenCalledWith({ + key: "featureA", + value: true, + version: 1, + }); - expect(vi.mocked(HttpClient.prototype.post)).toHaveBeenCalledWith({ - body: { - action: "check", - evalContext: { - company: { - id: "cid", - }, - other: undefined, - user: { - id: "uid", + expect(postSpy).toHaveBeenCalledWith({ + body: { + action: "check", + evalContext: { + company: { + id: "cid", + }, + other: undefined, + user: { + id: "uid", + }, }, + evalResult: true, + key: "featureA", + targetingVersion: 1, }, - evalResult: true, - key: "featureA", - targetingVersion: 1, - }, - path: "features/events", + path: "features/events", + }); }); - }); - it("sends check event for not-enabled features", async () => { - // disabled features don't appear in the API response - vi.spyOn(FeaturesClient.prototype, "sendCheckEvent"); + it(`sends check event when accessing "config"`, async () => { + const postSpy = vi.spyOn(HttpClient.prototype, "post"); - const client = new BucketClient({ publishableKey: KEY }); - await client.initialize(); + const client = new BucketClient({ + publishableKey: KEY, + user: { id: "uid" }, + }); - const nonExistentFeature = client.getFeature("non-existent"); + await client.initialize(); + const featureB = client.getFeature("featureB"); + expect(featureB.config).toEqual({ + model: "gpt-something", + temperature: 0.5, + }); - expect( - vi.mocked(FeaturesClient.prototype.sendCheckEvent), - ).toHaveBeenCalledTimes(0); - expect(nonExistentFeature.isEnabled).toBe(false); + expect(postSpy).toHaveBeenCalledWith({ + body: { + action: "check", + evalContext: { + other: undefined, + user: { + id: "uid", + }, + }, + evalResult: true, + key: "featureB", + targetingVersion: 11, + }, + path: "features/events", + }); + }); - expect( - vi.mocked(FeaturesClient.prototype.sendCheckEvent), - ).toHaveBeenCalledTimes(1); - expect( - vi.mocked(FeaturesClient.prototype.sendCheckEvent), - ).toHaveBeenCalledWith({ - value: false, - key: "non-existent", - version: undefined, + it("sends check event for not-enabled features", async () => { + // disabled features don't appear in the API response + vi.spyOn(FeaturesClient.prototype, "sendCheckEvent"); + + const client = new BucketClient({ publishableKey: KEY }); + await client.initialize(); + + const nonExistentFeature = client.getFeature("non-existent"); + + expect( + vi.mocked(FeaturesClient.prototype.sendCheckEvent), + ).toHaveBeenCalledTimes(0); + expect(nonExistentFeature.isEnabled).toBe(false); + + expect( + vi.mocked(FeaturesClient.prototype.sendCheckEvent), + ).toHaveBeenCalledTimes(1); + expect( + vi.mocked(FeaturesClient.prototype.sendCheckEvent), + ).toHaveBeenCalledWith({ + value: false, + key: "non-existent", + version: undefined, + }); }); - }); - describe("getFeature", async () => { it("calls client.track with the featureId", async () => { const client = new BucketClient({ publishableKey: KEY }); await client.initialize(); diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index e31879e8f..7a6eb5869 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -94,6 +94,11 @@ export interface Feature { */ isEnabled: boolean; + /** + * Optional user-defined configuration if the feature is enabled. + */ + config: any; + /** * Track feature usage in Bucket. */ diff --git a/packages/react-sdk/package.json b/packages/react-sdk/package.json index 0d5a01331..20af4cab4 100644 --- a/packages/react-sdk/package.json +++ b/packages/react-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@bucketco/react-sdk", - "version": "2.5.1", + "version": "2.6.0", "license": "MIT", "repository": { "type": "git", @@ -34,7 +34,7 @@ } }, "dependencies": { - "@bucketco/browser-sdk": "2.5.0", + "@bucketco/browser-sdk": "2.6.0", "canonical-json": "^0.0.4" }, "peerDependencies": { diff --git a/packages/react-sdk/src/index.tsx b/packages/react-sdk/src/index.tsx index 0d7e5adcf..33193802f 100644 --- a/packages/react-sdk/src/index.tsx +++ b/packages/react-sdk/src/index.tsx @@ -198,22 +198,30 @@ export function useFeature(key: FeatureKey) { const feature = features[key]; const enabled = feature?.isEnabled ?? false; + function sendCheckEvent() { + client + ?.sendCheckEvent({ + key, + value: enabled, + version: feature?.targetingVersion, + }) + .catch(() => { + // ignore + }); + } + return { isLoading, track, requestFeedback, get isEnabled() { - client - ?.sendCheckEvent({ - key, - value: enabled, - version: feature?.targetingVersion, - }) - .catch(() => { - // ignore - }); + sendCheckEvent(); return enabled; }, + get config() { + sendCheckEvent(); + return feature?.config?.payload; + }, }; } diff --git a/packages/react-sdk/test/usage.test.tsx b/packages/react-sdk/test/usage.test.tsx index 317a0bcb7..c78cb6394 100644 --- a/packages/react-sdk/test/usage.test.tsx +++ b/packages/react-sdk/test/usage.test.tsx @@ -81,6 +81,11 @@ const server = setupServer( key: "abc", isEnabled: true, targetingVersion: 1, + config: { + name: "gpt3", + payload: { model: "gpt-something", temperature: 0.5 }, + version: 2, + }, }, def: { key: "def", @@ -242,6 +247,24 @@ describe("useFeature", () => { unmount(); }); + + test("provides the expected values if feature is enabled", async () => { + const { result, unmount } = renderHook(() => useFeature("abc"), { + wrapper: ({ children }) => getProvider({ children }), + }); + + await waitFor(() => { + expect(result.current).toStrictEqual({ + isEnabled: true, + isLoading: false, + config: { model: "gpt-something", temperature: 0.5 }, + track: expect.any(Function), + requestFeedback: expect.any(Function), + }); + }); + + unmount(); + }); }); describe("useTrack", () => { diff --git a/yarn.lock b/yarn.lock index 4d2264ef7..3c9764b38 100644 --- a/yarn.lock +++ b/yarn.lock @@ -894,7 +894,7 @@ __metadata: languageName: node linkType: hard -"@bucketco/browser-sdk@npm:2.5.0, @bucketco/browser-sdk@workspace:packages/browser-sdk": +"@bucketco/browser-sdk@npm:2.6.0, @bucketco/browser-sdk@workspace:packages/browser-sdk": version: 0.0.0-use.local resolution: "@bucketco/browser-sdk@workspace:packages/browser-sdk" dependencies: @@ -1030,7 +1030,7 @@ __metadata: version: 0.0.0-use.local resolution: "@bucketco/react-sdk@workspace:packages/react-sdk" dependencies: - "@bucketco/browser-sdk": "npm:2.5.0" + "@bucketco/browser-sdk": "npm:2.6.0" "@bucketco/eslint-config": "workspace:^" "@bucketco/tsconfig": "workspace:^" "@testing-library/react": "npm:^15.0.7" From 7b3a4af7f261266f91b2f2af8ffc8f8f4f9046d0 Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Mon, 13 Jan 2025 15:21:51 +0000 Subject: [PATCH 02/37] Refactor flag evaluation logic and update SDK version. Simplified flag evaluation by introducing a generic `resolveFeature` method for type consistency and streamlined logic. Added extensive test cases for different flag types and scenarios. Updated `@bucketco/browser-sdk` dependency to version 2.6.0 for compatibility. --- .../openfeature-browser-provider/package.json | 2 +- .../src/index.test.ts | 160 +++++++++++++++++- .../openfeature-browser-provider/src/index.ts | 93 ++++++---- packages/react-sdk/test/usage.test.tsx | 1 + yarn.lock | 14 +- 5 files changed, 217 insertions(+), 53 deletions(-) diff --git a/packages/openfeature-browser-provider/package.json b/packages/openfeature-browser-provider/package.json index 6338dd258..0707a7062 100644 --- a/packages/openfeature-browser-provider/package.json +++ b/packages/openfeature-browser-provider/package.json @@ -35,7 +35,7 @@ } }, "dependencies": { - "@bucketco/browser-sdk": "2.4.0" + "@bucketco/browser-sdk": "2.6.0" }, "devDependencies": { "@bucketco/eslint-config": "0.0.2", diff --git a/packages/openfeature-browser-provider/src/index.test.ts b/packages/openfeature-browser-provider/src/index.test.ts index 54b4e7005..7a2a6e790 100644 --- a/packages/openfeature-browser-provider/src/index.test.ts +++ b/packages/openfeature-browser-provider/src/index.test.ts @@ -90,22 +90,174 @@ describe("BucketBrowserSDKProvider", () => { }); describe("resolveBooleanEvaluation", () => { - it("calls the client correctly for boolean evaluation", async () => { + function mockFeature(enabled: boolean, config: any) { bucketClientMock.getFeature = vi.fn().mockReturnValue({ - isEnabled: true, + isEnabled: enabled, + config: config, }); + bucketClientMock.getFeatures = vi.fn().mockReturnValue({ [testFlagKey]: { - isEnabled: true, + isEnabled: enabled, + config: { + name: "test", + version: 1, + payload: config, + }, targetingVersion: 1, }, }); + } + + it("calls the client correctly when evaluating", async () => { + mockFeature(true, true); await provider.initialize(); - ofClient.getBooleanDetails(testFlagKey, false); + const val = ofClient.getBooleanDetails(testFlagKey, false); + + expect(val).toBeDefined(); + expect(bucketClientMock.getFeatures).toHaveBeenCalled(); expect(bucketClientMock.getFeature).toHaveBeenCalledWith(testFlagKey); }); + + it.each([ + [true, true, false, true, "TARGETING_MATCH"], + [true, false, false, true, "TARGETING_MATCH"], + [true, null, false, true, "TARGETING_MATCH"], + [true, { obj: true }, false, true, "TARGETING_MATCH"], + [true, 15, false, true, "TARGETING_MATCH"], + [false, true, false, false, "DISABLED"], + [false, true, true, true, "DISABLED"], + ])( + "should return the correct result when evaluating boolean %s, %s, %s, %s, %s`", + async (enabled, config, def, expected, reason) => { + mockFeature(enabled, config); + expect(ofClient.getBooleanDetails(testFlagKey, def)).toEqual({ + flagKey: "a-key", + flagMetadata: {}, + reason: reason, + value: expected, + }); + }, + ); + + it.each([ + [true, 1, -1, 1, "TARGETING_MATCH"], + [true, null, -2, -2, "DEFAULT"], + [false, 3, -3, -3, "DISABLED"], + [false, 4, -4, -4, "DISABLED"], + ])( + "should return the correct result when evaluating number %s, %s, %s, %s, %s`", + async (enabled, config, def, expected, reason) => { + mockFeature(enabled, config); + expect(ofClient.getNumberDetails(testFlagKey, def)).toEqual({ + flagKey: "a-key", + flagMetadata: {}, + reason: reason, + value: expected, + }); + }, + ); + + it.each([["string"], [true], [{}]])( + "should handle type mismatch when evaluating number as %s`", + async (config) => { + mockFeature(true, config); + expect(ofClient.getNumberDetails(testFlagKey, -1)).toEqual({ + flagKey: "a-key", + flagMetadata: {}, + reason: "ERROR", + errorCode: "TYPE_MISMATCH", + errorMessage: "", + value: -1, + }); + }, + ); + + it.each([ + [true, "1", "-1", "1", "TARGETING_MATCH"], + [true, null, "-2", "-2", "DEFAULT"], + [false, "2", "-3", "-3", "DISABLED"], + [false, "3", "-4", "-4", "DISABLED"], + ])( + "should return the correct result when evaluating string %s, %s, %s, %s, %s`", + async (enabled, config, def, expected, reason) => { + mockFeature(enabled, config); + expect(ofClient.getStringDetails(testFlagKey, def)).toEqual({ + flagKey: "a-key", + flagMetadata: {}, + reason: reason, + value: expected, + }); + }, + ); + + it.each([[15], [true], [{}]])( + "should handle type mismatch when evaluating string as %s`", + async (config) => { + mockFeature(true, config); + expect(ofClient.getStringDetails(testFlagKey, "hello")).toEqual({ + flagKey: "a-key", + flagMetadata: {}, + reason: "ERROR", + errorCode: "TYPE_MISMATCH", + errorMessage: "", + value: "hello", + }); + }, + ); + + it.each([ + [true, [], [1], [], "TARGETING_MATCH"], + [true, null, [2], [2], "DEFAULT"], + [false, [3], [4], [4], "DISABLED"], + [false, [5], [6], [6], "DISABLED"], + ])( + "should return the correct result when evaluating array %s, %s, %s, %s, %s`", + async (enabled, config, def, expected, reason) => { + mockFeature(enabled, config); + expect(ofClient.getObjectDetails(testFlagKey, def)).toEqual({ + flagKey: "a-key", + flagMetadata: {}, + reason: reason, + value: expected, + }); + }, + ); + + it.each([ + [true, {}, { a: 1 }, {}, "TARGETING_MATCH"], + [true, null, { a: 2 }, { a: 2 }, "DEFAULT"], + [false, { a: 3 }, { a: 4 }, { a: 4 }, "DISABLED"], + [false, { a: 5 }, { a: 6 }, { a: 6 }, "DISABLED"], + ])( + "should return the correct result when evaluating object %s, %s, %s, %s, %s`", + async (enabled, config, def, expected, reason) => { + mockFeature(enabled, config); + expect(ofClient.getObjectDetails(testFlagKey, def)).toEqual({ + flagKey: "a-key", + flagMetadata: {}, + reason: reason, + value: expected, + }); + }, + ); + + it.each([["string"], [15], [true]])( + "should handle type mismatch when evaluating object as %s`", + async (config) => { + mockFeature(true, config); + expect(ofClient.getObjectDetails(testFlagKey, { obj: true })).toEqual({ + flagKey: "a-key", + flagMetadata: {}, + reason: "ERROR", + errorCode: "TYPE_MISMATCH", + errorMessage: "", + value: { obj: true }, + }); + }, + ); }); describe("track", () => { diff --git a/packages/openfeature-browser-provider/src/index.ts b/packages/openfeature-browser-provider/src/index.ts index 48894ecb7..995ab7ef1 100644 --- a/packages/openfeature-browser-provider/src/index.ts +++ b/packages/openfeature-browser-provider/src/index.ts @@ -44,8 +44,8 @@ export class BucketBrowserSDKProvider implements Provider { private _client?: BucketClient; - private _clientOptions: InitOptions; - private _contextTranslator: ContextTranslationFn; + private readonly _clientOptions: InitOptions; + private readonly _contextTranslator: ContextTranslationFn; public events = new OpenFeatureEventEmitter(); @@ -100,66 +100,89 @@ export class BucketBrowserSDKProvider implements Provider { await this.initialize(newContext); } - resolveBooleanEvaluation( + private resolveFeature( flagKey: string, - defaultValue: boolean, - ): ResolutionDetails { - if (!this._client) + defaultValue: T, + ): ResolutionDetails { + const expType = typeof defaultValue; + + if (!this._client) { return { value: defaultValue, reason: StandardResolutionReasons.DEFAULT, errorCode: ErrorCode.PROVIDER_NOT_READY, - } satisfies ResolutionDetails; + errorMessage: "Bucket client not initialized", + } satisfies ResolutionDetails; + } const features = this._client.getFeatures(); if (flagKey in features) { const feature = this._client.getFeature(flagKey); + + if (!feature.isEnabled) { + return { + value: defaultValue, + reason: StandardResolutionReasons.DISABLED, + }; + } + + if (expType === "boolean") { + return { + value: true as T, + reason: StandardResolutionReasons.TARGETING_MATCH, + }; + } + + if (feature.config === null || feature.config === undefined) { + return { + value: defaultValue, + reason: StandardResolutionReasons.DEFAULT, + }; + } + + if (typeof feature.config !== expType) { + return { + value: defaultValue, + reason: StandardResolutionReasons.ERROR, + errorCode: ErrorCode.TYPE_MISMATCH, + errorMessage: `Expected ${expType} but got ${typeof feature.config}`, + }; + } + return { - value: feature.isEnabled, + value: feature.config as T, reason: StandardResolutionReasons.TARGETING_MATCH, - } satisfies ResolutionDetails; + }; } return { value: defaultValue, reason: StandardResolutionReasons.DEFAULT, - } satisfies ResolutionDetails; + errorCode: ErrorCode.FLAG_NOT_FOUND, + errorMessage: `Flag ${flagKey} not found`, + }; } - resolveNumberEvaluation( - _flagKey: string, - defaultValue: number, - ): ResolutionDetails { - return { - value: defaultValue, - errorCode: ErrorCode.TYPE_MISMATCH, - reason: StandardResolutionReasons.ERROR, - errorMessage: "Bucket doesn't support number flags", - }; + resolveBooleanEvaluation(flagKey: string, defaultValue: boolean) { + return this.resolveFeature(flagKey, defaultValue); + } + + resolveNumberEvaluation(flagKey: string, defaultValue: number) { + return this.resolveFeature(flagKey, defaultValue); } resolveObjectEvaluation( - _flagKey: string, + flagKey: string, defaultValue: T, - ): ResolutionDetails { - return { - value: defaultValue, - errorCode: ErrorCode.TYPE_MISMATCH, - reason: StandardResolutionReasons.ERROR, - errorMessage: "Bucket doesn't support object flags", - }; + ) { + return this.resolveFeature(flagKey, defaultValue); } resolveStringEvaluation( - _flagKey: string, + flagKey: string, defaultValue: string, ): ResolutionDetails { - return { - value: defaultValue, - errorCode: ErrorCode.TYPE_MISMATCH, - reason: StandardResolutionReasons.ERROR, - errorMessage: "Bucket doesn't support string flags", - }; + return this.resolveFeature(flagKey, defaultValue); } track( diff --git a/packages/react-sdk/test/usage.test.tsx b/packages/react-sdk/test/usage.test.tsx index c78cb6394..535f0075f 100644 --- a/packages/react-sdk/test/usage.test.tsx +++ b/packages/react-sdk/test/usage.test.tsx @@ -238,6 +238,7 @@ describe("useFeature", () => { await waitFor(() => { expect(result.current).toStrictEqual({ + config: undefined, isEnabled: false, isLoading: false, track: expect.any(Function), diff --git a/yarn.lock b/yarn.lock index 3c9764b38..ee25e38d2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -882,18 +882,6 @@ __metadata: languageName: node linkType: hard -"@bucketco/browser-sdk@npm:2.4.0": - version: 2.4.0 - resolution: "@bucketco/browser-sdk@npm:2.4.0" - dependencies: - "@floating-ui/dom": "npm:^1.6.8" - canonical-json: "npm:^0.0.4" - js-cookie: "npm:^3.0.5" - preact: "npm:^10.22.1" - checksum: 10c0/b33a9fdafa4a857ac4f815fe69b602b37527a37d54270abd479b754da998d030f5a70b738c662ab57fa4f6374b8e1fbd052feb8bdbd8b78367086dcedc5a5432 - languageName: node - linkType: hard - "@bucketco/browser-sdk@npm:2.6.0, @bucketco/browser-sdk@workspace:packages/browser-sdk": version: 0.0.0-use.local resolution: "@bucketco/browser-sdk@workspace:packages/browser-sdk" @@ -984,7 +972,7 @@ __metadata: version: 0.0.0-use.local resolution: "@bucketco/openfeature-browser-provider@workspace:packages/openfeature-browser-provider" dependencies: - "@bucketco/browser-sdk": "npm:2.4.0" + "@bucketco/browser-sdk": "npm:2.6.0" "@bucketco/eslint-config": "npm:0.0.2" "@bucketco/tsconfig": "npm:0.0.2" "@openfeature/core": "npm:1.5.0" From 70f23932709b0959af08bd10ab24d5c6201a9717 Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Mon, 13 Jan 2025 16:34:23 +0000 Subject: [PATCH 03/37] Add feature configuration support and enhance fallback features Introduced support for feature-specific configurations, enabling dynamic payloads for features based on user-defined rules. Enhanced fallback features to accept both string arrays and object records, providing more flexibility for feature initialization. Updated documentation and tests to reflect the new configuration capabilities and backwards-compatible fallback behavior. --- packages/browser-sdk/README.md | 47 +++++++++++++++++-- packages/browser-sdk/src/client.ts | 12 ++--- packages/browser-sdk/src/feature/features.ts | 38 +++++++++++---- packages/browser-sdk/test/features.test.ts | 26 +++++++++- packages/node-sdk/src/client.ts | 3 ++ packages/node-sdk/test/client.test.ts | 4 ++ .../openfeature-browser-provider/README.md | 5 +- packages/react-sdk/README.md | 10 ++-- packages/react-sdk/src/index.tsx | 23 +++++---- 9 files changed, 133 insertions(+), 35 deletions(-) diff --git a/packages/browser-sdk/README.md b/packages/browser-sdk/README.md index 5b9082636..d74891400 100644 --- a/packages/browser-sdk/README.md +++ b/packages/browser-sdk/README.md @@ -27,19 +27,24 @@ const bucketClient = new BucketClient({ publishableKey, user, company }); await bucketClient.initialize(); -const { isEnabled, track, requestFeedback } = bucketClient.getFeature("huddle"); +const { isEnabled, config, track, requestFeedback } = bucketClient.getFeature("huddle"); if (isEnabled) { - // show feature. When retrieving `isEnabled` the client automatically + // Show feature. When retrieving `isEnabled` the client automatically // sends a "check" event for the "huddle" feature which is shown in the // Bucket UI. // On usage, call `track` to let Bucket know that a user interacted with the feature track(); + // The `config` is a user-supplied value in Bucket that can be dynamically evaluated + // with respect to the current context. Here, it is assumed that one could either get + // a config value that maches the context or not. + const question = config?.question ?? "Tell us what you think of Huddles" + // Use `requestFeedback` to create "Send feedback" buttons easily for specific // features. This is not related to `track` and you can call them individually. - requestFeedback({ title: "Tell us what you think of Huddles" }); + requestFeedback({ title: question }); } // `track` just calls `bucketClient.track()` to send an event using the same feature key @@ -127,6 +132,7 @@ To retrieve features along with their targeting information, use `getFeature(key const huddle = bucketClient.getFeature("huddle"); // { // isEnabled: true, +// config: any, // track: () => Promise // requestFeedback: (options: RequestFeedbackData) => void // } @@ -140,6 +146,7 @@ const features = bucketClient.getFeatures(); // huddle: { // isEnabled: true, // targetingVersion: 42, +// config: ... // } // } ``` @@ -148,7 +155,39 @@ const features = bucketClient.getFeatures(); by down-stream clients, like the React SDK. Note that accessing `isEnabled` on the object returned by `getFeatures` does not automatically -generate a `check` event, contrary to the `isEnabled` property on the object return from `getFeature`. +generate a `check` event, contrary to the `isEnabled` property on the object returned by `getFeature`. + +### Feature toggles + +Similar to `isEnabled`, each feature has a `config` property. This configuration is set by the user within Bucket. It is +similar to the way access is controlled, using matching rules. Each config-bound rule is given a configuration payload +(a JSON value) that is returned to the SDKs if the requested context matches that specific rule. It is possible to have +multiple rules with different configuration payloads. Whichever rule matches the context, provides the configuration +payload. + + +The config is accessible through the same methods as the `isEnabled` property: + +```ts +const features = bucketClient.getFeatures(); +// { +// huddle: { +// isEnabled: true, +// targetingVersion: 42, +// config?: { +// name: "gpt-3.5", +// version: 2, +// payload: { maxTokens: 10000, model: "gpt-3.5-beta1" } +// } +// } +// } +``` +The `name` is given by the user in Bucket for each configuration variant, and `version` is maintained by Bucket similar +to `targetingVersion`. The `payload` is the actual JSON value supplied by the user and serves as context-based +configuration. + +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`. ### Tracking feature usage diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index a5fc83911..148727f4f 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -102,16 +102,16 @@ export interface Feature { } export class BucketClient { - private publishableKey: string; - private context: BucketContext; + private readonly publishableKey: string; + private readonly context: BucketContext; private config: Config; private requestFeedbackOptions: Partial; - private logger: Logger; - private httpClient: HttpClient; + private readonly logger: Logger; + private readonly httpClient: HttpClient; - private autoFeedback: AutoFeedback | undefined; + private readonly autoFeedback: AutoFeedback | undefined; private autoFeedbackInit: Promise | undefined; - private featuresClient: FeaturesClient; + private readonly featuresClient: FeaturesClient; constructor(opts: InitOptions) { this.publishableKey = opts.publishableKey; diff --git a/packages/browser-sdk/src/feature/features.ts b/packages/browser-sdk/src/feature/features.ts index 4d4477b59..f3dbf0cb6 100644 --- a/packages/browser-sdk/src/feature/features.ts +++ b/packages/browser-sdk/src/feature/features.ts @@ -25,7 +25,7 @@ const FEATURES_UPDATED_EVENT = "features-updated"; export type RawFeatures = Record; export type FeaturesOptions = { - fallbackFeatures?: string[]; + fallbackFeatures?: string[] | Record; timeoutMs?: number; staleWhileRevalidate?: boolean; staleTimeMs?: number; @@ -33,13 +33,13 @@ export type FeaturesOptions = { }; type Config = { - fallbackFeatures: string[]; + fallbackFeatures: Record; timeoutMs: number; staleWhileRevalidate: boolean; }; export const DEFAULT_FEATURES_CONFIG: Config = { - fallbackFeatures: [], + fallbackFeatures: {}, timeoutMs: 5000, staleWhileRevalidate: false, }; @@ -133,6 +133,20 @@ export class FeaturesClient { staleTimeMs: options?.staleTimeMs ?? 0, expireTimeMs: options?.expireTimeMs ?? FEATURES_EXPIRE_MS, }); + + if (Array.isArray(options?.fallbackFeatures)) { + options = { + ...options, + fallbackFeatures: options.fallbackFeatures.reduce( + (acc, key) => { + acc[key] = null; + return acc; + }, + {} as Record, + ), + }; + } + this.config = { ...DEFAULT_FEATURES_CONFIG, ...options }; this.rateLimiter = options?.rateLimiter ?? @@ -312,12 +326,16 @@ export class FeaturesClient { } // fetch failed, nothing cached => return fallbacks - return this.config.fallbackFeatures.reduce((acc, key) => { - acc[key] = { - key, - isEnabled: true, - }; - return acc; - }, {} as RawFeatures); + return Object.entries(this.config.fallbackFeatures).reduce( + (acc, [key, config]) => { + acc[key] = { + key, + isEnabled: true, + config, + }; + return acc; + }, + {} as RawFeatures, + ); } } diff --git a/packages/browser-sdk/test/features.test.ts b/packages/browser-sdk/test/features.test.ts index 19be98127..2b99fa990 100644 --- a/packages/browser-sdk/test/features.test.ts +++ b/packages/browser-sdk/test/features.test.ts @@ -115,7 +115,7 @@ describe("FeaturesClient", () => { expect(timeoutMs).toEqual(5000); }); - test("return fallback features on failure", async () => { + test("return fallback features on failure (string list)", async () => { const { newFeaturesClient, httpClient } = featuresClientFactory(); vi.mocked(httpClient.get).mockRejectedValue( @@ -124,10 +124,32 @@ describe("FeaturesClient", () => { const featuresClient = newFeaturesClient({ fallbackFeatures: ["huddle"], }); + + await featuresClient.initialize(); + expect(featuresClient.getFeatures()).toStrictEqual({ + huddle: { + isEnabled: true, + config: null, + key: "huddle", + }, + }); + }); + + test("return fallback features on failure (record)", async () => { + const { newFeaturesClient, httpClient } = featuresClientFactory(); + + vi.mocked(httpClient.get).mockRejectedValue( + new Error("Failed to fetch features"), + ); + const featuresClient = newFeaturesClient({ + fallbackFeatures: { huddle: { name: "john" } }, + }); + await featuresClient.initialize(); - expect(featuresClient.getFeatures()).toEqual({ + expect(featuresClient.getFeatures()).toStrictEqual({ huddle: { isEnabled: true, + config: { name: "john" }, key: "huddle", }, }); diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index ec1393e78..8aef92706 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -925,6 +925,9 @@ export class BucketClient { return isEnabled; }, + get config() { + return undefined; + }, key, track: async () => { if (typeof options.user?.id === "undefined") { diff --git a/packages/node-sdk/test/client.test.ts b/packages/node-sdk/test/client.test.ts index ed6fac69d..201d4879a 100644 --- a/packages/node-sdk/test/client.test.ts +++ b/packages/node-sdk/test/client.test.ts @@ -1698,11 +1698,13 @@ describe("BucketClient", () => { key: "feature1", isEnabled: true, track: expect.any(Function), + config: undefined, }, feature2: { key: "feature2", isEnabled: false, track: expect.any(Function), + config: undefined, }, }); @@ -1720,11 +1722,13 @@ describe("BucketClient", () => { key: "feature1", isEnabled: false, track: expect.any(Function), + config: undefined, }, feature2: { key: "feature2", isEnabled: true, track: expect.any(Function), + config: undefined, }, }); }); diff --git a/packages/openfeature-browser-provider/README.md b/packages/openfeature-browser-provider/README.md index 4a15758ba..3725534a1 100644 --- a/packages/openfeature-browser-provider/README.md +++ b/packages/openfeature-browser-provider/README.md @@ -36,9 +36,10 @@ const client = OpenFeature.getClient(); // use client const boolValue = client.getBooleanValue("huddles", false); -``` -Bucket only supports boolean values. +// use more complex, config-enabled functionality. +const feedbackConfig = client.getObjectValue("ask-feedback", { question: "How are you enjoying this feature?" }); +``` Initializing the Bucket Browser Provider will also initialize [automatic feedback surveys](https://github.com/bucketco/bucket-javascript-sdk/tree/main/packages/browser-sdk#qualitative-feedback). diff --git a/packages/react-sdk/README.md b/packages/react-sdk/README.md index 30e5d2039..b48f82bca 100644 --- a/packages/react-sdk/README.md +++ b/packages/react-sdk/README.md @@ -29,6 +29,10 @@ declare module "@bucketco/react-sdk" { interface Features { huddle: boolean; recordVideo: boolean; + questionnaire?: { + showAll: boolean, + time: 600000, + } } } ``` @@ -92,7 +96,7 @@ Returns the state of a given features for the current context. import { useFeature } from "@bucketco/react-sdk"; function StartHuddleButton() { - const { isLoading, isEnabled, track, requestFeedback } = useFeature("huddle"); + const { isLoading, isEnabled, config, track, requestFeedback } = useFeature("huddle"); if (isLoading) { return ; @@ -108,7 +112,7 @@ function StartHuddleButton() { ; + * return ; * } * } * ``` */ -export function useFeature(key: FeatureKey) { +export function useFeature(key: TKey) { const { features: { features, isLoading }, client, @@ -220,7 +227,7 @@ export function useFeature(key: FeatureKey) { }, get config() { sendCheckEvent(); - return feature?.config?.payload; + return feature?.config?.payload as FeatureConfig; }, }; } From d2e9de6e6cdab1b8452e88ab3e842e7be5b2d9ed Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Mon, 13 Jan 2025 16:46:30 +0000 Subject: [PATCH 04/37] Bump version to 0.4.0 Update package version to 0.4.0 to reflect changes and improvements made in the OpenFeature browser provider. This helps signify a new iteration with potential new features or fixes. --- packages/openfeature-browser-provider/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/openfeature-browser-provider/package.json b/packages/openfeature-browser-provider/package.json index 0707a7062..69f0f53fb 100644 --- a/packages/openfeature-browser-provider/package.json +++ b/packages/openfeature-browser-provider/package.json @@ -1,6 +1,6 @@ { "name": "@bucketco/openfeature-browser-provider", - "version": "0.3.1", + "version": "0.4.0", "packageManager": "yarn@4.1.1", "license": "MIT", "repository": { From 340fc9c57faa485caa497ddd9df4a276f07a7a3b Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Wed, 22 Jan 2025 12:38:20 +0000 Subject: [PATCH 05/37] feat(browser-sdk): add format script, improve README formatting, and enhance feature handling - Added a new "format" script to package.json for consistent code formatting. - Improved formatting in README.md files across browser-sdk, react-sdk, and openfeature-browser-provider for better readability. - Updated feature handling in features.ts and mocks/handlers.ts to ensure proper type usage and maintainability. - Adjusted test cases to reflect changes in feature access patterns. These changes enhance the developer experience and maintain code quality. --- package.json | 1 + packages/browser-sdk/README.md | 13 ++++++----- packages/browser-sdk/src/feature/features.ts | 22 ++++++++++-------- packages/browser-sdk/test/client.test.ts | 2 +- packages/browser-sdk/test/mocks/handlers.ts | 23 ++++++++++--------- .../openfeature-browser-provider/README.md | 4 +++- packages/react-sdk/README.md | 9 ++++---- 7 files changed, 42 insertions(+), 32 deletions(-) diff --git a/package.json b/package.json index 2becdc8f4..ff3107eee 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "build": "lerna run build --stream", "test:ci": "lerna run test:ci --stream", "test": "lerna run test --stream", + "format": "lerna run format --stream", "prettier": "lerna run prettier --stream", "lint": "lerna run lint --stream", "lint:ci": "lerna run lint:ci --stream", diff --git a/packages/browser-sdk/README.md b/packages/browser-sdk/README.md index d8ecdf360..65932a9ed 100644 --- a/packages/browser-sdk/README.md +++ b/packages/browser-sdk/README.md @@ -27,7 +27,8 @@ const bucketClient = new BucketClient({ publishableKey, user, company }); await bucketClient.initialize(); -const { isEnabled, config, track, requestFeedback } = bucketClient.getFeature("huddle"); +const { isEnabled, config, track, requestFeedback } = + bucketClient.getFeature("huddle"); if (isEnabled) { // Show feature. When retrieving `isEnabled` the client automatically @@ -39,9 +40,9 @@ if (isEnabled) { // The `config` is a user-supplied value in Bucket that can be dynamically evaluated // with respect to the current context. Here, it is assumed that one could either get - // a config value that maches the context or not. - const question = config?.question ?? "Tell us what you think of Huddles" - + // a config value that matches the context or not. + const question = config?.question ?? "Tell us what you think of Huddles"; + // Use `requestFeedback` to create "Send feedback" buttons easily for specific // features. This is not related to `track` and you can call them individually. requestFeedback({ title: question }); @@ -165,7 +166,6 @@ similar to the way access is controlled, using matching rules. Each config-bound multiple rules with different configuration payloads. Whichever rule matches the context, provides the configuration payload. - The config is accessible through the same methods as the `isEnabled` property: ```ts @@ -176,12 +176,13 @@ const features = bucketClient.getFeatures(); // targetingVersion: 42, // config?: { // name: "gpt-3.5", -// version: 2, +// targetingVersion: 2, // payload: { maxTokens: 10000, model: "gpt-3.5-beta1" } // } // } // } ``` + The `name` is given by the user in Bucket for each configuration variant, and `version` is maintained by Bucket similar to `targetingVersion`. The `payload` is the actual JSON value supplied by the user and serves as context-based configuration. diff --git a/packages/browser-sdk/src/feature/features.ts b/packages/browser-sdk/src/feature/features.ts index 240d02714..d567364c5 100644 --- a/packages/browser-sdk/src/feature/features.ts +++ b/packages/browser-sdk/src/feature/features.ts @@ -48,6 +48,7 @@ export type FetchedFeature = { const FEATURES_UPDATED_EVENT = "features-updated"; export type FetchedFeatures = Record; + // todo: on next major, come up with a better name for this type. Maybe `LocalFeature`. export type RawFeature = FetchedFeature & { /** @@ -55,6 +56,7 @@ export type RawFeature = FetchedFeature & { */ isEnabledOverride: boolean | null; }; + export type RawFeatures = Record; export type FeaturesOptions = { @@ -456,15 +458,17 @@ export class FeaturesClient { } // fetch failed, nothing cached => return fallbacks - - return this.config.fallbackFeatures.reduce((acc, [key, config]) => { - acc[key] = { - key, - isEnabled: true, - config, - }; - return acc; - }, {} as FetchedFeatures); + return Object.entries(this.config.fallbackFeatures).reduce( + (acc, [key, config]) => { + acc[key] = { + key, + isEnabled: true, + config, + }; + return acc; + }, + {} as FetchedFeatures, + ); } setFeatureOverride(key: string, isEnabled: boolean | null) { diff --git a/packages/browser-sdk/test/client.test.ts b/packages/browser-sdk/test/client.test.ts index b6bd728a7..76cd1c241 100644 --- a/packages/browser-sdk/test/client.test.ts +++ b/packages/browser-sdk/test/client.test.ts @@ -68,7 +68,7 @@ describe("BucketClient", () => { describe("getFeature", () => { it("takes overrides into account", async () => { await client.initialize(); - expect(featuresResult.featureA.isEnabled).toBe(true); + expect(featuresResult["featureA"].isEnabled).toBe(true); expect(client.getFeature("featureA").isEnabled).toBe(true); client.setFeatureOverride("featureA", false); expect(client.getFeature("featureA").isEnabled).toBe(false); diff --git a/packages/browser-sdk/test/mocks/handlers.ts b/packages/browser-sdk/test/mocks/handlers.ts index 9b5d96863..06d03cc34 100644 --- a/packages/browser-sdk/test/mocks/handlers.ts +++ b/packages/browser-sdk/test/mocks/handlers.ts @@ -1,7 +1,6 @@ import { DefaultBodyType, http, HttpResponse, StrictRequest } from "msw"; -import { Features } from "../../../node-sdk/src/types"; -import { FeaturesResponse } from "../../src/feature/features"; +import { FeaturesResponse, RawFeatures } from "../../src/feature/features"; export const testChannel = "testChannel"; @@ -22,15 +21,17 @@ export const featureResponse: FeaturesResponse = { }, }; -export const featuresResult: Features = Object.entries( - featureResponse.features, -).reduce((acc, [key, feature]) => { - acc[key] = { - ...feature, - isEnabledOverride: null, - }; - return acc; -}, {} as Features); +export const featuresResult = Object.entries(featureResponse.features).reduce( + (acc, [key, feature]) => { + acc[key] = { + ...feature!, + key: key, + isEnabledOverride: null, + }; + return acc; + }, + {} as RawFeatures, +); function checkRequest(request: StrictRequest) { const url = new URL(request.url); diff --git a/packages/openfeature-browser-provider/README.md b/packages/openfeature-browser-provider/README.md index 3725534a1..cdb60e1fa 100644 --- a/packages/openfeature-browser-provider/README.md +++ b/packages/openfeature-browser-provider/README.md @@ -38,7 +38,9 @@ const client = OpenFeature.getClient(); const boolValue = client.getBooleanValue("huddles", false); // use more complex, config-enabled functionality. -const feedbackConfig = client.getObjectValue("ask-feedback", { question: "How are you enjoying this feature?" }); +const feedbackConfig = client.getObjectValue("ask-feedback", { + question: "How are you enjoying this feature?", +}); ``` Initializing the Bucket Browser Provider will diff --git a/packages/react-sdk/README.md b/packages/react-sdk/README.md index b48f82bca..006004c4b 100644 --- a/packages/react-sdk/README.md +++ b/packages/react-sdk/README.md @@ -30,9 +30,9 @@ declare module "@bucketco/react-sdk" { huddle: boolean; recordVideo: boolean; questionnaire?: { - showAll: boolean, - time: 600000, - } + showAll: boolean; + time: 600000; + }; } } ``` @@ -96,7 +96,8 @@ Returns the state of a given features for the current context. import { useFeature } from "@bucketco/react-sdk"; function StartHuddleButton() { - const { isLoading, isEnabled, config, track, requestFeedback } = useFeature("huddle"); + const { isLoading, isEnabled, config, track, requestFeedback } = + useFeature("huddle"); if (isLoading) { return ; From a6c66782b09de4a02037c5281ae893febf11c112 Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Wed, 22 Jan 2025 14:54:51 +0000 Subject: [PATCH 06/37] feat(browser-sdk): update feature configuration handling and improve documentation - Renamed "Feature toggles" section to "Remote config" in README.md for clarity. - Introduced a new type, `FeatureDynamicConfig`, to better represent dynamic feature configurations in client.ts. - Updated the `Feature` interface to use `FeatureDynamicConfig` instead of a generic `any` type for the `config` property. - Adjusted the handling of feature configurations in the `BucketClient` class to utilize a default `missingConfig`. - Enhanced the `FetchedFeature` type in features.ts to reflect the new configuration structure. - Updated test cases to align with the new configuration model, ensuring accurate feature representation. These changes enhance type safety and improve the overall developer experience when working with feature configurations. --- packages/browser-sdk/README.md | 2 +- packages/browser-sdk/src/client.ts | 45 +++++++++++++++++--- packages/browser-sdk/src/feature/features.ts | 11 +++-- packages/browser-sdk/test/features.test.ts | 15 ++++--- packages/browser-sdk/test/mocks/handlers.ts | 29 ++++++++++++- packages/browser-sdk/test/usage.test.ts | 12 ++++-- 6 files changed, 94 insertions(+), 20 deletions(-) diff --git a/packages/browser-sdk/README.md b/packages/browser-sdk/README.md index 65932a9ed..3804ca975 100644 --- a/packages/browser-sdk/README.md +++ b/packages/browser-sdk/README.md @@ -158,7 +158,7 @@ by down-stream clients, like the React SDK. Note that accessing `isEnabled` on the object returned by `getFeatures` does not automatically generate a `check` event, contrary to the `isEnabled` property on the object returned by `getFeature`. -### Feature toggles +### Remote config Similar to `isEnabled`, each feature has a `config` property. This configuration is set by the user within Bucket. It is similar to the way access is controlled, using matching rules. Each config-bound rule is given a configuration payload diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index 07d7ce960..8ade560e0 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -174,25 +174,54 @@ const defaultConfig: Config = { enableTracking: true, }; +/** + * A dynamic configuration value for a feature. + */ +export type FeatureDynamicConfig = + | { + /** + * The key of the matched configuration value. + */ + key: string; + /** + * The version of the matched configuration value. + */ + version: number; + + /** + * The user-supplied data. + */ + payload: any; + } + | { key: undefined; version: undefined; payload: undefined }; + +/** + * A feature. + */ export interface Feature { /** - * Result of feature flag evaluation + * Result of feature flag evaluation. */ isEnabled: boolean; /* - * Optional user-defined configuration + * Optional user-defined configuration. */ - config: any; + config: FeatureDynamicConfig; /** - * Function to send analytics events for this feature + * Function to send analytics events for this feature. */ track: () => Promise; + + /** + * Function to request feedback for this feature. + */ requestFeedback: ( options: Omit, ) => void; } + /** * BucketClient lets you interact with the Bucket API. * @@ -209,6 +238,7 @@ export class BucketClient { private readonly featuresClient: FeaturesClient; public readonly logger: Logger; + /** * Create a new BucketClient instance. */ @@ -514,6 +544,11 @@ export class BucketClient { return this.featuresClient.getFeatures(); } + private missingConfig: FeatureDynamicConfig = { + key: undefined, + version: undefined, + payload: undefined, + }; /** * Return a feature. Accessing `isEnabled` or `config` will automatically send a `check` event. * @returns A feature. @@ -523,7 +558,7 @@ export class BucketClient { const fClient = this.featuresClient; const value = f?.isEnabledOverride ?? f?.isEnabled ?? false; - const config = f?.config?.payload; + const config = f?.config ?? this.missingConfig; function sendCheckEvent() { fClient diff --git a/packages/browser-sdk/src/feature/features.ts b/packages/browser-sdk/src/feature/features.ts index d567364c5..45952b40a 100644 --- a/packages/browser-sdk/src/feature/features.ts +++ b/packages/browser-sdk/src/feature/features.ts @@ -9,6 +9,9 @@ import { parseAPIFeaturesResponse, } from "./featureCache"; +/** + * A feature fetched from the server. + */ export type FetchedFeature = { /** * Feature key @@ -26,15 +29,15 @@ export type FetchedFeature = { targetingVersion?: number; /** - * Optional user-defined configuration. + * Optional user-defined dynamic configuration. */ config?: { /** - * The name of the matched configuration variant. + * The key of the matched configuration value. */ - name: string | null; + key: string; /** - * The version of the matched configuration variant. + * The version of the matched configuration value. */ version: number; diff --git a/packages/browser-sdk/test/features.test.ts b/packages/browser-sdk/test/features.test.ts index d757240ad..cf2dd7ce9 100644 --- a/packages/browser-sdk/test/features.test.ts +++ b/packages/browser-sdk/test/features.test.ts @@ -144,14 +144,17 @@ describe("FeaturesClient", () => { new Error("Failed to fetch features"), ); const featuresClient = newFeaturesClient({ - fallbackFeatures: { huddle: { name: "john" } }, + fallbackFeatures: { + huddle: { key: "john", version: 1, payload: { something: "else" } }, + }, }); await featuresClient.initialize(); expect(featuresClient.getFeatures()).toStrictEqual({ huddle: { isEnabled: true, - config: { name: "john" }, + config: { key: "john", version: 1, payload: { something: "else" } }, + key: "huddle", isEnabledOverride: null, }, }); @@ -335,12 +338,12 @@ describe("FeaturesClient", () => { updated = true; }); - expect(client.getFeatures().featureB).toBeUndefined(); + expect(client.getFeatures().featureC).toBeUndefined(); - client.setFeatureOverride("featureB", true); + client.setFeatureOverride("featureC", true); expect(updated).toBe(true); - expect(client.getFeatures().featureB.isEnabled).toBe(false); - expect(client.getFeatures().featureB.isEnabledOverride).toBe(true); + expect(client.getFeatures().featureC.isEnabled).toBe(false); + expect(client.getFeatures().featureC.isEnabledOverride).toBe(true); }); }); diff --git a/packages/browser-sdk/test/mocks/handlers.ts b/packages/browser-sdk/test/mocks/handlers.ts index 06d03cc34..b05879e77 100644 --- a/packages/browser-sdk/test/mocks/handlers.ts +++ b/packages/browser-sdk/test/mocks/handlers.ts @@ -14,7 +14,7 @@ export const featureResponse: FeaturesResponse = { key: "featureB", config: { version: 12, - name: "gpt3", + key: "gpt3", payload: { model: "gpt-something", temperature: 0.5 }, }, }, @@ -116,6 +116,18 @@ export const handlers = [ success: true, }); }), + http.post("https://front.bucket.co/features/events", async ({ request }) => { + if (!checkRequest(request)) return invalidReqResponse; + const data = await request.json(); + + if (typeof data !== "object" || !data || !data["userId"]) { + return new HttpResponse(null, { status: 400 }); + } + + return HttpResponse.json({ + success: true, + }); + }), http.post("https://front.bucket.co/feedback", async ({ request }) => { if (!checkRequest(request)) return invalidReqResponse; const data = await request.json(); @@ -146,4 +158,19 @@ export const handlers = [ if (!checkRequest(request)) return invalidReqResponse; return HttpResponse.json({ success: true, keyName: "keyName" }); }), + http.post( + "https://livemessaging.bucket.co/keys/keyName/requestToken", + async ({ request }) => { + const data = await request.json(); + if (typeof data !== "object") { + return new HttpResponse(null, { status: 400 }); + } + + return HttpResponse.json({ + success: true, + token: "token", + expires: 1234567890, + }); + }, + ), ]; diff --git a/packages/browser-sdk/test/usage.test.ts b/packages/browser-sdk/test/usage.test.ts index 64986ce1f..5c712cd9f 100644 --- a/packages/browser-sdk/test/usage.test.ts +++ b/packages/browser-sdk/test/usage.test.ts @@ -79,7 +79,7 @@ describe("usage", () => { isEnabled: false, track: expect.any(Function), requestFeedback: expect.any(Function), - config: undefined, + config: { key: undefined, version: undefined, payload: undefined }, }); }); @@ -455,8 +455,12 @@ describe(`sends "check" events `, () => { await client.initialize(); const featureB = client.getFeature("featureB"); expect(featureB.config).toEqual({ - model: "gpt-something", - temperature: 0.5, + key: "gpt3", + version: 12, + payload: { + model: "gpt-something", + temperature: 0.5, + }, }); expect(postSpy).toHaveBeenCalledWith({ @@ -511,6 +515,7 @@ describe(`sends "check" events `, () => { isEnabled: false, track: expect.any(Function), requestFeedback: expect.any(Function), + config: { key: undefined, version: undefined, payload: undefined }, }); vi.spyOn(client, "track"); @@ -529,6 +534,7 @@ describe(`sends "check" events `, () => { isEnabled: false, track: expect.any(Function), requestFeedback: expect.any(Function), + config: { key: undefined, version: undefined, payload: undefined }, }); vi.spyOn(client, "requestFeedback"); From f5f250ed44e2c96540376865aaf1170835fa0983 Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Wed, 22 Jan 2025 15:07:58 +0000 Subject: [PATCH 07/37] feat(react-sdk): enhance feature handling in useFeature hook - Introduced a new EMPTY_FEATURE_CONFIG constant to provide a default configuration for features. - Updated the useFeature hook to return a more structured Feature type, including isEnabled, isLoading, and config properties. - Modified requestFeedback function to use a more specific RequestFeedbackOptions type. - Adjusted test cases to validate the new feature configuration structure and ensure correct default values are returned. These changes improve type safety and enhance the developer experience when working with feature flags. --- packages/react-sdk/src/index.tsx | 35 ++++++++++++++++++++++---- packages/react-sdk/test/usage.test.tsx | 11 +++++--- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/packages/react-sdk/src/index.tsx b/packages/react-sdk/src/index.tsx index 8821d1f0a..b7a1378da 100644 --- a/packages/react-sdk/src/index.tsx +++ b/packages/react-sdk/src/index.tsx @@ -170,6 +170,31 @@ export function BucketProvider({ ); } +const EMPTY_FEATURE_CONFIG = { + key: undefined, + version: undefined, + payload: undefined, +}; + +type RequestFeedbackOptions = Omit< + RequestFeedbackData, + "featureKey" | "featureId" +>; + +type Feature = { + isEnabled: boolean; + isLoading: boolean; + config: + | { + key: string; + version: number; + payload: FeatureConfig; + } + | typeof EMPTY_FEATURE_CONFIG; + track: () => void; + requestFeedback: (opts: RequestFeedbackOptions) => void; +}; + /** * Returns the state of a given feature for the current context, e.g. * @@ -182,21 +207,21 @@ export function BucketProvider({ * } * ``` */ -export function useFeature(key: TKey) { +export function useFeature(key: TKey): Feature { const { features: { features, isLoading }, client, } = useContext(ProviderContext); const track = () => client?.track(key); - const requestFeedback = ( - opts: Omit, - ) => client?.requestFeedback({ ...opts, featureKey: key }); + const requestFeedback = (opts: RequestFeedbackOptions) => + client?.requestFeedback({ ...opts, featureKey: key }); if (isLoading) { return { isLoading, isEnabled: false, + config: EMPTY_FEATURE_CONFIG, track, requestFeedback, }; @@ -227,7 +252,7 @@ export function useFeature(key: TKey) { }, get config() { sendCheckEvent(); - return feature?.config?.payload as FeatureConfig; + return feature?.config ?? EMPTY_FEATURE_CONFIG; }, }; } diff --git a/packages/react-sdk/test/usage.test.tsx b/packages/react-sdk/test/usage.test.tsx index 535f0075f..0c456f163 100644 --- a/packages/react-sdk/test/usage.test.tsx +++ b/packages/react-sdk/test/usage.test.tsx @@ -82,7 +82,7 @@ const server = setupServer( isEnabled: true, targetingVersion: 1, config: { - name: "gpt3", + key: "gpt3", payload: { model: "gpt-something", temperature: 0.5 }, version: 2, }, @@ -224,6 +224,7 @@ describe("useFeature", () => { expect(result.current).toStrictEqual({ isEnabled: false, isLoading: true, + config: { key: undefined, version: undefined, payload: undefined }, track: expect.any(Function), requestFeedback: expect.any(Function), }); @@ -238,7 +239,7 @@ describe("useFeature", () => { await waitFor(() => { expect(result.current).toStrictEqual({ - config: undefined, + config: { key: undefined, version: undefined, payload: undefined }, isEnabled: false, isLoading: false, track: expect.any(Function), @@ -258,7 +259,11 @@ describe("useFeature", () => { expect(result.current).toStrictEqual({ isEnabled: true, isLoading: false, - config: { model: "gpt-something", temperature: 0.5 }, + config: { + key: "gpt3", + version: 2, + payload: { model: "gpt-something", temperature: 0.5 }, + }, track: expect.any(Function), requestFeedback: expect.any(Function), }); From 66f661ad2975ed43a3bbdd703923695751a322c9 Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Wed, 22 Jan 2025 16:33:09 +0000 Subject: [PATCH 08/37] feat(browser-sdk, react-sdk, openfeature-browser-sdk): enhance feature configuration handling and improve documentation - Updated feature configuration structure across browser-sdk and react-sdk to use `targetingVersion` and `value` instead of `version` and `payload`. - Refactored related types and interfaces to improve type safety and clarity in feature handling. - Enhanced README documentation to reflect changes in feature configuration and usage examples. - Adjusted test cases to validate the new configuration structure and ensure accurate feature representation. These changes improve the developer experience and maintainability of the SDKs. --- packages/browser-sdk/README.md | 32 ++++--- packages/browser-sdk/src/client.ts | 10 +-- .../browser-sdk/src/feature/featureCache.ts | 6 +- packages/browser-sdk/src/feature/features.ts | 58 ++++++------- packages/browser-sdk/test/features.test.ts | 17 ++-- packages/browser-sdk/test/mocks/handlers.ts | 19 ++++- packages/browser-sdk/test/usage.test.ts | 4 +- .../openfeature-browser-provider/README.md | 2 +- .../src/index.test.ts | 85 ++++++++++--------- .../openfeature-browser-provider/src/index.ts | 23 +++-- packages/react-sdk/README.md | 11 ++- packages/react-sdk/src/index.tsx | 8 +- packages/react-sdk/test/usage.test.tsx | 12 ++- 13 files changed, 165 insertions(+), 122 deletions(-) diff --git a/packages/browser-sdk/README.md b/packages/browser-sdk/README.md index 3804ca975..f334f745b 100644 --- a/packages/browser-sdk/README.md +++ b/packages/browser-sdk/README.md @@ -27,8 +27,12 @@ const bucketClient = new BucketClient({ publishableKey, user, company }); await bucketClient.initialize(); -const { isEnabled, config, track, requestFeedback } = - bucketClient.getFeature("huddle"); +const { + isEnabled, + config: { value: question }, + track, + requestFeedback, +} = bucketClient.getFeature("huddle"); if (isEnabled) { // Show feature. When retrieving `isEnabled` the client automatically @@ -38,10 +42,10 @@ if (isEnabled) { // On usage, call `track` to let Bucket know that a user interacted with the feature track(); - // The `config` is a user-supplied value in Bucket that can be dynamically evaluated + // The `value` is a user-supplied value in Bucket that can be dynamically evaluated // with respect to the current context. Here, it is assumed that one could either get - // a config value that matches the context or not. - const question = config?.question ?? "Tell us what you think of Huddles"; + // a config value that matches the context or use a default. + const question = value?.question ?? "Tell us what you think of Huddles"; // Use `requestFeedback` to create "Send feedback" buttons easily for specific // features. This is not related to `track` and you can call them individually. @@ -133,7 +137,7 @@ To retrieve features along with their targeting information, use `getFeature(key const huddle = bucketClient.getFeature("huddle"); // { // isEnabled: true, -// config: any, +// config: { key: "zoom", targetingVersion: 2, value: { ... } }, // track: () => Promise // requestFeedback: (options: RequestFeedbackData) => void // } @@ -161,10 +165,10 @@ generate a `check` event, contrary to the `isEnabled` property on the object ret ### Remote config Similar to `isEnabled`, each feature has a `config` property. This configuration is set by the user within Bucket. It is -similar to the way access is controlled, using matching rules. Each config-bound rule is given a configuration payload -(a JSON value) that is returned to the SDKs if the requested context matches that specific rule. It is possible to have -multiple rules with different configuration payloads. Whichever rule matches the context, provides the configuration -payload. +similar to the way access is managed -- using rules. Each config-bound rule is given a configuration value +(a JSON value) that is returned to the SDK if the requested context matches. It is possible to have +multiple rules with different configuration values. Whichever rule matches the context, provides the configuration +value. The config is accessible through the same methods as the `isEnabled` property: @@ -175,16 +179,16 @@ const features = bucketClient.getFeatures(); // isEnabled: true, // targetingVersion: 42, // config?: { -// name: "gpt-3.5", +// key: "gpt-3.5", // targetingVersion: 2, -// payload: { maxTokens: 10000, model: "gpt-3.5-beta1" } +// value: { maxTokens: 10000, model: "gpt-3.5-beta1" } // } // } // } ``` -The `name` is given by the user in Bucket for each configuration variant, and `version` is maintained by Bucket similar -to `targetingVersion`. The `payload` is the actual JSON value supplied by the user and serves as context-based +The `key` is given by the user in Bucket for each configuration value, and `targetingVersion` is maintained by Bucket similar +to access `targetingVersion`. The `value` is the actual JSON value supplied by the user and serves as context-based configuration. Just as `isEnabled`, accessing `config` on the object returned by `getFeatures` does not automatically diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index 8ade560e0..fa5de08ec 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -186,14 +186,14 @@ export type FeatureDynamicConfig = /** * The version of the matched configuration value. */ - version: number; + targetingVersion?: number; /** * The user-supplied data. */ - payload: any; + value: any; } - | { key: undefined; version: undefined; payload: undefined }; + | { key: undefined; targetingVersion: undefined; value: undefined }; /** * A feature. @@ -546,8 +546,8 @@ export class BucketClient { private missingConfig: FeatureDynamicConfig = { key: undefined, - version: undefined, - payload: undefined, + targetingVersion: undefined, + value: undefined, }; /** * Return a feature. Accessing `isEnabled` or `config` will automatically send a `check` event. diff --git a/packages/browser-sdk/src/feature/featureCache.ts b/packages/browser-sdk/src/feature/featureCache.ts index b4cb8ed88..2bbd695bd 100644 --- a/packages/browser-sdk/src/feature/featureCache.ts +++ b/packages/browser-sdk/src/feature/featureCache.ts @@ -36,7 +36,11 @@ export function parseAPIFeaturesResponse( isEnabled: feature.isEnabled, targetingVersion: feature.targetingVersion, key, - config: feature.config, + config: feature.config && { + key: feature.config.key, + targetingVersion: feature.config.version, + value: feature.config.payload, + }, }; } diff --git a/packages/browser-sdk/src/feature/features.ts b/packages/browser-sdk/src/feature/features.ts index 45952b40a..99df2532e 100644 --- a/packages/browser-sdk/src/feature/features.ts +++ b/packages/browser-sdk/src/feature/features.ts @@ -39,12 +39,12 @@ export type FetchedFeature = { /** * The version of the matched configuration value. */ - version: number; + targetingVersion?: number; /** * The user-supplied data. */ - payload: any; + value: any; }; }; @@ -62,6 +62,11 @@ export type RawFeature = FetchedFeature & { export type RawFeatures = Record; +export type FallbackFeatureConfig = { + key: string; + value: any; +} | null; + export type FeaturesOptions = { /** * Feature keys for which `isEnabled` should fallback to true @@ -69,7 +74,7 @@ export type FeaturesOptions = { * is supplied instead of array, the values of each key represent the * configuration values and `isEnabled` is assume `true`. */ - fallbackFeatures?: string[] | Record; + fallbackFeatures?: string[] | Record; /** * Timeout in milliseconds @@ -86,7 +91,7 @@ export type FeaturesOptions = { }; type Config = { - fallbackFeatures: Record; + fallbackFeatures: Record; timeoutMs: number; staleWhileRevalidate: boolean; }; @@ -97,19 +102,6 @@ export const DEFAULT_FEATURES_CONFIG: Config = { staleWhileRevalidate: false, }; -// Deep merge two objects. -export type FeaturesResponse = { - /** - * `true` if call was successful - */ - success: boolean; - - /** - * List of enabled features - */ - features: FetchedFeatures; -}; - export function validateFeaturesResponse(response: any) { if (!isObject(response)) { return; @@ -238,20 +230,22 @@ export class FeaturesClient { expireTimeMs: options?.expireTimeMs ?? FEATURES_EXPIRE_MS, }); + let fallbackFeatures: Record; + if (Array.isArray(options?.fallbackFeatures)) { - options = { - ...options, - fallbackFeatures: options.fallbackFeatures.reduce( - (acc, key) => { - acc[key] = null; - return acc; - }, - {} as Record, - ), - }; + fallbackFeatures = options.fallbackFeatures.reduce( + (acc, key) => { + acc[key] = null; + return acc; + }, + {} as Record, + ); + } else { + fallbackFeatures = options?.fallbackFeatures ?? {}; } - this.config = { ...DEFAULT_FEATURES_CONFIG, ...options }; + this.config = { ...DEFAULT_FEATURES_CONFIG, ...options, fallbackFeatures }; + this.rateLimiter = options?.rateLimiter ?? new RateLimiter(FEATURE_EVENTS_PER_MIN, this.logger); @@ -420,6 +414,7 @@ export class FeaturesClient { private async maybeFetchFeatures(): Promise { const cacheKey = this.fetchParams().toString(); const cachedItem = this.cache.get(cacheKey); + console.log(cacheKey); if (cachedItem) { if (!cachedItem.stale) return cachedItem.features; @@ -466,7 +461,12 @@ export class FeaturesClient { acc[key] = { key, isEnabled: true, - config, + config: config + ? { + key: config.key, + value: config.value, + } + : undefined, }; return acc; }, diff --git a/packages/browser-sdk/test/features.test.ts b/packages/browser-sdk/test/features.test.ts index cf2dd7ce9..b50ba8c95 100644 --- a/packages/browser-sdk/test/features.test.ts +++ b/packages/browser-sdk/test/features.test.ts @@ -122,6 +122,7 @@ describe("FeaturesClient", () => { vi.mocked(httpClient.get).mockRejectedValue( new Error("Failed to fetch features"), ); + const featuresClient = newFeaturesClient({ fallbackFeatures: ["huddle"], }); @@ -130,7 +131,7 @@ describe("FeaturesClient", () => { expect(featuresClient.getFeatures()).toStrictEqual({ huddle: { isEnabled: true, - config: null, + config: undefined, key: "huddle", isEnabledOverride: null, }, @@ -145,7 +146,10 @@ describe("FeaturesClient", () => { ); const featuresClient = newFeaturesClient({ fallbackFeatures: { - huddle: { key: "john", version: 1, payload: { something: "else" } }, + huddle: { + key: "john", + value: { something: "else" }, + }, }, }); @@ -153,23 +157,24 @@ describe("FeaturesClient", () => { expect(featuresClient.getFeatures()).toStrictEqual({ huddle: { isEnabled: true, - config: { key: "john", version: 1, payload: { something: "else" } }, + config: { key: "john", value: { something: "else" } }, key: "huddle", isEnabledOverride: null, }, }); }); - test("caches response", async () => { + test.only("caches response", async () => { const { newFeaturesClient, httpClient } = featuresClientFactory(); - const featuresClient = newFeaturesClient(); - await featuresClient.initialize(); + const featuresClient1 = newFeaturesClient(); + await featuresClient1.initialize(); expect(httpClient.get).toBeCalledTimes(1); const featuresClient2 = newFeaturesClient(); await featuresClient2.initialize(); + const features = featuresClient2.getFeatures(); expect(features).toEqual(featuresResult); diff --git a/packages/browser-sdk/test/mocks/handlers.ts b/packages/browser-sdk/test/mocks/handlers.ts index b05879e77..1f1a90d29 100644 --- a/packages/browser-sdk/test/mocks/handlers.ts +++ b/packages/browser-sdk/test/mocks/handlers.ts @@ -1,13 +1,18 @@ import { DefaultBodyType, http, HttpResponse, StrictRequest } from "msw"; -import { FeaturesResponse, RawFeatures } from "../../src/feature/features"; +import { RawFeatures } from "../../src/feature/features"; export const testChannel = "testChannel"; -export const featureResponse: FeaturesResponse = { +export const featureResponse = { success: true, features: { - featureA: { isEnabled: true, key: "featureA", targetingVersion: 1 }, + featureA: { + isEnabled: true, + key: "featureA", + targetingVersion: 1, + config: undefined, + }, featureB: { isEnabled: true, targetingVersion: 11, @@ -25,7 +30,13 @@ export const featuresResult = Object.entries(featureResponse.features).reduce( (acc, [key, feature]) => { acc[key] = { ...feature!, - key: key, + config: feature.config + ? { + key: feature.config.key, + targetingVersion: feature.config.version, + value: feature.config.payload, + } + : undefined, isEnabledOverride: null, }; return acc; diff --git a/packages/browser-sdk/test/usage.test.ts b/packages/browser-sdk/test/usage.test.ts index 5c712cd9f..e9201d048 100644 --- a/packages/browser-sdk/test/usage.test.ts +++ b/packages/browser-sdk/test/usage.test.ts @@ -456,8 +456,8 @@ describe(`sends "check" events `, () => { const featureB = client.getFeature("featureB"); expect(featureB.config).toEqual({ key: "gpt3", - version: 12, - payload: { + targetingVersion: 12, + value: { model: "gpt-something", temperature: 0.5, }, diff --git a/packages/openfeature-browser-provider/README.md b/packages/openfeature-browser-provider/README.md index cdb60e1fa..41143a28b 100644 --- a/packages/openfeature-browser-provider/README.md +++ b/packages/openfeature-browser-provider/README.md @@ -37,7 +37,7 @@ const client = OpenFeature.getClient(); // use client const boolValue = client.getBooleanValue("huddles", false); -// use more complex, config-enabled functionality. +// use more complex, dynamic config-enabled functionality. const feedbackConfig = client.getObjectValue("ask-feedback", { question: "How are you enjoying this feature?", }); diff --git a/packages/openfeature-browser-provider/src/index.test.ts b/packages/openfeature-browser-provider/src/index.test.ts index 7a2a6e790..286baff45 100644 --- a/packages/openfeature-browser-provider/src/index.test.ts +++ b/packages/openfeature-browser-provider/src/index.test.ts @@ -90,32 +90,50 @@ describe("BucketBrowserSDKProvider", () => { }); describe("resolveBooleanEvaluation", () => { - function mockFeature(enabled: boolean, config: any) { + function mockFeature( + enabled: boolean, + configKey: string | undefined, + configValue: any | undefined, + ) { + const config = + configKey !== undefined + ? { + key: configKey, + targetingVersion: 1, + value: configValue, + } + : { + key: undefined, + targetingVersion: undefined, + value: undefined, + }; + bucketClientMock.getFeature = vi.fn().mockReturnValue({ isEnabled: enabled, - config: config, + config, }); bucketClientMock.getFeatures = vi.fn().mockReturnValue({ [testFlagKey]: { isEnabled: enabled, - config: { - name: "test", - version: 1, - payload: config, - }, + config, targetingVersion: 1, }, }); } it("calls the client correctly when evaluating", async () => { - mockFeature(true, true); + mockFeature(true, "key", true); await provider.initialize(); const val = ofClient.getBooleanDetails(testFlagKey, false); - expect(val).toBeDefined(); + expect(val).toEqual({ + flagKey: "a-key", + flagMetadata: {}, + reason: "TARGETING_MATCH", + value: true, + }); expect(bucketClientMock.getFeatures).toHaveBeenCalled(); expect(bucketClientMock.getFeature).toHaveBeenCalledWith(testFlagKey); @@ -131,8 +149,8 @@ describe("BucketBrowserSDKProvider", () => { [false, true, true, true, "DISABLED"], ])( "should return the correct result when evaluating boolean %s, %s, %s, %s, %s`", - async (enabled, config, def, expected, reason) => { - mockFeature(enabled, config); + async (enabled, value, def, expected, reason) => { + mockFeature(enabled, "key", value); expect(ofClient.getBooleanDetails(testFlagKey, def)).toEqual({ flagKey: "a-key", flagMetadata: {}, @@ -149,8 +167,8 @@ describe("BucketBrowserSDKProvider", () => { [false, 4, -4, -4, "DISABLED"], ])( "should return the correct result when evaluating number %s, %s, %s, %s, %s`", - async (enabled, config, def, expected, reason) => { - mockFeature(enabled, config); + async (enabled, value, def, expected, reason) => { + mockFeature(enabled, value ? "key" : undefined, value); expect(ofClient.getNumberDetails(testFlagKey, def)).toEqual({ flagKey: "a-key", flagMetadata: {}, @@ -162,8 +180,8 @@ describe("BucketBrowserSDKProvider", () => { it.each([["string"], [true], [{}]])( "should handle type mismatch when evaluating number as %s`", - async (config) => { - mockFeature(true, config); + async (value) => { + mockFeature(true, "key", value); expect(ofClient.getNumberDetails(testFlagKey, -1)).toEqual({ flagKey: "a-key", flagMetadata: {}, @@ -176,14 +194,12 @@ describe("BucketBrowserSDKProvider", () => { ); it.each([ - [true, "1", "-1", "1", "TARGETING_MATCH"], - [true, null, "-2", "-2", "DEFAULT"], - [false, "2", "-3", "-3", "DISABLED"], - [false, "3", "-4", "-4", "DISABLED"], + [true, { anything: 1 }, "default", "key", "TARGETING_MATCH"], + [false, 1337, "default", "default", "DISABLED"], ])( "should return the correct result when evaluating string %s, %s, %s, %s, %s`", - async (enabled, config, def, expected, reason) => { - mockFeature(enabled, config); + async (enabled, value, def, expected, reason) => { + mockFeature(enabled, "key", value); expect(ofClient.getStringDetails(testFlagKey, def)).toEqual({ flagKey: "a-key", flagMetadata: {}, @@ -193,21 +209,6 @@ describe("BucketBrowserSDKProvider", () => { }, ); - it.each([[15], [true], [{}]])( - "should handle type mismatch when evaluating string as %s`", - async (config) => { - mockFeature(true, config); - expect(ofClient.getStringDetails(testFlagKey, "hello")).toEqual({ - flagKey: "a-key", - flagMetadata: {}, - reason: "ERROR", - errorCode: "TYPE_MISMATCH", - errorMessage: "", - value: "hello", - }); - }, - ); - it.each([ [true, [], [1], [], "TARGETING_MATCH"], [true, null, [2], [2], "DEFAULT"], @@ -215,8 +216,8 @@ describe("BucketBrowserSDKProvider", () => { [false, [5], [6], [6], "DISABLED"], ])( "should return the correct result when evaluating array %s, %s, %s, %s, %s`", - async (enabled, config, def, expected, reason) => { - mockFeature(enabled, config); + async (enabled, value, def, expected, reason) => { + mockFeature(enabled, value ? "key" : undefined, value); expect(ofClient.getObjectDetails(testFlagKey, def)).toEqual({ flagKey: "a-key", flagMetadata: {}, @@ -233,8 +234,8 @@ describe("BucketBrowserSDKProvider", () => { [false, { a: 5 }, { a: 6 }, { a: 6 }, "DISABLED"], ])( "should return the correct result when evaluating object %s, %s, %s, %s, %s`", - async (enabled, config, def, expected, reason) => { - mockFeature(enabled, config); + async (enabled, value, def, expected, reason) => { + mockFeature(enabled, value ? "key" : undefined, value); expect(ofClient.getObjectDetails(testFlagKey, def)).toEqual({ flagKey: "a-key", flagMetadata: {}, @@ -246,8 +247,8 @@ describe("BucketBrowserSDKProvider", () => { it.each([["string"], [15], [true]])( "should handle type mismatch when evaluating object as %s`", - async (config) => { - mockFeature(true, config); + async (value) => { + mockFeature(true, "key", value); expect(ofClient.getObjectDetails(testFlagKey, { obj: true })).toEqual({ flagKey: "a-key", flagMetadata: {}, diff --git a/packages/openfeature-browser-provider/src/index.ts b/packages/openfeature-browser-provider/src/index.ts index 995ab7ef1..f9d95a854 100644 --- a/packages/openfeature-browser-provider/src/index.ts +++ b/packages/openfeature-browser-provider/src/index.ts @@ -133,24 +133,31 @@ export class BucketBrowserSDKProvider implements Provider { }; } - if (feature.config === null || feature.config === undefined) { + if (!feature.config.key) { return { value: defaultValue, reason: StandardResolutionReasons.DEFAULT, }; } - if (typeof feature.config !== expType) { + if (expType === "string") { + return { + value: feature.config.key, + reason: StandardResolutionReasons.TARGETING_MATCH, + }; + } + + if (typeof feature.config.value !== expType) { return { value: defaultValue, reason: StandardResolutionReasons.ERROR, errorCode: ErrorCode.TYPE_MISMATCH, - errorMessage: `Expected ${expType} but got ${typeof feature.config}`, + errorMessage: `Expected ${expType} but got ${typeof feature.config.value}`, }; } return { - value: feature.config as T, + value: feature.config.value as T, reason: StandardResolutionReasons.TARGETING_MATCH, }; } @@ -194,8 +201,10 @@ export class BucketBrowserSDKProvider implements Provider { this._clientOptions.logger?.error("client not initialized"); } - this._client?.track(trackingEventName, trackingEventDetails).catch((e) => { - this._clientOptions.logger?.error("error tracking event", e); - }); + this._client + ?.track(trackingEventName, trackingEventDetails) + .catch((e: any) => { + this._clientOptions.logger?.error("error tracking event", e); + }); } } diff --git a/packages/react-sdk/README.md b/packages/react-sdk/README.md index 006004c4b..2169d0bd6 100644 --- a/packages/react-sdk/README.md +++ b/packages/react-sdk/README.md @@ -96,8 +96,13 @@ Returns the state of a given features for the current context. import { useFeature } from "@bucketco/react-sdk"; function StartHuddleButton() { - const { isLoading, isEnabled, config, track, requestFeedback } = - useFeature("huddle"); + const { + isLoading, + isEnabled, + config: { key, value }, + track, + requestFeedback, + } = useFeature("huddle"); if (isLoading) { return ; @@ -113,7 +118,7 @@ function StartHuddleButton() { ; - * } + * return ; * } * ``` */ @@ -221,7 +218,7 @@ export function useFeature(key: TKey): Feature { return { isLoading, isEnabled: false, - config: EMPTY_FEATURE_CONFIG, + config: {} as EmptyFeatureConfig, track, requestFeedback, }; @@ -252,7 +249,7 @@ export function useFeature(key: TKey): Feature { }, get config() { sendCheckEvent(); - return feature?.config ?? EMPTY_FEATURE_CONFIG; + return feature?.config ?? ({} as EmptyFeatureConfig); }, }; } diff --git a/packages/react-sdk/test/usage.test.tsx b/packages/react-sdk/test/usage.test.tsx index f435747b7..a6c0013d4 100644 --- a/packages/react-sdk/test/usage.test.tsx +++ b/packages/react-sdk/test/usage.test.tsx @@ -224,7 +224,7 @@ describe("useFeature", () => { expect(result.current).toStrictEqual({ isEnabled: false, isLoading: true, - config: { key: undefined, targetingVersion: undefined, value: undefined }, + config: {}, track: expect.any(Function), requestFeedback: expect.any(Function), }); @@ -239,11 +239,7 @@ describe("useFeature", () => { await waitFor(() => { expect(result.current).toStrictEqual({ - config: { - key: undefined, - targetingVersion: undefined, - value: undefined, - }, + config: {}, isEnabled: false, isLoading: false, track: expect.any(Function), diff --git a/yarn.lock b/yarn.lock index cc264e6ff..812c4c4b1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -882,7 +882,19 @@ __metadata: languageName: node linkType: hard -"@bucketco/browser-sdk@npm:2.6.0, @bucketco/browser-sdk@workspace:packages/browser-sdk": +"@bucketco/browser-sdk@npm:2.4.0": + version: 2.4.0 + resolution: "@bucketco/browser-sdk@npm:2.4.0" + dependencies: + "@floating-ui/dom": "npm:^1.6.8" + canonical-json: "npm:^0.0.4" + js-cookie: "npm:^3.0.5" + preact: "npm:^10.22.1" + checksum: 10c0/b33a9fdafa4a857ac4f815fe69b602b37527a37d54270abd479b754da998d030f5a70b738c662ab57fa4f6374b8e1fbd052feb8bdbd8b78367086dcedc5a5432 + languageName: node + linkType: hard + +"@bucketco/browser-sdk@npm:2.5.1, @bucketco/browser-sdk@workspace:packages/browser-sdk": version: 0.0.0-use.local resolution: "@bucketco/browser-sdk@workspace:packages/browser-sdk" dependencies: @@ -972,7 +984,7 @@ __metadata: version: 0.0.0-use.local resolution: "@bucketco/openfeature-browser-provider@workspace:packages/openfeature-browser-provider" dependencies: - "@bucketco/browser-sdk": "npm:2.6.0" + "@bucketco/browser-sdk": "npm:2.4.0" "@bucketco/eslint-config": "npm:0.0.2" "@bucketco/tsconfig": "npm:0.0.2" "@openfeature/core": "npm:1.5.0" @@ -1018,7 +1030,7 @@ __metadata: version: 0.0.0-use.local resolution: "@bucketco/react-sdk@workspace:packages/react-sdk" dependencies: - "@bucketco/browser-sdk": "npm:2.6.0" + "@bucketco/browser-sdk": "npm:2.5.1" "@bucketco/eslint-config": "workspace:^" "@bucketco/tsconfig": "workspace:^" "@testing-library/react": "npm:^15.0.7" From f1383e6fe75bc84f67a57f0a49a64c9e914528ee Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Fri, 24 Jan 2025 10:43:56 +0000 Subject: [PATCH 11/37] fix(browser-sdk): update README to reflect changes in feature configuration - Changed the reference from `value` to `payload` in the README documentation to accurately describe the updated feature configuration structure. - Ensured that the example usage aligns with the latest implementation for better clarity and understanding. These updates improve the documentation and help developers understand the new configuration handling. --- packages/browser-sdk/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/browser-sdk/README.md b/packages/browser-sdk/README.md index e3c57fd08..4a3ed79c4 100644 --- a/packages/browser-sdk/README.md +++ b/packages/browser-sdk/README.md @@ -29,7 +29,7 @@ await bucketClient.initialize(); const { isEnabled, - config: { value: question }, + config: { payload: question }, track, requestFeedback, } = bucketClient.getFeature("huddle"); @@ -44,7 +44,7 @@ if (isEnabled) { // The `payload` is a user-supplied JSON in Bucket that is dynamically picked // out depending on the user/company. - const question = value?.question ?? "Tell us what you think of Huddles"; + const question = payload?.question ?? "Tell us what you think of Huddles"; // Use `requestFeedback` to create "Send feedback" buttons easily for specific // features. This is not related to `track` and you can call them individually. From cd276f42b91438230a32bf7abcbb9f101fb4ac31 Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Fri, 24 Jan 2025 10:51:51 +0000 Subject: [PATCH 12/37] feat(openfeature-browser-provider): update browser-sdk dependency and enhance feature evaluation - Updated the dependency on `@bucketco/browser-sdk` from version 2.4.0 to 2.5.1 in both `yarn.lock` and `package.json`. - Refactored feature evaluation methods to improve type handling and support for various data types (boolean, string, number, object). - Enhanced README documentation with new examples for dynamic configuration usage. - Improved test coverage for feature evaluation scenarios, ensuring accurate results across different data types. These changes enhance the functionality and robustness of the openfeature-browser-provider, improving the developer experience and maintainability. --- .../openfeature-browser-provider/README.md | 7 +- .../openfeature-browser-provider/package.json | 2 +- .../src/index.test.ts | 157 +++++++++++++++++- .../openfeature-browser-provider/src/index.ts | 108 +++++++----- yarn.lock | 14 +- 5 files changed, 229 insertions(+), 59 deletions(-) diff --git a/packages/openfeature-browser-provider/README.md b/packages/openfeature-browser-provider/README.md index 4a15758ba..41143a28b 100644 --- a/packages/openfeature-browser-provider/README.md +++ b/packages/openfeature-browser-provider/README.md @@ -36,9 +36,12 @@ const client = OpenFeature.getClient(); // use client const boolValue = client.getBooleanValue("huddles", false); -``` -Bucket only supports boolean values. +// use more complex, dynamic config-enabled functionality. +const feedbackConfig = client.getObjectValue("ask-feedback", { + question: "How are you enjoying this feature?", +}); +``` Initializing the Bucket Browser Provider will also initialize [automatic feedback surveys](https://github.com/bucketco/bucket-javascript-sdk/tree/main/packages/browser-sdk#qualitative-feedback). diff --git a/packages/openfeature-browser-provider/package.json b/packages/openfeature-browser-provider/package.json index 6338dd258..e0ee878f6 100644 --- a/packages/openfeature-browser-provider/package.json +++ b/packages/openfeature-browser-provider/package.json @@ -35,7 +35,7 @@ } }, "dependencies": { - "@bucketco/browser-sdk": "2.4.0" + "@bucketco/browser-sdk": "2.5.1" }, "devDependencies": { "@bucketco/eslint-config": "0.0.2", diff --git a/packages/openfeature-browser-provider/src/index.test.ts b/packages/openfeature-browser-provider/src/index.test.ts index 54b4e7005..c8418a9de 100644 --- a/packages/openfeature-browser-provider/src/index.test.ts +++ b/packages/openfeature-browser-provider/src/index.test.ts @@ -90,22 +90,169 @@ describe("BucketBrowserSDKProvider", () => { }); describe("resolveBooleanEvaluation", () => { - it("calls the client correctly for boolean evaluation", async () => { + function mockFeature( + enabled: boolean, + configKey: string | undefined, + configPayload: any | undefined, + ) { + const config = { + key: configKey, + payload: configPayload, + }; + bucketClientMock.getFeature = vi.fn().mockReturnValue({ - isEnabled: true, + isEnabled: enabled, + config, }); + bucketClientMock.getFeatures = vi.fn().mockReturnValue({ [testFlagKey]: { - isEnabled: true, - targetingVersion: 1, + isEnabled: enabled, + config: { + key: "key", + payload: configPayload, + }, }, }); + } + + it("calls the client correctly when evaluating", async () => { + mockFeature(true, "key", true); await provider.initialize(); - ofClient.getBooleanDetails(testFlagKey, false); + const val = ofClient.getBooleanDetails(testFlagKey, false); + + expect(val).toEqual({ + flagKey: "a-key", + flagMetadata: {}, + reason: "TARGETING_MATCH", + value: true, + }); + expect(bucketClientMock.getFeatures).toHaveBeenCalled(); expect(bucketClientMock.getFeature).toHaveBeenCalledWith(testFlagKey); }); + + it.each([ + [true, true, false, true, "TARGETING_MATCH"], + [true, false, false, true, "TARGETING_MATCH"], + [true, null, false, true, "TARGETING_MATCH"], + [true, { obj: true }, false, true, "TARGETING_MATCH"], + [true, 15, false, true, "TARGETING_MATCH"], + [false, true, false, false, "DISABLED"], + [false, true, true, true, "DISABLED"], + ])( + "should return the correct result when evaluating boolean %s, %s, %s, %s, %s`", + async (enabled, value, def, expected, reason) => { + mockFeature(enabled, "key", value); + expect(ofClient.getBooleanDetails(testFlagKey, def)).toEqual({ + flagKey: "a-key", + flagMetadata: {}, + reason: reason, + value: expected, + }); + }, + ); + + it.each([ + [true, 1, -1, 1, "TARGETING_MATCH"], + [true, null, -2, -2, "DEFAULT"], + [false, 3, -3, -3, "DISABLED"], + [false, 4, -4, -4, "DISABLED"], + ])( + "should return the correct result when evaluating number %s, %s, %s, %s, %s`", + async (enabled, value, def, expected, reason) => { + mockFeature(enabled, value ? "key" : undefined, value); + expect(ofClient.getNumberDetails(testFlagKey, def)).toEqual({ + flagKey: "a-key", + flagMetadata: {}, + reason: reason, + value: expected, + }); + }, + ); + + it.each([["string"], [true], [{}]])( + "should handle type mismatch when evaluating number as %s`", + async (value) => { + mockFeature(true, "key", value); + expect(ofClient.getNumberDetails(testFlagKey, -1)).toEqual({ + flagKey: "a-key", + flagMetadata: {}, + reason: "ERROR", + errorCode: "TYPE_MISMATCH", + errorMessage: "", + value: -1, + }); + }, + ); + + it.each([ + [true, { anything: 1 }, "default", "key", "TARGETING_MATCH"], + [false, 1337, "default", "default", "DISABLED"], + ])( + "should return the correct result when evaluating string %s, %s, %s, %s, %s`", + async (enabled, value, def, expected, reason) => { + mockFeature(enabled, "key", value); + expect(ofClient.getStringDetails(testFlagKey, def)).toEqual({ + flagKey: "a-key", + flagMetadata: {}, + reason: reason, + value: expected, + }); + }, + ); + + it.each([ + [true, [], [1], [], "TARGETING_MATCH"], + [true, null, [2], [2], "DEFAULT"], + [false, [3], [4], [4], "DISABLED"], + [false, [5], [6], [6], "DISABLED"], + ])( + "should return the correct result when evaluating array %s, %s, %s, %s, %s`", + async (enabled, value, def, expected, reason) => { + mockFeature(enabled, value ? "key" : undefined, value); + expect(ofClient.getObjectDetails(testFlagKey, def)).toEqual({ + flagKey: "a-key", + flagMetadata: {}, + reason: reason, + value: expected, + }); + }, + ); + + it.each([ + [true, {}, { a: 1 }, {}, "TARGETING_MATCH"], + [true, null, { a: 2 }, { a: 2 }, "DEFAULT"], + [false, { a: 3 }, { a: 4 }, { a: 4 }, "DISABLED"], + [false, { a: 5 }, { a: 6 }, { a: 6 }, "DISABLED"], + ])( + "should return the correct result when evaluating object %s, %s, %s, %s, %s`", + async (enabled, value, def, expected, reason) => { + mockFeature(enabled, value ? "key" : undefined, value); + expect(ofClient.getObjectDetails(testFlagKey, def)).toEqual({ + flagKey: "a-key", + flagMetadata: {}, + reason: reason, + value: expected, + }); + }, + ); + + it.each([["string"], [15], [true]])( + "should handle type mismatch when evaluating object as %s`", + async (value) => { + mockFeature(true, "key", value); + expect(ofClient.getObjectDetails(testFlagKey, { obj: true })).toEqual({ + flagKey: "a-key", + flagMetadata: {}, + reason: "ERROR", + errorCode: "TYPE_MISMATCH", + errorMessage: "", + value: { obj: true }, + }); + }, + ); }); describe("track", () => { diff --git a/packages/openfeature-browser-provider/src/index.ts b/packages/openfeature-browser-provider/src/index.ts index 48894ecb7..df10699c6 100644 --- a/packages/openfeature-browser-provider/src/index.ts +++ b/packages/openfeature-browser-provider/src/index.ts @@ -44,8 +44,8 @@ export class BucketBrowserSDKProvider implements Provider { private _client?: BucketClient; - private _clientOptions: InitOptions; - private _contextTranslator: ContextTranslationFn; + private readonly _clientOptions: InitOptions; + private readonly _contextTranslator: ContextTranslationFn; public events = new OpenFeatureEventEmitter(); @@ -100,66 +100,96 @@ export class BucketBrowserSDKProvider implements Provider { await this.initialize(newContext); } - resolveBooleanEvaluation( + private resolveFeature( flagKey: string, - defaultValue: boolean, - ): ResolutionDetails { - if (!this._client) + defaultValue: T, + ): ResolutionDetails { + const expType = typeof defaultValue; + + if (!this._client) { return { value: defaultValue, reason: StandardResolutionReasons.DEFAULT, errorCode: ErrorCode.PROVIDER_NOT_READY, - } satisfies ResolutionDetails; + errorMessage: "Bucket client not initialized", + } satisfies ResolutionDetails; + } const features = this._client.getFeatures(); if (flagKey in features) { const feature = this._client.getFeature(flagKey); + + if (!feature.isEnabled) { + return { + value: defaultValue, + reason: StandardResolutionReasons.DISABLED, + }; + } + + if (expType === "boolean") { + return { + value: true as T, + reason: StandardResolutionReasons.TARGETING_MATCH, + }; + } + + if (!feature.config.key) { + return { + value: defaultValue, + reason: StandardResolutionReasons.DEFAULT, + }; + } + + if (expType === "string") { + return { + value: feature.config.payload as T, + reason: StandardResolutionReasons.TARGETING_MATCH, + }; + } + + if (typeof feature.config.payload !== expType) { + return { + value: defaultValue, + reason: StandardResolutionReasons.ERROR, + errorCode: ErrorCode.TYPE_MISMATCH, + errorMessage: `Expected ${expType} but got ${typeof feature.config.payload}`, + }; + } + return { - value: feature.isEnabled, + value: feature.config.payload as T, reason: StandardResolutionReasons.TARGETING_MATCH, - } satisfies ResolutionDetails; + }; } return { value: defaultValue, reason: StandardResolutionReasons.DEFAULT, - } satisfies ResolutionDetails; + errorCode: ErrorCode.FLAG_NOT_FOUND, + errorMessage: `Flag ${flagKey} not found`, + }; } - resolveNumberEvaluation( - _flagKey: string, - defaultValue: number, - ): ResolutionDetails { - return { - value: defaultValue, - errorCode: ErrorCode.TYPE_MISMATCH, - reason: StandardResolutionReasons.ERROR, - errorMessage: "Bucket doesn't support number flags", - }; + resolveBooleanEvaluation(flagKey: string, defaultValue: boolean) { + return this.resolveFeature(flagKey, defaultValue); + } + + resolveNumberEvaluation(flagKey: string, defaultValue: number) { + return this.resolveFeature(flagKey, defaultValue); } resolveObjectEvaluation( - _flagKey: string, + flagKey: string, defaultValue: T, - ): ResolutionDetails { - return { - value: defaultValue, - errorCode: ErrorCode.TYPE_MISMATCH, - reason: StandardResolutionReasons.ERROR, - errorMessage: "Bucket doesn't support object flags", - }; + ) { + return this.resolveFeature(flagKey, defaultValue); } resolveStringEvaluation( - _flagKey: string, + flagKey: string, defaultValue: string, ): ResolutionDetails { - return { - value: defaultValue, - errorCode: ErrorCode.TYPE_MISMATCH, - reason: StandardResolutionReasons.ERROR, - errorMessage: "Bucket doesn't support string flags", - }; + return this.resolveFeature(flagKey, defaultValue); } track( @@ -171,8 +201,10 @@ export class BucketBrowserSDKProvider implements Provider { this._clientOptions.logger?.error("client not initialized"); } - this._client?.track(trackingEventName, trackingEventDetails).catch((e) => { - this._clientOptions.logger?.error("error tracking event", e); - }); + this._client + ?.track(trackingEventName, trackingEventDetails) + .catch((e: any) => { + this._clientOptions.logger?.error("error tracking event", e); + }); } } diff --git a/yarn.lock b/yarn.lock index 812c4c4b1..45002e2f6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -882,18 +882,6 @@ __metadata: languageName: node linkType: hard -"@bucketco/browser-sdk@npm:2.4.0": - version: 2.4.0 - resolution: "@bucketco/browser-sdk@npm:2.4.0" - dependencies: - "@floating-ui/dom": "npm:^1.6.8" - canonical-json: "npm:^0.0.4" - js-cookie: "npm:^3.0.5" - preact: "npm:^10.22.1" - checksum: 10c0/b33a9fdafa4a857ac4f815fe69b602b37527a37d54270abd479b754da998d030f5a70b738c662ab57fa4f6374b8e1fbd052feb8bdbd8b78367086dcedc5a5432 - languageName: node - linkType: hard - "@bucketco/browser-sdk@npm:2.5.1, @bucketco/browser-sdk@workspace:packages/browser-sdk": version: 0.0.0-use.local resolution: "@bucketco/browser-sdk@workspace:packages/browser-sdk" @@ -984,7 +972,7 @@ __metadata: version: 0.0.0-use.local resolution: "@bucketco/openfeature-browser-provider@workspace:packages/openfeature-browser-provider" dependencies: - "@bucketco/browser-sdk": "npm:2.4.0" + "@bucketco/browser-sdk": "npm:2.5.1" "@bucketco/eslint-config": "npm:0.0.2" "@bucketco/tsconfig": "npm:0.0.2" "@openfeature/core": "npm:1.5.0" From e61be445a519dcfdf419545f906572b02fb4cdcb Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Mon, 27 Jan 2025 18:27:20 +0000 Subject: [PATCH 13/37] feat(node-sdk): enhance feature configuration and fallback handling - Added support for more flexible fallback features with object-based configuration - Introduced new types for remote feature configurations - Updated client methods to handle feature configs with payload and targeting version - Improved type safety and configuration handling in feature evaluation - Extended feature override mechanisms to support config-based overrides These changes provide more robust and flexible feature flag configuration in the Node.js SDK. --- packages/node-sdk/src/client.ts | 132 +++++++++--- packages/node-sdk/src/config.ts | 18 +- packages/node-sdk/src/types.ts | 114 +++++++++- packages/node-sdk/test/client.test.ts | 279 ++++++++++++++++++++++--- packages/node-sdk/test/config.test.ts | 8 + packages/node-sdk/test/testConfig.json | 6 +- 6 files changed, 477 insertions(+), 80 deletions(-) diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index 49c3308c5..6dbbef302 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -18,8 +18,10 @@ import { newRateLimiter } from "./rate-limiter"; import type { EvaluatedFeaturesAPIResponse, FeatureOverridesFn, + FeatureRemoteConfig, IdType, RawFeature, + RawFeatureRemoteConfig, } from "./types"; import { Attributes, @@ -102,6 +104,7 @@ export class BucketClient { offline: boolean; configFile?: string; }; + private _initialize = once(async () => { if (!this._config.offline) { await this.getFeaturesCache().refresh(); @@ -140,8 +143,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 +183,32 @@ 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, value]) => { + acc[key as keyof TypedFeatures] = { + isEnabled: !!value, + key, + config: isObject(value) + ? (value as RawFeatureRemoteConfig) + : undefined, + }; + return acc; + }, + {} as Record, + ) + : undefined; this._config = { logger, @@ -451,6 +469,7 @@ export class BucketClient { key, isEnabled: feature?.isEnabled ?? false, targetingVersion: feature?.targetingVersion, + config: feature?.config, }); } @@ -825,6 +844,33 @@ export class BucketClient { }), ); + const evaluatedConfigs = evaluated + .filter(({ value }) => value) + .reduce( + (acc, { featureKey }) => { + const feature = featureDefinitions.find((f) => f.key === 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 }) => ({ @@ -859,7 +905,9 @@ export class BucketClient { acc[res.featureKey as keyof TypedFeatures] = { key: res.featureKey, isEnabled: res.value ?? false, + config: evaluatedConfigs[res.featureKey], targetingVersion: keyToVersionMap.get(res.featureKey), + missingContextFields: res.missingContextFields, }; return acc; }, @@ -869,7 +917,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: !!override, + config: isObject(override) ? override : undefined, + }, + ]); if (overrides.length > 0) { // merge overrides into evaluated features @@ -878,40 +933,47 @@ export class BucketClient { ...Object.fromEntries(overrides), }; } - this._config.logger?.debug("evaluated features", evaluatedFeatures); return evaluatedFeatures; } private _wrapRawFeature( options: { enableTracking: boolean } & Context, - { key, isEnabled, targetingVersion }: RawFeature, + { key, isEnabled, config, targetingVersion }: RawFeature, ): Feature { // 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 } + : ({} as FeatureRemoteConfig); + 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; }, key, track: async () => { @@ -949,6 +1011,7 @@ export class BucketClient { ...context, enableTracking: true, }; + checkContextWithTracking(contextWithTracking); const params = new URLSearchParams( @@ -968,6 +1031,7 @@ export class BucketClient { context, Object.values(res.features), ); + return Object.fromEntries( Object.entries(res.features).map(([featureKey, feature]) => { return [ diff --git a/packages/node-sdk/src/config.ts b/packages/node-sdk/src/config.ts index 503629c2a..f0a6229b4 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,17 @@ 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) && "key" in value), + `invalid type "${typeof value}" for key ${key}, expected boolean or object with key`, ); }); - return overrides; + + return config.featureOverrides; } + return {}; } diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 44d25a505..c766f567d 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 user-supplied data. + */ + payload: any; +}; + +/** + * Describes a feature. */ export interface RawFeature { /** @@ -74,12 +99,34 @@ export interface RawFeature { */ targetingVersion?: number; + /** + * The remote configuration value for the feature. + */ + config?: RawFeatureRemoteConfig; + /** * The missing fields in the evaluation context (optional). */ missingContextFields?: string[]; } +/** + * A remotely managed configuration value for a feature. + */ +export type FeatureRemoteConfig = + | { + /** + * The key of the matched configuration value. + */ + key: string; + + /** + * The user-supplied data. + */ + payload: any; + } + | { key: undefined; payload: undefined }; + /** * Describes a feature */ @@ -94,10 +141,10 @@ export interface Feature { */ isEnabled: boolean; - /** - * Optional user-defined configuration if the feature is enabled. + /* + * Optional user-defined configuration. */ - config: any; + config: FeatureRemoteConfig; /** * Track feature usage in Bucket. @@ -125,12 +172,49 @@ export type TypedFeatures = keyof Features extends never ? Record : Record; +type TypedFeatureKey = keyof TypedFeatures; + +type FeatureOverride = + | { + key: string; + payload: any; + } + | boolean; + /** * Describes the feature overrides. */ -export type FeatureOverrides = Partial>; +export type FeatureOverrides = Partial< + Record +>; + export type FeatureOverridesFn = (context: Context) => FeatureOverrides; +/** + * Describes a remote feature config variant. + */ +export type FeatureConfigVariant = { + /** + * The filter for the variant. + */ + filter: RuleFilter; + + /** + * The payload for the variant. + */ + payload: any; + + /** + * The key of the variant. + */ + key: string; + + /** + * Indicates if the variant is the default variant. + */ + default: boolean; +}; + /** * Describes a specific feature in the API response */ @@ -142,6 +226,10 @@ type FeatureAPIResponse = { filter: RuleFilter; }[]; }; + config?: { + version: number; + variants: FeatureConfigVariant[]; + }; }; /** @@ -335,8 +423,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 +446,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 201d4879a..ca9be43a9 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,52 @@ 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: { + key: "config1", + payload: { value: true }, + }, + }, + }); + + expect(bucketInstance["_config"].fallbackFeatures).toEqual({ + feature1: { + key: "feature1", + isEnabled: true, + }, + feature2: { + key: "feature2", + isEnabled: true, + config: { + key: "config1", + payload: { value: true }, + }, + }, + }); + }); + it("should create a client instance with valid options", () => { const client = new BucketClient(validOptions); @@ -287,7 +355,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 +973,8 @@ describe("BucketClient", () => { describe("getFeature", () => { let client: BucketClient; + let featureEvalSequence: Record; + beforeEach(async () => { httpClient.get.mockResolvedValue({ ok: true, @@ -917,12 +987,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 +1025,6 @@ describe("BucketClient", () => { }); it("returns a feature", async () => { - // test that the feature is returned await client.initialize(); const feature = client.getFeature( { @@ -951,9 +1035,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 +1160,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 +1267,7 @@ describe("BucketClient", () => { describe("getFeatures", () => { let client: BucketClient; + let featureEvalSequence: Record; beforeEach(async () => { httpClient.get.mockResolvedValue({ @@ -1149,12 +1281,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 +1331,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: {}, 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 +1442,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: {}, 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 +1519,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: {}, 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 +1596,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: {}, track: expect.any(Function), }, }); await client.flush(); - expect(evaluateFeatureRules).toHaveBeenCalledTimes(2); + expect(evaluateFeatureRules).toHaveBeenCalledTimes(3); expect(httpClient.post).not.toHaveBeenCalled(); }); @@ -1453,7 +1628,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 +1780,10 @@ describe("BucketClient", () => { "key", ); - expect(result).toEqual({ + expect(result).toStrictEqual({ key: "key", isEnabled: true, + config: {}, track: expect.any(Function), }); @@ -1648,15 +1824,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: {}, track: expect.any(Function), }, }); @@ -1674,10 +1857,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 +1886,31 @@ 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: {}, track: expect.any(Function), - config: undefined, }, }); client.featureOverrides = (_context: Context) => { - expect(context).toEqual(context); + expect(context).toStrictEqual(context); return { feature1: false, feature2: true, + feature3: { + key: "config-1", + payload: { something: "else" }, + }, }; }; const features = client.getFeatures(context); @@ -1721,14 +1919,23 @@ describe("BucketClient", () => { feature1: { key: "feature1", isEnabled: false, + config: {}, track: expect.any(Function), - config: undefined, }, feature2: { key: "feature2", isEnabled: true, + config: {}, + track: expect.any(Function), + }, + feature3: { + key: "feature3", + isEnabled: true, + config: { + key: "config-1", + payload: { something: "else" }, + }, track: expect.any(Function), - config: undefined, }, }); }); @@ -1749,6 +1956,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 +1985,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: {}, track: expect.any(Function), }, }); @@ -1827,6 +2045,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 +2069,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 +2297,17 @@ describe("BoundBucketClient", () => { const result = await boundClient.getFeaturesRemote(); - expect(result).toEqual({ + expect(result).toStrictEqual({ feature1: { key: "feature1", isEnabled: true, + config: {}, track: expect.any(Function), }, feature2: { key: "feature2", isEnabled: false, + config: {}, track: expect.any(Function), }, }); @@ -2099,9 +2329,10 @@ describe("BoundBucketClient", () => { const result = await boundClient.getFeatureRemote("feature1"); - expect(result).toEqual({ + expect(result).toStrictEqual({ key: "feature1", isEnabled: true, + config: {}, track: expect.any(Function), }); diff --git a/packages/node-sdk/test/config.test.ts b/packages/node-sdk/test/config.test.ts index 2e31670c9..d77c23d38 100644 --- a/packages/node-sdk/test/config.test.ts +++ b/packages/node-sdk/test/config.test.ts @@ -10,6 +10,10 @@ describe("config tests", () => { featureOverrides: { myFeature: true, myFeatureFalse: false, + myFeatureWithConfig: { + key: "config-1", + payload: { something: "else" }, + }, }, secretKey: "mySecretKey", offline: true, @@ -31,6 +35,10 @@ describe("config tests", () => { myFeatureFalse: false, myNewFeature: true, myNewFeatureFalse: false, + myFeatureWithConfig: { + 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 311bf1948..246363b41 100644 --- a/packages/node-sdk/test/testConfig.json +++ b/packages/node-sdk/test/testConfig.json @@ -1,7 +1,11 @@ { "featureOverrides": { "myFeature": true, - "myFeatureFalse": false + "myFeatureFalse": false, + "myFeatureWithConfig": { + "key": "config-1", + "payload": { "something": "else" } + } }, "secretKey": "mySecretKey", "offline": true, From 3d125810d4b476b7435734ac76b42a677ea0a385 Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Mon, 27 Jan 2025 18:44:18 +0000 Subject: [PATCH 14/37] docs(node-sdk): update README with remote configuration and type-safe feature flags - Added documentation for remote feature configuration with key-payload pairs - Updated README examples to demonstrate remote config usage - Enhanced type-safe feature flags section with more detailed examples - Included configuration override examples with complex feature configurations The changes improve the clarity and comprehensiveness of the Node.js SDK documentation. --- packages/node-sdk/README.md | 48 ++++++++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/packages/node-sdk/README.md b/packages/node-sdk/README.md index 765cb46cd..0e80ad153 100644 --- a/packages/node-sdk/README.md +++ b/packages/node-sdk/README.md @@ -72,13 +72,18 @@ const boundClient = bucketClient.bindClient({ // get the huddle feature using company, user and custom context to // evaluate the targeting. -const { isEnabled, track } = boundClient.getFeature("huddle"); +const { isEnabled, track, config } = boundClient.getFeature("huddle"); if (isEnabled) { // this is your feature gated code ... // send an event when the feature is used: track(); + if (config?.key === "zoom") { + // this code will run if a given remote configuration + // is set up. + } + // CAUTION: if you plan to use the event for automated feedback surveys // call `flush` immediately after `track`. It can optionally be awaited // to guarantee the sent happened. @@ -106,6 +111,28 @@ 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 configuration + +Bucket supports remote feature configuration. This functionality allows you to setup +an arbitrary number of key-payload pairs that are matched against the same context +that is used to evaluate whether the feature is enabled or not. The selected pair +is then, returned to the caller and can be accessed using the `config` property. + +The methods: `getFeature()`, `getFeatures()`, `getFeatureRemote()` and `getFeaturesRemote()`, +will include this config when the feature(s) are evaluated. In case no configuration pair +matched (or was configured), these methods will return an empty object. You can, thus, safely +deconstruct the `config` object in your code: + +```typescript +const { + isEnabled, + config: { key, payload }, +} = boundClient.getFeature("huddles"); +if (isEnabled && key === "premium") { + // ... your code +} +``` + ## Configuring The Bucket `Node.js` SDK can be configured through environment variables, @@ -134,7 +161,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 + } + } } } ``` @@ -161,7 +194,12 @@ declare module "@bucketco/node-sdk" { interface Features { "show-todos": boolean; "create-todos": boolean; - "delete-todos": boolean; + "delete-todos": { + key: string, + payload: { + someKey: string, + } + }; } } @@ -170,6 +208,10 @@ export const bucketClient = new BucketClient(); bucketClient.initialize().then({ console.log("Bucket initialized!") bucketClient.getFeature("invalid-feature") // feature doesn't exist + + // this will print out the value of the "key" as specified as a value to + // `delete-todos` feature. + console.log(bucketClient.getFeature("delete-todos").config.key) }) ``` From 295f05d80ffb499225d090b3d57a1fae69c46e76 Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Mon, 27 Jan 2025 18:54:18 +0000 Subject: [PATCH 15/37] fix(browser-sdk): update feature remote config type definition Simplified the feature remote config type by removing unnecessary properties and aligning with recent configuration changes. Minor refactoring of sendCheckEvent method to use shorthand property syntax. --- packages/browser-sdk/src/client.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index a34c94e0b..94f27984d 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -218,7 +218,7 @@ export type FeatureRemoteConfig = */ payload: any; } - | { key: undefined; targetingVersion: undefined; value: undefined }; + | { key: undefined; payload: undefined }; /** * A feature. @@ -616,7 +616,7 @@ export class BucketClient { function sendCheckEvent() { fClient .sendCheckEvent({ - key: key, + key, version: f?.targetingVersion, value, }) From 0dd5757a40d9e956f6f5ae65a099953404b556e9 Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Mon, 27 Jan 2025 18:59:10 +0000 Subject: [PATCH 16/37] chore(react-sdk): remove unused import from index.tsx Cleaned up an unnecessary import of Features from a development-specific path in the main index file. --- packages/react-sdk/src/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/react-sdk/src/index.tsx b/packages/react-sdk/src/index.tsx index e1203c289..e2ee4e7f0 100644 --- a/packages/react-sdk/src/index.tsx +++ b/packages/react-sdk/src/index.tsx @@ -21,7 +21,6 @@ import { UnassignedFeedback, } from "@bucketco/browser-sdk"; -import { Features } from "../dev/nextjs-flag-demo/components/Flags"; import { version } from "../package.json"; export interface Features {} From 717342364744248eb417653a968b8c11c4d86928 Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Mon, 27 Jan 2025 19:11:01 +0000 Subject: [PATCH 17/37] feat(browser-sdk): enhance fallback feature configuration handling Update FallbackFeatureConfig type to support boolean and object overrides, allowing more flexible feature flag configuration. Modify feature fallback logic to handle both simple boolean flags and complex feature configurations. --- packages/browser-sdk/src/feature/features.ts | 37 +++++++++++--------- packages/browser-sdk/test/features.test.ts | 7 ++++ 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/packages/browser-sdk/src/feature/features.ts b/packages/browser-sdk/src/feature/features.ts index 79d18d525..3e4a89b9a 100644 --- a/packages/browser-sdk/src/feature/features.ts +++ b/packages/browser-sdk/src/feature/features.ts @@ -63,10 +63,12 @@ export type RawFeature = FetchedFeature & { export type RawFeatures = Record; -export type FallbackFeatureConfig = { - key: string; - payload: any; -} | null; +export type FallbackFeatureOverride = + | { + key: string; + payload: any; + } + | true; export type FeaturesOptions = { /** @@ -75,7 +77,7 @@ export type FeaturesOptions = { * is supplied instead of array, the values of each key represent the * configuration values and `isEnabled` is assume `true`. */ - fallbackFeatures?: string[] | Record; + fallbackFeatures?: string[] | Record; /** * Timeout in milliseconds when fetching features @@ -99,7 +101,7 @@ export type FeaturesOptions = { }; type Config = { - fallbackFeatures: Record; + fallbackFeatures: Record; timeoutMs: number; staleWhileRevalidate: boolean; }; @@ -236,15 +238,15 @@ export class FeaturesClient { expireTimeMs: options?.expireTimeMs ?? FEATURES_EXPIRE_MS, }); - let fallbackFeatures: Record; + let fallbackFeatures: Record; if (Array.isArray(options?.fallbackFeatures)) { fallbackFeatures = options.fallbackFeatures.reduce( (acc, key) => { - acc[key] = null; + acc[key] = true; return acc; }, - {} as Record, + {} as Record, ); } else { fallbackFeatures = options?.fallbackFeatures ?? {}; @@ -477,16 +479,17 @@ export class FeaturesClient { // fetch failed, nothing cached => return fallbacks return Object.entries(this.config.fallbackFeatures).reduce( - (acc, [key, config]) => { + (acc, [key, override]) => { acc[key] = { key, - isEnabled: true, - config: config - ? { - key: config.key, - payload: config.payload, - } - : undefined, + isEnabled: !!override, + config: + typeof override === "object" && "key" in override + ? { + key: override.key, + payload: override.payload, + } + : undefined, }; return acc; }, diff --git a/packages/browser-sdk/test/features.test.ts b/packages/browser-sdk/test/features.test.ts index 98104f253..dcb4fb52b 100644 --- a/packages/browser-sdk/test/features.test.ts +++ b/packages/browser-sdk/test/features.test.ts @@ -153,6 +153,7 @@ describe("FeaturesClient", () => { key: "john", payload: { something: "else" }, }, + zoom: true, }, }); @@ -164,6 +165,12 @@ describe("FeaturesClient", () => { key: "huddle", isEnabledOverride: null, }, + zoom: { + isEnabled: true, + config: undefined, + key: "zoom", + isEnabledOverride: null, + }, }); }); From 862df2508fe14f6b172dfe3c6899faa6f5a5b98d Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Mon, 27 Jan 2025 19:12:12 +0000 Subject: [PATCH 18/37] test(browser-sdk): remove .only from features test Remove the .only modifier from the "caches response" test to ensure all tests are run during test execution. --- packages/browser-sdk/test/features.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/browser-sdk/test/features.test.ts b/packages/browser-sdk/test/features.test.ts index dcb4fb52b..b7f611085 100644 --- a/packages/browser-sdk/test/features.test.ts +++ b/packages/browser-sdk/test/features.test.ts @@ -174,7 +174,7 @@ describe("FeaturesClient", () => { }); }); - test.only("caches response", async () => { + test("caches response", async () => { const { newFeaturesClient, httpClient } = featuresClientFactory(); const featuresClient1 = newFeaturesClient(); From bfb427690dbc3dc68a2929421554e174612c2fed Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Mon, 27 Jan 2025 19:22:21 +0000 Subject: [PATCH 19/37] test(browser-sdk): update feature test assertions Modify feature test to reflect updated feature configuration handling, including changes to feature state and override expectations. --- packages/browser-sdk/test/features.test.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/browser-sdk/test/features.test.ts b/packages/browser-sdk/test/features.test.ts index b7f611085..9213a939d 100644 --- a/packages/browser-sdk/test/features.test.ts +++ b/packages/browser-sdk/test/features.test.ts @@ -354,15 +354,12 @@ describe("FeaturesClient", () => { updated = true; }); - expect(client.getFeatures().featureB.isEnabled).toBe(false); + expect(client.getFeatures().featureB.isEnabled).toBe(true); expect(client.getFeatures().featureB.isEnabledOverride).toBe(null); - expect(client.getFetchedFeatures()?.featureB).toBeUndefined(); - client.setFeatureOverride("featureC", true); expect(updated).toBe(true); - expect(client.getFeatures().featureC.isEnabled).toBe(false); - expect(client.getFeatures().featureC.isEnabledOverride).toBe(true); + expect(client.getFeatures().featureC).toBeUndefined(); }); }); From e22475b6c7857fa8a646ffed7cb9c2e0985fa600 Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Mon, 27 Jan 2025 19:24:43 +0000 Subject: [PATCH 20/37] test(browser-sdk): update feature test expectations Modify test assertions to reflect changes in feature configuration handling, including expected feature state and override behavior. --- packages/browser-sdk/test/features.test.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/browser-sdk/test/features.test.ts b/packages/browser-sdk/test/features.test.ts index b7f611085..9213a939d 100644 --- a/packages/browser-sdk/test/features.test.ts +++ b/packages/browser-sdk/test/features.test.ts @@ -354,15 +354,12 @@ describe("FeaturesClient", () => { updated = true; }); - expect(client.getFeatures().featureB.isEnabled).toBe(false); + expect(client.getFeatures().featureB.isEnabled).toBe(true); expect(client.getFeatures().featureB.isEnabledOverride).toBe(null); - expect(client.getFetchedFeatures()?.featureB).toBeUndefined(); - client.setFeatureOverride("featureC", true); expect(updated).toBe(true); - expect(client.getFeatures().featureC.isEnabled).toBe(false); - expect(client.getFeatures().featureC.isEnabledOverride).toBe(true); + expect(client.getFeatures().featureC).toBeUndefined(); }); }); From 7d454860d813b1948dc15d43c93b75652e8ce323 Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Mon, 27 Jan 2025 19:30:49 +0000 Subject: [PATCH 21/37] chore: remove artifacts from this PR --- .../openfeature-browser-provider/README.md | 7 +- .../openfeature-browser-provider/package.json | 2 +- .../src/index.test.ts | 158 +----------------- .../openfeature-browser-provider/src/index.ts | 108 +++++------- yarn.lock | 10 +- 5 files changed, 52 insertions(+), 233 deletions(-) diff --git a/packages/openfeature-browser-provider/README.md b/packages/openfeature-browser-provider/README.md index 41143a28b..4a15758ba 100644 --- a/packages/openfeature-browser-provider/README.md +++ b/packages/openfeature-browser-provider/README.md @@ -36,13 +36,10 @@ const client = OpenFeature.getClient(); // use client const boolValue = client.getBooleanValue("huddles", false); - -// use more complex, dynamic config-enabled functionality. -const feedbackConfig = client.getObjectValue("ask-feedback", { - question: "How are you enjoying this feature?", -}); ``` +Bucket only supports boolean values. + Initializing the Bucket Browser Provider will also initialize [automatic feedback surveys](https://github.com/bucketco/bucket-javascript-sdk/tree/main/packages/browser-sdk#qualitative-feedback). diff --git a/packages/openfeature-browser-provider/package.json b/packages/openfeature-browser-provider/package.json index e0ee878f6..6338dd258 100644 --- a/packages/openfeature-browser-provider/package.json +++ b/packages/openfeature-browser-provider/package.json @@ -35,7 +35,7 @@ } }, "dependencies": { - "@bucketco/browser-sdk": "2.5.1" + "@bucketco/browser-sdk": "2.4.0" }, "devDependencies": { "@bucketco/eslint-config": "0.0.2", diff --git a/packages/openfeature-browser-provider/src/index.test.ts b/packages/openfeature-browser-provider/src/index.test.ts index c8418a9de..0ff0c412f 100644 --- a/packages/openfeature-browser-provider/src/index.test.ts +++ b/packages/openfeature-browser-provider/src/index.test.ts @@ -90,169 +90,22 @@ describe("BucketBrowserSDKProvider", () => { }); describe("resolveBooleanEvaluation", () => { - function mockFeature( - enabled: boolean, - configKey: string | undefined, - configPayload: any | undefined, - ) { - const config = { - key: configKey, - payload: configPayload, - }; - + it("calls the client correctly for boolean evaluation", async () => { bucketClientMock.getFeature = vi.fn().mockReturnValue({ - isEnabled: enabled, - config, + isEnabled: true, }); - bucketClientMock.getFeatures = vi.fn().mockReturnValue({ [testFlagKey]: { - isEnabled: enabled, - config: { - key: "key", - payload: configPayload, - }, + isEnabled: true, + targetingVersion: 1, }, }); - } - - it("calls the client correctly when evaluating", async () => { - mockFeature(true, "key", true); await provider.initialize(); - const val = ofClient.getBooleanDetails(testFlagKey, false); - - expect(val).toEqual({ - flagKey: "a-key", - flagMetadata: {}, - reason: "TARGETING_MATCH", - value: true, - }); - + ofClient.getBooleanDetails(testFlagKey, false); expect(bucketClientMock.getFeatures).toHaveBeenCalled(); expect(bucketClientMock.getFeature).toHaveBeenCalledWith(testFlagKey); }); - - it.each([ - [true, true, false, true, "TARGETING_MATCH"], - [true, false, false, true, "TARGETING_MATCH"], - [true, null, false, true, "TARGETING_MATCH"], - [true, { obj: true }, false, true, "TARGETING_MATCH"], - [true, 15, false, true, "TARGETING_MATCH"], - [false, true, false, false, "DISABLED"], - [false, true, true, true, "DISABLED"], - ])( - "should return the correct result when evaluating boolean %s, %s, %s, %s, %s`", - async (enabled, value, def, expected, reason) => { - mockFeature(enabled, "key", value); - expect(ofClient.getBooleanDetails(testFlagKey, def)).toEqual({ - flagKey: "a-key", - flagMetadata: {}, - reason: reason, - value: expected, - }); - }, - ); - - it.each([ - [true, 1, -1, 1, "TARGETING_MATCH"], - [true, null, -2, -2, "DEFAULT"], - [false, 3, -3, -3, "DISABLED"], - [false, 4, -4, -4, "DISABLED"], - ])( - "should return the correct result when evaluating number %s, %s, %s, %s, %s`", - async (enabled, value, def, expected, reason) => { - mockFeature(enabled, value ? "key" : undefined, value); - expect(ofClient.getNumberDetails(testFlagKey, def)).toEqual({ - flagKey: "a-key", - flagMetadata: {}, - reason: reason, - value: expected, - }); - }, - ); - - it.each([["string"], [true], [{}]])( - "should handle type mismatch when evaluating number as %s`", - async (value) => { - mockFeature(true, "key", value); - expect(ofClient.getNumberDetails(testFlagKey, -1)).toEqual({ - flagKey: "a-key", - flagMetadata: {}, - reason: "ERROR", - errorCode: "TYPE_MISMATCH", - errorMessage: "", - value: -1, - }); - }, - ); - - it.each([ - [true, { anything: 1 }, "default", "key", "TARGETING_MATCH"], - [false, 1337, "default", "default", "DISABLED"], - ])( - "should return the correct result when evaluating string %s, %s, %s, %s, %s`", - async (enabled, value, def, expected, reason) => { - mockFeature(enabled, "key", value); - expect(ofClient.getStringDetails(testFlagKey, def)).toEqual({ - flagKey: "a-key", - flagMetadata: {}, - reason: reason, - value: expected, - }); - }, - ); - - it.each([ - [true, [], [1], [], "TARGETING_MATCH"], - [true, null, [2], [2], "DEFAULT"], - [false, [3], [4], [4], "DISABLED"], - [false, [5], [6], [6], "DISABLED"], - ])( - "should return the correct result when evaluating array %s, %s, %s, %s, %s`", - async (enabled, value, def, expected, reason) => { - mockFeature(enabled, value ? "key" : undefined, value); - expect(ofClient.getObjectDetails(testFlagKey, def)).toEqual({ - flagKey: "a-key", - flagMetadata: {}, - reason: reason, - value: expected, - }); - }, - ); - - it.each([ - [true, {}, { a: 1 }, {}, "TARGETING_MATCH"], - [true, null, { a: 2 }, { a: 2 }, "DEFAULT"], - [false, { a: 3 }, { a: 4 }, { a: 4 }, "DISABLED"], - [false, { a: 5 }, { a: 6 }, { a: 6 }, "DISABLED"], - ])( - "should return the correct result when evaluating object %s, %s, %s, %s, %s`", - async (enabled, value, def, expected, reason) => { - mockFeature(enabled, value ? "key" : undefined, value); - expect(ofClient.getObjectDetails(testFlagKey, def)).toEqual({ - flagKey: "a-key", - flagMetadata: {}, - reason: reason, - value: expected, - }); - }, - ); - - it.each([["string"], [15], [true]])( - "should handle type mismatch when evaluating object as %s`", - async (value) => { - mockFeature(true, "key", value); - expect(ofClient.getObjectDetails(testFlagKey, { obj: true })).toEqual({ - flagKey: "a-key", - flagMetadata: {}, - reason: "ERROR", - errorCode: "TYPE_MISMATCH", - errorMessage: "", - value: { obj: true }, - }); - }, - ); }); describe("track", () => { @@ -280,3 +133,4 @@ describe("BucketBrowserSDKProvider", () => { }); }); }); + diff --git a/packages/openfeature-browser-provider/src/index.ts b/packages/openfeature-browser-provider/src/index.ts index df10699c6..48894ecb7 100644 --- a/packages/openfeature-browser-provider/src/index.ts +++ b/packages/openfeature-browser-provider/src/index.ts @@ -44,8 +44,8 @@ export class BucketBrowserSDKProvider implements Provider { private _client?: BucketClient; - private readonly _clientOptions: InitOptions; - private readonly _contextTranslator: ContextTranslationFn; + private _clientOptions: InitOptions; + private _contextTranslator: ContextTranslationFn; public events = new OpenFeatureEventEmitter(); @@ -100,96 +100,66 @@ export class BucketBrowserSDKProvider implements Provider { await this.initialize(newContext); } - private resolveFeature( + resolveBooleanEvaluation( flagKey: string, - defaultValue: T, - ): ResolutionDetails { - const expType = typeof defaultValue; - - if (!this._client) { + defaultValue: boolean, + ): ResolutionDetails { + if (!this._client) return { value: defaultValue, reason: StandardResolutionReasons.DEFAULT, errorCode: ErrorCode.PROVIDER_NOT_READY, - errorMessage: "Bucket client not initialized", - } satisfies ResolutionDetails; - } + } satisfies ResolutionDetails; const features = this._client.getFeatures(); if (flagKey in features) { const feature = this._client.getFeature(flagKey); - - if (!feature.isEnabled) { - return { - value: defaultValue, - reason: StandardResolutionReasons.DISABLED, - }; - } - - if (expType === "boolean") { - return { - value: true as T, - reason: StandardResolutionReasons.TARGETING_MATCH, - }; - } - - if (!feature.config.key) { - return { - value: defaultValue, - reason: StandardResolutionReasons.DEFAULT, - }; - } - - if (expType === "string") { - return { - value: feature.config.payload as T, - reason: StandardResolutionReasons.TARGETING_MATCH, - }; - } - - if (typeof feature.config.payload !== expType) { - return { - value: defaultValue, - reason: StandardResolutionReasons.ERROR, - errorCode: ErrorCode.TYPE_MISMATCH, - errorMessage: `Expected ${expType} but got ${typeof feature.config.payload}`, - }; - } - return { - value: feature.config.payload as T, + value: feature.isEnabled, reason: StandardResolutionReasons.TARGETING_MATCH, - }; + } satisfies ResolutionDetails; } return { value: defaultValue, reason: StandardResolutionReasons.DEFAULT, - errorCode: ErrorCode.FLAG_NOT_FOUND, - errorMessage: `Flag ${flagKey} not found`, - }; - } - - resolveBooleanEvaluation(flagKey: string, defaultValue: boolean) { - return this.resolveFeature(flagKey, defaultValue); + } satisfies ResolutionDetails; } - resolveNumberEvaluation(flagKey: string, defaultValue: number) { - return this.resolveFeature(flagKey, defaultValue); + resolveNumberEvaluation( + _flagKey: string, + defaultValue: number, + ): ResolutionDetails { + return { + value: defaultValue, + errorCode: ErrorCode.TYPE_MISMATCH, + reason: StandardResolutionReasons.ERROR, + errorMessage: "Bucket doesn't support number flags", + }; } resolveObjectEvaluation( - flagKey: string, + _flagKey: string, defaultValue: T, - ) { - return this.resolveFeature(flagKey, defaultValue); + ): ResolutionDetails { + return { + value: defaultValue, + errorCode: ErrorCode.TYPE_MISMATCH, + reason: StandardResolutionReasons.ERROR, + errorMessage: "Bucket doesn't support object flags", + }; } resolveStringEvaluation( - flagKey: string, + _flagKey: string, defaultValue: string, ): ResolutionDetails { - return this.resolveFeature(flagKey, defaultValue); + return { + value: defaultValue, + errorCode: ErrorCode.TYPE_MISMATCH, + reason: StandardResolutionReasons.ERROR, + errorMessage: "Bucket doesn't support string flags", + }; } track( @@ -201,10 +171,8 @@ export class BucketBrowserSDKProvider implements Provider { this._clientOptions.logger?.error("client not initialized"); } - this._client - ?.track(trackingEventName, trackingEventDetails) - .catch((e: any) => { - this._clientOptions.logger?.error("error tracking event", e); - }); + this._client?.track(trackingEventName, trackingEventDetails).catch((e) => { + this._clientOptions.logger?.error("error tracking event", e); + }); } } diff --git a/yarn.lock b/yarn.lock index 9c867db96..718a54350 100644 --- a/yarn.lock +++ b/yarn.lock @@ -882,15 +882,15 @@ __metadata: languageName: node linkType: hard -"@bucketco/browser-sdk@npm:2.5.1": - version: 2.5.1 - resolution: "@bucketco/browser-sdk@npm:2.5.1" +"@bucketco/browser-sdk@npm:2.4.0": + version: 2.4.0 + resolution: "@bucketco/browser-sdk@npm:2.4.0" dependencies: "@floating-ui/dom": "npm:^1.6.8" canonical-json: "npm:^0.0.4" js-cookie: "npm:^3.0.5" preact: "npm:^10.22.1" - checksum: 10c0/ddeab08f8153590f28d2c620495eca19f626f1c4ef394b9ff8075ec24766efe94745b89bf408bb2cd5496d7d5f15e2c82dfe445986a05c6cbb0025af8c708871 + checksum: 10c0/b33a9fdafa4a857ac4f815fe69b602b37527a37d54270abd479b754da998d030f5a70b738c662ab57fa4f6374b8e1fbd052feb8bdbd8b78367086dcedc5a5432 languageName: node linkType: hard @@ -984,7 +984,7 @@ __metadata: version: 0.0.0-use.local resolution: "@bucketco/openfeature-browser-provider@workspace:packages/openfeature-browser-provider" dependencies: - "@bucketco/browser-sdk": "npm:2.5.1" + "@bucketco/browser-sdk": "npm:2.4.0" "@bucketco/eslint-config": "npm:0.0.2" "@bucketco/tsconfig": "npm:0.0.2" "@openfeature/core": "npm:1.5.0" From 2e62475d106132417e5b6f6a85ebcf588e68fdfd Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Mon, 27 Jan 2025 19:35:18 +0000 Subject: [PATCH 22/37] refactor(react-sdk): simplify feature config access and reduce payload Update useFeature hook to create a reduced configuration object with only key and payload, streamlining config retrieval and aligning with recent configuration changes in the SDK. --- packages/react-sdk/src/index.tsx | 6 +++++- packages/react-sdk/test/usage.test.tsx | 3 +-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/react-sdk/src/index.tsx b/packages/react-sdk/src/index.tsx index e2ee4e7f0..355e0535f 100644 --- a/packages/react-sdk/src/index.tsx +++ b/packages/react-sdk/src/index.tsx @@ -247,6 +247,10 @@ export function useFeature(key: TKey): Feature { }); } + const reducedConfig = feature?.config + ? { key: feature.config.key, payload: feature.config.payload } + : ({} as EmptyFeatureConfig); + return { isLoading, track, @@ -257,7 +261,7 @@ export function useFeature(key: TKey): Feature { }, get config() { sendCheckEvent(); - return feature?.config ?? ({} as EmptyFeatureConfig); + return reducedConfig; }, }; } diff --git a/packages/react-sdk/test/usage.test.tsx b/packages/react-sdk/test/usage.test.tsx index 573f4e1b9..5132a8865 100644 --- a/packages/react-sdk/test/usage.test.tsx +++ b/packages/react-sdk/test/usage.test.tsx @@ -263,8 +263,7 @@ describe("useFeature", () => { isLoading: false, config: { key: "gpt3", - targetingVersion: 2, - value: { model: "gpt-something", temperature: 0.5 }, + payload: { model: "gpt-something", temperature: 0.5 }, }, track: expect.any(Function), requestFeedback: expect.any(Function), From cedeb14f875c2cb41440253844f639f2633d0bda Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Mon, 27 Jan 2025 19:47:46 +0000 Subject: [PATCH 23/37] test(browser-sdk): remove trailing newline in test file Cleanup unnecessary blank line at the end of the test file. --- packages/openfeature-browser-provider/src/index.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/openfeature-browser-provider/src/index.test.ts b/packages/openfeature-browser-provider/src/index.test.ts index 0ff0c412f..54b4e7005 100644 --- a/packages/openfeature-browser-provider/src/index.test.ts +++ b/packages/openfeature-browser-provider/src/index.test.ts @@ -133,4 +133,3 @@ describe("BucketBrowserSDKProvider", () => { }); }); }); - From fa735d875c9d4e929927d80d908881b8948ff5c5 Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Tue, 28 Jan 2025 11:32:54 +0000 Subject: [PATCH 24/37] docs(browser-sdk,react-sdk): clarify remote config documentation Update README files for browser and react SDKs to provide more detailed explanations of remote configuration handling, including: - Clarifying optional payload behavior - Adding guidance on handling undefined config - Improving code examples for remote config usage --- packages/browser-sdk/README.md | 10 +++- packages/browser-sdk/src/client.ts | 2 +- packages/browser-sdk/src/feature/features.ts | 4 +- packages/react-sdk/README.md | 58 +++++++++++++++++++- 4 files changed, 67 insertions(+), 7 deletions(-) diff --git a/packages/browser-sdk/README.md b/packages/browser-sdk/README.md index 4a3ed79c4..49a796b4a 100644 --- a/packages/browser-sdk/README.md +++ b/packages/browser-sdk/README.md @@ -164,7 +164,8 @@ generate a `check` event, contrary to the `isEnabled` property on the object ret ### 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 the binary `isEnabled` you can have multiple configuration values which are given to different user/companies. +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(); @@ -172,7 +173,7 @@ const features = bucketClient.getFeatures(); // huddle: { // isEnabled: true, // targetingVersion: 42, -// config?: { +// config: { // key: "gpt-3.5", // payload: { maxTokens: 10000, model: "gpt-3.5-beta1" } // } @@ -180,7 +181,10 @@ const features = bucketClient.getFeatures(); // } ``` -The `key` is always present while the `payload` is a optional JSON value for arbitrary configuration needs. ` +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`. diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index 94f27984d..76d03bcbe 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -214,7 +214,7 @@ export type FeatureRemoteConfig = key: string; /** - * The user-supplied data. + * The optional user-supplied payload data. */ payload: any; } diff --git a/packages/browser-sdk/src/feature/features.ts b/packages/browser-sdk/src/feature/features.ts index 3e4a89b9a..7c11420af 100644 --- a/packages/browser-sdk/src/feature/features.ts +++ b/packages/browser-sdk/src/feature/features.ts @@ -43,9 +43,9 @@ export type FetchedFeature = { version?: number; /** - * The user-supplied data. + * The optional user-supplied payload data. */ - payload: any; + payload?: any; }; }; diff --git a/packages/react-sdk/README.md b/packages/react-sdk/README.md index 0db4a4bf9..2484c79ca 100644 --- a/packages/react-sdk/README.md +++ b/packages/react-sdk/README.md @@ -68,7 +68,7 @@ import { BucketProvider } from "@bucketco/react-sdk"; ```tsx function LoadingBucket({ children }) { - const {isLoading} = useFeature("myFeature") + const { isLoading } = useFeature("myFeature") if (isLoading) { return } @@ -86,6 +86,62 @@ import { BucketProvider } from "@bucketco/react-sdk"; - `enableTracking` (default: `true`): Set to `false` to stop sending tracking events and user/company updates to Bucket. Useful when you're impersonating a user. +## Feature toggles + +Bucket determines which features are active for a given `user`/`company`. The `user`/`company` are given in the `BucketProvider` as props. + +If you supply `user` or `company` objects, they must include at least the `id` property otherwise they will be ignored in their entirety. +In addition to the `id`, you must also supply anything additional that you want to be able to evaluate feature targeting rules against. +The additional attributes are supplied using the `otherContext` prop. + +Attributes cannot be nested (multiple levels) and must be either strings, integers or booleans. + +- `name` is a special attribute and is used to display name for user/company +- for `user`, `email` is also special and will be highlighted in the Bucket UI if available + +```tsx + + + {/* children here are shown when loading finishes */} + + +``` + +To retrieve features along with their targeting information, use `useFeature(key: string)` hook (described in a section below). + +Note that accessing `isEnabled` on the object returned by `useFeature()` automatically +generates a `check` event. + +## Remote config + +Similar to `isEnabled`, each feature accessed using `useFeature()` hook, 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 { + isEnabled, + config: { key, payload }, +} = useFeature("huddles"); + +// isEnabled: true, +// 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. + +Note that, similar to `isEnabled`, accessing `config` on the object returned by `useFeature()` automatically +generates a `check` event. + ## Hooks ### `useFeature()` From 441ab2e332dcfeadcddf5a70a098b465b66eacf4 Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Tue, 28 Jan 2025 11:51:12 +0000 Subject: [PATCH 25/37] feat(node-sdk): update feature override and configuration handling Refactor feature override and configuration types to support more flexible feature flag management: - Modify FeatureOverride type to include isEnabled and optional config - Update client-side feature evaluation to handle new override structure - Improve type definitions for feature API responses and configurations --- packages/node-sdk/src/client.ts | 65 ++++++++++++----------- packages/node-sdk/src/types.ts | 74 +++++++++++++++++++++++---- packages/node-sdk/test/client.test.ts | 9 ++-- 3 files changed, 103 insertions(+), 45 deletions(-) diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index 6dbbef302..8a82bfaea 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -17,6 +17,7 @@ import fetchClient from "./fetch-http-client"; import { newRateLimiter } from "./rate-limiter"; import type { EvaluatedFeaturesAPIResponse, + FeatureAPIResponse, FeatureOverridesFn, FeatureRemoteConfig, IdType, @@ -830,8 +831,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; @@ -844,32 +849,30 @@ export class BucketClient { }), ); - const evaluatedConfigs = evaluated - .filter(({ value }) => value) - .reduce( - (acc, { featureKey }) => { - const feature = featureDefinitions.find((f) => f.key === 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, - }; - } + 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, - ); + } + return acc; + }, + {} as Record, + ); this.warnMissingFeatureContextFields( context, @@ -885,7 +888,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, @@ -906,7 +909,7 @@ export class BucketClient { key: res.featureKey, isEnabled: res.value ?? false, config: evaluatedConfigs[res.featureKey], - targetingVersion: keyToVersionMap.get(res.featureKey), + targetingVersion: featureMap[res.featureKey].targeting.version, missingContextFields: res.missingContextFields, }; return acc; @@ -921,8 +924,8 @@ export class BucketClient { key, { key, - isEnabled: !!override, - config: isObject(override) ? override : undefined, + isEnabled: isObject(override) ? override.isEnabled : !!override, + config: isObject(override) ? override.config : undefined, }, ]); diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index c766f567d..fefa51ae9 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -176,8 +176,11 @@ type TypedFeatureKey = keyof TypedFeatures; type FeatureOverride = | { - key: string; - payload: any; + isEnabled: boolean; + config?: { + key: string; + payload: any; + }; } | boolean; @@ -191,7 +194,9 @@ export type FeatureOverrides = Partial< export type FeatureOverridesFn = (context: Context) => FeatureOverrides; /** - * Describes a remote feature config variant. + * (Internal) Describes a remote feature config variant. + * + * @internal */ export type FeatureConfigVariant = { /** @@ -216,43 +221,90 @@ export type FeatureConfigVariant = { }; /** - * Describes a specific feature in the API response + * (Internal) Describes a specific feature in the API response. + * + * @internal */ -type FeatureAPIResponse = { +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 = { /** diff --git a/packages/node-sdk/test/client.test.ts b/packages/node-sdk/test/client.test.ts index ca9be43a9..ffad9aa41 100644 --- a/packages/node-sdk/test/client.test.ts +++ b/packages/node-sdk/test/client.test.ts @@ -1905,11 +1905,14 @@ describe("BucketClient", () => { client.featureOverrides = (_context: Context) => { expect(context).toStrictEqual(context); return { - feature1: false, + feature1: { isEnabled: false }, feature2: true, feature3: { - key: "config-1", - payload: { something: "else" }, + isEnabled: true, + config: { + key: "config-1", + payload: { something: "else" }, + }, }, }; }; From a51a4950145d12d6c7cc27bcd19b6a33fd09e239 Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Tue, 28 Jan 2025 12:14:59 +0000 Subject: [PATCH 26/37] docs(node-sdk): Update README with detailed remote config explanation and type-safe example Enhance documentation for remote configuration in the Node.js SDK: - Clarify remote config behavior and usage - Add comprehensive code example demonstrating config access - Update type definitions to show more complex feature flag scenarios - Improve explanation of config key and payload handling --- packages/node-sdk/README.md | 80 +++++++++++++++++--------- packages/node-sdk/src/client.ts | 18 ++++-- packages/node-sdk/src/types.ts | 6 +- packages/node-sdk/test/client.test.ts | 11 +++- packages/node-sdk/test/testConfig.json | 9 ++- 5 files changed, 83 insertions(+), 41 deletions(-) diff --git a/packages/node-sdk/README.md b/packages/node-sdk/README.md index 0e80ad153..0fff522a0 100644 --- a/packages/node-sdk/README.md +++ b/packages/node-sdk/README.md @@ -111,27 +111,33 @@ 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 configuration - -Bucket supports remote feature configuration. This functionality allows you to setup -an arbitrary number of key-payload pairs that are matched against the same context -that is used to evaluate whether the feature is enabled or not. The selected pair -is then, returned to the caller and can be accessed using the `config` property. +### 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 methods: `getFeature()`, `getFeatures()`, `getFeatureRemote()` and `getFeaturesRemote()`, -will include this config when the feature(s) are evaluated. In case no configuration pair -matched (or was configured), these methods will return an empty object. You can, thus, safely -deconstruct the `config` object in your code: +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. -```typescript -const { - isEnabled, - config: { key, payload }, -} = boundClient.getFeature("huddles"); -if (isEnabled && key === "premium") { - // ... your code -} -``` +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 @@ -191,13 +197,18 @@ import { BucketClient } from "@bucketco/node-sdk"; // Extending the Features interface to define the available features declare module "@bucketco/node-sdk" { + type ConfirmationConfig = { + shouldShowConfirmation: boolean + } + interface Features { "show-todos": boolean; - "create-todos": boolean; + "create-todos": { isEnabled: boolean }; "delete-todos": { - key: string, - payload: { - someKey: string, + isEnabled: boolean, + config: { + key: string, + payload: ConfirmationConfig, } }; } @@ -208,12 +219,27 @@ export const bucketClient = new BucketClient(); bucketClient.initialize().then({ console.log("Bucket initialized!") bucketClient.getFeature("invalid-feature") // feature doesn't exist - - // this will print out the value of the "key" as specified as a value to - // `delete-todos` feature. - console.log(bucketClient.getFeature("delete-todos").config.key) }) +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 ite,?"); + // ... rest of the code + } +} + ``` ![Type check failed](docs/type-check-failed.png "Type check failed") diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index 8a82bfaea..bf1e0e945 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -197,13 +197,21 @@ export class BucketClient { ) : isObject(options.fallbackFeatures) ? Object.entries(options.fallbackFeatures).reduce( - (acc, [key, value]) => { + (acc, [key, fallback]) => { acc[key as keyof TypedFeatures] = { - isEnabled: !!value, + isEnabled: + typeof fallback === "object" + ? fallback.isEnabled + : !!fallback, key, - config: isObject(value) - ? (value as RawFeatureRemoteConfig) - : undefined, + config: + typeof fallback === "object" && fallback.config + ? { + key: fallback.config.key, + default: true, + payload: fallback.config.payload, + } + : undefined, }; return acc; }, diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index fefa51ae9..043dceac1 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -75,7 +75,7 @@ export type RawFeatureRemoteConfig = { default: boolean; /** - * The user-supplied data. + * The optional user-supplied payload data. */ payload: any; }; @@ -121,7 +121,7 @@ export type FeatureRemoteConfig = key: string; /** - * The user-supplied data. + * The optional user-supplied payload data. */ payload: any; } @@ -205,7 +205,7 @@ export type FeatureConfigVariant = { filter: RuleFilter; /** - * The payload for the variant. + * The optional user-supplied payload data. */ payload: any; diff --git a/packages/node-sdk/test/client.test.ts b/packages/node-sdk/test/client.test.ts index ffad9aa41..750796c95 100644 --- a/packages/node-sdk/test/client.test.ts +++ b/packages/node-sdk/test/client.test.ts @@ -221,21 +221,26 @@ describe("BucketClient", () => { fallbackFeatures: { feature1: true, feature2: { - key: "config1", - payload: { value: true }, + isEnabled: true, + config: { + key: "config1", + payload: { value: true }, + }, }, }, }); - expect(bucketInstance["_config"].fallbackFeatures).toEqual({ + 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 }, }, diff --git a/packages/node-sdk/test/testConfig.json b/packages/node-sdk/test/testConfig.json index 246363b41..c6986a139 100644 --- a/packages/node-sdk/test/testConfig.json +++ b/packages/node-sdk/test/testConfig.json @@ -1,10 +1,13 @@ { "featureOverrides": { - "myFeature": true, + "myFeature": { "isEnabled": true }, "myFeatureFalse": false, "myFeatureWithConfig": { - "key": "config-1", - "payload": { "something": "else" } + "isEnabled": true, + "config": { + "key": "config-1", + "payload": { "something": "else" } + } } }, "secretKey": "mySecretKey", From f986feb03e94685ff29bb1a9a3a1b65c46b1571c Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Tue, 28 Jan 2025 12:24:13 +0000 Subject: [PATCH 27/37] feat(browser-sdk): export FallbackFeatureOverride type Add FallbackFeatureOverride to the exported types, extending the SDK's type exports for feature configuration --- packages/browser-sdk/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/browser-sdk/src/index.ts b/packages/browser-sdk/src/index.ts index 7b1186cb8..2b4e7bf80 100644 --- a/packages/browser-sdk/src/index.ts +++ b/packages/browser-sdk/src/index.ts @@ -5,6 +5,7 @@ export { BucketClient } from "./client"; export type { BucketContext, CompanyContext, UserContext } from "./context"; export type { CheckEvent, + FallbackFeatureOverride, FeaturesOptions, RawFeature, RawFeatures, From 7c6c47c7992c62fd766e7c17dbed89ace1f463f1 Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Tue, 28 Jan 2025 12:32:11 +0000 Subject: [PATCH 28/37] chore(release): bump browser-sdk and react-sdk to 3.0.0-alpha.1 Update package versions and lock file to reflect the new alpha release, ensuring consistent versioning across SDKs --- packages/browser-sdk/package.json | 2 +- packages/react-sdk/package.json | 4 ++-- yarn.lock | 16 ++-------------- 3 files changed, 5 insertions(+), 17 deletions(-) diff --git a/packages/browser-sdk/package.json b/packages/browser-sdk/package.json index b6117ec56..b391f4e41 100644 --- a/packages/browser-sdk/package.json +++ b/packages/browser-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@bucketco/browser-sdk", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "packageManager": "yarn@4.1.1", "license": "MIT", "repository": { diff --git a/packages/react-sdk/package.json b/packages/react-sdk/package.json index c8d9a1fa6..6dc4cadfa 100644 --- a/packages/react-sdk/package.json +++ b/packages/react-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@bucketco/react-sdk", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "license": "MIT", "repository": { "type": "git", @@ -34,7 +34,7 @@ } }, "dependencies": { - "@bucketco/browser-sdk": "3.0.0-alpha.0", + "@bucketco/browser-sdk": "3.0.0-alpha.1", "canonical-json": "^0.0.4", "rollup": "^4.2.0" }, diff --git a/yarn.lock b/yarn.lock index e79fc1d59..1081f5ec2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -894,19 +894,7 @@ __metadata: languageName: node linkType: hard -"@bucketco/browser-sdk@npm:3.0.0-alpha.0": - version: 3.0.0-alpha.0 - resolution: "@bucketco/browser-sdk@npm:3.0.0-alpha.0" - dependencies: - "@floating-ui/dom": "npm:^1.6.8" - canonical-json: "npm:^0.0.4" - js-cookie: "npm:^3.0.5" - preact: "npm:^10.22.1" - checksum: 10c0/eba1a2f15c49c1aeca37c6d2b218e50d92e2034c51a9acdd6884ce143156dc7f285f9141f5e6cfcc2aed0186c0b8fc61bb9939aca737a1e76d1a2d9d65a7b74c - languageName: node - linkType: hard - -"@bucketco/browser-sdk@workspace:packages/browser-sdk": +"@bucketco/browser-sdk@npm:3.0.0-alpha.1, @bucketco/browser-sdk@workspace:packages/browser-sdk": version: 0.0.0-use.local resolution: "@bucketco/browser-sdk@workspace:packages/browser-sdk" dependencies: @@ -1042,7 +1030,7 @@ __metadata: version: 0.0.0-use.local resolution: "@bucketco/react-sdk@workspace:packages/react-sdk" dependencies: - "@bucketco/browser-sdk": "npm:3.0.0-alpha.0" + "@bucketco/browser-sdk": "npm:3.0.0-alpha.1" "@bucketco/eslint-config": "workspace:^" "@bucketco/tsconfig": "workspace:^" "@testing-library/react": "npm:^15.0.7" From 713b0d226a5c41a5f2f547932d1bf490c1bd6f83 Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Tue, 28 Jan 2025 13:12:33 +0000 Subject: [PATCH 29/37] feat(node-sdk): Enhance feature override validation and configuration structure Improve type checking and validation for feature overrides: - Add more robust validation for feature override objects - Require explicit `isEnabled` boolean for feature overrides - Support optional configuration with stricter type checks - Update test cases to reflect new feature override structure --- packages/node-sdk/src/config.ts | 20 ++++++++++++++++++-- packages/node-sdk/test/config.test.ts | 22 ++++++++++++++++------ 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/packages/node-sdk/src/config.ts b/packages/node-sdk/src/config.ts index f0a6229b4..b6ec95e24 100644 --- a/packages/node-sdk/src/config.ts +++ b/packages/node-sdk/src/config.ts @@ -24,9 +24,25 @@ function parseOverrides(config: object | undefined) { if ("featureOverrides" in config && isObject(config.featureOverrides)) { Object.entries(config.featureOverrides).forEach(([key, value]) => { ok( - typeof value === "boolean" || (isObject(value) && "key" in value), - `invalid type "${typeof value}" for key ${key}, expected boolean or object with key`, + 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 config.featureOverrides; diff --git a/packages/node-sdk/test/config.test.ts b/packages/node-sdk/test/config.test.ts index d77c23d38..653cfe9a2 100644 --- a/packages/node-sdk/test/config.test.ts +++ b/packages/node-sdk/test/config.test.ts @@ -8,11 +8,16 @@ describe("config tests", () => { expect(config).toEqual({ featureOverrides: { - myFeature: true, + myFeature: { + isEnabled: true, + }, myFeatureFalse: false, myFeatureWithConfig: { - key: "config-1", - payload: { something: "else" }, + isEnabled: true, + config: { + key: "config-1", + payload: { something: "else" }, + }, }, }, secretKey: "mySecretKey", @@ -31,13 +36,18 @@ 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: { - key: "config-1", - payload: { something: "else" }, + isEnabled: true, + config: { + key: "config-1", + payload: { something: "else" }, + }, }, }, secretKey: "mySecretKeyFromEnv", From dde88b5b4a3564cce628f054b5dc59f99d5ef9ea Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Wed, 29 Jan 2025 11:52:13 +0000 Subject: [PATCH 30/37] fix: ensure feature config always has key and payload properties --- packages/browser-sdk/src/client.ts | 2 +- packages/browser-sdk/test/usage.test.ts | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index 5c61c2c42..a3d34a19b 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -692,7 +692,7 @@ export class BucketClient { key: f.config.key, payload: f.config.payload, } - : ({} as FeatureRemoteConfig); + : { key: undefined, payload: undefined }; function sendCheckEvent() { fClient diff --git a/packages/browser-sdk/test/usage.test.ts b/packages/browser-sdk/test/usage.test.ts index 18cce364c..31399087e 100644 --- a/packages/browser-sdk/test/usage.test.ts +++ b/packages/browser-sdk/test/usage.test.ts @@ -79,7 +79,7 @@ describe("usage", () => { isEnabled: false, track: expect.any(Function), requestFeedback: expect.any(Function), - config: {}, + config: { key: undefined, payload: undefined }, }); }); @@ -410,7 +410,7 @@ describe(`sends "check" events `, () => { expect(client.getFeature("featureA")).toStrictEqual({ isEnabled: true, - config: {}, + config: { key: undefined, payload: undefined }, track: expect.any(Function), requestFeedback: expect.any(Function), }); @@ -430,7 +430,7 @@ describe(`sends "check" events `, () => { expect(client.getFeature("featureC")).toStrictEqual({ isEnabled: false, - config: {}, + config: { key: undefined, payload: undefined }, track: expect.any(Function), requestFeedback: expect.any(Function), }); @@ -548,7 +548,7 @@ describe(`sends "check" events `, () => { isEnabled: false, track: expect.any(Function), requestFeedback: expect.any(Function), - config: {}, + config: { key: undefined, payload: undefined }, }); vi.spyOn(client, "track"); @@ -567,7 +567,7 @@ describe(`sends "check" events `, () => { isEnabled: false, track: expect.any(Function), requestFeedback: expect.any(Function), - config: {}, + config: { key: undefined, payload: undefined }, }); vi.spyOn(client, "requestFeedback"); From bb506b469d17d2f8d0aea8765acf2886cf08eb3c Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Wed, 29 Jan 2025 11:54:14 +0000 Subject: [PATCH 31/37] refactor: simplify feature config type definition --- packages/react-sdk/src/index.tsx | 14 ++++++-------- packages/react-sdk/test/usage.test.tsx | 4 ++-- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/react-sdk/src/index.tsx b/packages/react-sdk/src/index.tsx index 355e0535f..d31a76f2d 100644 --- a/packages/react-sdk/src/index.tsx +++ b/packages/react-sdk/src/index.tsx @@ -183,11 +183,6 @@ type RequestFeedbackOptions = Omit< "featureKey" | "featureId" >; -type EmptyFeatureConfig = { - key: undefined; - payload: undefined; -}; - type Feature = { isEnabled: boolean; isLoading: boolean; @@ -196,7 +191,10 @@ type Feature = { key: string; payload: FeatureConfig; } - | EmptyFeatureConfig; + | { + key: undefined; + payload: undefined; + }; track: () => void; requestFeedback: (opts: RequestFeedbackOptions) => void; }; @@ -226,7 +224,7 @@ export function useFeature(key: TKey): Feature { return { isLoading, isEnabled: false, - config: {} as EmptyFeatureConfig, + config: { key: undefined, payload: undefined }, track, requestFeedback, }; @@ -249,7 +247,7 @@ export function useFeature(key: TKey): Feature { const reducedConfig = feature?.config ? { key: feature.config.key, payload: feature.config.payload } - : ({} as EmptyFeatureConfig); + : { key: undefined, payload: undefined }; return { isLoading, diff --git a/packages/react-sdk/test/usage.test.tsx b/packages/react-sdk/test/usage.test.tsx index 5132a8865..e24afc045 100644 --- a/packages/react-sdk/test/usage.test.tsx +++ b/packages/react-sdk/test/usage.test.tsx @@ -226,7 +226,7 @@ describe("useFeature", () => { expect(result.current).toStrictEqual({ isEnabled: false, isLoading: true, - config: {}, + config: { key: undefined, payload: undefined }, track: expect.any(Function), requestFeedback: expect.any(Function), }); @@ -241,7 +241,7 @@ describe("useFeature", () => { await waitFor(() => { expect(result.current).toStrictEqual({ - config: {}, + config: { key: undefined, payload: undefined }, isEnabled: false, isLoading: false, track: expect.any(Function), From 6e84e64bb1737814489c8e05bd69feb3b29e1294 Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Wed, 29 Jan 2025 11:59:39 +0000 Subject: [PATCH 32/37] fix: ensure feature config returns undefined key and payload when no config is provided --- packages/node-sdk/src/client.ts | 2 +- packages/node-sdk/test/client.test.ts | 26 +++++++++++++------------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index bf1e0e945..10cd9efef 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -975,7 +975,7 @@ export class BucketClient { const simplifiedConfig = config ? { key: config.key, payload: config.payload } - : ({} as FeatureRemoteConfig); + : { key: undefined, payload: undefined }; return { get isEnabled() { diff --git a/packages/node-sdk/test/client.test.ts b/packages/node-sdk/test/client.test.ts index 750796c95..bc96e1287 100644 --- a/packages/node-sdk/test/client.test.ts +++ b/packages/node-sdk/test/client.test.ts @@ -1351,7 +1351,7 @@ describe("BucketClient", () => { feature2: { key: "feature2", isEnabled: false, - config: {}, + config: { key: undefined, payload: undefined }, track: expect.any(Function), }, }); @@ -1462,7 +1462,7 @@ describe("BucketClient", () => { feature2: { key: "feature2", isEnabled: false, - config: {}, + config: { key: undefined, payload: undefined }, track: expect.any(Function), }, }); @@ -1539,7 +1539,7 @@ describe("BucketClient", () => { feature2: { key: "feature2", isEnabled: false, - config: {}, + config: { key: undefined, payload: undefined }, track: expect.any(Function), }, }); @@ -1616,7 +1616,7 @@ describe("BucketClient", () => { feature2: { key: "feature2", isEnabled: false, - config: {}, + config: { key: undefined, payload: undefined }, track: expect.any(Function), }, }); @@ -1788,7 +1788,7 @@ describe("BucketClient", () => { expect(result).toStrictEqual({ key: "key", isEnabled: true, - config: {}, + config: { key: undefined, payload: undefined }, track: expect.any(Function), }); @@ -1844,7 +1844,7 @@ describe("BucketClient", () => { feature2: { key: "feature2", isEnabled: false, - config: {}, + config: { key: undefined, payload: undefined }, track: expect.any(Function), }, }); @@ -1902,7 +1902,7 @@ describe("BucketClient", () => { feature2: { key: "feature2", isEnabled: false, - config: {}, + config: { key: undefined, payload: undefined }, track: expect.any(Function), }, }); @@ -1927,13 +1927,13 @@ describe("BucketClient", () => { feature1: { key: "feature1", isEnabled: false, - config: {}, + config: { key: undefined, payload: undefined }, track: expect.any(Function), }, feature2: { key: "feature2", isEnabled: true, - config: {}, + config: { key: undefined, payload: undefined }, track: expect.any(Function), }, feature3: { @@ -2006,7 +2006,7 @@ describe("BucketClient", () => { feature2: { key: "feature2", isEnabled: false, - config: {}, + config: { key: undefined, payload: undefined }, track: expect.any(Function), }, }); @@ -2309,13 +2309,13 @@ describe("BoundBucketClient", () => { feature1: { key: "feature1", isEnabled: true, - config: {}, + config: { key: undefined, payload: undefined }, track: expect.any(Function), }, feature2: { key: "feature2", isEnabled: false, - config: {}, + config: { key: undefined, payload: undefined }, track: expect.any(Function), }, }); @@ -2340,7 +2340,7 @@ describe("BoundBucketClient", () => { expect(result).toStrictEqual({ key: "feature1", isEnabled: true, - config: {}, + config: { key: undefined, payload: undefined }, track: expect.any(Function), }); From 9351f5b7a12dfb5838869602b15a68016e0657ae Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Wed, 29 Jan 2025 12:00:46 +0000 Subject: [PATCH 33/37] refactor: remove redundant comment in Feature interface --- packages/browser-sdk/src/client.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index 0781c665f..a3d34a19b 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -321,10 +321,6 @@ export interface Feature { */ track: () => Promise; - /** - * Function to request feedback for this feature. - */ - /** * Function to request feedback for this feature. */ From 9f5efd787cf0801c89f41e6a21658012c397eb23 Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Wed, 29 Jan 2025 12:06:15 +0000 Subject: [PATCH 34/37] docs: update README with type-safe feature flag configuration example --- packages/node-sdk/README.md | 41 +++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/packages/node-sdk/README.md b/packages/node-sdk/README.md index 03ee07c78..b673c9f2f 100644 --- a/packages/node-sdk/README.md +++ b/packages/node-sdk/README.md @@ -199,19 +199,12 @@ import { BucketClient } from "@bucketco/node-sdk"; // Extending the Features interface to define the available features declare module "@bucketco/node-sdk" { - type ConfirmationConfig = { - shouldShowConfirmation: boolean - } - interface Features { "show-todos": boolean; "create-todos": { isEnabled: boolean }; "delete-todos": { isEnabled: boolean, - config: { - key: string, - payload: ConfirmationConfig, - } + config: any }; } } @@ -222,10 +215,37 @@ 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") + const { + isEnabled, + config: { payload: confirmationConfig }, + } = bucketClient.getFeature("delete-todos"); // check that feature is enabled for user if (!isEnabled) { @@ -237,11 +257,10 @@ function deleteTodo(todoId: string) { // 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 ite,?"); + 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") From 813cde14e05a6c5a6e81899ebd6f74a948c47cae Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Wed, 29 Jan 2025 15:13:14 +0000 Subject: [PATCH 35/37] feat(node-sdk): extend node SDK to support feature configuration with type safety This commit adds support for feature configurations in the node SDK, including: - Updated type definitions to support typed feature configs - Modified client methods to handle feature configs - Added example implementation with minimum todo length configuration - Improved type safety for feature overrides and feature retrieval --- packages/node-sdk/example/app.ts | 10 ++++- packages/node-sdk/example/bucket.ts | 27 ++++++++++-- packages/node-sdk/example/bucketConfig.json | 4 +- packages/node-sdk/src/client.ts | 7 +-- packages/node-sdk/src/types.ts | 49 ++++++++++++--------- packages/node-sdk/test/client.test.ts | 4 -- 6 files changed, 68 insertions(+), 33 deletions(-) diff --git a/packages/node-sdk/example/app.ts b/packages/node-sdk/example/app.ts index 37812f178..31fca2653 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 6aebcfd81..907c4deba 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 e7c2bf24b..b4f55d975 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 10cd9efef..899d19553 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -19,7 +19,6 @@ import type { EvaluatedFeaturesAPIResponse, FeatureAPIResponse, FeatureOverridesFn, - FeatureRemoteConfig, IdType, RawFeature, RawFeatureRemoteConfig, @@ -1110,7 +1109,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); } @@ -1120,7 +1119,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/types.ts b/packages/node-sdk/src/types.ts index 9f6ee0131..e8637774d 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -110,6 +110,8 @@ export interface RawFeature { missingContextFields?: string[]; } +type EmptyFeatureRemoteConfig = { key: undefined; payload: undefined }; + /** * A remotely managed configuration value for a feature. */ @@ -125,12 +127,14 @@ export type FeatureRemoteConfig = */ payload: any; } - | { key: undefined; payload: undefined }; + | EmptyFeatureRemoteConfig; /** * Describes a feature */ -export interface Feature { +export interface Feature< + TConfig extends FeatureRemoteConfig | never = EmptyFeatureRemoteConfig, +> { /** * The key of the feature. */ @@ -144,12 +148,7 @@ export interface Feature { /* * Optional user-defined configuration. */ - config: FeatureRemoteConfig; - - /** - * Optional user-defined configuration if the feature is enabled. - */ - config: any; + config: TConfig extends never ? EmptyFeatureRemoteConfig : TConfig; /** * Track feature usage in Bucket. @@ -157,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. * @@ -175,25 +184,25 @@ 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; -type FeatureOverride = - | { - isEnabled: boolean; - config?: { - key: string; - payload: any; - }; - } - | boolean; - /** * Describes the feature overrides. */ export type FeatureOverrides = Partial< - Record + keyof Features extends never + ? Record + : { + [FeatureKey in keyof Features]: Features[FeatureKey] extends FullFeatureOverride + ? Features[FeatureKey] + : Exclude; + } >; export type FeatureOverridesFn = (context: Context) => FeatureOverrides; diff --git a/packages/node-sdk/test/client.test.ts b/packages/node-sdk/test/client.test.ts index 14920c1db..bc96e1287 100644 --- a/packages/node-sdk/test/client.test.ts +++ b/packages/node-sdk/test/client.test.ts @@ -1898,14 +1898,12 @@ describe("BucketClient", () => { }, }, track: expect.any(Function), - config: undefined, }, feature2: { key: "feature2", isEnabled: false, config: { key: undefined, payload: undefined }, track: expect.any(Function), - config: undefined, }, }); @@ -1931,7 +1929,6 @@ describe("BucketClient", () => { isEnabled: false, config: { key: undefined, payload: undefined }, track: expect.any(Function), - config: undefined, }, feature2: { key: "feature2", @@ -1947,7 +1944,6 @@ describe("BucketClient", () => { payload: { something: "else" }, }, track: expect.any(Function), - config: undefined, }, }); }); From c452976f20c99d3830e3b0c0e46813d6e43ca673 Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Wed, 29 Jan 2025 15:25:25 +0000 Subject: [PATCH 36/37] feat(node-sdk): add type-safe generics for feature retrieval methods Enhance type safety for feature retrieval methods by: - Adding generic type parameters to `getFeature` and `getFeatureRemote` - Updating return types to use `TypedFeatures` for precise type inference - Modifying `_wrapRawFeature` to support typed feature configurations --- packages/node-sdk/src/client.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index 899d19553..fe0d6e0c3 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -465,10 +465,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]; @@ -513,12 +513,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, @@ -947,10 +947,10 @@ export class BucketClient { return evaluatedFeatures; } - private _wrapRawFeature( + private _wrapRawFeature( options: { enableTracking: boolean } & Context, { key, isEnabled, config, targetingVersion }: RawFeature, - ): Feature { + ): TypedFeatures[TKey] { // eslint-disable-next-line @typescript-eslint/no-this-alias const client = this; From 07743eb30cb0fd0c1e3d95de28b6fd38c4992a44 Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Wed, 29 Jan 2025 15:35:47 +0000 Subject: [PATCH 37/37] fix(node-sdk): improve type safety for feature config getter Enhance type inference for feature configuration by casting the simplified config to the correct type for the specific feature --- packages/node-sdk/src/client.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index fe0d6e0c3..dfe0278b2 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -29,7 +29,6 @@ import { ClientOptions, Context, ContextWithTracking, - Feature, FeatureEvent, FeaturesAPIResponse, HttpClient, @@ -983,7 +982,7 @@ export class BucketClient { }, get config() { sendCheckEvent(); - return simplifiedConfig; + return simplifiedConfig as TypedFeatures[TKey]["config"]; }, key, track: async () => {