@feathq/web-sdk 0.1.0 → 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 +13 -5
- package/dist/index.cjs +133 -6
- package/dist/index.d.cts +6 -2
- package/dist/index.d.ts +6 -2
- package/dist/index.js +133 -6
- package/package.json +3 -8
package/README.md
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<a href="https://feat.so">
|
|
3
|
+
<img src="https://feat.so/logo/wordmark.png" alt="feat.so" width="320" />
|
|
4
|
+
</a>
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# feat Web SDK
|
|
2
10
|
|
|
3
11
|
Browser / client-side SDK for [feat](https://feat.so) feature flags. Polls a per-environment datafile to the browser and evaluates flags locally with a synchronous cache.
|
|
4
12
|
|
|
@@ -19,7 +27,7 @@ import { FeatWebClient } from "@feathq/web-sdk";
|
|
|
19
27
|
|
|
20
28
|
const client = new FeatWebClient({
|
|
21
29
|
apiKey: "feat_cs_…", // client-side ID key
|
|
22
|
-
|
|
30
|
+
url: "https://data-01.feat.so", // optional; this is the default
|
|
23
31
|
anonymous: { storage: "localStorage" }, // optional: auto-mint a stable anonymous user
|
|
24
32
|
cache: { storage: "localStorage" }, // optional: warm cache across page loads
|
|
25
33
|
});
|
|
@@ -54,7 +62,7 @@ import { OpenFeature } from "@openfeature/web-sdk";
|
|
|
54
62
|
import { FeatWebClient } from "@feathq/web-sdk";
|
|
55
63
|
import { FeatWebProvider } from "@feathq/openfeature-web";
|
|
56
64
|
|
|
57
|
-
const featClient = new FeatWebClient({ apiKey
|
|
65
|
+
const featClient = new FeatWebClient({ apiKey });
|
|
58
66
|
await OpenFeature.setProviderAndWait(new FeatWebProvider(featClient));
|
|
59
67
|
await OpenFeature.setContext({ targetingKey: "user-123" });
|
|
60
68
|
|
|
@@ -66,7 +74,7 @@ const enabled = OpenFeature.getClient().getBooleanValue("checkout-v2", false);
|
|
|
66
74
|
Fetch the datafile on the server and pass it through to the client to skip the first round trip:
|
|
67
75
|
|
|
68
76
|
```ts
|
|
69
|
-
new FeatWebClient({ apiKey,
|
|
77
|
+
new FeatWebClient({ apiKey, bootstrap: serverProvidedDatafile });
|
|
70
78
|
```
|
|
71
79
|
|
|
72
80
|
## How it works
|
|
@@ -75,7 +83,7 @@ new FeatWebClient({ apiKey, dataPlaneUrl, bootstrap: serverProvidedDatafile });
|
|
|
75
83
|
- Polls every 30 s by default; pauses while the tab is hidden and force-refreshes on visibility restore. Floored at 5 s.
|
|
76
84
|
- Cross-tab `BroadcastChannel` sync: when one tab fetches a new datafile, sibling tabs adopt it without their own network call.
|
|
77
85
|
- 304-aware via `ETag` / `If-None-Match`.
|
|
78
|
-
- `
|
|
86
|
+
- `url` must use `https://` if you override it (the constructor rejects plaintext URLs except `http://localhost` for tests).
|
|
79
87
|
|
|
80
88
|
## Security notes
|
|
81
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) {
|
|
@@ -161,11 +267,15 @@ function hasLocalStorage2() {
|
|
|
161
267
|
}
|
|
162
268
|
}
|
|
163
269
|
|
|
270
|
+
// src/version.ts
|
|
271
|
+
var SDK_VERSION = "0.2.0";
|
|
272
|
+
|
|
164
273
|
// src/client.ts
|
|
165
274
|
var CLIENT_SIDE_PREFIX = "feat_cs_";
|
|
166
275
|
var MIN_POLL_INTERVAL_MS = 5e3;
|
|
167
276
|
var DEFAULT_POLL_INTERVAL_MS = 3e4;
|
|
168
277
|
var MAX_DATAFILE_BYTES = 10 * 1024 * 1024;
|
|
278
|
+
var DEFAULT_URL = "https://data-01.feat.so";
|
|
169
279
|
var FeatWebClient = class {
|
|
170
280
|
constructor(config) {
|
|
171
281
|
this.config = config;
|
|
@@ -184,17 +294,29 @@ var FeatWebClient = class {
|
|
|
184
294
|
`FeatWebClient requires a client_side_id key (prefix "${CLIENT_SIDE_PREFIX}"). Server and mobile keys must never ship in browser code.`
|
|
185
295
|
);
|
|
186
296
|
}
|
|
187
|
-
|
|
297
|
+
this.url = config.url ?? DEFAULT_URL;
|
|
298
|
+
assertHttpsUrl(this.url);
|
|
188
299
|
this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
|
|
189
300
|
this.pollIntervalMs = Math.max(
|
|
190
301
|
config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
|
|
191
302
|
MIN_POLL_INTERVAL_MS
|
|
192
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
|
+
});
|
|
193
314
|
if (config.context) {
|
|
194
315
|
this.context = config.context;
|
|
195
316
|
} else if (config.anonymous) {
|
|
196
317
|
this.context = buildAnonymousContext(config.anonymous);
|
|
197
318
|
}
|
|
319
|
+
if (this.context) this.summarizer?.record(this.context);
|
|
198
320
|
if (config.bootstrap) {
|
|
199
321
|
this.datafile = config.bootstrap;
|
|
200
322
|
this.etag = config.bootstrap.etag;
|
|
@@ -224,6 +346,7 @@ var FeatWebClient = class {
|
|
|
224
346
|
// OpenFeature's `onContextChange` lifecycle hook bridges to this.
|
|
225
347
|
async setContext(context) {
|
|
226
348
|
this.context = context;
|
|
349
|
+
this.summarizer?.record(context);
|
|
227
350
|
await this.recomputeCache();
|
|
228
351
|
}
|
|
229
352
|
currentContext() {
|
|
@@ -290,6 +413,7 @@ var FeatWebClient = class {
|
|
|
290
413
|
}
|
|
291
414
|
this.broadcast?.close();
|
|
292
415
|
this.broadcast = null;
|
|
416
|
+
this.summarizer?.close();
|
|
293
417
|
this.emitter.removeAll();
|
|
294
418
|
}
|
|
295
419
|
async bootstrap() {
|
|
@@ -299,6 +423,7 @@ var FeatWebClient = class {
|
|
|
299
423
|
if (this.closed) return;
|
|
300
424
|
this.startPolling();
|
|
301
425
|
this.attachVisibilityHandler();
|
|
426
|
+
this.summarizer?.start();
|
|
302
427
|
this.emitter.emit("ready", void 0);
|
|
303
428
|
} catch (err) {
|
|
304
429
|
this.emitter.emit("failed", err instanceof Error ? err : new Error(String(err)));
|
|
@@ -331,9 +456,11 @@ var FeatWebClient = class {
|
|
|
331
456
|
document.addEventListener("visibilitychange", this.visibilityHandler);
|
|
332
457
|
}
|
|
333
458
|
async fetchDatafile() {
|
|
334
|
-
const url = `${this.
|
|
459
|
+
const url = `${this.url.replace(/\/$/, "")}/sdk/v1/datafile`;
|
|
335
460
|
const headers = {
|
|
336
|
-
Authorization: `Bearer ${this.config.apiKey}
|
|
461
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
462
|
+
// Custom header because browsers forbid setting User-Agent on fetch.
|
|
463
|
+
"X-Feat-Sdk": `web/${SDK_VERSION}`
|
|
337
464
|
};
|
|
338
465
|
if (this.etag) headers["If-None-Match"] = this.etag;
|
|
339
466
|
const res = await this.fetchImpl(url, { method: "GET", headers });
|
|
@@ -431,11 +558,11 @@ function assertHttpsUrl(url) {
|
|
|
431
558
|
}
|
|
432
559
|
} catch {
|
|
433
560
|
}
|
|
434
|
-
throw new Error("
|
|
561
|
+
throw new Error("url must use https:// (http://localhost allowed for tests)");
|
|
435
562
|
}
|
|
436
563
|
|
|
437
564
|
// src/index.ts
|
|
438
|
-
var
|
|
565
|
+
var SDK_VERSION2 = "0.2.0";
|
|
439
566
|
|
|
440
567
|
exports.FeatWebClient = FeatWebClient;
|
|
441
|
-
exports.SDK_VERSION =
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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) {
|
|
@@ -159,11 +265,15 @@ function hasLocalStorage2() {
|
|
|
159
265
|
}
|
|
160
266
|
}
|
|
161
267
|
|
|
268
|
+
// src/version.ts
|
|
269
|
+
var SDK_VERSION = "0.2.0";
|
|
270
|
+
|
|
162
271
|
// src/client.ts
|
|
163
272
|
var CLIENT_SIDE_PREFIX = "feat_cs_";
|
|
164
273
|
var MIN_POLL_INTERVAL_MS = 5e3;
|
|
165
274
|
var DEFAULT_POLL_INTERVAL_MS = 3e4;
|
|
166
275
|
var MAX_DATAFILE_BYTES = 10 * 1024 * 1024;
|
|
276
|
+
var DEFAULT_URL = "https://data-01.feat.so";
|
|
167
277
|
var FeatWebClient = class {
|
|
168
278
|
constructor(config) {
|
|
169
279
|
this.config = config;
|
|
@@ -182,17 +292,29 @@ var FeatWebClient = class {
|
|
|
182
292
|
`FeatWebClient requires a client_side_id key (prefix "${CLIENT_SIDE_PREFIX}"). Server and mobile keys must never ship in browser code.`
|
|
183
293
|
);
|
|
184
294
|
}
|
|
185
|
-
|
|
295
|
+
this.url = config.url ?? DEFAULT_URL;
|
|
296
|
+
assertHttpsUrl(this.url);
|
|
186
297
|
this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
|
|
187
298
|
this.pollIntervalMs = Math.max(
|
|
188
299
|
config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
|
|
189
300
|
MIN_POLL_INTERVAL_MS
|
|
190
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
|
+
});
|
|
191
312
|
if (config.context) {
|
|
192
313
|
this.context = config.context;
|
|
193
314
|
} else if (config.anonymous) {
|
|
194
315
|
this.context = buildAnonymousContext(config.anonymous);
|
|
195
316
|
}
|
|
317
|
+
if (this.context) this.summarizer?.record(this.context);
|
|
196
318
|
if (config.bootstrap) {
|
|
197
319
|
this.datafile = config.bootstrap;
|
|
198
320
|
this.etag = config.bootstrap.etag;
|
|
@@ -222,6 +344,7 @@ var FeatWebClient = class {
|
|
|
222
344
|
// OpenFeature's `onContextChange` lifecycle hook bridges to this.
|
|
223
345
|
async setContext(context) {
|
|
224
346
|
this.context = context;
|
|
347
|
+
this.summarizer?.record(context);
|
|
225
348
|
await this.recomputeCache();
|
|
226
349
|
}
|
|
227
350
|
currentContext() {
|
|
@@ -288,6 +411,7 @@ var FeatWebClient = class {
|
|
|
288
411
|
}
|
|
289
412
|
this.broadcast?.close();
|
|
290
413
|
this.broadcast = null;
|
|
414
|
+
this.summarizer?.close();
|
|
291
415
|
this.emitter.removeAll();
|
|
292
416
|
}
|
|
293
417
|
async bootstrap() {
|
|
@@ -297,6 +421,7 @@ var FeatWebClient = class {
|
|
|
297
421
|
if (this.closed) return;
|
|
298
422
|
this.startPolling();
|
|
299
423
|
this.attachVisibilityHandler();
|
|
424
|
+
this.summarizer?.start();
|
|
300
425
|
this.emitter.emit("ready", void 0);
|
|
301
426
|
} catch (err) {
|
|
302
427
|
this.emitter.emit("failed", err instanceof Error ? err : new Error(String(err)));
|
|
@@ -329,9 +454,11 @@ var FeatWebClient = class {
|
|
|
329
454
|
document.addEventListener("visibilitychange", this.visibilityHandler);
|
|
330
455
|
}
|
|
331
456
|
async fetchDatafile() {
|
|
332
|
-
const url = `${this.
|
|
457
|
+
const url = `${this.url.replace(/\/$/, "")}/sdk/v1/datafile`;
|
|
333
458
|
const headers = {
|
|
334
|
-
Authorization: `Bearer ${this.config.apiKey}
|
|
459
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
460
|
+
// Custom header because browsers forbid setting User-Agent on fetch.
|
|
461
|
+
"X-Feat-Sdk": `web/${SDK_VERSION}`
|
|
335
462
|
};
|
|
336
463
|
if (this.etag) headers["If-None-Match"] = this.etag;
|
|
337
464
|
const res = await this.fetchImpl(url, { method: "GET", headers });
|
|
@@ -429,10 +556,10 @@ function assertHttpsUrl(url) {
|
|
|
429
556
|
}
|
|
430
557
|
} catch {
|
|
431
558
|
}
|
|
432
|
-
throw new Error("
|
|
559
|
+
throw new Error("url must use https:// (http://localhost allowed for tests)");
|
|
433
560
|
}
|
|
434
561
|
|
|
435
562
|
// src/index.ts
|
|
436
|
-
var
|
|
563
|
+
var SDK_VERSION2 = "0.2.0";
|
|
437
564
|
|
|
438
|
-
export { FeatWebClient, SDK_VERSION };
|
|
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.
|
|
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",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"client-side"
|
|
12
12
|
],
|
|
13
13
|
"license": "MIT",
|
|
14
|
-
"author": "feat HQ <
|
|
14
|
+
"author": "feat HQ <support@feat.so>",
|
|
15
15
|
"homepage": "https://feat.so",
|
|
16
16
|
"repository": {
|
|
17
17
|
"type": "git",
|
|
@@ -48,11 +48,6 @@
|
|
|
48
48
|
"@feathq/datafile-schema": "^0.1.0",
|
|
49
49
|
"@feathq/feat-eval": "^0.1.0"
|
|
50
50
|
},
|
|
51
|
-
"resolutions": {
|
|
52
|
-
"@feathq/datafile-schema": "portal:../../packages/datafile-schema",
|
|
53
|
-
"@feathq/datafile-schema@workspace:*": "portal:../../packages/datafile-schema",
|
|
54
|
-
"@feathq/feat-eval": "portal:../../packages/feat-eval"
|
|
55
|
-
},
|
|
56
51
|
"devDependencies": {
|
|
57
52
|
"@types/node": "^25.6.2",
|
|
58
53
|
"happy-dom": "^15.11.7",
|
|
@@ -60,4 +55,4 @@
|
|
|
60
55
|
"typescript": "^6.0.3",
|
|
61
56
|
"vitest": "^4.1.6"
|
|
62
57
|
}
|
|
63
|
-
}
|
|
58
|
+
}
|