@camera.ui/transport 0.0.21 → 0.0.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
- import { i as isTokenOnlyChange, n as isEndpointChange, r as isSameTarget, t as TransportEmitter } from "./contract-d-0gfY8v.js";
1
+ import { n as isEndpointChange, r as isSameTarget, t as TransportEmitter } from "./contract-dt1m8U-o.js";
2
+ import { t as classifyClose } from "./closeCodes-ClWJVOpq.js";
2
3
  import { Logger } from "@camera.ui/logger";
3
4
  //#region src/core/reducer.ts
4
- var EMPTY_TRANSPORTS = /* @__PURE__ */ new Map();
5
5
  var DEFAULT_BACKOFF_MS = 5e3;
6
6
  function reducer(phase, action, ctx) {
7
7
  switch (action.type) {
@@ -10,124 +10,36 @@ function reducer(phase, action, ctx) {
10
10
  if (phase.kind !== "idle" && phase.kind !== "offline" && phase.kind !== "needs-auth") return phase;
11
11
  return {
12
12
  kind: "discovering",
13
- instanceId: action.instanceId,
14
- attempt: 1
13
+ instanceId: action.instanceId
15
14
  };
16
15
  case "USER_RETRY":
17
- if (phase.kind === "offline") return {
16
+ if (phase.kind === "offline" || phase.kind === "needs-auth") return {
18
17
  kind: "discovering",
19
- instanceId: phase.instanceId ?? "",
20
- attempt: 1
21
- };
22
- if (phase.kind === "reconnecting") return {
23
- kind: "discovering",
24
- instanceId: phase.instanceId,
25
- attempt: 1,
26
- transports: phase.transports
27
- };
28
- if (phase.kind === "needs-auth") return {
29
- kind: "discovering",
30
- instanceId: phase.instanceId ?? "",
31
- attempt: 1
32
- };
33
- if (phase.kind === "online") return {
34
- kind: "discovering",
35
- instanceId: phase.instanceId,
36
- attempt: 1,
37
- transports: phase.transports
18
+ instanceId: phase.instanceId ?? ""
38
19
  };
39
20
  return phase;
40
21
  case "PROBE_SUCCEEDED":
41
- if (phase.kind === "discovering") return {
42
- kind: "online",
43
- instanceId: phase.instanceId,
44
- target: {
45
- endpoint: action.endpoint,
46
- tokens: action.tokens
47
- },
48
- transports: phase.transports ?? EMPTY_TRANSPORTS
49
- };
50
- if (phase.kind === "reconnecting") return {
22
+ if (phase.kind !== "discovering") return phase;
23
+ return {
51
24
  kind: "online",
52
25
  instanceId: phase.instanceId,
53
26
  target: {
54
27
  endpoint: action.endpoint,
55
28
  tokens: action.tokens
56
- },
57
- transports: phase.transports
29
+ }
58
30
  };
59
- return phase;
60
- case "PROBE_FAILED_ALL": {
61
- if (phase.kind !== "discovering" && phase.kind !== "reconnecting") return phase;
31
+ case "PROBE_FAILED_ALL":
32
+ if (phase.kind !== "discovering" && phase.kind !== "online") return phase;
62
33
  if (action.error === "needs-auth") return {
63
34
  kind: "needs-auth",
64
35
  instanceId: phase.instanceId,
65
36
  reason: action.error
66
37
  };
67
- const attempt = phase.kind === "discovering" ? phase.attempt : 1;
68
- const backoff = ctx.retryBackoffMs?.(attempt) ?? DEFAULT_BACKOFF_MS;
69
38
  return {
70
39
  kind: "offline",
71
40
  instanceId: phase.instanceId,
72
41
  lastError: action.error,
73
- nextRetryAt: ctx.now() + backoff
74
- };
75
- }
76
- case "TRANSPORT_UP":
77
- if (phase.kind === "online") return {
78
- ...phase,
79
- transports: setStatus(phase.transports, action.id, { up: true })
80
- };
81
- if (phase.kind === "reconnecting") {
82
- const next = setStatus(phase.transports, action.id, { up: true });
83
- if (phase.lastTarget && allPhaseGatingUp(next, ctx)) return {
84
- kind: "online",
85
- instanceId: phase.instanceId,
86
- target: phase.lastTarget,
87
- transports: next
88
- };
89
- return {
90
- ...phase,
91
- transports: next
92
- };
93
- }
94
- if (phase.kind === "discovering") return {
95
- ...phase,
96
- transports: setStatus(phase.transports ?? EMPTY_TRANSPORTS, action.id, { up: true })
97
- };
98
- return phase;
99
- case "TRANSPORT_DOWN":
100
- if (phase.kind === "online" || phase.kind === "reconnecting") return {
101
- ...phase,
102
- transports: setStatus(phase.transports, action.id, {
103
- up: false,
104
- lastError: action.reason,
105
- downSince: ctx.now()
106
- })
107
- };
108
- if (phase.kind === "discovering") return {
109
- ...phase,
110
- transports: setStatus(phase.transports ?? EMPTY_TRANSPORTS, action.id, {
111
- up: false,
112
- lastError: action.reason,
113
- downSince: ctx.now()
114
- })
115
- };
116
- return phase;
117
- case "TRANSPORT_DOWN_CONFIRMED":
118
- if (phase.kind !== "online") return phase;
119
- if (!ctx.specs.get(action.id)?.phaseGating) return phase;
120
- return {
121
- kind: "reconnecting",
122
- instanceId: phase.instanceId,
123
- lastTarget: phase.target,
124
- cause: "transport-down",
125
- since: ctx.now(),
126
- transports: setStatus(phase.transports, action.id, {
127
- up: false,
128
- lastError: "down-confirmed",
129
- downSince: ctx.now()
130
- })
42
+ nextRetryAt: ctx.now() + DEFAULT_BACKOFF_MS
131
43
  };
132
44
  case "TOKENS_REFRESHED":
133
45
  if (phase.kind === "online") {
@@ -140,33 +52,46 @@ function reducer(phase, action, ctx) {
140
52
  }
141
53
  };
142
54
  }
143
- if (phase.kind === "reconnecting" && phase.lastTarget) {
144
- if (tokensEqual(phase.lastTarget.tokens, action.tokens)) return phase;
55
+ if (phase.kind === "discovering") {
56
+ if (phase.pendingTokens && tokensEqual(phase.pendingTokens, action.tokens)) return phase;
145
57
  return {
146
58
  ...phase,
147
- lastTarget: {
148
- ...phase.lastTarget,
149
- tokens: action.tokens
150
- }
59
+ pendingTokens: action.tokens
151
60
  };
152
61
  }
153
62
  return phase;
154
63
  case "TOKENS_INVALID":
155
- if (phase.kind !== "online" && phase.kind !== "reconnecting") return phase;
156
- if (action.transient === true) {
157
- const backoff = ctx.retryBackoffMs?.(1) ?? DEFAULT_BACKOFF_MS;
158
- return {
159
- kind: "offline",
160
- instanceId: phase.instanceId,
161
- lastError: `tokens invalid: ${action.reason}`,
162
- nextRetryAt: ctx.now() + backoff
163
- };
164
- }
64
+ if (phase.kind !== "online") return phase;
65
+ if (action.transient === true) return {
66
+ kind: "offline",
67
+ instanceId: phase.instanceId,
68
+ lastError: `tokens invalid: ${action.reason}`,
69
+ nextRetryAt: ctx.now() + DEFAULT_BACKOFF_MS
70
+ };
165
71
  return {
166
72
  kind: "needs-auth",
167
73
  instanceId: phase.instanceId,
168
74
  reason: `tokens invalid: ${action.reason}`
169
75
  };
76
+ case "ENDPOINT_SWAP":
77
+ if (phase.kind !== "online") return phase;
78
+ if (phase.target.endpoint.url === action.endpoint.url && phase.target.endpoint.mode === action.endpoint.mode) {
79
+ if (tokensEqual(phase.target.tokens, action.tokens)) return phase;
80
+ return {
81
+ ...phase,
82
+ target: {
83
+ ...phase.target,
84
+ tokens: action.tokens
85
+ }
86
+ };
87
+ }
88
+ return {
89
+ ...phase,
90
+ target: {
91
+ endpoint: action.endpoint,
92
+ tokens: action.tokens
93
+ }
94
+ };
170
95
  case "BACKOFF_HINT": {
171
96
  if (phase.kind !== "offline") return phase;
172
97
  const now = ctx.now();
@@ -184,18 +109,6 @@ function reducer(phase, action, ctx) {
184
109
  }
185
110
  }
186
111
  }
187
- function setStatus(map, id, status) {
188
- const next = new Map(map);
189
- next.set(id, status);
190
- return next;
191
- }
192
- function allPhaseGatingUp(map, ctx) {
193
- for (const [id, spec] of ctx.specs) {
194
- if (!spec.phaseGating) continue;
195
- if (!map.get(id)?.up) return false;
196
- }
197
- return true;
198
- }
199
112
  function tokensEqual(a, b) {
200
113
  return a.access === b.access && a.refresh === b.refresh && a.accessExpiresAt === b.accessExpiresAt && a.refreshExpiresAt === b.refreshExpiresAt && a.proxySession === b.proxySession && a.proxySessionExpiresAt === b.proxySessionExpiresAt && a.proxyRefresh === b.proxyRefresh;
201
114
  }
@@ -220,6 +133,11 @@ function createKernel(options) {
220
133
  while (next) {
221
134
  const prev = current;
222
135
  const after = reducer(prev, next, options.context);
136
+ if (options.onAction) try {
137
+ options.onAction(next, prev, after);
138
+ } catch (err) {
139
+ log$1.warn("onAction threw on", next.type, err);
140
+ }
223
141
  if (after !== prev) {
224
142
  current = after;
225
143
  for (const listener of [...listeners]) try {
@@ -255,15 +173,190 @@ function createKernel(options) {
255
173
  };
256
174
  }
257
175
  //#endregion
258
- //#region src/core/resolver.ts
259
- function sortByPriority(endpoints) {
260
- return [...endpoints].sort((a, b) => (a.priority ?? 99) - (b.priority ?? 99));
261
- }
262
- function isSameEndpoint(a, b) {
263
- return a.mode === b.mode && a.url === b.url;
176
+ //#region src/race.ts
177
+ var DEFAULT_RACE_TIMEOUT_BY_MODE = {
178
+ "direct-lan": 2e3,
179
+ "direct-wan": 5e3
180
+ };
181
+ var DEFAULT_PREFER_GRACE_MS = 400;
182
+ var RaceFirstError = class extends Error {
183
+ endpoint;
184
+ cause;
185
+ kind;
186
+ constructor(message, endpoint, cause, kind) {
187
+ super(message);
188
+ this.name = "RaceFirstError";
189
+ this.endpoint = endpoint;
190
+ this.cause = cause;
191
+ this.kind = kind;
192
+ }
193
+ };
194
+ function raceFirst(candidates, options = {}) {
195
+ const { timeoutByMode = (mode) => DEFAULT_RACE_TIMEOUT_BY_MODE[mode] ?? 5e3, shortCircuit, parentSignal, prefer } = options;
196
+ const preferGraceMs = options.preferGraceMs ?? DEFAULT_PREFER_GRACE_MS;
197
+ return new Promise((resolve, reject) => {
198
+ if (candidates.length === 0) {
199
+ reject(new RaceFirstError("raceFirst: no candidates", {
200
+ url: "",
201
+ mode: "direct-lan"
202
+ }, void 0, "all-failed"));
203
+ return;
204
+ }
205
+ if (parentSignal?.aborted) {
206
+ reject(new RaceFirstError("raceFirst: parent aborted", candidates[0].endpoint, void 0, "aborted"));
207
+ return;
208
+ }
209
+ let settled = false;
210
+ let remaining = candidates.length;
211
+ let pendingPreferred = prefer ? candidates.filter((c) => prefer(c.endpoint)).length : 0;
212
+ let held = null;
213
+ const lastErrors = /* @__PURE__ */ new Map();
214
+ const abortControllers = [];
215
+ const timers = [];
216
+ function cleanupAllExcept(except) {
217
+ for (const t of timers) clearTimeout(t);
218
+ for (const a of abortControllers) if (a !== except) a.abort();
219
+ }
220
+ function finishSuccess(endpoint, value, except) {
221
+ if (settled) return;
222
+ settled = true;
223
+ cleanupAllExcept(except);
224
+ if (onParentAbort) parentSignal?.removeEventListener("abort", onParentAbort);
225
+ resolve({
226
+ endpoint,
227
+ value
228
+ });
229
+ }
230
+ function settleHeld() {
231
+ if (!held) return;
232
+ finishSuccess(held.endpoint, held.value, held.ctrl);
233
+ }
234
+ function handleSuccess(endpoint, value, ctrl) {
235
+ if (settled) return;
236
+ if (!prefer || prefer(endpoint) || pendingPreferred <= 0) {
237
+ finishSuccess(endpoint, value, ctrl);
238
+ return;
239
+ }
240
+ if (held) return;
241
+ held = {
242
+ endpoint,
243
+ value,
244
+ ctrl
245
+ };
246
+ const holdTimer = setTimeout(settleHeld, preferGraceMs);
247
+ timers.push(holdTimer);
248
+ }
249
+ function finishFail(error) {
250
+ if (settled) return;
251
+ settled = true;
252
+ cleanupAllExcept(null);
253
+ if (onParentAbort) parentSignal?.removeEventListener("abort", onParentAbort);
254
+ reject(error);
255
+ }
256
+ const onParentAbort = parentSignal ? () => finishFail(new RaceFirstError("raceFirst: parent aborted", candidates[0].endpoint, void 0, "aborted")) : null;
257
+ if (onParentAbort) parentSignal.addEventListener("abort", onParentAbort);
258
+ candidates.forEach((cand) => {
259
+ const ctrl = new AbortController();
260
+ abortControllers.push(ctrl);
261
+ const delay = timeoutByMode(cand.endpoint.mode);
262
+ const timer = setTimeout(() => ctrl.abort(), delay);
263
+ timers.push(timer);
264
+ cand.run(ctrl.signal).then((value) => handleSuccess(cand.endpoint, value, ctrl), (err) => {
265
+ if (settled) {
266
+ lastErrors.set(cand.endpoint, err);
267
+ return;
268
+ }
269
+ if (prefer?.(cand.endpoint)) {
270
+ pendingPreferred--;
271
+ if (held && pendingPreferred <= 0) {
272
+ lastErrors.set(cand.endpoint, err);
273
+ settleHeld();
274
+ return;
275
+ }
276
+ }
277
+ const finalErr = ctrl.signal.aborted && !parentSignal?.aborted ? new RaceFirstError(`timeout (${delay}ms)`, cand.endpoint, err, "all-failed") : err;
278
+ lastErrors.set(cand.endpoint, finalErr);
279
+ if (shortCircuit?.(finalErr)) {
280
+ finishFail(new RaceFirstError("raceFirst: short-circuit", cand.endpoint, finalErr, "short-circuit"));
281
+ return;
282
+ }
283
+ remaining--;
284
+ if (remaining <= 0) {
285
+ const [endpoint, cause] = [...lastErrors.entries()].find(([, e]) => {
286
+ if (e instanceof RaceFirstError) return e.kind !== "all-failed" || !(e.cause instanceof Error && e.cause.message === "aborted");
287
+ return true;
288
+ }) ?? [cand.endpoint, finalErr];
289
+ finishFail(new RaceFirstError("raceFirst: all candidates failed", endpoint, cause, "all-failed"));
290
+ }
291
+ });
292
+ });
293
+ });
264
294
  }
265
- function endpointKey(ep) {
266
- return `${ep.mode}|${ep.url}`;
295
+ //#endregion
296
+ //#region src/effects/backgroundProbe.ts
297
+ function createBackgroundProbe(options) {
298
+ let inflight = null;
299
+ let disposed = false;
300
+ async function round() {
301
+ if (options.kernel.phase.kind !== "online") {
302
+ options.onResult?.("skipped", `phase=${options.kernel.phase.kind}`);
303
+ return "skipped";
304
+ }
305
+ const ctrl = new AbortController();
306
+ try {
307
+ const pool = await options.discover(ctrl.signal);
308
+ if (disposed || options.kernel.phase.kind !== "online") return "skipped";
309
+ if (pool.length === 0) {
310
+ options.onResult?.("failed", "empty pool");
311
+ return "failed";
312
+ }
313
+ const lastTokens = options.lastTarget?.()?.tokens;
314
+ const { endpoint, value: tokens } = await raceFirst(pool.map((endpoint) => ({
315
+ endpoint,
316
+ run: (signal) => options.probe({
317
+ endpoint,
318
+ lastTokens,
319
+ signal
320
+ })
321
+ })), {
322
+ timeoutByMode: options.timeoutByMode,
323
+ parentSignal: ctrl.signal,
324
+ prefer: options.prefer,
325
+ preferGraceMs: options.preferGraceMs
326
+ });
327
+ if (disposed) return "skipped";
328
+ const phase = options.kernel.phase;
329
+ if (phase.kind !== "online") {
330
+ options.onResult?.("skipped", `phase=${phase.kind}`);
331
+ return "skipped";
332
+ }
333
+ const same = phase.target.endpoint.url === endpoint.url && phase.target.endpoint.mode === endpoint.mode;
334
+ options.kernel.dispatch({
335
+ type: "ENDPOINT_SWAP",
336
+ endpoint,
337
+ tokens
338
+ });
339
+ const outcome = same ? "same" : "swap";
340
+ options.onResult?.(outcome, endpoint.url);
341
+ return outcome;
342
+ } catch (err) {
343
+ options.onResult?.("failed", err instanceof Error ? err.message : String(err));
344
+ return "failed";
345
+ }
346
+ }
347
+ return {
348
+ run() {
349
+ if (disposed) return Promise.resolve("skipped");
350
+ if (inflight) return inflight;
351
+ inflight = round().finally(() => {
352
+ inflight = null;
353
+ });
354
+ return inflight;
355
+ },
356
+ dispose() {
357
+ disposed = true;
358
+ }
359
+ };
267
360
  }
268
361
  //#endregion
269
362
  //#region src/effects/backoff.ts
@@ -338,8 +431,7 @@ function attachCrossTab(options) {
338
431
  if (e.key !== key) return;
339
432
  if (e.newValue === null) {
340
433
  options.absorb?.(null);
341
- const k = options.kernel.phase.kind;
342
- if (k !== "online" && k !== "reconnecting") return;
434
+ if (options.kernel.phase.kind !== "online") return;
343
435
  options.kernel.dispatch({ type: "RESET" });
344
436
  options.onResetReceived?.();
345
437
  return;
@@ -352,8 +444,7 @@ function attachCrossTab(options) {
352
444
  return;
353
445
  }
354
446
  if (!parsed?.tokens?.access) return;
355
- const k = options.kernel.phase.kind;
356
- if (k !== "online" && k !== "reconnecting") {
447
+ if (options.kernel.phase.kind !== "online") {
357
448
  if (parsed.endpoint?.url) {
358
449
  options.absorb?.({
359
450
  endpoint: parsed.endpoint,
@@ -375,16 +466,85 @@ function attachCrossTab(options) {
375
466
  };
376
467
  }
377
468
  //#endregion
469
+ //#region src/effects/degradedRecovery.ts
470
+ var DEFAULT_GRACE_MS$1 = 1e4;
471
+ function attachDegradedRecovery(options) {
472
+ const graceMs = options.graceMs ?? DEFAULT_GRACE_MS$1;
473
+ let timer = null;
474
+ let round = 0;
475
+ let detached = false;
476
+ function disarm() {
477
+ if (timer !== null) {
478
+ clearTimeout(timer);
479
+ timer = null;
480
+ }
481
+ round = 0;
482
+ }
483
+ function arm() {
484
+ if (detached || timer !== null) return;
485
+ timer = setTimeout(() => {
486
+ timer = null;
487
+ escalate();
488
+ }, graceMs);
489
+ }
490
+ async function escalate() {
491
+ if (detached || options.signal.current.kind !== "degraded") return;
492
+ round++;
493
+ options.onEscalate?.(round);
494
+ options.ensureAll();
495
+ const outcome = await options.probe();
496
+ if (detached || options.signal.current.kind !== "degraded") return;
497
+ if (outcome === "failed") {
498
+ if (options.isReachable?.()) {
499
+ options.onHold?.("probe failed but a channel is up — holding");
500
+ arm();
501
+ return;
502
+ }
503
+ options.onOffline?.("degraded: endpoint unreachable");
504
+ options.kernel.dispatch({
505
+ type: "PROBE_FAILED_ALL",
506
+ error: "degraded: endpoint unreachable"
507
+ });
508
+ return;
509
+ }
510
+ arm();
511
+ }
512
+ const unsub = options.signal.subscribe((next) => {
513
+ if (next.kind === "degraded") arm();
514
+ else disarm();
515
+ });
516
+ if (options.signal.current.kind === "degraded") arm();
517
+ return () => {
518
+ detached = true;
519
+ disarm();
520
+ unsub();
521
+ };
522
+ }
523
+ //#endregion
378
524
  //#region src/effects/networkChange.ts
379
525
  function attachNetworkChange(options) {
526
+ const debounceMs = options.debounceMs ?? 0;
380
527
  let detached = false;
528
+ let timer = null;
381
529
  const handler = (event) => {
382
530
  if (detached) return;
383
- options.onChange(options.kernel, event);
531
+ if (debounceMs <= 0) {
532
+ options.onChange(options.kernel, event);
533
+ return;
534
+ }
535
+ if (timer !== null) clearTimeout(timer);
536
+ timer = setTimeout(() => {
537
+ timer = null;
538
+ if (!detached) options.onChange(options.kernel, event);
539
+ }, debounceMs);
384
540
  };
385
541
  options.source.addEventListener("change", handler);
386
542
  return () => {
387
543
  detached = true;
544
+ if (timer !== null) {
545
+ clearTimeout(timer);
546
+ timer = null;
547
+ }
388
548
  options.source.removeEventListener("change", handler);
389
549
  };
390
550
  }
@@ -459,8 +619,15 @@ function attachPersistence(options) {
459
619
  }
460
620
  const unsub = options.kernel.subscribe((next, prev) => {
461
621
  if (detached) return;
462
- const nextTarget = next.kind === "online" ? next.target : next.kind === "reconnecting" ? next.lastTarget : null;
463
- const prevTarget = prev.kind === "online" ? prev.target : prev.kind === "reconnecting" ? prev.lastTarget : null;
622
+ if (next.kind === "discovering" && next.pendingTokens && (prev.kind !== "discovering" || prev.pendingTokens !== next.pendingTokens)) {
623
+ if (cached) persist({
624
+ endpoint: cached.endpoint,
625
+ tokens: next.pendingTokens
626
+ });
627
+ return;
628
+ }
629
+ const nextTarget = next.kind === "online" ? next.target : null;
630
+ const prevTarget = prev.kind === "online" ? prev.target : null;
464
631
  if (nextTarget && nextTarget !== prevTarget) {
465
632
  persist(nextTarget);
466
633
  return;
@@ -548,95 +715,6 @@ function attachPresence(options) {
548
715
  };
549
716
  }
550
717
  //#endregion
551
- //#region src/race.ts
552
- var DEFAULT_RACE_TIMEOUT_BY_MODE = {
553
- "direct-lan": 2e3,
554
- "direct-wan": 5e3
555
- };
556
- var RaceFirstError = class extends Error {
557
- endpoint;
558
- cause;
559
- kind;
560
- constructor(message, endpoint, cause, kind) {
561
- super(message);
562
- this.name = "RaceFirstError";
563
- this.endpoint = endpoint;
564
- this.cause = cause;
565
- this.kind = kind;
566
- }
567
- };
568
- function raceFirst(candidates, options = {}) {
569
- const { timeoutByMode = (mode) => DEFAULT_RACE_TIMEOUT_BY_MODE[mode] ?? 5e3, shortCircuit, parentSignal } = options;
570
- return new Promise((resolve, reject) => {
571
- if (candidates.length === 0) {
572
- reject(new RaceFirstError("raceFirst: no candidates", {
573
- url: "",
574
- mode: "direct-lan"
575
- }, void 0, "all-failed"));
576
- return;
577
- }
578
- if (parentSignal?.aborted) {
579
- reject(new RaceFirstError("raceFirst: parent aborted", candidates[0].endpoint, void 0, "aborted"));
580
- return;
581
- }
582
- let settled = false;
583
- let remaining = candidates.length;
584
- const lastErrors = /* @__PURE__ */ new Map();
585
- const abortControllers = [];
586
- const timers = [];
587
- function cleanupAllExcept(except) {
588
- for (const t of timers) clearTimeout(t);
589
- for (const a of abortControllers) if (a !== except) a.abort();
590
- }
591
- function finishSuccess(endpoint, value, except) {
592
- if (settled) return;
593
- settled = true;
594
- cleanupAllExcept(except);
595
- if (onParentAbort) parentSignal?.removeEventListener("abort", onParentAbort);
596
- resolve({
597
- endpoint,
598
- value
599
- });
600
- }
601
- function finishFail(error) {
602
- if (settled) return;
603
- settled = true;
604
- cleanupAllExcept(null);
605
- if (onParentAbort) parentSignal?.removeEventListener("abort", onParentAbort);
606
- reject(error);
607
- }
608
- const onParentAbort = parentSignal ? () => finishFail(new RaceFirstError("raceFirst: parent aborted", candidates[0].endpoint, void 0, "aborted")) : null;
609
- if (onParentAbort) parentSignal.addEventListener("abort", onParentAbort);
610
- candidates.forEach((cand) => {
611
- const ctrl = new AbortController();
612
- abortControllers.push(ctrl);
613
- const delay = timeoutByMode(cand.endpoint.mode);
614
- const timer = setTimeout(() => ctrl.abort(), delay);
615
- timers.push(timer);
616
- cand.run(ctrl.signal).then((value) => finishSuccess(cand.endpoint, value, ctrl), (err) => {
617
- if (settled) {
618
- lastErrors.set(cand.endpoint, err);
619
- return;
620
- }
621
- const finalErr = ctrl.signal.aborted && !parentSignal?.aborted ? new RaceFirstError(`timeout (${delay}ms)`, cand.endpoint, err, "all-failed") : err;
622
- lastErrors.set(cand.endpoint, finalErr);
623
- if (shortCircuit?.(finalErr)) {
624
- finishFail(new RaceFirstError("raceFirst: short-circuit", cand.endpoint, finalErr, "short-circuit"));
625
- return;
626
- }
627
- remaining--;
628
- if (remaining <= 0) {
629
- const [endpoint, cause] = [...lastErrors.entries()].find(([, e]) => {
630
- if (e instanceof RaceFirstError) return e.kind !== "all-failed" || !(e.cause instanceof Error && e.cause.message === "aborted");
631
- return true;
632
- }) ?? [cand.endpoint, finalErr];
633
- finishFail(new RaceFirstError("raceFirst: all candidates failed", endpoint, cause, "all-failed"));
634
- }
635
- });
636
- });
637
- });
638
- }
639
- //#endregion
640
718
  //#region src/effects/probeLoop.ts
641
719
  function makeProbeFailure(kind, message) {
642
720
  const err = new Error(message);
@@ -712,6 +790,8 @@ function attachProbeLoop(options) {
712
790
  const { endpoint, value: tokens } = await raceFirst(candidates, {
713
791
  timeoutByMode: options.timeoutByMode,
714
792
  parentSignal: ctrl.signal,
793
+ prefer: options.prefer,
794
+ preferGraceMs: options.preferGraceMs,
715
795
  shortCircuit: (err) => isProbeFailure(err) && (err.kind === "needs-auth" || err.kind === "fatal" || err.kind === "aborted")
716
796
  });
717
797
  if (ctrl.signal.aborted) return;
@@ -749,52 +829,13 @@ function attachProbeLoop(options) {
749
829
  };
750
830
  }
751
831
  //#endregion
752
- //#region src/effects/reconnectWatchdog.ts
753
- var DEFAULT_ESCALATE_AFTER_MS = 12e3;
754
- function attachReconnectWatchdog(options) {
755
- const escalateAfterMs = options.escalateAfterMs ?? DEFAULT_ESCALATE_AFTER_MS;
756
- const setTimer = options.setTimer ?? ((cb, ms) => setTimeout(cb, ms));
757
- const clearTimer = options.clearTimer ?? ((h) => clearTimeout(h));
758
- let timer;
759
- let attempt = 0;
760
- let detached = false;
761
- function cancelTimer() {
762
- if (timer !== void 0) {
763
- clearTimer(timer);
764
- timer = void 0;
765
- }
766
- }
767
- function arm() {
768
- cancelTimer();
769
- timer = setTimer(() => {
770
- timer = void 0;
771
- if (detached) return;
772
- if (options.kernel.phase.kind !== "reconnecting") return;
773
- attempt += 1;
774
- options.onEscalate?.(attempt);
775
- options.kernel.dispatch({ type: "USER_RETRY" });
776
- }, escalateAfterMs);
777
- }
778
- const unsubKernel = options.kernel.subscribe((next, prev) => {
779
- if (next.kind === "reconnecting" && prev.kind !== "reconnecting") arm();
780
- else if (next.kind !== "reconnecting" && prev.kind === "reconnecting") cancelTimer();
781
- if (next.kind === "online" || next.kind === "idle") attempt = 0;
782
- });
783
- if (options.kernel.phase.kind === "reconnecting") arm();
784
- return () => {
785
- detached = true;
786
- cancelTimer();
787
- unsubKernel();
788
- };
789
- }
790
- //#endregion
791
832
  //#region src/effects/tokenLifecycle.ts
792
- var DEFAULT_GRACE_MS$1 = 5e3;
833
+ var DEFAULT_GRACE_MS = 5e3;
793
834
  var DEFAULT_MAX_TRANSIENT_RETRIES = 3;
794
835
  var DEFAULT_TRANSIENT_RETRY_DELAY_MS = 2e3;
795
836
  var MAX_TIMER_DELAY_MS = 2 ** 31 - 1;
796
837
  function attachTokenLifecycle(options) {
797
- const graceMs = options.graceMs ?? DEFAULT_GRACE_MS$1;
838
+ const graceMs = options.graceMs ?? DEFAULT_GRACE_MS;
798
839
  const isTransient = options.isTransientError ?? (() => false);
799
840
  const maxTransientRetries = options.maxTransientRetries ?? DEFAULT_MAX_TRANSIENT_RETRIES;
800
841
  const transientRetryDelayMs = options.transientRetryDelayMs ?? DEFAULT_TRANSIENT_RETRY_DELAY_MS;
@@ -835,7 +876,7 @@ function attachTokenLifecycle(options) {
835
876
  return;
836
877
  }
837
878
  const phase = options.kernel.phase;
838
- const target = phase.kind === "online" ? phase.target : phase.kind === "reconnecting" ? phase.lastTarget : null;
879
+ const target = phase.kind === "online" ? phase.target : null;
839
880
  if (!target) {
840
881
  options.onTriggerSkipped?.(reason, "no-target", phase.kind);
841
882
  return;
@@ -856,7 +897,7 @@ function attachTokenLifecycle(options) {
856
897
  };
857
898
  }
858
899
  const livePhase = options.kernel.phase;
859
- const base = (livePhase.kind === "online" ? livePhase.target : livePhase.kind === "reconnecting" ? livePhase.lastTarget : null) ?? target;
900
+ const base = (livePhase.kind === "online" ? livePhase.target : null) ?? target;
860
901
  const refreshTarget = fresh ? {
861
902
  ...base,
862
903
  tokens: fresh
@@ -925,7 +966,7 @@ function attachTokenLifecycle(options) {
925
966
  transientRetries = 0;
926
967
  schedule(next.target);
927
968
  }
928
- } else if (next.kind === "reconnecting") {} else {
969
+ } else {
929
970
  cancelTimer();
930
971
  transientRetries = 0;
931
972
  }
@@ -946,7 +987,7 @@ function attachTokenLifecycle(options) {
946
987
  function wake() {
947
988
  if (detached) return;
948
989
  const phase = options.kernel.phase;
949
- const target = phase.kind === "online" ? phase.target : phase.kind === "reconnecting" ? phase.lastTarget : null;
990
+ const target = phase.kind === "online" ? phase.target : null;
950
991
  if (!target) {
951
992
  options.onWakeChecked?.({
952
993
  decision: "no-target",
@@ -989,29 +1030,58 @@ function stringifyError(err) {
989
1030
  //#endregion
990
1031
  //#region src/effects/transportSync.ts
991
1032
  var SKIP = Symbol("transport-sync-skip");
1033
+ var DEFAULT_RETRY_MS = 2e3;
992
1034
  function syncTargetFor(phase) {
993
1035
  switch (phase.kind) {
994
1036
  case "idle":
995
1037
  case "offline":
996
1038
  case "needs-auth": return null;
997
1039
  case "online": return phase.target;
998
- case "reconnecting": return phase.lastTarget;
999
1040
  case "discovering": return SKIP;
1000
1041
  }
1001
1042
  }
1002
1043
  function attachTransportSync(options) {
1003
1044
  let detached = false;
1004
- let initialized = false;
1005
- let lastApplied = null;
1006
- function applyAll(target) {
1007
- if (detached) return;
1008
- if (initialized && isSameTarget(lastApplied, target)) return;
1009
- initialized = true;
1010
- lastApplied = target;
1011
- for (const transport of options.transports) transport.apply(target).catch((err) => {
1045
+ const retryMs = options.retryMs ?? DEFAULT_RETRY_MS;
1046
+ const applied = /* @__PURE__ */ new Map();
1047
+ const retryTimers = /* @__PURE__ */ new Map();
1048
+ let lastNotified;
1049
+ function applyOne(transport, target) {
1050
+ applied.set(transport, target);
1051
+ transport.apply(target).catch((err) => {
1012
1052
  options.onError?.(transport, target, err);
1053
+ if (applied.get(transport) === target) {
1054
+ applied.delete(transport);
1055
+ scheduleRetry(transport);
1056
+ }
1013
1057
  });
1014
- options.onApplied?.(target);
1058
+ }
1059
+ function scheduleRetry(transport) {
1060
+ if (detached || retryTimers.has(transport)) return;
1061
+ const timer = setTimeout(() => {
1062
+ retryTimers.delete(transport);
1063
+ if (detached) return;
1064
+ const decision = syncTargetFor(options.kernel.phase);
1065
+ if (decision === SKIP) {
1066
+ scheduleRetry(transport);
1067
+ return;
1068
+ }
1069
+ if (applied.has(transport) && isSameTarget(applied.get(transport), decision)) return;
1070
+ options.onRetry?.(transport, decision);
1071
+ applyOne(transport, decision);
1072
+ }, retryMs);
1073
+ retryTimers.set(transport, timer);
1074
+ }
1075
+ function applyAll(target) {
1076
+ if (detached) return;
1077
+ for (const transport of options.transports) {
1078
+ if (applied.has(transport) && isSameTarget(applied.get(transport), target)) continue;
1079
+ applyOne(transport, target);
1080
+ }
1081
+ if (lastNotified === void 0 || !isSameTarget(lastNotified, target)) {
1082
+ lastNotified = target;
1083
+ options.onApplied?.(target);
1084
+ }
1015
1085
  }
1016
1086
  function syncFromPhase(phase) {
1017
1087
  const decision = syncTargetFor(phase);
@@ -1024,76 +1094,12 @@ function attachTransportSync(options) {
1024
1094
  });
1025
1095
  return () => {
1026
1096
  detached = true;
1097
+ for (const timer of retryTimers.values()) clearTimeout(timer);
1098
+ retryTimers.clear();
1027
1099
  unsub();
1028
1100
  };
1029
1101
  }
1030
1102
  //#endregion
1031
- //#region src/effects/transportWatchdog.ts
1032
- var DEFAULT_GRACE_MS = 4e3;
1033
- function attachTransportWatchdog(options) {
1034
- const defaultGraceMs = options.defaultGraceMs ?? DEFAULT_GRACE_MS;
1035
- const setTimer = options.setTimer ?? ((cb, ms) => setTimeout(cb, ms));
1036
- const clearTimer = options.clearTimer ?? ((h) => clearTimeout(h));
1037
- const timers = /* @__PURE__ */ new Map();
1038
- const cleanups = [];
1039
- let detached = false;
1040
- function cancelTimer(id, reason) {
1041
- const handle = timers.get(id);
1042
- if (handle !== void 0) {
1043
- clearTimer(handle);
1044
- timers.delete(id);
1045
- options.onGraceCleared?.(id, reason);
1046
- }
1047
- }
1048
- function cancelAll(reason) {
1049
- for (const id of [...timers.keys()]) cancelTimer(id, reason);
1050
- }
1051
- for (const transport of options.transports) {
1052
- const spec = transport.spec;
1053
- const offUp = transport.on("up", () => {
1054
- if (detached) return;
1055
- cancelTimer(spec.id, "up");
1056
- options.kernel.dispatch({
1057
- type: "TRANSPORT_UP",
1058
- id: spec.id
1059
- });
1060
- });
1061
- const offDown = transport.on("down", (payload) => {
1062
- if (detached) return;
1063
- options.kernel.dispatch({
1064
- type: "TRANSPORT_DOWN",
1065
- id: spec.id,
1066
- reason: payload.reason
1067
- });
1068
- if (!spec.phaseGating) return;
1069
- if (options.kernel.phase.kind !== "online") return;
1070
- if (timers.has(spec.id)) return;
1071
- const graceMs = spec.graceMs ?? defaultGraceMs;
1072
- options.onGraceStarted?.(spec.id, graceMs);
1073
- const handle = setTimer(() => {
1074
- timers.delete(spec.id);
1075
- if (detached) return;
1076
- options.onConfirmed?.(spec.id);
1077
- options.kernel.dispatch({
1078
- type: "TRANSPORT_DOWN_CONFIRMED",
1079
- id: spec.id
1080
- });
1081
- }, graceMs);
1082
- timers.set(spec.id, handle);
1083
- });
1084
- cleanups.push(offUp, offDown);
1085
- }
1086
- const unsubKernel = options.kernel.subscribe((next, prev) => {
1087
- if (prev.kind === "online" && next.kind !== "online") cancelAll("phase-change");
1088
- });
1089
- cleanups.push(unsubKernel);
1090
- return () => {
1091
- detached = true;
1092
- cancelAll("detach");
1093
- for (const fn of cleanups) fn();
1094
- };
1095
- }
1096
- //#endregion
1097
1103
  //#region src/effects/workerBridge.ts
1098
1104
  var log = new Logger("workerBridge");
1099
1105
  function attachWorkerBridge(options) {
@@ -1180,4 +1186,187 @@ function attachWorkerBridge(options) {
1180
1186
  };
1181
1187
  }
1182
1188
  //#endregion
1183
- export { DEFAULT_RACE_TIMEOUT_BY_MODE, RaceFirstError, TransportEmitter, attachBackoff, attachCrossTab, attachNetworkChange, attachPersistence, attachPresence, attachProbeLoop, attachReconnectWatchdog, attachTokenLifecycle, attachTransportSync, attachTransportWatchdog, attachWorkerBridge, createKernel, defaultOnNetworkOnline, endpointKey, isEndpointChange, isProbeFailure, isSameEndpoint, isSameTarget, isTokenOnlyChange, localStorageAdapter, makeProbeFailure, memoryStorageAdapter, raceFirst, reducer, sortByPriority };
1189
+ //#region src/journal.ts
1190
+ var DEFAULT_CAPACITY = 1e3;
1191
+ var MAX_DETAIL_LENGTH = 300;
1192
+ function stringifyDetail(detail) {
1193
+ if (detail === void 0 || detail === null) return void 0;
1194
+ let text;
1195
+ if (typeof detail === "string") text = detail;
1196
+ else if (detail instanceof Error) text = detail.message;
1197
+ else try {
1198
+ text = JSON.stringify(detail);
1199
+ } catch {
1200
+ text = String(detail);
1201
+ }
1202
+ return text.length > MAX_DETAIL_LENGTH ? `${text.slice(0, MAX_DETAIL_LENGTH)}…` : text;
1203
+ }
1204
+ function formatTime(t) {
1205
+ const d = new Date(t);
1206
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}:${String(d.getSeconds()).padStart(2, "0")}.${String(d.getMilliseconds()).padStart(3, "0")}`;
1207
+ }
1208
+ function createConnectionJournal(options = {}) {
1209
+ const capacity = options.capacity ?? DEFAULT_CAPACITY;
1210
+ const now = options.now ?? (() => Date.now());
1211
+ const buffer = [];
1212
+ const listeners = /* @__PURE__ */ new Set();
1213
+ let head = 0;
1214
+ let seq = 0;
1215
+ function record(scope, msg, detail) {
1216
+ const entry = {
1217
+ seq: seq++,
1218
+ t: now(),
1219
+ scope,
1220
+ msg,
1221
+ detail: stringifyDetail(detail)
1222
+ };
1223
+ if (buffer.length < capacity) buffer.push(entry);
1224
+ else {
1225
+ buffer[head] = entry;
1226
+ head = (head + 1) % capacity;
1227
+ }
1228
+ for (const listener of [...listeners]) try {
1229
+ listener(entry);
1230
+ } catch {}
1231
+ }
1232
+ function list() {
1233
+ return [...buffer.slice(head), ...buffer.slice(0, head)];
1234
+ }
1235
+ function clear() {
1236
+ buffer.length = 0;
1237
+ head = 0;
1238
+ }
1239
+ function subscribe(listener) {
1240
+ listeners.add(listener);
1241
+ return () => {
1242
+ listeners.delete(listener);
1243
+ };
1244
+ }
1245
+ function exportText() {
1246
+ const entries = list();
1247
+ return [`connection journal — exported ${new Date(now()).toISOString()} (${entries.length} entries)`, ...entries.map((e) => {
1248
+ const detail = e.detail ? ` — ${e.detail}` : "";
1249
+ return `${formatTime(e.t)} [${e.scope}] ${e.msg}${detail}`;
1250
+ })].join("\n");
1251
+ }
1252
+ return {
1253
+ record,
1254
+ list,
1255
+ clear,
1256
+ subscribe,
1257
+ exportText
1258
+ };
1259
+ }
1260
+ //#endregion
1261
+ //#region src/signal.ts
1262
+ var DEFAULT_DEBOUNCE_MS = 1500;
1263
+ function signalEquals(a, b) {
1264
+ if (a.kind !== b.kind) return false;
1265
+ if (a.kind === "degraded" && b.kind === "degraded") return a.channels.join(",") === b.channels.join(",");
1266
+ if (a.kind === "offline" && b.kind === "offline") return a.retryAt === b.retryAt;
1267
+ return true;
1268
+ }
1269
+ function createConnectionSignal(options) {
1270
+ const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
1271
+ const listeners = /* @__PURE__ */ new Set();
1272
+ const cleanups = [];
1273
+ let hasBeenOnline = false;
1274
+ let anyChannelEverUp = false;
1275
+ let pendingTimer = null;
1276
+ let pendingSignal = null;
1277
+ let disposed = false;
1278
+ function compute() {
1279
+ const phase = options.kernel.phase;
1280
+ switch (phase.kind) {
1281
+ case "idle": return { kind: "connecting" };
1282
+ case "needs-auth": return { kind: "needs-auth" };
1283
+ case "offline": return {
1284
+ kind: "offline",
1285
+ retryAt: phase.nextRetryAt
1286
+ };
1287
+ case "discovering": return hasBeenOnline ? { kind: "offline" } : { kind: "connecting" };
1288
+ case "online": {
1289
+ const channels = options.transports.filter((t) => t.spec.phaseGating && !t.health().up).map((t) => t.spec.id);
1290
+ if (channels.length === 0) return { kind: "online" };
1291
+ return anyChannelEverUp ? {
1292
+ kind: "degraded",
1293
+ channels
1294
+ } : { kind: "connecting" };
1295
+ }
1296
+ }
1297
+ }
1298
+ let current = compute();
1299
+ function clearPending() {
1300
+ if (pendingTimer !== null) {
1301
+ clearTimeout(pendingTimer);
1302
+ pendingTimer = null;
1303
+ pendingSignal = null;
1304
+ }
1305
+ }
1306
+ function apply(next) {
1307
+ clearPending();
1308
+ if (signalEquals(current, next)) return;
1309
+ current = next;
1310
+ for (const listener of [...listeners]) try {
1311
+ listener(next);
1312
+ } catch {}
1313
+ }
1314
+ function recompute() {
1315
+ if (disposed) return;
1316
+ const raw = compute();
1317
+ if (signalEquals(current, raw)) {
1318
+ clearPending();
1319
+ return;
1320
+ }
1321
+ if (raw.kind === "online" || raw.kind === "connecting" || raw.kind === "needs-auth") {
1322
+ apply(raw);
1323
+ return;
1324
+ }
1325
+ if (pendingSignal && pendingSignal.kind === raw.kind) {
1326
+ pendingSignal = raw;
1327
+ return;
1328
+ }
1329
+ clearPending();
1330
+ pendingSignal = raw;
1331
+ pendingTimer = setTimeout(() => {
1332
+ pendingTimer = null;
1333
+ const held = pendingSignal;
1334
+ pendingSignal = null;
1335
+ if (held) apply(compute().kind === held.kind ? compute() : held);
1336
+ }, debounceMs);
1337
+ }
1338
+ cleanups.push(options.kernel.subscribe((next) => {
1339
+ if (next.kind === "online") hasBeenOnline = true;
1340
+ else if (next.kind === "idle") {
1341
+ hasBeenOnline = false;
1342
+ anyChannelEverUp = false;
1343
+ }
1344
+ recompute();
1345
+ }));
1346
+ for (const transport of options.transports) {
1347
+ cleanups.push(transport.on("up", () => {
1348
+ anyChannelEverUp = true;
1349
+ recompute();
1350
+ }));
1351
+ cleanups.push(transport.on("down", () => recompute()));
1352
+ }
1353
+ return {
1354
+ get current() {
1355
+ return current;
1356
+ },
1357
+ raw: () => compute(),
1358
+ recompute,
1359
+ subscribe(listener) {
1360
+ listeners.add(listener);
1361
+ return () => listeners.delete(listener);
1362
+ },
1363
+ dispose() {
1364
+ disposed = true;
1365
+ clearPending();
1366
+ for (const fn of cleanups) fn();
1367
+ listeners.clear();
1368
+ }
1369
+ };
1370
+ }
1371
+ //#endregion
1372
+ export { TransportEmitter, attachBackoff, attachCrossTab, attachDegradedRecovery, attachNetworkChange, attachPersistence, attachPresence, attachProbeLoop, attachTokenLifecycle, attachTransportSync, attachWorkerBridge, classifyClose, createBackgroundProbe, createConnectionJournal, createConnectionSignal, createKernel, isEndpointChange, isProbeFailure, isSameTarget, localStorageAdapter, makeProbeFailure, memoryStorageAdapter, raceFirst, reducer };