From 82fd0d27b0eaf548d0ab613b4dc85d5563f66759 Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Wed, 16 Jul 2025 11:40:27 +0100 Subject: [PATCH] feat: added `offline` mode to aid in testing --- packages/browser-sdk/src/client.ts | 39 ++++++++++++++++++++ packages/browser-sdk/src/feature/features.ts | 13 ++++++- packages/browser-sdk/test/client.test.ts | 25 +++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index dcb3eac7..e0540584 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -165,6 +165,11 @@ export interface Config { * Whether to enable tracking. */ enableTracking: boolean; + + /** + * Whether to enable offline mode. + */ + offline: boolean; } /** @@ -228,6 +233,11 @@ export type InitOptions = { */ appBaseUrl?: string; + /** + * Whether to enable offline mode. Defaults to `false`. + */ + offline?: boolean; + /** * Feature keys for which `isEnabled` should fallback to true * if SDK fails to fetch features from Bucket servers. If a record @@ -294,6 +304,7 @@ const defaultConfig: Config = { appBaseUrl: APP_BASE_URL, sseBaseUrl: SSE_REALTIME_BASE_URL, enableTracking: true, + offline: false, }; /** @@ -396,6 +407,7 @@ export class BucketClient { appBaseUrl: opts?.appBaseUrl ?? defaultConfig.appBaseUrl, sseBaseUrl: opts?.sseBaseUrl ?? defaultConfig.sseBaseUrl, enableTracking: opts?.enableTracking ?? defaultConfig.enableTracking, + offline: opts?.offline ?? defaultConfig.offline, }; this.requestFeedbackOptions = { @@ -423,10 +435,12 @@ export class BucketClient { staleTimeMs: opts.staleTimeMs, fallbackFeatures: opts.fallbackFeatures, timeoutMs: opts.timeoutMs, + offline: this.config.offline, }, ); if ( + !this.config.offline && this.context?.user && !isNode && // do not prompt on server-side opts?.feedback?.enableAutoFeedback !== false // default to on @@ -470,6 +484,7 @@ export class BucketClient { * Must be called before calling other SDK methods. */ async initialize() { + const start = Date.now(); if (this.autoFeedback) { // do not block on automated feedback surveys initialization this.autoFeedbackInit = this.autoFeedback.initialize().catch((e) => { @@ -489,6 +504,13 @@ export class BucketClient { this.logger.error("error sending company", e); }); } + + this.logger.info( + "Bucket initialized in " + + Math.round(Date.now() - start) + + "ms" + + (this.config.offline ? " (offline mode)" : ""), + ); } /** @@ -607,6 +629,10 @@ export class BucketClient { return; } + if (this.config.offline) { + return; + } + const payload: TrackedEvent = { userId: String(this.context.user.id), event: eventName, @@ -634,9 +660,14 @@ export class BucketClient { * @returns The server response. */ async feedback(payload: Feedback) { + if (this.config.offline) { + return; + } + const userId = payload.userId || (this.context.user?.id ? String(this.context.user?.id) : undefined); + const companyId = payload.companyId || (this.context.company?.id ? String(this.context.company?.id) : undefined); @@ -833,6 +864,10 @@ export class BucketClient { return; } + if (this.config.offline) { + return; + } + const { id, ...attributes } = this.context.user; const payload: User = { userId: String(id), @@ -863,6 +898,10 @@ export class BucketClient { return; } + if (this.config.offline) { + return; + } + const { id, ...attributes } = this.context.company; const payload: Company = { userId: String(this.context.user.id), diff --git a/packages/browser-sdk/src/feature/features.ts b/packages/browser-sdk/src/feature/features.ts index a8d38bc9..197ed14c 100644 --- a/packages/browser-sdk/src/feature/features.ts +++ b/packages/browser-sdk/src/feature/features.ts @@ -97,12 +97,14 @@ type Config = { fallbackFeatures: Record; timeoutMs: number; staleWhileRevalidate: boolean; + offline: boolean; }; export const DEFAULT_FEATURES_CONFIG: Config = { fallbackFeatures: {}, timeoutMs: 5000, staleWhileRevalidate: false, + offline: false, }; export function validateFeaturesResponse(response: any) { @@ -235,6 +237,7 @@ export class FeaturesClient { expireTimeMs?: number; cache?: FeatureCache; rateLimiter?: RateLimiter; + offline?: boolean; }, ) { this.fetchedFeatures = {}; @@ -265,7 +268,11 @@ export class FeaturesClient { fallbackFeatures = options?.fallbackFeatures ?? {}; } - this.config = { ...DEFAULT_FEATURES_CONFIG, ...options, fallbackFeatures }; + this.config = { + ...DEFAULT_FEATURES_CONFIG, + ...options, + fallbackFeatures, + }; this.rateLimiter = options?.rateLimiter ?? @@ -456,6 +463,10 @@ export class FeaturesClient { } private async maybeFetchFeatures(): Promise { + if (this.config.offline) { + return; + } + const cacheKey = this.fetchParams().toString(); const cachedItem = this.cache.get(cacheKey); diff --git a/packages/browser-sdk/test/client.test.ts b/packages/browser-sdk/test/client.test.ts index b4c2e464..ec2d1f68 100644 --- a/packages/browser-sdk/test/client.test.ts +++ b/packages/browser-sdk/test/client.test.ts @@ -9,6 +9,7 @@ import { featuresResult } from "./mocks/handlers"; describe("BucketClient", () => { let client: BucketClient; const httpClientPost = vi.spyOn(HttpClient.prototype as any, "post"); + const httpClientGet = vi.spyOn(HttpClient.prototype as any, "get"); const featureClientSetContext = vi.spyOn( FeaturesClient.prototype, @@ -21,6 +22,8 @@ describe("BucketClient", () => { user: { id: "user1" }, company: { id: "company1" }, }); + + vi.clearAllMocks(); }); describe("updateUser", () => { @@ -161,4 +164,26 @@ describe("BucketClient", () => { expect(featuresUpdated).not.toHaveBeenCalled(); }); }); + + describe("offline mode", () => { + it("should not make HTTP calls when offline", async () => { + client = new BucketClient({ + publishableKey: "test-key", + user: { id: "user1" }, + company: { id: "company1" }, + offline: true, + feedback: { enableAutoFeedback: true }, + }); + + await client.initialize(); + await client.track("offline-event"); + await client.feedback({ featureKey: "featureA", score: 5 }); + await client.updateUser({ name: "New User" }); + await client.updateCompany({ name: "New Company" }); + await client.stop(); + + expect(httpClientPost).not.toHaveBeenCalled(); + expect(httpClientGet).not.toHaveBeenCalled(); + }); + }); });