@camera.ui/transport 0.0.30 → 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.
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
+ import { n as refusedByServer, t as classifyClose } from "./closeCodes-CltSRjdJ.js";
1
2
  import { n as isEndpointChange, r as isSameTarget, t as TransportEmitter } from "./contract-dt1m8U-o.js";
2
- import { t as classifyClose } from "./closeCodes-ClWJVOpq.js";
3
3
  import { Logger } from "@camera.ui/logger";
4
4
  //#region src/core/reducer.ts
5
5
  var DEFAULT_BACKOFF_MS = 5e3;
@@ -8,15 +8,15 @@ function reducer(phase, action, ctx) {
8
8
  case "RESET": return phase.kind === "idle" ? phase : { kind: "idle" };
9
9
  case "BOOT":
10
10
  if (phase.kind !== "idle" && phase.kind !== "offline" && phase.kind !== "needs-auth") return phase;
11
- return {
11
+ return withPending({
12
12
  kind: "discovering",
13
13
  instanceId: action.instanceId
14
- };
14
+ }, phase);
15
15
  case "USER_RETRY":
16
- if (phase.kind === "offline" || phase.kind === "needs-auth") return {
16
+ if (phase.kind === "offline" || phase.kind === "needs-auth") return withPending({
17
17
  kind: "discovering",
18
18
  instanceId: phase.instanceId ?? ""
19
- };
19
+ }, phase);
20
20
  return phase;
21
21
  case "PROBE_SUCCEEDED":
22
22
  if (phase.kind !== "discovering") return phase;
@@ -52,7 +52,7 @@ function reducer(phase, action, ctx) {
52
52
  }
53
53
  };
54
54
  }
