@nanoflags/sdk 0.0.0-stage → 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.
@@ -0,0 +1,1471 @@
1
+ // ../engine/src/index.ts
2
+ const encoder$1 = new TextEncoder();
3
+ /** MurmurHash3 x86 32-bit over UTF-8 bytes. Used for distribution only — never for security. */
4
+ function murmur3(input, seed = 0) {
5
+ const data = encoder$1.encode(input.normalize("NFC"));
6
+ const c1 = 3432918353;
7
+ const c2 = 461845907;
8
+ let h = seed >>> 0;
9
+ const blocks = data.length >> 2;
10
+ for (let i = 0; i < blocks; i++) {
11
+ const o = i * 4;
12
+ let k = data[o] | data[o + 1] << 8 | data[o + 2] << 16 | data[o + 3] << 24;
13
+ k = Math.imul(k, c1);
14
+ k = k << 15 | k >>> 17;
15
+ k = Math.imul(k, c2);
16
+ h ^= k;
17
+ h = h << 13 | h >>> 19;
18
+ h = Math.imul(h, 5) + 3864292196 | 0;
19
+ }
20
+ let k = 0;
21
+ const tail = blocks * 4;
22
+ switch (data.length & 3) {
23
+ case 3: k ^= data[tail + 2] << 16;
24
+ case 2: k ^= data[tail + 1] << 8;
25
+ case 1:
26
+ k ^= data[tail];
27
+ k = Math.imul(k, c1);
28
+ k = k << 15 | k >>> 17;
29
+ k = Math.imul(k, c2);
30
+ h ^= k;
31
+ }
32
+ h ^= data.length;
33
+ h ^= h >>> 16;
34
+ h = Math.imul(h, 2246822507);
35
+ h ^= h >>> 13;
36
+ h = Math.imul(h, 3266489909);
37
+ h ^= h >>> 16;
38
+ return h >>> 0;
39
+ }
40
+ /** Bucket in [0, 100) with two decimals of resolution. Stable for a given seed + identity. */
41
+ function bucketOf(seed, identity) {
42
+ return murmur3(`${seed}:${identity}`) % 1e4 / 100;
43
+ }
44
+ /** Own-property lookup, so keys such as "constructor" or "__proto__" never resolve to Object.prototype members. */
45
+ function own(obj, key) {
46
+ return obj != null && Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : void 0;
47
+ }
48
+ function looseEq(a, b) {
49
+ if (a === void 0 || b === void 0) return false;
50
+ if (typeof a === "object" || typeof b === "object") return JSON.stringify(a) === JSON.stringify(b);
51
+ return a === b;
52
+ }
53
+ function num(v) {
54
+ if (typeof v === "number" && Number.isFinite(v)) return v;
55
+ if (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v))) return Number(v);
56
+ return null;
57
+ }
58
+ function matchCondition(c, ctx, segments, depth = 0) {
59
+ if (c.op === "segment" || c.op === "not_segment") {
60
+ const seg = typeof c.value === "string" ? own(segments, c.value) : void 0;
61
+ const inSeg = seg !== void 0 && depth < 4 && matchAll(seg.match, seg.conditions, ctx, segments, depth + 1);
62
+ return c.op === "segment" ? inSeg : seg !== void 0 && !inSeg;
63
+ }
64
+ const actual = own(ctx, c.attribute);
65
+ switch (c.op) {
66
+ case "exists": return actual !== void 0 && actual !== null;
67
+ case "not_exists": return actual === void 0 || actual === null;
68
+ case "eq": return looseEq(actual, c.value);
69
+ case "neq": return actual !== void 0 && !looseEq(actual, c.value);
70
+ case "in": return Array.isArray(c.value) && c.value.some((v) => looseEq(actual, v));
71
+ case "not_in": return actual !== void 0 && Array.isArray(c.value) && !c.value.some((v) => looseEq(actual, v));
72
+ case "contains": return typeof actual === "string" && typeof c.value === "string" && actual.includes(c.value);
73
+ case "starts_with": return typeof actual === "string" && typeof c.value === "string" && actual.startsWith(c.value);
74
+ case "ends_with": return typeof actual === "string" && typeof c.value === "string" && actual.endsWith(c.value);
75
+ case "gt":
76
+ case "gte":
77
+ case "lt":
78
+ case "lte": {
79
+ const a = num(actual);
80
+ const b = num(c.value);
81
+ if (a === null || b === null) return false;
82
+ return c.op === "gt" ? a > b : c.op === "gte" ? a >= b : c.op === "lt" ? a < b : a <= b;
83
+ }
84
+ default: return false;
85
+ }
86
+ }
87
+ function matchAll(mode, conditions, ctx, segments, depth = 0) {
88
+ if (conditions.length === 0) return true;
89
+ return mode === "any" ? conditions.some((c) => matchCondition(c, ctx, segments, depth)) : conditions.every((c) => matchCondition(c, ctx, segments, depth));
90
+ }
91
+ function describeCondition(c) {
92
+ const v = Array.isArray(c.value) ? c.value.join(", ") : c.value === void 0 ? "" : String(c.value);
93
+ const words = {
94
+ eq: "=",
95
+ neq: "≠",
96
+ in: "in",
97
+ not_in: "not in",
98
+ contains: "contains",
99
+ starts_with: "starts with",
100
+ ends_with: "ends with",
101
+ gt: ">",
102
+ gte: "≥",
103
+ lt: "<",
104
+ lte: "≤",
105
+ exists: "exists",
106
+ not_exists: "does not exist",
107
+ segment: "in segment",
108
+ not_segment: "not in segment"
109
+ };
110
+ if (c.op === "segment" || c.op === "not_segment") return `${words[c.op]} ${v}`;
111
+ if (c.op === "exists" || c.op === "not_exists") return `${c.attribute} ${words[c.op]}`;
112
+ return `${c.attribute} ${words[c.op]} ${v}`;
113
+ }
114
+ /**
115
+ * Evaluate one flag. Never throws: on any problem it returns `fallback` with reason
116
+ * "flag_not_found" or "error", so callers always have an explicit default.
117
+ */
118
+ function evaluate(bundle, flagKey, ctx, fallback) {
119
+ const revision = bundle?.revision ?? 0;
120
+ const trace = [];
121
+ try {
122
+ const flag = own(bundle?.flags, flagKey);
123
+ if (!bundle || !flag) {
124
+ trace.push({
125
+ kind: "result",
126
+ title: `Returned ${fmt(fallback)}`,
127
+ detail: bundle ? "Flag is not in this configuration. The application fallback applies." : "No configuration is loaded. The application fallback applies."
128
+ });
129
+ return {
130
+ flagKey,
131
+ value: fallback,
132
+ reason: "flag_not_found",
133
+ revision,
134
+ trace
135
+ };
136
+ }
137
+ trace.push({
138
+ kind: "config",
139
+ title: "Configuration loaded",
140
+ detail: revision > 0 ? `r${revision}` : "draft"
141
+ });
142
+ if (!flag.enabled) {
143
+ trace.push({
144
+ kind: "result",
145
+ title: `Returned ${fmt(flag.off)}`,
146
+ detail: "Flag is disabled in this environment."
147
+ });
148
+ return {
149
+ flagKey,
150
+ value: flag.off,
151
+ reason: "off",
152
+ revision,
153
+ trace
154
+ };
155
+ }
156
+ for (let i = 0; i < flag.rules.length; i++) {
157
+ const rule = flag.rules[i];
158
+ const matched = matchAll(rule.match, rule.conditions, ctx, bundle.segments);
159
+ const label = `Rule ${String(i + 1).padStart(2, "0")}`;
160
+ const summary = rule.conditions.length ? rule.conditions.map(describeCondition).join(rule.match === "any" ? " OR " : " AND ") : "all contexts";
161
+ trace.push({
162
+ kind: "rule",
163
+ title: matched ? `${label} matched` : `${label} did not match`,
164
+ detail: summary,
165
+ matched
166
+ });
167
+ if (!matched) continue;
168
+ if (rule.serve.type === "fixed") {
169
+ trace.push({
170
+ kind: "result",
171
+ title: `Returned ${fmt(rule.serve.value)}`,
172
+ detail: `${label} serves a fixed value.`
173
+ });
174
+ return {
175
+ flagKey,
176
+ value: rule.serve.value,
177
+ reason: "rule_match",
178
+ ruleIndex: i,
179
+ ruleId: rule.id,
180
+ revision,
181
+ trace
182
+ };
183
+ }
184
+ const attr = rule.serve.bucketBy ?? "key";
185
+ const identity = own(ctx, attr);
186
+ if (identity === void 0 || identity === null || identity === "") {
187
+ trace.push({
188
+ kind: "allocation",
189
+ title: "Allocation skipped",
190
+ detail: `Context has no "${attr}" to bucket by; treated as outside the allocation.`,
191
+ matched: false
192
+ });
193
+ trace.push({
194
+ kind: "result",
195
+ title: `Returned ${fmt(rule.serve.otherwise)}`,
196
+ detail: "Remaining eligible users."
197
+ });
198
+ return {
199
+ flagKey,
200
+ value: rule.serve.otherwise,
201
+ reason: "rule_match",
202
+ ruleIndex: i,
203
+ ruleId: rule.id,
204
+ inRollout: false,
205
+ revision,
206
+ trace
207
+ };
208
+ }
209
+ const bucket = bucketOf(rule.serve.seed ?? flagKey, String(identity));
210
+ const inRollout = bucket < rule.serve.percentage;
211
+ trace.push({
212
+ kind: "allocation",
213
+ title: "Allocation evaluated",
214
+ detail: `${rule.serve.percentage}% rollout · ${attr} ${inRollout ? "inside" : "outside"} allocation (bucket ${bucket.toFixed(2)})`,
215
+ matched: inRollout
216
+ });
217
+ const value = inRollout ? rule.serve.value : rule.serve.otherwise;
218
+ trace.push({
219
+ kind: "result",
220
+ title: `Returned ${fmt(value)}`,
221
+ detail: inRollout ? `Inside the ${rule.serve.percentage}% allocation.` : "Remaining eligible users."
222
+ });
223
+ return {
224
+ flagKey,
225
+ value,
226
+ reason: "rule_match",
227
+ ruleIndex: i,
228
+ ruleId: rule.id,
229
+ inRollout,
230
+ bucket,
231
+ revision,
232
+ trace
233
+ };
234
+ }
235
+ trace.push({
236
+ kind: "result",
237
+ title: `Returned ${fmt(flag.default)}`,
238
+ detail: "No rule matched. The flag default applies."
239
+ });
240
+ return {
241
+ flagKey,
242
+ value: flag.default,
243
+ reason: "default",
244
+ revision,
245
+ trace
246
+ };
247
+ } catch (e) {
248
+ const error = e instanceof Error ? e.message : String(e);
249
+ trace.push({
250
+ kind: "result",
251
+ title: `Returned ${fmt(fallback)}`,
252
+ detail: `Evaluation error: ${error}. The application fallback applies.`
253
+ });
254
+ return {
255
+ flagKey,
256
+ value: fallback,
257
+ reason: "error",
258
+ revision,
259
+ trace,
260
+ error
261
+ };
262
+ }
263
+ }
264
+ function fmt(v) {
265
+ return typeof v === "string" ? v : JSON.stringify(v);
266
+ }
267
+ // src/store.ts
268
+ const DB = "nanoflags";
269
+ const OBJECT_STORE = "bundles";
270
+ /**
271
+ * Browser default: IndexedDB when available. Everywhere else (Node, edge runtimes, serverless):
272
+ * no persistence — there is no safe default location and cold starts should not touch disk.
273
+ */
274
+ function defaultStore(id) {
275
+ if (typeof window === "undefined" || typeof indexedDB === "undefined" || indexedDB === null) return null;
276
+ return indexedDbStore(id);
277
+ }
278
+ function indexedDbStore(id) {
279
+ const open = () => new Promise((resolve, reject) => {
280
+ const req = indexedDB.open(DB, 1);
281
+ req.onupgradeneeded = () => {
282
+ if (!req.result.objectStoreNames.contains(OBJECT_STORE)) req.result.createObjectStore(OBJECT_STORE);
283
+ };
284
+ req.onsuccess = () => resolve(req.result);
285
+ req.onerror = () => reject(req.error ?? /* @__PURE__ */ new Error("indexedDB open failed"));
286
+ req.onblocked = () => reject(/* @__PURE__ */ new Error("indexedDB blocked"));
287
+ });
288
+ const run = async (mode, op) => {
289
+ const db = await open();
290
+ try {
291
+ return await new Promise((resolve, reject) => {
292
+ const tx = db.transaction(OBJECT_STORE, mode);
293
+ const req = op(tx.objectStore(OBJECT_STORE));
294
+ tx.oncomplete = () => resolve(req.result);
295
+ tx.onerror = () => reject(tx.error ?? /* @__PURE__ */ new Error("indexedDB transaction failed"));
296
+ tx.onabort = () => reject(tx.error ?? /* @__PURE__ */ new Error("indexedDB transaction aborted"));
297
+ });
298
+ } finally {
299
+ db.close();
300
+ }
301
+ };
302
+ return {
303
+ async get() {
304
+ try {
305
+ const v = await run("readonly", (s) => s.get(id));
306
+ return typeof v === "string" ? v : null;
307
+ } catch {
308
+ return null;
309
+ }
310
+ },
311
+ async set(value) {
312
+ try {
313
+ await run("readwrite", (s) => s.put(value, id));
314
+ } catch {}
315
+ }
316
+ };
317
+ }
318
+ // src/sse.ts
319
+ /** Minimal server-sent-events reader for fetch streams (Node, workers, browsers without EventSource auth). */
320
+ async function readSse(body, onEvent) {
321
+ const reader = body.getReader();
322
+ const decoder = new TextDecoder();
323
+ let buffer = "";
324
+ const dispatch = (block) => {
325
+ let event = "message";
326
+ const data = [];
327
+ for (const line of block.split("\n")) {
328
+ if (line === "" || line.startsWith(":")) continue;
329
+ const i = line.indexOf(":");
330
+ const field = i === -1 ? line : line.slice(0, i);
331
+ const value = i === -1 ? "" : line.slice(i + 1).replace(/^ /, "");
332
+ if (field === "event") event = value;
333
+ else if (field === "data") data.push(value);
334
+ }
335
+ if (data.length > 0) onEvent(event, data.join("\n"));
336
+ };
337
+ try {
338
+ for (;;) {
339
+ const { done, value } = await reader.read();
340
+ if (done) break;
341
+ buffer = (buffer + decoder.decode(value, { stream: true })).replace(/\r\n?/g, "\n");
342
+ let i;
343
+ while ((i = buffer.indexOf("\n\n")) !== -1) {
344
+ dispatch(buffer.slice(0, i));
345
+ buffer = buffer.slice(i + 2);
346
+ }
347
+ if (buffer.length > 65536) buffer = "";
348
+ }
349
+ } finally {
350
+ reader.cancel().catch(() => void 0);
351
+ }
352
+ }
353
+ // src/verify.ts
354
+ const encoder = new TextEncoder();
355
+ function canonicalString(environmentId, revision, checksum, bundleSha256Hex) {
356
+ return `nanoflags-bundle-v1\n${environmentId}\n${revision}\n${checksum}\n${bundleSha256Hex}`;
357
+ }
358
+ function subtle() {
359
+ const s = globalThis.crypto?.subtle;
360
+ if (!s) throw new Error("WebCrypto is not available in this runtime.");
361
+ return s;
362
+ }
363
+ async function sha256Hex(text) {
364
+ const digest = new Uint8Array(await subtle().digest("SHA-256", encoder.encode(text)));
365
+ let out = "";
366
+ for (const b of digest) out += b.toString(16).padStart(2, "0");
367
+ return out;
368
+ }
369
+ function base64UrlDecode(input) {
370
+ const s = input.replace(/-/g, "+").replace(/_/g, "/").replace(/=+$/, "");
371
+ if (!/^[A-Za-z0-9+/]*$/.test(s) || s.length % 4 === 1) throw new Error("Malformed base64url value.");
372
+ const bin = atob(s + "=".repeat((4 - s.length % 4) % 4));
373
+ const out = new Uint8Array(new ArrayBuffer(bin.length));
374
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
375
+ return out;
376
+ }
377
+ function isPublicEd25519Jwk(v) {
378
+ if (typeof v !== "object" || v === null) return false;
379
+ const k = v;
380
+ return k.kty === "OKP" && k.crv === "Ed25519" && typeof k.x === "string" && k.d === void 0;
381
+ }
382
+ async function importPublicKeys(jwks) {
383
+ const out = [];
384
+ for (const jwk of jwks) {
385
+ if (!isPublicEd25519Jwk(jwk)) continue;
386
+ const key = await subtle().importKey("jwk", {
387
+ kty: "OKP",
388
+ crv: "Ed25519",
389
+ x: jwk.x
390
+ }, { name: "Ed25519" }, false, ["verify"]);
391
+ out.push({
392
+ kid: typeof jwk.kid === "string" ? jwk.kid : null,
393
+ key
394
+ });
395
+ }
396
+ return out;
397
+ }
398
+ /** True when `info.signature` is a valid signature by one of `keys` over the canonical string for `bundleText`. */
399
+ async function verifyBundleText(keys, info, bundleText) {
400
+ let signature;
401
+ try {
402
+ signature = base64UrlDecode(info.signature);
403
+ } catch {
404
+ return false;
405
+ }
406
+ if (signature.length !== 64) return false;
407
+ const data = encoder.encode(canonicalString(info.environmentId, info.revision, info.checksum, await sha256Hex(bundleText)));
408
+ const candidates = info.kid === null ? keys : keys.filter((k) => k.kid === info.kid || k.kid === null);
409
+ for (const k of candidates) try {
410
+ if (await subtle().verify({ name: "Ed25519" }, k.key, signature, data)) return true;
411
+ } catch {}
412
+ return false;
413
+ }
414
+ /**
415
+ * Returns the exact source text of the top-level `"bundle"` member of a JSON object, or null.
416
+ * The edge embeds the stored bundle text byte-for-byte; hashing that span avoids depending on
417
+ * re-serialisation. When the member occurs more than once the LAST one wins, like `JSON.parse`.
418
+ */
419
+ function rawMember(text, member) {
420
+ let i = 0;
421
+ const n = text.length;
422
+ const ws = () => {
423
+ while (i < n && (text[i] === " " || text[i] === "\n" || text[i] === "\r" || text[i] === " ")) i++;
424
+ };
425
+ const str = () => {
426
+ if (text[i] !== "\"") return null;
427
+ const start = i++;
428
+ while (i < n) {
429
+ const c = text[i];
430
+ if (c === "\\") i += 2;
431
+ else if (c === "\"") return text.slice(start, ++i);
432
+ else i++;
433
+ }
434
+ return null;
435
+ };
436
+ const value = () => {
437
+ ws();
438
+ const c = text[i];
439
+ if (c === "\"") return str() !== null;
440
+ if (c === "{" || c === "[") {
441
+ let depth = 0;
442
+ while (i < n) {
443
+ const d = text[i];
444
+ if (d === "\"") {
445
+ if (str() === null) return false;
446
+ continue;
447
+ }
448
+ if (d === "{" || d === "[") depth++;
449
+ else if (d === "}" || d === "]") depth--;
450
+ i++;
451
+ if (depth === 0) return true;
452
+ }
453
+ return false;
454
+ }
455
+ const start = i;
456
+ while (i < n && !",}] \n\r ".includes(text[i])) i++;
457
+ return i > start;
458
+ };
459
+ ws();
460
+ if (text[i] !== "{") return null;
461
+ i++;
462
+ let found = null;
463
+ for (;;) {
464
+ ws();
465
+ if (text[i] === "}") return found;
466
+ const key = str();
467
+ if (key === null) return null;
468
+ ws();
469
+ if (text[i] !== ":") return null;
470
+ i++;
471
+ ws();
472
+ const start = i;
473
+ if (!value()) return null;
474
+ let name;
475
+ try {
476
+ name = JSON.parse(key);
477
+ } catch {
478
+ return null;
479
+ }
480
+ if (name === member) found = text.slice(start, i);
481
+ ws();
482
+ if (text[i] === ",") i++;
483
+ else if (text[i] === "}") return found;
484
+ else return null;
485
+ }
486
+ }
487
+ // src/index.ts
488
+ /**
489
+ * @nanoflags/sdk — isomorphic core.
490
+ *
491
+ * Flags are evaluated locally and synchronously against an in-memory bundle. The network is only
492
+ * used in the background: to load the bundle, to learn about new revisions, and to send sampled
493
+ * telemetry. A flag accessor never performs I/O and never throws.
494
+ *
495
+ * New revisions arrive over one of three transports, best first: the live WebSocket (`/v1/live`,
496
+ * the edge pushes the signed bundle itself — no request follows), the SSE stream (`/v1/stream`,
497
+ * a notification followed by a conditional fetch), and polling (`/v1/bundle`).
498
+ */
499
+ const SDK_VERSION = "0.3.0";
500
+ /** Header that carries a test-session token between services. */
501
+ const SESSION_HEADER = "x-nanoflags-session";
502
+ /** WebSocket subprotocol of `/v1/live`. The key travels as a second protocol entry, never in the URL. */
503
+ const LIVE_PROTOCOL = "nanoflags.v1";
504
+ var NanoFlagsError = class extends Error {
505
+ code;
506
+ httpStatus;
507
+ constructor(message, code, httpStatus) {
508
+ super(message);
509
+ this.code = code;
510
+ this.httpStatus = httpStatus;
511
+ this.name = "NanoFlagsError";
512
+ }
513
+ };
514
+ const MAX_QUEUE = 500;
515
+ const MAX_BATCH = 50;
516
+ const MAX_EVENT_VALUE_CHARS = 1024;
517
+ const REQUEST_TIMEOUT_MS = 1e4;
518
+ const MAX_SESSIONS = 1e3;
519
+ const SESSION_MISS_TTL_MS = 15e3;
520
+ const SESSION_ERROR_TTL_MS = 3e3;
521
+ const KEYS_REFETCH_MS = 6e4;
522
+ const STORAGE_KEY = "nanoflags:session";
523
+ const LIVE_CONNECT_TIMEOUT_MS = 1e4;
524
+ const LIVE_PING_MS = 3e4;
525
+ const LIVE_PONG_TIMEOUT_MS = 1e4;
526
+ const LIVE_MAX_BACKOFF_MS = 3e5;
527
+ /** A healthy socket is replaced after about this long, so the edge validates the key again (the edge drops sockets older than 2 h). */
528
+ const LIVE_ROTATE_MS = 3e6;
529
+ /** A session token is opaque; it only has to be safe to put in a header. */
530
+ function isSessionToken(v) {
531
+ return typeof v === "string" && /^[\x21-\x7e]{1,512}$/.test(v);
532
+ }
533
+ function unref(t) {
534
+ t.unref?.();
535
+ return t;
536
+ }
537
+ function isBundle(v) {
538
+ if (typeof v !== "object" || v === null) return false;
539
+ const b = v;
540
+ return b.schema === 1 && typeof b.revision === "number" && typeof b.flags === "object" && b.flags !== null && !Array.isArray(b.flags) && typeof b.segments === "object" && b.segments !== null;
541
+ }
542
+ function typeOf(v) {
543
+ return v === null ? "null" : Array.isArray(v) ? "array" : typeof v;
544
+ }
545
+ function createClient(options) {
546
+ const sdkKey = options.sdkKey;
547
+ const baseUrl = String(options.baseUrl ?? "").replace(/\/+$/, "");
548
+ const pollIntervalMs = options.pollIntervalMs ?? 3e4;
549
+ const doFetch = options.fetch ?? (typeof fetch === "function" ? (input, init) => fetch(input, init) : void 0);
550
+ const ev = options.events === false ? { enabled: false } : options.events ?? {};
551
+ const eventsEnabled = ev.enabled ?? true;
552
+ const sampleRate = Math.min(1, Math.max(0, ev.sampleRate ?? .1));
553
+ const flushIntervalMs = ev.flushIntervalMs ?? 1e4;
554
+ const reportIntervalMs = ev.reportIntervalMs ?? 6e4;
555
+ const livePollIntervalMs = options.livePollIntervalMs ?? 3e5;
556
+ const random = ev.random ?? Math.random;
557
+ const service = options.service ?? "unknown";
558
+ /** Non-secret fingerprint binding persisted bundles to this key + edge. */
559
+ const id = murmur3(`${baseUrl}|${sdkKey}`).toString(16).padStart(8, "0");
560
+ const store = options.store === false ? null : options.store ?? defaultStore(id);
561
+ const isBrowserKey = typeof sdkKey === "string" && sdkKey.startsWith("nf_pub_");
562
+ const doc = typeof document !== "undefined" ? document : void 0;
563
+ let bundle = null;
564
+ let etag = null;
565
+ let checksum = null;
566
+ let status = "not-ready";
567
+ let version = 0;
568
+ let closed = false;
569
+ let failures = 0;
570
+ let lastSyncAt = 0;
571
+ let pollTimer = null;
572
+ let flushTimer = null;
573
+ let inFlight = null;
574
+ let streamAbort = null;
575
+ let eventSource = null;
576
+ /** Bumped to stop the running SSE loop; `streamActive` is true while SSE (either flavour) is in use. */
577
+ let streamGen = 0;
578
+ let streamActive = false;
579
+ let streamTriggered = false;
580
+ const WS = options.webSocket ?? (typeof WebSocket === "function" ? WebSocket : void 0);
581
+ const liveUrl = /^https?:\/\//i.test(baseUrl) ? `${baseUrl.replace(/^http/i, "ws")}/v1/live` : null;
582
+ let socket = null;
583
+ let liveOpen = false;
584
+ let liveAttempt = 0;
585
+ let liveTimer = null;
586
+ /** Highest revision adopted from a push: a slower HTTP answer must never replace it with something older. */
587
+ let liveRevision = 0;
588
+ let pushChain = Promise.resolve();
589
+ let lastReportAt = 0;
590
+ const usedAttrs = /* @__PURE__ */ new WeakMap();
591
+ const verifyOpt = options.verify;
592
+ const isBrowser = typeof window !== "undefined" && typeof document !== "undefined";
593
+ let keysPromise = null;
594
+ let keysLoadedAt = 0;
595
+ let pinnedEnvironment = null;
596
+ let sig = null;
597
+ let rawText = null;
598
+ const sessions = /* @__PURE__ */ new Map();
599
+ let pageToken = null;
600
+ let pageSession = null;
601
+ let pageResolved = false;
602
+ let pageTimer = null;
603
+ const queue = [];
604
+ const listeners = {
605
+ update: /* @__PURE__ */ new Set(),
606
+ error: /* @__PURE__ */ new Set()
607
+ };
608
+ const subscribers = /* @__PURE__ */ new Set();
609
+ let resolveReady;
610
+ const readyPromise = new Promise((r) => resolveReady = r);
611
+ const safely = (fn) => {
612
+ try {
613
+ fn();
614
+ } catch {}
615
+ };
616
+ const changed = () => {
617
+ version++;
618
+ for (const s of [...subscribers]) safely(s);
619
+ };
620
+ const setStatus = (next) => {
621
+ if (status === next) return;
622
+ status = next;
623
+ changed();
624
+ };
625
+ const emitError = (e) => {
626
+ for (const cb of [...listeners.error]) safely(() => cb(e));
627
+ };
628
+ function adopt(next, meta) {
629
+ const previousRevision = bundle ? bundle.revision : null;
630
+ const isNew = !bundle || previousRevision !== next.revision || checksum !== meta.checksum;
631
+ bundle = next;
632
+ etag = meta.etag;
633
+ checksum = meta.checksum;
634
+ status = meta.status;
635
+ changed();
636
+ resolveReady();
637
+ if (isNew) {
638
+ const e = {
639
+ revision: next.revision,
640
+ previousRevision,
641
+ checksum: meta.checksum,
642
+ source: meta.source,
643
+ latencyMs: meta.latencyMs ?? null,
644
+ transport: meta.transport ?? "poll"
645
+ };
646
+ for (const cb of [...listeners.update]) safely(() => cb(e));
647
+ }
648
+ }
649
+ function persist() {
650
+ if (!store || !bundle) return;
651
+ const payload = {
652
+ v: 1,
653
+ id,
654
+ etag,
655
+ checksum,
656
+ savedAt: (/* @__PURE__ */ new Date()).toISOString(),
657
+ bundle,
658
+ ...sig ? { sig } : {},
659
+ ...sig && rawText !== null ? { raw: rawText } : {}
660
+ };
661
+ Promise.resolve().then(() => store.set(JSON.stringify(payload))).catch(() => emitError(new NanoFlagsError("Could not persist the bundle.", "store")));
662
+ }
663
+ async function restore() {
664
+ if (!store) return;
665
+ try {
666
+ const raw = await store.get();
667
+ if (!raw || closed) return;
668
+ const p = JSON.parse(raw);
669
+ if (p.v !== 1 || p.id !== id || !isBundle(p.bundle)) return;
670
+ if (bundle && bundle.revision >= p.bundle.revision) return;
671
+ let restored = p.bundle;
672
+ let restoredSig = null;
673
+ let restoredRaw = null;
674
+ if (verifyOpt) {
675
+ const s = p.sig;
676
+ if (s && typeof s.signature === "string" && typeof s.environmentId === "string" && typeof s.checksum === "string" && s.revision === p.bundle.revision) {
677
+ const text = typeof p.raw === "string" ? p.raw : JSON.stringify(p.bundle);
678
+ let ok = false;
679
+ try {
680
+ ok = await verifyBundleText(await loadKeys(s.kid ?? null), {
681
+ signature: s.signature,
682
+ kid: s.kid ?? null,
683
+ environmentId: s.environmentId,
684
+ revision: s.revision,
685
+ checksum: s.checksum
686
+ }, text);
687
+ if (ok && typeof p.raw === "string") {
688
+ const parsed = JSON.parse(p.raw);
689
+ if (isBundle(parsed)) restored = parsed;
690
+ else ok = false;
691
+ }
692
+ } catch {
693
+ ok = false;
694
+ }
695
+ if (!ok) return void emitError(new NanoFlagsError("The persisted bundle failed signature verification and was ignored.", "signature"));
696
+ restoredSig = {
697
+ signature: s.signature,
698
+ kid: s.kid ?? null,
699
+ environmentId: s.environmentId,
700
+ revision: s.revision,
701
+ checksum: s.checksum
702
+ };
703
+ restoredRaw = typeof p.raw === "string" ? p.raw : null;
704
+ } else if (verifyOpt.require) return;
705
+ if (closed || bundle && bundle.revision >= restored.revision) return;
706
+ }
707
+ sig = restoredSig;
708
+ rawText = restoredRaw;
709
+ if (restoredSig) pinnedEnvironment ??= restoredSig.environmentId;
710
+ adopt(restored, {
711
+ etag: p.etag ?? null,
712
+ checksum: p.checksum ?? null,
713
+ status: "stale",
714
+ source: "store"
715
+ });
716
+ } catch {
717
+ emitError(new NanoFlagsError("Could not read the persisted bundle.", "store"));
718
+ }
719
+ }
720
+ async function request(path, init) {
721
+ if (!doFetch) throw new NanoFlagsError("No fetch implementation available.", "config");
722
+ const ac = new AbortController();
723
+ const t = unref(setTimeout(() => ac.abort(), REQUEST_TIMEOUT_MS));
724
+ try {
725
+ return await doFetch(`${baseUrl}${path}`, {
726
+ ...init,
727
+ signal: ac.signal,
728
+ headers: {
729
+ Authorization: `Bearer ${sdkKey}`,
730
+ ...init.headers
731
+ }
732
+ });
733
+ } finally {
734
+ clearTimeout(t);
735
+ }
736
+ }
737
+ async function fetchKeys() {
738
+ if (!doFetch) throw new NanoFlagsError("No fetch implementation available.", "config");
739
+ const ac = new AbortController();
740
+ const t = unref(setTimeout(() => ac.abort(), REQUEST_TIMEOUT_MS));
741
+ try {
742
+ const res = await doFetch(`${baseUrl}/v1/keys`, {
743
+ method: "GET",
744
+ signal: ac.signal
745
+ });
746
+ if (!res.ok) throw new NanoFlagsError(`Signing keys request failed with HTTP ${res.status}.`, "signature", res.status);
747
+ const body = await res.json();
748
+ const keys = await importPublicKeys(Array.isArray(body?.keys) ? body.keys : []);
749
+ if (keys.length === 0) throw new NanoFlagsError("The edge returned no usable signing keys.", "signature");
750
+ return keys;
751
+ } finally {
752
+ clearTimeout(t);
753
+ }
754
+ }
755
+ function loadKeys(kid) {
756
+ if (!verifyOpt) return Promise.resolve([]);
757
+ const start = () => {
758
+ keysLoadedAt = Date.now();
759
+ const p = verifyOpt.publicKeys === "fetch" ? fetchKeys() : importPublicKeys(Array.isArray(verifyOpt.publicKeys) ? verifyOpt.publicKeys : []);
760
+ keysPromise = p;
761
+ p.catch(() => {
762
+ if (keysPromise === p) keysPromise = null;
763
+ });
764
+ return p;
765
+ };
766
+ if (!keysPromise) return start();
767
+ if (verifyOpt.publicKeys !== "fetch" || kid === null) return keysPromise;
768
+ return keysPromise.then((keys) => keys.some((k) => k.kid === kid) || Date.now() - keysLoadedAt < KEYS_REFETCH_MS ? keys : start());
769
+ }
770
+ /** Returns the bundle to adopt, or throws a NanoFlagsError with code "signature". */
771
+ async function checkSignature(res, text, body) {
772
+ if (!verifyOpt) return {
773
+ bundle: body.bundle,
774
+ sig: null,
775
+ raw: null
776
+ };
777
+ const reject = (why) => new NanoFlagsError(`Bundle r${body.bundle.revision} was rejected: ${why} The configuration in use is unchanged.`, "signature");
778
+ if (res.headers.get("X-NanoFlags-Signed") !== "true") {
779
+ if (verifyOpt.require) throw reject("it is not signed and `verify.require` is set.");
780
+ return {
781
+ bundle: body.bundle,
782
+ sig: null,
783
+ raw: null
784
+ };
785
+ }
786
+ const signature = res.headers.get("X-NanoFlags-Signature");
787
+ const environmentId = res.headers.get("X-NanoFlags-Environment-Id");
788
+ const kid = res.headers.get("X-NanoFlags-Key-Id");
789
+ if (!signature || !environmentId || typeof body.checksum !== "string") throw reject("the signature headers are incomplete.");
790
+ const revision = typeof body.revision === "number" ? body.revision : body.bundle.revision;
791
+ if (revision !== body.bundle.revision) throw reject("the signed revision does not match the bundle.");
792
+ if (pinnedEnvironment !== null && pinnedEnvironment !== environmentId) throw reject("it was signed for a different environment.");
793
+ const info = {
794
+ signature,
795
+ kid,
796
+ environmentId,
797
+ revision,
798
+ checksum: body.checksum
799
+ };
800
+ let keys;
801
+ try {
802
+ keys = await loadKeys(kid);
803
+ } catch (e) {
804
+ throw reject(`the signing keys could not be loaded (${e instanceof Error ? e.message : String(e)}).`);
805
+ }
806
+ try {
807
+ const raw = rawMember(text, "bundle");
808
+ const again = JSON.stringify(body.bundle);
809
+ if (raw !== null && raw !== again && await verifyBundleText(keys, info, raw)) {
810
+ const parsed = JSON.parse(raw);
811
+ if (isBundle(parsed) && parsed.revision === revision) {
812
+ pinnedEnvironment = environmentId;
813
+ return {
814
+ bundle: parsed,
815
+ sig: info,
816
+ raw
817
+ };
818
+ }
819
+ } else if (await verifyBundleText(keys, info, again)) {
820
+ pinnedEnvironment = environmentId;
821
+ return {
822
+ bundle: body.bundle,
823
+ sig: info,
824
+ raw: null
825
+ };
826
+ }
827
+ } catch (e) {
828
+ throw reject(`the signature could not be checked (${e instanceof Error ? e.message : String(e)}).`);
829
+ }
830
+ throw reject("its signature does not verify.");
831
+ }
832
+ async function syncOnce() {
833
+ lastSyncAt = Date.now();
834
+ const transport = streamTriggered ? "stream" : "poll";
835
+ streamTriggered = false;
836
+ try {
837
+ const res = await request("/v1/bundle", {
838
+ method: "GET",
839
+ headers: etag ? { "If-None-Match": etag } : {}
840
+ });
841
+ if (closed) return;
842
+ const servedStale = res.headers.get("X-NanoFlags-Stale") === "true";
843
+ if (res.status === 304) {
844
+ failures = 0;
845
+ if (bundle) setStatus(servedStale ? "stale" : "ready");
846
+ return;
847
+ }
848
+ if (res.status === 401 || res.status === 403) throw new NanoFlagsError("The SDK key was rejected (unknown or revoked).", "unauthorized", res.status);
849
+ if (!res.ok) throw new NanoFlagsError(`Bundle request failed with HTTP ${res.status}.`, "http", res.status);
850
+ const text = await res.text();
851
+ if (closed) return;
852
+ const body = (() => {
853
+ try {
854
+ return JSON.parse(text);
855
+ } catch {
856
+ return null;
857
+ }
858
+ })();
859
+ if (!body || !isBundle(body.bundle)) throw new NanoFlagsError("The edge returned an invalid bundle.", "invalid_bundle");
860
+ const verified = await checkSignature(res, text, body);
861
+ if (closed) return;
862
+ const next = verified.bundle;
863
+ failures = 0;
864
+ if (servedStale && bundle && bundle.revision >= next.revision) {
865
+ setStatus("stale");
866
+ return;
867
+ }
868
+ if (bundle && next.revision < liveRevision && bundle.revision >= liveRevision) return;
869
+ sig = verified.sig;
870
+ rawText = verified.raw;
871
+ adopt(next, {
872
+ etag: res.headers.get("ETag"),
873
+ checksum: body.checksum ?? null,
874
+ status: servedStale ? "stale" : "ready",
875
+ source: "network",
876
+ transport
877
+ });
878
+ persist();
879
+ } catch (e) {
880
+ if (closed) return;
881
+ failures++;
882
+ if (e instanceof NanoFlagsError && e.code === "signature") return void emitError(e);
883
+ if (e instanceof NanoFlagsError && e.code === "unauthorized") dropLive();
884
+ if (bundle) setStatus("stale");
885
+ emitError(e instanceof NanoFlagsError ? e : new NanoFlagsError(`Could not reach the NanoFlags edge: ${e instanceof Error ? e.message : String(e)}`, "network"));
886
+ }
887
+ }
888
+ /** Single-flight: concurrent triggers (poll, stream, visibility) share one request. */
889
+ function sync() {
890
+ if (closed) return Promise.resolve();
891
+ if (!inFlight) inFlight = syncOnce().finally(() => {
892
+ inFlight = null;
893
+ });
894
+ return inFlight;
895
+ }
896
+ function nextDelay() {
897
+ const jitter = .8 + .4 * Math.random();
898
+ const base = failures > 0 ? Math.min(pollIntervalMs, 1e3 * 2 ** Math.min(failures - 1, 10)) : liveOpen ? Math.max(pollIntervalMs, livePollIntervalMs) : pollIntervalMs;
899
+ return Math.max(250, Math.round(base * jitter));
900
+ }
901
+ function schedulePoll() {
902
+ if (closed || pollIntervalMs <= 0) return;
903
+ if (pollTimer) clearTimeout(pollTimer);
904
+ pollTimer = unref(setTimeout(() => {
905
+ pollTimer = null;
906
+ if (doc && doc.visibilityState === "hidden") return schedulePoll();
907
+ sync().then(schedulePoll);
908
+ }, nextDelay()));
909
+ }
910
+ const onVisible = () => {
911
+ if (closed || !doc || doc.visibilityState !== "visible") return void flush();
912
+ if (Date.now() - lastSyncAt >= Math.min(pollIntervalMs > 0 ? pollIntervalMs : 3e4, 3e4) || failures > 0) sync().then(schedulePoll);
913
+ };
914
+ const onOnline = () => {
915
+ if (!closed) sync().then(schedulePoll);
916
+ };
917
+ function onRevision(data) {
918
+ try {
919
+ const m = JSON.parse(data);
920
+ if (!bundle || m.revision !== bundle.revision || (m.checksum ?? null) !== checksum || status !== "ready") {
921
+ streamTriggered = true;
922
+ sync();
923
+ }
924
+ } catch {}
925
+ }
926
+ function startStream() {
927
+ if (closed || options.streaming === false || !doFetch || streamActive) return;
928
+ streamActive = true;
929
+ const ES = typeof EventSource === "function" ? EventSource : void 0;
930
+ if (ES && isBrowserKey) {
931
+ const es = new ES(`${baseUrl}/v1/stream?key=${encodeURIComponent(sdkKey)}`);
932
+ eventSource = es;
933
+ es.addEventListener("revision", (e) => onRevision(String(e.data)));
934
+ es.addEventListener("revoked", () => {
935
+ es.close();
936
+ sync();
937
+ });
938
+ return;
939
+ }
940
+ streamLoop(++streamGen);
941
+ }
942
+ function stopStream() {
943
+ streamGen++;
944
+ streamActive = false;
945
+ streamAbort?.abort();
946
+ streamAbort = null;
947
+ eventSource?.close();
948
+ eventSource = null;
949
+ }
950
+ async function streamLoop(gen) {
951
+ let attempt = 0;
952
+ const current = () => !closed && gen === streamGen;
953
+ while (current()) {
954
+ const ac = new AbortController();
955
+ streamAbort = ac;
956
+ const startedAt = Date.now();
957
+ let stop = false;
958
+ try {
959
+ const res = await doFetch(`${baseUrl}/v1/stream`, {
960
+ headers: {
961
+ Authorization: `Bearer ${sdkKey}`,
962
+ Accept: "text/event-stream"
963
+ },
964
+ signal: ac.signal
965
+ });
966
+ if (res.status === 401 || res.status === 403) stop = true;
967
+ else if (res.ok && res.body) await readSse(res.body, (event, data) => {
968
+ if (event === "revision") onRevision(data);
969
+ else if (event === "revoked") {
970
+ stop = true;
971
+ ac.abort();
972
+ sync();
973
+ }
974
+ });
975
+ } catch {}
976
+ if (!current()) return;
977
+ if (stop) {
978
+ streamActive = false;
979
+ return;
980
+ }
981
+ attempt = Date.now() - startedAt > 3e4 ? 0 : attempt + 1;
982
+ const wait = Math.min(3e4, 1e3 * 2 ** Math.min(attempt, 5)) * (.5 + Math.random());
983
+ await new Promise((r) => unref(setTimeout(r, wait)));
984
+ }
985
+ }
986
+ const liveWanted = options.streaming !== false && options.live !== false && WS !== void 0 && liveUrl !== null;
987
+ /** Applies one `rev` / `hello` message. Never throws; a message that cannot be trusted changes nothing. */
988
+ async function applyPush(m) {
989
+ if (closed || m.t !== "rev" && m.t !== "hello") return;
990
+ const revision = m.revision;
991
+ if (typeof revision !== "number" || !Number.isInteger(revision) || revision < 1 || typeof m.bundle !== "string") return;
992
+ if (bundle && revision < bundle.revision) return;
993
+ const text = m.bundle;
994
+ const pushedChecksum = typeof m.checksum === "string" ? m.checksum : null;
995
+ let info = null;
996
+ if (verifyOpt) {
997
+ const reject = (why) => {
998
+ emitError(new NanoFlagsError(`Pushed bundle r${revision} was rejected: ${why} The configuration in use is unchanged.`, "signature"));
999
+ sync();
1000
+ };
1001
+ if (typeof m.signature !== "string" || typeof m.environmentId !== "string" || pushedChecksum === null) return reject("it carries no complete signature.");
1002
+ if (pinnedEnvironment !== null && pinnedEnvironment !== m.environmentId) return reject("it was signed for a different environment.");
1003
+ info = {
1004
+ signature: m.signature,
1005
+ kid: typeof m.kid === "string" ? m.kid : null,
1006
+ environmentId: m.environmentId,
1007
+ revision,
1008
+ checksum: pushedChecksum
1009
+ };
1010
+ let ok = false;
1011
+ try {
1012
+ ok = await verifyBundleText(await loadKeys(info.kid), info, text);
1013
+ } catch {
1014
+ ok = false;
1015
+ }
1016
+ if (closed) return;
1017
+ if (!ok) return reject("its signature does not verify.");
1018
+ }
1019
+ let parsed;
1020
+ try {
1021
+ parsed = JSON.parse(text);
1022
+ } catch {
1023
+ parsed = null;
1024
+ }
1025
+ if (!isBundle(parsed) || parsed.revision !== revision) return void emitError(new NanoFlagsError("The edge pushed an invalid bundle; it was ignored.", "invalid_bundle"));
1026
+ if (bundle && revision < bundle.revision) return;
1027
+ failures = 0;
1028
+ if (bundle && revision === bundle.revision) {
1029
+ if (checksum === null || checksum === pushedChecksum) setStatus("ready");
1030
+ return;
1031
+ }
1032
+ if (info) pinnedEnvironment = info.environmentId;
1033
+ sig = info;
1034
+ rawText = info && JSON.stringify(parsed) !== text ? text : null;
1035
+ liveRevision = revision;
1036
+ const committedAt = m.committedAt;
1037
+ adopt(parsed, {
1038
+ etag: pushedChecksum !== null ? `"${pushedChecksum}-${isBrowserKey ? "client" : "server"}"` : null,
1039
+ checksum: pushedChecksum,
1040
+ status: "ready",
1041
+ source: "network",
1042
+ transport: "live",
1043
+ latencyMs: typeof committedAt === "number" && Number.isFinite(committedAt) ? Date.now() - committedAt : null
1044
+ });
1045
+ persist();
1046
+ }
1047
+ /** Closes the current socket; its close handler schedules the reconnect. */
1048
+ function dropLive() {
1049
+ try {
1050
+ socket?.close(1e3, "bye");
1051
+ } catch {}
1052
+ }
1053
+ function connectLive() {
1054
+ if (closed || !liveWanted || socket) return;
1055
+ let ws;
1056
+ try {
1057
+ ws = new WS(liveUrl, [LIVE_PROTOCOL, `key.${sdkKey}`]);
1058
+ } catch {
1059
+ return liveEnded(false, 0, false);
1060
+ }
1061
+ socket = ws;
1062
+ let opened = false;
1063
+ let done = false;
1064
+ let rotating = false;
1065
+ let openedAt = 0;
1066
+ let pingTimer = null;
1067
+ let pongTimer = null;
1068
+ let rotateTimer = null;
1069
+ const end = () => {
1070
+ if (done) return;
1071
+ done = true;
1072
+ clearTimeout(connectTimer);
1073
+ if (pingTimer) clearTimeout(pingTimer);
1074
+ if (pongTimer) clearTimeout(pongTimer);
1075
+ if (rotateTimer) clearTimeout(rotateTimer);
1076
+ ws.onopen = ws.onmessage = ws.onclose = ws.onerror = null;
1077
+ try {
1078
+ ws.close();
1079
+ } catch {}
1080
+ if (socket === ws) socket = null;
1081
+ liveOpen = false;
1082
+ liveEnded(opened, opened ? Date.now() - openedAt : 0, rotating);
1083
+ };
1084
+ const connectTimer = unref(setTimeout(end, LIVE_CONNECT_TIMEOUT_MS));
1085
+ const ping = () => {
1086
+ pingTimer = unref(setTimeout(() => {
1087
+ try {
1088
+ ws.send("ping");
1089
+ } catch {
1090
+ return end();
1091
+ }
1092
+ pongTimer ??= unref(setTimeout(end, LIVE_PONG_TIMEOUT_MS));
1093
+ ping();
1094
+ }, LIVE_PING_MS * (.9 + .2 * Math.random())));
1095
+ };
1096
+ ws.onopen = () => {
1097
+ if (done) return;
1098
+ opened = true;
1099
+ openedAt = Date.now();
1100
+ liveOpen = true;
1101
+ clearTimeout(connectTimer);
1102
+ stopStream();
1103
+ schedulePoll();
1104
+ ping();
1105
+ rotateTimer = unref(setTimeout(() => {
1106
+ rotating = true;
1107
+ end();
1108
+ }, LIVE_ROTATE_MS * (.9 + .2 * Math.random())));
1109
+ };
1110
+ ws.onmessage = (event) => {
1111
+ if (done || typeof event?.data !== "string") return;
1112
+ if (pongTimer) clearTimeout(pongTimer);
1113
+ pongTimer = null;
1114
+ if (event.data === "pong") return;
1115
+ let m;
1116
+ try {
1117
+ m = JSON.parse(event.data);
1118
+ } catch {
1119
+ return;
1120
+ }
1121
+ if (typeof m !== "object" || m === null || Array.isArray(m)) return;
1122
+ pushChain = pushChain.then(() => applyPush(m)).catch(() => void 0);
1123
+ };
1124
+ ws.onclose = end;
1125
+ ws.onerror = end;
1126
+ }
1127
+ function liveEnded(opened, openMs, rotating) {
1128
+ if (closed) return;
1129
+ if (rotating) return connectLive();
1130
+ liveAttempt = opened && openMs > 3e4 ? 1 : liveAttempt + 1;
1131
+ if (!opened) startStream();
1132
+ else schedulePoll();
1133
+ const wait = Math.min(LIVE_MAX_BACKOFF_MS, 1e3 * 2 ** Math.min(liveAttempt - 1, 10)) * (.5 + Math.random());
1134
+ if (liveTimer) clearTimeout(liveTimer);
1135
+ liveTimer = unref(setTimeout(() => {
1136
+ liveTimer = null;
1137
+ connectLive();
1138
+ }, wait));
1139
+ }
1140
+ function startRealtime() {
1141
+ if (closed || options.streaming === false) return;
1142
+ if (liveWanted) connectLive();
1143
+ else startStream();
1144
+ }
1145
+ function attrsUsedBy(b, flagKey) {
1146
+ let cache = b ? usedAttrs.get(b) : void 0;
1147
+ if (b && !cache) usedAttrs.set(b, cache = /* @__PURE__ */ new Map());
1148
+ const hit = cache?.get(flagKey);
1149
+ if (hit) return hit;
1150
+ const names = /* @__PURE__ */ new Set();
1151
+ const flag = b && Object.prototype.hasOwnProperty.call(b.flags, flagKey) ? b.flags[flagKey] : void 0;
1152
+ const visit = (conditions, depth) => {
1153
+ for (const c of conditions) if (c.op === "segment" || c.op === "not_segment") {
1154
+ const seg = b && typeof c.value === "string" && Object.prototype.hasOwnProperty.call(b.segments, c.value) ? b.segments[c.value] : void 0;
1155
+ if (seg && depth < 4) visit(seg.conditions, depth + 1);
1156
+ } else if (c.attribute) names.add(c.attribute);
1157
+ };
1158
+ try {
1159
+ for (const r of flag?.rules ?? []) {
1160
+ visit(r.conditions, 0);
1161
+ if (r.serve.type === "rollout") names.add(r.serve.bucketBy ?? "key");
1162
+ }
1163
+ } catch {}
1164
+ const list = [...names];
1165
+ cache?.set(flagKey, list);
1166
+ return list;
1167
+ }
1168
+ function defaultRedact(b, flagKey, ctx) {
1169
+ const out = {};
1170
+ for (const name of attrsUsedBy(b, flagKey)) if (Object.prototype.hasOwnProperty.call(ctx, name) && ctx[name] !== void 0) out[name] = typeOf(ctx[name]);
1171
+ return out;
1172
+ }
1173
+ function record(e, ctx, b, sessionId) {
1174
+ if (!eventsEnabled || closed || sampleRate <= 0 || random() >= sampleRate) return;
1175
+ if (queue.length >= MAX_QUEUE) return;
1176
+ try {
1177
+ queue.push({
1178
+ flagKey: e.flagKey,
1179
+ value: e.value,
1180
+ reason: e.reason,
1181
+ ...e.ruleIndex === void 0 ? {} : { ruleIndex: e.ruleIndex },
1182
+ revision: e.revision,
1183
+ context: ev.redact ? ev.redact({ ...ctx }) : defaultRedact(b, e.flagKey, ctx),
1184
+ at: (/* @__PURE__ */ new Date()).toISOString(),
1185
+ ...sessionId === void 0 ? {} : { sessionId }
1186
+ });
1187
+ } catch {}
1188
+ }
1189
+ async function flush() {
1190
+ if (!eventsEnabled || !doFetch) return;
1191
+ const batch = queue.splice(0, MAX_BATCH).map((e) => {
1192
+ let value = e.value;
1193
+ try {
1194
+ if (typeof value === "object" && value !== null && JSON.stringify(value).length > MAX_EVENT_VALUE_CHARS) value = null;
1195
+ else if (typeof value === "string" && value.length > MAX_EVENT_VALUE_CHARS) value = value.slice(0, MAX_EVENT_VALUE_CHARS);
1196
+ } catch {
1197
+ value = null;
1198
+ }
1199
+ return {
1200
+ ...e,
1201
+ flagKey: e.flagKey.slice(0, 63),
1202
+ value
1203
+ };
1204
+ });
1205
+ lastReportAt = Date.now();
1206
+ try {
1207
+ await request("/v1/events", {
1208
+ method: "POST",
1209
+ keepalive: true,
1210
+ headers: { "Content-Type": "application/json" },
1211
+ body: JSON.stringify({
1212
+ service,
1213
+ sdk: `js/${SDK_VERSION}`,
1214
+ revision: bundle?.revision ?? 0,
1215
+ events: batch
1216
+ })
1217
+ });
1218
+ } catch {}
1219
+ }
1220
+ function scheduleFlush() {
1221
+ if (closed || !eventsEnabled || flushIntervalMs <= 0) return;
1222
+ flushTimer = unref(setTimeout(() => {
1223
+ flushTimer = null;
1224
+ if (queue.length === 0 && Date.now() - lastReportAt < reportIntervalMs) return scheduleFlush();
1225
+ flush().then(scheduleFlush);
1226
+ }, flushIntervalMs));
1227
+ }
1228
+ const live = (s) => s !== null && Date.now() < s.expiresAtMs ? s : null;
1229
+ /** `scope` undefined: the client's own accessors (which follow the browser page session, if any). */
1230
+ function run(key, fallback, ctx, accepts, scope) {
1231
+ const s = live(scope === void 0 ? pageSession : scope);
1232
+ const b = s ? s.bundle : bundle;
1233
+ const context = s ? {
1234
+ ...s.context,
1235
+ ...ctx
1236
+ } : ctx ?? {};
1237
+ let result = evaluate(b, key, context, fallback);
1238
+ if (accepts && result.reason !== "flag_not_found" && result.reason !== "error" && !accepts(result.value)) {
1239
+ const error = `Flag "${key}" has a ${typeOf(result.value)} value, which does not match the requested type.`;
1240
+ result = {
1241
+ flagKey: key,
1242
+ value: fallback,
1243
+ reason: "error",
1244
+ revision: result.revision,
1245
+ trace: [...result.trace, {
1246
+ kind: "result",
1247
+ title: "Returned the fallback",
1248
+ detail: error
1249
+ }],
1250
+ error
1251
+ };
1252
+ }
1253
+ record(result, context, b, s?.id);
1254
+ return result;
1255
+ }
1256
+ function evaluatorFor(state) {
1257
+ return {
1258
+ get active() {
1259
+ return live(state) !== null;
1260
+ },
1261
+ id: state ? state.id : null,
1262
+ expiresAt: state ? state.expiresAt : null,
1263
+ get revision() {
1264
+ const s = live(state);
1265
+ return s ? s.bundle.revision : bundle ? bundle.revision : null;
1266
+ },
1267
+ boolean: (key, fallback, ctx) => run(key, fallback, ctx, (v) => typeof v === "boolean", state).value,
1268
+ string: (key, fallback, ctx) => run(key, fallback, ctx, (v) => typeof v === "string", state).value,
1269
+ number: (key, fallback, ctx) => run(key, fallback, ctx, (v) => typeof v === "number" && Number.isFinite(v), state).value,
1270
+ json: (key, fallback, ctx) => run(key, fallback, ctx, (v) => v !== void 0, state).value,
1271
+ evaluate: (key, fallback, ctx) => run(key, fallback, ctx, null, state)
1272
+ };
1273
+ }
1274
+ /** Resolves to null for "no such session" (unknown, expired, revoked, service not permitted) and on any failure. */
1275
+ async function fetchSession(token, serviceName) {
1276
+ try {
1277
+ const res = await request("/v1/session", {
1278
+ method: "GET",
1279
+ cache: "no-store",
1280
+ headers: {
1281
+ "X-NanoFlags-Session": token,
1282
+ ...serviceName ? { "X-NanoFlags-Service": serviceName } : {}
1283
+ }
1284
+ });
1285
+ if (res.status === 404) return {
1286
+ state: null,
1287
+ failed: false
1288
+ };
1289
+ if (!res.ok) throw new NanoFlagsError(`Session request failed with HTTP ${res.status}.`, res.status === 401 || res.status === 403 ? "unauthorized" : "session", res.status);
1290
+ const body = await res.json();
1291
+ const expiresAtMs = typeof body?.expires_at === "string" ? Date.parse(body.expires_at) : NaN;
1292
+ if (!body || typeof body.id !== "string" || !isBundle(body.bundle) || !Number.isFinite(expiresAtMs)) throw new NanoFlagsError("The edge returned an invalid session.", "session");
1293
+ if (expiresAtMs <= Date.now()) return {
1294
+ state: null,
1295
+ failed: false
1296
+ };
1297
+ const context = typeof body.context === "object" && body.context !== null && !Array.isArray(body.context) ? body.context : {};
1298
+ return {
1299
+ state: {
1300
+ id: body.id,
1301
+ bundle: body.bundle,
1302
+ context,
1303
+ expiresAt: new Date(expiresAtMs).toISOString(),
1304
+ expiresAtMs
1305
+ },
1306
+ failed: false
1307
+ };
1308
+ } catch (e) {
1309
+ if (!closed) emitError(e instanceof NanoFlagsError ? e : new NanoFlagsError(`Could not load the test session: ${e instanceof Error ? e.message : String(e)}`, "session"));
1310
+ return {
1311
+ state: null,
1312
+ failed: true
1313
+ };
1314
+ }
1315
+ }
1316
+ function resolveSession(token, opts) {
1317
+ if (!isSessionToken(token) || closed || baseUrl === "") return Promise.resolve(null);
1318
+ const serviceName = opts?.service ?? options.service;
1319
+ const cacheKey = `${serviceName ?? ""}\n${token}`;
1320
+ const now = Date.now();
1321
+ const hit = sessions.get(cacheKey);
1322
+ if (hit && now < hit.until) return hit.promise;
1323
+ sessions.delete(cacheKey);
1324
+ if (sessions.size >= MAX_SESSIONS) {
1325
+ for (const [k, v] of sessions) if (now >= v.until) sessions.delete(k);
1326
+ for (const k of sessions.keys()) {
1327
+ if (sessions.size < MAX_SESSIONS) break;
1328
+ sessions.delete(k);
1329
+ }
1330
+ }
1331
+ const entry = {
1332
+ promise: Promise.resolve(null),
1333
+ until: Number.POSITIVE_INFINITY
1334
+ };
1335
+ entry.promise = fetchSession(token, serviceName).then(({ state, failed }) => {
1336
+ entry.until = state ? state.expiresAtMs : Date.now() + (failed ? SESSION_ERROR_TTL_MS : SESSION_MISS_TTL_MS);
1337
+ return state;
1338
+ });
1339
+ sessions.set(cacheKey, entry);
1340
+ return entry.promise;
1341
+ }
1342
+ function pageStorage() {
1343
+ try {
1344
+ return isBrowser && typeof sessionStorage !== "undefined" ? sessionStorage : null;
1345
+ } catch {
1346
+ return null;
1347
+ }
1348
+ }
1349
+ function setPageSession(state, resolved) {
1350
+ if (pageTimer) clearTimeout(pageTimer);
1351
+ pageTimer = null;
1352
+ pageSession = state;
1353
+ pageResolved = resolved;
1354
+ if (state) {
1355
+ const wait = Math.min(Math.max(0, state.expiresAtMs - Date.now()) + 50, 2147e6);
1356
+ pageTimer = unref(setTimeout(() => {
1357
+ pageTimer = null;
1358
+ if (pageSession === state) changed();
1359
+ }, wait));
1360
+ }
1361
+ changed();
1362
+ }
1363
+ async function usePageSession(token) {
1364
+ if (!isBrowser) {
1365
+ if (token !== null) emitError(new NanoFlagsError("useSession() is for browsers. On servers use client.session(token) or @nanoflags/sdk/node.", "config"));
1366
+ return evaluatorFor(null);
1367
+ }
1368
+ const storage = pageStorage();
1369
+ if (!isSessionToken(token)) {
1370
+ pageToken = null;
1371
+ try {
1372
+ storage?.removeItem(STORAGE_KEY);
1373
+ } catch {}
1374
+ setPageSession(null, false);
1375
+ return evaluatorFor(null);
1376
+ }
1377
+ pageToken = token;
1378
+ try {
1379
+ storage?.setItem(STORAGE_KEY, token);
1380
+ } catch {}
1381
+ const state = await resolveSession(token, void 0);
1382
+ if (pageToken === token) setPageSession(state, true);
1383
+ return evaluatorFor(state);
1384
+ }
1385
+ const client = {
1386
+ ready: () => readyPromise,
1387
+ get status() {
1388
+ return status;
1389
+ },
1390
+ get revision() {
1391
+ return bundle ? bundle.revision : null;
1392
+ },
1393
+ get version() {
1394
+ return version;
1395
+ },
1396
+ get transport() {
1397
+ return liveOpen ? "live" : streamActive ? "stream" : "poll";
1398
+ },
1399
+ boolean: (key, fallback, ctx) => run(key, fallback, ctx, (v) => typeof v === "boolean").value,
1400
+ string: (key, fallback, ctx) => run(key, fallback, ctx, (v) => typeof v === "string").value,
1401
+ number: (key, fallback, ctx) => run(key, fallback, ctx, (v) => typeof v === "number" && Number.isFinite(v)).value,
1402
+ json: (key, fallback, ctx) => run(key, fallback, ctx, (v) => v !== void 0).value,
1403
+ evaluate: (key, fallback, ctx) => run(key, fallback, ctx, null),
1404
+ session: (token, opts) => resolveSession(token, opts).then(evaluatorFor, () => evaluatorFor(null)),
1405
+ useSession: (token) => usePageSession(token).catch(() => evaluatorFor(null)),
1406
+ sessionHeaders() {
1407
+ const headers = {};
1408
+ if (pageToken !== null && !(pageResolved && live(pageSession) === null)) headers[SESSION_HEADER] = pageToken;
1409
+ return headers;
1410
+ },
1411
+ on(event, cb) {
1412
+ const set = listeners[event];
1413
+ set.add(cb);
1414
+ return () => void set.delete(cb);
1415
+ },
1416
+ subscribe(listener) {
1417
+ subscribers.add(listener);
1418
+ return () => void subscribers.delete(listener);
1419
+ },
1420
+ async refresh() {
1421
+ if (inFlight) await inFlight;
1422
+ return sync();
1423
+ },
1424
+ flush,
1425
+ close() {
1426
+ if (closed) return;
1427
+ flush();
1428
+ closed = true;
1429
+ if (pollTimer) clearTimeout(pollTimer);
1430
+ if (flushTimer) clearTimeout(flushTimer);
1431
+ if (pageTimer) clearTimeout(pageTimer);
1432
+ sessions.clear();
1433
+ if (liveTimer) clearTimeout(liveTimer);
1434
+ dropLive();
1435
+ stopStream();
1436
+ doc?.removeEventListener("visibilitychange", onVisible);
1437
+ if (typeof removeEventListener === "function") removeEventListener("online", onOnline);
1438
+ resolveReady();
1439
+ }
1440
+ };
1441
+ if (options.bootstrap !== void 0) {
1442
+ if (isBundle(options.bootstrap)) adopt(options.bootstrap, {
1443
+ etag: null,
1444
+ checksum: null,
1445
+ status: "ready",
1446
+ source: "bootstrap"
1447
+ });
1448
+ else queueMicrotask(() => emitError(new NanoFlagsError("The bootstrap bundle is invalid and was ignored.", "invalid_bundle")));
1449
+ }
1450
+ if (verifyOpt && Array.isArray(verifyOpt.publicKeys) && verifyOpt.publicKeys.some((k) => !isPublicEd25519Jwk(k))) queueMicrotask(() => emitError(new NanoFlagsError("`verify.publicKeys` must contain public Ed25519 JWKs only. Other entries (including private keys) were ignored.", "config")));
1451
+ if (typeof sdkKey !== "string" || sdkKey === "" || baseUrl === "") {
1452
+ queueMicrotask(() => emitError(new NanoFlagsError("`sdkKey` and `baseUrl` are required.", "config")));
1453
+ resolveReady();
1454
+ return client;
1455
+ }
1456
+ Promise.all([restore(), sync()]).then(() => {
1457
+ resolveReady();
1458
+ if (closed) return;
1459
+ schedulePoll();
1460
+ scheduleFlush();
1461
+ startRealtime();
1462
+ });
1463
+ try {
1464
+ const remembered = pageStorage()?.getItem(STORAGE_KEY);
1465
+ if (isSessionToken(remembered)) usePageSession(remembered).catch(() => void 0);
1466
+ } catch {}
1467
+ doc?.addEventListener("visibilitychange", onVisible);
1468
+ if (doc && typeof addEventListener === "function") addEventListener("online", onOnline);
1469
+ return client;
1470
+ }
1471
+ export { createClient as a, SESSION_HEADER as i, NanoFlagsError as n, isSessionToken as o, SDK_VERSION as r, LIVE_PROTOCOL as t };