@featureflare/sdk-js 0.0.85 → 0.0.87

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 CHANGED
@@ -62,6 +62,7 @@ const featureflare = new FeatureFlareClient({
62
62
  },
63
63
  realtime: {
64
64
  // optional: enabled defaults to true (SSE primary, polling fallback)
65
+ // optional: hidden tabs disconnect by default and reconnect on visibility restore
65
66
  pollingIntervalMs: 15_000,
66
67
  ssePath: '/api/v1/sdk/stream'
67
68
  }
@@ -72,6 +73,7 @@ const featureflare = new FeatureFlareClient({
72
73
  - `bool(...)` and `evaluate(...)` are outage-safe and return deterministic defaults for expected network failures.
73
74
  - Hard kill switches have highest precedence and return `false` even when the API is unreachable.
74
75
  - Realtime updates are enabled by default: SSE is primary transport; polling is used automatically as fallback when SSE is unavailable.
76
+ - In browser runtimes, hidden tabs disconnect the realtime stream by default and reconnect with a fresh authenticated stream request when the tab becomes visible again.
75
77
 
76
78
  To explicitly disable realtime updates:
77
79
 
package/dist/index.cjs CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  // src/index.ts
4
4
  var DEFAULT_FEATUREFLARE_API_BASE_URL = "https://api.featureflare.com";
5
+ var DEFAULT_CACHE_TTL_MS = 30 * 60 * 1e3;
6
+ var DEFAULT_STALE_TTL_MS = 10 * 60 * 1e3;
5
7
  function getApiBaseUrlFromEnv() {
6
8
  if (typeof process !== "undefined" && process.env) {
7
9
  return process.env.FEATUREFLARE_API_BASE_URL?.trim() || process.env.SHIPIT_API_BASE_URL?.trim() || null;
@@ -21,6 +23,54 @@ function getEnvKeyFromEnv() {
21
23
  }
22
24
  return null;
23
25
  }
26
+ function normalizeHashInput(value) {
27
+ if (Array.isArray(value)) {
28
+ return value.map((entry) => normalizeHashInput(entry));
29
+ }
30
+ if (value && typeof value === "object") {
31
+ const entries = Object.entries(value).filter(([, entryValue]) => entryValue !== void 0).sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey));
32
+ return Object.fromEntries(entries.map(([key, entryValue]) => [key, normalizeHashInput(entryValue)]));
33
+ }
34
+ return value;
35
+ }
36
+ function hashForStream(value) {
37
+ const input = JSON.stringify(normalizeHashInput(value));
38
+ let forward = 2166136261;
39
+ let backward = 2166136261;
40
+ for (let index = 0; index < input.length; index += 1) {
41
+ forward ^= input.charCodeAt(index);
42
+ forward = Math.imul(forward, 16777619);
43
+ const reverseIndex = input.length - 1 - index;
44
+ backward ^= input.charCodeAt(reverseIndex);
45
+ backward = Math.imul(backward, 16777619);
46
+ }
47
+ return `${(forward >>> 0).toString(16).padStart(8, "0")}${(backward >>> 0).toString(16).padStart(8, "0")}`;
48
+ }
49
+ function encodeBase64Url(value) {
50
+ if (typeof Buffer !== "undefined") {
51
+ return Buffer.from(value, "utf8").toString("base64url");
52
+ }
53
+ const bytes = typeof TextEncoder !== "undefined" ? new TextEncoder().encode(value) : [];
54
+ let binary = "";
55
+ for (const byte of bytes) {
56
+ binary += String.fromCharCode(byte);
57
+ }
58
+ const base64 = typeof btoa === "function" ? btoa(binary) : "";
59
+ return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
60
+ }
61
+ function buildHashedRealtimeUserContext(input) {
62
+ const payload = {
63
+ id: input.id ?? input.key
64
+ };
65
+ return {
66
+ payloadHash: hashForStream(payload),
67
+ fields: {
68
+ ...input.id ?? input.key ? { id: hashForStream(input.id ?? input.key) } : {}
69
+ },
70
+ metaEntries: [],
71
+ customEntries: []
72
+ };
73
+ }
24
74
  function getSdkKeyFromEnv() {
25
75
  if (typeof process !== "undefined" && process.env) {
26
76
  return process.env.FEATUREFLARE_CLIENT_KEY?.trim() || process.env.FEATUREFLARE_SERVER_KEY?.trim() || process.env.SHIPIT_CLIENT_KEY?.trim() || process.env.SHIPIT_SERVER_KEY?.trim() || null;
@@ -33,6 +83,31 @@ function monotonicNow() {
33
83
  }
34
84
  return Date.now();
35
85
  }
86
+ function isFeatureFlareDebugEnabled() {
87
+ const envDebug = typeof process !== "undefined" && process.env ? process.env.NEXT_PUBLIC_FEATUREFLARE_DEBUG?.trim() || process.env.FEATUREFLARE_DEBUG?.trim() : "";
88
+ if (envDebug === "1" || envDebug?.toLowerCase() === "true") {
89
+ return true;
90
+ }
91
+ if (typeof window !== "undefined") {
92
+ try {
93
+ const storageDebug = window.localStorage.getItem("featureflare:debug");
94
+ return storageDebug === "1" || storageDebug?.toLowerCase() === "true";
95
+ } catch {
96
+ return false;
97
+ }
98
+ }
99
+ return false;
100
+ }
101
+ function debugLog(message, details) {
102
+ if (!isFeatureFlareDebugEnabled()) {
103
+ return;
104
+ }
105
+ if (details) {
106
+ console.debug(`[FeatureFlare SDK] ${message}`, details);
107
+ return;
108
+ }
109
+ console.debug(`[FeatureFlare SDK] ${message}`);
110
+ }
36
111
  function sleep(ms) {
37
112
  return new Promise((resolve) => {
38
113
  setTimeout(resolve, ms);
@@ -76,6 +151,7 @@ var FeatureFlareClient = class {
76
151
  projectKey;
77
152
  envKey;
78
153
  expectedEnvKey;
154
+ getRealtimeUser;
79
155
  timeoutMs;
80
156
  maxRetries;
81
157
  backoffMs;
@@ -100,11 +176,16 @@ var FeatureFlareClient = class {
100
176
  lastUser = null;
101
177
  lastDefaultValue = false;
102
178
  realtimeEnabled = true;
179
+ realtimePollingEnabled = true;
103
180
  realtimePollingMs = 15e3;
104
181
  realtimeSsePath = "/api/v1/sdk/stream";
182
+ realtimeDisconnectWhenHidden = true;
183
+ visibilityListenerAttached = false;
184
+ connectionState = { state: "offline", transport: "none" };
105
185
  pollTimer = null;
106
186
  reconnectTimer = null;
107
187
  eventSource = null;
188
+ streamConnectVersion = 0;
108
189
  constructor(options = {}) {
109
190
  this.apiBaseUrl = getApiBaseUrl(options.apiBaseUrl).replace(/\/$/, "");
110
191
  this.sdkKey = options.sdkKey?.trim() || getSdkKeyFromEnv();
@@ -112,6 +193,7 @@ var FeatureFlareClient = class {
112
193
  const explicitOrEnvKey = options.envKey?.trim() || getEnvKeyFromEnv() || null;
113
194
  this.envKey = explicitOrEnvKey ?? "production";
114
195
  this.expectedEnvKey = explicitOrEnvKey;
196
+ this.getRealtimeUser = options.getRealtimeUser;
115
197
  if (!this.sdkKey && !this.projectKey) {
116
198
  throw new Error(
117
199
  "FeatureFlareClient requires either sdkKey (recommended) or projectKey (legacy). Set FEATUREFLARE_CLIENT_KEY or FEATUREFLARE_SERVER_KEY env var, or pass sdkKey in options."
@@ -121,20 +203,57 @@ var FeatureFlareClient = class {
121
203
  this.maxRetries = Number.isFinite(options.maxRetries) && (options.maxRetries ?? 0) >= 0 ? Number(options.maxRetries) : 2;
122
204
  this.backoffMs = Number.isFinite(options.backoffMs) && (options.backoffMs ?? 0) > 0 ? Number(options.backoffMs) : 200;
123
205
  this.jitter = Number.isFinite(options.jitter) && (options.jitter ?? 0) >= 0 ? Number(options.jitter) : 0.25;
124
- this.cacheTtlMs = Number.isFinite(options.cacheTtlMs) && (options.cacheTtlMs ?? 0) > 0 ? Number(options.cacheTtlMs) : 6e4;
125
- const configuredStaleTtlMs = Number.isFinite(options.staleTtlMs) && (options.staleTtlMs ?? 0) > 0 ? Number(options.staleTtlMs) : 1e4;
206
+ this.cacheTtlMs = Number.isFinite(options.cacheTtlMs) && (options.cacheTtlMs ?? 0) > 0 ? Number(options.cacheTtlMs) : DEFAULT_CACHE_TTL_MS;
207
+ const configuredStaleTtlMs = Number.isFinite(options.staleTtlMs) && (options.staleTtlMs ?? 0) > 0 ? Number(options.staleTtlMs) : DEFAULT_STALE_TTL_MS;
126
208
  this.staleTtlMs = Math.min(configuredStaleTtlMs, this.cacheTtlMs);
127
209
  this.persistentCache = options.persistentCache;
128
210
  this.onMetric = options.onMetric;
129
211
  this.realtimeEnabled = options.realtime?.enabled ?? true;
130
- this.realtimePollingMs = Number.isFinite(options.realtime?.pollingIntervalMs) && (options.realtime?.pollingIntervalMs ?? 0) > 0 ? Number(options.realtime?.pollingIntervalMs) : 15e3;
212
+ const configuredPollingIntervalMs = Number(options.realtime?.pollingIntervalMs ?? NaN);
213
+ if (Number.isFinite(configuredPollingIntervalMs) && configuredPollingIntervalMs === 0) {
214
+ this.realtimePollingEnabled = false;
215
+ this.realtimePollingMs = 0;
216
+ } else {
217
+ this.realtimePollingEnabled = true;
218
+ this.realtimePollingMs = Number.isFinite(configuredPollingIntervalMs) && configuredPollingIntervalMs > 0 ? configuredPollingIntervalMs : 15e3;
219
+ }
131
220
  this.realtimeSsePath = options.realtime?.ssePath ?? "/api/v1/sdk/stream";
221
+ this.realtimeDisconnectWhenHidden = options.realtime?.disconnectWhenHidden ?? true;
132
222
  this.applyBootstrap(options.bootstrap);
133
223
  this.persistentLoadPromise = this.loadPersistentCache();
134
224
  if (this.realtimeEnabled && this.sdkKey) {
225
+ this.attachVisibilityHandler();
226
+ if (this.realtimeDisconnectWhenHidden && typeof document !== "undefined" && document.visibilityState === "hidden") {
227
+ return;
228
+ }
135
229
  this.startRealtime();
136
230
  }
137
231
  }
232
+ handleVisibilityChange = () => {
233
+ if (!this.realtimeDisconnectWhenHidden || typeof document === "undefined") return;
234
+ debugLog("Visibility changed", {
235
+ visibilityState: document.visibilityState,
236
+ realtimeEnabled: this.realtimeEnabled,
237
+ hasEventSource: Boolean(this.eventSource)
238
+ });
239
+ if (document.visibilityState === "hidden") {
240
+ this.stopRealtime({ preserveEnabled: true });
241
+ return;
242
+ }
243
+ if (this.realtimeEnabled && this.sdkKey && !this.eventSource) {
244
+ this.connectSse(0);
245
+ }
246
+ };
247
+ attachVisibilityHandler() {
248
+ if (!this.realtimeDisconnectWhenHidden || this.visibilityListenerAttached || typeof document === "undefined") return;
249
+ document.addEventListener("visibilitychange", this.handleVisibilityChange);
250
+ this.visibilityListenerAttached = true;
251
+ }
252
+ detachVisibilityHandler() {
253
+ if (!this.visibilityListenerAttached || typeof document === "undefined") return;
254
+ document.removeEventListener("visibilitychange", this.handleVisibilityChange);
255
+ this.visibilityListenerAttached = false;
256
+ }
138
257
  getCacheKey(flagKey) {
139
258
  return `${this.envKey}:${flagKey}`;
140
259
  }
@@ -146,20 +265,105 @@ var FeatureFlareClient = class {
146
265
  email: input.email,
147
266
  name: input.name,
148
267
  country: input.country,
149
- custom: input.meta ?? input.custom
268
+ custom: input.meta ?? input.custom,
269
+ session: input.session,
270
+ metrics: input.metrics
150
271
  };
151
272
  }
273
+ resolveRealtimeUserPayload() {
274
+ const candidate = this.getRealtimeUser?.();
275
+ if (candidate) {
276
+ try {
277
+ const normalizedUser = this.normalizeUser(candidate);
278
+ this.lastUser = normalizedUser;
279
+ return candidate;
280
+ } catch {
281
+ }
282
+ }
283
+ if (!this.lastUser) {
284
+ return null;
285
+ }
286
+ return {
287
+ id: this.lastUser.key,
288
+ email: this.lastUser.email,
289
+ name: this.lastUser.name,
290
+ country: this.lastUser.country,
291
+ meta: this.lastUser.custom,
292
+ session: this.lastUser.session,
293
+ metrics: this.lastUser.metrics
294
+ };
295
+ }
296
+ resolveRealtimeUser() {
297
+ const candidate = this.resolveRealtimeUserPayload();
298
+ if (candidate) {
299
+ try {
300
+ const normalizedUser = this.normalizeUser(candidate);
301
+ this.lastUser = normalizedUser;
302
+ return normalizedUser;
303
+ } catch {
304
+ }
305
+ }
306
+ return this.lastUser;
307
+ }
308
+ getRealtimeStreamHashes() {
309
+ const realtimeUserPayload = this.resolveRealtimeUserPayload();
310
+ if (!realtimeUserPayload) {
311
+ return {};
312
+ }
313
+ const hashedContext = buildHashedRealtimeUserContext(realtimeUserPayload);
314
+ return {
315
+ userContextHash: encodeBase64Url(JSON.stringify(hashedContext)),
316
+ userPayloadHash: hashedContext.payloadHash
317
+ };
318
+ }
319
+ async fetchStreamToken() {
320
+ if (!this.sdkKey || typeof fetch !== "function") {
321
+ return null;
322
+ }
323
+ const { userContextHash, userPayloadHash } = this.getRealtimeStreamHashes();
324
+ try {
325
+ const response = await fetch(`${this.apiBaseUrl}/api/v1/sdk/stream-token`, {
326
+ method: "POST",
327
+ headers: {
328
+ "content-type": "application/json",
329
+ "x-featureflare-sdk-key": this.sdkKey
330
+ },
331
+ body: JSON.stringify({
332
+ expectedEnvKey: this.expectedEnvKey ?? void 0,
333
+ userContextHash,
334
+ userPayloadHash
335
+ })
336
+ });
337
+ if (!response.ok) {
338
+ return null;
339
+ }
340
+ const payload = await response.json();
341
+ return typeof payload.key === "string" && payload.key.trim() ? payload.key : null;
342
+ } catch {
343
+ return null;
344
+ }
345
+ }
152
346
  emit(event, payload) {
153
347
  for (const listener of this.listeners[event]) {
154
348
  listener(payload);
155
349
  }
156
350
  }
351
+ emitConnectionState(state) {
352
+ this.connectionState = state;
353
+ this.emit("connectionState", state);
354
+ }
157
355
  on(event, listener) {
158
356
  this.listeners[event].add(listener);
357
+ if (event === "connectionState") {
358
+ listener(this.connectionState);
359
+ }
159
360
  return () => {
160
361
  this.listeners[event].delete(listener);
161
362
  };
162
363
  }
364
+ isRealtimeEnabled() {
365
+ return this.realtimeEnabled && Boolean(this.sdkKey);
366
+ }
163
367
  emitMetric(metricName, value, tags) {
164
368
  this.onMetric?.(metricName, value, tags);
165
369
  }
@@ -392,6 +596,12 @@ var FeatureFlareClient = class {
392
596
  return null;
393
597
  }
394
598
  const started = monotonicNow();
599
+ debugLog("Fetching flags", {
600
+ transport,
601
+ fromSnapshotInvalidate: Boolean(opts?.fromSnapshotInvalidate),
602
+ userKey: normalizedUser.key,
603
+ defaultValue
604
+ });
395
605
  try {
396
606
  const response = await this.fetchWithRetry(
397
607
  `${this.apiBaseUrl}/api/v1/sdk/flags`,
@@ -447,6 +657,12 @@ var FeatureFlareClient = class {
447
657
  env: this.envKey,
448
658
  transport
449
659
  });
660
+ debugLog("Fetched flags", {
661
+ transport,
662
+ fromSnapshotInvalidate: Boolean(opts?.fromSnapshotInvalidate),
663
+ flagsCount: list.length,
664
+ revision
665
+ });
450
666
  return list;
451
667
  } catch {
452
668
  this.emitMetric("ff_revalidate_latency_ms", monotonicNow() - started, {
@@ -454,6 +670,10 @@ var FeatureFlareClient = class {
454
670
  transport,
455
671
  result: "error"
456
672
  });
673
+ debugLog("Fetch flags failed", {
674
+ transport,
675
+ fromSnapshotInvalidate: Boolean(opts?.fromSnapshotInvalidate)
676
+ });
457
677
  return null;
458
678
  }
459
679
  }
@@ -609,6 +829,11 @@ var FeatureFlareClient = class {
609
829
  const normalizedUser = this.normalizeUser(user);
610
830
  this.lastUser = normalizedUser;
611
831
  this.lastDefaultValue = defaultValue;
832
+ const cached = this.collectCachedFlags();
833
+ if (cached.length > 0) {
834
+ void this.revalidate(normalizedUser, defaultValue);
835
+ return cached;
836
+ }
612
837
  const list = await this.revalidate(normalizedUser, defaultValue);
613
838
  if (list && list.length > 0) {
614
839
  return list.map((entry) => ({
@@ -616,8 +841,6 @@ var FeatureFlareClient = class {
616
841
  value: this.killSwitches.has(entry.key) ? false : entry.value
617
842
  }));
618
843
  }
619
- const cached = this.collectCachedFlags();
620
- if (cached.length > 0) return cached;
621
844
  return [];
622
845
  }
623
846
  applyRealtimeMessage(message) {
@@ -662,9 +885,6 @@ var FeatureFlareClient = class {
662
885
  });
663
886
  }
664
887
  }
665
- if (message.type === "snapshot.invalidate" && this.lastUser) {
666
- void this.revalidate(this.lastUser, this.lastDefaultValue, { fromSnapshotInvalidate: true });
667
- }
668
888
  const eventLagMs = Math.max(0, Date.now() - (message.timestamp ?? Date.now()));
669
889
  this.emitMetric("ff_realtime_lag_ms", eventLagMs, {
670
890
  env: this.envKey,
@@ -676,54 +896,162 @@ var FeatureFlareClient = class {
676
896
  }
677
897
  }
678
898
  startPolling(immediate = false) {
899
+ if (!this.realtimePollingEnabled) {
900
+ debugLog("Polling disabled", {
901
+ immediate,
902
+ reason: "realtimePollingEnabled=false"
903
+ });
904
+ return;
905
+ }
679
906
  if (this.pollTimer) {
680
907
  clearTimeout(this.pollTimer);
681
908
  this.pollTimer = null;
682
909
  }
683
910
  const tick = async () => {
684
- if (!this.lastUser) {
685
- this.pollTimer = setTimeout(tick, this.realtimePollingMs);
911
+ if (this.eventSource) {
912
+ debugLog("Polling tick skipped", {
913
+ reason: "eventSource-active"
914
+ });
915
+ this.pollTimer = null;
686
916
  return;
687
917
  }
688
- await this.fetchFlagsFromNetwork(this.lastUser, this.lastDefaultValue, "polling");
689
- this.pollTimer = setTimeout(tick, this.realtimePollingMs);
918
+ const realtimeUser = this.resolveRealtimeUser();
919
+ if (!realtimeUser) {
920
+ debugLog("Polling tick deferred", {
921
+ reason: "missing-realtime-user",
922
+ nextInMs: this.realtimePollingMs
923
+ });
924
+ if (!this.eventSource) {
925
+ this.pollTimer = setTimeout(tick, this.realtimePollingMs);
926
+ }
927
+ return;
928
+ }
929
+ debugLog("Polling tick started", {
930
+ intervalMs: this.realtimePollingMs,
931
+ userKey: realtimeUser.key
932
+ });
933
+ await this.fetchFlagsFromNetwork(realtimeUser, this.lastDefaultValue, "polling");
934
+ if (!this.eventSource) {
935
+ this.pollTimer = setTimeout(tick, this.realtimePollingMs);
936
+ } else {
937
+ this.pollTimer = null;
938
+ }
690
939
  };
691
940
  if (immediate) {
941
+ debugLog("Polling started immediately", {
942
+ intervalMs: this.realtimePollingMs
943
+ });
692
944
  void tick();
693
945
  return;
694
946
  }
947
+ debugLog("Polling scheduled", {
948
+ intervalMs: this.realtimePollingMs
949
+ });
695
950
  this.pollTimer = setTimeout(tick, this.realtimePollingMs);
696
951
  }
697
952
  connectSse(attempt = 0) {
953
+ void this.connectSseInternal(attempt);
954
+ }
955
+ async connectSseInternal(attempt = 0) {
698
956
  const EventSourceImpl = typeof EventSource !== "undefined" ? EventSource : null;
699
957
  if (!EventSourceImpl || !this.sdkKey) {
700
- this.emit("connectionState", { state: "degraded", transport: "polling" });
701
- this.startPolling(true);
958
+ debugLog("SSE unavailable, falling back", {
959
+ hasEventSourceImpl: Boolean(EventSourceImpl),
960
+ hasSdkKey: Boolean(this.sdkKey),
961
+ pollingEnabled: this.realtimePollingEnabled
962
+ });
963
+ this.emitConnectionState({ state: this.realtimePollingEnabled ? "degraded" : "offline", transport: this.realtimePollingEnabled ? "polling" : "none" });
964
+ if (this.realtimePollingEnabled) {
965
+ this.startPolling(true);
966
+ }
967
+ return;
968
+ }
969
+ const connectionVersion = ++this.streamConnectVersion;
970
+ const streamToken = await this.fetchStreamToken();
971
+ if (connectionVersion !== this.streamConnectVersion) {
702
972
  return;
703
973
  }
704
974
  const url = new URL(this.realtimeSsePath, this.apiBaseUrl);
705
- url.searchParams.set("sdkKey", this.sdkKey);
706
- url.searchParams.set("envKey", this.envKey);
975
+ if (streamToken) {
976
+ url.searchParams.set("key", streamToken);
977
+ } else {
978
+ url.searchParams.set("sdkKey", this.sdkKey);
979
+ if (this.expectedEnvKey) {
980
+ url.searchParams.set("envKey", this.expectedEnvKey);
981
+ }
982
+ const { userContextHash, userPayloadHash } = this.getRealtimeStreamHashes();
983
+ if (userContextHash) {
984
+ url.searchParams.set("userContextHash", userContextHash);
985
+ }
986
+ if (userPayloadHash) {
987
+ url.searchParams.set("userPayloadHash", userPayloadHash);
988
+ }
989
+ }
990
+ debugLog("Connecting SSE", {
991
+ attempt,
992
+ url: url.toString(),
993
+ pollingEnabled: this.realtimePollingEnabled,
994
+ authMode: streamToken ? "stream-token" : "sdk-key"
995
+ });
707
996
  this.eventSource = new EventSourceImpl(url.toString());
708
997
  this.eventSource.onopen = () => {
709
- this.emit("connectionState", { state: "connected", transport: "sse" });
998
+ debugLog("SSE connected", {
999
+ attempt,
1000
+ url: url.toString()
1001
+ });
1002
+ this.emitConnectionState({ state: "connected", transport: "sse" });
710
1003
  if (this.pollTimer) {
711
1004
  clearTimeout(this.pollTimer);
712
1005
  this.pollTimer = null;
713
1006
  }
714
1007
  };
715
1008
  this.eventSource.onmessage = (event) => {
1009
+ if (this.connectionState.state !== "connected" || this.connectionState.transport !== "sse") {
1010
+ debugLog("Promoting connection state to connected from SSE message", {
1011
+ previousState: this.connectionState.state,
1012
+ previousTransport: this.connectionState.transport
1013
+ });
1014
+ this.emitConnectionState({ state: "connected", transport: "sse" });
1015
+ }
1016
+ console.log("SSE message received", {
1017
+ raw: String(event.data)
1018
+ });
716
1019
  try {
717
1020
  const payload = JSON.parse(String(event.data));
1021
+ if (payload.type === "snapshot.invalidate") {
1022
+ const realtimeUser = this.resolveRealtimeUser();
1023
+ console.log("Snapshot invalidate message received", {
1024
+ payload
1025
+ }, realtimeUser);
1026
+ if (realtimeUser) {
1027
+ console.log("Refetching flags from snapshot invalidate", {
1028
+ userKey: realtimeUser.key,
1029
+ defaultValue: this.lastDefaultValue
1030
+ });
1031
+ void this.revalidate(realtimeUser, this.lastDefaultValue, { fromSnapshotInvalidate: true });
1032
+ } else {
1033
+ debugLog("Snapshot invalidate ignored because no user has been evaluated yet");
1034
+ }
1035
+ }
718
1036
  this.applyRealtimeMessage(payload);
719
1037
  } catch {
720
1038
  }
721
1039
  };
722
1040
  this.eventSource.onerror = () => {
723
- this.emit("connectionState", { state: "degraded", transport: "polling" });
1041
+ debugLog("SSE errored", {
1042
+ attempt,
1043
+ pollingEnabled: this.realtimePollingEnabled,
1044
+ reconnectInMs: Math.min(this.backoffMs * (attempt + 1), 3e4)
1045
+ });
1046
+ this.emitConnectionState({
1047
+ state: this.realtimePollingEnabled ? "degraded" : "offline",
1048
+ transport: this.realtimePollingEnabled ? "polling" : "none"
1049
+ });
724
1050
  this.eventSource?.close();
725
1051
  this.eventSource = null;
726
- this.startPolling(true);
1052
+ if (this.realtimePollingEnabled) {
1053
+ this.startPolling(true);
1054
+ }
727
1055
  if (this.reconnectTimer) {
728
1056
  clearTimeout(this.reconnectTimer);
729
1057
  }
@@ -735,10 +1063,30 @@ var FeatureFlareClient = class {
735
1063
  }
736
1064
  startRealtime() {
737
1065
  this.realtimeEnabled = true;
1066
+ debugLog("Realtime start requested", {
1067
+ disconnectWhenHidden: this.realtimeDisconnectWhenHidden,
1068
+ visibilityState: typeof document !== "undefined" ? document.visibilityState : "unknown",
1069
+ pollingEnabled: this.realtimePollingEnabled
1070
+ });
1071
+ this.attachVisibilityHandler();
1072
+ if (this.realtimeDisconnectWhenHidden && typeof document !== "undefined" && document.visibilityState === "hidden") {
1073
+ debugLog("Realtime start skipped because document is hidden");
1074
+ return;
1075
+ }
738
1076
  this.connectSse(0);
739
1077
  }
740
- stopRealtime() {
741
- this.realtimeEnabled = false;
1078
+ stopRealtime(opts) {
1079
+ this.streamConnectVersion += 1;
1080
+ debugLog("Realtime stop requested", {
1081
+ preserveEnabled: Boolean(opts?.preserveEnabled),
1082
+ hadEventSource: Boolean(this.eventSource),
1083
+ hadPollTimer: Boolean(this.pollTimer),
1084
+ hadReconnectTimer: Boolean(this.reconnectTimer)
1085
+ });
1086
+ if (!opts?.preserveEnabled) {
1087
+ this.realtimeEnabled = false;
1088
+ this.detachVisibilityHandler();
1089
+ }
742
1090
  if (this.eventSource) {
743
1091
  this.eventSource.close();
744
1092
  this.eventSource = null;
@@ -751,7 +1099,7 @@ var FeatureFlareClient = class {
751
1099
  clearTimeout(this.reconnectTimer);
752
1100
  this.reconnectTimer = null;
753
1101
  }
754
- this.emit("connectionState", { state: "offline", transport: "none" });
1102
+ this.emitConnectionState({ state: "offline", transport: "none" });
755
1103
  }
756
1104
  /**
757
1105
  * Report observed error metrics (error rate, latency) for a flag back to the FeatureFlare API.