@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 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
- dataPlaneUrl: "https://data.feat.so",
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, dataPlaneUrl });
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, dataPlaneUrl, bootstrap: serverProvidedDatafile });
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
- - `dataPlaneUrl` must use `https://` (the constructor rejects plaintext URLs except `http://localhost` for tests).
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.1.1";
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
- assertHttpsUrl(config.dataPlaneUrl);
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.config.dataPlaneUrl.replace(/\/$/, "")}/sdk/v1/datafile`;
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. Only adopt if the broadcast carries a newer
366
- // version (or we have nothing); old broadcasts can race with our own
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("dataPlaneUrl must use https:// (http://localhost allowed for tests)");
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.1.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
- dataPlaneUrl: string;
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.1.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
- dataPlaneUrl: string;
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.1.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.1.1";
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
- assertHttpsUrl(config.dataPlaneUrl);
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.config.dataPlaneUrl.replace(/\/$/, "")}/sdk/v1/datafile`;
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. Only adopt if the broadcast carries a newer
364
- // version (or we have nothing); old broadcasts can race with our own
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("dataPlaneUrl must use https:// (http://localhost allowed for tests)");
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.1.0";
686
+ var SDK_VERSION2 = "0.3.0";
442
687
 
443
688
  export { FeatWebClient, SDK_VERSION2 as SDK_VERSION };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feathq/web-sdk",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "feat feature-flag SDK for browsers. Polling client + sync evaluation cache. Pair with @feathq/openfeature-web for OpenFeature.",
5
5
  "keywords": [
6
6
  "feature-flags",