@feathq/web-sdk 0.2.0 → 0.3.0

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 CHANGED
@@ -55,6 +55,19 @@ await client.setContext({
55
55
 
56
56
  `change` events fire per flag whose evaluated value flipped, after either a context change or a datafile refresh.
57
57
 
58
+ ## Live streaming
59
+
60
+ The SDK can hold a Server-Sent Events connection so datafile changes land near-instantly instead of waiting for the next poll. On each update the server pushes the full datafile; the SDK adopts it in version order (no extra HTTP request) and fires `change` events.
61
+
62
+ By default streaming **follows your subscription**: the stream opens when the first `change` listener is added and closes when the last one is removed, so a page that never listens pays nothing. Override with the `streaming` option:
63
+
64
+ ```ts
65
+ new FeatWebClient({ apiKey, streaming: true }); // always stream, opens on ready
66
+ new FeatWebClient({ apiKey, streaming: false }); // never stream
67
+ ```
68
+
69
+ Polling stays on as a safety net in every mode, so a dropped stream still self-heals.
70
+
58
71
  ## OpenFeature
59
72
 
60
73
  ```ts
@@ -81,6 +94,7 @@ new FeatWebClient({ apiKey, bootstrap: serverProvidedDatafile });
81
94
 
82
95
  - Pre-evaluates every flag against the current context into a `Map` so `getValue` is synchronous.
83
96
  - Polls every 30 s by default; pauses while the tab is hidden and force-refreshes on visibility restore. Floored at 5 s.
97
+ - Optional Server-Sent Events stream for near-instant updates (see [Live streaming](#live-streaming)); polling remains the fallback.
84
98
  - Cross-tab `BroadcastChannel` sync: when one tab fetches a new datafile, sibling tabs adopt it without their own network call.
85
99
  - 304-aware via `ETag` / `If-None-Match`.
86
100
  - `url` must use `https://` if you override it (the constructor rejects plaintext URLs except `http://localhost` for tests).
package/dist/index.cjs CHANGED
@@ -267,8 +267,55 @@ function hasLocalStorage2() {
267
267
  }
268
268
  }
269
269
 
270
+ // src/streaming.ts
271
+ var EVENT_SOURCE_CLOSED = 2;
272
+ var DatafileStream = class {
273
+ constructor(options) {
274
+ this.source = null;
275
+ this.warned = false;
276
+ this.options = options;
277
+ }
278
+ open() {
279
+ if (this.source) return;
280
+ const base = this.options.url.replace(/\/$/, "");
281
+ const streamUrl = `${base}/sdk/v1/datafile/stream?key=${encodeURIComponent(
282
+ this.options.apiKey
283
+ )}`;
284
+ const source = new this.options.eventSourceCtor(streamUrl);
285
+ this.source = source;
286
+ source.addEventListener("put", (ev) => {
287
+ const datafile = parsePut(ev.data);
288
+ if (datafile) this.options.onPut(datafile);
289
+ });
290
+ source.addEventListener("error", () => {
291
+ if (!this.warned) {
292
+ this.warned = true;
293
+ console.warn("feat: datafile stream error; falling back to polling");
294
+ }
295
+ if (this.source && this.source.readyState === EVENT_SOURCE_CLOSED) {
296
+ this.close();
297
+ }
298
+ });
299
+ }
300
+ close() {
301
+ this.source?.close();
302
+ this.source = null;
303
+ }
304
+ };
305
+ function parsePut(data) {
306
+ if (typeof data !== "string") return null;
307
+ try {
308
+ const parsed = JSON.parse(data);
309
+ if (parsed && typeof parsed === "object" && typeof parsed.version === "number") {
310
+ return parsed;
311
+ }
312
+ } catch {
313
+ }
314
+ return null;
315
+ }
316
+
270
317
  // src/version.ts
271
- var SDK_VERSION = "0.2.0";
318
+ var SDK_VERSION = "0.3.0";
272
319
 
273
320
  // src/client.ts
274
321
  var CLIENT_SIDE_PREFIX = "feat_cs_";
@@ -288,6 +335,14 @@ var FeatWebClient = class {
288
335
  this.visibilityHandler = null;
289
336
  this.emitter = new Emitter();
290
337
  this.broadcast = null;
338
+ this.stream = null;
339
+ // The set of live `change` listeners. The streaming-follows-subscription
340
+ // policy keys off whether this is non-empty; tracking the listeners (rather
341
+ // than a counter) keeps the refcount correct whether the caller unsubscribes
342
+ // via the returned disposer or via off().
343
+ this.changeListeners = /* @__PURE__ */ new Set();
344
+ this.started = false;
345
+ this.warnedNoEventSource = false;
291
346
  this.closed = false;
292
347
  if (!config.apiKey.startsWith(CLIENT_SIDE_PREFIX)) {
293
348
  throw new Error(
@@ -297,6 +352,7 @@ var FeatWebClient = class {
297
352
  this.url = config.url ?? DEFAULT_URL;
298
353
  assertHttpsUrl(this.url);
299
354
  this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
355
+ this.eventSourceCtor = config.eventSource ?? (typeof globalThis.EventSource !== "undefined" ? globalThis.EventSource : void 0);
300
356
  this.pollIntervalMs = Math.max(
301
357
  config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
302
358
  MIN_POLL_INTERVAL_MS
@@ -389,10 +445,17 @@ var FeatWebClient = class {
389
445
  return new Map(this.cache);
390
446
  }
391
447
  on(event, listener) {
392
- return this.emitter.on(event, listener);
448
+ if (event !== "change") return this.emitter.on(event, listener);
449
+ this.emitter.on(event, listener);
450
+ this.changeListeners.add(listener);
451
+ this.maybeUpdateStream();
452
+ return () => this.off(event, listener);
393
453
  }
394
454
  off(event, listener) {
395
455
  this.emitter.off(event, listener);
456
+ if (event === "change" && this.changeListeners.delete(listener)) {
457
+ this.maybeUpdateStream();
458
+ }
396
459
  }
397
460
  // Force a one-shot fetch. Returns true if the in-memory datafile changed.
398
461
  async refresh() {
@@ -407,6 +470,9 @@ var FeatWebClient = class {
407
470
  clearInterval(this.timer);
408
471
  this.timer = null;
409
472
  }
473
+ this.stream?.close();
474
+ this.stream = null;
475
+ this.changeListeners.clear();
410
476
  if (this.visibilityHandler && typeof document !== "undefined") {
411
477
  document.removeEventListener("visibilitychange", this.visibilityHandler);
412
478
  this.visibilityHandler = null;
@@ -424,6 +490,8 @@ var FeatWebClient = class {
424
490
  this.startPolling();
425
491
  this.attachVisibilityHandler();
426
492
  this.summarizer?.start();
493
+ this.started = true;
494
+ this.maybeUpdateStream();
427
495
  this.emitter.emit("ready", void 0);
428
496
  } catch (err) {
429
497
  this.emitter.emit("failed", err instanceof Error ? err : new Error(String(err)));
@@ -484,18 +552,73 @@ var FeatWebClient = class {
484
552
  await this.recomputeCache();
485
553
  return true;
486
554
  }
487
- // Sibling-tab handler. Only adopt if the broadcast carries a newer
488
- // version (or we have nothing); old broadcasts can race with our own
489
- // fresh fetches and we don't want to regress.
555
+ // Sibling-tab handler. Adopt without republishing: this is the receive end
556
+ // of a BroadcastChannel message, so echoing it back would loop.
490
557
  async adoptFromBroadcast(datafile, etag) {
558
+ await this.adoptDatafile(datafile, etag, false);
559
+ }
560
+ // Version-guarded adopt for datafiles that arrive outside the fetch path
561
+ // (sibling-tab broadcast, SSE `put`). Only adopt a strictly newer version so
562
+ // an old broadcast or out-of-order frame can't regress us; the same guard
563
+ // stops a republished put from looping back through a sibling tab. When
564
+ // `publish` is set we mirror the fetch path and rebroadcast so sibling tabs
565
+ // stay fresh without their own network call.
566
+ async adoptDatafile(datafile, etag, publish) {
567
+ if (this.closed) return;
491
568
  if (this.datafile && datafile.version <= this.datafile.version) return;
492
569
  this.datafile = datafile;
493
570
  this.etag = etag;
494
571
  if (this.config.cache) {
495
572
  saveCachedDatafile(this.config.cache, { datafile, etag });
496
573
  }
574
+ if (publish) this.broadcast?.publish(datafile, etag);
497
575
  await this.recomputeCache();
498
576
  }
577
+ // Reconcile the SSE connection with the current streaming policy. Called
578
+ // whenever an input to that policy changes: ready, a `change` (un)subscribe,
579
+ // or close.
580
+ maybeUpdateStream() {
581
+ if (this.closed) return;
582
+ const want = this.wantsStream();
583
+ if (want) {
584
+ this.openStream();
585
+ } else if (this.stream) {
586
+ this.stream.close();
587
+ this.stream = null;
588
+ }
589
+ }
590
+ wantsStream() {
591
+ if (this.config.streaming === false) return false;
592
+ if (this.config.streaming === true) return this.started;
593
+ return this.changeListeners.size > 0;
594
+ }
595
+ openStream() {
596
+ if (!this.eventSourceCtor) {
597
+ if (!this.warnedNoEventSource) {
598
+ this.warnedNoEventSource = true;
599
+ console.warn("feat: streaming requested but EventSource is unavailable; using polling");
600
+ }
601
+ return;
602
+ }
603
+ if (!this.stream) {
604
+ this.stream = new DatafileStream({
605
+ url: this.url,
606
+ apiKey: this.config.apiKey,
607
+ eventSourceCtor: this.eventSourceCtor,
608
+ // A `put` carries the full datafile; reuse the version-ordered adopt
609
+ // path so a duplicate or out-of-order frame is ignored and the cache
610
+ // recomputes (firing `change`) only on a strictly newer version. Like
611
+ // the fetch path, a stream-adopted put republishes on the
612
+ // BroadcastChannel so sibling tabs stay fresh without their own fetch.
613
+ onPut: (datafile) => {
614
+ void this.adoptDatafile(datafile, datafile.etag, true).catch((err) => {
615
+ console.warn("feat: streamed datafile update failed:", messageOf(err));
616
+ });
617
+ }
618
+ });
619
+ }
620
+ this.stream.open();
621
+ }
499
622
  // Pre-evaluate every flag in the datafile against the current context
500
623
  // and diff against the previous cache. Skips silently if datafile or
501
624
  // context is missing; the caller will recompute as soon as both land.
@@ -562,7 +685,7 @@ function assertHttpsUrl(url) {
562
685
  }
563
686
 
564
687
  // src/index.ts
565
- var SDK_VERSION2 = "0.2.0";
688
+ var SDK_VERSION2 = "0.3.0";
566
689
 
567
690
  exports.FeatWebClient = FeatWebClient;
568
691
  exports.SDK_VERSION = SDK_VERSION2;
package/dist/index.d.cts CHANGED
@@ -13,6 +13,13 @@ interface DatafileCacheConfig {
13
13
  storage: DatafileCacheStorage;
14
14
  }
15
15
 
16
+ interface EventSourceLike {
17
+ readyState: number;
18
+ addEventListener(type: string, listener: (ev: MessageEvent) => void): void;
19
+ close(): void;
20
+ }
21
+ type EventSourceConstructor = new (url: string, init?: EventSourceInit) => EventSourceLike;
22
+
16
23
  interface FeatWebClientConfig {
17
24
  apiKey: string;
18
25
  url?: string;
@@ -25,6 +32,8 @@ interface FeatWebClientConfig {
25
32
  events?: boolean;
26
33
  eventsFlushIntervalMs?: number;
27
34
  fetch?: typeof fetch;
35
+ streaming?: boolean;
36
+ eventSource?: EventSourceConstructor;
28
37
  }
29
38
  interface ChangeEvent {
30
39
  flagKey: string;
@@ -51,8 +60,13 @@ declare class FeatWebClient {
51
60
  private visibilityHandler;
52
61
  private emitter;
53
62
  private broadcast;
63
+ private stream;
64
+ private changeListeners;
65
+ private started;
66
+ private warnedNoEventSource;
54
67
  private readonly summarizer;
55
68
  private readonly fetchImpl;
69
+ private readonly eventSourceCtor;
56
70
  private readonly pollIntervalMs;
57
71
  private readonly url;
58
72
  private closed;
@@ -77,9 +91,13 @@ declare class FeatWebClient {
77
91
  private attachVisibilityHandler;
78
92
  private fetchDatafile;
79
93
  private adoptFromBroadcast;
94
+ private adoptDatafile;
95
+ private maybeUpdateStream;
96
+ private wantsStream;
97
+ private openStream;
80
98
  private recomputeCache;
81
99
  }
82
100
 
83
- declare const SDK_VERSION = "0.2.0";
101
+ declare const SDK_VERSION = "0.3.0";
84
102
 
85
- export { type ChangeEvent, FeatWebClient, type FeatWebClientConfig, type FlagEventMap, SDK_VERSION };
103
+ export { type ChangeEvent, type EventSourceConstructor, FeatWebClient, type FeatWebClientConfig, type FlagEventMap, SDK_VERSION };
package/dist/index.d.ts CHANGED
@@ -13,6 +13,13 @@ interface DatafileCacheConfig {
13
13
  storage: DatafileCacheStorage;
14
14
  }
15
15
 
16
+ interface EventSourceLike {
17
+ readyState: number;
18
+ addEventListener(type: string, listener: (ev: MessageEvent) => void): void;
19
+ close(): void;
20
+ }
21
+ type EventSourceConstructor = new (url: string, init?: EventSourceInit) => EventSourceLike;
22
+
16
23
  interface FeatWebClientConfig {
17
24
  apiKey: string;
18
25
  url?: string;
@@ -25,6 +32,8 @@ interface FeatWebClientConfig {
25
32
  events?: boolean;
26
33
  eventsFlushIntervalMs?: number;
27
34
  fetch?: typeof fetch;
35
+ streaming?: boolean;
36
+ eventSource?: EventSourceConstructor;
28
37
  }
29
38
  interface ChangeEvent {
30
39
  flagKey: string;
@@ -51,8 +60,13 @@ declare class FeatWebClient {
51
60
  private visibilityHandler;
52
61
  private emitter;
53
62
  private broadcast;
63
+ private stream;
64
+ private changeListeners;
65
+ private started;
66
+ private warnedNoEventSource;
54
67
  private readonly summarizer;
55
68
  private readonly fetchImpl;
69
+ private readonly eventSourceCtor;
56
70
  private readonly pollIntervalMs;
57
71
  private readonly url;
58
72
  private closed;
@@ -77,9 +91,13 @@ declare class FeatWebClient {
77
91
  private attachVisibilityHandler;
78
92
  private fetchDatafile;
79
93
  private adoptFromBroadcast;
94
+ private adoptDatafile;
95
+ private maybeUpdateStream;
96
+ private wantsStream;
97
+ private openStream;
80
98
  private recomputeCache;
81
99
  }
82
100
 
83
- declare const SDK_VERSION = "0.2.0";
101
+ declare const SDK_VERSION = "0.3.0";
84
102
 
85
- export { type ChangeEvent, FeatWebClient, type FeatWebClientConfig, type FlagEventMap, SDK_VERSION };
103
+ export { type ChangeEvent, type EventSourceConstructor, FeatWebClient, type FeatWebClientConfig, type FlagEventMap, SDK_VERSION };
package/dist/index.js CHANGED
@@ -265,8 +265,55 @@ function hasLocalStorage2() {
265
265
  }
266
266
  }
267
267
 
268
+ // src/streaming.ts
269
+ var EVENT_SOURCE_CLOSED = 2;
270
+ var DatafileStream = class {
271
+ constructor(options) {
272
+ this.source = null;
273
+ this.warned = false;
274
+ this.options = options;
275
+ }
276
+ open() {
277
+ if (this.source) return;
278
+ const base = this.options.url.replace(/\/$/, "");
279
+ const streamUrl = `${base}/sdk/v1/datafile/stream?key=${encodeURIComponent(
280
+ this.options.apiKey
281
+ )}`;
282
+ const source = new this.options.eventSourceCtor(streamUrl);
283
+ this.source = source;
284
+ source.addEventListener("put", (ev) => {
285
+ const datafile = parsePut(ev.data);
286
+ if (datafile) this.options.onPut(datafile);
287
+ });
288
+ source.addEventListener("error", () => {
289
+ if (!this.warned) {
290
+ this.warned = true;
291
+ console.warn("feat: datafile stream error; falling back to polling");
292
+ }
293
+ if (this.source && this.source.readyState === EVENT_SOURCE_CLOSED) {
294
+ this.close();
295
+ }
296
+ });
297
+ }
298
+ close() {
299
+ this.source?.close();
300
+ this.source = null;
301
+ }
302
+ };
303
+ function parsePut(data) {
304
+ if (typeof data !== "string") return null;
305
+ try {
306
+ const parsed = JSON.parse(data);
307
+ if (parsed && typeof parsed === "object" && typeof parsed.version === "number") {
308
+ return parsed;
309
+ }
310
+ } catch {
311
+ }
312
+ return null;
313
+ }
314
+
268
315
  // src/version.ts
269
- var SDK_VERSION = "0.2.0";
316
+ var SDK_VERSION = "0.3.0";
270
317
 
271
318
  // src/client.ts
272
319
  var CLIENT_SIDE_PREFIX = "feat_cs_";
@@ -286,6 +333,14 @@ var FeatWebClient = class {
286
333
  this.visibilityHandler = null;
287
334
  this.emitter = new Emitter();
288
335
  this.broadcast = null;
336
+ this.stream = null;
337
+ // The set of live `change` listeners. The streaming-follows-subscription
338
+ // policy keys off whether this is non-empty; tracking the listeners (rather
339
+ // than a counter) keeps the refcount correct whether the caller unsubscribes
340
+ // via the returned disposer or via off().
341
+ this.changeListeners = /* @__PURE__ */ new Set();
342
+ this.started = false;
343
+ this.warnedNoEventSource = false;
289
344
  this.closed = false;
290
345
  if (!config.apiKey.startsWith(CLIENT_SIDE_PREFIX)) {
291
346
  throw new Error(
@@ -295,6 +350,7 @@ var FeatWebClient = class {
295
350
  this.url = config.url ?? DEFAULT_URL;
296
351
  assertHttpsUrl(this.url);
297
352
  this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
353
+ this.eventSourceCtor = config.eventSource ?? (typeof globalThis.EventSource !== "undefined" ? globalThis.EventSource : void 0);
298
354
  this.pollIntervalMs = Math.max(
299
355
  config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
300
356
  MIN_POLL_INTERVAL_MS
@@ -387,10 +443,17 @@ var FeatWebClient = class {
387
443
  return new Map(this.cache);
388
444
  }
389
445
  on(event, listener) {
390
- return this.emitter.on(event, listener);
446
+ if (event !== "change") return this.emitter.on(event, listener);
447
+ this.emitter.on(event, listener);
448
+ this.changeListeners.add(listener);
449
+ this.maybeUpdateStream();
450
+ return () => this.off(event, listener);
391
451
  }
392
452
  off(event, listener) {
393
453
  this.emitter.off(event, listener);
454
+ if (event === "change" && this.changeListeners.delete(listener)) {
455
+ this.maybeUpdateStream();
456
+ }
394
457
  }
395
458
  // Force a one-shot fetch. Returns true if the in-memory datafile changed.
396
459
  async refresh() {
@@ -405,6 +468,9 @@ var FeatWebClient = class {
405
468
  clearInterval(this.timer);
406
469
  this.timer = null;
407
470
  }
471
+ this.stream?.close();
472
+ this.stream = null;
473
+ this.changeListeners.clear();
408
474
  if (this.visibilityHandler && typeof document !== "undefined") {
409
475
  document.removeEventListener("visibilitychange", this.visibilityHandler);
410
476
  this.visibilityHandler = null;
@@ -422,6 +488,8 @@ var FeatWebClient = class {
422
488
  this.startPolling();
423
489
  this.attachVisibilityHandler();
424
490
  this.summarizer?.start();
491
+ this.started = true;
492
+ this.maybeUpdateStream();
425
493
  this.emitter.emit("ready", void 0);
426
494
  } catch (err) {
427
495
  this.emitter.emit("failed", err instanceof Error ? err : new Error(String(err)));
@@ -482,18 +550,73 @@ var FeatWebClient = class {
482
550
  await this.recomputeCache();
483
551
  return true;
484
552
  }
485
- // Sibling-tab handler. Only adopt if the broadcast carries a newer
486
- // version (or we have nothing); old broadcasts can race with our own
487
- // fresh fetches and we don't want to regress.
553
+ // Sibling-tab handler. Adopt without republishing: this is the receive end
554
+ // of a BroadcastChannel message, so echoing it back would loop.
488
555
  async adoptFromBroadcast(datafile, etag) {
556
+ await this.adoptDatafile(datafile, etag, false);
557
+ }
558
+ // Version-guarded adopt for datafiles that arrive outside the fetch path
559
+ // (sibling-tab broadcast, SSE `put`). Only adopt a strictly newer version so
560
+ // an old broadcast or out-of-order frame can't regress us; the same guard
561
+ // stops a republished put from looping back through a sibling tab. When
562
+ // `publish` is set we mirror the fetch path and rebroadcast so sibling tabs
563
+ // stay fresh without their own network call.
564
+ async adoptDatafile(datafile, etag, publish) {
565
+ if (this.closed) return;
489
566
  if (this.datafile && datafile.version <= this.datafile.version) return;
490
567
  this.datafile = datafile;
491
568
  this.etag = etag;
492
569
  if (this.config.cache) {
493
570
  saveCachedDatafile(this.config.cache, { datafile, etag });
494
571
  }
572
+ if (publish) this.broadcast?.publish(datafile, etag);
495
573
  await this.recomputeCache();
496
574
  }
575
+ // Reconcile the SSE connection with the current streaming policy. Called
576
+ // whenever an input to that policy changes: ready, a `change` (un)subscribe,
577
+ // or close.
578
+ maybeUpdateStream() {
579
+ if (this.closed) return;
580
+ const want = this.wantsStream();
581
+ if (want) {
582
+ this.openStream();
583
+ } else if (this.stream) {
584
+ this.stream.close();
585
+ this.stream = null;
586
+ }
587
+ }
588
+ wantsStream() {
589
+ if (this.config.streaming === false) return false;
590
+ if (this.config.streaming === true) return this.started;
591
+ return this.changeListeners.size > 0;
592
+ }
593
+ openStream() {
594
+ if (!this.eventSourceCtor) {
595
+ if (!this.warnedNoEventSource) {
596
+ this.warnedNoEventSource = true;
597
+ console.warn("feat: streaming requested but EventSource is unavailable; using polling");
598
+ }
599
+ return;
600
+ }
601
+ if (!this.stream) {
602
+ this.stream = new DatafileStream({
603
+ url: this.url,
604
+ apiKey: this.config.apiKey,
605
+ eventSourceCtor: this.eventSourceCtor,
606
+ // A `put` carries the full datafile; reuse the version-ordered adopt
607
+ // path so a duplicate or out-of-order frame is ignored and the cache
608
+ // recomputes (firing `change`) only on a strictly newer version. Like
609
+ // the fetch path, a stream-adopted put republishes on the
610
+ // BroadcastChannel so sibling tabs stay fresh without their own fetch.
611
+ onPut: (datafile) => {
612
+ void this.adoptDatafile(datafile, datafile.etag, true).catch((err) => {
613
+ console.warn("feat: streamed datafile update failed:", messageOf(err));
614
+ });
615
+ }
616
+ });
617
+ }
618
+ this.stream.open();
619
+ }
497
620
  // Pre-evaluate every flag in the datafile against the current context
498
621
  // and diff against the previous cache. Skips silently if datafile or
499
622
  // context is missing; the caller will recompute as soon as both land.
@@ -560,6 +683,6 @@ function assertHttpsUrl(url) {
560
683
  }
561
684
 
562
685
  // src/index.ts
563
- var SDK_VERSION2 = "0.2.0";
686
+ var SDK_VERSION2 = "0.3.0";
564
687
 
565
688
  export { FeatWebClient, SDK_VERSION2 as SDK_VERSION };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feathq/web-sdk",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "feat feature-flag SDK for browsers. Polling client + sync evaluation cache. Pair with @feathq/openfeature-web for OpenFeature.",
5
5
  "keywords": [
6
6
  "feature-flags",