viveworker 0.8.2 → 0.8.4

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.
@@ -13,12 +13,13 @@
13
13
  * 2. If LAN throws a `TypeError` (network failure, DNS, connect refused —
14
14
  * i.e. the bridge isn't reachable), fall back to the relay's
15
15
  * `RemotePairingRpcClient.fetch()`.
16
- * 3. After a LAN failure, enter a STICKY_RELAY_MS window where subsequent
17
- * requests skip LAN and go straight to relay. Avoids paying the LAN
18
- * timeout cost on every call after we've already learned LAN is dead.
19
- * 4. When the window expires, the next request re-probes LAN. If LAN is
20
- * back, the sticky window is left dormant and we return to the happy
21
- * path. If LAN is still dead, the window resets.
16
+ * 3. After a LAN failure, enter a sticky relay window where subsequent
17
+ * requests skip LAN and go straight to relay. If LAN was healthy very
18
+ * recently, keep that window short so one transient WiFi wobble doesn't
19
+ * make an in-room phone behave like it is off-LAN for minutes.
20
+ * 4. While sticky relay is active, periodically run a tiny LAN probe. If
21
+ * LAN is back, clear sticky state and close the relay client. If LAN is
22
+ * still dead, the sticky window resets and relay stays the fast path.
22
23
  * 5. AbortError (caller cancelled) is never treated as a LAN failure —
23
24
  * we re-throw immediately rather than waste a relay attempt.
24
25
  *
@@ -76,11 +77,9 @@ import { loadPairingState } from "./pairing-state.js";
76
77
  // ---------------------------------------------------------------------------
77
78
 
78
79
  /**
79
- * After a LAN failure we prefer relay for this long before re-probing LAN.
80
- * 5 minutes is a coarse pick: long enough that a phone that fell off LAN
81
- * (cellular handoff, WiFi drop) doesn't pay LAN connect timeouts on every
82
- * outbound call, short enough that "I just walked back into wifi range"
83
- * recovers without the user noticing.
80
+ * Hard upper bound after a LAN failure. We keep this for real off-LAN use so
81
+ * cellular/remote sessions do not pay LAN connect timeouts on every request.
82
+ * Recent successful LAN sessions use a much shorter transient window below.
84
83
  */
85
84
  const STICKY_RELAY_MS = 5 * 60 * 1000;
86
85
 
@@ -99,6 +98,20 @@ const DEFAULT_RELAY_TIMEOUT_MS = 60_000;
99
98
  */
100
99
  const DEFAULT_LAN_TIMEOUT_MS = 2_500;
101
100
  const DEFAULT_STICKY_LAN_PROBE_TIMEOUT_MS = 350;
101
+ // Recover quickly from accidentally sticky relay while still avoiding LAN
102
+ // timeout spam during real off-LAN sessions.
103
+ const AUTO_STICKY_LAN_PROBE_INTERVAL_MS = 8_000;
104
+ const AUTO_STICKY_LAN_PROBE_TIMEOUT_MS = 900;
105
+ const LAN_REACHABILITY_PROBE_TIMEOUT_MS = 700;
106
+ const LAN_REACHABILITY_PROBE_PATH = "/health";
107
+ const RECENT_LAN_OK_GRACE_MS = 60_000;
108
+ const TRANSIENT_LAN_FAILURE_STICKY_MS = 8_000;
109
+ const TRANSIENT_LAN_FAILURE_THRESHOLD = 2;
110
+ // If LAN was healthy moments ago, a single timed-out local request is more
111
+ // likely a busy bridge/Safari wobble than a true off-LAN transition. Delay
112
+ // relay fallback for the first few consecutive failures to avoid burning
113
+ // Cloudflare Durable Object quota while the phone is still on trusted LAN.
114
+ const RECENT_LAN_RELAY_SUPPRESSION_FAILURES = 2;
102
115
  const RELAY_FAILURE_WINDOW_MS = 60_000;
103
116
  const RELAY_FAILURE_THRESHOLD = 6;
104
117
  const RELAY_CIRCUIT_BREAKER_MS = 60_000;
@@ -132,6 +145,15 @@ let _lastPairingStateStatus = null;
132
145
  /** Last transport route that completed successfully. */
133
146
  let _lastSuccessfulRoute = null;
134
147
 
148
+ /** Last time a LAN request completed successfully. */
149
+ let _lastLanOkAtMs = 0;
150
+
151
+ /** Last time we tried a LAN probe while sticky relay was active. */
152
+ let _lastStickyLanProbeAtMs = 0;
153
+
154
+ /** Consecutive LAN transport failures since the last LAN success. */
155
+ let _consecutiveLanFailures = 0;
156
+
135
157
  /** Recent relay-level failures used to avoid burning relay/DO quota in loops. */
136
158
  let _relayFailureAtMs = [];
137
159
 
@@ -142,8 +164,10 @@ function newTelemetry() {
142
164
  return {
143
165
  lanOk: 0,
144
166
  lanFail: 0,
167
+ lanReachableAfterFailure: 0,
145
168
  relayOk: 0,
146
169
  relayFail: 0,
170
+ relaySuppressed: 0,
147
171
  lastLanFailAt: 0,
148
172
  lastRelayFailAt: 0,
149
173
  clientResets: 0,
@@ -440,6 +464,29 @@ function relayCircuitError(opts = {}) {
440
464
  return err;
441
465
  }
442
466
 
467
+ function shouldAutoProbeLanWhileSticky(now, opts = {}) {
468
+ if (opts.autoProbeLanWhileSticky === false) {
469
+ return false;
470
+ }
471
+ if (relayCircuitDelayMs(opts) > 0) {
472
+ return false;
473
+ }
474
+ return now - _lastStickyLanProbeAtMs >= AUTO_STICKY_LAN_PROBE_INTERVAL_MS;
475
+ }
476
+
477
+ function stickyLanProbeOpts(opts, autoProbe) {
478
+ if (!autoProbe) {
479
+ return opts;
480
+ }
481
+ if (opts.stickyLanProbeTimeoutMs != null) {
482
+ return opts;
483
+ }
484
+ return {
485
+ ...opts,
486
+ stickyLanProbeTimeoutMs: AUTO_STICKY_LAN_PROBE_TIMEOUT_MS,
487
+ };
488
+ }
489
+
443
490
  function recordRelayFailure(err, opts = {}) {
444
491
  const now = nowMs(opts);
445
492
  _relayFailureAtMs = _relayFailureAtMs.filter((at) => now - at <= RELAY_FAILURE_WINDOW_MS);
@@ -554,8 +601,11 @@ async function attemptLanFetch(url, init, opts) {
554
601
  ? await Promise.race([fetchPromise, timeoutPromise])
555
602
  : await fetchPromise;
556
603
  _telemetry.lanOk++;
604
+ _lastLanOkAtMs = nowMs(opts);
605
+ _consecutiveLanFailures = 0;
557
606
  _lastSuccessfulRoute = "lan";
558
607
  _stickyRelayUntilMs = 0;
608
+ _lastStickyLanProbeAtMs = 0;
559
609
  resetRelayFailureCircuit();
560
610
  closeRelayClient("lan connected");
561
611
  emitRoutingStatus("lan-connected", opts, { url: String(url || "") });
@@ -566,12 +616,17 @@ async function attemptLanFetch(url, init, opts) {
566
616
  throw err;
567
617
  }
568
618
  const lanErr = didTimeout ? new TypeError("LAN fetch timed out") : err;
619
+ const failedAt = nowMs(opts);
569
620
  _telemetry.lanFail++;
570
- _telemetry.lastLanFailAt = nowMs(opts);
571
- _stickyRelayUntilMs = _telemetry.lastLanFailAt + STICKY_RELAY_MS;
621
+ _consecutiveLanFailures++;
622
+ _telemetry.lastLanFailAt = failedAt;
623
+ _lastStickyLanProbeAtMs = failedAt;
624
+ _stickyRelayUntilMs = failedAt + stickyRelayWindowAfterLanFailure(failedAt);
572
625
  emitRoutingStatus("lan-failed", opts, {
573
626
  url: String(url || ""),
574
627
  reason: lanErr?.message || String(lanErr),
628
+ consecutiveLanFailures: _consecutiveLanFailures,
629
+ stickyRelayMs: Math.max(0, _stickyRelayUntilMs - failedAt),
575
630
  });
576
631
  return { ok: false, err: lanErr };
577
632
  } finally {
@@ -582,8 +637,71 @@ async function attemptLanFetch(url, init, opts) {
582
637
  }
583
638
  }
584
639
 
640
+ function stickyRelayWindowAfterLanFailure(now) {
641
+ const recentLanOk = _lastLanOkAtMs > 0 && now - _lastLanOkAtMs <= RECENT_LAN_OK_GRACE_MS;
642
+ if (recentLanOk && _consecutiveLanFailures <= TRANSIENT_LAN_FAILURE_THRESHOLD) {
643
+ return TRANSIENT_LAN_FAILURE_STICKY_MS;
644
+ }
645
+ return STICKY_RELAY_MS;
646
+ }
647
+
648
+ function shouldDelayRelayAfterRecentLanFailure(now, opts = {}) {
649
+ if (opts.delayRelayAfterRecentLanFailure === false) {
650
+ return false;
651
+ }
652
+ if (_lastLanOkAtMs <= 0 || now - _lastLanOkAtMs > RECENT_LAN_OK_GRACE_MS) {
653
+ return false;
654
+ }
655
+ return _consecutiveLanFailures > 0 &&
656
+ _consecutiveLanFailures <= RECENT_LAN_RELAY_SUPPRESSION_FAILURES;
657
+ }
658
+
659
+ function suppressRelayAfterRecentLanFailure(url, err, opts = {}) {
660
+ _telemetry.relaySuppressed++;
661
+ emitRoutingStatus("remote-delayed", opts, {
662
+ url: String(url || ""),
663
+ reason: err?.message || String(err || "LAN failure"),
664
+ consecutiveLanFailures: _consecutiveLanFailures,
665
+ recentLanOkAtMs: _lastLanOkAtMs,
666
+ });
667
+ }
668
+
669
+ function shouldConfirmLanReachability(now, opts = {}) {
670
+ if (opts.confirmLanReachabilityBeforeRelay === false) {
671
+ return false;
672
+ }
673
+ return _lastLanOkAtMs > 0 && now - _lastLanOkAtMs <= RECENT_LAN_OK_GRACE_MS;
674
+ }
675
+
676
+ async function confirmLanUnavailableBeforeRelay(url, err, opts = {}) {
677
+ if (!shouldConfirmLanReachability(nowMs(opts), opts)) {
678
+ return false;
679
+ }
680
+ const path = urlToRelayPath(url);
681
+ if (path === LAN_REACHABILITY_PROBE_PATH) {
682
+ return false;
683
+ }
684
+
685
+ const probe = await attemptLanFetch(LAN_REACHABILITY_PROBE_PATH, {
686
+ cache: "no-store",
687
+ credentials: "same-origin",
688
+ headers: { Accept: "application/json" },
689
+ }, {
690
+ ...opts,
691
+ lanTimeoutMs: opts.lanReachabilityProbeTimeoutMs ?? LAN_REACHABILITY_PROBE_TIMEOUT_MS,
692
+ });
693
+ if (!probe.ok) {
694
+ return false;
695
+ }
696
+
697
+ _telemetry.lanReachableAfterFailure++;
698
+ suppressRelayAfterRecentLanFailure(url, err, opts);
699
+ return true;
700
+ }
701
+
585
702
  async function attemptStickyLanProbe(url, init, opts) {
586
703
  const probeTimeoutMs = opts.stickyLanProbeTimeoutMs ?? DEFAULT_STICKY_LAN_PROBE_TIMEOUT_MS;
704
+ _lastStickyLanProbeAtMs = nowMs(opts);
587
705
  const lan = await attemptLanFetch(url, init, {
588
706
  ...opts,
589
707
  lanTimeoutMs: probeTimeoutMs,
@@ -859,7 +977,11 @@ function nowMs(opts) {
859
977
  * suppressRoutingStatus?: boolean,
860
978
  * preferRelayError?: boolean,
861
979
  * probeLanWhileSticky?: boolean,
980
+ * autoProbeLanWhileSticky?: boolean,
981
+ * delayRelayAfterRecentLanFailure?: boolean,
982
+ * confirmLanReachabilityBeforeRelay?: boolean,
862
983
  * stickyLanProbeTimeoutMs?: number,
984
+ * lanReachabilityProbeTimeoutMs?: number,
863
985
  * }} [opts]
864
986
  * @returns {Promise<{
865
987
  * ok: boolean,
@@ -875,9 +997,24 @@ export async function routedFetch(url, init = {}, opts = {}) {
875
997
 
876
998
  // Sticky-relay path: LAN just failed, prefer relay for a while.
877
999
  if (_stickyRelayUntilMs > t) {
878
- if (opts.probeLanWhileSticky === true) {
879
- const lan = await attemptStickyLanProbe(url, init, opts);
1000
+ if (shouldDelayRelayAfterRecentLanFailure(t, opts)) {
1001
+ const lan = await attemptLanFetch(url, init, opts);
1002
+ if (lan.ok) return lan.response;
1003
+ if (shouldDelayRelayAfterRecentLanFailure(nowMs(opts), opts)) {
1004
+ suppressRelayAfterRecentLanFailure(url, lan.err, opts);
1005
+ throw lan.err;
1006
+ }
1007
+ if (await confirmLanUnavailableBeforeRelay(url, lan.err, opts)) {
1008
+ throw lan.err;
1009
+ }
1010
+ }
1011
+ const autoProbe = shouldAutoProbeLanWhileSticky(t, opts);
1012
+ if (opts.probeLanWhileSticky === true || autoProbe) {
1013
+ const lan = await attemptStickyLanProbe(url, init, stickyLanProbeOpts(opts, autoProbe));
880
1014
  if (lan.ok) return lan.response;
1015
+ if (await confirmLanUnavailableBeforeRelay(url, lan.err, opts)) {
1016
+ throw lan.err;
1017
+ }
881
1018
  }
882
1019
  emitRoutingStatus("remote-switching", opts, { url: String(url || ""), sticky: true });
883
1020
  const r = await attemptRelayFetch(url, init, opts);
@@ -897,6 +1034,13 @@ export async function routedFetch(url, init = {}, opts = {}) {
897
1034
  // Happy path: LAN first.
898
1035
  const lan = await attemptLanFetch(url, init, opts);
899
1036
  if (lan.ok) return lan.response;
1037
+ if (shouldDelayRelayAfterRecentLanFailure(nowMs(opts), opts)) {
1038
+ suppressRelayAfterRecentLanFailure(url, lan.err, opts);
1039
+ throw lan.err;
1040
+ }
1041
+ if (await confirmLanUnavailableBeforeRelay(url, lan.err, opts)) {
1042
+ throw lan.err;
1043
+ }
900
1044
 
901
1045
  // Try relay once before giving up.
902
1046
  emitRoutingStatus("remote-switching", opts, { url: String(url || ""), sticky: false });
@@ -918,6 +1062,9 @@ export function __getTelemetry() {
918
1062
  ..._telemetry,
919
1063
  stickyRelayUntilMs: _stickyRelayUntilMs,
920
1064
  lastRoute: _lastSuccessfulRoute,
1065
+ lastLanOkAtMs: _lastLanOkAtMs,
1066
+ lastStickyLanProbeAtMs: _lastStickyLanProbeAtMs,
1067
+ consecutiveLanFailures: _consecutiveLanFailures,
921
1068
  hasClient: Boolean(_client),
922
1069
  clientPairingKey: _clientPairingKey,
923
1070
  relayCircuitOpenUntilMs: _relayCircuitOpenUntilMs,
@@ -946,6 +1093,9 @@ export function __resetForTest() {
946
1093
  _telemetry = newTelemetry();
947
1094
  _lastPairingStateStatus = null;
948
1095
  _lastSuccessfulRoute = null;
1096
+ _lastLanOkAtMs = 0;
1097
+ _lastStickyLanProbeAtMs = 0;
1098
+ _consecutiveLanFailures = 0;
949
1099
  _relayFailureAtMs = [];
950
1100
  _relayCircuitOpenUntilMs = 0;
951
1101
  }
@@ -953,5 +1103,8 @@ export function __resetForTest() {
953
1103
  // Test-visible constants for tests that want to assert behavior at the
954
1104
  // sticky-window boundary without hard-coding 5 minutes in two places.
955
1105
  export const __STICKY_RELAY_MS = STICKY_RELAY_MS;
1106
+ export const __AUTO_STICKY_LAN_PROBE_INTERVAL_MS = AUTO_STICKY_LAN_PROBE_INTERVAL_MS;
1107
+ export const __TRANSIENT_LAN_FAILURE_STICKY_MS = TRANSIENT_LAN_FAILURE_STICKY_MS;
956
1108
  export const __DEFAULT_RELAY_TIMEOUT_MS = DEFAULT_RELAY_TIMEOUT_MS;
957
1109
  export const __DEFAULT_LAN_TIMEOUT_MS = DEFAULT_LAN_TIMEOUT_MS;
1110
+ export const __RECENT_LAN_RELAY_SUPPRESSION_FAILURES = RECENT_LAN_RELAY_SUPPRESSION_FAILURES;
@@ -88,6 +88,7 @@ const DEFAULT_FAILURE_WINDOW_MS = 60_000;
88
88
  const DEFAULT_FAILURE_THRESHOLD = 4;
89
89
  const DEFAULT_CIRCUIT_BREAKER_MS = 60_000;
90
90
  const DEFAULT_MAX_CIRCUIT_BREAKER_MS = 10 * 60_000;
91
+ const DEFAULT_STABLE_CONNECTION_MS = 15_000;
91
92
  const DEFAULT_PROLOGUE = new TextEncoder().encode("viveworker/remote-pairing/v1");
92
93
 
93
94
  // CloseEvent codes we emit. 1000 is normal; 4xxx is application-defined.
@@ -121,6 +122,7 @@ const CLOSE_RELAY_RESET_SESSION = 4004;
121
122
  * @property {number} [failureThreshold]
122
123
  * @property {number} [circuitBreakerMs]
123
124
  * @property {number} [maxCircuitBreakerMs]
125
+ * @property {number} [stableConnectionMs]
124
126
  * @property {typeof WebSocket} [WebSocketImpl] injectable for Node-side tests
125
127
  * @property {{debug?: Function, warn?: Function}} [logger]
126
128
  */
@@ -166,6 +168,7 @@ export class RemotePairingTransport {
166
168
  this._failureThreshold = opts.failureThreshold ?? DEFAULT_FAILURE_THRESHOLD;
167
169
  this._circuitBreakerMs = opts.circuitBreakerMs ?? DEFAULT_CIRCUIT_BREAKER_MS;
168
170
  this._maxCircuitBreakerMs = opts.maxCircuitBreakerMs ?? DEFAULT_MAX_CIRCUIT_BREAKER_MS;
171
+ this._stableConnectionMs = opts.stableConnectionMs ?? DEFAULT_STABLE_CONNECTION_MS;
169
172
 
170
173
  this._WebSocketImpl = opts.WebSocketImpl ?? globalThis.WebSocket;
171
174
  if (typeof this._WebSocketImpl !== "function") {
@@ -200,6 +203,8 @@ export class RemotePairingTransport {
200
203
  this._pingTimer = null;
201
204
  /** @type {ReturnType<typeof setTimeout> | null} */
202
205
  this._handshakeTimer = null;
206
+ /** @type {ReturnType<typeof setTimeout> | null} */
207
+ this._stableConnectionTimer = null;
203
208
  /** @type {number[]} recent reconnect-triggering failures. */
204
209
  this._recentFailureAtMs = [];
205
210
  /** If > Date.now(), reconnect attempts are intentionally delayed. */
@@ -317,6 +322,7 @@ export class RemotePairingTransport {
317
322
  this._closed = true;
318
323
  this._started = false;
319
324
  this._cancelReconnectTimer();
325
+ this._cancelStableConnectionTimer();
320
326
  this._stopPing();
321
327
  this._stopHandshakeTimer();
322
328
  if (this._ws) {
@@ -682,6 +688,24 @@ export class RemotePairingTransport {
682
688
  }
683
689
  }
684
690
 
691
+ _scheduleStableConnectionReset() {
692
+ const delay = Math.max(0, Number(this._stableConnectionMs) || 0);
693
+ this._stableConnectionTimer = setTimeout(() => {
694
+ this._stableConnectionTimer = null;
695
+ if (this._closed || this._state !== STATE.CONNECTED) return;
696
+ this._recentFailureAtMs = [];
697
+ this._circuitOpenUntilMs = 0;
698
+ this._circuitOpenCount = 0;
699
+ }, delay);
700
+ }
701
+
702
+ _cancelStableConnectionTimer() {
703
+ if (this._stableConnectionTimer != null) {
704
+ clearTimeout(this._stableConnectionTimer);
705
+ this._stableConnectionTimer = null;
706
+ }
707
+ }
708
+
685
709
  _startPing() {
686
710
  this._stopPing();
687
711
  this._pingTimer = setInterval(() => {
@@ -742,11 +766,10 @@ export class RemotePairingTransport {
742
766
  if (this._state === newState) return;
743
767
  const prev = this._state;
744
768
  this._state = newState;
769
+ this._cancelStableConnectionTimer();
745
770
  if (newState === STATE.CONNECTED) {
746
771
  this._reconnectAttempt = 0;
747
- this._recentFailureAtMs = [];
748
- this._circuitOpenUntilMs = 0;
749
- this._circuitOpenCount = 0;
772
+ this._scheduleStableConnectionReset();
750
773
  }
751
774
  try {
752
775
  this._onStateChange(newState, prev, info);
@@ -771,6 +794,7 @@ export class RemotePairingTransport {
771
794
  this._stopPing();
772
795
  this._stopHandshakeTimer();
773
796
  this._cancelReconnectTimer();
797
+ this._cancelStableConnectionTimer();
774
798
  if (this._ws) {
775
799
  try { this._ws.close(CLOSE_FATAL, "fatal-error"); } catch {}
776
800
  this._ws = null;