Skip to content

Commit 1e5a65a

Browse files
committed
feat: add ability to bootstrap flags on server and populate clients
1 parent e5fff59 commit 1e5a65a

19 files changed

Lines changed: 1302 additions & 97 deletions

File tree

packages/browser-sdk/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@reflag/browser-sdk",
3-
"version": "1.1.0",
3+
"version": "1.2.0",
44
"packageManager": "yarn@4.1.1",
55
"license": "MIT",
66
"repository": {

packages/browser-sdk/src/client.ts

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import * as feedbackLib from "./feedback/ui";
1010
import {
1111
CheckEvent,
1212
FallbackFlagOverride,
13+
FetchedFlags,
1314
FlagsClient,
1415
RawFlags,
1516
} from "./flag/flags";
@@ -238,6 +239,12 @@ export type InitOptions = {
238239
*/
239240
offline?: boolean;
240241

242+
/**
243+
* An object containing pre-fetched flags to be used instead of fetching them from the server.
244+
* This is intended to be used with the Node-SDK getFlagsForBootstrap method.
245+
*/
246+
flags?: FetchedFlags;
247+
241248
/**
242249
* Flag keys for which `isEnabled` should fallback to true
243250
* if SDK fails to fetch flags from Reflag servers. If a record
@@ -431,6 +438,7 @@ export class ReflagClient {
431438
},
432439
this.logger,
433440
{
441+
flags: opts.flags,
434442
expireTimeMs: opts.expireTimeMs,
435443
staleTimeMs: opts.staleTimeMs,
436444
fallbackFlags: opts.fallbackFlags,
@@ -482,8 +490,10 @@ export class ReflagClient {
482490
* Initialize the Reflag SDK.
483491
*
484492
* Must be called before calling other SDK methods.
493+
*
494+
* @param bootstrap - Whether to bootstrap the client, fetching flags, sending user, and company events.
485495
*/
486-
async initialize() {
496+
async initialize(bootstrap = true) {
487497
const start = Date.now();
488498
if (this.autoFeedback) {
489499
// do not block on automated feedback surveys initialization
@@ -492,17 +502,20 @@ export class ReflagClient {
492502
});
493503
}
494504

495-
await this.flagsClient.initialize();
496-
if (this.context.user && this.config.enableTracking) {
497-
this.user().catch((e) => {
498-
this.logger.error("error sending user", e);
499-
});
500-
}
505+
if (bootstrap) {
506+
await this.flagsClient.initialize();
501507

502-
if (this.context.company && this.config.enableTracking) {
503-
this.company().catch((e) => {
504-
this.logger.error("error sending company", e);
505-
});
508+
if (this.context.user && this.config.enableTracking) {
509+
this.user().catch((e) => {
510+
this.logger.error("error sending user", e);
511+
});
512+
}
513+
514+
if (this.context.company && this.config.enableTracking) {
515+
this.company().catch((e) => {
516+
this.logger.error("error sending company", e);
517+
});
518+
}
506519
}
507520

508521
this.logger.info(

packages/browser-sdk/src/flag/flags.ts

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,6 @@ export type FetchedFlag = {
6868

6969
const FLAGS_UPDATED_EVENT = "flagsUpdated";
7070

71-
/**
72-
* @internal
73-
*/
7471
export type FetchedFlags = Record<string, FetchedFlag | undefined>;
7572

7673
export type RawFlag = FetchedFlag & {
@@ -176,7 +173,7 @@ export interface CheckEvent {
176173
missingContextFields?: string[];
177174
}
178175

179-
type context = {
176+
type Context = {
180177
user?: Record<string, any>;
181178
company?: Record<string, any>;
182179
other?: Record<string, any>;
@@ -207,8 +204,9 @@ function getOverridesCache(): OverridesFlags {
207204
* @internal
208205
*/
209206
export class FlagsClient {
207+
private initialized = false;
210208
private cache: FlagCache;
211-
private fetchedFlags: FetchedFlags;
209+
private fetchedFlags: FetchedFlags = {};
212210
private flagOverrides: OverridesFlags = {};
213211

214212
private flags: RawFlags = {};
@@ -222,9 +220,10 @@ export class FlagsClient {
222220

223221
constructor(
224222
private httpClient: HttpClient,
225-
private context: context,
223+
private context: Context,
226224
logger: Logger,
227225
options?: {
226+
flags?: FetchedFlags;
228227
fallbackFlags?: Record<string, FallbackFlagOverride> | string[];
229228
timeoutMs?: number;
230229
staleTimeMs?: number;
@@ -234,7 +233,6 @@ export class FlagsClient {
234233
offline?: boolean;
235234
},
236235
) {
237-
this.fetchedFlags = {};
238236
this.logger = loggerWithPrefix(logger, "[Flags]");
239237
this.cache = options?.cache
240238
? options.cache
@@ -280,14 +278,22 @@ export class FlagsClient {
280278
this.logger.warn("error getting flag overrides from cache", e);
281279
this.flagOverrides = {};
282280
}
281+
282+
if (options?.flags) {
283+
this.initialized = true;
284+
this.fetchedFlags = options.flags;
285+
this.flags = this.mergeFlags(this.fetchedFlags, this.flagOverrides);
286+
}
283287
}
284288

285289
async initialize() {
286-
const flags = (await this.maybeFetchFlags()) || {};
287-
this.setFetchedFlags(flags);
290+
if (!this.initialized) {
291+
this.initialized = true;
292+
this.setFetchedFlags((await this.maybeFetchFlags()) || {});
293+
}
288294
}
289295

290-
async setContext(context: context) {
296+
async setContext(context: Context) {
291297
this.context = context;
292298
await this.initialize();
293299
}
@@ -397,21 +403,20 @@ export class FlagsClient {
397403
return checkEvent.value;
398404
}
399405

400-
private triggerFlagsUpdated() {
406+
private mergeFlags(fetchedFlags: FetchedFlags, overrides: OverridesFlags) {
401407
const mergedFlags: RawFlags = {};
402-
403408
// merge fetched flags with overrides into `this.flags`
404-
for (const key in this.fetchedFlags) {
405-
const fetchedFlag = this.fetchedFlags[key];
409+
for (const key in fetchedFlags) {
410+
const fetchedFlag = fetchedFlags[key];
406411
if (!fetchedFlag) continue;
407-
const isEnabledOverride = this.flagOverrides[key] ?? null;
408-
mergedFlags[key] = {
409-
...fetchedFlag,
410-
isEnabledOverride,
411-
};
412+
const isEnabledOverride = overrides[key] ?? null;
413+
mergedFlags[key] = { ...fetchedFlag, isEnabledOverride };
412414
}
415+
return mergedFlags;
416+
}
413417

414-
this.flags = mergedFlags;
418+
private triggerFlagsUpdated() {
419+
this.flags = this.mergeFlags(this.fetchedFlags, this.flagOverrides);
415420

416421
this.eventTarget.dispatchEvent(new Event(FLAGS_UPDATED_EVENT));
417422
}

packages/browser-sdk/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export type {
3232
CheckEvent,
3333
FallbackFlagOverride,
3434
FetchedFlag,
35+
FetchedFlags,
3536
RawFlag,
3637
RawFlags,
3738
} from "./flag/flags";

packages/browser-sdk/test/client.test.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,4 +171,114 @@ describe("ReflagClient", () => {
171171
expect(httpClientGet).not.toHaveBeenCalled();
172172
});
173173
});
174+
175+
describe("bootstrap parameter", () => {
176+
const flagsClientInitialize = vi.spyOn(FlagsClient.prototype, "initialize");
177+
178+
beforeEach(() => {
179+
flagsClientInitialize.mockClear();
180+
});
181+
182+
it("should skip flagsClient.initialize() when bootstrap is false", async () => {
183+
client = new ReflagClient({
184+
publishableKey: "test-key",
185+
user: { id: "user1" },
186+
company: { id: "company1" },
187+
feedback: { enableAutoFeedback: false }, // Disable to avoid HTTP calls
188+
});
189+
190+
await client.initialize(false);
191+
192+
expect(flagsClientInitialize).not.toHaveBeenCalled();
193+
expect(httpClientPost).not.toHaveBeenCalled(); // No user/company tracking
194+
});
195+
196+
it("should use pre-fetched flags and skip initialization when flags are provided", async () => {
197+
const preFetchedFlags = {
198+
testFlag: {
199+
key: "testFlag",
200+
isEnabled: true,
201+
targetingVersion: 1,
202+
},
203+
};
204+
205+
// Create a spy to monitor maybeFetchFlags which should not be called if already initialized
206+
const maybeFetchFlags = vi.spyOn(
207+
FlagsClient.prototype as any,
208+
"maybeFetchFlags",
209+
);
210+
211+
client = new ReflagClient({
212+
publishableKey: "test-key",
213+
user: { id: "user1" },
214+
company: { id: "company1" },
215+
flags: preFetchedFlags,
216+
feedback: { enableAutoFeedback: false }, // Disable to avoid HTTP calls
217+
});
218+
219+
// FlagsClient should be initialized in constructor when flags are provided
220+
expect(client["flagsClient"]["initialized"]).toBe(true);
221+
expect(client.getFlags()).toEqual({
222+
testFlag: {
223+
key: "testFlag",
224+
isEnabled: true,
225+
targetingVersion: 1,
226+
isEnabledOverride: null,
227+
},
228+
});
229+
230+
maybeFetchFlags.mockClear();
231+
232+
await client.initialize();
233+
234+
// maybeFetchFlags should not be called since flagsClient is already initialized
235+
expect(maybeFetchFlags).not.toHaveBeenCalled();
236+
});
237+
238+
it("should combine pre-fetched flags with bootstrap=false correctly", async () => {
239+
const preFetchedFlags = {
240+
testFlag: {
241+
key: "testFlag",
242+
isEnabled: true,
243+
targetingVersion: 1,
244+
config: {
245+
key: "config1",
246+
version: 1,
247+
payload: { value: "test" },
248+
},
249+
},
250+
};
251+
252+
client = new ReflagClient({
253+
publishableKey: "test-key",
254+
user: { id: "user1" },
255+
company: { id: "company1" },
256+
flags: preFetchedFlags,
257+
feedback: { enableAutoFeedback: false }, // Disable to avoid HTTP calls
258+
});
259+
260+
await client.initialize(false);
261+
262+
// Should not call flagsClient.initialize() because bootstrap is false
263+
expect(flagsClientInitialize).not.toHaveBeenCalled();
264+
// Should not make any HTTP calls for user/company tracking
265+
expect(httpClientPost).not.toHaveBeenCalled();
266+
// Should have the pre-fetched flags available
267+
expect(client.getFlags()).toEqual({
268+
testFlag: {
269+
key: "testFlag",
270+
isEnabled: true,
271+
targetingVersion: 1,
272+
config: {
273+
key: "config1",
274+
version: 1,
275+
payload: { value: "test" },
276+
},
277+
isEnabledOverride: null,
278+
},
279+
});
280+
// Should be able to use the flag
281+
expect(client.getFlag("testFlag").isEnabled).toBe(true);
282+
});
283+
});
174284
});

0 commit comments

Comments
 (0)