@featureflare/sdk-js 0.0.26 → 0.0.27

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