@flagix/js-sdk 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.ts ADDED
@@ -0,0 +1,482 @@
1
+ import { evaluateFlag, resolveIdentifier } from "@flagix/evaluation-core";
2
+ import {
3
+ EVENT_TO_LISTEN,
4
+ type FlagUpdateType,
5
+ REMOVE_TRAILING_SLASH,
6
+ } from "@/lib/constants";
7
+ import { FLAG_UPDATE_EVENT, FlagixEventEmitter } from "@/lib/emitter";
8
+ import { log, setLogLevel } from "@/lib/logger";
9
+ import {
10
+ createEventSource,
11
+ type FlagStreamConnection,
12
+ } from "@/sse/create-event-source";
13
+ import type {
14
+ EvaluationContext,
15
+ FlagConfig,
16
+ FlagixClientOptions,
17
+ FlagVariation,
18
+ VariationValue,
19
+ } from "@/types";
20
+
21
+ /**
22
+ * The primary class for the Flagix SDK. Manages configuration state,
23
+ * local caching, and evaluation.
24
+ */
25
+ export class FlagixClient {
26
+ private readonly apiKey: string;
27
+ private readonly apiBaseUrl: string;
28
+ private readonly localCache = new Map<string, FlagConfig>();
29
+ private context: EvaluationContext;
30
+ private isInitialized = false;
31
+ private sseConnection: FlagStreamConnection | null = null;
32
+ private readonly emitter: FlagixEventEmitter;
33
+ private reconnectAttempts = 0;
34
+ private reconnectTimeoutId: ReturnType<typeof setTimeout> | null = null;
35
+ private isReconnecting = false;
36
+ private hasEstablishedConnection = false;
37
+ private readonly maxReconnectAttempts = Number.POSITIVE_INFINITY;
38
+ private readonly baseReconnectDelay = 1000;
39
+ private readonly maxReconnectDelay = 30_000;
40
+
41
+ constructor(options: FlagixClientOptions) {
42
+ this.apiKey = options.apiKey;
43
+ this.apiBaseUrl = options.apiBaseUrl.replace(REMOVE_TRAILING_SLASH, "");
44
+ this.context = options.initialContext || {};
45
+ this.emitter = new FlagixEventEmitter();
46
+ setLogLevel(options.logs?.level ?? "none");
47
+ }
48
+
49
+ /**
50
+ * Subscribes a listener to a flag update event.
51
+ */
52
+ on(
53
+ event: typeof FLAG_UPDATE_EVENT,
54
+ listener: (flagKey: string) => void
55
+ ): void {
56
+ this.emitter.on(event, listener);
57
+ }
58
+
59
+ /**
60
+ * Unsubscribes a listener from a flag update event.
61
+ */
62
+ off(
63
+ event: typeof FLAG_UPDATE_EVENT,
64
+ listener: (flagKey: string) => void
65
+ ): void {
66
+ this.emitter.off(event, listener);
67
+ }
68
+
69
+ /**
70
+ * Fetches all flag configurations from the API, populates the local cache,
71
+ * and sets up the SSE connection for real-time updates.
72
+ */
73
+ async initialize(): Promise<void> {
74
+ if (this.isInitialized) {
75
+ return;
76
+ }
77
+
78
+ log("info", "[Flagix SDK] Starting initialization...");
79
+
80
+ await this.fetchInitialConfig();
81
+ this.setupSSEListener();
82
+
83
+ this.isInitialized = true;
84
+ log("info", "[Flagix SDK] Initialization complete.");
85
+ }
86
+
87
+ /**
88
+ * Returns true if the client has completed initialization
89
+ */
90
+ getIsInitialized(): boolean {
91
+ return this.isInitialized;
92
+ }
93
+
94
+ /**
95
+ * Evaluates a flag locally using the cached configuration and current context.
96
+ * @param flagKey The key of the flag to evaluate.
97
+ * @param context Optional, temporary context overrides for this specific evaluation.
98
+ */
99
+ evaluate<T extends VariationValue>(
100
+ flagKey: string,
101
+ contextOverrides?: EvaluationContext
102
+ ): T | null {
103
+ if (!this.isInitialized) {
104
+ log(
105
+ "warn",
106
+ `[Flagix SDK] Not initialized. Cannot evaluate flag: ${flagKey}`
107
+ );
108
+ return null;
109
+ }
110
+
111
+ const config = this.localCache.get(flagKey);
112
+
113
+ if (!config) {
114
+ return null;
115
+ }
116
+
117
+ const finalContext = { ...this.context, ...contextOverrides };
118
+
119
+ const result = evaluateFlag(config, finalContext);
120
+
121
+ if (result) {
122
+ this.trackEvaluation(flagKey, result, finalContext);
123
+ }
124
+
125
+ return (result?.value as T) ?? null;
126
+ }
127
+
128
+ /**
129
+ * Sets or updates the global evaluation context.
130
+ * @param newContext New context attributes to merge or replace.
131
+ */
132
+ setContext(newContext: EvaluationContext): void {
133
+ this.context = { ...this.context, ...newContext };
134
+ log(
135
+ "info",
136
+ "[Flagix SDK] Context updated. Evaluations will use the new context."
137
+ );
138
+ }
139
+
140
+ private async fetchInitialConfig(): Promise<void> {
141
+ const url = `${this.apiBaseUrl}/api/flag-config/all`;
142
+
143
+ try {
144
+ const response = await fetch(url, {
145
+ headers: { "X-Api-Key": this.apiKey },
146
+ });
147
+
148
+ if (!response.ok) {
149
+ throw new Error(
150
+ `Failed to load initial config: ${response.statusText}`
151
+ );
152
+ }
153
+
154
+ const allFlags = (await response.json()) as Record<string, FlagConfig>;
155
+
156
+ this.localCache.clear();
157
+ for (const [key, config] of Object.entries(allFlags)) {
158
+ this.localCache.set(key, config);
159
+ }
160
+ log("info", `[Flagix SDK] Loaded ${this.localCache.size} flag configs.`);
161
+ } catch (error) {
162
+ log(
163
+ "error",
164
+ "[Flagix SDK] CRITICAL: Initial configuration fetch failed.",
165
+ error
166
+ );
167
+ }
168
+ }
169
+
170
+ private async setupSSEListener(): Promise<void> {
171
+ if (this.sseConnection) {
172
+ try {
173
+ this.sseConnection.close();
174
+ } catch (error) {
175
+ log(
176
+ "warn",
177
+ "[Flagix SDK] Error closing existing SSE connection",
178
+ error
179
+ );
180
+ }
181
+ this.sseConnection = null;
182
+ }
183
+
184
+ const url = `${this.apiBaseUrl}/api/sse/stream`;
185
+
186
+ const source = await createEventSource(url, this.apiKey);
187
+ if (!source) {
188
+ log("warn", "[Flagix SDK] Failed to create EventSource. Retrying...");
189
+ this.scheduleReconnect();
190
+ return;
191
+ }
192
+
193
+ this.sseConnection = source;
194
+
195
+ source.onopen = () => {
196
+ this.reconnectAttempts = 0;
197
+ this.isReconnecting = false;
198
+ if (this.reconnectTimeoutId) {
199
+ clearTimeout(this.reconnectTimeoutId);
200
+ this.reconnectTimeoutId = null;
201
+ }
202
+
203
+ // If this is a reconnection and not the first connection, refresh the cache
204
+ // this ensures we have the latest flag values that may have changed while disconnected
205
+ if (this.hasEstablishedConnection && this.isInitialized) {
206
+ log(
207
+ "info",
208
+ "[Flagix SDK] SSE reconnected. Refreshing cache to sync with server..."
209
+ );
210
+ this.fetchInitialConfig().catch((error) => {
211
+ log(
212
+ "error",
213
+ "[Flagix SDK] Failed to refresh cache after reconnection",
214
+ error
215
+ );
216
+ });
217
+ } else {
218
+ this.hasEstablishedConnection = true;
219
+ }
220
+
221
+ log("info", "[Flagix SDK] SSE connection established.");
222
+ };
223
+
224
+ source.onerror = (error) => {
225
+ const eventSource = error.target as EventSource;
226
+ const readyState = eventSource?.readyState;
227
+
228
+ // EventSource.readyState: 0 = CONNECTING, 1 = OPEN, 2 = CLOSED
229
+ if (readyState === 2) {
230
+ log(
231
+ "warn",
232
+ "[Flagix SDK] SSE connection closed. Attempting to reconnect..."
233
+ );
234
+ this.handleReconnect();
235
+ } else if (readyState === 0) {
236
+ log(
237
+ "warn",
238
+ "[Flagix SDK] SSE connection error (connecting state)",
239
+ error
240
+ );
241
+ } else {
242
+ log("error", "[Flagix SDK] SSE error", error);
243
+ this.handleReconnect();
244
+ }
245
+ };
246
+
247
+ // Listen for the "connected" event from the server
248
+ source.addEventListener("connected", () => {
249
+ log("info", "[Flagix SDK] SSE connection confirmed by server.");
250
+ });
251
+
252
+ source.addEventListener(EVENT_TO_LISTEN, (event) => {
253
+ try {
254
+ const data = JSON.parse(event.data);
255
+ const { flagKey, type } = data as {
256
+ flagKey: string;
257
+ type: FlagUpdateType;
258
+ };
259
+
260
+ log("info", `[Flagix SDK] Received update for ${flagKey} (${type}).`);
261
+
262
+ this.fetchSingleFlagConfig(flagKey, type);
263
+ } catch (error) {
264
+ log("error", "[Flagix SDK] Failed to parse SSE event data.", error);
265
+ }
266
+ });
267
+ }
268
+
269
+ private handleReconnect(): void {
270
+ if (this.isReconnecting || !this.isInitialized) {
271
+ return;
272
+ }
273
+
274
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
275
+ log(
276
+ "error",
277
+ "[Flagix SDK] Max reconnection attempts reached. Stopping reconnection."
278
+ );
279
+ return;
280
+ }
281
+
282
+ this.isReconnecting = true;
283
+ this.scheduleReconnect();
284
+ }
285
+
286
+ private scheduleReconnect(): void {
287
+ if (this.reconnectTimeoutId) {
288
+ clearTimeout(this.reconnectTimeoutId);
289
+ }
290
+
291
+ // Calculate exponential backoff delay with jitter
292
+ const delay = Math.min(
293
+ this.baseReconnectDelay * 2 ** this.reconnectAttempts,
294
+ this.maxReconnectDelay
295
+ );
296
+ // Add ±25% jitter to prevent thundering herd
297
+ const jitter = delay * 0.25 * (Math.random() * 2 - 1);
298
+ const finalDelay = Math.max(100, delay + jitter);
299
+
300
+ this.reconnectAttempts++;
301
+
302
+ log(
303
+ "info",
304
+ `[Flagix SDK] Scheduling SSE reconnection attempt ${this.reconnectAttempts} in ${Math.round(finalDelay)}ms...`
305
+ );
306
+
307
+ this.reconnectTimeoutId = setTimeout(() => {
308
+ this.isReconnecting = false;
309
+ this.reconnectTimeoutId = null;
310
+ this.setupSSEListener().catch((error) => {
311
+ log("error", "[Flagix SDK] Failed to reconnect SSE", error);
312
+ this.handleReconnect();
313
+ });
314
+ }, finalDelay);
315
+ }
316
+
317
+ private async fetchSingleFlagConfig(
318
+ flagKey: string,
319
+ type: FlagUpdateType
320
+ ): Promise<void> {
321
+ const url = `${this.apiBaseUrl}/api/flag-config/${flagKey}`;
322
+
323
+ if (type === "FLAG_DELETED" || type === "RULE_DELETED") {
324
+ this.localCache.delete(flagKey);
325
+ log("info", `[Flagix SDK] Flag ${flagKey} deleted from cache.`);
326
+ this.emitter.emit(FLAG_UPDATE_EVENT, flagKey);
327
+ return;
328
+ }
329
+
330
+ try {
331
+ const response = await fetch(url, {
332
+ headers: { "X-Api-Key": this.apiKey },
333
+ });
334
+
335
+ if (response.status === 404) {
336
+ this.localCache.delete(flagKey);
337
+
338
+ log(
339
+ "warn",
340
+ `[Flagix SDK] Flag ${flagKey} not found on API, deleted from cache.`
341
+ );
342
+ this.emitter.emit(FLAG_UPDATE_EVENT, flagKey);
343
+ return;
344
+ }
345
+
346
+ if (!response.ok) {
347
+ throw new Error(
348
+ `Failed to fetch update for ${flagKey}: ${response.statusText}`
349
+ );
350
+ }
351
+
352
+ const config = (await response.json()) as FlagConfig;
353
+
354
+ this.localCache.set(flagKey, config);
355
+ log("info", `[Flagix SDK] Flag ${flagKey} updated/synced.`);
356
+ this.emitter.emit(FLAG_UPDATE_EVENT, flagKey);
357
+ } catch (error) {
358
+ log(
359
+ "error",
360
+ `[Flagix SDK] Failed to fetch update for ${flagKey}.`,
361
+ error
362
+ );
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Records a custom event (conversion) for analytics and A/B testing.
368
+ */
369
+ track(
370
+ eventName: string,
371
+ properties?: Record<string, unknown>,
372
+ contextOverrides?: EvaluationContext
373
+ ): void {
374
+ const url = `${this.apiBaseUrl}/api/track/event`;
375
+
376
+ const finalContext = { ...this.context, ...contextOverrides };
377
+ const distinctId = resolveIdentifier(finalContext);
378
+
379
+ const payload = {
380
+ apiKey: this.apiKey,
381
+ event_name: eventName,
382
+ distinctId,
383
+ properties: properties || {},
384
+ timestamp: new Date().toISOString(),
385
+ };
386
+
387
+ const payloadJson = JSON.stringify(payload);
388
+
389
+ if (typeof navigator !== "undefined" && navigator.sendBeacon) {
390
+ const blob = new Blob([payloadJson], { type: "application/json" });
391
+ if (navigator.sendBeacon(url, blob)) {
392
+ return;
393
+ }
394
+ }
395
+
396
+ this.fireAndForgetFetch(url, payloadJson);
397
+ }
398
+
399
+ /**
400
+ * Closes the Server-Sent Events (SSE) connection and cleans up resources.
401
+ */
402
+ close(): void {
403
+ if (this.reconnectTimeoutId) {
404
+ clearTimeout(this.reconnectTimeoutId);
405
+ this.reconnectTimeoutId = null;
406
+ }
407
+
408
+ this.isReconnecting = false;
409
+ this.reconnectAttempts = 0;
410
+ this.hasEstablishedConnection = false;
411
+
412
+ if (this.sseConnection) {
413
+ try {
414
+ this.sseConnection.close();
415
+ } catch (error) {
416
+ log("warn", "[Flagix SDK] Error closing SSE connection", error);
417
+ }
418
+ this.sseConnection = null;
419
+ log("info", "[Flagix SDK] SSE connection closed.");
420
+ }
421
+
422
+ this.localCache.clear();
423
+ this.isInitialized = false;
424
+ this.emitter.removeAllListeners();
425
+ }
426
+
427
+ /**
428
+ * Asynchronously sends an evaluation event to the backend tracking service.
429
+ */
430
+ private trackEvaluation(
431
+ flagKey: string,
432
+ result: FlagVariation,
433
+ context: EvaluationContext
434
+ ): void {
435
+ const url = `${this.apiBaseUrl}/api/track/evaluation`;
436
+
437
+ const distinctId = resolveIdentifier(context);
438
+
439
+ const payload = {
440
+ apiKey: this.apiKey,
441
+ flagKey,
442
+ variationName: result.name,
443
+ variationValue: result.value,
444
+ variationType: result.type,
445
+ distinctId,
446
+ evaluationContext: context,
447
+ evaluatedAt: new Date().toISOString(),
448
+ };
449
+
450
+ const payloadJson = JSON.stringify(payload);
451
+
452
+ if (typeof navigator !== "undefined" && navigator.sendBeacon) {
453
+ const blob = new Blob([payloadJson], { type: "application/json" });
454
+
455
+ const success = navigator.sendBeacon(url, blob);
456
+
457
+ if (success) {
458
+ log("info", `Successfully queued beacon for ${flagKey}.`);
459
+ return;
460
+ }
461
+
462
+ log("warn", `Beacon queue full for ${flagKey}. Falling back to fetch.`);
463
+ this.fireAndForgetFetch(url, payloadJson);
464
+ } else {
465
+ this.fireAndForgetFetch(url, payloadJson);
466
+ }
467
+ }
468
+
469
+ private fireAndForgetFetch(url: string, payloadJson: string): void {
470
+ fetch(url, {
471
+ method: "POST",
472
+ headers: { "Content-Type": "application/json" },
473
+ body: payloadJson,
474
+ keepalive: true,
475
+ }).catch((error) => {
476
+ log(
477
+ "error",
478
+ `Critical failure sending impression event via fetch: ${error.message}`
479
+ );
480
+ });
481
+ }
482
+ }
package/src/index.ts ADDED
@@ -0,0 +1,139 @@
1
+ import { FlagixClient } from "@/client";
2
+ import { FLAG_UPDATE_EVENT } from "@/lib/emitter";
3
+ import { log } from "@/lib/logger";
4
+ import type {
5
+ EvaluationContext,
6
+ FlagixClientOptions,
7
+ VariationValue,
8
+ } from "@/types";
9
+
10
+ let clientInstance: FlagixClient | null = null;
11
+
12
+ let isInitializing = false;
13
+ let initializationPromise: Promise<void> | null = null;
14
+
15
+ export const Flagix = {
16
+ /**
17
+ * Initializes the Flagix SDK, fetches all flags, and sets up an SSE connection.
18
+ */
19
+ async initialize(options: FlagixClientOptions): Promise<void> {
20
+ if (clientInstance) {
21
+ log("warn", "Flagix SDK already initialized. Ignoring subsequent call.");
22
+ return;
23
+ }
24
+
25
+ if (isInitializing && initializationPromise) {
26
+ return initializationPromise;
27
+ }
28
+
29
+ isInitializing = true;
30
+
31
+ try {
32
+ clientInstance = new FlagixClient(options);
33
+ initializationPromise = clientInstance.initialize();
34
+ await initializationPromise;
35
+ } catch (error) {
36
+ log("error", "Flagix SDK failed during initialization:", error);
37
+ throw error;
38
+ } finally {
39
+ isInitializing = false;
40
+ initializationPromise = null;
41
+ }
42
+ },
43
+
44
+ /**
45
+ * Evaluates a flag based on the local cache and current context.
46
+ * @param flagKey The key of the flag to evaluate.
47
+ * @param contextOverrides Optional, temporary context overrides.
48
+ */
49
+ evaluate<T extends VariationValue>(
50
+ flagKey: string,
51
+ contextOverrides?: EvaluationContext
52
+ ): T | null {
53
+ if (!clientInstance || !clientInstance.getIsInitialized()) {
54
+ log(
55
+ "error",
56
+ "Flagix SDK not initialized. Call Flagix.initialize() first."
57
+ );
58
+
59
+ return null;
60
+ }
61
+ return clientInstance.evaluate<T>(flagKey, contextOverrides);
62
+ },
63
+
64
+ /**
65
+ * Records a custom event for analytics.
66
+ * @param eventName The name of the event.
67
+ * @param properties Optional custom metadata.
68
+ * @param contextOverrides Optional context.
69
+ */
70
+ track(
71
+ eventName: string,
72
+ properties?: Record<string, unknown>,
73
+ contextOverrides?: EvaluationContext
74
+ ): void {
75
+ if (!clientInstance) {
76
+ log(
77
+ "error",
78
+ "Flagix SDK not initialized. Call Flagix.initialize() first."
79
+ );
80
+ return;
81
+ }
82
+ clientInstance.track(eventName, properties, contextOverrides);
83
+ },
84
+
85
+ /**
86
+ * Sets or updates the global evaluation context.
87
+ * @param newContext New context attributes to merge or replace.
88
+ */
89
+ setContext(newContext: EvaluationContext): void {
90
+ if (!clientInstance) {
91
+ log("error", "Flagix SDK not initialized.");
92
+ return;
93
+ }
94
+ clientInstance.setContext(newContext);
95
+ },
96
+
97
+ /**
98
+ * Closes the SSE connection and cleans up resources.
99
+ */
100
+ close(): void {
101
+ if (clientInstance) {
102
+ clientInstance.close();
103
+ clientInstance = null;
104
+ }
105
+ },
106
+
107
+ /**
108
+ * checks initialization status
109
+ */
110
+ isInitialized(): boolean {
111
+ return !!clientInstance && clientInstance.getIsInitialized();
112
+ },
113
+
114
+ /**
115
+ * Subscribes a listener to updates for any flag.
116
+ * @param listener The callback function (receives the updated flagKey).
117
+ */
118
+ onFlagUpdate(listener: (flagKey: string) => void): void {
119
+ if (!clientInstance) {
120
+ log("warn", "Flagix SDK not initialized. Cannot subscribe to updates.");
121
+ return;
122
+ }
123
+ clientInstance.on(FLAG_UPDATE_EVENT, listener);
124
+ },
125
+
126
+ /**
127
+ * Unsubscribes a listener from flag updates.
128
+ * @param listener The callback function to remove.
129
+ */
130
+ offFlagUpdate(listener: (flagKey: string) => void): void {
131
+ if (!clientInstance) {
132
+ return;
133
+ }
134
+ clientInstance.off(FLAG_UPDATE_EVENT, listener);
135
+ },
136
+ };
137
+
138
+ // biome-ignore lint/performance/noBarrelFile: <>
139
+ export * from "./types";
@@ -0,0 +1,12 @@
1
+ export const REMOVE_TRAILING_SLASH = /\/$/;
2
+
3
+ export type FlagUpdateType =
4
+ | "FLAG_CREATED"
5
+ | "FLAG_UPDATED"
6
+ | "FLAG_DELETED"
7
+ | "FLAG_METADATA_UPDATED"
8
+ | "FLAG_STATE_TOGGLED"
9
+ | "RULE_UPDATED"
10
+ | "RULE_DELETED";
11
+
12
+ export const EVENT_TO_LISTEN = "flag-update";
@@ -0,0 +1,10 @@
1
+ import EventEmitter from "eventemitter3";
2
+ import { EVENT_TO_LISTEN } from "@/lib/constants";
3
+
4
+ export type FlagixEvents = {
5
+ "flag-update": (flagKey: string) => void;
6
+ };
7
+
8
+ export const FLAG_UPDATE_EVENT = EVENT_TO_LISTEN;
9
+
10
+ export class FlagixEventEmitter extends EventEmitter<FlagixEvents> {}
@@ -0,0 +1,41 @@
1
+ /**
2
+ * 'info' is for detailed debugging information
3
+ * 'warn' is for non-critical issues
4
+ * 'error' is for critical failures
5
+ * 'none' suppresses all output.
6
+ */
7
+ export type LogLevel = "none" | "error" | "warn" | "info";
8
+
9
+ let currentLogLevel: LogLevel = "none";
10
+
11
+ const LOG_LEVELS: Record<LogLevel, number> = {
12
+ none: 0,
13
+ error: 1,
14
+ warn: 2,
15
+ info: 3,
16
+ };
17
+
18
+ export function setLogLevel(level: LogLevel = "none"): void {
19
+ currentLogLevel = level;
20
+ }
21
+
22
+ export function log(
23
+ level: "error" | "warn" | "info",
24
+ message: string,
25
+ ...args: unknown[]
26
+ ): void {
27
+ const currentLevel = LOG_LEVELS[currentLogLevel];
28
+ const requiredLevel = LOG_LEVELS[level];
29
+
30
+ if (currentLevel >= requiredLevel) {
31
+ const prefixedMessage = `[Flagix SDK] ${message}`;
32
+
33
+ if (level === "error") {
34
+ console.error(prefixedMessage, ...args);
35
+ } else if (level === "warn") {
36
+ console.warn(prefixedMessage, ...args);
37
+ } else {
38
+ console.info(prefixedMessage, ...args);
39
+ }
40
+ }
41
+ }