@feathq/web-sdk 0.1.1 → 0.2.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
  });
@@ -62,7 +62,7 @@ import { OpenFeature } from "@openfeature/web-sdk";
62
62
  import { FeatWebClient } from "@feathq/web-sdk";
63
63
  import { FeatWebProvider } from "@feathq/openfeature-web";
64
64
 
65
- const featClient = new FeatWebClient({ apiKey, dataPlaneUrl });
65
+ const featClient = new FeatWebClient({ apiKey });
66
66
  await OpenFeature.setProviderAndWait(new FeatWebProvider(featClient));
67
67
  await OpenFeature.setContext({ targetingKey: "user-123" });
68
68
 
@@ -74,7 +74,7 @@ const enabled = OpenFeature.getClient().getBooleanValue("checkout-v2", false);
74
74
  Fetch the datafile on the server and pass it through to the client to skip the first round trip:
75
75
 
76
76
  ```ts
77
- new FeatWebClient({ apiKey, dataPlaneUrl, bootstrap: serverProvidedDatafile });
77
+ new FeatWebClient({ apiKey, bootstrap: serverProvidedDatafile });
78
78
  ```
79
79
 
80
80
  ## How it works
@@ -83,7 +83,7 @@ new FeatWebClient({ apiKey, dataPlaneUrl, bootstrap: serverProvidedDatafile });
83
83
  - Polls every 30 s by default; pauses while the tab is hidden and force-refreshes on visibility restore. Floored at 5 s.
84
84
  - Cross-tab `BroadcastChannel` sync: when one tab fetches a new datafile, sibling tabs adopt it without their own network call.
85
85
  - 304-aware via `ETag` / `If-None-Match`.
86
- - `dataPlaneUrl` must use `https://` (the constructor rejects plaintext URLs except `http://localhost` for tests).
86
+ - `url` must use `https://` if you override it (the constructor rejects plaintext URLs except `http://localhost` for tests).
87
87
 
88
88
  ## Security notes
