@feathq/web-sdk 0.2.0 → 0.4.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,87 @@ 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("patch", (ev) => {
291
+ const patch = parsePatch(ev.data);
292
+ if (patch) this.options.onPatch(patch);
293
+ });
294
+ source.addEventListener("error", () => {
295
+ if (!this.warned) {
296
+ this.warned = true;
297
+ console.warn("feat: datafile stream error; falling back to polling");
298
+ }
299
+ if (this.source && this.source.readyState === EVENT_SOURCE_CLOSED) {
300
+ this.close();
301
+ }
302
+ });
303
+ }
304
+ close() {
305
+ this.source?.close();
306
+ this.source = null;
307
+ }
308
+ };
309
+ function parsePut(data) {
310
+ if (typeof data !== "string") return null;
311
+ try {
312
+ const parsed = JSON.parse(data);
313
+ if (parsed && typeof parsed === "object" && typeof parsed.version === "number") {
314
+ return parsed;
315
+ }
316
+ } catch {
317
+ }
318
+ return null;
319
+ }
320
+ function parsePatch(data) {
321
+ if (typeof data !== "string") return null;
322
+ try {
323
+ const p = JSON.parse(data);
324
+ if (!p || typeof p !== "object") return null;
325
+ if (!Number.isInteger(p.from) || !Number.isInteger(p.to)) return null;
326
+ if (p.to <= p.from) return null;
327
+ if (typeof p.etag !== "string" || typeof p.generatedAt !== "string") return null;
328
+ return {
329
+ from: p.from,
330
+ to: p.to,
331
+ etag: p.etag,
332
+ generatedAt: p.generatedAt,
333
+ flags: isRecord(p.flags) ? p.flags : {},
334
+ removedFlags: isStringArray(p.removedFlags) ? p.removedFlags : [],
335
+ segments: isRecord(p.segments) ? p.segments : {},
336
+ removedSegments: isStringArray(p.removedSegments) ? p.removedSegments : []
337
+ };
338
+ } catch {
339
+ }
340
+ return null;
341
+ }
342
+ function isRecord(v) {
343
+ return typeof v === "object" && v !== null && !Array.isArray(v);
344
+ }
345
+ function isStringArray(v) {
346
+ return Array.isArray(v) && v.every((x) => typeof x === "string");
347
+ }
348
+
270
349
  // src/version.ts
271
- var SDK_VERSION = "0.2.0";
350
+ var SDK_VERSION = "0.4.0";
272
351
 
273
352
  // src/client.ts
274
353
  var CLIENT_SIDE_PREFIX = "feat_cs_";
