@feathq/web-sdk 0.3.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/dist/index.cjs CHANGED
@@ -287,6 +287,10 @@ var DatafileStream = class {
287
287
  const datafile = parsePut(ev.data);
288
288
  if (datafile) this.options.onPut(datafile);
289
289
  });
290
+ source.addEventListener("patch", (ev) => {
291
+ const patch = parsePatch(ev.data);
292
+ if (patch) this.options.onPatch(patch);
293
+ });
290
294
  source.addEventListener("error", () => {
291
295
  if (!this.warned) {
292
296
  this.warned = true;
@@ -313,9 +317,37 @@ function parsePut(data) {
313
317
  }
314
318
  return null;
315
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
+ }
316
348
 
317
349
  // src/version.ts
318
- var SDK_VERSION = "0.3.0";
350
+ var SDK_VERSION = "0.4.0";
319
351
 
320
352
  // src/client.ts
321
353
  var CLIENT_SIDE_PREFIX = "feat_cs_";
@@ -574,6 +606,34 @@ var FeatWebClient = class {
574
606
  if (publish) this.broadcast?.publish(datafile, etag);
575
607
  await this.recomputeCache();
576
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
+ }
577
637
  // Reconcile the SSE connection with the current streaming policy. Called
578
638
  // whenever an input to that policy changes: ready, a `change` (un)subscribe,
579
639
  // or close.
@@ -614,6 +674,16 @@ var FeatWebClient = class {
614
674
  void this.adoptDatafile(datafile, datafile.etag, true).catch((err) => {
615
675
  console.warn("feat: streamed datafile update failed:", messageOf(err));
616
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
+ });
617
687
  }
618
688
  });
619
689
  }
@@ -685,7 +755,7 @@ function assertHttpsUrl(url) {
685
755
  }
686
756
 
687
757
  // src/index.ts
688
- var SDK_VERSION2 = "0.3.0";
758
+ var SDK_VERSION2 = "0.4.0";
689
759
 
690
760
  exports.FeatWebClient = FeatWebClient;
691
761
  exports.SDK_VERSION = SDK_VERSION2;
package/dist/index.d.cts CHANGED
@@ -92,12 +92,13 @@ declare class FeatWebClient {
92
92
  private fetchDatafile;
93
93
  private adoptFromBroadcast;
94
94
  private adoptDatafile;
95
+ private applyPatch;
95
96
  private maybeUpdateStream;
96
97
  private wantsStream;
97
98
  private openStream;
98
99
  private recomputeCache;
99
100
  }
100
101
 
101
- declare const SDK_VERSION = "0.3.0";
102
+ declare const SDK_VERSION = "0.4.0";
102
103
 
103
104
  export { type ChangeEvent, type EventSourceConstructor, FeatWebClient, type FeatWebClientConfig, type FlagEventMap, SDK_VERSION };
package/dist/index.d.ts CHANGED
@@ -92,12 +92,13 @@ declare class FeatWebClient {
92
92
  private fetchDatafile;
93
93
  private adoptFromBroadcast;
94
94
  private adoptDatafile;
95
+ private applyPatch;
95
96
  private maybeUpdateStream;
96
97
  private wantsStream;
97
98
  private openStream;
98
99
  private recomputeCache;
99
100
  }
100
101
 
101
- declare const SDK_VERSION = "0.3.0";
102
+ declare const SDK_VERSION = "0.4.0";
102
103
 
103
104
  export { type ChangeEvent, type EventSourceConstructor, FeatWebClient, type FeatWebClientConfig, type FlagEventMap, SDK_VERSION };
package/dist/index.js CHANGED
@@ -285,6 +285,10 @@ var DatafileStream = class {
285
285
  const datafile = parsePut(ev.data);
286
286
  if (datafile) this.options.onPut(datafile);
287
287
  });
288
+ source.addEventListener("patch", (ev) => {
289
+ const patch = parsePatch(ev.data);
290
+ if (patch) this.options.onPatch(patch);
291
+ });
288
292
  source.addEventListener("error", () => {
289
293
  if (!this.warned) {
290
294
  this.warned = true;
@@ -311,9 +315,37 @@ function parsePut(data) {
311
315
  }
312
316
  return null;
313
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
+ }
314
346
 
315
347
  // src/version.ts
316
- var SDK_VERSION = "0.3.0";
348
+ var SDK_VERSION = "0.4.0";
317
349
 
318
350
  // src/client.ts
319
351
  var CLIENT_SIDE_PREFIX = "feat_cs_";
@@ -572,6 +604,34 @@ var FeatWebClient = class {
572
604
  if (publish) this.broadcast?.publish(datafile, etag);
573
605
  await this.recomputeCache();
574
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
+ }
575
635
  // Reconcile the SSE connection with the current streaming policy. Called
576
636
  // whenever an input to that policy changes: ready, a `change` (un)subscribe,
577
637
  // or close.
@@ -612,6 +672,16 @@ var FeatWebClient = class {
612
672
  void this.adoptDatafile(datafile, datafile.etag, true).catch((err) => {
613
673
  console.warn("feat: streamed datafile update failed:", messageOf(err));
614
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
+ });
615
685
  }
616
686
  });
617
687
  }
@@ -683,6 +753,6 @@ function assertHttpsUrl(url) {
683
753
  }
684
754
 
685
755
  // src/index.ts
686
- var SDK_VERSION2 = "0.3.0";
756
+ var SDK_VERSION2 = "0.4.0";
687
757
 
688
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.3.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",