@flipswitch-io/sdk 0.1.4 → 0.1.6
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/README.md +301 -55
- package/dist/index.d.mts +228 -15
- package/dist/index.d.ts +228 -15
- package/dist/index.js +455 -30
- package/dist/index.mjs +454 -30
- package/package.json +2 -2
package/dist/index.mjs
CHANGED
|
@@ -9,26 +9,37 @@ import { OFREPWebProvider } from "@openfeature/ofrep-web-provider";
|
|
|
9
9
|
var MIN_RETRY_DELAY = 1e3;
|
|
10
10
|
var MAX_RETRY_DELAY = 3e4;
|
|
11
11
|
var SseClient = class {
|
|
12
|
-
constructor(baseUrl, apiKey, onFlagChange, onStatusChange, telemetryHeaders) {
|
|
12
|
+
constructor(baseUrl, apiKey, onFlagChange, onStatusChange, telemetryHeaders, enableVisibilityHandling = true) {
|
|
13
13
|
this.baseUrl = baseUrl;
|
|
14
14
|
this.apiKey = apiKey;
|
|
15
15
|
this.onFlagChange = onFlagChange;
|
|
16
16
|
this.onStatusChange = onStatusChange;
|
|
17
17
|
this.telemetryHeaders = telemetryHeaders;
|
|
18
|
+
this.enableVisibilityHandling = enableVisibilityHandling;
|
|
18
19
|
this.eventSource = null;
|
|
19
20
|
this.retryDelay = MIN_RETRY_DELAY;
|
|
20
21
|
this.reconnectTimeout = null;
|
|
21
22
|
this.closed = false;
|
|
23
|
+
this.paused = false;
|
|
22
24
|
this.status = "disconnected";
|
|
25
|
+
this.visibilityHandler = null;
|
|
26
|
+
this.abortController = null;
|
|
23
27
|
}
|
|
24
28
|
/**
|
|
25
29
|
* Start the SSE connection.
|
|
26
30
|
*/
|
|
27
31
|
connect() {
|
|
28
|
-
if (this.closed) return;
|
|
32
|
+
if (this.closed || this.paused) return;
|
|
33
|
+
if (this.abortController) {
|
|
34
|
+
this.abortController.abort();
|
|
35
|
+
}
|
|
36
|
+
this.abortController = new AbortController();
|
|
29
37
|
if (this.eventSource) {
|
|
30
38
|
this.eventSource.close();
|
|
31
39
|
}
|
|
40
|
+
if (this.enableVisibilityHandling) {
|
|
41
|
+
this.setupVisibilityHandling();
|
|
42
|
+
}
|
|
32
43
|
this.updateStatus("connecting");
|
|
33
44
|
const url = `${this.baseUrl}/api/v1/flags/events`;
|
|
34
45
|
try {
|
|
@@ -38,11 +49,62 @@ var SseClient = class {
|
|
|
38
49
|
this.connectWithPolyfill(url);
|
|
39
50
|
}
|
|
40
51
|
} catch (error) {
|
|
41
|
-
console.
|
|
52
|
+
console.warn("[Flipswitch] Failed to establish SSE connection:", error);
|
|
42
53
|
this.updateStatus("error");
|
|
43
54
|
this.scheduleReconnect();
|
|
44
55
|
}
|
|
45
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Setup visibility API handling.
|
|
59
|
+
* Disconnects when tab is hidden, reconnects when visible.
|
|
60
|
+
*/
|
|
61
|
+
setupVisibilityHandling() {
|
|
62
|
+
if (typeof document === "undefined" || this.visibilityHandler) return;
|
|
63
|
+
this.visibilityHandler = () => {
|
|
64
|
+
if (document.hidden) {
|
|
65
|
+
this.pause();
|
|
66
|
+
} else {
|
|
67
|
+
this.resume();
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
document.addEventListener("visibilitychange", this.visibilityHandler);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Pause the SSE connection (used by visibility API).
|
|
74
|
+
* Different from close() - can be resumed.
|
|
75
|
+
*/
|
|
76
|
+
pause() {
|
|
77
|
+
if (this.paused || this.closed) return;
|
|
78
|
+
this.paused = true;
|
|
79
|
+
if (this.abortController) {
|
|
80
|
+
this.abortController.abort();
|
|
81
|
+
this.abortController = null;
|
|
82
|
+
}
|
|
83
|
+
if (this.reconnectTimeout) {
|
|
84
|
+
clearTimeout(this.reconnectTimeout);
|
|
85
|
+
this.reconnectTimeout = null;
|
|
86
|
+
}
|
|
87
|
+
if (this.eventSource) {
|
|
88
|
+
this.eventSource.close();
|
|
89
|
+
this.eventSource = null;
|
|
90
|
+
}
|
|
91
|
+
this.updateStatus("disconnected");
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Resume the SSE connection after being paused.
|
|
95
|
+
*/
|
|
96
|
+
resume() {
|
|
97
|
+
if (!this.paused || this.closed) return;
|
|
98
|
+
this.paused = false;
|
|
99
|
+
this.retryDelay = MIN_RETRY_DELAY;
|
|
100
|
+
this.connect();
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Check if the connection is paused.
|
|
104
|
+
*/
|
|
105
|
+
isPaused() {
|
|
106
|
+
return this.paused;
|
|
107
|
+
}
|
|
46
108
|
/**
|
|
47
109
|
* Connect using fetch-based SSE (supports custom headers).
|
|
48
110
|
*/
|
|
@@ -56,7 +118,8 @@ var SseClient = class {
|
|
|
56
118
|
};
|
|
57
119
|
const response = await fetch(url, {
|
|
58
120
|
method: "GET",
|
|
59
|
-
headers
|
|
121
|
+
headers,
|
|
122
|
+
signal: this.abortController?.signal
|
|
60
123
|
});
|
|
61
124
|
if (!response.ok) {
|
|
62
125
|
throw new Error(`SSE connection failed: ${response.status}`);
|
|
@@ -97,14 +160,14 @@ var SseClient = class {
|
|
|
97
160
|
};
|
|
98
161
|
processStream().catch((error) => {
|
|
99
162
|
if (!this.closed) {
|
|
100
|
-
console.
|
|
163
|
+
console.warn("[Flipswitch] SSE stream error:", error);
|
|
101
164
|
this.updateStatus("error");
|
|
102
165
|
this.scheduleReconnect();
|
|
103
166
|
}
|
|
104
167
|
});
|
|
105
168
|
} catch (error) {
|
|
106
169
|
if (!this.closed) {
|
|
107
|
-
console.
|
|
170
|
+
console.warn("[Flipswitch] SSE connection error:", error);
|
|
108
171
|
this.updateStatus("error");
|
|
109
172
|
this.scheduleReconnect();
|
|
110
173
|
}
|
|
@@ -124,13 +187,34 @@ var SseClient = class {
|
|
|
124
187
|
if (eventType === "heartbeat") {
|
|
125
188
|
return;
|
|
126
189
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const
|
|
190
|
+
try {
|
|
191
|
+
if (eventType === "flag-updated") {
|
|
192
|
+
const parsed = JSON.parse(data);
|
|
193
|
+
const event = {
|
|
194
|
+
flagKey: parsed.flagKey,
|
|
195
|
+
timestamp: parsed.timestamp
|
|
196
|
+
};
|
|
130
197
|
this.onFlagChange(event);
|
|
131
|
-
}
|
|
132
|
-
|
|
198
|
+
} else if (eventType === "config-updated") {
|
|
199
|
+
const parsed = JSON.parse(data);
|
|
200
|
+
const event = {
|
|
201
|
+
flagKey: null,
|
|
202
|
+
// null indicates all flags should be refreshed
|
|
203
|
+
timestamp: parsed.timestamp
|
|
204
|
+
};
|
|
205
|
+
this.onFlagChange(event);
|
|
206
|
+
} else if (eventType === "api-key-rotated") {
|
|
207
|
+
const parsed = JSON.parse(data);
|
|
208
|
+
if (!parsed.validUntil) {
|
|
209
|
+
console.info("[Flipswitch] API key rotation was aborted");
|
|
210
|
+
} else {
|
|
211
|
+
console.warn(
|
|
212
|
+
`[Flipswitch] API key was rotated. Current key valid until: ${parsed.validUntil}`
|
|
213
|
+
);
|
|
214
|
+
}
|
|
133
215
|
}
|
|
216
|
+
} catch (error) {
|
|
217
|
+
console.error(`[Flipswitch] Failed to parse ${eventType} event:`, error);
|
|
134
218
|
}
|
|
135
219
|
}
|
|
136
220
|
/**
|
|
@@ -167,6 +251,14 @@ var SseClient = class {
|
|
|
167
251
|
close() {
|
|
168
252
|
this.closed = true;
|
|
169
253
|
this.updateStatus("disconnected");
|
|
254
|
+
if (this.visibilityHandler && typeof document !== "undefined") {
|
|
255
|
+
document.removeEventListener("visibilitychange", this.visibilityHandler);
|
|
256
|
+
this.visibilityHandler = null;
|
|
257
|
+
}
|
|
258
|
+
if (this.abortController) {
|
|
259
|
+
this.abortController.abort();
|
|
260
|
+
this.abortController = null;
|
|
261
|
+
}
|
|
170
262
|
if (this.reconnectTimeout) {
|
|
171
263
|
clearTimeout(this.reconnectTimeout);
|
|
172
264
|
this.reconnectTimeout = null;
|
|
@@ -178,9 +270,202 @@ var SseClient = class {
|
|
|
178
270
|
}
|
|
179
271
|
};
|
|
180
272
|
|
|
273
|
+
// src/browser-cache.ts
|
|
274
|
+
var CACHE_PREFIX = "flipswitch:flags:";
|
|
275
|
+
var CACHE_META_KEY = "flipswitch:cache-meta";
|
|
276
|
+
var BrowserCache = class {
|
|
277
|
+
constructor() {
|
|
278
|
+
this.cacheVersion = 1;
|
|
279
|
+
this.storage = this.getStorage();
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Get storage instance if available.
|
|
283
|
+
*/
|
|
284
|
+
getStorage() {
|
|
285
|
+
if (typeof window === "undefined") return null;
|
|
286
|
+
try {
|
|
287
|
+
const testKey = "__flipswitch_test__";
|
|
288
|
+
window.localStorage.setItem(testKey, "test");
|
|
289
|
+
window.localStorage.removeItem(testKey);
|
|
290
|
+
return window.localStorage;
|
|
291
|
+
} catch {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Check if browser cache is available.
|
|
297
|
+
*/
|
|
298
|
+
isAvailable() {
|
|
299
|
+
return this.storage !== null;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Get a cached flag value.
|
|
303
|
+
*/
|
|
304
|
+
get(flagKey) {
|
|
305
|
+
if (!this.storage) return null;
|
|
306
|
+
try {
|
|
307
|
+
const key = CACHE_PREFIX + flagKey;
|
|
308
|
+
const item = this.storage.getItem(key);
|
|
309
|
+
if (!item) return null;
|
|
310
|
+
const cached = JSON.parse(item);
|
|
311
|
+
return cached;
|
|
312
|
+
} catch {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Set a cached flag value.
|
|
318
|
+
*/
|
|
319
|
+
set(flagKey, value, variant, reason) {
|
|
320
|
+
if (!this.storage) return;
|
|
321
|
+
try {
|
|
322
|
+
const key = CACHE_PREFIX + flagKey;
|
|
323
|
+
const cached = {
|
|
324
|
+
value,
|
|
325
|
+
timestamp: Date.now(),
|
|
326
|
+
variant,
|
|
327
|
+
reason
|
|
328
|
+
};
|
|
329
|
+
this.storage.setItem(key, JSON.stringify(cached));
|
|
330
|
+
this.updateMeta();
|
|
331
|
+
} catch {
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Set multiple cached flag values at once.
|
|
336
|
+
*/
|
|
337
|
+
setAll(flags) {
|
|
338
|
+
if (!this.storage) return;
|
|
339
|
+
try {
|
|
340
|
+
for (const flag of flags) {
|
|
341
|
+
const cacheKey = CACHE_PREFIX + flag.key;
|
|
342
|
+
const cached = {
|
|
343
|
+
value: flag.value,
|
|
344
|
+
timestamp: Date.now(),
|
|
345
|
+
variant: flag.variant,
|
|
346
|
+
reason: flag.reason
|
|
347
|
+
};
|
|
348
|
+
this.storage.setItem(cacheKey, JSON.stringify(cached));
|
|
349
|
+
}
|
|
350
|
+
this.updateMeta();
|
|
351
|
+
} catch {
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Get all cached flags.
|
|
356
|
+
*/
|
|
357
|
+
getAll() {
|
|
358
|
+
const result = /* @__PURE__ */ new Map();
|
|
359
|
+
if (!this.storage) return result;
|
|
360
|
+
try {
|
|
361
|
+
for (let i = 0; i < this.storage.length; i++) {
|
|
362
|
+
const key = this.storage.key(i);
|
|
363
|
+
if (key?.startsWith(CACHE_PREFIX)) {
|
|
364
|
+
const flagKey = key.slice(CACHE_PREFIX.length);
|
|
365
|
+
const item = this.storage.getItem(key);
|
|
366
|
+
if (item) {
|
|
367
|
+
const cached = JSON.parse(item);
|
|
368
|
+
result.set(flagKey, cached);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
} catch {
|
|
373
|
+
}
|
|
374
|
+
return result;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Invalidate a specific flag or all flags.
|
|
378
|
+
*/
|
|
379
|
+
invalidate(flagKey) {
|
|
380
|
+
if (!this.storage) return;
|
|
381
|
+
try {
|
|
382
|
+
if (flagKey) {
|
|
383
|
+
this.storage.removeItem(CACHE_PREFIX + flagKey);
|
|
384
|
+
} else {
|
|
385
|
+
const keysToRemove = [];
|
|
386
|
+
for (let i = 0; i < this.storage.length; i++) {
|
|
387
|
+
const key = this.storage.key(i);
|
|
388
|
+
if (key?.startsWith(CACHE_PREFIX)) {
|
|
389
|
+
keysToRemove.push(key);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
for (const key of keysToRemove) {
|
|
393
|
+
this.storage.removeItem(key);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
this.updateMeta();
|
|
397
|
+
} catch {
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Update cache metadata.
|
|
402
|
+
*/
|
|
403
|
+
updateMeta() {
|
|
404
|
+
if (!this.storage) return;
|
|
405
|
+
try {
|
|
406
|
+
const meta = {
|
|
407
|
+
lastUpdated: Date.now(),
|
|
408
|
+
version: this.cacheVersion
|
|
409
|
+
};
|
|
410
|
+
this.storage.setItem(CACHE_META_KEY, JSON.stringify(meta));
|
|
411
|
+
} catch {
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Get cache metadata.
|
|
416
|
+
*/
|
|
417
|
+
getMeta() {
|
|
418
|
+
if (!this.storage) return null;
|
|
419
|
+
try {
|
|
420
|
+
const item = this.storage.getItem(CACHE_META_KEY);
|
|
421
|
+
if (!item) return null;
|
|
422
|
+
return JSON.parse(item);
|
|
423
|
+
} catch {
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Get the age of a cached flag in milliseconds.
|
|
429
|
+
*/
|
|
430
|
+
getAge(flagKey) {
|
|
431
|
+
const cached = this.get(flagKey);
|
|
432
|
+
if (!cached) return null;
|
|
433
|
+
return Date.now() - cached.timestamp;
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Check if a cached flag is stale based on max age.
|
|
437
|
+
*/
|
|
438
|
+
isStale(flagKey, maxAgeMs) {
|
|
439
|
+
const age = this.getAge(flagKey);
|
|
440
|
+
if (age === null) return true;
|
|
441
|
+
return age > maxAgeMs;
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Clear all Flipswitch data from localStorage.
|
|
445
|
+
*/
|
|
446
|
+
clear() {
|
|
447
|
+
if (!this.storage) return;
|
|
448
|
+
try {
|
|
449
|
+
const keysToRemove = [];
|
|
450
|
+
for (let i = 0; i < this.storage.length; i++) {
|
|
451
|
+
const key = this.storage.key(i);
|
|
452
|
+
if (key?.startsWith("flipswitch:")) {
|
|
453
|
+
keysToRemove.push(key);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
for (const key of keysToRemove) {
|
|
457
|
+
this.storage.removeItem(key);
|
|
458
|
+
}
|
|
459
|
+
} catch {
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
|
|
181
464
|
// src/provider.ts
|
|
182
465
|
var DEFAULT_BASE_URL = "https://api.flipswitch.io";
|
|
183
|
-
var SDK_VERSION = "0.1.
|
|
466
|
+
var SDK_VERSION = "0.1.2";
|
|
467
|
+
var DEFAULT_POLLING_INTERVAL = 3e4;
|
|
468
|
+
var DEFAULT_MAX_SSE_RETRIES = 5;
|
|
184
469
|
var FlipswitchProvider = class {
|
|
185
470
|
constructor(options, eventHandlers) {
|
|
186
471
|
this.metadata = {
|
|
@@ -191,23 +476,41 @@ var FlipswitchProvider = class {
|
|
|
191
476
|
this._status = ClientProviderStatus.NOT_READY;
|
|
192
477
|
this.eventHandlers = /* @__PURE__ */ new Map();
|
|
193
478
|
this.userEventHandlers = {};
|
|
479
|
+
this.pollingTimer = null;
|
|
480
|
+
this.sseRetryCount = 0;
|
|
481
|
+
this.isPollingFallbackActive = false;
|
|
482
|
+
this.onlineHandler = null;
|
|
483
|
+
this.offlineHandler = null;
|
|
484
|
+
this._isOnline = true;
|
|
194
485
|
this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
195
486
|
this.apiKey = options.apiKey;
|
|
196
487
|
this.enableRealtime = options.enableRealtime ?? true;
|
|
197
|
-
this.enableTelemetry = options.enableTelemetry ?? true;
|
|
198
488
|
this.fetchImpl = options.fetchImplementation ?? (typeof window !== "undefined" ? fetch.bind(window) : fetch);
|
|
199
489
|
this.userEventHandlers = eventHandlers ?? {};
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
490
|
+
const isBrowser = typeof window !== "undefined";
|
|
491
|
+
this.enableVisibilityHandling = options.enableVisibilityHandling ?? isBrowser;
|
|
492
|
+
this.offlineMode = options.offlineMode ?? isBrowser;
|
|
493
|
+
this.enablePollingFallback = options.enablePollingFallback ?? true;
|
|
494
|
+
this.pollingInterval = options.pollingInterval ?? DEFAULT_POLLING_INTERVAL;
|
|
495
|
+
this.maxSseRetries = options.maxSseRetries ?? DEFAULT_MAX_SSE_RETRIES;
|
|
496
|
+
const persistCache = options.persistCache ?? isBrowser;
|
|
497
|
+
this.browserCache = persistCache ? new BrowserCache() : null;
|
|
498
|
+
if (typeof navigator !== "undefined") {
|
|
499
|
+
this._isOnline = navigator.onLine;
|
|
206
500
|
}
|
|
501
|
+
const headers = [
|
|
502
|
+
["X-API-Key", this.apiKey],
|
|
503
|
+
["X-Flipswitch-SDK", this.getTelemetrySdkHeader()],
|
|
504
|
+
["X-Flipswitch-Runtime", this.getTelemetryRuntimeHeader()],
|
|
505
|
+
["X-Flipswitch-OS", this.getTelemetryOsHeader()],
|
|
506
|
+
["X-Flipswitch-Features", this.getTelemetryFeaturesHeader()]
|
|
507
|
+
];
|
|
207
508
|
this.ofrepProvider = new OFREPWebProvider({
|
|
208
509
|
baseUrl: this.baseUrl,
|
|
209
510
|
fetchImplementation: this.fetchImpl,
|
|
210
|
-
headers
|
|
511
|
+
headers,
|
|
512
|
+
pollInterval: 0
|
|
513
|
+
// Disable polling - SSE handles real-time updates
|
|
211
514
|
});
|
|
212
515
|
}
|
|
213
516
|
getTelemetrySdkHeader() {
|
|
@@ -261,7 +564,6 @@ var FlipswitchProvider = class {
|
|
|
261
564
|
return `sse=${this.enableRealtime}`;
|
|
262
565
|
}
|
|
263
566
|
getTelemetryHeaders() {
|
|
264
|
-
if (!this.enableTelemetry) return {};
|
|
265
567
|
return {
|
|
266
568
|
"X-Flipswitch-SDK": this.getTelemetrySdkHeader(),
|
|
267
569
|
"X-Flipswitch-Runtime": this.getTelemetryRuntimeHeader(),
|
|
@@ -278,6 +580,13 @@ var FlipswitchProvider = class {
|
|
|
278
580
|
*/
|
|
279
581
|
async initialize(context) {
|
|
280
582
|
this._status = ClientProviderStatus.NOT_READY;
|
|
583
|
+
this.setupOfflineHandling();
|
|
584
|
+
if (!this._isOnline && this.offlineMode) {
|
|
585
|
+
console.warn("[Flipswitch] Starting in offline mode - using cached flag values");
|
|
586
|
+
this._status = ClientProviderStatus.STALE;
|
|
587
|
+
this.emit(ClientProviderEvents.Stale);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
281
590
|
try {
|
|
282
591
|
await this.ofrepProvider.initialize(context);
|
|
283
592
|
} catch (error) {
|
|
@@ -312,15 +621,107 @@ var FlipswitchProvider = class {
|
|
|
312
621
|
this._status = ClientProviderStatus.READY;
|
|
313
622
|
this.emit(ClientProviderEvents.Ready);
|
|
314
623
|
}
|
|
624
|
+
/**
|
|
625
|
+
* Setup online/offline event handling.
|
|
626
|
+
*/
|
|
627
|
+
setupOfflineHandling() {
|
|
628
|
+
if (typeof window === "undefined" || !this.offlineMode) return;
|
|
629
|
+
this.onlineHandler = () => {
|
|
630
|
+
this._isOnline = true;
|
|
631
|
+
console.info("[Flipswitch] Connection restored - refreshing flags");
|
|
632
|
+
if (this.enableRealtime && this.sseClient) {
|
|
633
|
+
this.sseClient.resume();
|
|
634
|
+
}
|
|
635
|
+
this.refreshFlags();
|
|
636
|
+
};
|
|
637
|
+
this.offlineHandler = () => {
|
|
638
|
+
this._isOnline = false;
|
|
639
|
+
console.warn("[Flipswitch] Connection lost - serving cached values");
|
|
640
|
+
if (this.sseClient) {
|
|
641
|
+
this.sseClient.pause();
|
|
642
|
+
}
|
|
643
|
+
this.stopPolling();
|
|
644
|
+
if (this._status !== ClientProviderStatus.STALE) {
|
|
645
|
+
this._status = ClientProviderStatus.STALE;
|
|
646
|
+
this.emit(ClientProviderEvents.Stale);
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
window.addEventListener("online", this.onlineHandler);
|
|
650
|
+
window.addEventListener("offline", this.offlineHandler);
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Refresh flags from the server.
|
|
654
|
+
*/
|
|
655
|
+
async refreshFlags() {
|
|
656
|
+
try {
|
|
657
|
+
await this.ofrepProvider.onContextChange?.({}, {});
|
|
658
|
+
if (this._status === ClientProviderStatus.STALE) {
|
|
659
|
+
this._status = ClientProviderStatus.READY;
|
|
660
|
+
this.emit(ClientProviderEvents.Ready);
|
|
661
|
+
}
|
|
662
|
+
this.emit(ClientProviderEvents.ConfigurationChanged);
|
|
663
|
+
} catch (error) {
|
|
664
|
+
console.warn("[Flipswitch] Failed to refresh flags:", error);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
315
667
|
/**
|
|
316
668
|
* Called when the provider is shut down.
|
|
317
669
|
*/
|
|
318
670
|
async onClose() {
|
|
319
671
|
this.sseClient?.close();
|
|
320
672
|
this.sseClient = null;
|
|
673
|
+
this.stopPolling();
|
|
674
|
+
if (typeof window !== "undefined") {
|
|
675
|
+
if (this.onlineHandler) {
|
|
676
|
+
window.removeEventListener("online", this.onlineHandler);
|
|
677
|
+
this.onlineHandler = null;
|
|
678
|
+
}
|
|
679
|
+
if (this.offlineHandler) {
|
|
680
|
+
window.removeEventListener("offline", this.offlineHandler);
|
|
681
|
+
this.offlineHandler = null;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
321
684
|
await this.ofrepProvider.onClose?.();
|
|
322
685
|
this._status = ClientProviderStatus.NOT_READY;
|
|
323
686
|
}
|
|
687
|
+
/**
|
|
688
|
+
* Stop the polling fallback.
|
|
689
|
+
*/
|
|
690
|
+
stopPolling() {
|
|
691
|
+
if (this.pollingTimer) {
|
|
692
|
+
clearInterval(this.pollingTimer);
|
|
693
|
+
this.pollingTimer = null;
|
|
694
|
+
this.isPollingFallbackActive = false;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Start polling fallback when SSE fails.
|
|
699
|
+
*/
|
|
700
|
+
startPollingFallback() {
|
|
701
|
+
if (this.isPollingFallbackActive || !this.enablePollingFallback) return;
|
|
702
|
+
console.info(`[Flipswitch] Starting polling fallback (interval: ${this.pollingInterval}ms)`);
|
|
703
|
+
this.isPollingFallbackActive = true;
|
|
704
|
+
this.pollingTimer = setInterval(async () => {
|
|
705
|
+
if (!this._isOnline) return;
|
|
706
|
+
try {
|
|
707
|
+
await this.refreshFlags();
|
|
708
|
+
} catch (error) {
|
|
709
|
+
console.warn("[Flipswitch] Polling refresh failed:", error);
|
|
710
|
+
}
|
|
711
|
+
}, this.pollingInterval);
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Check if the provider is currently online.
|
|
715
|
+
*/
|
|
716
|
+
isOnline() {
|
|
717
|
+
return this._isOnline;
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Check if polling fallback is active.
|
|
721
|
+
*/
|
|
722
|
+
isPollingActive() {
|
|
723
|
+
return this.isPollingFallbackActive;
|
|
724
|
+
}
|
|
324
725
|
/**
|
|
325
726
|
* Start the SSE connection for real-time updates.
|
|
326
727
|
*/
|
|
@@ -335,14 +736,27 @@ var FlipswitchProvider = class {
|
|
|
335
736
|
(status) => {
|
|
336
737
|
this.userEventHandlers.onConnectionStatusChange?.(status);
|
|
337
738
|
if (status === "error") {
|
|
739
|
+
this.sseRetryCount++;
|
|
740
|
+
if (this.sseRetryCount >= this.maxSseRetries && this.enablePollingFallback) {
|
|
741
|
+
console.warn(`[Flipswitch] SSE failed after ${this.sseRetryCount} retries - falling back to polling`);
|
|
742
|
+
this.startPollingFallback();
|
|
743
|
+
}
|
|
338
744
|
this._status = ClientProviderStatus.STALE;
|
|
339
745
|
this.emit(ClientProviderEvents.Stale);
|
|
340
|
-
} else if (status === "connected"
|
|
341
|
-
this.
|
|
342
|
-
this.
|
|
746
|
+
} else if (status === "connected") {
|
|
747
|
+
this.sseRetryCount = 0;
|
|
748
|
+
if (this.isPollingFallbackActive) {
|
|
749
|
+
console.info("[Flipswitch] SSE reconnected - stopping polling fallback");
|
|
750
|
+
this.stopPolling();
|
|
751
|
+
}
|
|
752
|
+
if (this._status === ClientProviderStatus.STALE) {
|
|
753
|
+
this._status = ClientProviderStatus.READY;
|
|
754
|
+
this.emit(ClientProviderEvents.Ready);
|
|
755
|
+
}
|
|
343
756
|
}
|
|
344
757
|
},
|
|
345
|
-
telemetryHeaders
|
|
758
|
+
telemetryHeaders,
|
|
759
|
+
this.enableVisibilityHandling
|
|
346
760
|
);
|
|
347
761
|
this.sseClient.connect();
|
|
348
762
|
}
|
|
@@ -350,9 +764,6 @@ var FlipswitchProvider = class {
|
|
|
350
764
|
* Get telemetry headers as a map.
|
|
351
765
|
*/
|
|
352
766
|
getTelemetryHeadersMap() {
|
|
353
|
-
if (!this.enableTelemetry) {
|
|
354
|
-
return void 0;
|
|
355
|
-
}
|
|
356
767
|
return {
|
|
357
768
|
"X-Flipswitch-SDK": this.getTelemetrySdkHeader(),
|
|
358
769
|
"X-Flipswitch-Runtime": this.getTelemetryRuntimeHeader(),
|
|
@@ -362,10 +773,22 @@ var FlipswitchProvider = class {
|
|
|
362
773
|
}
|
|
363
774
|
/**
|
|
364
775
|
* Handle a flag change event from SSE.
|
|
365
|
-
*
|
|
776
|
+
* Triggers OFREP cache refresh, updates browser cache, and emits PROVIDER_CONFIGURATION_CHANGED.
|
|
366
777
|
*/
|
|
367
|
-
handleFlagChange(event) {
|
|
778
|
+
async handleFlagChange(event) {
|
|
368
779
|
this.userEventHandlers.onFlagChange?.(event);
|
|
780
|
+
if (this.browserCache) {
|
|
781
|
+
if (event.flagKey) {
|
|
782
|
+
this.browserCache.invalidate(event.flagKey);
|
|
783
|
+
} else {
|
|
784
|
+
this.browserCache.invalidate();
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
try {
|
|
788
|
+
await this.ofrepProvider.onContextChange?.({}, {});
|
|
789
|
+
} catch (error) {
|
|
790
|
+
console.warn("[Flipswitch] Failed to refresh flags after SSE event:", error);
|
|
791
|
+
}
|
|
369
792
|
this.emit(ClientProviderEvents.ConfigurationChanged);
|
|
370
793
|
}
|
|
371
794
|
/**
|
|
@@ -614,6 +1037,7 @@ var FlagCache = class {
|
|
|
614
1037
|
}
|
|
615
1038
|
};
|
|
616
1039
|
export {
|
|
1040
|
+
BrowserCache,
|
|
617
1041
|
FlagCache,
|
|
618
1042
|
FlipswitchProvider,
|
|
619
1043
|
SseClient
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flipswitch-io/sdk",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "Flipswitch SDK with real-time SSE support for OpenFeature",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"module": "./dist/index.mjs",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"@openfeature/ofrep-provider": "^0.2.2",
|
|
57
57
|
"@openfeature/server-sdk": "^1.17.0",
|
|
58
58
|
"@openfeature/web-sdk": "^1.7.2",
|
|
59
|
-
"@types/node": "^
|
|
59
|
+
"@types/node": "^24.0.0",
|
|
60
60
|
"eslint": "^9.0.0",
|
|
61
61
|
"tsup": "^8.0.1",
|
|
62
62
|
"tsx": "^4.7.0",
|