@@ -288,6 +367,14 @@ var FeatWebClient = class {
288
367
  this.visibilityHandler = null;
289
368
  this.emitter = new Emitter();
290
369
  this.broadcast = null;
370
+ this.stream = null;
371
+ // The set of live `change` listeners. The streaming-follows-subscription
372
+ // policy keys off whether this is non-empty; tracking the listeners (rather
373
+ // than a counter) keeps the refcount correct whether the caller unsubscribes
374
+ // via the returned disposer or via off().
375
+ this.changeListeners = /* @__PURE__ */ new Set();
376
+ this.started = false;
377
+ this.warnedNoEventSource = false;
291
378
  this.closed = false;
292
379
  if (!config.apiKey.startsWith(CLIENT_SIDE_PREFIX)) {
293
380
  throw new Error(
@@ -297,6 +384,7 @@ var FeatWebClient = class {
297
384
  this.url = config.url ?? DEFAULT_URL;
298
385
  assertHttpsUrl(this.url);
299
386
  this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
387
+ this.eventSourceCtor = config.eventSource ?? (typeof globalThis.EventSource !== "undefined" ? globalThis.EventSource : void 0);
300
388
  this.pollIntervalMs = Math.max(
301
389
  config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
302
390
  MIN_POLL_INTERVAL_MS
@@ -389,10 +477,17 @@ var FeatWebClient = class {
389
477
  return new Map(this.cache);
390
478
  }
391
479
  on(event, listener) {
392
- return this.emitter.on(event, listener);
480
+ if (event !== "change") return this.emitter.on(event, listener);
481
+ this.emitter.on(event, listener);
482
+ this.changeListeners.add(listener);
483
+ this.maybeUpdateStream();
484
+ return () => this.off(event, listener);
393
485
  }
394
486
  off(event, listener) {
395
487
  this.emitter.off(event, listener);
488
+ if (event === "change" && this.changeListeners.delete(listener)) {
489
+ this.maybeUpdateStream();
490
+ }
396
491
  }
397
492
  // Force a one-shot fetch. Returns true if the in-memory datafile changed.
398
493
  async refresh() {
@@ -407,6 +502,9 @@ var FeatWebClient = class {
407
502
  clearInterval(this.timer);
408
503
  this.timer = null;
409
504
  }
505
+ this.stream?.close();
506
+ this.stream = null;
507
+ this.changeListeners.clear();
410
508
  if (this.visibilityHandler && typeof document !== "undefined") {
411
509
  document.removeEventListener("visibilitychange", this.visibilityHandler);
412
510
  this.visibilityHandler = null;
@@ -424,6 +522,8 @@ var FeatWebClient = class {
424
522
  this.startPolling();
425
523
  this.attachVisibilityHandler();
426
524
  this.summarizer?.start();
525
+ this.started = true;
526
+ this.maybeUpdateStream();
427
527
  this.emitter.emit("ready", void 0);
428
528
  } catch (err) {
429
529
  this.emitter.emit("failed", err instanceof Error ? err : new Error(String(err)));
@@ -484,18 +584,111 @@ var FeatWebClient = class {
484
584
  await this.recomputeCache();
485
585
  return true;
486
586
  }
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.
587
+ // Sibling-tab handler. Adopt without republishing: this is the receive end
588
+ // of a BroadcastChannel message, so echoing it back would loop.
490
589
  async adoptFromBroadcast(datafile, etag) {
590
+ await this.adoptDatafile(datafile, etag, false);
591
+ }
592
+ // Version-guarded adopt for datafiles that arrive outside the fetch path
593
+ // (sibling-tab broadcast, SSE `put`). Only adopt a strictly newer version so
594
+ // an old broadcast or out-of-order frame can't regress us; the same guard
595
+ // stops a republished put from looping back through a sibling tab. When
596
+ // `publish` is set we mirror the fetch path and rebroadcast so sibling tabs
597
+ // stay fresh without their own network call.
598
+ async adoptDatafile(datafile, etag, publish) {
599
+ if (this.closed) return;
491
600
  if (this.datafile && datafile.version <= this.datafile.version) return;
492
601
  this.datafile = datafile;
493
602
  this.etag = etag;
494
603
  if (this.config.cache) {
495
604
  saveCachedDatafile(this.config.cache, { datafile, etag });
496
605
  }
606
+ if (publish) this.broadcast?.publish(datafile, etag);
497
607
  await this.recomputeCache();
498
608
  }
609
+ // Apply an incremental SSE `patch`. Version-gated and atomic: a patch is only
610
+ // adopted when its `from` exactly matches the version we currently hold, so a
611
+ // missed intermediate patch (a gap) or a duplicate is ignored rather than
612
+ // applied out of order. On a match we merge the changed flags/segments, drop
613
+ // the removed keys, and advance version/etag/generatedAt to the patch's `to`,
614
+ // then hand the rebuilt datafile to adoptDatafile - the single version-ordered
615
+ // adopt path - so the cache recompute, etag write, cache save, and sibling-tab
616
+ // broadcast all go through the same code as a full `put` (no drift, no HTTP
617
+ // refetch). adoptDatafile's `version <= held` guard is satisfied because the
618
+ // wire invariant `to > from` holds and `from` equals the held version.
619
+ async applyPatch(patch) {
620
+ if (this.closed) return;
621
+ const current = this.datafile;
622
+ if (!current || current.version !== patch.from) return;
623
+ const flags = { ...current.flags, ...patch.flags };
624
+ for (const key of patch.removedFlags) delete flags[key];
625
+ const segments = { ...current.segments, ...patch.segments };
626
+ for (const key of patch.removedSegments) delete segments[key];
627
+ const next = {
628
+ ...current,
629
+ flags,
630
+ segments,
631
+ version: patch.to,
632
+ etag: patch.etag,
633
+ generatedAt: patch.generatedAt
634
+ };
635
+ return this.adoptDatafile(next, patch.etag, true);
636
+ }
637
+ // Reconcile the SSE connection with the current streaming policy. Called
638
+ // whenever an input to that policy changes: ready, a `change` (un)subscribe,
639
+ // or close.
640
+ maybeUpdateStream() {
641
+ if (this.closed) return;
642
+ const want = this.wantsStream();
643
+ if (want) {
644
+ this.openStream();
645
+ } else if (this.stream) {
646
+ this.stream.close();
647
+ this.stream = null;
648
+ }
649
+ }
650
+ wantsStream() {
651
+ if (this.config.streaming === false) return false;
652
+ if (this.config.streaming === true) return this.started;
653
+ return this.changeListeners.size > 0;
654
+ }
655
+ openStream() {
656
+ if (!this.eventSourceCtor) {
657
+ if (!this.warnedNoEventSource) {
658
+ this.warnedNoEventSource = true;
659
+ console.warn("feat: streaming requested but EventSource is unavailable; using polling");
660
+ }
661
+ return;
662
+ }
663
+ if (!this.stream) {
664
+ this.stream = new DatafileStream({
665
+ url: this.url,
666
+ apiKey: this.config.apiKey,
667
+ eventSourceCtor: this.eventSourceCtor,
668
+ // A `put` carries the full datafile; reuse the version-ordered adopt
669
+ // path so a duplicate or out-of-order frame is ignored and the cache
670
+ // recomputes (firing `change`) only on a strictly newer version. Like
671
+ // the fetch path, a stream-adopted put republishes on the
672
+ // BroadcastChannel so sibling tabs stay fresh without their own fetch.
673
+ onPut: (datafile) => {
674
+ void this.adoptDatafile(datafile, datafile.etag, true).catch((err) => {
675
+ console.warn("feat: streamed datafile update failed:", messageOf(err));
676
+ });
677
+ },
678
+ // A `patch` carries an incremental delta. Apply it only when it builds
679
+ // on the version we currently hold; a pure version gap is ignored. The
680
+ // SSE connection stays healthy in that case and does NOT reconnect or
681
+ // re-seed, so recovery comes solely from the safety-net poll. Reconnect
682
+ // re-seeding (a fresh full `put`) only happens on an actual stream drop.
683
+ onPatch: (patch) => {
684
+ void this.applyPatch(patch).catch((err) => {
685
+ console.warn("feat: streamed datafile patch failed:", messageOf(err));
686
+ });
687
+ }
688
+ });
689
+ }
690
+ this.stream.open();
691
+ }
499
692
  // Pre-evaluate every flag in the datafile against the current context
500
693
  // and diff against the previous cache. Skips silently if datafile or
501
694
  // context is missing; the caller will recompute as soon as both land.
@@ -562,7 +755,7 @@ function assertHttpsUrl(url) {
562
755
  }
563
756
 
564
757
  // src/index.ts
565
- var SDK_VERSION2 = "0.2.0";
758
+ var SDK_VERSION2 = "0.4.0";
566
759
 
567
760
  exports.FeatWebClient = FeatWebClient;
568
761
  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,14 @@ declare class FeatWebClient {
77
91
  private attachVisibilityHandler;
78
92
  private fetchDatafile;
79
93
  private adoptFromBroadcast;
94
+ private adoptDatafile;
95
+ private applyPatch;
96
+ private maybeUpdateStream;
97
+ private wantsStream;
98
+ private openStream;
80
99
  private recomputeCache;
81
100
  }
82
101
 
83
- declare const SDK_VERSION = "0.2.0";
102
+ declare const SDK_VERSION = "0.4.0";
84
103
 
85
- export { type ChangeEvent, FeatWebClient, type FeatWebClientConfig, type FlagEventMap, SDK_VERSION };
104
+ 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,14 @@ declare class FeatWebClient {
77
91
  private attachVisibilityHandler;
78
92
  private fetchDatafile;
79
93
  private adoptFromBroadcast;
94
+ private adoptDatafile;
95
+ private applyPatch;
96
+ private maybeUpdateStream;
97
+ private wantsStream;
98
+ private openStream;
80
99
  private recomputeCache;
81
100
  }
82
101
 
83
- declare const SDK_VERSION = "0.2.0";
102
+ declare const SDK_VERSION = "0.4.0";
84
103
 
85
- export { type ChangeEvent, FeatWebClient, type FeatWebClientConfig, type FlagEventMap, SDK_VERSION };
104
+ export { type ChangeEvent, type EventSourceConstructor, FeatWebClient, type FeatWebClientConfig, type FlagEventMap, SDK_VERSION };
package/dist/index.js CHANGED
@@ -265,8 +265,87 @@ 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("patch", (ev) => {
289
+ const patch = parsePatch(ev.data);
290
+ if (patch) this.options.onPatch(patch);
291
+ });
292
+ source.addEventListener("error", () => {
293
+ if (!this.warned) {
294
+ this.warned = true;
295
+ console.warn("feat: datafile stream error; falling back to polling");
296
+ }
297
+ if (this.source && this.source.readyState === EVENT_SOURCE_CLOSED) {
298
+ this.close();
299
+ }
300
+ });
301
+ }
302
+ close() {
303
+ this.source?.close();
304
+ this.source = null;
305
+ }
306
+ };
307
+ function parsePut(data) {
308
+ if (typeof data !== "string") return null;
309
+ try {
310
+ const parsed = JSON.parse(data);
311
+ if (parsed && typeof parsed === "object" && typeof parsed.version === "number") {
312
+ return parsed;
313
+ }
314
+ } catch {
315
+ }
316
+ return null;
317
+ }
318
+ function parsePatch(data) {
319
+ if (typeof data !== "string") return null;
320
+ try {
321
+ const p = JSON.parse(data);
322
+ if (!p || typeof p !== "object") return null;
323
+ if (!Number.isInteger(p.from) || !Number.isInteger(p.to)) return null;
324
+ if (p.to <= p.from) return null;
325
+ if (typeof p.etag !== "string" || typeof p.generatedAt !== "string") return null;
326
+ return {
327
+ from: p.from,
328
+ to: p.to,
329
+ etag: p.etag,
330
+ generatedAt: p.generatedAt,
331
+ flags: isRecord(p.flags) ? p.flags : {},
332
+ removedFlags: isStringArray(p.removedFlags) ? p.removedFlags : [],
333
+ segments: isRecord(p.segments) ? p.segments : {},
334
+ removedSegments: isStringArray(p.removedSegments) ? p.removedSegments : []
335
+ };
336
+ } catch {
337
+ }
338
+ return null;
339
+ }
340
+ function isRecord(v) {
341
+ return typeof v === "object" && v !== null && !Array.isArray(v);
342
+ }
343
+ function isStringArray(v) {
344
+ return Array.isArray(v) && v.every((x) => typeof x === "string");
345
+ }
346
+
268
347
  // src/version.ts
269
- var SDK_VERSION = "0.2.0";
348
+ var SDK_VERSION = "0.4.0";
270
349
 
271
350
  // src/client.ts
272
351
  var CLIENT_SIDE_PREFIX = "feat_cs_";
@@ -286,6 +365,14 @@ var FeatWebClient = class {
286
365
  this.visibilityHandler = null;
287
366
  this.emitter = new Emitter();
288
367
  this.broadcast = null;
368
+ this.stream = null;
369
+ // The set of live `change` listeners. The streaming-follows-subscription
370
+ // policy keys off whether this is non-empty; tracking the listeners (rather
371
+ // than a counter) keeps the refcount correct whether the caller unsubscribes
372
+ // via the returned disposer or via off().
373
+ this.changeListeners = /* @__PURE__ */ new Set();
374
+ this.started = false;
375
+ this.warnedNoEventSource = false;
289
376
  this.closed = false;
290
377
  if (!config.apiKey.startsWith(CLIENT_SIDE_PREFIX)) {
291
378
  throw new Error(
@@ -295,6 +382,7 @@ var FeatWebClient = class {
295
382
  this.url = config.url ?? DEFAULT_URL;
296
383
  assertHttpsUrl(this.url);
297
384
  this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
385
+ this.eventSourceCtor = config.eventSource ?? (typeof globalThis.EventSource !== "undefined" ? globalThis.EventSource : void 0);
298
386
  this.pollIntervalMs = Math.max(
299
387
  config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
300
388
  MIN_POLL_INTERVAL_MS
@@ -387,10 +475,17 @@ var FeatWebClient = class {
387
475
  return new Map(this.cache);
388
476
  }
389
477
  on(event, listener) {
390
- return this.emitter.on(event, listener);
478
+ if (event !== "change") return this.emitter.on(event, listener);
479
+ this.emitter.on(event, listener);
480
+ this.changeListeners.add(listener);
481
+ this.maybeUpdateStream();
482
+ return () => this.off(event, listener);
391
483
  }
392
484
  off(event, listener) {
393
485
  this.emitter.off(event, listener);
486
+ if (event === "change" && this.changeListeners.delete(listener)) {
487
+ this.maybeUpdateStream();
488
+ }
394
489
  }
395
490
  // Force a one-shot fetch. Returns true if the in-memory datafile changed.
396
491
  async refresh() {
@@ -405,6 +500,9 @@ var FeatWebClient = class {
405
500
  clearInterval(this.timer);
406
501
  this.timer = null;
407
502
  }
503
+ this.stream?.close();
504
+ this.stream = null;
505
+ this.changeListeners.clear();
408
506
  if (this.visibilityHandler && typeof document !== "undefined") {
409
507
  document.removeEventListener("visibilitychange", this.visibilityHandler);
410
508
  this.visibilityHandler = null;
@@ -422,6 +520,8 @@ var FeatWebClient = class {
422
520
  this.startPolling();
423
521
  this.attachVisibilityHandler();
424
522
  this.summarizer?.start();
523
+ this.started = true;
524
+ this.maybeUpdateStream();
425
525
  this.emitter.emit("ready", void 0);
426
526
  } catch (err) {
427
527
  this.emitter.emit("failed", err instanceof Error ? err : new Error(String(err)));
@@ -482,18 +582,111 @@ var FeatWebClient = class {
482
582
  await this.recomputeCache();
483
583
  return true;
484
584
  }
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.
585
+ // Sibling-tab handler. Adopt without republishing: this is the receive end
586
+ // of a BroadcastChannel message, so echoing it back would loop.
488
587
  async adoptFromBroadcast(datafile, etag) {
588
+ await this.adoptDatafile(datafile, etag, false);
589
+ }
590
+ // Version-guarded adopt for datafiles that arrive outside the fetch path
591
+ // (sibling-tab broadcast, SSE `put`). Only adopt a strictly newer version so
592
+ // an old broadcast or out-of-order frame can't regress us; the same guard
593
+ // stops a republished put from looping back through a sibling tab. When
594
+ // `publish` is set we mirror the fetch path and rebroadcast so sibling tabs
595
+ // stay fresh without their own network call.
596
+ async adoptDatafile(datafile, etag, publish) {
597
+ if (this.closed) return;
489
598
  if (this.datafile && datafile.version <= this.datafile.version) return;
490
599
  this.datafile = datafile;
491
600
  this.etag = etag;
492
601
  if (this.config.cache) {
493
602
  saveCachedDatafile(this.config.cache, { datafile, etag });
494
603
  }
604
+ if (publish) this.broadcast?.publish(datafile, etag);
495
605
  await this.recomputeCache();
496
606
  }
607
+ // Apply an incremental SSE `patch`. Version-gated and atomic: a patch is only
608
+ // adopted when its `from` exactly matches the version we currently hold, so a
609
+ // missed intermediate patch (a gap) or a duplicate is ignored rather than
610
+ // applied out of order. On a match we merge the changed flags/segments, drop
611
+ // the removed keys, and advance version/etag/generatedAt to the patch's `to`,
612
+ // then hand the rebuilt datafile to adoptDatafile - the single version-ordered
613
+ // adopt path - so the cache recompute, etag write, cache save, and sibling-tab
614
+ // broadcast all go through the same code as a full `put` (no drift, no HTTP
615
+ // refetch). adoptDatafile's `version <= held` guard is satisfied because the
616
+ // wire invariant `to > from` holds and `from` equals the held version.
617
+ async applyPatch(patch) {
618
+ if (this.closed) return;
619
+ const current = this.datafile;
620
+ if (!current || current.version !== patch.from) return;
621
+ const flags = { ...current.flags, ...patch.flags };
622
+ for (const key of patch.removedFlags) delete flags[key];
623
+ const segments = { ...current.segments, ...patch.segments };
624
+ for (const key of patch.removedSegments) delete segments[key];
625
+ const next = {
626
+ ...current,
627
+ flags,
628
+ segments,
629
+ version: patch.to,
630
+ etag: patch.etag,
631
+ generatedAt: patch.generatedAt
632
+ };
633
+ return this.adoptDatafile(next, patch.etag, true);
634
+ }
635
+ // Reconcile the SSE connection with the current streaming policy. Called
636
+ // whenever an input to that policy changes: ready, a `change` (un)subscribe,
637
+ // or close.
638
+ maybeUpdateStream() {
639
+ if (this.closed) return;
640
+ const want = this.wantsStream();
641
+ if (want) {
642
+ this.openStream();
643
+ } else if (this.stream) {
644
+ this.stream.close();
645
+ this.stream = null;
646
+ }
647
+ }
648
+ wantsStream() {
649
+ if (this.config.streaming === false) return false;
650
+ if (this.config.streaming === true) return this.started;
651
+ return this.changeListeners.size > 0;
652
+ }
653
+ openStream() {
654
+ if (!this.eventSourceCtor) {
655
+ if (!this.warnedNoEventSource) {
656
+ this.warnedNoEventSource = true;
657
+ console.warn("feat: streaming requested but EventSource is unavailable; using polling");
658
+ }
659
+ return;
660
+ }
661
+ if (!this.stream) {
662
+ this.stream = new DatafileStream({
663
+ url: this.url,
664
+ apiKey: this.config.apiKey,
665
+ eventSourceCtor: this.eventSourceCtor,
666
+ // A `put` carries the full datafile; reuse the version-ordered adopt
667
+ // path so a duplicate or out-of-order frame is ignored and the cache
668
+ // recomputes (firing `change`) only on a strictly newer version. Like
669
+ // the fetch path, a stream-adopted put republishes on the
670
+ // BroadcastChannel so sibling tabs stay fresh without their own fetch.
671
+ onPut: (datafile) => {
672
+ void this.adoptDatafile(datafile, datafile.etag, true).catch((err) => {
673
+ console.warn("feat: streamed datafile update failed:", messageOf(err));
674
+ });
675
+ },
676
+ // A `patch` carries an incremental delta. Apply it only when it builds
677
+ // on the version we currently hold; a pure version gap is ignored. The
678
+ // SSE connection stays healthy in that case and does NOT reconnect or
679
+ // re-seed, so recovery comes solely from the safety-net poll. Reconnect
680
+ // re-seeding (a fresh full `put`) only happens on an actual stream drop.
681
+ onPatch: (patch) => {
682
+ void this.applyPatch(patch).catch((err) => {
683
+ console.warn("feat: streamed datafile patch failed:", messageOf(err));
684
+ });
685
+ }
686
+ });
687
+ }
688
+ this.stream.open();
689
+ }
497
690
  // Pre-evaluate every flag in the datafile against the current context
498
691
  // and diff against the previous cache. Skips silently if datafile or
499
692
  // context is missing; the caller will recompute as soon as both land.
@@ -560,6 +753,6 @@ function assertHttpsUrl(url) {
560
753
  }
561
754
 
562
755
  // src/index.ts
563
- var SDK_VERSION2 = "0.2.0";
756
+ var SDK_VERSION2 = "0.4.0";
564
757
 
565
758
  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.4.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",