@featureflare/sdk-js 0.0.26 → 0.0.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -25,12 +25,84 @@ function getSdkKeyFromEnv() {
25
25
  }
26
26
  return null;
27
27
  }
28
+ function monotonicNow() {
29
+ if (typeof performance !== "undefined" && typeof performance.now === "function") {
30
+ return performance.now();
31
+ }
32
+ return Date.now();
33
+ }
34
+ function sleep(ms) {
35
+ return new Promise((resolve) => {
36
+ setTimeout(resolve, ms);
37
+ });
38
+ }
39
+ function isRetriableStatus(status) {
40
+ return status === 408 || status === 425 || status === 429 || status >= 500;
41
+ }
42
+ function normalizeBoolMap(input) {
43
+ if (Array.isArray(input.flags)) {
44
+ return input.flags.filter(
45
+ (entry) => Boolean(entry) && typeof entry.key === "string" && typeof entry.value === "boolean"
46
+ ).map((entry) => ({ key: entry.key, value: entry.value }));
47
+ }
48
+ const values = input.values ?? {};
49
+ return Object.entries(values).map(([key, value]) => ({ key, value: Boolean(value) }));
50
+ }
51
+ var InMemoryMetricsCollector = class {
52
+ buckets = /* @__PURE__ */ new Map();
53
+ record = (metricName, value, tags) => {
54
+ const key = `${metricName}:${JSON.stringify(tags ?? {})}`;
55
+ const list = this.buckets.get(key) ?? [];
56
+ list.push(value);
57
+ this.buckets.set(key, list);
58
+ };
59
+ get(metricName) {
60
+ const rows = [];
61
+ for (const [key, values] of this.buckets.entries()) {
62
+ if (!key.startsWith(`${metricName}:`)) continue;
63
+ rows.push({
64
+ tags: JSON.parse(key.slice(metricName.length + 1)),
65
+ values: [...values]
66
+ });
67
+ }
68
+ return rows;
69
+ }
70
+ };
28
71
  var FeatureFlareClient = class {
29
72
  apiBaseUrl;
30
73
  sdkKey;
31
74
  projectKey;
32
75
  envKey;
33
76
  expectedEnvKey;
77
+ timeoutMs;
78
+ maxRetries;
79
+ backoffMs;
80
+ jitter;
81
+ cacheTtlMs;
82
+ staleTtlMs;
83
+ persistentCache;
84
+ onMetric;
85
+ listeners = {
86
+ update: /* @__PURE__ */ new Set(),
87
+ circuitOpen: /* @__PURE__ */ new Set(),
88
+ circuitClose: /* @__PURE__ */ new Set(),
89
+ connectionState: /* @__PURE__ */ new Set()
90
+ };
91
+ cache = /* @__PURE__ */ new Map();
92
+ diagnostics = /* @__PURE__ */ new Map();
93
+ killSwitches = /* @__PURE__ */ new Set();
94
+ revision = 0;
95
+ circuitOpen = false;
96
+ persistentLoadPromise = null;
97
+ inFlightRevalidate = /* @__PURE__ */ new Map();
98
+ lastUser = null;
99
+ lastDefaultValue = false;
100
+ realtimeEnabled = true;
101
+ realtimePollingMs = 15e3;
102
+ realtimeSsePath = "/api/v1/sdk/stream";
103
+ pollTimer = null;
104
+ reconnectTimer = null;
105
+ eventSource = null;
34
106
  constructor(options = {}) {
35
107
  this.apiBaseUrl = getApiBaseUrl(options.apiBaseUrl).replace(/\/$/, "");
36
108
  this.sdkKey = options.sdkKey?.trim() || getSdkKeyFromEnv();
@@ -43,6 +115,26 @@ var FeatureFlareClient = class {
43
115
  "FeatureFlareClient requires either sdkKey (recommended) or projectKey (legacy). Set FEATUREFLARE_CLIENT_KEY or FEATUREFLARE_SERVER_KEY env var, or pass sdkKey in options."
44
116
  );
45
117
  }
118
+ this.timeoutMs = Number.isFinite(options.timeoutMs) && (options.timeoutMs ?? 0) > 0 ? Number(options.timeoutMs) : 3e3;
119
+ this.maxRetries = Number.isFinite(options.maxRetries) && (options.maxRetries ?? 0) >= 0 ? Number(options.maxRetries) : 2;
120
+ this.backoffMs = Number.isFinite(options.backoffMs) && (options.backoffMs ?? 0) > 0 ? Number(options.backoffMs) : 200;
121
+ this.jitter = Number.isFinite(options.jitter) && (options.jitter ?? 0) >= 0 ? Number(options.jitter) : 0.25;
122
+ this.cacheTtlMs = Number.isFinite(options.cacheTtlMs) && (options.cacheTtlMs ?? 0) > 0 ? Number(options.cacheTtlMs) : 6e4;
123
+ const configuredStaleTtlMs = Number.isFinite(options.staleTtlMs) && (options.staleTtlMs ?? 0) > 0 ? Number(options.staleTtlMs) : 1e4;
124
+ this.staleTtlMs = Math.min(configuredStaleTtlMs, this.cacheTtlMs);
125
+ this.persistentCache = options.persistentCache;
126
+ this.onMetric = options.onMetric;
127
+ this.realtimeEnabled = options.realtime?.enabled ?? true;
128
+ this.realtimePollingMs = Number.isFinite(options.realtime?.pollingIntervalMs) && (options.realtime?.pollingIntervalMs ?? 0) > 0 ? Number(options.realtime?.pollingIntervalMs) : 15e3;
129
+ this.realtimeSsePath = options.realtime?.ssePath ?? "/api/v1/sdk/stream";
130
+ this.applyBootstrap(options.bootstrap);
131
+ this.persistentLoadPromise = this.loadPersistentCache();
132
+ if (this.realtimeEnabled && this.sdkKey) {
133
+ this.startRealtime();
134
+ }
135
+ }
136
+ getCacheKey(flagKey) {
137
+ return `${this.envKey}:${flagKey}`;
46
138
  }
