@featureflare/sdk-js 0.0.85 → 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 +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/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
|
}
|
|
@@ -390,6 +594,12 @@ var FeatureFlareClient = class {
|
|
|
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`,
|
|
@@ -445,6 +655,12 @@ var FeatureFlareClient = class {
|
|
|
445
655
|
env: this.envKey,
|
|
446
656
|
transport
|
|
447
657
|
});
|
|
658
|
+
debugLog("Fetched flags", {
|
|
659
|
+
transport,
|
|
660
|
+
fromSnapshotInvalidate: Boolean(opts?.fromSnapshotInvalidate),
|
|
661
|
+
flagsCount: list.length,
|
|
662
|
+
revision
|
|
663
|
+
});
|
|
448
664
|
return list;
|
|
449
665
|
} catch {
|
|
450
666
|
this.emitMetric("ff_revalidate_latency_ms", monotonicNow() - started, {
|
|
@@ -452,6 +668,10 @@ var FeatureFlareClient = class {
|
|
|
452
668
|
transport,
|
|
453
669
|
result: "error"
|
|
454
670
|
});
|
|
671
|
+
debugLog("Fetch flags failed", {
|
|
672
|
+
transport,
|
|
673
|
+
fromSnapshotInvalidate: Boolean(opts?.fromSnapshotInvalidate)
|
|
674
|
+
});
|
|
455
675
|
return null;
|
|
456
676
|
}
|
|
457
677
|
}
|
|
@@ -607,6 +827,11 @@ var FeatureFlareClient = class {
|
|
|
607
827
|
const normalizedUser = this.normalizeUser(user);
|
|
608
828
|
this.lastUser = normalizedUser;
|
|
609
829
|
this.lastDefaultValue = defaultValue;
|
|
830
|
+
const cached = this.collectCachedFlags();
|
|
831
|
+
if (cached.length > 0) {
|
|
832
|
+
void this.revalidate(normalizedUser, defaultValue);
|
|
833
|
+
return cached;
|
|
834
|
+
}
|
|
610
835
|
const list = await this.revalidate(normalizedUser, defaultValue);
|
|
611
836
|
if (list && list.length > 0) {
|
|
612
837
|
return list.map((entry) => ({
|
|
@@ -614,8 +839,6 @@ var FeatureFlareClient = class {
|
|
|
614
839
|
value: this.killSwitches.has(entry.key) ? false : entry.value
|
|
615
840
|
}));
|
|
616
841
|
}
|
|
617
|
-
const cached = this.collectCachedFlags();
|
|
618
|
-
if (cached.length > 0) return cached;
|
|
619
842
|
return [];
|
|
620
843
|
}
|
|
621
844
|
applyRealtimeMessage(message) {
|
|
@@ -660,9 +883,6 @@ var FeatureFlareClient = class {
|
|
|
660
883
|
});
|
|
661
884
|
}
|
|
662
885
|
}
|
|
663
|
-
if (message.type === "snapshot.invalidate" && this.lastUser) {
|
|
664
|
-
void this.revalidate(this.lastUser, this.lastDefaultValue, { fromSnapshotInvalidate: true });
|
|
665
|
-
}
|
|
666
886
|
const eventLagMs = Math.max(0, Date.now() - (message.timestamp ?? Date.now()));
|
|
667
887
|
this.emitMetric("ff_realtime_lag_ms", eventLagMs, {
|
|
668
888
|
env: this.envKey,
|
|
@@ -674,54 +894,162 @@ var FeatureFlareClient = class {
|
|
|
674
894
|
}
|
|
675
895
|
}
|
|
676
896
|
startPolling(immediate = false) {
|
|
897
|
+
if (!this.realtimePollingEnabled) {
|
|
898
|
+
debugLog("Polling disabled", {
|
|
899
|
+
immediate,
|
|
900
|
+
reason: "realtimePollingEnabled=false"
|
|
901
|
+
});
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
677
904
|
if (this.pollTimer) {
|
|
678
905
|
clearTimeout(this.pollTimer);
|
|
679
906
|
this.pollTimer = null;
|
|
680
907
|
}
|
|
681
908
|
const tick = async () => {
|
|
682
|
-
if (
|
|
683
|
-
|
|
909
|
+
if (this.eventSource) {
|
|
910
|
+
debugLog("Polling tick skipped", {
|
|
911
|
+
reason: "eventSource-active"
|
|
912
|
+
});
|
|
913
|
+
this.pollTimer = null;
|
|
684
914
|
return;
|
|
685
915
|
}
|
|
686
|
-
|
|
687
|
-
|
|
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
|
+
}
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
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
|
+
}
|
|
688
937
|
};
|
|
689
938
|
if (immediate) {
|
|
939
|
+
debugLog("Polling started immediately", {
|
|
940
|
+
intervalMs: this.realtimePollingMs
|
|
941
|
+
});
|
|
690
942
|
void tick();
|
|
691
943
|
return;
|
|
692
944
|
}
|
|
945
|
+
debugLog("Polling scheduled", {
|
|
946
|
+
intervalMs: this.realtimePollingMs
|
|
947
|
+
});
|
|
693
948
|
this.pollTimer = setTimeout(tick, this.realtimePollingMs);
|
|
694
949
|
}
|
|
695
950
|
connectSse(attempt = 0) {
|
|
951
|
+
void this.connectSseInternal(attempt);
|
|
952
|
+
}
|
|
953
|
+
async connectSseInternal(attempt = 0) {
|
|
696
954
|
const EventSourceImpl = typeof EventSource !== "undefined" ? EventSource : null;
|
|
697
955
|
if (!EventSourceImpl || !this.sdkKey) {
|
|
698
|
-
|
|
699
|
-
|
|
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) {
|
|
700
970
|
return;
|
|
701
971
|
}
|
|
702
972
|
const url = new URL(this.realtimeSsePath, this.apiBaseUrl);
|
|
703
|
-
|
|
704
|
-
|
|
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
|
+
});
|
|
705
994
|
this.eventSource = new EventSourceImpl(url.toString());
|
|
706
995
|
this.eventSource.onopen = () => {
|
|
707
|
-
|
|
996
|
+
debugLog("SSE connected", {
|
|
997
|
+
attempt,
|
|
998
|
+
url: url.toString()
|
|
999
|
+
});
|
|
1000
|
+
this.emitConnectionState({ state: "connected", transport: "sse" });
|
|
708
1001
|
if (this.pollTimer) {
|
|
709
1002
|
clearTimeout(this.pollTimer);
|
|
710
1003
|
this.pollTimer = null;
|
|
711
1004
|
}
|
|
712
1005
|
};
|
|
713
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
|
+
});
|
|
714
1017
|
try {
|
|
715
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
|
+
}
|
|
716
1034
|
this.applyRealtimeMessage(payload);
|
|
717
1035
|
} catch {
|
|
718
1036
|
}
|
|
719
1037
|
};
|
|
720
1038
|
this.eventSource.onerror = () => {
|
|
721
|
-
|
|
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
|
+
});
|
|
722
1048
|
this.eventSource?.close();
|
|
723
1049
|
this.eventSource = null;
|
|
724
|
-
this.
|
|
1050
|
+
if (this.realtimePollingEnabled) {
|
|
1051
|
+
this.startPolling(true);
|
|
1052
|
+
}
|
|
725
1053
|
if (this.reconnectTimer) {
|
|
726
1054
|
clearTimeout(this.reconnectTimer);
|
|
727
1055
|
}
|
|
@@ -733,10 +1061,30 @@ var FeatureFlareClient = class {
|
|
|
733
1061
|
}
|
|
734
1062
|
startRealtime() {
|
|
735
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
|
+
}
|
|
736
1074
|
this.connectSse(0);
|
|
737
1075
|
}
|
|
738
|
-
stopRealtime() {
|
|
739
|
-
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
|
+
}
|
|
740
1088
|
if (this.eventSource) {
|
|
741
1089
|
this.eventSource.close();
|
|
742
1090
|
this.eventSource = null;
|
|
@@ -749,7 +1097,7 @@ var FeatureFlareClient = class {
|
|
|
749
1097
|
clearTimeout(this.reconnectTimer);
|
|
750
1098
|
this.reconnectTimer = null;
|
|
751
1099
|
}
|
|
752
|
-
this.
|
|
1100
|
+
this.emitConnectionState({ state: "offline", transport: "none" });
|
|
753
1101
|
}
|
|
754
1102
|
/**
|
|
755
1103
|
* Report observed error metrics (error rate, latency) for a flag back to the FeatureFlare API.
|