diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index 35024df4..9e16a076 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -498,6 +498,8 @@ export class BucketClient { /** * Returns a map of enabled features. * Accessing a feature will *not* send a check event + * and `isEnabled` does not take any feature overrides + * into account. * * @returns Map of features */ @@ -513,13 +515,13 @@ export class BucketClient { const f = this.getFeatures()[key]; const fClient = this.featuresClient; - const value = f?.isEnabled ?? false; + const value = f?.isEnabledOverride ?? f?.isEnabled ?? false; return { get isEnabled() { fClient .sendCheckEvent({ - key: key, + key, version: f?.targetingVersion, value, }) @@ -540,6 +542,14 @@ export class BucketClient { }; } + setFeatureOverride(key: string, isEnabled: boolean | null) { + this.featuresClient.setFeatureOverride(key, isEnabled); + } + + getFeatureOverride(key: string): boolean | null { + return this.featuresClient.getFeatureOverride(key); + } + sendCheckEvent(checkEvent: CheckEvent) { return this.featuresClient.sendCheckEvent(checkEvent); } diff --git a/packages/browser-sdk/src/feature/featureCache.ts b/packages/browser-sdk/src/feature/featureCache.ts index 1a66c441..306aef97 100644 --- a/packages/browser-sdk/src/feature/featureCache.ts +++ b/packages/browser-sdk/src/feature/featureCache.ts @@ -1,4 +1,4 @@ -import { RawFeatures } from "./features"; +import { FetchedFeatures } from "./features"; interface StorageItem { get(): string | null; @@ -8,18 +8,18 @@ interface StorageItem { interface cacheEntry { expireAt: number; staleAt: number; - features: RawFeatures; + features: FetchedFeatures; } // Parse and validate an API feature response export function parseAPIFeaturesResponse( featuresInput: any, -): RawFeatures | undefined { +): FetchedFeatures | undefined { if (!isObject(featuresInput)) { return; } - const features: RawFeatures = {}; + const features: FetchedFeatures = {}; for (const key in featuresInput) { const feature = featuresInput[key]; if ( @@ -39,7 +39,7 @@ export function parseAPIFeaturesResponse( } export interface CacheResult { - features: RawFeatures; + features: FetchedFeatures; stale: boolean; } @@ -67,7 +67,7 @@ export class FeatureCache { { features, }: { - features: RawFeatures; + features: FetchedFeatures; }, ) { let cacheData: CacheData = {}; diff --git a/packages/browser-sdk/src/feature/features.ts b/packages/browser-sdk/src/feature/features.ts index 46d02a61..c50c723d 100644 --- a/packages/browser-sdk/src/feature/features.ts +++ b/packages/browser-sdk/src/feature/features.ts @@ -9,7 +9,7 @@ import { parseAPIFeaturesResponse, } from "./featureCache"; -export type RawFeature = { +export type FetchedFeature = { /** * Feature key */ @@ -28,7 +28,15 @@ export type RawFeature = { const FEATURES_UPDATED_EVENT = "features-updated"; -export type RawFeatures = Record; +export type FetchedFeatures = Record; +// todo: on next major, come up with a better name for this type. Maybe `LocalFeature`. +export type RawFeature = FetchedFeature & { + /** + * If not null, the result is being overridden locally + */ + isEnabledOverride: boolean | null; +}; +export type RawFeatures = Record; export type FeaturesOptions = { /** @@ -38,7 +46,7 @@ export type FeaturesOptions = { fallbackFeatures?: string[]; /** - * Timeout in miliseconds + * Timeout in milliseconds */ timeoutMs?: number; @@ -73,7 +81,7 @@ export type FeaturesResponse = { /** * List of enabled features */ - features: RawFeatures; + features: FetchedFeatures; }; export function validateFeaturesResponse(response: any) { @@ -138,14 +146,40 @@ type context = { export const FEATURES_EXPIRE_MS = 30 * 24 * 60 * 60 * 1000; // expire entirely after 30 days -const localStorageCacheKey = `__bucket_features`; +const localStorageFetchedFeaturesKey = `__bucket_fetched_features`; +const localStorageOverridesKey = `__bucket_overrides`; + +type OverridesFeatures = Record; + +function setOverridesCache(overrides: OverridesFeatures) { + localStorage.setItem(localStorageOverridesKey, JSON.stringify(overrides)); +} + +function getOverridesCache(): OverridesFeatures { + try { + const cachedOverrides = JSON.parse( + localStorage.getItem(localStorageOverridesKey) || "{}", + ); + + if (!isObject(cachedOverrides)) { + return {}; + } + return cachedOverrides; + } catch (e) { + return {}; + } +} /** * @internal */ export class FeaturesClient { private cache: FeatureCache; - private features: RawFeatures; + private fetchedFeatures: FetchedFeatures; + private featureOverrides: OverridesFeatures; + + private features: RawFeatures = {}; + private config: Config; private rateLimiter: RateLimiter; private readonly logger: Logger; @@ -162,14 +196,15 @@ export class FeaturesClient { rateLimiter?: RateLimiter; }, ) { - this.features = {}; + this.fetchedFeatures = {}; this.logger = loggerWithPrefix(logger, "[Features]"); this.cache = options?.cache ? options.cache : new FeatureCache({ storage: { - get: () => localStorage.getItem(localStorageCacheKey), - set: (value) => localStorage.setItem(localStorageCacheKey, value), + get: () => localStorage.getItem(localStorageFetchedFeaturesKey), + set: (value) => + localStorage.setItem(localStorageFetchedFeaturesKey, value), }, staleTimeMs: options?.staleTimeMs ?? 0, expireTimeMs: options?.expireTimeMs ?? FEATURES_EXPIRE_MS, @@ -178,11 +213,12 @@ export class FeaturesClient { this.rateLimiter = options?.rateLimiter ?? new RateLimiter(FEATURE_EVENTS_PER_MIN, this.logger); + this.featureOverrides = getOverridesCache(); } async initialize() { const features = (await this.maybeFetchFeatures()) || {}; - this.setFeatures(features); + this.setFetchedFeatures(features); } async setContext(context: context) { @@ -222,7 +258,7 @@ export class FeaturesClient { return this.features; } - public async fetchFeatures(): Promise { + public async fetchFeatures(): Promise { const params = this.fetchParams(); try { const res = await this.httpClient.get({ @@ -291,11 +327,41 @@ export class FeaturesClient { return checkEvent.value; } - private setFeatures(features: RawFeatures) { - this.features = features; + private triggerFeaturesChanged() { + const mergedFeatures: RawFeatures = {}; + + // merge fetched features with overrides into `this.features` + for (const key in this.fetchedFeatures) { + const fetchedFeature = this.fetchedFeatures[key]; + if (!fetchedFeature) continue; + const isEnabledOverride = this.featureOverrides[key] ?? null; + mergedFeatures[key] = { + ...fetchedFeature, + isEnabledOverride, + }; + } + + // add any overrides that aren't in the fetched features + for (const key in this.featureOverrides) { + if (!this.features[key]) { + mergedFeatures[key] = { + key, + isEnabled: false, + isEnabledOverride: this.featureOverrides[key], + }; + } + } + + this.features = mergedFeatures; + this.eventTarget.dispatchEvent(new Event(FEATURES_UPDATED_EVENT)); } + private setFetchedFeatures(features: FetchedFeatures) { + this.fetchedFeatures = features; + this.triggerFeaturesChanged(); + } + private fetchParams() { const flattenedContext = flattenJSON({ context: this.context }); const params = new URLSearchParams(flattenedContext); @@ -308,7 +374,7 @@ export class FeaturesClient { return params; } - private async maybeFetchFeatures(): Promise { + private async maybeFetchFeatures(): Promise { const cacheKey = this.fetchParams().toString(); const cachedItem = this.cache.get(cacheKey); @@ -325,7 +391,7 @@ export class FeaturesClient { this.cache.set(cacheKey, { features, }); - this.setFeatures(features); + this.setFetchedFeatures(features); }) .catch(() => { // we don't care about the result, we just want to re-fetch @@ -358,6 +424,25 @@ export class FeaturesClient { isEnabled: true, }; return acc; - }, {} as RawFeatures); + }, {} as FetchedFeatures); + } + + setFeatureOverride(key: string, isEnabled: boolean | null) { + if (!(typeof isEnabled === "boolean" || isEnabled === null)) { + throw new Error("setFeatureOverride: isEnabled must be boolean or null"); + } + + if (isEnabled === null) { + delete this.featureOverrides[key]; + } else { + this.featureOverrides[key] = isEnabled; + } + setOverridesCache(this.featureOverrides); + + this.triggerFeaturesChanged(); + } + + getFeatureOverride(key: string): boolean | null { + return this.featureOverrides[key] ?? null; } } diff --git a/packages/browser-sdk/test/client.test.ts b/packages/browser-sdk/test/client.test.ts index d475a0f9..b6bd728a 100644 --- a/packages/browser-sdk/test/client.test.ts +++ b/packages/browser-sdk/test/client.test.ts @@ -4,6 +4,8 @@ import { BucketClient } from "../src/client"; import { FeaturesClient } from "../src/feature/features"; import { HttpClient } from "../src/httpClient"; +import { featuresResult } from "./mocks/handlers"; + describe("BucketClient", () => { let client: BucketClient; const httpClientPost = vi.spyOn(HttpClient.prototype as any, "post"); @@ -62,4 +64,14 @@ describe("BucketClient", () => { expect(featureClientSetContext).toHaveBeenCalledWith(client["context"]); }); }); + + describe("getFeature", () => { + it("takes overrides into account", async () => { + await client.initialize(); + 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/features.test.ts b/packages/browser-sdk/test/features.test.ts index 6a8ae823..ccc1b1ab 100644 --- a/packages/browser-sdk/test/features.test.ts +++ b/packages/browser-sdk/test/features.test.ts @@ -5,6 +5,7 @@ import { FEATURES_EXPIRE_MS, FeaturesClient, FeaturesOptions, + FetchedFeature, RawFeature, } from "../src/feature/features"; import { HttpClient } from "../src/httpClient"; @@ -125,6 +126,7 @@ describe("FeaturesClient unit tests", () => { expect(featuresClient.getFeatures()).toEqual({ huddle: { isEnabled: true, + isEnabledOverride: null, key: "huddle", }, }); @@ -174,7 +176,7 @@ describe("FeaturesClient unit tests", () => { isEnabled: true, key: "featureB", targetingVersion: 1, - } satisfies RawFeature, + } satisfies FetchedFeature, }, }; @@ -199,6 +201,7 @@ describe("FeaturesClient unit tests", () => { isEnabled: true, key: "featureB", targetingVersion: 1, + isEnabledOverride: null, } satisfies RawFeature, }); @@ -237,7 +240,8 @@ describe("FeaturesClient unit tests", () => { isEnabled: true, targetingVersion: 1, key: "featureA", - }, + isEnabledOverride: null, + } satisfies RawFeature, }), ); }); @@ -269,4 +273,49 @@ describe("FeaturesClient unit tests", () => { expect(httpClient.get).toHaveBeenCalledTimes(2); expect(a).not.toEqual(b); }); + + test("handled overrides", async () => { + // change the response so we can validate that we'll serve the stale cache + const { newFeaturesClient } = featuresClientFactory(); + // localStorage.clear(); + const client = newFeaturesClient(); + await client.initialize(); + + let updated = false; + client.onUpdated(() => { + updated = true; + }); + + expect(client.getFeatures().featureA.isEnabled).toBe(true); + expect(client.getFeatures().featureA.isEnabledOverride).toBe(null); + + expect(updated).toBe(false); + + client.setFeatureOverride("featureA", false); + + expect(updated).toBe(true); + expect(client.getFeatures().featureA.isEnabled).toBe(true); + expect(client.getFeatures().featureA.isEnabledOverride).toBe(false); + }); + + test("handled overrides for features not returned by API", async () => { + // change the response so we can validate that we'll serve the stale cache + const { newFeaturesClient } = featuresClientFactory(); + // localStorage.clear(); + const client = newFeaturesClient(); + await client.initialize(); + + let updated = false; + client.onUpdated(() => { + updated = true; + }); + + expect(client.getFeatures().featureB).toBeUndefined(); + + client.setFeatureOverride("featureB", true); + + expect(updated).toBe(true); + expect(client.getFeatures().featureB.isEnabled).toBe(false); + expect(client.getFeatures().featureB.isEnabledOverride).toBe(true); + }); }); diff --git a/packages/browser-sdk/test/mocks/handlers.ts b/packages/browser-sdk/test/mocks/handlers.ts index 0c575745..21d6a3e8 100644 --- a/packages/browser-sdk/test/mocks/handlers.ts +++ b/packages/browser-sdk/test/mocks/handlers.ts @@ -12,13 +12,14 @@ export const featureResponse: FeaturesResponse = { }, }; -export const featuresResult: Features = { +export const featuresResult = { featureA: { isEnabled: true, key: "featureA", targetingVersion: 1, + isEnabledOverride: null, }, -}; +} satisfies Features; function checkRequest(request: StrictRequest) { const url = new URL(request.url);