Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(browser-sdk): support overrides, propagate updates #286

Merged
merged 7 commits into from
Jan 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions packages/browser-sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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;
roncohen marked this conversation as resolved.
Show resolved Hide resolved

return {
get isEnabled() {
fClient
.sendCheckEvent({
key: key,
key,
version: f?.targetingVersion,
value,
})
Expand All @@ -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);
}
Expand Down
12 changes: 6 additions & 6 deletions packages/browser-sdk/src/feature/featureCache.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { RawFeatures } from "./features";
import { FetchedFeatures } from "./features";

interface StorageItem {
get(): string | null;
Expand All @@ -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 (
Expand All @@ -39,7 +39,7 @@ export function parseAPIFeaturesResponse(
}

export interface CacheResult {
features: RawFeatures;
features: FetchedFeatures;
stale: boolean;
}

Expand Down Expand Up @@ -67,7 +67,7 @@ export class FeatureCache {
{
features,
}: {
features: RawFeatures;
features: FetchedFeatures;
},
) {
let cacheData: CacheData = {};
Expand Down
117 changes: 101 additions & 16 deletions packages/browser-sdk/src/feature/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
parseAPIFeaturesResponse,
} from "./featureCache";

export type RawFeature = {
export type FetchedFeature = {
/**
* Feature key
*/
Expand All @@ -28,7 +28,15 @@ export type RawFeature = {

const FEATURES_UPDATED_EVENT = "features-updated";

export type RawFeatures = Record<string, RawFeature | undefined>;
export type FetchedFeatures = Record<string, FetchedFeature | undefined>;
// todo: on next major, come up with a better name for this type. Maybe `LocalFeature`.
export type RawFeature = FetchedFeature & {
roncohen marked this conversation as resolved.
Show resolved Hide resolved
/**
* If not null, the result is being overridden locally
*/
isEnabledOverride: boolean | null;
};
export type RawFeatures = Record<string, RawFeature>;

export type FeaturesOptions = {
/**
Expand All @@ -38,7 +46,7 @@ export type FeaturesOptions = {
fallbackFeatures?: string[];

/**
* Timeout in miliseconds
* Timeout in milliseconds
*/
timeoutMs?: number;

Expand Down Expand Up @@ -73,7 +81,7 @@ export type FeaturesResponse = {
/**
* List of enabled features
*/
features: RawFeatures;
features: FetchedFeatures;
};

export function validateFeaturesResponse(response: any) {
Expand Down Expand Up @@ -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<string, boolean>;

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;
Expand All @@ -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,
Expand All @@ -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) {
Expand Down Expand Up @@ -222,7 +258,7 @@ export class FeaturesClient {
return this.features;
}

public async fetchFeatures(): Promise<RawFeatures | undefined> {
public async fetchFeatures(): Promise<FetchedFeatures | undefined> {
const params = this.fetchParams();
try {
const res = await this.httpClient.get({
Expand Down Expand Up @@ -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);
Expand All @@ -308,7 +374,7 @@ export class FeaturesClient {
return params;
}

private async maybeFetchFeatures(): Promise<RawFeatures | undefined> {
private async maybeFetchFeatures(): Promise<FetchedFeatures | undefined> {
const cacheKey = this.fetchParams().toString();
const cachedItem = this.cache.get(cacheKey);

Expand All @@ -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
Expand Down Expand Up @@ -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;
}
}
12 changes: 12 additions & 0 deletions packages/browser-sdk/test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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);
});
});
});
Loading
Loading