55
- if (phase.kind === "discovering") {
55
+ if (phase.kind === "discovering" || phase.kind === "offline") {
56
56
  if (phase.pendingTokens && tokensEqual(phase.pendingTokens, action.tokens)) return phase;
57
57
  return {
58
58
  ...phase,
@@ -96,7 +96,7 @@ function reducer(phase, action, ctx) {
96
96
  if (phase.kind !== "offline") return phase;
97
97
  const now = ctx.now();
98
98
  const candidate = now + action.retryAfterMs;
99
- if (candidate <= phase.nextRetryAt) return phase;
99
+ if (phase.backoffHint && candidate <= phase.nextRetryAt) return phase;
100
100
  return {
101
101
  ...phase,
102
102
  nextRetryAt: candidate,
@@ -109,6 +109,12 @@ function reducer(phase, action, ctx) {
109
109
  }
110
110
  }
111
111
  }
112
+ function withPending(next, prev) {
113
+ return prev.kind === "offline" && prev.pendingTokens ? {
114
+ ...next,
115
+ pendingTokens: prev.pendingTokens
116
+ } : next;
117
+ }
112
118
  function tokensEqual(a, b) {
113
119
  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;
114
120
  }
@@ -311,8 +317,12 @@ function createBackgroundProbe(options) {
311
317
  options.onResult?.("failed", "empty pool");
312
318
  return "failed";
313
319
  }
320
+ if (options.ready) {
321
+ await options.ready().catch(() => void 0);
322
+ if (disposed || options.kernel.phase.kind !== "online") return "skipped";
323
+ }
314
324
  const lastTokens = options.lastTarget?.()?.tokens;
315
- const { endpoint, value: tokens } = await raceFirst(pool.map((endpoint) => ({
325
+ const { endpoint, value } = await raceFirst(pool.map((endpoint) => ({
316
326
  endpoint,
317
327
  run: (signal) => options.probe({
318
328
  endpoint,
@@ -333,10 +343,17 @@ function createBackgroundProbe(options) {
333
343
  }
334
344
  const current = phase.target.endpoint;
335
345
  const same = current.url === endpoint.url && current.mode === endpoint.mode;
336
- if (!same && options.prefer && options.prefer(current) && !options.prefer(endpoint) && options.isCurrentHealthy?.() === true) {
337
- options.onResult?.("kept", current.url);
346
+ const preferredWinner = options.prefer?.(endpoint) ?? false;
347
+ const preferredCurrent = options.prefer?.(current) ?? false;
348
+ if (!same && options.isCurrentHealthy?.() === true && !(preferredWinner && !preferredCurrent)) {
349
+ if (value !== lastTokens) options.kernel.dispatch({
350
+ type: "TOKENS_REFRESHED",
351
+ tokens: value
352
+ });
353
+ options.onResult?.("kept", preferredWinner === preferredCurrent ? `${current.url} (same class as ${endpoint.url})` : current.url);
338
354
  return "kept";
339
355
  }
356
+ const tokens = value === lastTokens ? phase.target.tokens : value;
340
357
  options.kernel.dispatch({
341
358
  type: "ENDPOINT_SWAP",
342
359
  endpoint,
@@ -379,8 +396,10 @@ function attachBackoff(options) {
379
396
  const setTimer = options.setTimer ?? ((cb, ms) => setTimeout(cb, ms));
380
397
  const clearTimer = options.clearTimer ?? ((h) => clearTimeout(h));
381
398
  let timer;
399
+ let firesAt = 0;
382
400
  let attempt = 0;
383
401
  let detached = false;
402
+ const ownRetry = { type: "USER_RETRY" };
384
403
  function cancelTimer(reason) {
385
404
  if (timer !== void 0) {
386
405
  clearTimer(timer);
@@ -388,34 +407,42 @@ function attachBackoff(options) {
388
407
  if (reason) options.onCancelled?.(reason);
389
408
  }
390
409
  }
410
+ function fastDelay() {
411
+ const quick = options.firstAttemptDelayMs?.();
412
+ return typeof quick === "number" && quick >= 0 ? quick : void 0;
413
+ }
391
414
  function scheduleFromPhase(phase, isReschedule) {
392
415
  cancelTimer(isReschedule ? "rescheduled" : void 0);
393
416
  const baseDelay = schedule[Math.min(attempt, schedule.length - 1)];
394
- const quick = attempt === 0 ? options.firstAttemptDelayMs?.() : void 0;
395
- const scheduleDelay = typeof quick === "number" && quick >= 0 ? Math.min(quick, baseDelay) : baseDelay;
396
- const phaseDelay = Math.max(0, phase.nextRetryAt - now());
417
+ const quick = attempt === 0 ? fastDelay() : void 0;
418
+ const scheduleDelay = quick === void 0 ? baseDelay : Math.min(quick, baseDelay);
419
+ const phaseDelay = phase.backoffHint ? Math.max(0, phase.nextRetryAt - now()) : 0;
397
420
  const delay = Math.max(scheduleDelay, phaseDelay);
398
421
  options.onScheduled?.(attempt + 1, delay);
399
422
  if (phase.backoffHint && phaseDelay > scheduleDelay) options.onHintApplied?.(delay, phase.backoffHint.source);
423
+ firesAt = now() + delay;
400
424
  timer = setTimer(() => {
401
425
  timer = void 0;
402
426
  if (detached) return;
403
427
  if (options.kernel.phase.kind !== "offline") return;
404
428
  attempt += 1;
405
429
  options.onFire?.(attempt);
406
- options.kernel.dispatch({ type: "USER_RETRY" });
430
+ options.kernel.dispatch(ownRetry);
407
431
  }, delay);
408
432
  }
409
- const unsubKernel = options.kernel.subscribe((next, prev) => {
433
+ const unsubKernel = options.kernel.subscribe((next, prev, action) => {
410
434
  if (next.kind === "offline" && prev.kind !== "offline") {
411
435
  scheduleFromPhase(next, false);
412
436
  return;
413
437
  }
414
- if (next.kind === "offline" && prev.kind === "offline" && next.nextRetryAt !== prev.nextRetryAt) {
415
- scheduleFromPhase(next, true);
438
+ if (next.kind === "offline" && prev.kind === "offline" && next.backoffHint !== prev.backoffHint) {
439
+ if (next.backoffHint && next.nextRetryAt > firesAt) scheduleFromPhase(next, true);
416
440
  return;
417
441
  }
418
- if (prev.kind === "offline" && next.kind !== "offline") cancelTimer("phase-left-offline");
442
+ if (prev.kind === "offline" && next.kind !== "offline") {
443
+ cancelTimer("phase-left-offline");
444
+ if (action.type === "USER_RETRY" && action !== ownRetry && fastDelay() !== void 0) attempt = 0;
445
+ }
419
446
  if (next.kind === "online" || next.kind === "idle") attempt = 0;
420
447
  });
421
448
  if (options.kernel.phase.kind === "offline") scheduleFromPhase(options.kernel.phase, false);
@@ -450,7 +477,16 @@ function attachCrossTab(options) {
450
477
  return;
451
478
  }
452
479
  if (!parsed?.tokens?.access) return;
453
- if (options.kernel.phase.kind !== "online") {
480
+ const payload = {
481
+ endpoint: parsed.endpoint,
482
+ tokens: parsed.tokens
483
+ };
484
+ const phase = options.kernel.phase;
485
+ if (options.accept && !options.accept(payload, phase.kind === "online" ? phase.target : null)) {
486
+ options.onTokensIgnored?.(payload);
487
+ return;
488
+ }
489
+ if (phase.kind !== "online") {
454
490
  if (parsed.endpoint?.url) {
455
491
  options.absorb?.({
456
492
  endpoint: parsed.endpoint,
@@ -474,24 +510,37 @@ function attachCrossTab(options) {
474
510
  //#endregion
475
511
  //#region src/effects/degradedRecovery.ts
476
512
  var DEFAULT_GRACE_MS$1 = 1e4;
513
+ var DEFAULT_MAX_BACKOFF_MS = 3e5;
477
514
  function attachDegradedRecovery(options) {
478
515
  const graceMs = options.graceMs ?? DEFAULT_GRACE_MS$1;
516
+ const maxBackoffMs = options.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
479
517
  let timer = null;
480
518
  let round = 0;
519
+ let backoff = 0;
520
+ let channels = "";
481
521
  let detached = false;
482
- function disarm() {
522
+ function clearTimer() {
483
523
  if (timer !== null) {
484
524
  clearTimeout(timer);
485
525
  timer = null;
486
526
  }
527
+ }
528
+ function disarm() {
529
+ clearTimer();
487
530
  round = 0;
531
+ backoff = 0;
532
+ channels = "";
488
533
  }
489
534
  function arm() {
490
535
  if (detached || timer !== null) return;
491
536
  timer = setTimeout(() => {
492
537
  timer = null;
493
538
  escalate();
494
- }, graceMs);
539
+ }, Math.min(graceMs * 2 ** backoff, maxBackoffMs));
540
+ }
541
+ function rearm() {
542
+ backoff = options.isReachable?.() ? backoff + 1 : 0;
543
+ arm();
495
544
  }
496
545
  async function escalate() {
497
546
  if (detached || options.signal.current.kind !== "degraded") return;
@@ -503,7 +552,7 @@ function attachDegradedRecovery(options) {
503
552
  if (outcome === "failed") {
504
553
  if (options.isReachable?.()) {
505
554
  options.onHold?.("probe failed but a channel is up — holding");
506
- arm();
555
+ rearm();
507
556
  return;
508
557
  }
509
558
  options.onOffline?.("degraded: endpoint unreachable");
@@ -513,13 +562,31 @@ function attachDegradedRecovery(options) {
513
562
  });
514
563
  return;
515
564
  }
516
- arm();
565
+ rearm();
566
+ }
567
+ function lostWanTarget() {
568
+ const phase = options.kernel.phase;
569
+ return phase.kind === "online" && phase.target.endpoint.mode === "direct-wan" && options.isReachable?.() === false;
570
+ }
571
+ function onDegraded(signal) {
572
+ const next = signal.channels.join(",");
573
+ if (next !== channels) {
574
+ channels = next;
575
+ if (backoff > 0) {
576
+ backoff = 0;
577
+ clearTimer();
578
+ }
579
+ }
580
+ if (round === 0 && lostWanTarget()) {
581
+ disarm();
582
+ escalate();
583
+ } else arm();
517
584
  }
518
585
  const unsub = options.signal.subscribe((next) => {
519
- if (next.kind === "degraded") arm();
586
+ if (next.kind === "degraded") onDegraded(next);
520
587
  else disarm();
521
588
  });
522
- if (options.signal.current.kind === "degraded") arm();
589
+ if (options.signal.current.kind === "degraded") onDegraded(options.signal.current);
523
590
  return () => {
524
591
  detached = true;
525
592
  disarm();
@@ -557,19 +624,30 @@ function attachNetworkChange(options) {
557
624
  //#endregion
558
625
  //#region src/effects/persistence.ts
559
626
  var DEFAULT_KEY = "camera.ui:transport:target";
627
+ var DEFAULT_READY_TIMEOUT_MS = 3e3;
560
628
  function attachPersistence(options) {
561
629
  const key = options.key ?? DEFAULT_KEY;
562
630
  const onError = options.onError ?? (() => {});
631
+ const readyTimeoutMs = options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
563
632
  let detached = false;
564
633
  let cached = null;
565
- restore();
634
+ let readyTimer;
635
+ let settleReady;
636
+ const ready = new Promise((resolve) => {
637
+ settleReady = () => {
638
+ clearTimeout(readyTimer);
639
+ resolve();
640
+ };
641
+ });
642
+ if (readyTimeoutMs > 0 && Number.isFinite(readyTimeoutMs)) readyTimer = setTimeout(settleReady, readyTimeoutMs);
643
+ restore().then(settleReady, settleReady);
566
644
  async function restore() {
567
- let raw;
568
- try {
645
+ let raw = null;
646
+ for (let attempt = 0; attempt < 2; attempt++) try {
569
647
  raw = await options.storage.get(key);
648
+ break;
570
649
  } catch (err) {
571
650
  onError("get", err);
572
- raw = null;
573
651
  }
574
652
  if (detached) return;
575
653
  if (cached !== null) {
@@ -625,10 +703,11 @@ function attachPersistence(options) {
625
703
  }
626
704
  const unsub = options.kernel.subscribe((next, prev) => {
627
705
  if (detached) return;
628
- if (next.kind === "discovering" && next.pendingTokens && (prev.kind !== "discovering" || prev.pendingTokens !== next.pendingTokens)) {
706
+ const pending = pendingTokensOf(next);
707
+ if (pending && pending !== pendingTokensOf(prev)) {
629
708
  if (cached) persist({
630
709
  endpoint: cached.endpoint,
631
- tokens: next.pendingTokens
710
+ tokens: pending
632
711
  });
633
712
  return;
634
713
  }
@@ -643,8 +722,10 @@ function attachPersistence(options) {
643
722
  return {
644
723
  detach: () => {
645
724
  detached = true;
725
+ settleReady();
646
726
  unsub();
647
727
  },
728
+ ready,
648
729
  peek: () => cached,
649
730
  seed: async (target) => {
650
731
  if (detached) return;
@@ -656,8 +737,14 @@ function attachPersistence(options) {
656
737
  }
657
738
  };
658
739
  }
659
- function localStorageAdapter(scope = globalThis.localStorage) {
660
- if (!scope) throw new Error("localStorageAdapter: localStorage is not available in this environment");
740
+ function pendingTokensOf(phase) {
741
+ return phase.kind === "discovering" || phase.kind === "offline" ? phase.pendingTokens : void 0;
742
+ }
743
+ function localStorageAdapter(scope) {
744
+ if (!scope) try {
745
+ scope = globalThis.localStorage;
746
+ } catch {}
747
+ if (!scope) return memoryStorageAdapter();
661
748
  return {
662
749
  get(k) {
663
750
  return scope.getItem(k);
@@ -730,10 +817,18 @@ function makeProbeFailure(kind, message) {
730
817
  function isProbeFailure(err) {
731
818
  return err instanceof Error && typeof err.kind === "string";
732
819
  }
820
+ var CANCELED_RERUN_MS = 200;
821
+ var MAX_CANCELED_ROUNDS = 3;
733
822
  function attachProbeLoop(options) {
734
823
  let masterAbort;
735
824
  let detached = false;
825
+ let canceledRounds = 0;
826
+ let rerunTimer;
736
827
  function cancel() {
828
+ if (rerunTimer !== void 0) {
829
+ clearTimeout(rerunTimer);
830
+ rerunTimer = void 0;
831
+ }
737
832
  if (masterAbort) {
738
833
  masterAbort.abort();
739
834
  masterAbort = void 0;
@@ -744,6 +839,8 @@ function attachProbeLoop(options) {
744
839
  cancel();
745
840
  const ctrl = new AbortController();
746
841
  masterAbort = ctrl;
842
+ const startPhase = options.kernel.phase;
843
+ const pendingAtStart = startPhase.kind === "discovering" ? startPhase.pendingTokens : void 0;
747
844
  let pool;
748
845
  try {
749
846
  options.onDiscoverStart?.();
@@ -770,6 +867,10 @@ function attachProbeLoop(options) {
770
867
  });
771
868
  return;
772
869
  }
870
+ if (options.ready) {
871
+ await options.ready().catch(() => void 0);
872
+ if (ctrl.signal.aborted) return;
873
+ }
773
874
  const lastTokens = options.lastTarget?.()?.tokens;
774
875
  const candidates = pool.map((endpoint) => ({
775
876
  endpoint,
@@ -795,7 +896,7 @@ function attachProbeLoop(options) {
795
896
  }
796
897
  }));
797
898
  try {
798
- const { endpoint, value: tokens } = await raceFirst(candidates, {
899
+ const { endpoint, value } = await raceFirst(candidates, {
799
900
  timeoutByMode: options.timeoutByMode,
800
901
  parentSignal: ctrl.signal,
801
902
  prefer: options.prefer,
@@ -804,6 +905,9 @@ function attachProbeLoop(options) {
804
905
  informative: (cause) => !(isProbeFailure(cause) && cause.kind === "aborted")
805
906
  });
806
907
  if (ctrl.signal.aborted) return;
908
+ const phase = options.kernel.phase;
909
+ const refreshed = phase.kind === "discovering" && phase.pendingTokens !== pendingAtStart ? phase.pendingTokens : void 0;
910
+ const tokens = value === lastTokens && refreshed ? refreshed : value;
807
911
  options.kernel.dispatch({
808
912
  type: "PROBE_SUCCEEDED",
809
913
  endpoint,
@@ -812,13 +916,15 @@ function attachProbeLoop(options) {
812
916
  } catch (err) {
813
917
  if (ctrl.signal.aborted) return;
814
918
  const underlying = err instanceof RaceFirstError ? err.cause : err;
815
- if (isProbeFailure(underlying) && underlying.kind === "aborted") {
816
- setTimeout(() => {
919
+ const allCanceled = isProbeFailure(underlying) && underlying.kind === "aborted";
920
+ if (allCanceled && ++canceledRounds < MAX_CANCELED_ROUNDS) {
921
+ rerunTimer = setTimeout(() => {
922
+ rerunTimer = void 0;
817
923
  if (!detached && options.kernel.phase.kind === "discovering") runRound();
818
- }, 200);
924
+ }, CANCELED_RERUN_MS);
819
925
  return;
820
926
  }
821
- const reason = isProbeFailure(underlying) && underlying.kind === "needs-auth" ? "needs-auth" : underlying instanceof Error ? underlying.message : "all endpoints failed";
927
+ const reason = allCanceled ? `${underlying.message} (${canceledRounds} rounds, certificate blocked?)` : isProbeFailure(underlying) && underlying.kind === "needs-auth" ? "needs-auth" : underlying instanceof Error ? underlying.message : "all endpoints failed";
822
928
  options.onAllFailed?.(reason);
823
929
  options.kernel.dispatch({
824
930
  type: "PROBE_FAILED_ALL",
@@ -827,6 +933,7 @@ function attachProbeLoop(options) {
827
933
  }
828
934
  }
829
935
  const unsub = options.kernel.subscribe((next, prev) => {
936
+ if (next.kind !== prev.kind) canceledRounds = 0;
830
937
  if (next.kind === "discovering" && prev.kind !== "discovering") runRound();
831
938
  else if (next.kind !== "discovering" && prev.kind === "discovering") cancel();
832
939
  });
@@ -842,22 +949,36 @@ function attachProbeLoop(options) {
842
949
  var DEFAULT_GRACE_MS = 5e3;
843
950
  var DEFAULT_MAX_TRANSIENT_RETRIES = 3;
844
951
  var DEFAULT_TRANSIENT_RETRY_DELAY_MS = 2e3;
952
+ var DEFAULT_MIN_REFRESH_INTERVAL_MS = 3e4;
953
+ var DEFAULT_REACHABLE_RETRY_DELAYS_MS = [1e4, 3e4];
954
+ var DEFAULT_LOCK_TIMEOUT_MS = 15e3;
955
+ var AUTH_ERROR_RETRY_GAP_MS = 5e3;
845
956
  var MAX_TIMER_DELAY_MS = 2 ** 31 - 1;
846
957
  function attachTokenLifecycle(options) {
847
958
  const graceMs = options.graceMs ?? DEFAULT_GRACE_MS;
848
959
  const maxTransientRetries = options.maxTransientRetries ?? DEFAULT_MAX_TRANSIENT_RETRIES;
849
960
  const maxServerErrorRetries = options.maxServerErrorRetries ?? maxTransientRetries;
850
961
  const transientRetryDelayMs = options.transientRetryDelayMs ?? DEFAULT_TRANSIENT_RETRY_DELAY_MS;
962
+ const minRefreshIntervalMs = options.minRefreshIntervalMs ?? DEFAULT_MIN_REFRESH_INTERVAL_MS;
963
+ const reachableRetryDelaysMs = options.reachableRetryDelaysMs?.length ? options.reachableRetryDelaysMs : DEFAULT_REACHABLE_RETRY_DELAYS_MS;
964
+ const lockTimeoutMs = options.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
965
+ const lockWaitBounded = Number.isFinite(lockTimeoutMs) && lockTimeoutMs > 0;
851
966
  const isTransient = options.isTransientError ?? (() => false);
852
967
  const isServerError = options.isServerError ?? (() => false);
853
968
  const now = options.now ?? (() => Date.now());
854
969
  const setTimer = options.setTimer ?? ((cb, ms) => setTimeout(cb, ms));
855
970
  const clearTimer = options.clearTimer ?? ((h) => clearTimeout(h));
856
971
  let timer;
857
- let inflight = false;
972
+ let inflight = null;
858
973
  let detached = false;
859
974
  let transientRetries = 0;
975
+ let slowRetries = 0;
976
+ let lastSlowFailureAt = 0;
860
977
  let pendingAuthError = false;
978
+ let refreshFloorUntil;
979
+ let rotatedAccess;
980
+ let rejectedAccess;
981
+ let stopLockWait;
861
982
  const cleanups = [];
862
983
  function cancelTimer() {
863
984
  if (timer !== void 0) {
@@ -865,11 +986,66 @@ function attachTokenLifecycle(options) {
865
986
  timer = void 0;
866
987
  }
867
988
  }
989
+ function retryLater(reason, delayMs) {
990
+ cancelTimer();
991
+ timer = setTimer(() => {
992
+ timer = void 0;
993
+ triggerRefresh(reason);
994
+ }, delayMs);
995
+ }
996
+ function insideRefreshFloor() {
997
+ return refreshFloorUntil !== void 0 && now() < refreshFloorUntil;
998
+ }
999
+ function refreshFloorFor(tokens) {
1000
+ const lifetimeMs = tokens.accessExpiresAt ? tokens.accessExpiresAt - now() : void 0;
1001
+ if (lifetimeMs === void 0 || lifetimeMs <= graceMs) return minRefreshIntervalMs;
1002
+ return Math.min(minRefreshIntervalMs, Math.max(graceMs, lifetimeMs - graceMs));
1003
+ }
1004
+ function needsNoRefresh(tokens) {
1005
+ if (tokens.access === rotatedAccess && insideRefreshFloor()) return true;
1006
+ if (tokens.access === rejectedAccess && tokens.refresh) return false;
1007
+ return !!tokens.accessExpiresAt && tokens.accessExpiresAt > now() + graceMs;
1008
+ }
1009
+ function isCurrentAccess(access) {
1010
+ const phase = options.kernel.phase;
1011
+ return phase.kind === "online" && phase.target.tokens.access === access || options.getLatestTokens?.()?.access === access;
1012
+ }
1013
+ function underRefreshLock(fn) {
1014
+ const acquire = options.acquireRefreshLock;
1015
+ if (!acquire) return fn();
1016
+ if (!lockWaitBounded) return acquire(fn);
1017
+ return new Promise((resolve, reject) => {
1018
+ const abort = new AbortController();
1019
+ let waiting = true;
1020
+ function stopWaiting() {
1021
+ waiting = false;
1022
+ clearTimer(handle);
1023
+ if (stopLockWait === giveUp) stopLockWait = void 0;
1024
+ }
1025
+ function giveUp() {
1026
+ if (!waiting) return;
1027
+ stopWaiting();
1028
+ abort.abort();
1029
+ resolve(null);
1030
+ }
1031
+ const handle = setTimer(giveUp, Math.min(lockTimeoutMs, MAX_TIMER_DELAY_MS));
1032
+ stopLockWait = giveUp;
1033
+ acquire(() => {
1034
+ if (!waiting) return Promise.resolve(null);
1035
+ stopWaiting();
1036
+ return fn();
1037
+ }, abort.signal).then(resolve, (err) => {
1038
+ stopWaiting();
1039
+ reject(err);
1040
+ });
1041
+ });
1042
+ }
868
1043
  function schedule(target) {
869
1044
  cancelTimer();
870
1045
  const exp = target.tokens.accessExpiresAt;
871
1046
  if (!exp) return;
872
- const delayMs = Math.min(Math.max(0, exp - now() - graceMs), MAX_TIMER_DELAY_MS);
1047
+ const floorAt = refreshFloorUntil ?? 0;
1048
+ const delayMs = Math.min(Math.max(0, exp - now() - graceMs, floorAt - now()), MAX_TIMER_DELAY_MS);
873
1049
  options.onScheduled?.(delayMs, exp);
874
1050
  timer = setTimer(() => {
875
1051
  timer = void 0;
@@ -879,25 +1055,28 @@ function attachTokenLifecycle(options) {
879
1055
  async function triggerRefresh(reason) {
880
1056
  if (detached) {
881
1057
  options.onTriggerSkipped?.(reason, "detached", options.kernel.phase.kind);
882
- return;
1058
+ return false;
883
1059
  }
884
1060
  if (inflight) {
885
1061
  if (reason === "auth-error") pendingAuthError = true;
886
1062
  options.onTriggerSkipped?.(reason, "already-inflight", options.kernel.phase.kind);
887
- return;
1063
+ return inflight;
888
1064
  }
889
1065
  const phase = options.kernel.phase;
890
1066
  const target = phase.kind === "online" ? phase.target : null;
891
1067
  if (!target) {
892
1068
  options.onTriggerSkipped?.(reason, "no-target", phase.kind);
893
- return;
1069
+ return false;
894
1070
  }
895
- inflight = true;
1071
+ let settle;
1072
+ let tokenComing = false;
1073
+ let lockTimedOut = false;
1074
+ inflight = new Promise((resolve) => settle = resolve);
896
1075
  options.onRefreshStart?.(reason);
897
1076
  try {
898
- const result = await (options.acquireRefreshLock ?? ((fn) => fn()))(async () => {
1077
+ let result = await underRefreshLock(async () => {
899
1078
  const fresh = options.getLatestTokens?.();
900
- if (fresh?.accessExpiresAt && fresh.accessExpiresAt > now() + graceMs) {
1079
+ if (fresh && needsNoRefresh(fresh)) {
901
1080
  options.kernel.dispatch({
902
1081
  type: "TOKENS_REFRESHED",
903
1082
  tokens: fresh
@@ -927,16 +1106,34 @@ function attachTokenLifecycle(options) {
927
1106
  skipped: false
928
1107
  };
929
1108
  });
930
- if (detached) return;
1109
+ if (detached) return false;
1110
+ if (!result) {
1111
+ const fresh = options.getLatestTokens?.();
1112
+ if (!fresh || !needsNoRefresh(fresh)) {
1113
+ lockTimedOut = true;
1114
+ throw new Error(`refresh lock not granted within ${lockTimeoutMs} ms`);
1115
+ }
1116
+ options.kernel.dispatch({
1117
+ type: "TOKENS_REFRESHED",
1118
+ tokens: fresh
1119
+ });
1120
+ result = {
1121
+ tokens: fresh,
1122
+ skipped: true
1123
+ };
1124
+ }
931
1125
  transientRetries = 0;
1126
+ slowRetries = 0;
1127
+ const live = options.kernel.phase;
1128
+ tokenComing = live.kind === "online" && live.target.tokens.access !== target.tokens.access;
932
1129
  if (result.skipped) {
933
1130
  options.onRefreshSkipped?.(reason, result.tokens);
934
1131
  const p = options.kernel.phase;
935
1132
  if (p.kind === "online" && timer === void 0) schedule(p.target);
936
1133
  } else options.onRefreshSuccess?.(reason, result.tokens);
937
1134
  } catch (err) {
938
- if (detached) return;
939
- const transient = isTransient(err);
1135
+ if (detached) return false;
1136
+ const transient = lockTimedOut || isTransient(err);
940
1137
  const serverError = transient && isServerError(err);
941
1138
  const budget = serverError ? maxServerErrorRetries : maxTransientRetries;
942
1139
  if (transient && transientRetries < budget) {
@@ -948,13 +1145,22 @@ function attachTokenLifecycle(options) {
948
1145
  retriesLeft,
949
1146
  willRetry: true
950
1147
  });
951
- cancelTimer();
952
- timer = setTimer(() => {
953
- timer = void 0;
954
- triggerRefresh(reason);
955
- }, transientRetryDelayMs);
1148
+ tokenComing = true;
1149
+ retryLater(reason, transientRetryDelayMs);
1150
+ } else if (transient && options.isReachable?.() === true) {
1151
+ const delayMs = reachableRetryDelaysMs[Math.min(slowRetries, reachableRetryDelaysMs.length - 1)];
1152
+ slowRetries++;
1153
+ lastSlowFailureAt = now();
1154
+ options.onRefreshError?.(reason, err, {
1155
+ transient: true,
1156
+ serverError,
1157
+ retriesLeft: 0,
1158
+ willRetry: true
1159
+ });
1160
+ retryLater(reason, delayMs);
956
1161
  } else {
957
1162
  transientRetries = 0;
1163
+ slowRetries = 0;
958
1164
  options.onRefreshError?.(reason, err, {
959
1165
  transient,
960
1166
  serverError,
@@ -968,28 +1174,45 @@ function attachTokenLifecycle(options) {
968
1174
  });
969
1175
  }
970
1176
  } finally {
971
- inflight = false;
1177
+ inflight = null;
1178
+ settle(tokenComing);
972
1179
  if (!detached && pendingAuthError) {
973
1180
  pendingAuthError = false;
974
- triggerRefresh("auth-error");
1181
+ triggerOnAuthError();
975
1182
  }
976
1183
  }
1184
+ return tokenComing;
1185
+ }
1186
+ function triggerOnAuthError() {
1187
+ if (slowRetries > 0 && now() - lastSlowFailureAt < AUTH_ERROR_RETRY_GAP_MS) {
1188
+ options.onTriggerSkipped?.("auth-error", "throttled", options.kernel.phase.kind);
1189
+ return Promise.resolve(false);
1190
+ }
1191
+ return triggerRefresh("auth-error");
977
1192
  }
978
1193
  const unsubKernel = options.kernel.subscribe((next, prev) => {
979
1194
  if (next.kind === "online") {
980
1195
  if (prev.kind !== "online" || prev.target.tokens.access !== next.target.tokens.access) {
1196
+ if (prev.kind === "online") {
1197
+ refreshFloorUntil = now() + refreshFloorFor(next.target.tokens);
1198
+ rotatedAccess = next.target.tokens.access;
1199
+ }
981
1200
  transientRetries = 0;
1201
+ slowRetries = 0;
982
1202
  schedule(next.target);
983
1203
  }
984
1204
  } else {
985
1205
  cancelTimer();
986
1206
  transientRetries = 0;
1207
+ slowRetries = 0;
987
1208
  }
988
1209
  });
989
1210
  cleanups.push(unsubKernel);
990
1211
  for (const transport of options.transports) {
991
- const off = transport.on("auth-error", () => {
992
- triggerRefresh("auth-error");
1212
+ const off = transport.on("auth-error", (event) => {
1213
+ if (event.access && isCurrentAccess(event.access)) rejectedAccess = event.access;
1214
+ const refresh = triggerOnAuthError();
1215
+ event.holdFor?.(refresh);
993
1216
  });
994
1217
  cleanups.push(off);
995
1218
  }
@@ -997,6 +1220,7 @@ function attachTokenLifecycle(options) {
997
1220
  function detach() {
998
1221
  detached = true;
999
1222
  cancelTimer();
1223
+ stopLockWait?.();
1000
1224
  for (const fn of cleanups) fn();
1001
1225
  }
1002
1226
  function wake() {
@@ -1019,7 +1243,12 @@ function attachTokenLifecycle(options) {
1019
1243
  return;
1020
1244
  }
1021
1245
  const remaining = exp - now();
1022
- if (remaining < graceMs) {
1246
+ if (remaining < graceMs && insideRefreshFloor()) options.onWakeChecked?.({
1247
+ decision: "throttled",
1248
+ remainingMs: remaining,
1249
+ phase: phase.kind
1250
+ });
1251
+ else if (remaining < graceMs) {
1023
1252
  options.onWakeChecked?.({
1024
1253
  decision: "refresh-now",
1025
1254
  remainingMs: remaining,
@@ -1037,6 +1266,24 @@ function attachTokenLifecycle(options) {
1037
1266
  wake
1038
1267
  };
1039
1268
  }
1269
+ function localAccessExpiry(access, serverExpiresAt, receivedAt = Date.now()) {
1270
+ const claims = decodeJwtClaims(access);
1271
+ const iat = claims?.iat;
1272
+ const exp = claims?.exp;
1273
+ if (typeof iat === "number" && typeof exp === "number" && exp > iat) return receivedAt + (exp - iat) * 1e3;
1274
+ return serverExpiresAt;
1275
+ }
1276
+ function decodeJwtClaims(token) {
1277
+ const payload = token.split(".")[1];
1278
+ if (!payload) return null;
1279
+ try {
1280
+ const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
1281
+ const claims = JSON.parse(atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, "=")));
1282
+ return typeof claims === "object" && claims !== null ? claims : null;
1283
+ } catch {
1284
+ return null;
1285
+ }
1286
+ }
1040
1287
  function stringifyError(err) {
1041
1288
  if (err instanceof Error) return err.message;
1042
1289
  if (typeof err === "string") return err;
@@ -1065,10 +1312,9 @@ function attachTransportSync(options) {
1065
1312
  applied.set(transport, target);
1066
1313
  transport.apply(target).catch((err) => {
1067
1314
  options.onError?.(transport, target, err);
1068
- if (applied.get(transport) === target) {
1069
- applied.delete(transport);
1070
- scheduleRetry(transport);
1071
- }
1315
+ if (applied.get(transport) !== target || refusedByServer(err)) return;
1316
+ applied.delete(transport);
1317
+ scheduleRetry(transport);
1072
1318
  });
1073
1319
  }
1074
1320
  function scheduleRetry(transport) {
@@ -1134,17 +1380,22 @@ function attachWorkerBridge(options) {
1134
1380
  if (detached) return;
1135
1381
  const msg = makeSync(phase);
1136
1382
  let count = 0;
1137
- for (const host of options.hosts()) try {
1138
- host.postMessage(msg);
1139
- count++;
1140
- maybeAttachHostListener(host);
1141
- } catch (err) {
1142
- log.warn("broadcast postMessage failed", {
1143
- gen: msg.generation,
1144
- phase: phase.kind,
1145
- err
1146
- });
1383
+ const present = /* @__PURE__ */ new Set();
1384
+ for (const host of options.hosts()) {
1385
+ present.add(host);
1386
+ try {
1387
+ host.postMessage(msg);
1388
+ count++;
1389
+ maybeAttachHostListener(host);
1390
+ } catch (err) {
1391
+ log.warn("broadcast postMessage failed", {
1392
+ gen: msg.generation,
1393
+ phase: phase.kind,
1394
+ err
1395
+ });
1396
+ }
1147
1397
  }
1398
+ forgetDeparted(present);
1148
1399
  options.onBroadcast?.(generation, count);
1149
1400
  }
1150
1401
  function syncOne(host) {
@@ -1207,6 +1458,13 @@ function attachWorkerBridge(options) {
1207
1458
  host.removeEventListener?.("message", listener);
1208
1459
  });
1209
1460
  }
1461
+ function forgetHost(host) {
1462
+ hostListenerCleanups.get(host)?.();
1463
+ hostListenerCleanups.delete(host);
1464
+ }
1465
+ function forgetDeparted(present) {
1466
+ for (const host of hostListenerCleanups.keys()) if (!present.has(host)) forgetHost(host);
1467
+ }
1210
1468
  const unsubKernel = options.kernel.subscribe((next) => {
1211
1469
  broadcast(next);
1212
1470
  });
@@ -1225,13 +1483,19 @@ function attachWorkerBridge(options) {
1225
1483
  },
1226
1484
  revalidateWorkers() {
1227
1485
  if (detached) return;
1228
- for (const host of options.hosts()) try {
1229
- host.postMessage({ type: "kernel-revalidate" });
1230
- maybeAttachHostListener(host);
1231
- } catch (err) {
1232
- log.warn("revalidate postMessage failed", err);
1486
+ const present = /* @__PURE__ */ new Set();
1487
+ for (const host of options.hosts()) {
1488
+ present.add(host);
1489
+ try {
1490
+ host.postMessage({ type: "kernel-revalidate" });
1491
+ maybeAttachHostListener(host);
1492
+ } catch (err) {
1493
+ log.warn("revalidate postMessage failed", err);
1494
+ }
1233
1495
  }
1234
- }
1496
+ forgetDeparted(present);
1497
+ },
1498
+ forgetHost
1235
1499
  };
1236
1500
  }
1237
1501
  //#endregion
@@ -1309,18 +1573,22 @@ function createConnectionJournal(options = {}) {
1309
1573
  //#endregion
1310
1574
  //#region src/signal.ts
1311
1575
  var DEFAULT_DEBOUNCE_MS = 1500;
1576
+ var DEFAULT_FIRST_CONNECT_TIMEOUT_MS = 15e3;
1312
1577
  function signalEquals(a, b) {
1313
1578
  if (a.kind !== b.kind) return false;
1314
- if (a.kind === "degraded" && b.kind === "degraded") return a.channels.join(",") === b.channels.join(",");
1579
+ if (a.kind === "degraded" && b.kind === "degraded") return a.reason === b.reason && a.channels.join(",") === b.channels.join(",");
1315
1580
  if (a.kind === "offline" && b.kind === "offline") return a.retryAt === b.retryAt;
1316
1581
  return true;
1317
1582
  }
1318
1583
  function createConnectionSignal(options) {
1319
1584
  const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
1585
+ const firstConnectTimeoutMs = options.firstConnectTimeoutMs ?? DEFAULT_FIRST_CONNECT_TIMEOUT_MS;
1320
1586
  const listeners = /* @__PURE__ */ new Set();
1321
1587
  const cleanups = [];
1322
1588
  let hasBeenOnline = false;
1323
1589
  let anyChannelEverUp = false;
1590
+ let firstConnectOverdue = false;
1591
+ let firstConnectTimer = null;
1324
1592
  let pendingTimer = null;
1325
1593
  let pendingSignal = null;
1326
1594
  let disposed = false;
@@ -1337,14 +1605,38 @@ function createConnectionSignal(options) {
1337
1605
  case "online": {
1338
1606
  const channels = options.transports.filter((t) => t.spec.phaseGating && !t.health().up).map((t) => t.spec.id);
1339
1607
  if (channels.length === 0) return { kind: "online" };
1340
- return anyChannelEverUp ? {
1608
+ if (anyChannelEverUp) return {
1341
1609
  kind: "degraded",
1342
1610
  channels
1611
+ };
1612
+ return firstConnectOverdue ? {
1613
+ kind: "degraded",
1614
+ channels,
1615
+ reason: "first-connect"
1343
1616
  } : { kind: "connecting" };
1344
1617
  }
1345
1618
  }
1346
1619
  }
1347
1620
  let current = compute();
1621
+ function clearFirstConnectTimer() {
1622
+ if (firstConnectTimer !== null) {
1623
+ clearTimeout(firstConnectTimer);
1624
+ firstConnectTimer = null;
1625
+ }
1626
+ }
1627
+ function watchFirstConnect() {
1628
+ if (options.kernel.phase.kind !== "online" || anyChannelEverUp) {
1629
+ clearFirstConnectTimer();
1630
+ firstConnectOverdue = false;
1631
+ return;
1632
+ }
1633
+ if (firstConnectTimer !== null || firstConnectOverdue || !(firstConnectTimeoutMs > 0 && Number.isFinite(firstConnectTimeoutMs))) return;
1634
+ firstConnectTimer = setTimeout(() => {
1635
+ firstConnectTimer = null;
1636
+ firstConnectOverdue = true;
1637
+ apply(compute());
1638
+ }, firstConnectTimeoutMs);
1639
+ }
1348
1640
  function clearPending() {
1349
1641
  if (pendingTimer !== null) {
1350
1642
  clearTimeout(pendingTimer);
@@ -1390,15 +1682,18 @@ function createConnectionSignal(options) {
1390
1682
  hasBeenOnline = false;
1391
1683
  anyChannelEverUp = false;
1392
1684
  }
1685
+ watchFirstConnect();
1393
1686
  recompute();
1394
1687
  }));
1395
1688
  for (const transport of options.transports) {
1396
1689
  cleanups.push(transport.on("up", () => {
1397
1690
  anyChannelEverUp = true;
1691
+ watchFirstConnect();
1398
1692
  recompute();
1399
1693
  }));
1400
1694
  cleanups.push(transport.on("down", () => recompute()));
1401
1695
  }
1696
+ watchFirstConnect();
1402
1697
  return {
1403
1698
  get current() {
1404
1699
  return current;
@@ -1412,10 +1707,11 @@ function createConnectionSignal(options) {
1412
1707
  dispose() {
1413
1708
  disposed = true;
1414
1709
  clearPending();
1710
+ clearFirstConnectTimer();
1415
1711
  for (const fn of cleanups) fn();
1416
1712
  listeners.clear();
1417
1713
  }
1418
1714
  };
1419
1715
  }
1420
1716
  //#endregion
1421
- 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 };
1717
+ export { TransportEmitter, attachBackoff, attachCrossTab, attachDegradedRecovery, attachNetworkChange, attachPersistence, attachPresence, attachProbeLoop, attachTokenLifecycle, attachTransportSync, attachWorkerBridge, classifyClose, createBackgroundProbe, createConnectionJournal, createConnectionSignal, createKernel, isEndpointChange, isProbeFailure, isSameTarget, localAccessExpiry, localStorageAdapter, makeProbeFailure, memoryStorageAdapter, raceFirst, reducer };