@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 +14 -0
- package/dist/index.cjs +129 -6
- package/dist/index.d.cts +20 -2
- package/dist/index.d.ts +20 -2
- package/dist/index.js +129 -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,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.
|
|
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.
|
|
488
|
-
//
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
486
|
-
//
|
|
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.
|
|
686
|
+
var SDK_VERSION2 = "0.3.0";
|
|
564
687
|
|
|
565
688
|
export { FeatWebClient, SDK_VERSION2 as SDK_VERSION };
|
package/package.json
CHANGED