@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 +2 -0
- package/dist/index.cjs +371 -23
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +25 -1
- package/dist/index.d.ts +25 -1
- package/dist/index.js +371 -23
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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) :
|
|
125
|
-
const configuredStaleTtlMs = Number.isFinite(options.staleTtlMs) && (options.staleTtlMs ?? 0) > 0 ? Number(options.staleTtlMs) :
|
|
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
|
-
|
|
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 (
|
|
685
|
-
|
|
911
|
+
if (this.eventSource) {
|
|
912
|
+
debugLog("Polling tick skipped", {
|
|
913
|
+
reason: "eventSource-active"
|
|
914
|
+
});
|
|
915
|
+
this.pollTimer = null;
|
|
686
916
|
return;
|
|
687
917
|
}
|
|
688
|
-
|
|
689
|
-
|
|
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
|
-
|
|
701
|
-
|
|
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
|
-
|
|
706
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|