@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 +4 -4
- package/dist/index.cjs +127 -5
- package/dist/index.d.cts +6 -2
- package/dist/index.d.ts +6 -2
- package/dist/index.js +127 -5
- 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
|
});
|
|
@@ -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
|
|
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,
|
|
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
|
-
- `
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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("
|
|
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.
|
|
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
|
-
|
|
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) {
|
|
@@ -160,13 +266,14 @@ function hasLocalStorage2() {
|
|
|
160
266
|
}
|
|
161
267
|
|
|
162
268
|
// src/version.ts
|
|
163
|
-
var SDK_VERSION = "0.
|
|
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
|
-
|
|
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.
|
|
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("
|
|
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.
|
|
563
|
+
var SDK_VERSION2 = "0.2.0";
|
|
442
564
|
|
|
443
565
|
export { FeatWebClient, SDK_VERSION2 as SDK_VERSION };
|
package/package.json
CHANGED