@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 +14 -0
- package/dist/index.cjs +199 -6
- package/dist/index.d.cts +21 -2
- package/dist/index.d.ts +21 -2
- package/dist/index.js +199 -6
- package/package.json +1 -1
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.
|
|
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.
|
|
488
|
-
//
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
486
|
-
//
|
|
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.
|
|
756
|
+
var SDK_VERSION2 = "0.4.0";
|
|
564
757
|
|
|
565
758
|
export { FeatWebClient, SDK_VERSION2 as SDK_VERSION };
|
package/package.json
CHANGED