@toggld/browser 0.0.0-stage → 0.1.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,742 @@
1
+ // ../spec/src/index.ts
2
+ /**
3
+ * Subprotocol entry carrying the bearer credential on a sync WebSocket
4
+ * upgrade: `toggld.bearer.<token>`. The standard `WebSocket` constructor
5
+ * cannot set an `Authorization` header, so the token rides the
6
+ * `Sec-WebSocket-Protocol` offer instead (a recognized pattern — it stays out
7
+ * of URLs and access logs). Servers accept either this or a real
8
+ * `Authorization: Bearer` header.
9
+ */
10
+ const SYNC_BEARER_PROTOCOL_PREFIX = "toggld.bearer.";
11
+ /**
12
+ * The evaluation stream: `GET /eval/v1/stream` with a WebSocket upgrade. Unlike
13
+ * the sync stream, it carries evaluated values, never targeting rules, so it
14
+ * accepts public tokens (`ffp_…`) as well as evaluation keys. Authenticate with
15
+ * the {@link SYNC_BEARER_PROTOCOL_PREFIX} subprotocol entry, offered after this
16
+ * protocol name.
17
+ */
18
+ const EVAL_STREAM_PATH = "/eval/v1/stream";
19
+ const EVAL_STREAM_PROTOCOL = "toggld.eval.v1";
20
+ /**
21
+ * Heartbeat on the evaluation stream: the client sends the text frame
22
+ * {@link STREAM_PING} (not JSON) and the server answers {@link STREAM_PONG}.
23
+ * Without it, a connection that a proxy or a sleeping laptop dropped silently
24
+ * would look open forever. Plain fixed strings, so a Durable Object can answer
25
+ * with an auto-response without waking from hibernation.
26
+ */
27
+ const STREAM_PING = "ping";
28
+ /** How often the client pings, and how long it waits for the pong before it reconnects. */
29
+ const STREAM_PING_INTERVAL_MS = 3e4;
30
+ const STREAM_PONG_TIMEOUT_MS = 1e4;
31
+ // src/internal/client.ts
32
+ const DEFAULT_ENDPOINT = "https://api.toggld.io";
33
+ const DEFAULT_TIMEOUT_MS = 5e3;
34
+ const DEFAULT_READY_TIMEOUT_MS = 5e3;
35
+ const DEFAULT_STALE_AFTER_MS = 6e4;
36
+ const MIN_POLL_INTERVAL_MS = 5e3;
37
+ const RETRY_BASE_MS = 1e3;
38
+ const RETRY_MAX_MS = 6e4;
39
+ const STREAM_RETRY_MAX_MS = 3e4;
40
+ /** After a 4429 close (too many context changes), wait out the server's one-minute window. */
41
+ const RATE_LIMITED_RECONNECT_MS = 61e3;
42
+ /**
43
+ * Connects in a row that close before opening, after which the stream stops
44
+ * reconnecting on its own. A rejected upgrade (401/403/404/400/429) reaches a
45
+ * page only as a 1006 close, and its usual causes do not heal: an older
46
+ * self-hosted server without the stream, a revoked token, or the shared rate
47
+ * limit, which every retry spends for all visitors.
48
+ */
49
+ const MAX_FAILED_CONNECTS = 3;
50
+ const NORMAL_CLOSURE = 1e3;
51
+ const ABNORMAL_CLOSURE = 1006;
52
+ const isRecord = (u) => typeof u === "object" && u !== null && !Array.isArray(u);
53
+ const describe = (e) => isRecord(e) && typeof e.message === "string" ? e.message : String(e);
54
+ /**
55
+ * Deep-freeze a JSON value. Results are shared between every reader (and React
56
+ * renders), so they must not be mutable. Freezing instead of cloning also keeps
57
+ * an object flag's identity stable across reads.
58
+ */
59
+ const freeze = (value) => {
60
+ if (typeof value === "object" && value !== null && !Object.isFrozen(value)) {
61
+ Object.freeze(value);
62
+ for (const v of Object.values(value)) freeze(v);
63
+ }
64
+ return value;
65
+ };
66
+ /** Structural equality for JSON values, for context and result comparisons. */
67
+ const sameJson = (a, b) => JSON.stringify(a) === JSON.stringify(b);
68
+ /**
69
+ * Exponential backoff from 1 s, capped, with ±20% jitter, so many visitors that
70
+ * failed together do not retry together.
71
+ */
72
+ const backoff = (attempt, maxMs) => Math.min(maxMs, RETRY_BASE_MS * 2 ** attempt) * (.8 + Math.random() * .4);
73
+ /** `Retry-After` as milliseconds from now: delay-seconds or an HTTP date. */
74
+ const parseRetryAfter = (header) => {
75
+ if (header === null) return void 0;
76
+ const seconds = Number(header);
77
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1e3);
78
+ const date = Date.parse(header);
79
+ return Number.isNaN(date) ? void 0 : Math.max(0, date - Date.now());
80
+ };
81
+ const errorDetails = (flagKey, value, errorCode, errorMessage, flagMetadata = {}) => ({
82
+ flagKey,
83
+ value,
84
+ variant: void 0,
85
+ reason: "ERROR",
86
+ errorCode,
87
+ errorMessage,
88
+ flagMetadata
89
+ });
90
+ /** One entry of an OFREP bulk response, or `undefined` when it is malformed. */
91
+ const fromWire = (raw) => {
92
+ if (!isRecord(raw) || typeof raw.key !== "string") return void 0;
93
+ const flagMetadata = freeze(isRecord(raw.metadata) ? raw.metadata : {});
94
+ if (typeof raw.errorCode === "string") return {
95
+ flagKey: raw.key,
96
+ value: null,
97
+ variant: void 0,
98
+ reason: "ERROR",
99
+ errorCode: raw.errorCode,
100
+ errorMessage: typeof raw.errorDetails === "string" ? raw.errorDetails : void 0,
101
+ flagMetadata
102
+ };
103
+ if (!("value" in raw) || typeof raw.reason !== "string") return void 0;
104
+ return {
105
+ flagKey: raw.key,
106
+ value: freeze(raw.value),
107
+ variant: typeof raw.variant === "string" ? raw.variant : void 0,
108
+ reason: raw.reason,
109
+ errorCode: void 0,
110
+ errorMessage: void 0,
111
+ flagMetadata
112
+ };
113
+ };
114
+ /**
115
+ * A bootstrap entry. It may have passed through JSON (server-side rendering),
116
+ * which drops `undefined` fields, so every field is optional on the way in.
117
+ */
118
+ const fromBootstrap = (raw) => {
119
+ if (!isRecord(raw) || typeof raw.flagKey !== "string") return void 0;
120
+ return {
121
+ flagKey: raw.flagKey,
122
+ value: freeze(raw.value ?? null),
123
+ variant: typeof raw.variant === "string" ? raw.variant : void 0,
124
+ reason: typeof raw.reason === "string" ? raw.reason : "UNKNOWN",
125
+ errorCode: typeof raw.errorCode === "string" ? raw.errorCode : void 0,
126
+ errorMessage: typeof raw.errorMessage === "string" ? raw.errorMessage : void 0,
127
+ flagMetadata: freeze(isRecord(raw.flagMetadata) ? { ...raw.flagMetadata } : {})
128
+ };
129
+ };
130
+ const isPresent = (v) => v !== void 0;
131
+ /** A stable fingerprint of a result, cached per (immutable) result object. */
132
+ const signatures = /* @__PURE__ */ new WeakMap();
133
+ const signature = (d) => {
134
+ let s = signatures.get(d);
135
+ if (s === void 0) {
136
+ s = JSON.stringify([
137
+ d.value,
138
+ d.variant,
139
+ d.reason,
140
+ d.errorCode,
141
+ d.errorMessage,
142
+ d.flagMetadata
143
+ ]);
144
+ signatures.set(d, s);
145
+ }
146
+ return s;
147
+ };
148
+ /**
149
+ * Merge fresh results into the previous map with structural sharing: a result
150
+ * that did not change keeps its old object, and when nothing changed the old
151
+ * map itself is returned. Readers that compare by identity see no change.
152
+ */
153
+ const mergeFlags = (previous, incoming) => {
154
+ const next = /* @__PURE__ */ new Map();
155
+ let changed = previous.size !== incoming.length;
156
+ for (const entry of incoming) {
157
+ const old = previous.get(entry.flagKey);
158
+ if (old !== void 0 && signature(old) === signature(entry)) next.set(entry.flagKey, old);
159
+ else {
160
+ next.set(entry.flagKey, entry);
161
+ changed = true;
162
+ }
163
+ }
164
+ return changed ? next : previous;
165
+ };
166
+ const matches = (value, type) => {
167
+ switch (type) {
168
+ case "boolean": return typeof value === "boolean";
169
+ case "string": return typeof value === "string";
170
+ case "number": return typeof value === "number" && Number.isFinite(value);
171
+ case "object": return typeof value === "object" && value !== null;
172
+ }
173
+ };
174
+ /**
175
+ * A Toggld client for browser code. Create one per page (outside any React
176
+ * render), with {@link ToggldClient.create} to await the first values or with
177
+ * `new ToggldClient(options)` to start loading in the background.
178
+ */
179
+ var ToggldClient = class ToggldClient {
180
+ #fetch;
181
+ #url;
182
+ #token;
183
+ #timeoutMs;
184
+ #staleAfterMs;
185
+ #refreshOnVisible;
186
+ /** The WebSocket constructor for the evaluation stream, if it is enabled and available. */
187
+ #webSocket;
188
+ #streamUrl;
189
+ #snapshot;
190
+ #listeners = /* @__PURE__ */ new Set();
191
+ /** Increments on every context change; responses for an older one are dropped. */
192
+ #generation = 0;
193
+ #inFlight;
194
+ #retryTimer;
195
+ #retryAttempt = 0;
196
+ #pollTimer;
197
+ /** No request before this time (`Date.now()`), after a 429. */
198
+ #blockedUntil = 0;
199
+ /** The token was rejected (401/403): evaluations report `PROVIDER_FATAL`. */
200
+ #fatal = false;
201
+ /**
202
+ * The last failure was permanent (a rejected token or context), so background
203
+ * refreshes stop until an explicit `refresh()` or a new context.
204
+ */
205
+ #halted = false;
206
+ #closed = false;
207
+ #socket;
208
+ #reconnectTimer;
209
+ #reconnectAttempt = 0;
210
+ /** The server closed with 1008 (policy), or the socket cannot be built: never reconnect. */
211
+ #streamStopped = false;
212
+ /**
213
+ * Waiting instead of reconnecting: `context` after 1007/1009 (a new context
214
+ * retries), `connect` after too many failed connects (see
215
+ * {@link MAX_FAILED_CONNECTS}; `setContext()` or `refresh()` tries once more).
216
+ */
217
+ #parked;
218
+ #failedConnects = 0;
219
+ /** The current socket delivered values for the current generation. */
220
+ #socketLive = false;
221
+ /** Adopted pushes, so an HTTP response can tell if one arrived during its flight. */
222
+ #pushes = 0;
223
+ #pingTimer;
224
+ /** Set while a ping awaits its answer. */
225
+ #pongTimer;
226
+ #hiddenAt;
227
+ #readyResolved = false;
228
+ #ready;
229
+ #resolveReady;
230
+ #onVisibilityChange = () => this.#handleVisible();
231
+ constructor(options) {
232
+ if (typeof options.publicToken !== "string" || options.publicToken.length === 0) throw new TypeError("[toggld] `publicToken` is required: the environment's public token (ffp_…).");
233
+ if (options.publicToken.startsWith("ffk_")) console.warn("[toggld] `publicToken` looks like a secret key (ffk_…). Secret keys must never ship to browsers. Use the environment's public token (ffp_…) from Settings → API Keys.");
234
+ const poll = options.pollIntervalMs;
235
+ if (poll !== void 0 && !(Number.isFinite(poll) && poll >= MIN_POLL_INTERVAL_MS)) throw new RangeError(`[toggld] \`pollIntervalMs\` must be a finite number of at least ${MIN_POLL_INTERVAL_MS}.`);
236
+ const fetchImpl = options.fetch ?? globalThis.fetch;
237
+ this.#fetch = (input, init) => fetchImpl(input, init);
238
+ this.#url = `${(options.endpoint ?? DEFAULT_ENDPOINT).replace(/\/+$/, "")}/ofrep/v1/evaluate/flags`;
239
+ this.#token = options.publicToken;
240
+ this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
241
+ this.#staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
242
+ this.#refreshOnVisible = options.refreshOnVisible ?? true;
243
+ const base = (options.endpoint ?? DEFAULT_ENDPOINT).replace(/\/+$/, "");
244
+ this.#streamUrl = `${base.replace(/^http(s?):\/\//i, "ws$1://")}${EVAL_STREAM_PATH}`;
245
+ const webSocket = options.webSocket ?? (typeof WebSocket === "function" ? WebSocket : void 0);
246
+ this.#webSocket = options.stream !== false && typeof webSocket === "function" && (options.webSocket !== void 0 || typeof document !== "undefined") ? webSocket : void 0;
247
+ this.#ready = new Promise((resolve) => {
248
+ this.#resolveReady = resolve;
249
+ });
250
+ const bootstrap = (options.bootstrap ?? []).map(fromBootstrap).filter(isPresent);
251
+ const context = freeze({ ...options.context });
252
+ this.#snapshot = bootstrap.length > 0 ? {
253
+ status: "ready",
254
+ context,
255
+ flags: mergeFlags(/* @__PURE__ */ new Map(), bootstrap),
256
+ error: void 0,
257
+ updatedAt: Date.now(),
258
+ streaming: false
259
+ } : {
260
+ status: "loading",
261
+ context,
262
+ flags: /* @__PURE__ */ new Map(),
263
+ error: void 0,
264
+ updatedAt: void 0,
265
+ streaming: false
266
+ };
267
+ if (bootstrap.length > 0) this.#markReady();
268
+ else this.#request();
269
+ if (typeof document !== "undefined") {
270
+ document.addEventListener("visibilitychange", this.#onVisibilityChange);
271
+ if (poll !== void 0) this.#pollTimer = setInterval(() => this.#poll(), poll);
272
+ }
273
+ this.#connect();
274
+ }
275
+ /**
276
+ * Create a client and wait for its first values, or for `readyTimeoutMs`
277
+ * (default 5 s), whichever is first. It never rejects: on a timeout or a
278
+ * failure the client serves caller defaults and keeps retrying.
279
+ */
280
+ static async create(options) {
281
+ const client = new ToggldClient(options);
282
+ let timer;
283
+ await Promise.race([client.ready(), new Promise((resolve) => {
284
+ timer = setTimeout(resolve, options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS);
285
+ })]);
286
+ clearTimeout(timer);
287
+ return client;
288
+ }
289
+ /**
290
+ * Resolves once the client first has values for its context, or once a
291
+ * failure makes that impossible (a rejected token or context). It never
292
+ * rejects. A client built with a bootstrap is ready at once.
293
+ */
294
+ ready() {
295
+ return this.#ready;
296
+ }
297
+ /** The current {@link ClientSnapshot}: the same object until something changes. */
298
+ getSnapshot = () => this.#snapshot;
299
+ /**
300
+ * Call `listener` after every state change: new values, a status change, or
301
+ * a new context. Returns a function that removes it. The signature matches
302
+ * React's `useSyncExternalStore`, so `client.subscribe` can be passed as is.
303
+ */
304
+ subscribe = (listener) => {
305
+ this.#listeners.add(listener);
306
+ return () => {
307
+ this.#listeners.delete(listener);
308
+ };
309
+ };
310
+ get status() {
311
+ return this.#snapshot.status;
312
+ }
313
+ get context() {
314
+ return this.#snapshot.context;
315
+ }
316
+ /**
317
+ * Evaluate for a new context: for example after sign-in, or when an
318
+ * attribute that targeting rules use changes. The context is **replaced**,
319
+ * not merged. Until the new values arrive the status is `"reconciling"` and
320
+ * the previous values are still served. Resolves when the request settles; it
321
+ * never rejects. Setting an identical context does nothing. An open stream
322
+ * receives the new context too.
323
+ */
324
+ setContext(context) {
325
+ if (this.#closed) return Promise.resolve();
326
+ if (sameJson(context, this.#snapshot.context)) return this.#inFlight?.promise ?? Promise.resolve();
327
+ this.#generation++;
328
+ this.#socketLive = false;
329
+ this.#clearRetry();
330
+ this.#halted = false;
331
+ const hasValues = this.#snapshot.updatedAt !== void 0;
332
+ this.#update({
333
+ context: freeze({ ...context }),
334
+ status: hasValues ? "reconciling" : "loading"
335
+ });
336
+ if (this.#snapshot.streaming) this.#sendContext();
337
+ else if (this.#parked !== void 0) this.#unpark();
338
+ return this.#request();
339
+ }
340
+ /**
341
+ * Fetch fresh values for the current context now. A request already in
342
+ * flight for it is shared rather than repeated. Resolves when it settles; it
343
+ * never rejects. After a 429 it waits for `Retry-After` first. While the
344
+ * stream is live it does nothing: the values are already current.
345
+ */
346
+ refresh() {
347
+ if (this.#closed) return Promise.resolve();
348
+ if (this.#live()) return this.#inFlight?.promise ?? Promise.resolve();
349
+ this.#halted = false;
350
+ if (this.#parked === "connect") this.#unpark();
351
+ return this.#request();
352
+ }
353
+ /**
354
+ * Stop all background work (requests, retries, polling, the stream,
355
+ * listeners). Values stay readable, but they no longer update.
356
+ */
357
+ close() {
358
+ if (this.#closed) return;
359
+ this.#closed = true;
360
+ this.#inFlight?.controller.abort();
361
+ this.#inFlight = void 0;
362
+ this.#clearRetry();
363
+ if (this.#pollTimer !== void 0) clearInterval(this.#pollTimer);
364
+ if (typeof document !== "undefined") document.removeEventListener("visibilitychange", this.#onVisibilityChange);
365
+ this.#listeners.clear();
366
+ this.#teardown();
367
+ this.#markReady();
368
+ }
369
+ /** Resolve a flag, returning full details with an untyped (JSON) value. */
370
+ evaluate(key, defaultValue) {
371
+ const snapshot = this.#snapshot;
372
+ const entry = snapshot.flags.get(key);
373
+ if (entry === void 0) {
374
+ if (snapshot.updatedAt !== void 0) return errorDetails(key, defaultValue, "FLAG_NOT_FOUND", `Flag '${key}' not found`);
375
+ return this.#fatal ? errorDetails(key, defaultValue, "PROVIDER_FATAL", snapshot.error ?? "The client cannot evaluate flags") : errorDetails(key, defaultValue, "PROVIDER_NOT_READY", snapshot.error ?? "Flag values are not loaded yet");
376
+ }
377
+ if (entry.errorCode !== void 0) return errorDetails(key, defaultValue, entry.errorCode, entry.errorMessage ?? "", entry.flagMetadata);
378
+ return entry;
379
+ }
380
+ /** The last result of every flag for the current context. */
381
+ allFlags() {
382
+ return Array.from(this.#snapshot.flags.values());
383
+ }
384
+ getBooleanValue(key, defaultValue) {
385
+ return this.getBooleanDetails(key, defaultValue).value;
386
+ }
387
+ getBooleanDetails(key, defaultValue) {
388
+ return this.#typed(key, defaultValue, "boolean");
389
+ }
390
+ getStringValue(key, defaultValue) {
391
+ return this.getStringDetails(key, defaultValue).value;
392
+ }
393
+ getStringDetails(key, defaultValue) {
394
+ return this.#typed(key, defaultValue, "string");
395
+ }
396
+ getNumberValue(key, defaultValue) {
397
+ return this.getNumberDetails(key, defaultValue).value;
398
+ }
399
+ getNumberDetails(key, defaultValue) {
400
+ return this.#typed(key, defaultValue, "number");
401
+ }
402
+ /** Object values are deep-frozen and shared: do not mutate them. */
403
+ getObjectValue(key, defaultValue) {
404
+ return this.getObjectDetails(key, defaultValue).value;
405
+ }
406
+ getObjectDetails(key, defaultValue) {
407
+ return this.#typed(key, defaultValue, "object");
408
+ }
409
+ #typed(key, defaultValue, type) {
410
+ const details = this.evaluate(key, defaultValue);
411
+ if (details.errorCode !== void 0 || matches(details.value, type)) return details;
412
+ return errorDetails(key, defaultValue, "TYPE_MISMATCH", `Flag '${key}' is not a ${type} flag`, details.flagMetadata);
413
+ }
414
+ #update(patch) {
415
+ const prev = this.#snapshot;
416
+ const next = {
417
+ ...prev,
418
+ ...patch
419
+ };
420
+ if (next.status === prev.status && next.context === prev.context && next.flags === prev.flags && next.error === prev.error && next.updatedAt === prev.updatedAt && next.streaming === prev.streaming) return;
421
+ this.#snapshot = next;
422
+ for (const listener of [...this.#listeners]) try {
423
+ listener();
424
+ } catch (e) {
425
+ console.error("[toggld] A subscriber threw:", e);
426
+ }
427
+ }
428
+ #markReady() {
429
+ if (this.#readyResolved) return;
430
+ this.#readyResolved = true;
431
+ this.#resolveReady();
432
+ }
433
+ /** Start (or join) the request for the current context. */
434
+ #request() {
435
+ const generation = this.#generation;
436
+ if (this.#inFlight?.generation === generation) return this.#inFlight.promise;
437
+ this.#inFlight?.controller.abort();
438
+ const controller = new AbortController();
439
+ const pushes = this.#pushes;
440
+ const promise = this.#fetchAll(this.#snapshot.context, controller.signal).then((outcome) => {
441
+ if (this.#inFlight?.controller === controller) this.#inFlight = void 0;
442
+ this.#settle(generation, outcome, pushes);
443
+ });
444
+ this.#inFlight = {
445
+ generation,
446
+ controller,
447
+ promise
448
+ };
449
+ return promise;
450
+ }
451
+ /**
452
+ * Apply a finished HTTP request (`pushes`: the push count when it started).
453
+ * HTTP may come from a cache that lags the stream, so it must not overwrite
454
+ * pushed values: it is dropped if a push for its context arrived during its
455
+ * flight, or if the stream is live. (A request started after the stream
456
+ * dropped can still briefly return older cached values; fixing that needs an
457
+ * ordered version on both channels.)
458
+ */
459
+ #settle(generation, outcome, pushes) {
460
+ if (this.#closed || generation !== this.#generation || outcome.kind === "aborted") return;
461
+ if (pushes !== this.#pushes || this.#live()) return;
462
+ if (outcome.kind === "ok") {
463
+ this.#adopt(outcome.flags);
464
+ return;
465
+ }
466
+ if (outcome.retryAfterMs !== void 0) this.#blockedUntil = Math.max(this.#blockedUntil, Date.now() + outcome.retryAfterMs);
467
+ if (outcome.kind !== "retry") this.#halted = true;
468
+ if (outcome.kind === "fatal") {
469
+ this.#fatal = true;
470
+ this.#teardown();
471
+ }
472
+ if (this.#snapshot.status === "ready") {
473
+ this.#update({ error: outcome.message });
474
+ return;
475
+ }
476
+ this.#update({
477
+ status: "error",
478
+ error: outcome.message
479
+ });
480
+ if (outcome.kind === "retry") this.#scheduleRetry();
481
+ else this.#markReady();
482
+ }
483
+ /** Take fresh values for the current context, from HTTP or the stream. */
484
+ #adopt(flags) {
485
+ this.#clearRetry();
486
+ this.#halted = false;
487
+ if (this.#fatal) {
488
+ this.#fatal = false;
489
+ this.#connect();
490
+ }
491
+ this.#update({
492
+ status: "ready",
493
+ flags: mergeFlags(this.#snapshot.flags, flags),
494
+ error: void 0,
495
+ updatedAt: Date.now()
496
+ });
497
+ this.#markReady();
498
+ }
499
+ #scheduleRetry() {
500
+ this.#clearRetry();
501
+ const delay = backoff(this.#retryAttempt, RETRY_MAX_MS);
502
+ this.#retryAttempt++;
503
+ this.#retryTimer = setTimeout(() => {
504
+ this.#retryTimer = void 0;
505
+ this.#request();
506
+ }, delay);
507
+ }
508
+ #clearRetry() {
509
+ if (this.#retryTimer !== void 0) clearTimeout(this.#retryTimer);
510
+ this.#retryTimer = void 0;
511
+ this.#retryAttempt = 0;
512
+ }
513
+ /**
514
+ * Open, and this socket delivered values for the current context: a socket
515
+ * that never answers must not stop polling.
516
+ */
517
+ #live() {
518
+ return this.#snapshot.streaming && this.#socketLive;
519
+ }
520
+ /** May a background trigger (poll, visibility) send a request now? */
521
+ #backgroundAllowed() {
522
+ return !this.#closed && !this.#halted && !this.#live() && this.#retryTimer === void 0 && Date.now() >= this.#blockedUntil && (typeof document === "undefined" || document.visibilityState !== "hidden");
523
+ }
524
+ #poll() {
525
+ if (this.#backgroundAllowed()) this.#request();
526
+ }
527
+ #handleVisible() {
528
+ if (document.visibilityState === "hidden") {
529
+ this.#hiddenAt = Date.now();
530
+ return;
531
+ }
532
+ const hiddenFor = this.#hiddenAt === void 0 ? 0 : Date.now() - this.#hiddenAt;
533
+ this.#hiddenAt = void 0;
534
+ if (this.#snapshot.streaming && hiddenFor >= this.#staleAfterMs) this.#ping();
535
+ this.#refreshIfStale();
536
+ }
537
+ #refreshIfStale() {
538
+ if (!this.#refreshOnVisible || !this.#backgroundAllowed()) return;
539
+ const { updatedAt } = this.#snapshot;
540
+ if (updatedAt !== void 0 && Date.now() - updatedAt >= this.#staleAfterMs) this.#request();
541
+ }
542
+ /**
543
+ * Open the evaluation stream, unless it is off, open, or stopped. Handlers
544
+ * are guarded and ignore stale sockets: a socket never breaks evaluation.
545
+ */
546
+ #connect() {
547
+ clearTimeout(this.#reconnectTimer);
548
+ this.#reconnectTimer = void 0;
549
+ const WebSocketImpl = this.#webSocket;
550
+ if (WebSocketImpl === void 0 || this.#closed || this.#streamStopped || this.#fatal || this.#socket !== void 0) return;
551
+ let socket;
552
+ try {
553
+ socket = new WebSocketImpl(this.#streamUrl, [EVAL_STREAM_PROTOCOL, SYNC_BEARER_PROTOCOL_PREFIX + this.#token]);
554
+ } catch {
555
+ this.#streamStopped = true;
556
+ return;
557
+ }
558
+ this.#socket = socket;
559
+ this.#socketLive = false;
560
+ let opened = false;
561
+ const guard = (f) => (...args) => {
562
+ if (this.#socket !== socket) return;
563
+ try {
564
+ f(...args);
565
+ } catch (e) {
566
+ console.error("[toggld] The evaluation stream failed:", e);
567
+ }
568
+ };
569
+ socket.onopen = guard(() => {
570
+ opened = true;
571
+ this.#failedConnects = 0;
572
+ this.#pingTimer = setInterval(() => this.#ping(), STREAM_PING_INTERVAL_MS);
573
+ this.#update({ streaming: true });
574
+ this.#sendContext();
575
+ });
576
+ socket.onmessage = guard((event) => this.#receive(event.data));
577
+ socket.onclose = guard((event) => this.#disconnected(event.code, opened));
578
+ }
579
+ #send(data) {
580
+ try {
581
+ this.#socket?.send(data);
582
+ } catch {}
583
+ }
584
+ #sendContext() {
585
+ const message = {
586
+ type: "context",
587
+ seq: this.#generation,
588
+ context: this.#snapshot.context
589
+ };
590
+ this.#send(JSON.stringify(message));
591
+ }
592
+ /**
593
+ * Send a heartbeat, unless one is waiting. With no frame back in time, the
594
+ * connection died silently (a proxy timeout, a sleep): drop it and reconnect.
595
+ */
596
+ #ping() {
597
+ if (this.#socket === void 0 || this.#pongTimer !== void 0) return;
598
+ this.#pongTimer = setTimeout(() => {
599
+ this.#pongTimer = void 0;
600
+ this.#disconnected(ABNORMAL_CLOSURE, true);
601
+ this.#refreshIfStale();
602
+ }, STREAM_PONG_TIMEOUT_MS);
603
+ this.#send(STREAM_PING);
604
+ }
605
+ #receive(data) {
606
+ clearTimeout(this.#pongTimer);
607
+ this.#pongTimer = void 0;
608
+ if (typeof data !== "string" || data === "pong") return;
609
+ let message;
610
+ try {
611
+ message = JSON.parse(data);
612
+ } catch {
613
+ return;
614
+ }
615
+ if (!isRecord(message) || message.type !== "flags" || typeof message.seq !== "number") return;
616
+ if (!Array.isArray(message.flags)) return;
617
+ this.#reconnectAttempt = 0;
618
+ if (message.seq !== this.#generation) return;
619
+ this.#pushes++;
620
+ this.#socketLive = true;
621
+ this.#adopt(message.flags.map(fromWire).filter(isPresent));
622
+ }
623
+ #teardown() {
624
+ const socket = this.#socket;
625
+ this.#socket = void 0;
626
+ this.#socketLive = false;
627
+ clearInterval(this.#pingTimer);
628
+ clearTimeout(this.#pongTimer);
629
+ clearTimeout(this.#reconnectTimer);
630
+ this.#pingTimer = this.#pongTimer = this.#reconnectTimer = void 0;
631
+ try {
632
+ socket?.close(NORMAL_CLOSURE);
633
+ } catch {}
634
+ this.#update({ streaming: false });
635
+ }
636
+ #unpark() {
637
+ this.#parked = void 0;
638
+ this.#connect();
639
+ }
640
+ #disconnected(code, opened) {
641
+ this.#teardown();
642
+ let delay;
643
+ if (!opened && ++this.#failedConnects >= MAX_FAILED_CONNECTS) this.#parked = "connect";
644
+ else if (code === 1008) this.#streamStopped = true;
645
+ else if (code === 4429) delay = RATE_LIMITED_RECONNECT_MS;
646
+ else if (code === 1007 || code === 1009) this.#parked = "context";
647
+ else delay = backoff(this.#reconnectAttempt++, STREAM_RETRY_MAX_MS);
648
+ if (delay !== void 0) this.#reconnectTimer = setTimeout(() => this.#connect(), delay);
649
+ }
650
+ /** One OFREP bulk request, mapped to an {@link Outcome}. Never rejects. */
651
+ async #fetchAll(context, signal) {
652
+ const wait = this.#blockedUntil - Date.now();
653
+ if (wait > 0) {
654
+ if (!await new Promise((resolve) => {
655
+ const timer = setTimeout(() => resolve(true), wait);
656
+ signal.addEventListener("abort", () => {
657
+ clearTimeout(timer);
658
+ resolve(false);
659
+ }, { once: true });
660
+ })) return { kind: "aborted" };
661
+ }
662
+ const timeout = new AbortController();
663
+ const timer = setTimeout(() => timeout.abort(), this.#timeoutMs);
664
+ const onAbort = () => timeout.abort();
665
+ signal.addEventListener("abort", onAbort, { once: true });
666
+ try {
667
+ let response;
668
+ try {
669
+ response = await this.#fetch(this.#url, {
670
+ method: "POST",
671
+ headers: {
672
+ Authorization: `Bearer ${this.#token}`,
673
+ "Content-Type": "application/json"
674
+ },
675
+ body: JSON.stringify({ context }),
676
+ signal: timeout.signal
677
+ });
678
+ } catch (e) {
679
+ if (signal.aborted) return { kind: "aborted" };
680
+ if (timeout.signal.aborted) return {
681
+ kind: "retry",
682
+ message: `The request timed out after ${this.#timeoutMs} ms`
683
+ };
684
+ return {
685
+ kind: "retry",
686
+ message: `The request failed: ${describe(e)}`
687
+ };
688
+ }
689
+ return await this.#readResponse(response);
690
+ } catch (e) {
691
+ if (signal.aborted) return { kind: "aborted" };
692
+ return {
693
+ kind: "retry",
694
+ message: `The response could not be read: ${describe(e)}`
695
+ };
696
+ } finally {
697
+ clearTimeout(timer);
698
+ signal.removeEventListener("abort", onAbort);
699
+ }
700
+ }
701
+ async #readResponse(response) {
702
+ switch (response.status) {
703
+ case 401: return {
704
+ kind: "fatal",
705
+ message: "The API rejected the public token (HTTP 401). Check that `publicToken` is the environment's ffp_… token."
706
+ };
707
+ case 403: return {
708
+ kind: "fatal",
709
+ message: "The token cannot evaluate flags (HTTP 403)."
710
+ };
711
+ case 429: {
712
+ const retryAfterMs = parseRetryAfter(response.headers.get("retry-after"));
713
+ return {
714
+ kind: "retry",
715
+ message: `The API rate limit was reached (HTTP 429)${retryAfterMs !== void 0 ? `. Retrying in ${Math.ceil(retryAfterMs / 1e3)} s` : ""}.`,
716
+ retryAfterMs
717
+ };
718
+ }
719
+ case 400: {
720
+ const body = await response.json().catch(() => void 0);
721
+ return {
722
+ kind: "rejected",
723
+ message: `The API rejected the evaluation context (${isRecord(body) && typeof body.errorCode === "string" ? body.errorCode : "GENERAL"})${isRecord(body) && typeof body.errorDetails === "string" ? `: ${body.errorDetails}` : ""}`
724
+ };
725
+ }
726
+ }
727
+ if (!response.ok) return {
728
+ kind: "retry",
729
+ message: `Unexpected response status ${response.status}`
730
+ };
731
+ const body = await response.json().catch(() => void 0);
732
+ if (!isRecord(body) || !Array.isArray(body.flags)) return {
733
+ kind: "retry",
734
+ message: "The response is not a valid OFREP bulk evaluation response (PARSE_ERROR)"
735
+ };
736
+ return {
737
+ kind: "ok",
738
+ flags: body.flags.map(fromWire).filter(isPresent)
739
+ };
740
+ }
741
+ };
742
+ export { ToggldClient as t };