@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 +2 -0
- package/dist/index.cjs +395 -28
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +27 -1
- package/dist/index.d.ts +27 -1
- package/dist/index.js +395 -28
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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) :
|
|
123
|
-
const configuredStaleTtlMs = Number.isFinite(options.staleTtlMs) && (options.staleTtlMs ?? 0) > 0 ? Number(options.staleTtlMs) :
|
|
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
|
-
|
|
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 (
|
|
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)
|
|
455
|
-
|
|
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 (
|
|
664
|
-
|
|
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
|
-
|
|
668
|
-
|
|
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
|
-
|
|
680
|
-
|
|
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
|
-
|
|
685
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|