47
139
  normalizeUser(input) {
48
140
  const key = (input.id ?? input.key ?? "").trim();
@@ -55,64 +147,596 @@ var FeatureFlareClient = class {
55
147
  custom: input.meta ?? input.custom
56
148
  };
57
149
  }
58
- async bool(flagKey, user, defaultValue = false) {
59
- const normalizedUser = this.normalizeUser(user);
60
- const res = this.sdkKey ? await fetch(`${this.apiBaseUrl}/api/v1/sdk/eval`, {
61
- method: "POST",
62
- headers: {
63
- "content-type": "application/json",
64
- "x-featureflare-sdk-key": this.sdkKey
65
- },
66
- body: JSON.stringify({
67
- flagKey,
68
- user: normalizedUser,
69
- defaultValue,
70
- expectedEnvKey: this.expectedEnvKey ?? void 0
71
- })
72
- }) : await fetch(`${this.apiBaseUrl}/api/v1/eval`, {
73
- method: "POST",
74
- headers: { "content-type": "application/json" },
75
- body: JSON.stringify({
76
- projectKey: this.projectKey,
77
- envKey: this.envKey,
78
- flagKey,
79
- user: normalizedUser,
80
- defaultValue
81
- })
150
+ emit(event, payload) {
151
+ for (const listener of this.listeners[event]) {
152
+ listener(payload);
153
+ }
154
+ }
155
+ on(event, listener) {
156
+ this.listeners[event].add(listener);
157
+ return () => {
158
+ this.listeners[event].delete(listener);
159
+ };
160
+ }
161
+ emitMetric(metricName, value, tags) {
162
+ this.onMetric?.(metricName, value, tags);
163
+ }
164
+ async ensurePersistentLoaded() {
165
+ if (!this.persistentLoadPromise) return;
166
+ await this.persistentLoadPromise;
167
+ }
168
+ async loadPersistentCache() {
169
+ if (!this.persistentCache) return;
170
+ try {
171
+ const snapshot = await this.persistentCache.load(this.envKey);
172
+ if (!snapshot) return;
173
+ this.revision = Math.max(this.revision, snapshot.revision ?? 0);
174
+ for (const killSwitchKey of snapshot.killSwitches ?? []) {
175
+ this.killSwitches.add(killSwitchKey);
176
+ }
177
+ for (const [flagKey, item] of Object.entries(snapshot.flags ?? {})) {
178
+ const existing = this.cache.get(this.getCacheKey(flagKey));
179
+ if (existing && existing.source === "bootstrap") continue;
180
+ this.cache.set(this.getCacheKey(flagKey), {
181
+ envKey: this.envKey,
182
+ flagKey,
183
+ value: Boolean(item.value),
184
+ updatedAt: Number(item.updatedAt) || Date.now(),
185
+ staleAt: Number(item.staleAt) || Date.now(),
186
+ expiresAt: Number(item.expiresAt) || Date.now(),
187
+ source: item.source ?? "persistent",
188
+ revision: item.revision ?? 0
189
+ });
190
+ }
191
+ } catch {
192
+ }
193
+ }
194
+ async persistCache() {
195
+ if (!this.persistentCache) return;
196
+ const snapshot = {
197
+ revision: this.revision,
198
+ killSwitches: [...this.killSwitches],
199
+ flags: {}
200
+ };
201
+ for (const [key, item] of this.cache.entries()) {
202
+ if (!key.startsWith(`${this.envKey}:`)) continue;
203
+ snapshot.flags[item.flagKey] = {
204
+ value: item.value,
205
+ updatedAt: item.updatedAt,
206
+ staleAt: item.staleAt,
207
+ expiresAt: item.expiresAt,
208
+ source: item.source,
209
+ revision: item.revision
210
+ };
211
+ }
212
+ try {
213
+ await this.persistentCache.save(this.envKey, snapshot);
214
+ } catch {
215
+ }
216
+ }
217
+ makeCacheWindow(at = Date.now()) {
218
+ return {
219
+ updatedAt: at,
220
+ staleAt: at + this.staleTtlMs,
221
+ expiresAt: at + this.cacheTtlMs
222
+ };
223
+ }
224
+ setCacheItem(flagKey, value, source, revision = this.revision, at = Date.now()) {
225
+ this.cache.set(this.getCacheKey(flagKey), {
226
+ envKey: this.envKey,
227
+ flagKey,
228
+ value,
229
+ source,
230
+ revision,
231
+ ...this.makeCacheWindow(at)
82
232
  });
83
- if (!res.ok) return defaultValue;
84
- const json = await res.json();
85
- return json.value;
86
233
  }
87
- async flags(user, defaultValue = false) {
234
+ applyBootstrap(bootstrap) {
235
+ if (!bootstrap) return;
236
+ this.revision = Math.max(this.revision, bootstrap.revision ?? 0);
237
+ for (const killSwitchKey of bootstrap.killSwitches ?? []) {
238
+ this.killSwitches.add(killSwitchKey);
239
+ }
240
+ const apply = (flagKey, value, revision, updatedAt) => {
241
+ this.setCacheItem(flagKey, value, "bootstrap", revision ?? this.revision, updatedAt ?? Date.now());
242
+ };
243
+ if (Array.isArray(bootstrap.flags)) {
244
+ for (const item of bootstrap.flags) {
245
+ if (!item || typeof item.key !== "string") continue;
246
+ apply(item.key, Boolean(item.value), item.revision, item.updatedAt);
247
+ }
248
+ return;
249
+ }
250
+ for (const [flagKey, value] of Object.entries(bootstrap.flags ?? {})) {
251
+ apply(flagKey, Boolean(value));
252
+ }
253
+ }
254
+ getCacheState(item, now = Date.now()) {
255
+ if (!item) return "missing";
256
+ if (now <= item.staleAt) return "fresh";
257
+ if (now <= item.expiresAt) return "stale";
258
+ return "expired";
259
+ }
260
+ setCircuitState(isOpen, reason = "") {
261
+ if (this.circuitOpen === isOpen) return;
262
+ this.circuitOpen = isOpen;
263
+ if (isOpen) {
264
+ this.emit("circuitOpen", { envKey: this.envKey, reason });
265
+ return;
266
+ }
267
+ this.emit("circuitClose", { envKey: this.envKey });
268
+ }
269
+ async fetchWithRetry(url, init, transport) {
270
+ let lastError = null;
271
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
272
+ const ac = typeof AbortController !== "undefined" ? new AbortController() : null;
273
+ const timeout = ac !== null ? setTimeout(() => {
274
+ ac.abort();
275
+ }, this.timeoutMs) : null;
276
+ try {
277
+ const response = await fetch(url, {
278
+ ...init,
279
+ signal: ac?.signal
280
+ });
281
+ if (timeout) clearTimeout(timeout);
282
+ if (!response.ok && isRetriableStatus(response.status) && attempt < this.maxRetries) {
283
+ lastError = new Error(`HTTP ${response.status}`);
284
+ const jitterOffset = this.backoffMs * this.jitter * Math.random();
285
+ await sleep(this.backoffMs * (attempt + 1) + jitterOffset);
286
+ continue;
287
+ }
288
+ return response;
289
+ } catch (error) {
290
+ if (timeout) clearTimeout(timeout);
291
+ lastError = error;
292
+ if (attempt >= this.maxRetries) break;
293
+ const jitterOffset = this.backoffMs * this.jitter * Math.random();
294
+ await sleep(this.backoffMs * (attempt + 1) + jitterOffset);
295
+ }
296
+ }
297
+ this.setCircuitState(true, transport);
298
+ throw lastError;
299
+ }
300
+ collectCachedFlags() {
301
+ const now = Date.now();
302
+ const flags = [];
303
+ for (const [cacheKey, item] of this.cache.entries()) {
304
+ if (!cacheKey.startsWith(`${this.envKey}:`)) continue;
305
+ if (this.killSwitches.has(item.flagKey)) {
306
+ flags.push({ key: item.flagKey, value: false });
307
+ continue;
308
+ }
309
+ const state = this.getCacheState(item, now);
310
+ if (state === "missing" || state === "expired") continue;
311
+ flags.push({ key: item.flagKey, value: item.value });
312
+ }
313
+ return flags.sort((a, b) => a.key.localeCompare(b.key));
314
+ }
315
+ getCachedFlags() {
316
+ const flags = this.collectCachedFlags();
317
+ return { flags, hasData: flags.length > 0 };
318
+ }
319
+ getFlagDiagnostics(flagKey) {
320
+ return this.diagnostics.get(this.getCacheKey(flagKey)) ?? null;
321
+ }
322
+ setKillSwitch(flagKey, enabled) {
323
+ const started = monotonicNow();
324
+ if (enabled) {
325
+ this.killSwitches.add(flagKey);
326
+ } else {
327
+ this.killSwitches.delete(flagKey);
328
+ }
329
+ const elapsed = monotonicNow() - started;
330
+ this.emitMetric("ff_killswitch_apply_latency_ms", elapsed, {
331
+ env: this.envKey,
332
+ result: enabled ? "enabled" : "disabled"
333
+ });
334
+ this.emit("update", { changedKeys: [flagKey], source: "realtime" });
335
+ void this.persistCache();
336
+ }
337
+ async fetchEvalFromNetwork(flagKey, normalizedUser, defaultValue) {
338
+ const response = this.sdkKey ? await this.fetchWithRetry(
339
+ `${this.apiBaseUrl}/api/v1/sdk/eval`,
340
+ {
341
+ method: "POST",
342
+ headers: {
343
+ "content-type": "application/json",
344
+ "x-featureflare-sdk-key": this.sdkKey
345
+ },
346
+ body: JSON.stringify({
347
+ flagKey,
348
+ user: normalizedUser,
349
+ defaultValue,
350
+ expectedEnvKey: this.expectedEnvKey ?? void 0
351
+ })
352
+ },
353
+ "network"
354
+ ) : await this.fetchWithRetry(
355
+ `${this.apiBaseUrl}/api/v1/eval`,
356
+ {
357
+ method: "POST",
358
+ headers: { "content-type": "application/json" },
359
+ body: JSON.stringify({
360
+ projectKey: this.projectKey,
361
+ envKey: this.envKey,
362
+ flagKey,
363
+ user: normalizedUser,
364
+ defaultValue
365
+ })
366
+ },
367
+ "network"
368
+ );
369
+ if (!response.ok) {
370
+ if (!isRetriableStatus(response.status)) {
371
+ this.setCircuitState(true, `http_${response.status}`);
372
+ }
373
+ return null;
374
+ }
375
+ const json = await response.json();
376
+ const value = typeof json.value === "boolean" ? json.value : defaultValue;
377
+ if (json.killSwitch === true) {
378
+ this.killSwitches.add(flagKey);
379
+ }
380
+ const revision = json.revision ?? this.revision;
381
+ this.revision = Math.max(this.revision, revision);
382
+ this.setCacheItem(flagKey, value, "network", revision);
383
+ this.setCircuitState(false);
384
+ void this.persistCache();
385
+ this.emit("update", { changedKeys: [flagKey], source: "network" });
386
+ return value;
387
+ }
388
+ async fetchFlagsFromNetwork(normalizedUser, defaultValue, transport) {
88
389
  if (!this.sdkKey) {
89
- throw new Error("FeatureFlareClient.flags requires sdkKey. Legacy projectKey mode does not support listing all flags.");
390
+ return null;
90
391
  }
392
+ const started = monotonicNow();
393
+ try {
394
+ const response = await this.fetchWithRetry(
395
+ `${this.apiBaseUrl}/api/v1/sdk/flags`,
396
+ {
397
+ method: "POST",
398
+ headers: {
399
+ "content-type": "application/json",
400
+ "x-featureflare-sdk-key": this.sdkKey
401
+ },
402
+ body: JSON.stringify({
403
+ user: normalizedUser,
404
+ defaultValue,
405
+ expectedEnvKey: this.expectedEnvKey ?? void 0
406
+ })
407
+ },
408
+ transport
409
+ );
410
+ if (!response.ok) {
411
+ if (!isRetriableStatus(response.status)) {
412
+ this.setCircuitState(true, `http_${response.status}`);
413
+ }
414
+ return null;
415
+ }
416
+ const json = await response.json();
417
+ const list = normalizeBoolMap(json);
418
+ const revision = json.revision ?? this.revision;
419
+ this.revision = Math.max(this.revision, revision);
420
+ if (Array.isArray(json.killSwitches)) {
421
+ for (const key of json.killSwitches) {
422
+ this.killSwitches.add(key);
423
+ }
424
+ }
425
+ const changed = /* @__PURE__ */ new Set();
426
+ for (const entry of list) {
427
+ const existing = this.cache.get(this.getCacheKey(entry.key));
428
+ if (!existing || existing.value !== entry.value || existing.revision !== revision) {
429
+ changed.add(entry.key);
430
+ }
431
+ this.setCacheItem(entry.key, entry.value, "network", revision);
432
+ }
433
+ if (changed.size > 0) {
434
+ this.emit("update", { changedKeys: [...changed], source: "network" });
435
+ }
436
+ this.setCircuitState(false);
437
+ void this.persistCache();
438
+ this.emitMetric("ff_revalidate_latency_ms", monotonicNow() - started, {
439
+ env: this.envKey,
440
+ transport
441
+ });
442
+ return list;
443
+ } catch {
444
+ this.emitMetric("ff_revalidate_latency_ms", monotonicNow() - started, {
445
+ env: this.envKey,
446
+ transport,
447
+ result: "error"
448
+ });
449
+ return null;
450
+ }
451
+ }
452
+ revalidate(normalizedUser, defaultValue) {
453
+ const existing = this.inFlightRevalidate.get(this.envKey);
454
+ if (existing) return existing;
455
+ const promise = this.fetchFlagsFromNetwork(normalizedUser, defaultValue, "network").finally(() => {
456
+ this.inFlightRevalidate.delete(this.envKey);
457
+ });
458
+ this.inFlightRevalidate.set(this.envKey, promise);
459
+ return promise;
460
+ }
461
+ async evaluate(flagKey, user, defaultValue = false) {
462
+ const started = monotonicNow();
463
+ await this.ensurePersistentLoaded();
91
464
  const normalizedUser = this.normalizeUser(user);
92
- const res = await fetch(`${this.apiBaseUrl}/api/v1/sdk/flags`, {
93
- method: "POST",
94
- headers: {
95
- "content-type": "application/json",
96
- "x-featureflare-sdk-key": this.sdkKey
97
- },
98
- body: JSON.stringify({
99
- user: normalizedUser,
100
- defaultValue,
101
- expectedEnvKey: this.expectedEnvKey ?? void 0
102
- })
465
+ this.lastUser = normalizedUser;
466
+ this.lastDefaultValue = defaultValue;
467
+ if (this.killSwitches.has(flagKey)) {
468
+ const metadata2 = {
469
+ reason: "kill_switch",
470
+ isStale: false,
471
+ latencyMs: monotonicNow() - started,
472
+ source: "kill_switch"
473
+ };
474
+ this.emitMetric("ff_eval_latency_ms", metadata2.latencyMs, {
475
+ env: this.envKey,
476
+ source: metadata2.source,
477
+ result: "disabled"
478
+ });
479
+ this.diagnostics.set(this.getCacheKey(flagKey), metadata2);
480
+ return { value: false, metadata: metadata2 };
481
+ }
482
+ const cacheItem = this.cache.get(this.getCacheKey(flagKey));
483
+ const cacheState = this.getCacheState(cacheItem);
484
+ if (cacheItem && cacheState === "fresh") {
485
+ const metadata2 = {
486
+ reason: cacheItem.source === "bootstrap" || cacheItem.source === "persistent" ? "bootstrap" : "fresh_cache",
487
+ isStale: false,
488
+ latencyMs: monotonicNow() - started,
489
+ source: cacheItem.source,
490
+ updatedAt: cacheItem.updatedAt,
491
+ staleAt: cacheItem.staleAt,
492
+ expiresAt: cacheItem.expiresAt
493
+ };
494
+ this.emitMetric("ff_cache_hit_ratio", 1, { env: this.envKey, source: metadata2.reason });
495
+ this.emitMetric("ff_eval_latency_ms", metadata2.latencyMs, {
496
+ env: this.envKey,
497
+ source: metadata2.source,
498
+ result: "cache_hit"
499
+ });
500
+ this.diagnostics.set(this.getCacheKey(flagKey), metadata2);
501
+ return { value: cacheItem.value, metadata: metadata2 };
502
+ }
503
+ if (cacheItem && cacheState === "stale") {
504
+ void this.revalidate(normalizedUser, defaultValue);
505
+ const metadata2 = {
506
+ reason: "stale_cache",
507
+ isStale: true,
508
+ latencyMs: monotonicNow() - started,
509
+ source: cacheItem.source,
510
+ updatedAt: cacheItem.updatedAt,
511
+ staleAt: cacheItem.staleAt,
512
+ expiresAt: cacheItem.expiresAt
513
+ };
514
+ this.emitMetric("ff_cache_hit_ratio", 1, { env: this.envKey, source: metadata2.reason });
515
+ this.emitMetric("ff_eval_latency_ms", metadata2.latencyMs, {
516
+ env: this.envKey,
517
+ source: metadata2.source,
518
+ result: "stale"
519
+ });
520
+ this.diagnostics.set(this.getCacheKey(flagKey), metadata2);
521
+ return { value: cacheItem.value, metadata: metadata2 };
522
+ }
523
+ if (cacheItem && (cacheItem.source === "bootstrap" || cacheItem.source === "persistent")) {
524
+ void this.revalidate(normalizedUser, defaultValue);
525
+ const metadata2 = {
526
+ reason: "bootstrap",
527
+ isStale: true,
528
+ latencyMs: monotonicNow() - started,
529
+ source: cacheItem.source,
530
+ updatedAt: cacheItem.updatedAt,
531
+ staleAt: cacheItem.staleAt,
532
+ expiresAt: cacheItem.expiresAt
533
+ };
534
+ this.emitMetric("ff_cache_hit_ratio", 1, { env: this.envKey, source: metadata2.reason });
535
+ this.emitMetric("ff_eval_latency_ms", metadata2.latencyMs, {
536
+ env: this.envKey,
537
+ source: metadata2.source,
538
+ result: "bootstrap"
539
+ });
540
+ this.diagnostics.set(this.getCacheKey(flagKey), metadata2);
541
+ return { value: cacheItem.value, metadata: metadata2 };
542
+ }
543
+ try {
544
+ const networkValue = await this.fetchEvalFromNetwork(flagKey, normalizedUser, defaultValue);
545
+ if (networkValue !== null) {
546
+ const item = this.cache.get(this.getCacheKey(flagKey));
547
+ const metadata2 = {
548
+ reason: "network",
549
+ isStale: false,
550
+ latencyMs: monotonicNow() - started,
551
+ source: "network",
552
+ updatedAt: item?.updatedAt,
553
+ staleAt: item?.staleAt,
554
+ expiresAt: item?.expiresAt
555
+ };
556
+ this.emitMetric("ff_cache_hit_ratio", 0, { env: this.envKey, source: metadata2.reason });
557
+ this.emitMetric("ff_eval_latency_ms", metadata2.latencyMs, {
558
+ env: this.envKey,
559
+ source: metadata2.source,
560
+ result: "network"
561
+ });
562
+ this.diagnostics.set(this.getCacheKey(flagKey), metadata2);
563
+ return { value: networkValue, metadata: metadata2 };
564
+ }
565
+ } catch {
566
+ }
567
+ const metadata = {
568
+ reason: "default",
569
+ isStale: false,
570
+ latencyMs: monotonicNow() - started,
571
+ source: "default"
572
+ };
573
+ this.emitMetric("ff_cache_hit_ratio", 0, { env: this.envKey, source: metadata.reason });
574
+ this.emitMetric("ff_eval_latency_ms", metadata.latencyMs, {
575
+ env: this.envKey,
576
+ source: metadata.source,
577
+ result: "default"
578
+ });
579
+ this.diagnostics.set(this.getCacheKey(flagKey), metadata);
580
+ return { value: defaultValue, metadata };
581
+ }
582
+ async bool(flagKey, user, defaultValue = false) {
583
+ const result = await this.evaluate(flagKey, user, defaultValue);
584
+ return result.value;
585
+ }
586
+ async flags(user, defaultValue = false) {
587
+ await this.ensurePersistentLoaded();
588
+ const normalizedUser = this.normalizeUser(user);
589
+ this.lastUser = normalizedUser;
590
+ this.lastDefaultValue = defaultValue;
591
+ const list = await this.revalidate(normalizedUser, defaultValue);
592
+ if (list && list.length > 0) {
593
+ return list.map((entry) => ({
594
+ key: entry.key,
595
+ value: this.killSwitches.has(entry.key) ? false : entry.value
596
+ }));
597
+ }
598
+ const cached = this.collectCachedFlags();
599
+ if (cached.length > 0) return cached;
600
+ return [];
601
+ }
602
+ applyRealtimeMessage(message) {
603
+ if (typeof message.revision === "number" && message.revision < this.revision) {
604
+ return;
605
+ }
606
+ if (typeof message.revision === "number") {
607
+ this.revision = message.revision;
608
+ }
609
+ const changed = /* @__PURE__ */ new Set();
610
+ const now = Date.now();
611
+ if (message.type === "flag.updated") {
612
+ const flagKey = typeof message.data?.flagKey === "string" ? message.data.flagKey : null;
613
+ const value = typeof message.data?.value === "boolean" ? message.data.value : null;
614
+ if (flagKey !== null && value !== null) {
615
+ this.setCacheItem(flagKey, value, "realtime", message.revision ?? this.revision, now);
616
+ changed.add(flagKey);
617
+ }
618
+ }
619
+ if (message.type === "flag.deleted") {
620
+ const flagKey = typeof message.data?.flagKey === "string" ? message.data.flagKey : null;
621
+ if (flagKey !== null) {
622
+ this.cache.delete(this.getCacheKey(flagKey));
623
+ changed.add(flagKey);
624
+ }
625
+ }
626
+ if (message.type === "env.kill_switch.updated") {
627
+ const started = monotonicNow();
628
+ const flagKey = typeof message.data?.flagKey === "string" ? message.data.flagKey : null;
629
+ const enabled = typeof message.data?.enabled === "boolean" ? message.data.enabled : true;
630
+ if (flagKey !== null) {
631
+ if (enabled) {
632
+ this.killSwitches.add(flagKey);
633
+ } else {
634
+ this.killSwitches.delete(flagKey);
635
+ }
636
+ changed.add(flagKey);
637
+ this.emitMetric("ff_killswitch_apply_latency_ms", monotonicNow() - started, {
638
+ env: this.envKey,
639
+ transport: this.eventSource ? "sse" : "polling",
640
+ result: enabled ? "enabled" : "disabled"
641
+ });
642
+ }
643
+ }
644
+ if (message.type === "snapshot.invalidate" && this.lastUser) {
645
+ void this.revalidate(this.lastUser, this.lastDefaultValue);
646
+ }
647
+ const eventLagMs = Math.max(0, Date.now() - (message.timestamp ?? Date.now()));
648
+ this.emitMetric("ff_realtime_lag_ms", eventLagMs, {
649
+ env: this.envKey,
650
+ transport: this.eventSource ? "sse" : "polling"
103
651
  });
104
- if (!res.ok) return [];
105
- const json = await res.json();
106
- if (Array.isArray(json.flags)) {
107
- return json.flags.filter(
108
- (entry) => !!entry && typeof entry.key === "string" && typeof entry.value === "boolean"
109
- ).map((entry) => ({ key: entry.key, value: entry.value }));
652
+ if (changed.size > 0) {
653
+ this.emit("update", { changedKeys: [...changed], source: "realtime" });
654
+ void this.persistCache();
110
655
  }
111
- const values = json.values ?? {};
112
- return Object.entries(values).map(([key, value]) => ({ key, value: Boolean(value) }));
656
+ }
657
+ startPolling() {
658
+ if (this.pollTimer) {
659
+ clearTimeout(this.pollTimer);
660
+ this.pollTimer = null;
661
+ }
662
+ const tick = async () => {
663
+ if (!this.lastUser) {
664
+ this.pollTimer = setTimeout(tick, this.realtimePollingMs);
665
+ return;
666
+ }
667
+ await this.fetchFlagsFromNetwork(this.lastUser, this.lastDefaultValue, "polling");
668
+ this.pollTimer = setTimeout(tick, this.realtimePollingMs);
669
+ };
670
+ this.pollTimer = setTimeout(tick, this.realtimePollingMs);
671
+ }
672
+ connectSse(attempt = 0) {
673
+ const EventSourceImpl = typeof EventSource !== "undefined" ? EventSource : null;
674
+ if (!EventSourceImpl || !this.sdkKey) {
675
+ this.emit("connectionState", { state: "degraded", transport: "polling" });
676
+ this.startPolling();
677
+ return;
678
+ }
679
+ const url = new URL(this.realtimeSsePath, this.apiBaseUrl);
680
+ url.searchParams.set("sdkKey", this.sdkKey);
681
+ url.searchParams.set("envKey", this.envKey);
682
+ this.eventSource = new EventSourceImpl(url.toString());
683
+ this.eventSource.onopen = () => {
684
+ this.emit("connectionState", { state: "connected", transport: "sse" });
685
+ if (this.pollTimer) {
686
+ clearTimeout(this.pollTimer);
687
+ this.pollTimer = null;
688
+ }
689
+ };
690
+ this.eventSource.onmessage = (event) => {
691
+ try {
692
+ const payload = JSON.parse(String(event.data));
693
+ this.applyRealtimeMessage(payload);
694
+ } catch {
695
+ }
696
+ };
697
+ this.eventSource.onerror = () => {
698
+ this.emit("connectionState", { state: "degraded", transport: "polling" });
699
+ this.eventSource?.close();
700
+ this.eventSource = null;
701
+ this.startPolling();
702
+ if (this.reconnectTimer) {
703
+ clearTimeout(this.reconnectTimer);
704
+ }
705
+ const backoff = Math.min(this.backoffMs * (attempt + 1), 3e4);
706
+ this.reconnectTimer = setTimeout(() => {
707
+ this.connectSse(attempt + 1);
708
+ }, backoff);
709
+ };
710
+ }
711
+ startRealtime() {
712
+ this.realtimeEnabled = true;
713
+ this.connectSse(0);
714
+ }
715
+ stopRealtime() {
716
+ this.realtimeEnabled = false;
717
+ if (this.eventSource) {
718
+ this.eventSource.close();
719
+ this.eventSource = null;
720
+ }
721
+ if (this.pollTimer) {
722
+ clearTimeout(this.pollTimer);
723
+ this.pollTimer = null;
724
+ }
725
+ if (this.reconnectTimer) {
726
+ clearTimeout(this.reconnectTimer);
727
+ this.reconnectTimer = null;
728
+ }
729
+ this.emit("connectionState", { state: "offline", transport: "none" });
730
+ }
731
+ dispose() {
732
+ this.stopRealtime();
733
+ this.listeners.update.clear();
734
+ this.listeners.circuitOpen.clear();
735
+ this.listeners.circuitClose.clear();
736
+ this.listeners.connectionState.clear();
113
737
  }
114
738
  };
115
739
 
116
- export { FeatureFlareClient };
740
+ export { FeatureFlareClient, InMemoryMetricsCollector };
117
741
  //# sourceMappingURL=index.js.map
118
742
  //# sourceMappingURL=index.js.map