@featureflare/sdk-js 0.0.85 → 0.0.86

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