89
89
 
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) {
@@ -162,13 +268,14 @@ function hasLocalStorage2() {
162
268
  }
163
269
 
164
270
  // src/version.ts
165
- var SDK_VERSION = "0.1.1";
271
+ var SDK_VERSION = "0.2.0";
166
272
 
167
273
  // src/client.ts
168
274
  var CLIENT_SIDE_PREFIX = "feat_cs_";
169
275
  var MIN_POLL_INTERVAL_MS = 5e3;
170
276
  var DEFAULT_POLL_INTERVAL_MS = 3e4;
171
277
  var MAX_DATAFILE_BYTES = 10 * 1024 * 1024;
278
+ var DEFAULT_URL = "https://data-01.feat.so";
172
279
  var FeatWebClient = class {
173
280
  constructor(config) {
174
281
  this.config = config;
@@ -187,17 +294,29 @@ var FeatWebClient = class {
187
294
  `FeatWebClient requires a client_side_id key (prefix "${CLIENT_SIDE_PREFIX}"). Server and mobile keys must never ship in browser code.`
188
295
  );
189
296
  }
190
- assertHttpsUrl(config.dataPlaneUrl);
297
+ this.url = config.url ?? DEFAULT_URL;
298
+ assertHttpsUrl(this.url);
191
299
  this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
192
300
  this.pollIntervalMs = Math.max(
193
301
  config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
194
302
  MIN_POLL_INTERVAL_MS
195
303
  );
304
+ this.summarizer = config.events === false ? null : new EventSummarizer({
305
+ url: this.url,
306
+ apiKey: config.apiKey,
307
+ fetchImpl: this.fetchImpl,
308
+ sdkHeader: `web/${SDK_VERSION}`,
309
+ flushIntervalMs: Math.max(
310
+ config.eventsFlushIntervalMs ?? DEFAULT_EVENTS_FLUSH_INTERVAL_MS,
311
+ MIN_EVENTS_FLUSH_INTERVAL_MS
312
+ )
313
+ });
196
314
  if (config.context) {
197
315
  this.context = config.context;
198
316
  } else if (config.anonymous) {
199
317
  this.context = buildAnonymousContext(config.anonymous);
200
318
  }
319
+ if (this.context) this.summarizer?.record(this.context);
201
320
  if (config.bootstrap) {
202
321
  this.datafile = config.bootstrap;
203
322
  this.etag = config.bootstrap.etag;
@@ -227,6 +346,7 @@ var FeatWebClient = class {
227
346
  // OpenFeature's `onContextChange` lifecycle hook bridges to this.
228
347
  async setContext(context) {
229
348
  this.context = context;
349
+ this.summarizer?.record(context);
230
350
  await this.recomputeCache();
231
351
  }
232
352
  currentContext() {
@@ -293,6 +413,7 @@ var FeatWebClient = class {
293
413
  }
294
414
  this.broadcast?.close();
295
415
  this.broadcast = null;
416
+ this.summarizer?.close();
296
417
  this.emitter.removeAll();
297
418
  }
298
419
  async bootstrap() {
@@ -302,6 +423,7 @@ var FeatWebClient = class {
302
423
  if (this.closed) return;
303
424
  this.startPolling();
304
425
  this.attachVisibilityHandler();
426
+ this.summarizer?.start();
305
427
  this.emitter.emit("ready", void 0);
306
428
  } catch (err) {
307
429
  this.emitter.emit("failed", err instanceof Error ? err : new Error(String(err)));
@@ -334,7 +456,7 @@ var FeatWebClient = class {
334
456
  document.addEventListener("visibilitychange", this.visibilityHandler);
335
457
  }
336
458
  async fetchDatafile() {
337
- const url = `${this.config.dataPlaneUrl.replace(/\/$/, "")}/sdk/v1/datafile`;
459
+ const url = `${this.url.replace(/\/$/, "")}/sdk/v1/datafile`;
338
460
  const headers = {
339
461
  Authorization: `Bearer ${this.config.apiKey}`,
340
462
  // Custom header because browsers forbid setting User-Agent on fetch.
@@ -436,11 +558,11 @@ function assertHttpsUrl(url) {
436
558
  }
437
559
  } catch {
438
560
  }
439
- throw new Error("dataPlaneUrl must use https:// (http://localhost allowed for tests)");
561
+ throw new Error("url must use https:// (http://localhost allowed for tests)");
440
562
  }
441
563
 
442
564
  // src/index.ts
443
- var SDK_VERSION2 = "0.1.0";
565
+ var SDK_VERSION2 = "0.2.0";
444
566
 
445
567
  exports.FeatWebClient = FeatWebClient;
446
568
  exports.SDK_VERSION = SDK_VERSION2;
package/dist/index.d.cts CHANGED
@@ -15,13 +15,15 @@ interface DatafileCacheConfig {
15
15
 
16
16
  interface FeatWebClientConfig {
17
17
  apiKey: string;
18
- dataPlaneUrl: string;
18
+ url?: string;
19
19
  context?: EvalContext;
20
20
  anonymous?: AnonymousConfig;
21
21
  bootstrap?: Datafile;
22
22
  cache?: DatafileCacheConfig;
23
23
  pollIntervalMs?: number;
24
24
  crossTabSync?: boolean;
25
+ events?: boolean;
26
+ eventsFlushIntervalMs?: number;
25
27
  fetch?: typeof fetch;
26
28
  }
27
29
  interface ChangeEvent {
@@ -49,8 +51,10 @@ declare class FeatWebClient {
49
51
  private visibilityHandler;
50
52
  private emitter;
51
53
  private broadcast;
54
+ private readonly summarizer;
52
55
  private readonly fetchImpl;
53
56
  private readonly pollIntervalMs;
57
+ private readonly url;
54
58
  private closed;
55
59
  constructor(config: FeatWebClientConfig);
56
60
  ready(): Promise<void>;
@@ -76,6 +80,6 @@ declare class FeatWebClient {
76
80
  private recomputeCache;
77
81
  }
78
82
 
79
- declare const SDK_VERSION = "0.1.0";
83
+ declare const SDK_VERSION = "0.2.0";
80
84
 
81
85
  export { type ChangeEvent, FeatWebClient, type FeatWebClientConfig, type FlagEventMap, SDK_VERSION };
package/dist/index.d.ts CHANGED
@@ -15,13 +15,15 @@ interface DatafileCacheConfig {
15
15
 
16
16
  interface FeatWebClientConfig {
17
17
  apiKey: string;
18
- dataPlaneUrl: string;
18
+ url?: string;
19
19
  context?: EvalContext;
20
20
  anonymous?: AnonymousConfig;
21
21
  bootstrap?: Datafile;
22
22
  cache?: DatafileCacheConfig;
23
23
  pollIntervalMs?: number;
24
24
  crossTabSync?: boolean;
25
+ events?: boolean;
26
+ eventsFlushIntervalMs?: number;
25
27
  fetch?: typeof fetch;
26
28
  }
27
29
  interface ChangeEvent {
@@ -49,8 +51,10 @@ declare class FeatWebClient {
49
51
  private visibilityHandler;
50
52
  private emitter;
51
53
  private broadcast;
54
+ private readonly summarizer;
52
55
  private readonly fetchImpl;
53
56
  private readonly pollIntervalMs;
57
+ private readonly url;
54
58
  private closed;
55
59
  constructor(config: FeatWebClientConfig);
56
60
  ready(): Promise<void>;
@@ -76,6 +80,6 @@ declare class FeatWebClient {
76
80
  private recomputeCache;
77
81
  }
78
82
 
79
- declare const SDK_VERSION = "0.1.0";
83
+ declare const SDK_VERSION = "0.2.0";
80
84
 
81
85
  export { type ChangeEvent, 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) {
@@ -160,13 +266,14 @@ function hasLocalStorage2() {
160
266
  }
161
267
 
162
268
  // src/version.ts
163
- var SDK_VERSION = "0.1.1";
269
+ var SDK_VERSION = "0.2.0";
164
270
 
165
271
  // src/client.ts
166
272
  var CLIENT_SIDE_PREFIX = "feat_cs_";
167
273
  var MIN_POLL_INTERVAL_MS = 5e3;
168
274
  var DEFAULT_POLL_INTERVAL_MS = 3e4;
169
275
  var MAX_DATAFILE_BYTES = 10 * 1024 * 1024;
276
+ var DEFAULT_URL = "https://data-01.feat.so";
170
277
  var FeatWebClient = class {
171
278
  constructor(config) {
172
279
  this.config = config;
@@ -185,17 +292,29 @@ var FeatWebClient = class {
185
292
  `FeatWebClient requires a client_side_id key (prefix "${CLIENT_SIDE_PREFIX}"). Server and mobile keys must never ship in browser code.`
186
293
  );
187
294
  }
188
- assertHttpsUrl(config.dataPlaneUrl);
295
+ this.url = config.url ?? DEFAULT_URL;
296
+ assertHttpsUrl(this.url);
189
297
  this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
190
298
  this.pollIntervalMs = Math.max(
191
299
  config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
192
300
  MIN_POLL_INTERVAL_MS
193
301
  );
302
+ this.summarizer = config.events === false ? null : new EventSummarizer({
303
+ url: this.url,
304
+ apiKey: config.apiKey,
305
+ fetchImpl: this.fetchImpl,
306
+ sdkHeader: `web/${SDK_VERSION}`,
307
+ flushIntervalMs: Math.max(
308
+ config.eventsFlushIntervalMs ?? DEFAULT_EVENTS_FLUSH_INTERVAL_MS,
309
+ MIN_EVENTS_FLUSH_INTERVAL_MS
310
+ )
311
+ });
194
312
  if (config.context) {
195
313
  this.context = config.context;
196
314
  } else if (config.anonymous) {
197
315
  this.context = buildAnonymousContext(config.anonymous);
198
316
  }
317
+ if (this.context) this.summarizer?.record(this.context);
199
318
  if (config.bootstrap) {
200
319
  this.datafile = config.bootstrap;
201
320
  this.etag = config.bootstrap.etag;
@@ -225,6 +344,7 @@ var FeatWebClient = class {
225
344
  // OpenFeature's `onContextChange` lifecycle hook bridges to this.
226
345
  async setContext(context) {
227
346
  this.context = context;
347
+ this.summarizer?.record(context);
228
348
  await this.recomputeCache();
229
349
  }
230
350
  currentContext() {
@@ -291,6 +411,7 @@ var FeatWebClient = class {
291
411
  }
292
412
  this.broadcast?.close();
293
413
  this.broadcast = null;
414
+ this.summarizer?.close();
294
415
  this.emitter.removeAll();
295
416
  }
296
417
  async bootstrap() {
@@ -300,6 +421,7 @@ var FeatWebClient = class {
300
421
  if (this.closed) return;
301
422
  this.startPolling();
302
423
  this.attachVisibilityHandler();
424
+ this.summarizer?.start();
303
425
  this.emitter.emit("ready", void 0);
304
426
  } catch (err) {
305
427
  this.emitter.emit("failed", err instanceof Error ? err : new Error(String(err)));
@@ -332,7 +454,7 @@ var FeatWebClient = class {
332
454
  document.addEventListener("visibilitychange", this.visibilityHandler);
333
455
  }
334
456
  async fetchDatafile() {
335
- const url = `${this.config.dataPlaneUrl.replace(/\/$/, "")}/sdk/v1/datafile`;
457
+ const url = `${this.url.replace(/\/$/, "")}/sdk/v1/datafile`;
336
458
  const headers = {
337
459
  Authorization: `Bearer ${this.config.apiKey}`,
338
460
  // Custom header because browsers forbid setting User-Agent on fetch.
@@ -434,10 +556,10 @@ function assertHttpsUrl(url) {
434
556
  }
435
557
  } catch {
436
558
  }
437
- throw new Error("dataPlaneUrl must use https:// (http://localhost allowed for tests)");
559
+ throw new Error("url must use https:// (http://localhost allowed for tests)");
438
560
  }
439
561
 
440
562
  // src/index.ts
441
- var SDK_VERSION2 = "0.1.0";
563
+ var SDK_VERSION2 = "0.2.0";
442
564
 
443
565
  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.2.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",