@toggld/js 0.0.0-stage → 0.9.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,921 @@
1
+ import { a as decodeOfrepSuccess, d as discard, f as makeSend, h as withTimeout, i as decodeOfrepFailure, l as apiUrl, m as refusalMessage, n as decodeOfrepBulk, o as decodeSnapshot, p as readJson, s as decodeStreamMessage, u as describe } from "./wire-CrkBeqnM.mjs";
2
+ import { _ as wireErrorDetails, a as Resolution, b as noTelemetry, d as SYNC_PROTOCOL, f as attributeOf, g as rejectedMessage, h as okDetails, i as syncCredentialProblem, l as STREAM_PONG_TIMEOUT_MS, m as codeFor, n as errorDetails, o as STREAM_PING, p as targetingKeyOf, r as invalidEndpointMessage, s as STREAM_PING_INTERVAL_MS, t as enforceType, u as SYNC_BEARER_PROTOCOL_PREFIX, v as lazyTelemetry, x as startTelemetry } from "./details-AiMdctXB.mjs";
3
+ import { readFile, watch } from "node:fs/promises";
4
+ import { basename, dirname } from "node:path";
5
+ // ../engine/src/hash.ts
6
+ /**
7
+ * MurmurHash3 (x86, 32-bit). A fast, well-distributed, non-cryptographic hash.
8
+ * Used to deterministically bucket a subject into a percentage rollout slice so
9
+ * that a given subject always lands in the same slice for a given flag/rule.
10
+ */
11
+ const murmur3 = (key, seed = 0) => {
12
+ const data = new TextEncoder().encode(key);
13
+ const len = data.length;
14
+ const c1 = 3432918353;
15
+ const c2 = 461845907;
16
+ let h1 = seed >>> 0;
17
+ const nblocks = len >>> 2;
18
+ const mul32 = (a, b) => {
19
+ const aHi = a >>> 16 & 65535;
20
+ return ((a & 65535) * b >>> 0) + ((aHi * b & 65535) << 16) >>> 0;
21
+ };
22
+ const rotl32 = (x, r) => (x << r | x >>> 32 - r) >>> 0;
23
+ for (let i = 0; i < nblocks; i++) {
24
+ const o = i * 4;
25
+ let k1 = (data[o] | data[o + 1] << 8 | data[o + 2] << 16 | data[o + 3] << 24) >>> 0;
26
+ k1 = mul32(k1, c1);
27
+ k1 = rotl32(k1, 15);
28
+ k1 = mul32(k1, c2);
29
+ h1 = (h1 ^ k1) >>> 0;
30
+ h1 = rotl32(h1, 13);
31
+ h1 = mul32(h1, 5) + 3864292196 >>> 0;
32
+ }
33
+ let k1 = 0;
34
+ const tail = nblocks * 4;
35
+ const rem = len & 3;
36
+ if (rem === 3) k1 ^= data[tail + 2] << 16;
37
+ if (rem >= 2) k1 ^= data[tail + 1] << 8;
38
+ if (rem >= 1) {
39
+ k1 ^= data[tail];
40
+ k1 = mul32(k1, c1);
41
+ k1 = rotl32(k1, 15);
42
+ k1 = mul32(k1, c2);
43
+ h1 = (h1 ^ k1) >>> 0;
44
+ }
45
+ h1 = (h1 ^ len) >>> 0;
46
+ h1 = (h1 ^ h1 >>> 16) >>> 0;
47
+ h1 = mul32(h1, 2246822507);
48
+ h1 = (h1 ^ h1 >>> 13) >>> 0;
49
+ h1 = mul32(h1, 3266489909);
50
+ h1 = (h1 ^ h1 >>> 16) >>> 0;
51
+ return h1 >>> 0;
52
+ };
53
+ /** Resolution of a bucket: a subject maps to an integer in `[0, BUCKET_SCALE)`. */
54
+ const BUCKET_SCALE = 1e4;
55
+ /**
56
+ * Deterministically maps a subject to a bucket in `[0, BUCKET_SCALE)`. The hash
57
+ * input combines the flag key, rule id, and subject so that the same subject
58
+ * gets independent buckets across different flags/rules (no correlation).
59
+ */
60
+ const bucketFor = (flagKey, ruleId, subject) => {
61
+ return murmur3(`${flagKey}:${ruleId}:${subject}`) % BUCKET_SCALE;
62
+ };
63
+ // ../engine/src/targeting.ts
64
+ const asNumber = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
65
+ const asString = (v) => typeof v === "string" ? v : void 0;
66
+ /** Evaluates a single condition against the context. */
67
+ const matchCondition = (condition, ctx) => {
68
+ const actual = attributeOf(ctx, condition.attribute);
69
+ const operand = condition.value;
70
+ switch (condition.operator) {
71
+ case "exists": return actual !== void 0 && actual !== null;
72
+ case "notExists": return actual === void 0 || actual === null;
73
+ }
74
+ if (actual === void 0 || actual === null) return false;
75
+ switch (condition.operator) {
76
+ case "eq": return deepEqual(actual, operand ?? null);
77
+ case "ne": return !deepEqual(actual, operand ?? null);
78
+ case "in": return Array.isArray(operand) && operand.some((o) => deepEqual(actual, o));
79
+ case "notIn": return !Array.isArray(operand) || !operand.some((o) => deepEqual(actual, o));
80
+ case "lt":
81
+ case "lte":
82
+ case "gt":
83
+ case "gte": {
84
+ const a = asNumber(actual);
85
+ const b = asNumber(operand);
86
+ if (a === void 0 || b === void 0) return false;
87
+ return condition.operator === "lt" ? a < b : condition.operator === "lte" ? a <= b : condition.operator === "gt" ? a > b : a >= b;
88
+ }
89
+ case "contains":
90
+ case "startsWith":
91
+ case "endsWith": {
92
+ const a = asString(actual);
93
+ const b = asString(operand);
94
+ if (a === void 0 || b === void 0) return false;
95
+ return condition.operator === "contains" ? a.includes(b) : condition.operator === "startsWith" ? a.startsWith(b) : a.endsWith(b);
96
+ }
97
+ }
98
+ };
99
+ /**
100
+ * A rule matches when every one of its conditions matches **and** every
101
+ * referenced segment resolves (member XOR `negate`) through `isMemberOf`
102
+ * (logical AND throughout). A rule with no segment references behaves exactly as
103
+ * conditions-only. `isMemberOf` is consulted only when the rule has segments;
104
+ * when absent, any segment reference resolves `false` (closed).
105
+ */
106
+ const matchRule = (rule, ctx, isMemberOf) => {
107
+ if (!rule.conditions.every((c) => matchCondition(c, ctx))) return false;
108
+ if (rule.segments === void 0 || rule.segments.length === 0) return true;
109
+ return rule.segments.every((ref) => {
110
+ const member = isMemberOf?.(ref.segment) ?? false;
111
+ return ref.negate ? !member : member;
112
+ });
113
+ };
114
+ /**
115
+ * Resolves segment membership for a subject from a synced segment *definition*
116
+ * (ADR-0006) — the local path for rule-based and `inline`-list segments. Pure
117
+ * and isomorphic: the server and the Node SDK build their `isMemberOf` over
118
+ * this. Membership is the union of a rule match (OR of rules, each an AND of
119
+ * conditions) and presence in the inline member list (keyed by `keyAttribute`).
120
+ * `remote` segments carry no `members` here and must be resolved out of band.
121
+ */
122
+ const matchSegment = (segment, ctx) => {
123
+ if (segment.rules.some((r) => r.conditions.every((c) => matchCondition(c, ctx)))) return true;
124
+ if (segment.members !== void 0 && segment.members.length > 0) {
125
+ const subject = attributeOf(ctx, segment.keyAttribute);
126
+ if (typeof subject === "string" && segment.members.includes(subject)) return true;
127
+ }
128
+ return false;
129
+ };
130
+ /**
131
+ * Selects a rollout variant for a subject. Weights are normalised across all
132
+ * splits, and the subject's bucket (consistent for the flag/rule/subject) is
133
+ * mapped onto the cumulative weight ranges. Returns `MissingSubject` when no
134
+ * bucketing value is available (→ `TARGETING_KEY_MISSING`).
135
+ */
136
+ const selectRolloutVariant = (flagKey, ruleId, splits, bucketBy, ctx) => {
137
+ const subject = bucketBy === void 0 ? targetingKeyOf(ctx) : asString(attributeOf(ctx, bucketBy));
138
+ if (subject === void 0) return { _tag: "MissingSubject" };
139
+ const total = splits.reduce((sum, s) => sum + s.weight, 0);
140
+ if (total <= 0) {
141
+ const first = splits[0];
142
+ if (first === void 0) return { _tag: "MissingSubject" };
143
+ return {
144
+ _tag: "Variant",
145
+ variant: first.variant,
146
+ bucket: 0
147
+ };
148
+ }
149
+ const bucket = bucketFor(flagKey, ruleId, subject);
150
+ let cumulative = 0;
151
+ for (const split of splits) {
152
+ cumulative += split.weight / total * BUCKET_SCALE;
153
+ if (bucket < cumulative) return {
154
+ _tag: "Variant",
155
+ variant: split.variant,
156
+ bucket
157
+ };
158
+ }
159
+ return {
160
+ _tag: "Variant",
161
+ variant: splits[splits.length - 1].variant,
162
+ bucket
163
+ };
164
+ };
165
+ const deepEqual = (a, b) => {
166
+ if (a === b) return true;
167
+ if (typeof a !== typeof b) return false;
168
+ if (a === null || b === null) return a === b;
169
+ if (Array.isArray(a) || Array.isArray(b)) {
170
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
171
+ return a.every((x, i) => deepEqual(x, b[i]));
172
+ }
173
+ if (typeof a === "object" && typeof b === "object") {
174
+ const ak = Object.keys(a);
175
+ const bk = Object.keys(b);
176
+ if (ak.length !== bk.length) return false;
177
+ return ak.every((k) => Object.prototype.hasOwnProperty.call(b, k) && deepEqual(a[k], b[k]));
178
+ }
179
+ return false;
180
+ };
181
+ // ../engine/src/evaluate.ts
182
+ /**
183
+ * Looks up a variant's value and returns a successful resolution, or an
184
+ * abnormal `GENERAL` resolution if the variant is not declared (a
185
+ * misconfiguration that validation should have caught).
186
+ */
187
+ const serveVariant = (flag, variant, reason) => {
188
+ if (!Object.prototype.hasOwnProperty.call(flag.variants, variant)) return Resolution.error({
189
+ value: null,
190
+ errorCode: "GENERAL",
191
+ errorMessage: `Flag '${flag.key}' references undeclared variant '${variant}'`,
192
+ flagMetadata: flag.metadata
193
+ });
194
+ return Resolution.ok({
195
+ value: flag.variants[variant],
196
+ variant,
197
+ reason,
198
+ flagMetadata: flag.metadata
199
+ });
200
+ };
201
+ /**
202
+ * Evaluates a flag against an evaluation context, producing OpenFeature
203
+ * resolution details. This is the pure heart of the service.
204
+ *
205
+ * Order of resolution:
206
+ * 1. disabled flag → `offVariant` (or `defaultVariant`) with reason `DISABLED`
207
+ * 2. first matching targeting rule:
208
+ * - fixed variant → `TARGETING_MATCH`
209
+ * - rollout → `SPLIT` (or `TARGETING_KEY_MISSING` if no subject)
210
+ * 3. no rule matches → `defaultVariant` with reason `STATIC`
211
+ *
212
+ * `isMemberOf` resolves any {@link SegmentRef} a rule references (ADR-0006). It
213
+ * is optional: flags with no segment references never call it, and when it is
214
+ * omitted a segment reference resolves `false` (closed).
215
+ */
216
+ const evaluateFlag = (flag, ctx, isMemberOf) => {
217
+ if (!flag.enabled) return serveVariant(flag, flag.offVariant ?? flag.defaultVariant, "DISABLED");
218
+ for (const rule of flag.targeting) {
219
+ if (!matchRule(rule, ctx, isMemberOf)) continue;
220
+ if (rule.serve.type === "variant") return serveVariant(flag, rule.serve.variant, "TARGETING_MATCH");
221
+ const outcome = selectRolloutVariant(flag.key, rule.id, rule.serve.splits, rule.serve.bucketBy, ctx);
222
+ if (outcome._tag === "MissingSubject") return Resolution.error({
223
+ value: flag.variants[flag.defaultVariant] ?? null,
224
+ variant: flag.defaultVariant,
225
+ errorCode: "TARGETING_KEY_MISSING",
226
+ errorMessage: `Flag '${flag.key}' rule '${rule.id}' requires a bucketing key but none was provided`,
227
+ flagMetadata: flag.metadata
228
+ });
229
+ return serveVariant(flag, outcome.variant, "SPLIT");
230
+ }
231
+ return serveVariant(flag, flag.defaultVariant, "STATIC");
232
+ };
233
+ // src/internal/evaluate.ts
234
+ /** Map an engine {@link Resolution} to evaluation details, OpenFeature-style:
235
+ * an abnormal resolution serves the **caller's** default, not the engine's
236
+ * internal fallback — exactly what the remote wire does on a 400. */
237
+ const resolutionDetails = (key, resolution, fallback) => resolution.errorCode !== void 0 ? errorDetails(key, fallback, resolution.errorCode, resolution.errorMessage ?? "", resolution.flagMetadata) : {
238
+ flagKey: key,
239
+ value: typeof resolution.value === "object" && resolution.value !== null ? structuredClone(resolution.value) : resolution.value,
240
+ variant: resolution.variant,
241
+ reason: resolution.reason,
242
+ errorCode: void 0,
243
+ errorMessage: void 0,
244
+ flagMetadata: { ...resolution.flagMetadata }
245
+ };
246
+ /**
247
+ * Whether a fetched snapshot may replace the held one. Without a
248
+ * `configVersion` on either side (an older server, a file), versions can't be
249
+ * ordered, so the fetch wins. Stream messages are not gated: one connection
250
+ * delivers them in order, and its greeting resets the order after a reconnect.
251
+ */
252
+ const isNewer = (incoming, held) => incoming === void 0 || held === void 0 || incoming > held;
253
+ /**
254
+ * A snapshot version that can travel as a strong entity tag: visible ASCII
255
+ * without `"`. The server only sends an `ETag` for such versions, so the
256
+ * clients only send `If-None-Match` for them.
257
+ */
258
+ const etagFor = (version) => version !== void 0 && /^[\x21\x23-\x7e]+$/.test(version) ? `"${version}"` : void 0;
259
+ /**
260
+ * The in-memory snapshot a source feeds and the engine reads, plus the
261
+ * evaluation surface over it. Sources (network, file, serverless refresh) differ
262
+ * only in how they produce snapshots; everything downstream of `adopt` is
263
+ * shared.
264
+ */
265
+ const makeSnapshotStore = (mergedContext, record) => {
266
+ let current;
267
+ let epoch = 0;
268
+ let lastError;
269
+ let markReady;
270
+ const ready = new Promise((resolve) => {
271
+ markReady = resolve;
272
+ });
273
+ const notReadyMessage = () => lastError === void 0 ? "No flag snapshot loaded yet" : `No flag snapshot loaded yet. Last error: ${lastError}`;
274
+ const sink = {
275
+ adopt: (snapshot) => {
276
+ epoch++;
277
+ lastError = void 0;
278
+ current = {
279
+ version: snapshot.version,
280
+ configVersion: snapshot.configVersion,
281
+ flags: snapshot.flags,
282
+ byKey: new Map(snapshot.flags.map((f) => [f.key, f])),
283
+ segmentsByKey: new Map((snapshot.segments ?? []).map((s) => [s.key, s]))
284
+ };
285
+ markReady();
286
+ },
287
+ fail: (message) => {
288
+ lastError = message;
289
+ },
290
+ epoch: () => epoch,
291
+ version: () => current?.version,
292
+ configVersion: () => current?.configVersion
293
+ };
294
+ const warnedRemoteSegments = /* @__PURE__ */ new Set();
295
+ const memberOf = (state, ctx) => (segmentKey) => {
296
+ const segment = state.segmentsByKey.get(segmentKey);
297
+ if (segment === void 0) return false;
298
+ if (matchSegment(segment, ctx)) return true;
299
+ if (segment.listMode === "remote" && !warnedRemoteSegments.has(segmentKey)) {
300
+ warnedRemoteSegments.add(segmentKey);
301
+ console.warn(`[toggld] Segment '${segmentKey}' uses a remote member list, which is not available to in-process evaluation yet — treating subjects its rules don't match as non-members.`);
302
+ }
303
+ return false;
304
+ };
305
+ const evaluate = (key, defaultValue, context) => {
306
+ const state = current;
307
+ if (state === void 0) return errorDetails(key, defaultValue, "PROVIDER_NOT_READY", notReadyMessage());
308
+ const flag = state.byKey.get(key);
309
+ if (flag === void 0) return errorDetails(key, defaultValue, "FLAG_NOT_FOUND", `Flag '${key}' not found`);
310
+ const ctx = mergedContext(context);
311
+ const resolution = evaluateFlag(flag, ctx, memberOf(state, ctx));
312
+ record(key, resolution);
313
+ return resolutionDetails(key, resolution, defaultValue);
314
+ };
315
+ const evaluateAll = (context) => {
316
+ const state = current;
317
+ if (state === void 0) return [];
318
+ const ctx = mergedContext(context);
319
+ const isMemberOf = memberOf(state, ctx);
320
+ return state.flags.map((flag) => {
321
+ const resolution = evaluateFlag(flag, ctx, isMemberOf);
322
+ record(flag.key, resolution);
323
+ return resolutionDetails(flag.key, resolution, null);
324
+ });
325
+ };
326
+ return {
327
+ sink,
328
+ ready,
329
+ evaluate,
330
+ evaluateAll,
331
+ isReady: () => current !== void 0,
332
+ lastError: () => lastError
333
+ };
334
+ };
335
+ // src/internal/sync.ts
336
+ /**
337
+ * The two in-process snapshot sources, on plain timers and callbacks:
338
+ *
339
+ * - the network source for sync mode (ADR-0012): bootstrap + safety-net poll
340
+ * over `GET /sync/v1/flags`, plus a live WebSocket stream (`/sync/v1/stream`);
341
+ * - the file source for file mode (ADR-0013): a snapshot on disk, watched for
342
+ * edits.
343
+ *
344
+ * Each `start…` function begins work at once and returns a `stop` function
345
+ * that cancels every timer, socket, and watcher it owns. Work that is already
346
+ * in flight when `stop` runs is abandoned, and never adopts.
347
+ */
348
+ const DEFAULT_POLL_INTERVAL_MS = 3e4;
349
+ /** The relaxed safety poll while a durable server's stream is healthy. */
350
+ const DEFAULT_STREAM_POLL_INTERVAL_MS = 3e5;
351
+ /** The default flag file in file mode. */
352
+ const DEFAULT_FLAGS_FILE = "./toggld.flags.json";
353
+ /** File mode's safety-net re-read; the directory watcher adopts edits sooner. */
354
+ const FILE_RELOAD_INTERVAL_MS = 3e4;
355
+ const NORMAL_CLOSURE = 1e3;
356
+ /**
357
+ * Timers that all die with their owner. `sleep` resolves early (and `after`
358
+ * callbacks never run) once `stop` is called, so loops end by checking
359
+ * `stopped` after each wait.
360
+ */
361
+ var Lifetime = class {
362
+ stopped = false;
363
+ controller = new AbortController();
364
+ #timers = /* @__PURE__ */ new Set();
365
+ #sleepers = /* @__PURE__ */ new Set();
366
+ after(ms, f) {
367
+ const timer = setTimeout(() => {
368
+ this.#timers.delete(timer);
369
+ if (!this.stopped) f();
370
+ }, ms);
371
+ this.#timers.add(timer);
372
+ return timer;
373
+ }
374
+ cancel(timer) {
375
+ if (timer === void 0) return;
376
+ clearTimeout(timer);
377
+ this.#timers.delete(timer);
378
+ }
379
+ sleep(ms) {
380
+ return new Promise((resolve) => {
381
+ const wake = () => {
382
+ this.#sleepers.delete(wake);
383
+ resolve();
384
+ };
385
+ this.#sleepers.add(wake);
386
+ const timer = setTimeout(() => {
387
+ this.#timers.delete(timer);
388
+ wake();
389
+ }, ms);
390
+ this.#timers.add(timer);
391
+ });
392
+ }
393
+ stop() {
394
+ if (this.stopped) return;
395
+ this.stopped = true;
396
+ for (const timer of this.#timers) clearTimeout(timer);
397
+ this.#timers.clear();
398
+ for (const wake of [...this.#sleepers]) wake();
399
+ this.controller.abort();
400
+ }
401
+ };
402
+ /** The sync stream's `ws(s)://` URL under the endpoint, keeping any path prefix. Exported for tests. */
403
+ const toStreamUrl = (endpoint) => {
404
+ const url = apiUrl(endpoint, "/sync/v1/stream");
405
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
406
+ return url.toString();
407
+ };
408
+ /** Whether `incoming` is a known version above the held one (or the first one). */
409
+ const isNewerLabel = (incoming, held) => incoming !== void 0 && (held === void 0 || incoming > held);
410
+ const textDecoder = new TextDecoder();
411
+ /**
412
+ * The network source (ADR-0012): bootstrap + safety-net poll over
413
+ * `GET /sync/v1/flags`, plus a live WebSocket stream. Feeds `sink.adopt`.
414
+ */
415
+ const startNetworkSource = (options, send, sink, timeoutMs) => {
416
+ const life = new Lifetime();
417
+ const transport = options.sync?.transport ?? "ws";
418
+ const pollIntervalMs = options.sync?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
419
+ const streamPollIntervalMs = Math.max(pollIntervalMs, options.sync?.streamPollIntervalMs ?? DEFAULT_STREAM_POLL_INTERVAL_MS);
420
+ const health = {
421
+ open: false,
422
+ durable: false,
423
+ heartbeat: false,
424
+ answered: false
425
+ };
426
+ const isRelaxed = () => health.open && health.durable && health.heartbeat && health.answered;
427
+ let relaxedNow = false;
428
+ const updateHealth = (change) => {
429
+ Object.assign(health, change);
430
+ const relaxed = isRelaxed();
431
+ if (relaxed === relaxedNow) return;
432
+ relaxedNow = relaxed;
433
+ if (pollTimer !== void 0) schedulePoll();
434
+ };
435
+ const fetchSnapshot = () => withTimeout(timeoutMs, async (signal) => {
436
+ const etag = etagFor(sink.version());
437
+ const response = await send("GET", "/sync/v1/flags", void 0, signal, etag === void 0 ? void 0 : { "if-none-match": etag });
438
+ if (response.status === 304) {
439
+ discard(response);
440
+ return;
441
+ }
442
+ if (response.status < 200 || response.status >= 300) {
443
+ discard(response);
444
+ throw new Error(refusalMessage(response, "The key cannot read flag definitions (HTTP 403). Sync mode needs a secret SDK key (ffk_…)."));
445
+ }
446
+ return decodeSnapshot(await readJson(response, "GET /sync/v1/flags"));
447
+ }, life.controller.signal);
448
+ const report = (error) => {
449
+ if (!life.stopped) sink.fail(describe(error));
450
+ };
451
+ const pollOnce = async () => {
452
+ const before = sink.epoch();
453
+ const snapshot = await fetchSnapshot();
454
+ if (life.stopped || snapshot === void 0) return;
455
+ if (sink.epoch() !== before) return;
456
+ if (sink.version() === snapshot.version) return;
457
+ if (!isNewer(snapshot.configVersion, sink.configVersion())) return;
458
+ sink.adopt(snapshot);
459
+ };
460
+ let pollTimer;
461
+ let lastPollAt = 0;
462
+ const schedulePoll = () => {
463
+ life.cancel(pollTimer);
464
+ pollTimer = void 0;
465
+ if (life.stopped) return;
466
+ const waitMs = Math.max(0, lastPollAt + (relaxedNow ? streamPollIntervalMs : pollIntervalMs) - Date.now());
467
+ if (waitMs === 0) runPoll();
468
+ else pollTimer = life.after(waitMs, () => void runPoll());
469
+ };
470
+ const runPoll = async () => {
471
+ pollTimer = void 0;
472
+ await pollOnce().catch(report);
473
+ lastPollAt = Date.now();
474
+ schedulePoll();
475
+ };
476
+ const startPolling = async () => {
477
+ let delay = 250;
478
+ while (!life.stopped && sink.version() === void 0) {
479
+ await pollOnce().catch(report);
480
+ if (life.stopped || sink.version() !== void 0) break;
481
+ await life.sleep(delay);
482
+ delay = Math.min(delay * 2, 5e3);
483
+ }
484
+ if (life.stopped) return;
485
+ lastPollAt = Date.now();
486
+ schedulePoll();
487
+ };
488
+ const handleMessage = (raw) => {
489
+ if (raw === "pong") return;
490
+ let message;
491
+ try {
492
+ message = decodeStreamMessage(JSON.parse(raw));
493
+ } catch {
494
+ return;
495
+ }
496
+ updateHealth({
497
+ durable: message.durable,
498
+ heartbeat: message.heartbeat
499
+ });
500
+ const { snapshot } = message;
501
+ if (sink.version() === snapshot.version && !isNewerLabel(snapshot.configVersion, sink.configVersion())) return;
502
+ sink.adopt(snapshot);
503
+ };
504
+ const streamUrl = toStreamUrl(options.endpoint ?? "");
505
+ const WebSocketImpl = options.sync?.webSocketConstructor ?? globalThis.WebSocket;
506
+ const protocols = [SYNC_PROTOCOL, `${SYNC_BEARER_PROTOCOL_PREFIX}${options.sdkKey ?? ""}`];
507
+ let consecutiveFailures = 0;
508
+ let endConnection;
509
+ /**
510
+ * One connection: open (within the request timeout), read, and heartbeat,
511
+ * until it closes, errors, or misses a pong. Then reconnect with backoff.
512
+ */
513
+ const connect = () => {
514
+ if (life.stopped) return;
515
+ let ws;
516
+ try {
517
+ ws = new WebSocketImpl(streamUrl, protocols);
518
+ } catch {
519
+ reconnectLater();
520
+ return;
521
+ }
522
+ try {
523
+ ws.binaryType = "arraybuffer";
524
+ } catch {}
525
+ let opened = false;
526
+ let ended = false;
527
+ let awaitingPong = false;
528
+ let heartbeatTimer;
529
+ const openTimer = life.after(timeoutMs, () => end());
530
+ const beatIn = (ms) => {
531
+ heartbeatTimer = life.after(ms, () => {
532
+ if (!health.heartbeat) return beatIn(STREAM_PING_INTERVAL_MS);
533
+ awaitingPong = true;
534
+ try {
535
+ ws.send(STREAM_PING);
536
+ } catch {
537
+ return end();
538
+ }
539
+ heartbeatTimer = life.after(STREAM_PONG_TIMEOUT_MS, () => {
540
+ if (awaitingPong) return end();
541
+ beatIn(STREAM_PING_INTERVAL_MS - STREAM_PONG_TIMEOUT_MS);
542
+ });
543
+ });
544
+ };
545
+ const onOpen = () => {
546
+ if (ended || opened) return;
547
+ opened = true;
548
+ life.cancel(openTimer);
549
+ consecutiveFailures = 0;
550
+ updateHealth({ open: true });
551
+ beatIn(STREAM_PING_INTERVAL_MS);
552
+ };
553
+ const onFrame = (text) => {
554
+ if (ended) return;
555
+ if (awaitingPong) {
556
+ awaitingPong = false;
557
+ updateHealth({ answered: true });
558
+ }
559
+ handleMessage(text);
560
+ };
561
+ const onMessage = (event) => {
562
+ const data = event.data;
563
+ if (typeof data === "string") onFrame(data);
564
+ else if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) onFrame(textDecoder.decode(data));
565
+ else if (typeof Blob !== "undefined" && data instanceof Blob) data.text().then(onFrame, () => end());
566
+ };
567
+ const onEnd = () => end();
568
+ const end = () => {
569
+ if (ended) return;
570
+ ended = true;
571
+ endConnection = void 0;
572
+ life.cancel(openTimer);
573
+ life.cancel(heartbeatTimer);
574
+ ws.removeEventListener("open", onOpen);
575
+ ws.removeEventListener("message", onMessage);
576
+ ws.removeEventListener("error", onEnd);
577
+ ws.removeEventListener("close", onEnd);
578
+ ws.addEventListener("error", () => {});
579
+ try {
580
+ ws.close(NORMAL_CLOSURE);
581
+ } catch {}
582
+ if (opened) updateHealth({
583
+ open: false,
584
+ durable: false,
585
+ heartbeat: false,
586
+ answered: false
587
+ });
588
+ reconnectLater();
589
+ };
590
+ endConnection = end;
591
+ ws.addEventListener("open", onOpen);
592
+ ws.addEventListener("message", onMessage);
593
+ ws.addEventListener("error", onEnd);
594
+ ws.addEventListener("close", onEnd);
595
+ if (ws.readyState >= 2) end();
596
+ else if (ws.readyState === 1) onOpen();
597
+ };
598
+ const reconnectLater = () => {
599
+ if (life.stopped) return;
600
+ consecutiveFailures++;
601
+ const base = Math.min(3e4, 1e3 * 2 ** Math.min(consecutiveFailures - 1, 5));
602
+ life.after(base * (.5 + Math.random() * .5), connect);
603
+ };
604
+ startPolling();
605
+ if (transport === "ws") connect();
606
+ return () => {
607
+ if (life.stopped) return;
608
+ life.stop();
609
+ endConnection?.();
610
+ };
611
+ };
612
+ /**
613
+ * The file source (ADR-0013): read a `FlagSnapshot` from disk, watch it for
614
+ * edits, and adopt on change. The envelope keys a `pull`-generated file carries
615
+ * (`$schema`, `toggld`) are ignored by the snapshot decoder. No network, key, or
616
+ * stream. Re-reads on a fixed safety interval; `sync` options do not apply.
617
+ */
618
+ const startFileSource = (path, sink) => {
619
+ const life = new Lifetime();
620
+ let lastError;
621
+ let loadedText;
622
+ const readOnce = async () => {
623
+ try {
624
+ const text = await readFile(path, "utf8");
625
+ if (life.stopped) return;
626
+ if (text === loadedText) {
627
+ lastError = void 0;
628
+ return;
629
+ }
630
+ const snapshot = decodeSnapshot(JSON.parse(text));
631
+ lastError = void 0;
632
+ loadedText = text;
633
+ sink.adopt(snapshot);
634
+ } catch (error) {
635
+ if (life.stopped) return;
636
+ const message = describe(error);
637
+ sink.fail(`Could not load flags file '${path}': ${message}`);
638
+ if (message === lastError) return;
639
+ lastError = message;
640
+ console.warn(`[toggld] Could not load flags file '${path}': ${message}`);
641
+ }
642
+ };
643
+ const firstRead = readOnce();
644
+ const ticker = async () => {
645
+ await firstRead;
646
+ let delay = 100;
647
+ while (!life.stopped && sink.version() === void 0) {
648
+ await life.sleep(delay);
649
+ if (life.stopped) break;
650
+ delay = Math.min(delay * 2, 2e3);
651
+ await readOnce();
652
+ }
653
+ while (!life.stopped) {
654
+ await life.sleep(FILE_RELOAD_INTERVAL_MS);
655
+ if (life.stopped) break;
656
+ await readOnce();
657
+ }
658
+ };
659
+ const dir = dirname(path);
660
+ const base = basename(path);
661
+ const watchLoop = async () => {
662
+ while (!life.stopped) {
663
+ let iterator;
664
+ try {
665
+ iterator = watch(dir, { signal: life.controller.signal })[Symbol.asyncIterator]();
666
+ } catch {
667
+ iterator = void 0;
668
+ }
669
+ if (iterator === void 0) {
670
+ await life.sleep(1e3);
671
+ continue;
672
+ }
673
+ for (;;) {
674
+ let next;
675
+ try {
676
+ next = await iterator.next();
677
+ } catch {
678
+ break;
679
+ }
680
+ if (next.done) break;
681
+ const filename = next.value?.filename;
682
+ if (filename === base || filename === null || filename === void 0) await readOnce();
683
+ }
684
+ if (life.stopped) break;
685
+ await life.sleep(500);
686
+ }
687
+ };
688
+ watchLoop();
689
+ ticker();
690
+ return {
691
+ stop: () => life.stop(),
692
+ firstRead
693
+ };
694
+ };
695
+ // src/internal/core.ts
696
+ const DEFAULT_TIMEOUT_MS = 5e3;
697
+ const DEFAULT_READY_TIMEOUT_MS = 5e3;
698
+ /**
699
+ * Refuse, at creation, a sync-mode client that can never sync: no key, a
700
+ * public token (the API answers `/sync/v1/*` with 403 for those, so the client
701
+ * would serve fallback values forever), or no usable endpoint. Sync is the
702
+ * default mode, so each message also names the way out.
703
+ */
704
+ const assertCanSync = (options) => {
705
+ switch (syncCredentialProblem(options)) {
706
+ case "missing-key": throw new TypeError("[toggld] `sdkKey` is required. Sync mode (`mode: \"sync\"`, the default) downloads flag definitions with a secret SDK key (ffk_…). To evaluate from a flag file instead, set `mode: \"file\"` or TOGGLD_FLAGS_FILE.");
707
+ case "public-token": throw new TypeError("[toggld] `sdkKey` is a public token (ffp_…). Public tokens cannot download flag definitions, so they cannot run in sync mode (`mode: \"sync\"`, the default). Use a secret SDK key (ffk_…), or set `mode: \"remote\"` to send each evaluation to the API.");
708
+ case "invalid-endpoint": throw new TypeError(invalidEndpointMessage(options.endpoint));
709
+ case void 0: return;
710
+ }
711
+ };
712
+ /**
713
+ * The runtime handles one request at a time per I/O context and cannot keep a
714
+ * WebSocket, timers, or a background poll between requests: Cloudflare Workers
715
+ * (`navigator.userAgent`) and Vercel Edge (the `EdgeRuntime` global). Sync
716
+ * mode needs all three, so it refuses to start there.
717
+ */
718
+ const requestScopedRuntime = () => {
719
+ const g = globalThis;
720
+ if (g.navigator?.userAgent === "Cloudflare-Workers") return "Cloudflare Workers";
721
+ if (typeof g.EdgeRuntime === "string") return "an edge runtime";
722
+ };
723
+ const assertLongLivedRuntime = () => {
724
+ const runtime = requestScopedRuntime();
725
+ if (runtime === void 0) return warnOnLambda();
726
+ throw new TypeError(`[toggld] Sync mode (\`mode: "sync"\`, the default) keeps a WebSocket, timers, and a background poll, which ${runtime} cannot hold between requests. Use "@toggld/js/serverless", the remote client for serverless runtimes (one bulk request per incoming request), or set \`mode: "remote"\` to send each evaluation to the API.`);
727
+ };
728
+ /**
729
+ * AWS Lambda freezes the process between invocations. Sync mode still works
730
+ * there, but its stream and poll stall while frozen, so after a
731
+ * freeze an evaluation can use a snapshot of any age until the stream
732
+ * reconnects. Warn once per process; the serverless client (remote, one bulk
733
+ * request per invocation) fits better.
734
+ */
735
+ let warnedLambda = false;
736
+ const warnOnLambda = () => {
737
+ if (warnedLambda) return;
738
+ const name = typeof process === "undefined" ? void 0 : process.env.AWS_LAMBDA_FUNCTION_NAME;
739
+ if (name === void 0 || name === "") return;
740
+ warnedLambda = true;
741
+ console.warn("[toggld] Sync mode (`mode: \"sync\"`, the default) is running on AWS Lambda. Lambda freezes the process between invocations, so the sync stream and poll stall, and after a freeze evaluations can use a flag snapshot of any age. Use \"@toggld/js/serverless\", the remote client for serverless runtimes (one bulk request per incoming request), or set `mode: \"remote\"`.");
742
+ };
743
+ /** Apply `f` to a value that may still be on its way. */
744
+ const mapMaybe = (a, f) => a instanceof Promise ? a.then(f) : f(a);
745
+ const makeRemote = (send, mergedContext, timeoutMs, telemetry) => {
746
+ const evaluate = async (key, defaultValue, context, signal) => {
747
+ const path = `/ofrep/v1/evaluate/flags/${encodeURIComponent(key)}`;
748
+ try {
749
+ return await withTimeout(timeoutMs, async (s) => {
750
+ const response = await send("POST", path, { context: mergedContext(context) }, s);
751
+ const status = response.status;
752
+ if (status >= 200 && status < 300) return okDetails(key, decodeOfrepSuccess(await readJson(response, `POST ${path}`)));
753
+ if (status >= 400 && status < 500) {
754
+ let failure;
755
+ try {
756
+ failure = decodeOfrepFailure(await response.json());
757
+ } catch {
758
+ return errorDetails(key, defaultValue, "GENERAL", rejectedMessage(response));
759
+ }
760
+ return wireErrorDetails(defaultValue, failure);
761
+ }
762
+ return errorDetails(key, defaultValue, "GENERAL", `Unexpected response status ${status}`);
763
+ }, signal);
764
+ } catch (error) {
765
+ return errorDetails(key, defaultValue, codeFor(error), describe(error));
766
+ }
767
+ };
768
+ const evaluateAll = async (context, signal) => {
769
+ try {
770
+ return await withTimeout(timeoutMs, async (s) => {
771
+ const path = "/ofrep/v1/evaluate/flags";
772
+ const response = await send("POST", path, { context: mergedContext(context) }, s);
773
+ if (response.status < 200 || response.status >= 300) throw new Error(`Unexpected response status ${response.status}`);
774
+ const bulk = decodeOfrepBulk(await readJson(response, `POST ${path}`));
775
+ for (const entry of bulk) telemetry.record(entry.key, entry.ok ? {
776
+ variant: entry.variant,
777
+ reason: entry.reason
778
+ } : {
779
+ variant: void 0,
780
+ reason: "ERROR"
781
+ });
782
+ return bulk.map((entry) => entry.ok ? okDetails(entry.key, entry) : wireErrorDetails(null, entry));
783
+ }, signal);
784
+ } catch {
785
+ return [];
786
+ }
787
+ };
788
+ return {
789
+ ready: Promise.resolve(),
790
+ evaluate,
791
+ evaluateAll,
792
+ stop: () => {}
793
+ };
794
+ };
795
+ const MODES = /* @__PURE__ */ new Set([
796
+ "sync",
797
+ "file",
798
+ "remote"
799
+ ]);
800
+ /**
801
+ * Validate `mode`, `file`, and `sync.file`, then apply `TOGGLD_FLAGS_FILE`.
802
+ * Throws a `TypeError` for the renamed `"local"` mode, an unknown mode, the
803
+ * removed `sync.file`, and `file` outside file mode, so a migration mistake
804
+ * fails at creation instead of being ignored. The env var, when set and not
805
+ * empty, makes any client run in file mode from that path (the "redirect with
806
+ * no code change" path, ADR-0013), overriding `file`.
807
+ */
808
+ const resolveMode = (options) => {
809
+ const mode = options.mode ?? "sync";
810
+ if (mode === "local") throw new TypeError("[toggld] mode \"local\" was renamed: use \"sync\" to keep flag rules in sync with Toggld, or \"file\" to read a flag file.");
811
+ if (typeof mode !== "string" || !MODES.has(mode)) throw new TypeError(`[toggld] Unknown mode ${JSON.stringify(mode)}: use "sync" (the default), "file", or "remote".`);
812
+ const legacyFile = options.sync?.file;
813
+ if (legacyFile !== void 0) {
814
+ const path = typeof legacyFile === "string" && legacyFile.length > 0 ? legacyFile : DEFAULT_FLAGS_FILE;
815
+ throw new TypeError(`[toggld] \`sync.file\` was removed: to evaluate from a flag file, set \`mode: "file", file: ${JSON.stringify(path)}\`.`);
816
+ }
817
+ const file = options.file;
818
+ if (file !== void 0) {
819
+ if (mode !== "file") throw new TypeError(`[toggld] \`file\` only applies to file mode, but mode is "${mode}". Set \`mode: "file"\` to evaluate from ${JSON.stringify(file)}, or remove \`file\`.`);
820
+ if (typeof file !== "string" || file.length === 0) throw new TypeError(`[toggld] \`file\` must be a path to a flag file, got ${JSON.stringify(file)}.`);
821
+ }
822
+ const env = typeof process === "undefined" ? void 0 : process.env.TOGGLD_FLAGS_FILE;
823
+ if (typeof env === "string" && env.length > 0) return {
824
+ mode: "file",
825
+ path: env
826
+ };
827
+ if (mode === "file") return {
828
+ mode: "file",
829
+ path: file ?? "./toggld.flags.json"
830
+ };
831
+ return { mode };
832
+ };
833
+ /**
834
+ * Start a client: sync (or the file watch) begins at once; nothing waits for
835
+ * readiness. Throws a `TypeError` for invalid mode options (see {@link
836
+ * resolveMode}) and for a sync-mode client that can never sync (see {@link
837
+ * assertCanSync}), before any timer or request starts.
838
+ */
839
+ const makeCore = (options) => {
840
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
841
+ const resolved = resolveMode(options);
842
+ const local = resolved.mode !== "remote";
843
+ const networkSync = resolved.mode === "sync";
844
+ if (networkSync) {
845
+ assertLongLivedRuntime();
846
+ assertCanSync(options);
847
+ }
848
+ const send = makeSend(options, (json) => Buffer.from(json, "utf8"));
849
+ const mergedContext = (context) => ({
850
+ ...options.defaultContext,
851
+ ...context
852
+ });
853
+ const telemetry = options.telemetry === false ? noTelemetry : networkSync ? startTelemetry(send, options) : resolved.mode === "remote" && requestScopedRuntime() === void 0 ? lazyTelemetry(() => startTelemetry(send, {
854
+ ...options,
855
+ unref: true
856
+ })) : noTelemetry;
857
+ let impl;
858
+ let probe;
859
+ let firstRead;
860
+ if (local) {
861
+ const store = makeSnapshotStore(mergedContext, telemetry.record);
862
+ let stop;
863
+ if (resolved.mode === "file") {
864
+ const source = startFileSource(resolved.path, store.sink);
865
+ stop = source.stop;
866
+ firstRead = source.firstRead;
867
+ } else stop = startNetworkSource(options, send, store.sink, timeoutMs);
868
+ impl = {
869
+ ready: store.ready,
870
+ evaluate: (key, defaultValue, context) => store.evaluate(key, defaultValue, context),
871
+ evaluateAll: (context) => store.evaluateAll(context),
872
+ stop
873
+ };
874
+ probe = store;
875
+ } else impl = makeRemote(send, mergedContext, timeoutMs, telemetry);
876
+ let closing;
877
+ let rejectClosed;
878
+ const closedSignal = new Promise((_, reject) => {
879
+ rejectClosed = reject;
880
+ });
881
+ closedSignal.catch(() => {});
882
+ const readyOrClosed = Promise.race([impl.ready, closedSignal]);
883
+ readyOrClosed.catch(() => {});
884
+ let warnedNotReady = false;
885
+ const waitForReady = async () => {
886
+ if (probe === void 0) return;
887
+ if (firstRead !== void 0) {
888
+ await Promise.race([firstRead, readyOrClosed.catch(() => {})]);
889
+ return;
890
+ }
891
+ const waitMs = options.sync?.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
892
+ let timer;
893
+ await Promise.race([readyOrClosed.catch(() => {}), new Promise((resolve) => {
894
+ timer = setTimeout(resolve, waitMs);
895
+ })]);
896
+ clearTimeout(timer);
897
+ if (closing !== void 0 || probe.isReady() || warnedNotReady) return;
898
+ warnedNotReady = true;
899
+ const last = probe.lastError();
900
+ console.warn(`[toggld] No flag snapshot after ${waitMs} ms. Evaluations return your fallback values (PROVIDER_NOT_READY) until the first sync succeeds; sync keeps retrying in the background.` + (last === void 0 ? "" : ` Last error: ${last}`));
901
+ };
902
+ const typed = (key, defaultValue, context, type, signal) => {
903
+ return mapMaybe(impl.evaluate(key, defaultValue, context, signal), (d) => enforceType(d, key, defaultValue, type));
904
+ };
905
+ return {
906
+ synchronous: local,
907
+ ready: () => readyOrClosed,
908
+ waitForReady,
909
+ flushTelemetry: () => telemetry.flush(),
910
+ evaluate: (key, defaultValue, context, signal) => impl.evaluate(key, defaultValue, context, signal),
911
+ evaluateAll: (context, signal) => impl.evaluateAll(context, signal),
912
+ typed,
913
+ closed: () => closing !== void 0,
914
+ close: () => closing ??= (async () => {
915
+ rejectClosed(/* @__PURE__ */ new Error("[toggld] The client was closed."));
916
+ impl.stop();
917
+ await telemetry.close();
918
+ })()
919
+ };
920
+ };
921
+ export { mapMaybe as n, makeCore as t };