@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/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
  }
@@ -385,11 +589,17 @@ var FeatureFlareClient = class {
385
589
  this.emit("update", { changedKeys: [flagKey], source: "network" });
386
590
  return value;
387
591
  }
388
- async fetchFlagsFromNetwork(normalizedUser, defaultValue, transport) {
592
+ async fetchFlagsFromNetwork(normalizedUser, defaultValue, transport, opts) {
389
593
  if (!this.sdkKey) {
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`,
@@ -430,7 +640,13 @@ var FeatureFlareClient = class {
430
640
  }
431
641
  this.setCacheItem(entry.key, entry.value, "network", revision);
432
642
  }
433
- if (changed.size > 0) {
643
+ if (opts?.fromSnapshotInvalidate && list !== null) {
644
+ this.emit("update", {
645
+ changedKeys: list.map((e) => e.key),
646
+ source: "realtime",
647
+ snapshotInvalidate: true
648
+ });
649
+ } else if (changed.size > 0) {
434
650
  this.emit("update", { changedKeys: [...changed], source: "network" });
435
651
  }
436
652
  this.setCircuitState(false);
@@ -439,6 +655,12 @@ var FeatureFlareClient = class {
439
655
  env: this.envKey,
440
656
  transport
441
657
  });
658
+ debugLog("Fetched flags", {
659
+ transport,
660
+ fromSnapshotInvalidate: Boolean(opts?.fromSnapshotInvalidate),
661
+ flagsCount: list.length,
662
+ revision
663
+ });
442
664
  return list;
443
665
  } catch {
444
666
  this.emitMetric("ff_revalidate_latency_ms", monotonicNow() - started, {
@@ -446,13 +668,30 @@ var FeatureFlareClient = class {
446
668
  transport,
447
669
  result: "error"
448
670
  });
671
+ debugLog("Fetch flags failed", {
672
+ transport,
673
+ fromSnapshotInvalidate: Boolean(opts?.fromSnapshotInvalidate)
674
+ });
449
675
  return null;
450
676
  }
451
677
  }
452
- revalidate(normalizedUser, defaultValue) {
678
+ revalidate(normalizedUser, defaultValue, opts) {
453
679
  const existing = this.inFlightRevalidate.get(this.envKey);
454
- if (existing) return existing;
455
- const promise = this.fetchFlagsFromNetwork(normalizedUser, defaultValue, "network").finally(() => {
680
+ if (existing) {
681
+ if (opts?.fromSnapshotInvalidate) {
682
+ void existing.then((list) => {
683
+ if (list !== null) {
684
+ this.emit("update", {
685
+ changedKeys: list.map((e) => e.key),
686
+ source: "realtime",
687
+ snapshotInvalidate: true
688
+ });
689
+ }
690
+ });
691
+ }
692
+ return existing;
693
+ }
694
+ const promise = this.fetchFlagsFromNetwork(normalizedUser, defaultValue, "network", opts).finally(() => {
456
695
  this.inFlightRevalidate.delete(this.envKey);
457
696
  });
458
697
  this.inFlightRevalidate.set(this.envKey, promise);
@@ -588,6 +827,11 @@ var FeatureFlareClient = class {
588
827
  const normalizedUser = this.normalizeUser(user);
589
828
  this.lastUser = normalizedUser;
590
829
  this.lastDefaultValue = defaultValue;
830
+ const cached = this.collectCachedFlags();
831
+ if (cached.length > 0) {
832
+ void this.revalidate(normalizedUser, defaultValue);
833
+ return cached;
834
+ }
591
835
  const list = await this.revalidate(normalizedUser, defaultValue);
592
836
  if (list && list.length > 0) {
593
837
  return list.map((entry) => ({
@@ -595,8 +839,6 @@ var FeatureFlareClient = class {
595
839
  value: this.killSwitches.has(entry.key) ? false : entry.value
596
840
  }));
597
841
  }
598
- const cached = this.collectCachedFlags();
599
- if (cached.length > 0) return cached;
600
842
  return [];
601
843
  }
602
844
  applyRealtimeMessage(message) {
@@ -641,9 +883,6 @@ var FeatureFlareClient = class {
641
883
  });
642
884
  }
643
885
  }
644
- if (message.type === "snapshot.invalidate" && this.lastUser) {
645
- void this.revalidate(this.lastUser, this.lastDefaultValue);
646
- }
647
886
  const eventLagMs = Math.max(0, Date.now() - (message.timestamp ?? Date.now()));
648
887
  this.emitMetric("ff_realtime_lag_ms", eventLagMs, {
649
888
  env: this.envKey,
@@ -655,54 +894,162 @@ var FeatureFlareClient = class {
655
894
  }
656
895
  }
657
896
  startPolling(immediate = false) {
897
+ if (!this.realtimePollingEnabled) {
898
+ debugLog("Polling disabled", {
899
+ immediate,
900
+ reason: "realtimePollingEnabled=false"
901
+ });
902
+ return;
903
+ }
658
904
  if (this.pollTimer) {
659
905
  clearTimeout(this.pollTimer);
660
906
  this.pollTimer = null;
661
907
  }
662
908
  const tick = async () => {
663
- if (!this.lastUser) {
664
- this.pollTimer = setTimeout(tick, this.realtimePollingMs);
909
+ if (this.eventSource) {
910
+ debugLog("Polling tick skipped", {
911
+ reason: "eventSource-active"
912
+ });
913
+ this.pollTimer = null;
914
+ return;
915
+ }
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
+ }
665
925
  return;
666
926
  }
667
- await this.fetchFlagsFromNetwork(this.lastUser, this.lastDefaultValue, "polling");
668
- this.pollTimer = setTimeout(tick, this.realtimePollingMs);
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
+ }
669
937
  };
670
938
  if (immediate) {
939
+ debugLog("Polling started immediately", {
940
+ intervalMs: this.realtimePollingMs
941
+ });
671
942
  void tick();
672
943
  return;
673
944
  }
945
+ debugLog("Polling scheduled", {
946
+ intervalMs: this.realtimePollingMs
947
+ });
674
948
  this.pollTimer = setTimeout(tick, this.realtimePollingMs);
675
949
  }
676
950
  connectSse(attempt = 0) {
951
+ void this.connectSseInternal(attempt);
952
+ }
953
+ async connectSseInternal(attempt = 0) {
677
954
  const EventSourceImpl = typeof EventSource !== "undefined" ? EventSource : null;
678
955
  if (!EventSourceImpl || !this.sdkKey) {
679
- this.emit("connectionState", { state: "degraded", transport: "polling" });
680
- 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) {
681
970
  return;
682
971
  }
683
972
  const url = new URL(this.realtimeSsePath, this.apiBaseUrl);
684
- url.searchParams.set("sdkKey", this.sdkKey);
685
- 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
+ });
686
994
  this.eventSource = new EventSourceImpl(url.toString());
687
995
  this.eventSource.onopen = () => {
688
- 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" });
689
1001
  if (this.pollTimer) {
690
1002
  clearTimeout(this.pollTimer);
691
1003
  this.pollTimer = null;
692
1004
  }
693
1005
  };
694
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
+ });
695
1017
  try {
696
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
+ }
697
1034
  this.applyRealtimeMessage(payload);
698
1035
  } catch {
699
1036
  }
700
1037
  };
701
1038
  this.eventSource.onerror = () => {
702
- 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
+ });
703
1048
  this.eventSource?.close();
704
1049
  this.eventSource = null;
705
- this.startPolling(true);
1050
+ if (this.realtimePollingEnabled) {
1051
+ this.startPolling(true);
1052
+ }
706
1053
  if (this.reconnectTimer) {
707
1054
  clearTimeout(this.reconnectTimer);
708
1055
  }
@@ -714,10 +1061,30 @@ var FeatureFlareClient = class {
714
1061
  }
715
1062
  startRealtime() {
716
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
+ }
717
1074
  this.connectSse(0);
718
1075
  }
719
- stopRealtime() {
720
- 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
+ }
721
1088
  if (this.eventSource) {
722
1089
  this.eventSource.close();
723
1090
  this.eventSource = null;
@@ -730,7 +1097,7 @@ var FeatureFlareClient = class {
730
1097
  clearTimeout(this.reconnectTimer);
731
1098
  this.reconnectTimer = null;
732
1099
  }
733
- this.emit("connectionState", { state: "offline", transport: "none" });
1100
+ this.emitConnectionState({ state: "offline", transport: "none" });
734
1101
  }
735
1102
  /**
736
1103
  * Report observed error metrics (error rate, latency) for a flag back to the FeatureFlare API.