@flipswitch-io/sdk 0.1.5 → 0.1.7

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/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.error("[Flipswitch] Failed to establish SSE connection:", error);
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.error("[Flipswitch] SSE stream error:", error);
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.error("[Flipswitch] SSE connection error:", error);
170
+ console.warn("[Flipswitch] SSE connection error:", error);
108
171
  this.updateStatus("error");
109
172
  this.scheduleReconnect();
110
173
  }
@@ -134,20 +197,21 @@ var SseClient = class {
134
197
  this.onFlagChange(event);
135
198
  } else if (eventType === "config-updated") {
136
199
  const parsed = JSON.parse(data);
137
- if (parsed.reason === "api-key-rotated") {
138
- console.warn(
139
- "[Flipswitch] API key has been rotated. You may need to update your API key configuration."
140
- );
141
- }
142
200
  const event = {
143
201
  flagKey: null,
144
202
  // null indicates all flags should be refreshed
145
203
  timestamp: parsed.timestamp
146
204
  };
147
205
  this.onFlagChange(event);
148
- } else if (eventType === "flag-change") {
149
- const event = JSON.parse(data);
150
- 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
+ }
151
215
  }
152
216
  } catch (error) {
153
217
  console.error(`[Flipswitch] Failed to parse ${eventType} event:`, error);
@@ -187,6 +251,14 @@ var SseClient = class {
187
251
  close() {
188
252
  this.closed = true;
189
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
+ }
190
262
  if (this.reconnectTimeout) {
191
263
  clearTimeout(this.reconnectTimeout);
192
264
  this.reconnectTimeout = null;
@@ -198,9 +270,204 @@ var SseClient = class {
198
270
  }
199
271
  };
200
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
+
464
+ // package.json
465
+ var version = "0.1.7";
466
+
201
467
  // src/provider.ts
202
468
  var DEFAULT_BASE_URL = "https://api.flipswitch.io";
203
- var SDK_VERSION = "0.1.1";
469
+ var DEFAULT_POLLING_INTERVAL = 3e4;
470
+ var DEFAULT_MAX_SSE_RETRIES = 5;
204
471
  var FlipswitchProvider = class {
205
472
  constructor(options, eventHandlers) {
206
473
  this.metadata = {
@@ -211,11 +478,28 @@ var FlipswitchProvider = class {
211
478
  this._status = ClientProviderStatus.NOT_READY;
212
479
  this.eventHandlers = /* @__PURE__ */ new Map();
213
480
  this.userEventHandlers = {};
481
+ this.pollingTimer = null;
482
+ this.sseRetryCount = 0;
483
+ this.isPollingFallbackActive = false;
484
+ this.onlineHandler = null;
485
+ this.offlineHandler = null;
486
+ this._isOnline = true;
214
487
  this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
215
488
  this.apiKey = options.apiKey;
216
489
  this.enableRealtime = options.enableRealtime ?? true;
217
490
  this.fetchImpl = options.fetchImplementation ?? (typeof window !== "undefined" ? fetch.bind(window) : fetch);
218
491
  this.userEventHandlers = eventHandlers ?? {};
492
+ const isBrowser = typeof window !== "undefined";
493
+ this.enableVisibilityHandling = options.enableVisibilityHandling ?? isBrowser;
494
+ this.offlineMode = options.offlineMode ?? isBrowser;
495
+ this.enablePollingFallback = options.enablePollingFallback ?? true;
496
+ this.pollingInterval = options.pollingInterval ?? DEFAULT_POLLING_INTERVAL;
497
+ this.maxSseRetries = options.maxSseRetries ?? DEFAULT_MAX_SSE_RETRIES;
498
+ const persistCache = options.persistCache ?? isBrowser;
499
+ this.browserCache = persistCache ? new BrowserCache() : null;
500
+ if (typeof navigator !== "undefined") {
501
+ this._isOnline = navigator.onLine;
502
+ }
219
503
  const headers = [
220
504
  ["X-API-Key", this.apiKey],
221
505
  ["X-Flipswitch-SDK", this.getTelemetrySdkHeader()],
@@ -226,11 +510,13 @@ var FlipswitchProvider = class {
226
510
  this.ofrepProvider = new OFREPWebProvider({
227
511
  baseUrl: this.baseUrl,
228
512
  fetchImplementation: this.fetchImpl,
229
- headers
513
+ headers,
514
+ pollInterval: 0
515
+ // Disable polling - SSE handles real-time updates
230
516
  });
231
517
  }
232
518
  getTelemetrySdkHeader() {
233
- return `javascript/${SDK_VERSION}`;
519
+ return `javascript/${version}`;
234
520
  }
235
521
  getTelemetryRuntimeHeader() {
236
522
  if (typeof process !== "undefined" && process.versions?.node) {
@@ -296,6 +582,13 @@ var FlipswitchProvider = class {
296
582
  */
297
583
  async initialize(context) {
298
584
  this._status = ClientProviderStatus.NOT_READY;
585
+ this.setupOfflineHandling();
586
+ if (!this._isOnline && this.offlineMode) {
587
+ console.warn("[Flipswitch] Starting in offline mode - using cached flag values");
588
+ this._status = ClientProviderStatus.STALE;
589
+ this.emit(ClientProviderEvents.Stale);
590
+ return;
591
+ }
299
592
  try {
300
593
  await this.ofrepProvider.initialize(context);
301
594
  } catch (error) {
@@ -330,15 +623,107 @@ var FlipswitchProvider = class {
330
623
  this._status = ClientProviderStatus.READY;
331
624
  this.emit(ClientProviderEvents.Ready);
332
625
  }
626
+ /**
627
+ * Setup online/offline event handling.
628
+ */
629
+ setupOfflineHandling() {
630
+ if (typeof window === "undefined" || !this.offlineMode) return;
631
+ this.onlineHandler = () => {
632
+ this._isOnline = true;
633
+ console.info("[Flipswitch] Connection restored - refreshing flags");
634
+ if (this.enableRealtime && this.sseClient) {
635
+ this.sseClient.resume();
636
+ }
637
+ this.refreshFlags();
638
+ };
639
+ this.offlineHandler = () => {
640
+ this._isOnline = false;
641
+ console.warn("[Flipswitch] Connection lost - serving cached values");
642
+ if (this.sseClient) {
643
+ this.sseClient.pause();
644
+ }
645
+ this.stopPolling();
646
+ if (this._status !== ClientProviderStatus.STALE) {
647
+ this._status = ClientProviderStatus.STALE;
648
+ this.emit(ClientProviderEvents.Stale);
649
+ }
650
+ };
651
+ window.addEventListener("online", this.onlineHandler);
652
+ window.addEventListener("offline", this.offlineHandler);
653
+ }
654
+ /**
655
+ * Refresh flags from the server.
656
+ */
657
+ async refreshFlags() {
658
+ try {
659
+ await this.ofrepProvider.onContextChange?.({}, {});
660
+ if (this._status === ClientProviderStatus.STALE) {
661
+ this._status = ClientProviderStatus.READY;
662
+ this.emit(ClientProviderEvents.Ready);
663
+ }
664
+ this.emit(ClientProviderEvents.ConfigurationChanged);
665
+ } catch (error) {
666
+ console.warn("[Flipswitch] Failed to refresh flags:", error);
667
+ }
668
+ }
333
669
  /**
334
670
  * Called when the provider is shut down.
335
671
  */
336
672
  async onClose() {
337
673
  this.sseClient?.close();
338
674
  this.sseClient = null;
675
+ this.stopPolling();
676
+ if (typeof window !== "undefined") {
677
+ if (this.onlineHandler) {
678
+ window.removeEventListener("online", this.onlineHandler);
679
+ this.onlineHandler = null;
680
+ }
681
+ if (this.offlineHandler) {
682
+ window.removeEventListener("offline", this.offlineHandler);
683
+ this.offlineHandler = null;
684
+ }
685
+ }
339
686
  await this.ofrepProvider.onClose?.();
340
687
  this._status = ClientProviderStatus.NOT_READY;
341
688
  }
689
+ /**
690
+ * Stop the polling fallback.
691
+ */
692
+ stopPolling() {
693
+ if (this.pollingTimer) {
694
+ clearInterval(this.pollingTimer);
695
+ this.pollingTimer = null;
696
+ this.isPollingFallbackActive = false;
697
+ }
698
+ }
699
+ /**
700
+ * Start polling fallback when SSE fails.
701
+ */
702
+ startPollingFallback() {
703
+ if (this.isPollingFallbackActive || !this.enablePollingFallback) return;
704
+ console.info(`[Flipswitch] Starting polling fallback (interval: ${this.pollingInterval}ms)`);
705
+ this.isPollingFallbackActive = true;
706
+ this.pollingTimer = setInterval(async () => {
707
+ if (!this._isOnline) return;
708
+ try {
709
+ await this.refreshFlags();
710
+ } catch (error) {
711
+ console.warn("[Flipswitch] Polling refresh failed:", error);
712
+ }
713
+ }, this.pollingInterval);
714
+ }
715
+ /**
716
+ * Check if the provider is currently online.
717
+ */
718
+ isOnline() {
719
+ return this._isOnline;
720
+ }
721
+ /**
722
+ * Check if polling fallback is active.
723
+ */
724
+ isPollingActive() {
725
+ return this.isPollingFallbackActive;
726
+ }
342
727
  /**
343
728
  * Start the SSE connection for real-time updates.
344
729
  */
@@ -353,14 +738,27 @@ var FlipswitchProvider = class {
353
738
  (status) => {
354
739
  this.userEventHandlers.onConnectionStatusChange?.(status);
355
740
  if (status === "error") {
741
+ this.sseRetryCount++;
742
+ if (this.sseRetryCount >= this.maxSseRetries && this.enablePollingFallback) {
743
+ console.warn(`[Flipswitch] SSE failed after ${this.sseRetryCount} retries - falling back to polling`);
744
+ this.startPollingFallback();
745
+ }
356
746
  this._status = ClientProviderStatus.STALE;
357
747
  this.emit(ClientProviderEvents.Stale);
358
- } else if (status === "connected" && this._status === ClientProviderStatus.STALE) {
359
- this._status = ClientProviderStatus.READY;
360
- this.emit(ClientProviderEvents.Ready);
748
+ } else if (status === "connected") {
749
+ this.sseRetryCount = 0;
750
+ if (this.isPollingFallbackActive) {
751
+ console.info("[Flipswitch] SSE reconnected - stopping polling fallback");
752
+ this.stopPolling();
753
+ }
754
+ if (this._status === ClientProviderStatus.STALE) {
755
+ this._status = ClientProviderStatus.READY;
756
+ this.emit(ClientProviderEvents.Ready);
757
+ }
361
758
  }
362
759
  },
363
- telemetryHeaders
760
+ telemetryHeaders,
761
+ this.enableVisibilityHandling
364
762
  );
365
763
  this.sseClient.connect();
366
764
  }
@@ -377,10 +775,22 @@ var FlipswitchProvider = class {
377
775
  }
378
776
  /**
379
777
  * Handle a flag change event from SSE.
380
- * Emits PROVIDER_CONFIGURATION_CHANGED to trigger re-evaluation.
778
+ * Triggers OFREP cache refresh, updates browser cache, and emits PROVIDER_CONFIGURATION_CHANGED.
381
779
  */
382
- handleFlagChange(event) {
780
+ async handleFlagChange(event) {
383
781
  this.userEventHandlers.onFlagChange?.(event);
782
+ if (this.browserCache) {
783
+ if (event.flagKey) {
784
+ this.browserCache.invalidate(event.flagKey);
785
+ } else {
786
+ this.browserCache.invalidate();
787
+ }
788
+ }
789
+ try {
790
+ await this.ofrepProvider.onContextChange?.({}, {});
791
+ } catch (error) {
792
+ console.warn("[Flipswitch] Failed to refresh flags after SSE event:", error);
793
+ }
384
794
  this.emit(ClientProviderEvents.ConfigurationChanged);
385
795
  }
386
796
  /**
@@ -629,6 +1039,7 @@ var FlagCache = class {
629
1039
  }
630
1040
  };
631
1041
  export {
1042
+ BrowserCache,
632
1043
  FlagCache,
633
1044
  FlipswitchProvider,
634
1045
  SseClient
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flipswitch-io/sdk",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Flipswitch SDK with real-time SSE support for OpenFeature",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",