@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/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
|
}
|
|
@@ -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 (
|
|
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)
|
|
457
|
-
|
|
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 (
|
|
666
|
-
|
|
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
|
-
|
|
670
|
-
|
|
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
|
-
|
|
682
|
-
|
|
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
|
-
|
|
687
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|