@camera.ui/transport 0.0.21 → 0.0.22

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,6 +466,56 @@ 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
+ options.onOffline?.("degraded: endpoint unreachable");
499
+ options.kernel.dispatch({
500
+ type: "PROBE_FAILED_ALL",
501
+ error: "degraded: endpoint unreachable"
502
+ });
503
+ return;
504
+ }
505
+ arm();
506
+ }
507
+ const unsub = options.signal.subscribe((next) => {
508
+ if (next.kind === "degraded") arm();
509
+ else disarm();
510
+ });
511
+ if (options.signal.current.kind === "degraded") arm();
512
+ return () => {
513
+ detached = true;
514
+ disarm();
515
+ unsub();
516
+ };
517
+ }
518
+ //#endregion
378
519
  //#region src/effects/networkChange.ts
379
520
  function attachNetworkChange(options) {
380
521
  let detached = false;
@@ -459,8 +600,15 @@ function attachPersistence(options) {
459
600
  }
460
601
  const unsub = options.kernel.subscribe((next, prev) => {
461
602
  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;
603
+ if (next.kind === "discovering" && next.pendingTokens && (prev.kind !== "discovering" || prev.pendingTokens !== next.pendingTokens)) {
604
+ if (cached) persist({
605
+ endpoint: cached.endpoint,
606
+ tokens: next.pendingTokens
607
+ });
608
+ return;
609
+ }
610
+ const nextTarget = next.kind === "online" ? next.target : null;
611
+ const prevTarget = prev.kind === "online" ? prev.target : null;
464
612
  if (nextTarget && nextTarget !== prevTarget) {
465
613
  persist(nextTarget);
466
614
  return;
@@ -548,95 +696,6 @@ function attachPresence(options) {
548
696
  };
549
697
  }
550
698
  //#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
699
  //#region src/effects/probeLoop.ts
641
700
  function makeProbeFailure(kind, message) {
642
701
  const err = new Error(message);
@@ -712,6 +771,8 @@ function attachProbeLoop(options) {
712
771
  const { endpoint, value: tokens } = await raceFirst(candidates, {
713
772
  timeoutByMode: options.timeoutByMode,
714
773
  parentSignal: ctrl.signal,
774
+ prefer: options.prefer,
775
+ preferGraceMs: options.preferGraceMs,
715
776
  shortCircuit: (err) => isProbeFailure(err) && (err.kind === "needs-auth" || err.kind === "fatal" || err.kind === "aborted")
716
777
  });
717
778
  if (ctrl.signal.aborted) return;
@@ -749,52 +810,13 @@ function attachProbeLoop(options) {
749
810
  };
750
811
  }
751
812
  //#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
813
  //#region src/effects/tokenLifecycle.ts
792
- var DEFAULT_GRACE_MS$1 = 5e3;
814
+ var DEFAULT_GRACE_MS = 5e3;
793
815
  var DEFAULT_MAX_TRANSIENT_RETRIES = 3;
794
816
  var DEFAULT_TRANSIENT_RETRY_DELAY_MS = 2e3;
795
817
  var MAX_TIMER_DELAY_MS = 2 ** 31 - 1;
796
818
  function attachTokenLifecycle(options) {
797
- const graceMs = options.graceMs ?? DEFAULT_GRACE_MS$1;
819
+ const graceMs = options.graceMs ?? DEFAULT_GRACE_MS;
798
820
  const isTransient = options.isTransientError ?? (() => false);
799
821
  const maxTransientRetries = options.maxTransientRetries ?? DEFAULT_MAX_TRANSIENT_RETRIES;
800
822
  const transientRetryDelayMs = options.transientRetryDelayMs ?? DEFAULT_TRANSIENT_RETRY_DELAY_MS;
@@ -835,7 +857,7 @@ function attachTokenLifecycle(options) {
835
857
  return;
836
858
  }
837
859
  const phase = options.kernel.phase;
838
- const target = phase.kind === "online" ? phase.target : phase.kind === "reconnecting" ? phase.lastTarget : null;
860
+ const target = phase.kind === "online" ? phase.target : null;
839
861
  if (!target) {
840
862
  options.onTriggerSkipped?.(reason, "no-target", phase.kind);
841
863
  return;
@@ -856,7 +878,7 @@ function attachTokenLifecycle(options) {
856
878
  };
857
879
  }
858
880
  const livePhase = options.kernel.phase;
859
- const base = (livePhase.kind === "online" ? livePhase.target : livePhase.kind === "reconnecting" ? livePhase.lastTarget : null) ?? target;
881
+ const base = (livePhase.kind === "online" ? livePhase.target : null) ?? target;
860
882
  const refreshTarget = fresh ? {
861
883
  ...base,
862
884
  tokens: fresh
@@ -925,7 +947,7 @@ function attachTokenLifecycle(options) {
925
947
  transientRetries = 0;
926
948
  schedule(next.target);
927
949
  }
928
- } else if (next.kind === "reconnecting") {} else {
950
+ } else {
929
951
  cancelTimer();
930
952
  transientRetries = 0;
931
953
  }
@@ -946,7 +968,7 @@ function attachTokenLifecycle(options) {
946
968
  function wake() {
947
969
  if (detached) return;
948
970
  const phase = options.kernel.phase;
949
- const target = phase.kind === "online" ? phase.target : phase.kind === "reconnecting" ? phase.lastTarget : null;
971
+ const target = phase.kind === "online" ? phase.target : null;
950
972
  if (!target) {
951
973
  options.onWakeChecked?.({
952
974
  decision: "no-target",
@@ -989,29 +1011,58 @@ function stringifyError(err) {
989
1011
  //#endregion
990
1012
  //#region src/effects/transportSync.ts
991
1013
  var SKIP = Symbol("transport-sync-skip");
1014
+ var DEFAULT_RETRY_MS = 2e3;
992
1015
  function syncTargetFor(phase) {
993
1016
  switch (phase.kind) {
994
1017
  case "idle":
995
1018
  case "offline":
996
1019
  case "needs-auth": return null;
997
1020
  case "online": return phase.target;
998
- case "reconnecting": return phase.lastTarget;
999
1021
  case "discovering": return SKIP;
1000
1022
  }
1001
1023
  }
1002
1024
  function attachTransportSync(options) {
1003
1025
  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) => {
1026
+ const retryMs = options.retryMs ?? DEFAULT_RETRY_MS;
1027
+ const applied = /* @__PURE__ */ new Map();
1028
+ const retryTimers = /* @__PURE__ */ new Map();
1029
+ let lastNotified;
1030
+ function applyOne(transport, target) {
1031
+ applied.set(transport, target);
1032
+ transport.apply(target).catch((err) => {
1012
1033
  options.onError?.(transport, target, err);
1034
+ if (applied.get(transport) === target) {
1035
+ applied.delete(transport);
1036
+ scheduleRetry(transport);
1037
+ }
1013
1038
  });
1014
- options.onApplied?.(target);
1039
+ }
1040
+ function scheduleRetry(transport) {
1041
+ if (detached || retryTimers.has(transport)) return;
1042
+ const timer = setTimeout(() => {
1043
+ retryTimers.delete(transport);
1044
+ if (detached) return;
1045
+ const decision = syncTargetFor(options.kernel.phase);
1046
+ if (decision === SKIP) {
1047
+ scheduleRetry(transport);
1048
+ return;
1049
+ }
1050
+ if (applied.has(transport) && isSameTarget(applied.get(transport), decision)) return;
1051
+ options.onRetry?.(transport, decision);
1052
+ applyOne(transport, decision);
1053
+ }, retryMs);
1054
+ retryTimers.set(transport, timer);
1055
+ }
1056
+ function applyAll(target) {
1057
+ if (detached) return;
1058
+ for (const transport of options.transports) {
1059
+ if (applied.has(transport) && isSameTarget(applied.get(transport), target)) continue;
1060
+ applyOne(transport, target);
1061
+ }
1062
+ if (lastNotified === void 0 || !isSameTarget(lastNotified, target)) {
1063
+ lastNotified = target;
1064
+ options.onApplied?.(target);
1065
+ }
1015
1066
  }
1016
1067
  function syncFromPhase(phase) {
1017
1068
  const decision = syncTargetFor(phase);
@@ -1024,76 +1075,12 @@ function attachTransportSync(options) {
1024
1075
  });
1025
1076
  return () => {
1026
1077
  detached = true;
1078
+ for (const timer of retryTimers.values()) clearTimeout(timer);
1079
+ retryTimers.clear();
1027
1080
  unsub();
1028
1081
  };
1029
1082
  }
1030
1083
  //#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
1084
  //#region src/effects/workerBridge.ts
1098
1085
  var log = new Logger("workerBridge");
1099
1086
  function attachWorkerBridge(options) {
@@ -1180,4 +1167,187 @@ function attachWorkerBridge(options) {
1180
1167
  };
1181
1168
  }
1182
1169
  //#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 };
1170
+ //#region src/journal.ts
1171
+ var DEFAULT_CAPACITY = 1e3;
1172
+ var MAX_DETAIL_LENGTH = 300;
1173
+ function stringifyDetail(detail) {
1174
+ if (detail === void 0 || detail === null) return void 0;
1175
+ let text;
1176
+ if (typeof detail === "string") text = detail;
1177
+ else if (detail instanceof Error) text = detail.message;
1178
+ else try {
1179
+ text = JSON.stringify(detail);
1180
+ } catch {
1181
+ text = String(detail);
1182
+ }
1183
+ return text.length > MAX_DETAIL_LENGTH ? `${text.slice(0, MAX_DETAIL_LENGTH)}…` : text;
1184
+ }
1185
+ function formatTime(t) {
1186
+ const d = new Date(t);
1187
+ 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")}`;
1188
+ }
1189
+ function createConnectionJournal(options = {}) {
1190
+ const capacity = options.capacity ?? DEFAULT_CAPACITY;
1191
+ const now = options.now ?? (() => Date.now());
1192
+ const buffer = [];
1193
+ const listeners = /* @__PURE__ */ new Set();
1194
+ let head = 0;
1195
+ let seq = 0;
1196
+ function record(scope, msg, detail) {
1197
+ const entry = {
1198
+ seq: seq++,
1199
+ t: now(),
1200
+ scope,
1201
+ msg,
1202
+ detail: stringifyDetail(detail)
1203
+ };
1204
+ if (buffer.length < capacity) buffer.push(entry);
1205
+ else {
1206
+ buffer[head] = entry;
1207
+ head = (head + 1) % capacity;
1208
+ }
1209
+ for (const listener of [...listeners]) try {
1210
+ listener(entry);
1211
+ } catch {}
1212
+ }
1213
+ function list() {
1214
+ return [...buffer.slice(head), ...buffer.slice(0, head)];
1215
+ }
1216
+ function clear() {
1217
+ buffer.length = 0;
1218
+ head = 0;
1219
+ }
1220
+ function subscribe(listener) {
1221
+ listeners.add(listener);
1222
+ return () => {
1223
+ listeners.delete(listener);
1224
+ };
1225
+ }
1226
+ function exportText() {
1227
+ const entries = list();
1228
+ return [`connection journal — exported ${new Date(now()).toISOString()} (${entries.length} entries)`, ...entries.map((e) => {
1229
+ const detail = e.detail ? ` — ${e.detail}` : "";
1230
+ return `${formatTime(e.t)} [${e.scope}] ${e.msg}${detail}`;
1231
+ })].join("\n");
1232
+ }
1233
+ return {
1234
+ record,
1235
+ list,
1236
+ clear,
1237
+ subscribe,
1238
+ exportText
1239
+ };
1240
+ }
1241
+ //#endregion
1242
+ //#region src/signal.ts
1243
+ var DEFAULT_DEBOUNCE_MS = 1500;
1244
+ function signalEquals(a, b) {
1245
+ if (a.kind !== b.kind) return false;
1246
+ if (a.kind === "degraded" && b.kind === "degraded") return a.channels.join(",") === b.channels.join(",");
1247
+ if (a.kind === "offline" && b.kind === "offline") return a.retryAt === b.retryAt;
1248
+ return true;
1249
+ }
1250
+ function createConnectionSignal(options) {
1251
+ const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
1252
+ const listeners = /* @__PURE__ */ new Set();
1253
+ const cleanups = [];
1254
+ let hasBeenOnline = false;
1255
+ let anyChannelEverUp = false;
1256
+ let pendingTimer = null;
1257
+ let pendingSignal = null;
1258
+ let disposed = false;
1259
+ function compute() {
1260
+ const phase = options.kernel.phase;
1261
+ switch (phase.kind) {
1262
+ case "idle": return { kind: "connecting" };
1263
+ case "needs-auth": return { kind: "needs-auth" };
1264
+ case "offline": return {
1265
+ kind: "offline",
1266
+ retryAt: phase.nextRetryAt
1267
+ };
1268
+ case "discovering": return hasBeenOnline ? { kind: "offline" } : { kind: "connecting" };
1269
+ case "online": {
1270
+ const channels = options.transports.filter((t) => t.spec.phaseGating && !t.health().up).map((t) => t.spec.id);
1271
+ if (channels.length === 0) return { kind: "online" };
1272
+ return anyChannelEverUp ? {
1273
+ kind: "degraded",
1274
+ channels
1275
+ } : { kind: "connecting" };
1276
+ }
1277
+ }
1278
+ }
1279
+ let current = compute();
1280
+ function clearPending() {
1281
+ if (pendingTimer !== null) {
1282
+ clearTimeout(pendingTimer);
1283
+ pendingTimer = null;
1284
+ pendingSignal = null;
1285
+ }
1286
+ }
1287
+ function apply(next) {
1288
+ clearPending();
1289
+ if (signalEquals(current, next)) return;
1290
+ current = next;
1291
+ for (const listener of [...listeners]) try {
1292
+ listener(next);
1293
+ } catch {}
1294
+ }
1295
+ function recompute() {
1296
+ if (disposed) return;
1297
+ const raw = compute();
1298
+ if (signalEquals(current, raw)) {
1299
+ clearPending();
1300
+ return;
1301
+ }
1302
+ if (raw.kind === "online" || raw.kind === "connecting" || raw.kind === "needs-auth") {
1303
+ apply(raw);
1304
+ return;
1305
+ }
1306
+ if (pendingSignal && pendingSignal.kind === raw.kind) {
1307
+ pendingSignal = raw;
1308
+ return;
1309
+ }
1310
+ clearPending();
1311
+ pendingSignal = raw;
1312
+ pendingTimer = setTimeout(() => {
1313
+ pendingTimer = null;
1314
+ const held = pendingSignal;
1315
+ pendingSignal = null;
1316
+ if (held) apply(compute().kind === held.kind ? compute() : held);
1317
+ }, debounceMs);
1318
+ }
1319
+ cleanups.push(options.kernel.subscribe((next) => {
1320
+ if (next.kind === "online") hasBeenOnline = true;
1321
+ else if (next.kind === "idle") {
1322
+ hasBeenOnline = false;
1323
+ anyChannelEverUp = false;
1324
+ }
1325
+ recompute();
1326
+ }));
1327
+ for (const transport of options.transports) {
1328
+ cleanups.push(transport.on("up", () => {
1329
+ anyChannelEverUp = true;
1330
+ recompute();
1331
+ }));
1332
+ cleanups.push(transport.on("down", () => recompute()));
1333
+ }
1334
+ return {
1335
+ get current() {
1336
+ return current;
1337
+ },
1338
+ raw: () => compute(),
1339
+ recompute,
1340
+ subscribe(listener) {
1341
+ listeners.add(listener);
1342
+ return () => listeners.delete(listener);
1343
+ },
1344
+ dispose() {
1345
+ disposed = true;
1346
+ clearPending();
1347
+ for (const fn of cleanups) fn();
1348
+ listeners.clear();
1349
+ }
1350
+ };
1351
+ }
1352
+ //#endregion
1353
+ 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 };