@featureflare/sdk-js 0.0.84 → 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/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
  }
@@ -387,11 +591,17 @@ var FeatureFlareClient = class {
387
591
  this.emit("update", { changedKeys: [flagKey], source: "network" });
388
592
  return value;
389
593
  }
390
- async fetchFlagsFromNetwork(normalizedUser, defaultValue, transport) {
594
+ async fetchFlagsFromNetwork(normalizedUser, defaultValue, transport, opts) {
391
595
  if (!this.sdkKey) {
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`,
@@ -432,7 +642,13 @@ var FeatureFlareClient = class {
432
642
  }
433
643
  this.setCacheItem(entry.key, entry.value, "network", revision);
434
644
  }
435
- if (changed.size > 0) {
645
+ if (opts?.fromSnapshotInvalidate && list !== null) {
646
+ this.emit("update", {
647
+ changedKeys: list.map((e) => e.key),
648
+ source: "realtime",
649
+ snapshotInvalidate: true
650
+ });
651
+ } else if (changed.size > 0) {
436
652
  this.emit("update", { changedKeys: [...changed], source: "network" });
437
653
  }
438
654
  this.setCircuitState(false);
@@ -441,6 +657,12 @@ var FeatureFlareClient = class {
441
657
  env: this.envKey,
442
658
  transport
443
659
  });
660
+ debugLog("Fetched flags", {
661
+ transport,
662
+ fromSnapshotInvalidate: Boolean(opts?.fromSnapshotInvalidate),
663
+ flagsCount: list.length,
664
+ revision
665
+ });
444
666
  return list;
445
667
  } catch {
446
668
  this.emitMetric("ff_revalidate_latency_ms", monotonicNow() - started, {
@@ -448,13 +670,30 @@ var FeatureFlareClient = class {
448
670
  transport,
449
671
  result: "error"
450
672
  });
673
+ debugLog("Fetch flags failed", {
674
+ transport,
675
+ fromSnapshotInvalidate: Boolean(opts?.fromSnapshotInvalidate)
676
+ });
451
677
  return null;
452
678
  }
453
679
  }
454
- revalidate(normalizedUser, defaultValue) {
680
+ revalidate(normalizedUser, defaultValue, opts) {
455
681
  const existing = this.inFlightRevalidate.get(this.envKey);
456
- if (existing) return existing;
457
- const promise = this.fetchFlagsFromNetwork(normalizedUser, defaultValue, "network").finally(() => {
682
+ if (existing) {
683
+ if (opts?.fromSnapshotInvalidate) {
684
+ void existing.then((list) => {
685
+ if (list !== null) {
686
+ this.emit("update", {
687
+ changedKeys: list.map((e) => e.key),
688
+ source: "realtime",
689
+ snapshotInvalidate: true
690
+ });
691
+ }
692
+ });
693
+ }
694
+ return existing;
695
+ }
696
+ const promise = this.fetchFlagsFromNetwork(normalizedUser, defaultValue, "network", opts).finally(() => {
458
697
  this.inFlightRevalidate.delete(this.envKey);
459
698
  });
460
699
  this.inFlightRevalidate.set(this.envKey, promise);
@@ -590,6 +829,11 @@ var FeatureFlareClient = class {
590
829
  const normalizedUser = this.normalizeUser(user);
591
830
  this.lastUser = normalizedUser;
592
831
  this.lastDefaultValue = defaultValue;
832
+ const cached = this.collectCachedFlags();
833
+ if (cached.length > 0) {
834
+ void this.revalidate(normalizedUser, defaultValue);
835
+ return cached;
836
+ }
593
837
  const list = await this.revalidate(normalizedUser, defaultValue);
594
838
  if (list && list.length > 0) {
595
839
  return list.map((entry) => ({
@@ -597,8 +841,6 @@ var FeatureFlareClient = class {
597
841
  value: this.killSwitches.has(entry.key) ? false : entry.value
598
842
  }));
599
843
  }
600
- const cached = this.collectCachedFlags();
601
- if (cached.length > 0) return cached;
602
844
  return [];
603
845
  }
604
846
  applyRealtimeMessage(message) {
@@ -643,9 +885,6 @@ var FeatureFlareClient = class {
643
885
  });
644
886
  }
645
887
  }
646
- if (message.type === "snapshot.invalidate" && this.lastUser) {
647
- void this.revalidate(this.lastUser, this.lastDefaultValue);
648
- }
649
888
  const eventLagMs = Math.max(0, Date.now() - (message.timestamp ?? Date.now()));
650
889
  this.emitMetric("ff_realtime_lag_ms", eventLagMs, {
651
890
  env: this.envKey,
@@ -657,54 +896,162 @@ var FeatureFlareClient = class {
657
896
  }
658
897
  }
659
898
  startPolling(immediate = false) {
899
+ if (!this.realtimePollingEnabled) {
900
+ debugLog("Polling disabled", {
901
+ immediate,
902
+ reason: "realtimePollingEnabled=false"
903
+ });
904
+ return;
905
+ }
660
906
  if (this.pollTimer) {
661
907
  clearTimeout(this.pollTimer);
662
908
  this.pollTimer = null;
663
909
  }
664
910
  const tick = async () => {
665
- if (!this.lastUser) {
666
- this.pollTimer = setTimeout(tick, this.realtimePollingMs);
911
+ if (this.eventSource) {
912
+ debugLog("Polling tick skipped", {
913
+ reason: "eventSource-active"
914
+ });
915
+ this.pollTimer = null;
916
+ return;
917
+ }
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
+ }
667
927
  return;
668
928
  }
669
- await this.fetchFlagsFromNetwork(this.lastUser, this.lastDefaultValue, "polling");
670
- this.pollTimer = setTimeout(tick, this.realtimePollingMs);
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
+ }
671
939
  };
672
940
  if (immediate) {
941
+ debugLog("Polling started immediately", {
942
+ intervalMs: this.realtimePollingMs
943
+ });
673
944
  void tick();
674
945
  return;
675
946
  }
947
+ debugLog("Polling scheduled", {
948
+ intervalMs: this.realtimePollingMs
949
+ });
676
950
  this.pollTimer = setTimeout(tick, this.realtimePollingMs);
677
951
  }
678
952
  connectSse(attempt = 0) {
953
+ void this.connectSseInternal(attempt);
954
+ }
955
+ async connectSseInternal(attempt = 0) {
679
956
  const EventSourceImpl = typeof EventSource !== "undefined" ? EventSource : null;
680
957
  if (!EventSourceImpl || !this.sdkKey) {
681
- this.emit("connectionState", { state: "degraded", transport: "polling" });
682
- 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) {
683
972
  return;
684
973
  }
685
974
  const url = new URL(this.realtimeSsePath, this.apiBaseUrl);
686
- url.searchParams.set("sdkKey", this.sdkKey);
687
- 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
+ });
688
996
  this.eventSource = new EventSourceImpl(url.toString());
689
997
  this.eventSource.onopen = () => {
690
- 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" });
691
1003
  if (this.pollTimer) {
692
1004
  clearTimeout(this.pollTimer);
693
1005
  this.pollTimer = null;
694
1006
  }
695
1007
  };
696
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
+ });
697
1019
  try {
698
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
+ }
699
1036
  this.applyRealtimeMessage(payload);
700
1037
  } catch {
701
1038
  }
702
1039
  };
703
1040
  this.eventSource.onerror = () => {
704
- 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
+ });
705
1050
  this.eventSource?.close();
706
1051
  this.eventSource = null;
707
- this.startPolling(true);
1052
+ if (this.realtimePollingEnabled) {
1053
+ this.startPolling(true);
1054
+ }
708
1055
  if (this.reconnectTimer) {
709
1056
  clearTimeout(this.reconnectTimer);
710
1057
  }
@@ -716,10 +1063,30 @@ var FeatureFlareClient = class {
716
1063
  }
717
1064
  startRealtime() {
718
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
+ }
719
1076
  this.connectSse(0);
720
1077
  }
721
- stopRealtime() {
722
- 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
+ }
723
1090
  if (this.eventSource) {
724
1091
  this.eventSource.close();
725
1092
  this.eventSource = null;
@@ -732,7 +1099,7 @@ var FeatureFlareClient = class {
732
1099
  clearTimeout(this.reconnectTimer);
733
1100
  this.reconnectTimer = null;
734
1101
  }
735
- this.emit("connectionState", { state: "offline", transport: "none" });
1102
+ this.emitConnectionState({ state: "offline", transport: "none" });
736
1103
  }
737
1104
  /**
738
1105
  * Report observed error metrics (error rate, latency) for a flag back to the FeatureFlare API.