@feathq/web-sdk 0.1.1 → 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 +18 -4
- package/dist/index.cjs +254 -9
- package/dist/index.d.cts +25 -3
- package/dist/index.d.ts +25 -3
- package/dist/index.js +254 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -27,7 +27,7 @@ import { FeatWebClient } from "@feathq/web-sdk";
|
|
|
27
27
|
|
|
28
28
|
const client = new FeatWebClient({
|
|
29
29
|
apiKey: "feat_cs_…", // client-side ID key
|
|
30
|
-
|
|
30
|
+
url: "https://data-01.feat.so", // optional; this is the default
|
|
31
31
|
anonymous: { storage: "localStorage" }, // optional: auto-mint a stable anonymous user
|
|
32
32
|
cache: { storage: "localStorage" }, // optional: warm cache across page loads
|
|
33
33
|
});
|
|
@@ -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
|
|
@@ -62,7 +75,7 @@ import { OpenFeature } from "@openfeature/web-sdk";
|
|
|
62
75
|
import { FeatWebClient } from "@feathq/web-sdk";
|
|
63
76
|
import { FeatWebProvider } from "@feathq/openfeature-web";
|
|
64
77
|
|
|
65
|
-
const featClient = new FeatWebClient({ apiKey
|
|
78
|
+
const featClient = new FeatWebClient({ apiKey });
|
|
66
79
|
await OpenFeature.setProviderAndWait(new FeatWebProvider(featClient));
|
|
67
80
|
await OpenFeature.setContext({ targetingKey: "user-123" });
|
|
68
81
|
|
|
@@ -74,16 +87,17 @@ const enabled = OpenFeature.getClient().getBooleanValue("checkout-v2", false);
|
|
|
74
87
|
Fetch the datafile on the server and pass it through to the client to skip the first round trip:
|
|
75
88
|
|
|
76
89
|
```ts
|
|
77
|
-
new FeatWebClient({ apiKey,
|
|
90
|
+
new FeatWebClient({ apiKey, bootstrap: serverProvidedDatafile });
|
|
78
91
|
```
|
|
79
92
|
|
|
80
93
|
## How it works
|
|
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).
|
|
87
101
|
|
|
88
102
|
## Security notes
|
|
89
103
|
|
package/dist/index.cjs
CHANGED
|
@@ -130,6 +130,112 @@ var Emitter = class {
|
|
|
130
130
|
}
|
|
131
131
|
};
|
|
132
132
|
|
|
133
|
+
// src/events.ts
|
|
134
|
+
var DEFAULT_EVENTS_FLUSH_INTERVAL_MS = 6e4;
|
|
135
|
+
var MIN_EVENTS_FLUSH_INTERVAL_MS = 5e3;
|
|
136
|
+
var MAX_BATCH = 2e3;
|
|
137
|
+
var MAX_BUFFERED_PAIRS = 1e5;
|
|
138
|
+
function extractContextPairs(context) {
|
|
139
|
+
const out = [];
|
|
140
|
+
const seen = /* @__PURE__ */ new Set();
|
|
141
|
+
const push = (kind, key) => {
|
|
142
|
+
const id = `${kind} ${key}`;
|
|
143
|
+
if (seen.has(id)) return;
|
|
144
|
+
seen.add(id);
|
|
145
|
+
out.push({ kind, key });
|
|
146
|
+
};
|
|
147
|
+
const userObj = context.user;
|
|
148
|
+
if (userObj && typeof userObj === "object" && typeof userObj.key === "string") {
|
|
149
|
+
push("user", userObj.key);
|
|
150
|
+
} else if (typeof context.targetingKey === "string") {
|
|
151
|
+
push("user", context.targetingKey);
|
|
152
|
+
}
|
|
153
|
+
for (const [kind, value] of Object.entries(context)) {
|
|
154
|
+
if (kind === "targetingKey" || kind === "user") continue;
|
|
155
|
+
if (value && typeof value === "object" && typeof value.key === "string") {
|
|
156
|
+
push(kind, value.key);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
var EventSummarizer = class {
|
|
162
|
+
constructor(opts) {
|
|
163
|
+
this.opts = opts;
|
|
164
|
+
this.pending = /* @__PURE__ */ new Map();
|
|
165
|
+
this.timer = null;
|
|
166
|
+
this.inFlight = false;
|
|
167
|
+
this.visibilityHandler = null;
|
|
168
|
+
this.endpoint = `${opts.url.replace(/\/$/, "")}/sdk/v1/events`;
|
|
169
|
+
}
|
|
170
|
+
// Synchronous, never throws: safe to call inline from setContext().
|
|
171
|
+
record(context) {
|
|
172
|
+
for (const pair of extractContextPairs(context)) {
|
|
173
|
+
const id = `${pair.kind} ${pair.key}`;
|
|
174
|
+
if (this.pending.has(id)) continue;
|
|
175
|
+
if (this.pending.size >= MAX_BUFFERED_PAIRS) return;
|
|
176
|
+
this.pending.set(id, pair);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
start() {
|
|
180
|
+
if (this.timer) return;
|
|
181
|
+
this.timer = setInterval(() => {
|
|
182
|
+
void this.flush();
|
|
183
|
+
}, this.opts.flushIntervalMs);
|
|
184
|
+
if (typeof document !== "undefined") {
|
|
185
|
+
this.visibilityHandler = () => {
|
|
186
|
+
if (document.visibilityState === "hidden") void this.flush(true);
|
|
187
|
+
};
|
|
188
|
+
document.addEventListener("visibilitychange", this.visibilityHandler);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
async flush(keepalive = false) {
|
|
192
|
+
if (this.inFlight || this.pending.size === 0) return;
|
|
193
|
+
const batch = [...this.pending.values()].slice(0, MAX_BATCH);
|
|
194
|
+
for (const c of batch) this.pending.delete(`${c.kind} ${c.key}`);
|
|
195
|
+
this.inFlight = true;
|
|
196
|
+
try {
|
|
197
|
+
const res = await this.opts.fetchImpl(this.endpoint, {
|
|
198
|
+
method: "POST",
|
|
199
|
+
headers: {
|
|
200
|
+
Authorization: `Bearer ${this.opts.apiKey}`,
|
|
201
|
+
"Content-Type": "application/json",
|
|
202
|
+
"X-Feat-Sdk": this.opts.sdkHeader
|
|
203
|
+
},
|
|
204
|
+
body: JSON.stringify({ contexts: batch }),
|
|
205
|
+
keepalive
|
|
206
|
+
});
|
|
207
|
+
if (!res.ok && (res.status >= 500 || res.status === 429)) {
|
|
208
|
+
this.requeue(batch);
|
|
209
|
+
}
|
|
210
|
+
} catch {
|
|
211
|
+
this.requeue(batch);
|
|
212
|
+
} finally {
|
|
213
|
+
this.inFlight = false;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
// Detach the page-hide hook, stop the timer, and make a best-effort final
|
|
217
|
+
// flush. Fire-and-forget: the flush is not awaited so close() stays sync.
|
|
218
|
+
close() {
|
|
219
|
+
if (this.timer) {
|
|
220
|
+
clearInterval(this.timer);
|
|
221
|
+
this.timer = null;
|
|
222
|
+
}
|
|
223
|
+
if (this.visibilityHandler && typeof document !== "undefined") {
|
|
224
|
+
document.removeEventListener("visibilitychange", this.visibilityHandler);
|
|
225
|
+
this.visibilityHandler = null;
|
|
226
|
+
}
|
|
227
|
+
void this.flush(true);
|
|
228
|
+
}
|
|
229
|
+
requeue(batch) {
|
|
230
|
+
for (const c of batch) {
|
|
231
|
+
const id = `${c.kind} ${c.key}`;
|
|
232
|
+
if (this.pending.has(id)) continue;
|
|
233
|
+
if (this.pending.size >= MAX_BUFFERED_PAIRS) return;
|
|
234
|
+
this.pending.set(id, c);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
|
|
133
239
|
// src/persistence.ts
|
|
134
240
|
var STORAGE_KEY2 = "feat:datafile";
|
|
135
241
|
function loadCachedDatafile(config) {
|
|
@@ -161,14 +267,62 @@ function hasLocalStorage2() {
|
|
|
161
267
|
}
|
|
162
268
|
}
|
|
163
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
|
+
|
|
164
317
|
// src/version.ts
|
|
165
|
-
var SDK_VERSION = "0.
|
|
318
|
+
var SDK_VERSION = "0.3.0";
|
|
166
319
|
|
|
167
320
|
// src/client.ts
|
|
168
321
|
var CLIENT_SIDE_PREFIX = "feat_cs_";
|
|
169
322
|
var MIN_POLL_INTERVAL_MS = 5e3;
|
|
170
323
|
var DEFAULT_POLL_INTERVAL_MS = 3e4;
|
|
171
324
|
var MAX_DATAFILE_BYTES = 10 * 1024 * 1024;
|
|
325
|
+
var DEFAULT_URL = "https://data-01.feat.so";
|
|
172
326
|
var FeatWebClient = class {
|
|
173
327
|
constructor(config) {
|
|
174
328
|
this.config = config;
|
|
@@ -181,23 +335,44 @@ var FeatWebClient = class {
|
|
|
181
335
|
this.visibilityHandler = null;
|
|
182
336
|
this.emitter = new Emitter();
|
|
183
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;
|
|
184
346
|
this.closed = false;
|
|
185
347
|
if (!config.apiKey.startsWith(CLIENT_SIDE_PREFIX)) {
|
|
186
348
|
throw new Error(
|
|
187
349
|
`FeatWebClient requires a client_side_id key (prefix "${CLIENT_SIDE_PREFIX}"). Server and mobile keys must never ship in browser code.`
|
|
188
350
|
);
|
|
189
351
|
}
|
|
190
|
-
|
|
352
|
+
this.url = config.url ?? DEFAULT_URL;
|
|
353
|
+
assertHttpsUrl(this.url);
|
|
191
354
|
this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
|
|
355
|
+
this.eventSourceCtor = config.eventSource ?? (typeof globalThis.EventSource !== "undefined" ? globalThis.EventSource : void 0);
|
|
192
356
|
this.pollIntervalMs = Math.max(
|
|
193
357
|
config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
|
|
194
358
|
MIN_POLL_INTERVAL_MS
|
|
195
359
|
);
|
|
360
|
+
this.summarizer = config.events === false ? null : new EventSummarizer({
|
|
361
|
+
url: this.url,
|
|
362
|
+
apiKey: config.apiKey,
|
|
363
|
+
fetchImpl: this.fetchImpl,
|
|
364
|
+
sdkHeader: `web/${SDK_VERSION}`,
|
|
365
|
+
flushIntervalMs: Math.max(
|
|
366
|
+
config.eventsFlushIntervalMs ?? DEFAULT_EVENTS_FLUSH_INTERVAL_MS,
|
|
367
|
+
MIN_EVENTS_FLUSH_INTERVAL_MS
|
|
368
|
+
)
|
|
369
|
+
});
|
|
196
370
|
if (config.context) {
|
|
197
371
|
this.context = config.context;
|
|
198
372
|
} else if (config.anonymous) {
|
|
199
373
|
this.context = buildAnonymousContext(config.anonymous);
|
|
200
374
|
}
|
|
375
|
+
if (this.context) this.summarizer?.record(this.context);
|
|
201
376
|
if (config.bootstrap) {
|
|
202
377
|
this.datafile = config.bootstrap;
|
|
203
378
|
this.etag = config.bootstrap.etag;
|
|
@@ -227,6 +402,7 @@ var FeatWebClient = class {
|
|
|
227
402
|
// OpenFeature's `onContextChange` lifecycle hook bridges to this.
|
|
228
403
|
async setContext(context) {
|
|
229
404
|
this.context = context;
|
|
405
|
+
this.summarizer?.record(context);
|
|
230
406
|
await this.recomputeCache();
|
|
231
407
|
}
|
|
232
408
|
currentContext() {
|
|
@@ -269,10 +445,17 @@ var FeatWebClient = class {
|
|
|
269
445
|
return new Map(this.cache);
|
|
270
446
|
}
|
|
271
447
|
on(event, listener) {
|
|
272
|
-
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);
|
|
273
453
|
}
|
|
274
454
|
off(event, listener) {
|
|
275
455
|
this.emitter.off(event, listener);
|
|
456
|
+
if (event === "change" && this.changeListeners.delete(listener)) {
|
|
457
|
+
this.maybeUpdateStream();
|
|
458
|
+
}
|
|
276
459
|
}
|
|
277
460
|
// Force a one-shot fetch. Returns true if the in-memory datafile changed.
|
|
278
461
|
async refresh() {
|
|
@@ -287,12 +470,16 @@ var FeatWebClient = class {
|
|
|
287
470
|
clearInterval(this.timer);
|
|
288
471
|
this.timer = null;
|
|
289
472
|
}
|
|
473
|
+
this.stream?.close();
|
|
474
|
+
this.stream = null;
|
|
475
|
+
this.changeListeners.clear();
|
|
290
476
|
if (this.visibilityHandler && typeof document !== "undefined") {
|
|
291
477
|
document.removeEventListener("visibilitychange", this.visibilityHandler);
|
|
292
478
|
this.visibilityHandler = null;
|
|
293
479
|
}
|
|
294
480
|
this.broadcast?.close();
|
|
295
481
|
this.broadcast = null;
|
|
482
|
+
this.summarizer?.close();
|
|
296
483
|
this.emitter.removeAll();
|
|
297
484
|
}
|
|
298
485
|
async bootstrap() {
|
|
@@ -302,6 +489,9 @@ var FeatWebClient = class {
|
|
|
302
489
|
if (this.closed) return;
|
|
303
490
|
this.startPolling();
|
|
304
491
|
this.attachVisibilityHandler();
|
|
492
|
+
this.summarizer?.start();
|
|
493
|
+
this.started = true;
|
|
494
|
+
this.maybeUpdateStream();
|
|
305
495
|
this.emitter.emit("ready", void 0);
|
|
306
496
|
} catch (err) {
|
|
307
497
|
this.emitter.emit("failed", err instanceof Error ? err : new Error(String(err)));
|
|
@@ -334,7 +524,7 @@ var FeatWebClient = class {
|
|
|
334
524
|
document.addEventListener("visibilitychange", this.visibilityHandler);
|
|
335
525
|
}
|
|
336
526
|
async fetchDatafile() {
|
|
337
|
-
const url = `${this.
|
|
527
|
+
const url = `${this.url.replace(/\/$/, "")}/sdk/v1/datafile`;
|
|
338
528
|
const headers = {
|
|
339
529
|
Authorization: `Bearer ${this.config.apiKey}`,
|
|
340
530
|
// Custom header because browsers forbid setting User-Agent on fetch.
|
|
@@ -362,18 +552,73 @@ var FeatWebClient = class {
|
|
|
362
552
|
await this.recomputeCache();
|
|
363
553
|
return true;
|
|
364
554
|
}
|
|
365
|
-
// Sibling-tab handler.
|
|
366
|
-
//
|
|
367
|
-
// 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.
|
|
368
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;
|
|
369
568
|
if (this.datafile && datafile.version <= this.datafile.version) return;
|
|
370
569
|
this.datafile = datafile;
|
|
371
570
|
this.etag = etag;
|
|
372
571
|
if (this.config.cache) {
|
|
373
572
|
saveCachedDatafile(this.config.cache, { datafile, etag });
|
|
374
573
|
}
|
|
574
|
+
if (publish) this.broadcast?.publish(datafile, etag);
|
|
375
575
|
await this.recomputeCache();
|
|
376
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
|
+
}
|
|
377
622
|
// Pre-evaluate every flag in the datafile against the current context
|
|
378
623
|
// and diff against the previous cache. Skips silently if datafile or
|
|
379
624
|
// context is missing; the caller will recompute as soon as both land.
|
|
@@ -436,11 +681,11 @@ function assertHttpsUrl(url) {
|
|
|
436
681
|
}
|
|
437
682
|
} catch {
|
|
438
683
|
}
|
|
439
|
-
throw new Error("
|
|
684
|
+
throw new Error("url must use https:// (http://localhost allowed for tests)");
|
|
440
685
|
}
|
|
441
686
|
|
|
442
687
|
// src/index.ts
|
|
443
|
-
var SDK_VERSION2 = "0.
|
|
688
|
+
var SDK_VERSION2 = "0.3.0";
|
|
444
689
|
|
|
445
690
|
exports.FeatWebClient = FeatWebClient;
|
|
446
691
|
exports.SDK_VERSION = SDK_VERSION2;
|
package/dist/index.d.cts
CHANGED
|
@@ -13,16 +13,27 @@ 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;
|
|
19
26
|
context?: EvalContext;
|
|
20
27
|
anonymous?: AnonymousConfig;
|
|
21
28
|
bootstrap?: Datafile;
|
|
22
29
|
cache?: DatafileCacheConfig;
|
|
23
30
|
pollIntervalMs?: number;
|
|
24
31
|
crossTabSync?: boolean;
|
|
32
|
+
events?: boolean;
|
|
33
|
+
eventsFlushIntervalMs?: number;
|
|
25
34
|
fetch?: typeof fetch;
|
|
35
|
+
streaming?: boolean;
|
|
36
|
+
eventSource?: EventSourceConstructor;
|
|
26
37
|
}
|
|
27
38
|
interface ChangeEvent {
|
|
28
39
|
flagKey: string;
|
|
@@ -49,8 +60,15 @@ declare class FeatWebClient {
|
|
|
49
60
|
private visibilityHandler;
|
|
50
61
|
private emitter;
|
|
51
62
|
private broadcast;
|
|
63
|
+
private stream;
|
|
64
|
+
private changeListeners;
|
|
65
|
+
private started;
|
|
66
|
+
private warnedNoEventSource;
|
|
67
|
+
private readonly summarizer;
|
|
52
68
|
private readonly fetchImpl;
|
|
69
|
+
private readonly eventSourceCtor;
|
|
53
70
|
private readonly pollIntervalMs;
|
|
71
|
+
private readonly url;
|
|
54
72
|
private closed;
|
|
55
73
|
constructor(config: FeatWebClientConfig);
|
|
56
74
|
ready(): Promise<void>;
|
|
@@ -73,9 +91,13 @@ declare class FeatWebClient {
|
|
|
73
91
|
private attachVisibilityHandler;
|
|
74
92
|
private fetchDatafile;
|
|
75
93
|
private adoptFromBroadcast;
|
|
94
|
+
private adoptDatafile;
|
|
95
|
+
private maybeUpdateStream;
|
|
96
|
+
private wantsStream;
|
|
97
|
+
private openStream;
|
|
76
98
|
private recomputeCache;
|
|
77
99
|
}
|
|
78
100
|
|
|
79
|
-
declare const SDK_VERSION = "0.
|
|
101
|
+
declare const SDK_VERSION = "0.3.0";
|
|
80
102
|
|
|
81
|
-
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,16 +13,27 @@ 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;
|
|
19
26
|
context?: EvalContext;
|
|
20
27
|
anonymous?: AnonymousConfig;
|
|
21
28
|
bootstrap?: Datafile;
|
|
22
29
|
cache?: DatafileCacheConfig;
|
|
23
30
|
pollIntervalMs?: number;
|
|
24
31
|
crossTabSync?: boolean;
|
|
32
|
+
events?: boolean;
|
|
33
|
+
eventsFlushIntervalMs?: number;
|
|
25
34
|
fetch?: typeof fetch;
|
|
35
|
+
streaming?: boolean;
|
|
36
|
+
eventSource?: EventSourceConstructor;
|
|
26
37
|
}
|
|
27
38
|
interface ChangeEvent {
|
|
28
39
|
flagKey: string;
|
|
@@ -49,8 +60,15 @@ declare class FeatWebClient {
|
|
|
49
60
|
private visibilityHandler;
|
|
50
61
|
private emitter;
|
|
51
62
|
private broadcast;
|
|
63
|
+
private stream;
|
|
64
|
+
private changeListeners;
|
|
65
|
+
private started;
|
|
66
|
+
private warnedNoEventSource;
|
|
67
|
+
private readonly summarizer;
|
|
52
68
|
private readonly fetchImpl;
|
|
69
|
+
private readonly eventSourceCtor;
|
|
53
70
|
private readonly pollIntervalMs;
|
|
71
|
+
private readonly url;
|
|
54
72
|
private closed;
|
|
55
73
|
constructor(config: FeatWebClientConfig);
|
|
56
74
|
ready(): Promise<void>;
|
|
@@ -73,9 +91,13 @@ declare class FeatWebClient {
|
|
|
73
91
|
private attachVisibilityHandler;
|
|
74
92
|
private fetchDatafile;
|
|
75
93
|
private adoptFromBroadcast;
|
|
94
|
+
private adoptDatafile;
|
|
95
|
+
private maybeUpdateStream;
|
|
96
|
+
private wantsStream;
|
|
97
|
+
private openStream;
|
|
76
98
|
private recomputeCache;
|
|
77
99
|
}
|
|
78
100
|
|
|
79
|
-
declare const SDK_VERSION = "0.
|
|
101
|
+
declare const SDK_VERSION = "0.3.0";
|
|
80
102
|
|
|
81
|
-
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
|
@@ -128,6 +128,112 @@ var Emitter = class {
|
|
|
128
128
|
}
|
|
129
129
|
};
|
|
130
130
|
|
|
131
|
+
// src/events.ts
|
|
132
|
+
var DEFAULT_EVENTS_FLUSH_INTERVAL_MS = 6e4;
|
|
133
|
+
var MIN_EVENTS_FLUSH_INTERVAL_MS = 5e3;
|
|
134
|
+
var MAX_BATCH = 2e3;
|
|
135
|
+
var MAX_BUFFERED_PAIRS = 1e5;
|
|
136
|
+
function extractContextPairs(context) {
|
|
137
|
+
const out = [];
|
|
138
|
+
const seen = /* @__PURE__ */ new Set();
|
|
139
|
+
const push = (kind, key) => {
|
|
140
|
+
const id = `${kind} ${key}`;
|
|
141
|
+
if (seen.has(id)) return;
|
|
142
|
+
seen.add(id);
|
|
143
|
+
out.push({ kind, key });
|
|
144
|
+
};
|
|
145
|
+
const userObj = context.user;
|
|
146
|
+
if (userObj && typeof userObj === "object" && typeof userObj.key === "string") {
|
|
147
|
+
push("user", userObj.key);
|
|
148
|
+
} else if (typeof context.targetingKey === "string") {
|
|
149
|
+
push("user", context.targetingKey);
|
|
150
|
+
}
|
|
151
|
+
for (const [kind, value] of Object.entries(context)) {
|
|
152
|
+
if (kind === "targetingKey" || kind === "user") continue;
|
|
153
|
+
if (value && typeof value === "object" && typeof value.key === "string") {
|
|
154
|
+
push(kind, value.key);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
var EventSummarizer = class {
|
|
160
|
+
constructor(opts) {
|
|
161
|
+
this.opts = opts;
|
|
162
|
+
this.pending = /* @__PURE__ */ new Map();
|
|
163
|
+
this.timer = null;
|
|
164
|
+
this.inFlight = false;
|
|
165
|
+
this.visibilityHandler = null;
|
|
166
|
+
this.endpoint = `${opts.url.replace(/\/$/, "")}/sdk/v1/events`;
|
|
167
|
+
}
|
|
168
|
+
// Synchronous, never throws: safe to call inline from setContext().
|
|
169
|
+
record(context) {
|
|
170
|
+
for (const pair of extractContextPairs(context)) {
|
|
171
|
+
const id = `${pair.kind} ${pair.key}`;
|
|
172
|
+
if (this.pending.has(id)) continue;
|
|
173
|
+
if (this.pending.size >= MAX_BUFFERED_PAIRS) return;
|
|
174
|
+
this.pending.set(id, pair);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
start() {
|
|
178
|
+
if (this.timer) return;
|
|
179
|
+
this.timer = setInterval(() => {
|
|
180
|
+
void this.flush();
|
|
181
|
+
}, this.opts.flushIntervalMs);
|
|
182
|
+
if (typeof document !== "undefined") {
|
|
183
|
+
this.visibilityHandler = () => {
|
|
184
|
+
if (document.visibilityState === "hidden") void this.flush(true);
|
|
185
|
+
};
|
|
186
|
+
document.addEventListener("visibilitychange", this.visibilityHandler);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
async flush(keepalive = false) {
|
|
190
|
+
if (this.inFlight || this.pending.size === 0) return;
|
|
191
|
+
const batch = [...this.pending.values()].slice(0, MAX_BATCH);
|
|
192
|
+
for (const c of batch) this.pending.delete(`${c.kind} ${c.key}`);
|
|
193
|
+
this.inFlight = true;
|
|
194
|
+
try {
|
|
195
|
+
const res = await this.opts.fetchImpl(this.endpoint, {
|
|
196
|
+
method: "POST",
|
|
197
|
+
headers: {
|
|
198
|
+
Authorization: `Bearer ${this.opts.apiKey}`,
|
|
199
|
+
"Content-Type": "application/json",
|
|
200
|
+
"X-Feat-Sdk": this.opts.sdkHeader
|
|
201
|
+
},
|
|
202
|
+
body: JSON.stringify({ contexts: batch }),
|
|
203
|
+
keepalive
|
|
204
|
+
});
|
|
205
|
+
if (!res.ok && (res.status >= 500 || res.status === 429)) {
|
|
206
|
+
this.requeue(batch);
|
|
207
|
+
}
|
|
208
|
+
} catch {
|
|
209
|
+
this.requeue(batch);
|
|
210
|
+
} finally {
|
|
211
|
+
this.inFlight = false;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// Detach the page-hide hook, stop the timer, and make a best-effort final
|
|
215
|
+
// flush. Fire-and-forget: the flush is not awaited so close() stays sync.
|
|
216
|
+
close() {
|
|
217
|
+
if (this.timer) {
|
|
218
|
+
clearInterval(this.timer);
|
|
219
|
+
this.timer = null;
|
|
220
|
+
}
|
|
221
|
+
if (this.visibilityHandler && typeof document !== "undefined") {
|
|
222
|
+
document.removeEventListener("visibilitychange", this.visibilityHandler);
|
|
223
|
+
this.visibilityHandler = null;
|
|
224
|
+
}
|
|
225
|
+
void this.flush(true);
|
|
226
|
+
}
|
|
227
|
+
requeue(batch) {
|
|
228
|
+
for (const c of batch) {
|
|
229
|
+
const id = `${c.kind} ${c.key}`;
|
|
230
|
+
if (this.pending.has(id)) continue;
|
|
231
|
+
if (this.pending.size >= MAX_BUFFERED_PAIRS) return;
|
|
232
|
+
this.pending.set(id, c);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
131
237
|
// src/persistence.ts
|
|
132
238
|
var STORAGE_KEY2 = "feat:datafile";
|
|
133
239
|
function loadCachedDatafile(config) {
|
|
@@ -159,14 +265,62 @@ function hasLocalStorage2() {
|
|
|
159
265
|
}
|
|
160
266
|
}
|
|
161
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
|
+
|
|
162
315
|
// src/version.ts
|
|
163
|
-
var SDK_VERSION = "0.
|
|
316
|
+
var SDK_VERSION = "0.3.0";
|
|
164
317
|
|
|
165
318
|
// src/client.ts
|
|
166
319
|
var CLIENT_SIDE_PREFIX = "feat_cs_";
|
|
167
320
|
var MIN_POLL_INTERVAL_MS = 5e3;
|
|
168
321
|
var DEFAULT_POLL_INTERVAL_MS = 3e4;
|
|
169
322
|
var MAX_DATAFILE_BYTES = 10 * 1024 * 1024;
|
|
323
|
+
var DEFAULT_URL = "https://data-01.feat.so";
|
|
170
324
|
var FeatWebClient = class {
|
|
171
325
|
constructor(config) {
|
|
172
326
|
this.config = config;
|
|
@@ -179,23 +333,44 @@ var FeatWebClient = class {
|
|
|
179
333
|
this.visibilityHandler = null;
|
|
180
334
|
this.emitter = new Emitter();
|
|
181
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;
|
|
182
344
|
this.closed = false;
|
|
183
345
|
if (!config.apiKey.startsWith(CLIENT_SIDE_PREFIX)) {
|
|
184
346
|
throw new Error(
|
|
185
347
|
`FeatWebClient requires a client_side_id key (prefix "${CLIENT_SIDE_PREFIX}"). Server and mobile keys must never ship in browser code.`
|
|
186
348
|
);
|
|
187
349
|
}
|
|
188
|
-
|
|
350
|
+
this.url = config.url ?? DEFAULT_URL;
|
|
351
|
+
assertHttpsUrl(this.url);
|
|
189
352
|
this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
|
|
353
|
+
this.eventSourceCtor = config.eventSource ?? (typeof globalThis.EventSource !== "undefined" ? globalThis.EventSource : void 0);
|
|
190
354
|
this.pollIntervalMs = Math.max(
|
|
191
355
|
config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
|
|
192
356
|
MIN_POLL_INTERVAL_MS
|
|
193
357
|
);
|
|
358
|
+
this.summarizer = config.events === false ? null : new EventSummarizer({
|
|
359
|
+
url: this.url,
|
|
360
|
+
apiKey: config.apiKey,
|
|
361
|
+
fetchImpl: this.fetchImpl,
|
|
362
|
+
sdkHeader: `web/${SDK_VERSION}`,
|
|
363
|
+
flushIntervalMs: Math.max(
|
|
364
|
+
config.eventsFlushIntervalMs ?? DEFAULT_EVENTS_FLUSH_INTERVAL_MS,
|
|
365
|
+
MIN_EVENTS_FLUSH_INTERVAL_MS
|
|
366
|
+
)
|
|
367
|
+
});
|
|
194
368
|
if (config.context) {
|
|
195
369
|
this.context = config.context;
|
|
196
370
|
} else if (config.anonymous) {
|
|
197
371
|
this.context = buildAnonymousContext(config.anonymous);
|
|
198
372
|
}
|
|
373
|
+
if (this.context) this.summarizer?.record(this.context);
|
|
199
374
|
if (config.bootstrap) {
|
|
200
375
|
this.datafile = config.bootstrap;
|
|
201
376
|
this.etag = config.bootstrap.etag;
|
|
@@ -225,6 +400,7 @@ var FeatWebClient = class {
|
|
|
225
400
|
// OpenFeature's `onContextChange` lifecycle hook bridges to this.
|
|
226
401
|
async setContext(context) {
|
|
227
402
|
this.context = context;
|
|
403
|
+
this.summarizer?.record(context);
|
|
228
404
|
await this.recomputeCache();
|
|
229
405
|
}
|
|
230
406
|
currentContext() {
|
|
@@ -267,10 +443,17 @@ var FeatWebClient = class {
|
|
|
267
443
|
return new Map(this.cache);
|
|
268
444
|
}
|
|
269
445
|
on(event, listener) {
|
|
270
|
-
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);
|
|
271
451
|
}
|
|
272
452
|
off(event, listener) {
|
|
273
453
|
this.emitter.off(event, listener);
|
|
454
|
+
if (event === "change" && this.changeListeners.delete(listener)) {
|
|
455
|
+
this.maybeUpdateStream();
|
|
456
|
+
}
|
|
274
457
|
}
|
|
275
458
|
// Force a one-shot fetch. Returns true if the in-memory datafile changed.
|
|
276
459
|
async refresh() {
|
|
@@ -285,12 +468,16 @@ var FeatWebClient = class {
|
|
|
285
468
|
clearInterval(this.timer);
|
|
286
469
|
this.timer = null;
|
|
287
470
|
}
|
|
471
|
+
this.stream?.close();
|
|
472
|
+
this.stream = null;
|
|
473
|
+
this.changeListeners.clear();
|
|
288
474
|
if (this.visibilityHandler && typeof document !== "undefined") {
|
|
289
475
|
document.removeEventListener("visibilitychange", this.visibilityHandler);
|
|
290
476
|
this.visibilityHandler = null;
|
|
291
477
|
}
|
|
292
478
|
this.broadcast?.close();
|
|
293
479
|
this.broadcast = null;
|
|
480
|
+
this.summarizer?.close();
|
|
294
481
|
this.emitter.removeAll();
|
|
295
482
|
}
|
|
296
483
|
async bootstrap() {
|
|
@@ -300,6 +487,9 @@ var FeatWebClient = class {
|
|
|
300
487
|
if (this.closed) return;
|
|
301
488
|
this.startPolling();
|
|
302
489
|
this.attachVisibilityHandler();
|
|
490
|
+
this.summarizer?.start();
|
|
491
|
+
this.started = true;
|
|
492
|
+
this.maybeUpdateStream();
|
|
303
493
|
this.emitter.emit("ready", void 0);
|
|
304
494
|
} catch (err) {
|
|
305
495
|
this.emitter.emit("failed", err instanceof Error ? err : new Error(String(err)));
|
|
@@ -332,7 +522,7 @@ var FeatWebClient = class {
|
|
|
332
522
|
document.addEventListener("visibilitychange", this.visibilityHandler);
|
|
333
523
|
}
|
|
334
524
|
async fetchDatafile() {
|
|
335
|
-
const url = `${this.
|
|
525
|
+
const url = `${this.url.replace(/\/$/, "")}/sdk/v1/datafile`;
|
|
336
526
|
const headers = {
|
|
337
527
|
Authorization: `Bearer ${this.config.apiKey}`,
|
|
338
528
|
// Custom header because browsers forbid setting User-Agent on fetch.
|
|
@@ -360,18 +550,73 @@ var FeatWebClient = class {
|
|
|
360
550
|
await this.recomputeCache();
|
|
361
551
|
return true;
|
|
362
552
|
}
|
|
363
|
-
// Sibling-tab handler.
|
|
364
|
-
//
|
|
365
|
-
// 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.
|
|
366
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;
|
|
367
566
|
if (this.datafile && datafile.version <= this.datafile.version) return;
|
|
368
567
|
this.datafile = datafile;
|
|
369
568
|
this.etag = etag;
|
|
370
569
|
if (this.config.cache) {
|
|
371
570
|
saveCachedDatafile(this.config.cache, { datafile, etag });
|
|
372
571
|
}
|
|
572
|
+
if (publish) this.broadcast?.publish(datafile, etag);
|
|
373
573
|
await this.recomputeCache();
|
|
374
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
|
+
}
|
|
375
620
|
// Pre-evaluate every flag in the datafile against the current context
|
|
376
621
|
// and diff against the previous cache. Skips silently if datafile or
|
|
377
622
|
// context is missing; the caller will recompute as soon as both land.
|
|
@@ -434,10 +679,10 @@ function assertHttpsUrl(url) {
|
|
|
434
679
|
}
|
|
435
680
|
} catch {
|
|
436
681
|
}
|
|
437
|
-
throw new Error("
|
|
682
|
+
throw new Error("url must use https:// (http://localhost allowed for tests)");
|
|
438
683
|
}
|
|
439
684
|
|
|
440
685
|
// src/index.ts
|
|
441
|
-
var SDK_VERSION2 = "0.
|
|
686
|
+
var SDK_VERSION2 = "0.3.0";
|
|
442
687
|
|
|
443
688
|
export { FeatWebClient, SDK_VERSION2 as SDK_VERSION };
|
package/package.json
CHANGED