viveworker 0.8.1 → 0.8.3

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
 
@@ -98,6 +97,24 @@ const DEFAULT_RELAY_TIMEOUT_MS = 60_000;
98
97
  * LAN is the fast path, and slow/unreachable LAN should become relay.
99
98
  */
100
99
  const DEFAULT_LAN_TIMEOUT_MS = 2_500;
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;
115
+ const RELAY_FAILURE_WINDOW_MS = 60_000;
116
+ const RELAY_FAILURE_THRESHOLD = 6;
117
+ const RELAY_CIRCUIT_BREAKER_MS = 60_000;
101
118
  const PAIRING_STATE_STORAGE_KEY = "viveworker.remote-pairing.state";
102
119
  const PAIRING_STATE_SCHEMA_VERSION = 2;
103
120
  const PAIRING_STATE_LEGACY_SCHEMA_VERSION = 1;
@@ -128,12 +145,29 @@ let _lastPairingStateStatus = null;
128
145
  /** Last transport route that completed successfully. */
129
146
  let _lastSuccessfulRoute = null;
130
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
+
157
+ /** Recent relay-level failures used to avoid burning relay/DO quota in loops. */
158
+ let _relayFailureAtMs = [];
159
+
160
+ /** If > now, relay attempts are intentionally skipped. */
161
+ let _relayCircuitOpenUntilMs = 0;
162
+
131
163
  function newTelemetry() {
132
164
  return {
133
165
  lanOk: 0,
134
166
  lanFail: 0,
167
+ lanReachableAfterFailure: 0,
135
168
  relayOk: 0,
136
169
  relayFail: 0,
170
+ relaySuppressed: 0,
137
171
  lastLanFailAt: 0,
138
172
  lastRelayFailAt: 0,
139
173
  clientResets: 0,
@@ -404,6 +438,74 @@ function resetRelayClientForRetry() {
404
438
  _wakeUnbind = null;
405
439
  }
406
440
 
441
+ function closeRelayClient(_reason = "closed") {
442
+ if (_client) {
443
+ _telemetry.clientResets++;
444
+ try { _client.close(); } catch { /* ignore */ }
445
+ }
446
+ _client = null;
447
+ _clientPairingKey = null;
448
+ if (_wakeUnbind) {
449
+ try { _wakeUnbind(); } catch { /* ignore */ }
450
+ }
451
+ _wakeUnbind = null;
452
+ }
453
+
454
+ function relayCircuitDelayMs(opts = {}) {
455
+ return Math.max(0, _relayCircuitOpenUntilMs - nowMs(opts));
456
+ }
457
+
458
+ function relayCircuitError(opts = {}) {
459
+ const delayMs = relayCircuitDelayMs(opts);
460
+ const err = new Error(`remote relay temporarily cooled down (${Math.ceil(delayMs / 1000)}s)`);
461
+ err.name = "RemoteRelayCircuitOpenError";
462
+ err.code = "remote-relay-circuit-open";
463
+ err.retryAfterMs = delayMs;
464
+ return err;
465
+ }
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
+
490
+ function recordRelayFailure(err, opts = {}) {
491
+ const now = nowMs(opts);
492
+ _relayFailureAtMs = _relayFailureAtMs.filter((at) => now - at <= RELAY_FAILURE_WINDOW_MS);
493
+ _relayFailureAtMs.push(now);
494
+ if (_relayFailureAtMs.length < RELAY_FAILURE_THRESHOLD) return;
495
+ _relayCircuitOpenUntilMs = Math.max(_relayCircuitOpenUntilMs, now + RELAY_CIRCUIT_BREAKER_MS);
496
+ _relayFailureAtMs = [];
497
+ closeRelayClient("relay circuit open");
498
+ emitRoutingStatus("remote-cooled-down", opts, {
499
+ reason: err?.message || String(err || "relay failure"),
500
+ retryAfterMs: RELAY_CIRCUIT_BREAKER_MS,
501
+ });
502
+ }
503
+
504
+ function resetRelayFailureCircuit() {
505
+ _relayFailureAtMs = [];
506
+ _relayCircuitOpenUntilMs = 0;
507
+ }
508
+
407
509
  // ---------------------------------------------------------------------------
408
510
  // Response adapter — RpcResponse → minimal Fetch-Response shape
409
511
  // ---------------------------------------------------------------------------
@@ -499,7 +601,13 @@ async function attemptLanFetch(url, init, opts) {
499
601
  ? await Promise.race([fetchPromise, timeoutPromise])
500
602
  : await fetchPromise;
501
603
  _telemetry.lanOk++;
604
+ _lastLanOkAtMs = nowMs(opts);
605
+ _consecutiveLanFailures = 0;
502
606
  _lastSuccessfulRoute = "lan";
607
+ _stickyRelayUntilMs = 0;
608
+ _lastStickyLanProbeAtMs = 0;
609
+ resetRelayFailureCircuit();
610
+ closeRelayClient("lan connected");
503
611
  emitRoutingStatus("lan-connected", opts, { url: String(url || "") });
504
612
  return { ok: true, response };
505
613
  } catch (err) {
@@ -508,12 +616,17 @@ async function attemptLanFetch(url, init, opts) {
508
616
  throw err;
509
617
  }
510
618
  const lanErr = didTimeout ? new TypeError("LAN fetch timed out") : err;
619
+ const failedAt = nowMs(opts);
511
620
  _telemetry.lanFail++;
512
- _telemetry.lastLanFailAt = nowMs(opts);
513
- _stickyRelayUntilMs = _telemetry.lastLanFailAt + STICKY_RELAY_MS;
621
+ _consecutiveLanFailures++;
622
+ _telemetry.lastLanFailAt = failedAt;
623
+ _lastStickyLanProbeAtMs = failedAt;
624
+ _stickyRelayUntilMs = failedAt + stickyRelayWindowAfterLanFailure(failedAt);
514
625
  emitRoutingStatus("lan-failed", opts, {
515
626
  url: String(url || ""),
516
627
  reason: lanErr?.message || String(lanErr),
628
+ consecutiveLanFailures: _consecutiveLanFailures,
629
+ stickyRelayMs: Math.max(0, _stickyRelayUntilMs - failedAt),
517
630
  });
518
631
  return { ok: false, err: lanErr };
519
632
  } finally {
@@ -524,6 +637,81 @@ async function attemptLanFetch(url, init, opts) {
524
637
  }
525
638
  }
526
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
+
702
+ async function attemptStickyLanProbe(url, init, opts) {
703
+ const probeTimeoutMs = opts.stickyLanProbeTimeoutMs ?? DEFAULT_STICKY_LAN_PROBE_TIMEOUT_MS;
704
+ _lastStickyLanProbeAtMs = nowMs(opts);
705
+ const lan = await attemptLanFetch(url, init, {
706
+ ...opts,
707
+ lanTimeoutMs: probeTimeoutMs,
708
+ });
709
+ if (lan.ok) {
710
+ _stickyRelayUntilMs = 0;
711
+ }
712
+ return lan;
713
+ }
714
+
527
715
  function abortError(signal) {
528
716
  if (signal?.reason instanceof Error) {
529
717
  const err = signal.reason;
@@ -680,6 +868,9 @@ async function encodeFormDataForRelay(formData, signal) {
680
868
 
681
869
  async function attemptRelayFetch(url, init, opts) {
682
870
  emitRoutingStatus("remote-connecting", opts, { url: String(url || "") });
871
+ if (relayCircuitDelayMs(opts) > 0) {
872
+ return { ok: false, err: relayCircuitError(opts) };
873
+ }
683
874
  const client = await getOrInitClient(opts);
684
875
  if (!client) {
685
876
  return { ok: false, err: relayClientUnavailableError(_lastPairingStateStatus) };
@@ -724,6 +915,7 @@ async function attemptRelayFetch(url, init, opts) {
724
915
  });
725
916
  _telemetry.relayOk++;
726
917
  _lastSuccessfulRoute = "relay";
918
+ resetRelayFailureCircuit();
727
919
  emitRoutingStatus("remote-connected", opts, { url: String(url || "") });
728
920
  return { ok: true, response: adaptRpcResponse(rpcRes) };
729
921
  } catch (err) {
@@ -734,6 +926,7 @@ async function attemptRelayFetch(url, init, opts) {
734
926
  }
735
927
  _telemetry.relayFail++;
736
928
  _telemetry.lastRelayFailAt = nowMs(opts);
929
+ recordRelayFailure(err, opts);
737
930
  emitRoutingStatus("remote-failed", opts, {
738
931
  url: String(url || ""),
739
932
  reason: err?.message || String(err),
@@ -783,6 +976,12 @@ function nowMs(opts) {
783
976
  * onRouteStatus?: (event: { phase: string, atMs: number, url?: string, sticky?: boolean, reason?: string, state?: string, previousState?: string, code?: number, resumed?: boolean }) => void,
784
977
  * suppressRoutingStatus?: boolean,
785
978
  * preferRelayError?: boolean,
979
+ * probeLanWhileSticky?: boolean,
980
+ * autoProbeLanWhileSticky?: boolean,
981
+ * delayRelayAfterRecentLanFailure?: boolean,
982
+ * confirmLanReachabilityBeforeRelay?: boolean,
983
+ * stickyLanProbeTimeoutMs?: number,
984
+ * lanReachabilityProbeTimeoutMs?: number,
786
985
  * }} [opts]
787
986
  * @returns {Promise<{
788
987
  * ok: boolean,
@@ -798,6 +997,25 @@ export async function routedFetch(url, init = {}, opts = {}) {
798
997
 
799
998
  // Sticky-relay path: LAN just failed, prefer relay for a while.
800
999
  if (_stickyRelayUntilMs > t) {
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));
1014
+ if (lan.ok) return lan.response;
1015
+ if (await confirmLanUnavailableBeforeRelay(url, lan.err, opts)) {
1016
+ throw lan.err;
1017
+ }
1018
+ }
801
1019
  emitRoutingStatus("remote-switching", opts, { url: String(url || ""), sticky: true });
802
1020
  const r = await attemptRelayFetch(url, init, opts);
803
1021
  if (r.ok) return r.response;
@@ -816,6 +1034,13 @@ export async function routedFetch(url, init = {}, opts = {}) {
816
1034
  // Happy path: LAN first.
817
1035
  const lan = await attemptLanFetch(url, init, opts);
818
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
+ }
819
1044
 
820
1045
  // Try relay once before giving up.
821
1046
  emitRoutingStatus("remote-switching", opts, { url: String(url || ""), sticky: false });
@@ -837,8 +1062,12 @@ export function __getTelemetry() {
837
1062
  ..._telemetry,
838
1063
  stickyRelayUntilMs: _stickyRelayUntilMs,
839
1064
  lastRoute: _lastSuccessfulRoute,
1065
+ lastLanOkAtMs: _lastLanOkAtMs,
1066
+ lastStickyLanProbeAtMs: _lastStickyLanProbeAtMs,
1067
+ consecutiveLanFailures: _consecutiveLanFailures,
840
1068
  hasClient: Boolean(_client),
841
1069
  clientPairingKey: _clientPairingKey,
1070
+ relayCircuitOpenUntilMs: _relayCircuitOpenUntilMs,
842
1071
  };
843
1072
  }
844
1073
 
@@ -864,10 +1093,18 @@ export function __resetForTest() {
864
1093
  _telemetry = newTelemetry();
865
1094
  _lastPairingStateStatus = null;
866
1095
  _lastSuccessfulRoute = null;
1096
+ _lastLanOkAtMs = 0;
1097
+ _lastStickyLanProbeAtMs = 0;
1098
+ _consecutiveLanFailures = 0;
1099
+ _relayFailureAtMs = [];
1100
+ _relayCircuitOpenUntilMs = 0;
867
1101
  }
868
1102
 
869
1103
  // Test-visible constants for tests that want to assert behavior at the
870
1104
  // sticky-window boundary without hard-coding 5 minutes in two places.
871
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;
872
1108
  export const __DEFAULT_RELAY_TIMEOUT_MS = DEFAULT_RELAY_TIMEOUT_MS;
873
1109
  export const __DEFAULT_LAN_TIMEOUT_MS = DEFAULT_LAN_TIMEOUT_MS;
1110
+ export const __RECENT_LAN_RELAY_SUPPRESSION_FAILURES = RECENT_LAN_RELAY_SUPPRESSION_FAILURES;
@@ -84,6 +84,10 @@ const DEFAULT_BACKOFF_MS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000];
84
84
  const DEFAULT_HANDSHAKE_TIMEOUT_MS = 30_000;
85
85
  const MAX_PRE_CONNECT_BACKOFF_MS = 4_000;
86
86
  const RELAY_RESET_RECONNECT_MS = 250;
87
+ const DEFAULT_FAILURE_WINDOW_MS = 60_000;
88
+ const DEFAULT_FAILURE_THRESHOLD = 4;
89
+ const DEFAULT_CIRCUIT_BREAKER_MS = 60_000;
90
+ const DEFAULT_MAX_CIRCUIT_BREAKER_MS = 10 * 60_000;
87
91
  const DEFAULT_PROLOGUE = new TextEncoder().encode("viveworker/remote-pairing/v1");
88
92
 
89
93
  // CloseEvent codes we emit. 1000 is normal; 4xxx is application-defined.
@@ -113,6 +117,10 @@ const CLOSE_RELAY_RESET_SESSION = 4004;
113
117
  * @property {number} [pingIntervalMs]
114
118
  * @property {number[]} [backoffMs]
115
119
  * @property {number} [handshakeTimeoutMs]
120
+ * @property {number} [failureWindowMs]
121
+ * @property {number} [failureThreshold]
122
+ * @property {number} [circuitBreakerMs]
123
+ * @property {number} [maxCircuitBreakerMs]
116
124
  * @property {typeof WebSocket} [WebSocketImpl] injectable for Node-side tests
117
125
  * @property {{debug?: Function, warn?: Function}} [logger]
118
126
  */
@@ -154,6 +162,10 @@ export class RemotePairingTransport {
154
162
  this._backoffMs = (opts.backoffMs ?? DEFAULT_BACKOFF_MS).slice();
155
163
  if (this._backoffMs.length === 0) this._backoffMs = [1_000];
156
164
  this._handshakeTimeoutMs = opts.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS;
165
+ this._failureWindowMs = opts.failureWindowMs ?? DEFAULT_FAILURE_WINDOW_MS;
166
+ this._failureThreshold = opts.failureThreshold ?? DEFAULT_FAILURE_THRESHOLD;
167
+ this._circuitBreakerMs = opts.circuitBreakerMs ?? DEFAULT_CIRCUIT_BREAKER_MS;
168
+ this._maxCircuitBreakerMs = opts.maxCircuitBreakerMs ?? DEFAULT_MAX_CIRCUIT_BREAKER_MS;
157
169
 
158
170
  this._WebSocketImpl = opts.WebSocketImpl ?? globalThis.WebSocket;
159
171
  if (typeof this._WebSocketImpl !== "function") {
@@ -188,6 +200,12 @@ export class RemotePairingTransport {
188
200
  this._pingTimer = null;
189
201
  /** @type {ReturnType<typeof setTimeout> | null} */
190
202
  this._handshakeTimer = null;
203
+ /** @type {number[]} recent reconnect-triggering failures. */
204
+ this._recentFailureAtMs = [];
205
+ /** If > Date.now(), reconnect attempts are intentionally delayed. */
206
+ this._circuitOpenUntilMs = 0;
207
+ /** Consecutive circuit openings, used to lengthen persistent outages. */
208
+ this._circuitOpenCount = 0;
191
209
 
192
210
  // ---- crypto state (persists across reconnects until RESUME_FAIL) ----
193
211
  /** @type {import("../remote-pairing.bundle.js").NoiseSession | null} */
@@ -320,6 +338,12 @@ export class RemotePairingTransport {
320
338
 
321
339
  _open() {
322
340
  this._cancelReconnectTimer();
341
+ const circuitDelay = this._circuitDelayMs();
342
+ if (circuitDelay > 0) {
343
+ this._log.warn?.(`relay reconnect circuit open for ${circuitDelay}ms`);
344
+ this._scheduleReconnect();
345
+ return;
346
+ }
323
347
  this._setState(STATE.OPENING);
324
348
 
325
349
  const url =
@@ -484,6 +508,12 @@ export class RemotePairingTransport {
484
508
 
485
509
  this._log.debug?.(`ws closed code=${evt?.code} reason=${evt?.reason}`);
486
510
  this._setState(STATE.DISCONNECTED, { code: evt?.code, reason: evt?.reason });
511
+ if (Number(evt?.code) !== CLOSE_NORMAL) {
512
+ this._recordReconnectFailure({
513
+ code: Number(evt?.code) || 0,
514
+ reason: String(evt?.reason || ""),
515
+ });
516
+ }
487
517
  if (isRelayReset) {
488
518
  this._scheduleRelayResetReconnect();
489
519
  } else {
@@ -619,9 +649,10 @@ export class RemotePairingTransport {
619
649
  const idx = Math.min(this._reconnectAttempt, this._backoffMs.length - 1);
620
650
  const waitingForFirstConnect = this._connectPromise != null && this._session == null;
621
651
  const rawDelay = this._backoffMs[idx];
622
- const delay = waitingForFirstConnect
652
+ const backoffDelay = waitingForFirstConnect
623
653
  ? Math.min(rawDelay, MAX_PRE_CONNECT_BACKOFF_MS)
624
654
  : rawDelay;
655
+ const delay = Math.max(backoffDelay, this._circuitDelayMs());
625
656
  this._reconnectAttempt += 1;
626
657
  this._log.debug?.(`reconnect in ${delay}ms (attempt ${this._reconnectAttempt})`);
627
658
  this._reconnectTimer = setTimeout(() => {
@@ -636,7 +667,7 @@ export class RemotePairingTransport {
636
667
  // rendezvous again. Treat it as a protocol reset, not a network failure,
637
668
  // so app boot does not sit behind the exponential backoff ladder.
638
669
  this._reconnectAttempt = 0;
639
- const delay = RELAY_RESET_RECONNECT_MS;
670
+ const delay = Math.max(RELAY_RESET_RECONNECT_MS, this._circuitDelayMs());
640
671
  this._log.debug?.(`reconnect in ${delay}ms (relay reset)`);
641
672
  this._reconnectTimer = setTimeout(() => {
642
673
  this._reconnectTimer = null;
@@ -713,6 +744,9 @@ export class RemotePairingTransport {
713
744
  this._state = newState;
714
745
  if (newState === STATE.CONNECTED) {
715
746
  this._reconnectAttempt = 0;
747
+ this._recentFailureAtMs = [];
748
+ this._circuitOpenUntilMs = 0;
749
+ this._circuitOpenCount = 0;
716
750
  }
717
751
  try {
718
752
  this._onStateChange(newState, prev, info);
@@ -729,6 +763,10 @@ export class RemotePairingTransport {
729
763
  }
730
764
 
731
765
  _fail(err) {
766
+ this._recordReconnectFailure({
767
+ code: CLOSE_FATAL,
768
+ reason: err?.message || "fatal-error",
769
+ });
732
770
  this._setState(STATE.FAILED, { error: err });
733
771
  this._stopPing();
734
772
  this._stopHandshakeTimer();
@@ -748,6 +786,33 @@ export class RemotePairingTransport {
748
786
  this._session = null;
749
787
  this._handshake = null;
750
788
  }
789
+
790
+ _recordReconnectFailure(info = {}) {
791
+ const now = Date.now();
792
+ const windowMs = Math.max(1_000, Number(this._failureWindowMs) || DEFAULT_FAILURE_WINDOW_MS);
793
+ this._recentFailureAtMs = this._recentFailureAtMs.filter((at) => now - at <= windowMs);
794
+ this._recentFailureAtMs.push(now);
795
+ const threshold = Math.max(2, Math.floor(Number(this._failureThreshold) || DEFAULT_FAILURE_THRESHOLD));
796
+ if (this._recentFailureAtMs.length < threshold) return;
797
+
798
+ const baseCooldownMs = Math.max(1_000, Number(this._circuitBreakerMs) || DEFAULT_CIRCUIT_BREAKER_MS);
799
+ const maxCooldownMs = Math.max(baseCooldownMs, Number(this._maxCircuitBreakerMs) || DEFAULT_MAX_CIRCUIT_BREAKER_MS);
800
+ const multiplier = Math.min(16, 2 ** this._circuitOpenCount);
801
+ const cooldownMs = Math.min(maxCooldownMs, baseCooldownMs * multiplier);
802
+ this._circuitOpenCount += 1;
803
+ this._circuitOpenUntilMs = Math.max(this._circuitOpenUntilMs, now + cooldownMs);
804
+ this._recentFailureAtMs = [];
805
+ this._dropNoiseState();
806
+ this._log.warn?.(
807
+ `relay reconnect circuit opened for ${cooldownMs}ms` +
808
+ `${info?.code ? ` after close=${info.code}` : ""}` +
809
+ `${info?.reason ? ` (${String(info.reason).slice(0, 64)})` : ""}`,
810
+ );
811
+ }
812
+
813
+ _circuitDelayMs() {
814
+ return Math.max(0, this._circuitOpenUntilMs - Date.now());
815
+ }
751
816
  }
752
817
 
753
818
  // ---------------------------------------------------------------------------
package/web/sw.js CHANGED
@@ -1,6 +1,7 @@
1
1
  const APP_BUILD_ID = "__VIVEWORKER_APP_BUILD_ID__";
2
2
  const CACHE_NAME = `viveworker-${APP_BUILD_ID}`;
3
3
  const APP_SCRIPT_URL = `/app.js?v=${APP_BUILD_ID}`;
4
+ const APP_STYLE_URL = `/app.css?v=${APP_BUILD_ID}`;
4
5
  const API_ROUTER_URL = `/remote-pairing/api-router.js?v=${APP_BUILD_ID}`;
5
6
  const NOTIFICATION_INTENT_CACHE = "viveworker-notification-intent-v1";
6
7
  const NOTIFICATION_INTENT_PATH = "/__viveworker_notification_intent__";
@@ -14,7 +15,7 @@ const NOTIFICATION_INTENT_PATH = "/__viveworker_notification_intent__";
14
15
  const APP_ASSETS = [
15
16
  "/app",
16
17
  APP_SCRIPT_URL,
17
- "/app.css",
18
+ APP_STYLE_URL,
18
19
  "/app.js",
19
20
  "/i18n.js",
20
21
  "/icons/viveworker-v-pulse.svg",
@@ -31,10 +32,12 @@ const APP_ROUTES = new Set(["/", "/app", "/app/"]);
31
32
  const CACHED_PATHS = new Set(APP_ASSETS.map((asset) => new URL(asset, self.location.origin).pathname));
32
33
  const VERSIONED_CACHE_PATHS = new Set([
33
34
  "/app.js",
35
+ "/app.css",
34
36
  "/remote-pairing/api-router.js",
35
37
  ]);
36
38
  const NETWORK_FIRST_PATHS = new Set([
37
39
  "/app.js",
40
+ "/app.css",
38
41
  "/remote-pairing/api-router.js",
39
42
  ]);
40
43
  const APP_NAVIGATION_NETWORK_TIMEOUT_MS = 1800;
@@ -49,7 +52,7 @@ const APP_SHELL_FALLBACK_HTML = `<!doctype html>
49
52
  <link rel="manifest" href="/manifest.webmanifest">
50
53
  <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
51
54
  <link rel="icon" type="image/png" sizes="192x192" href="/icons/viveworker-icon-192.png">
52
- <link rel="stylesheet" href="/app.css">
55
+ <link rel="stylesheet" href="${APP_STYLE_URL}">
53
56
  <style>
54
57
  html, body { min-height: 100%; margin: 0; background: #081015; color: #f5fbff; }
55
58
  .boot-splash { position: fixed; inset: 0; display: grid; place-items: center; font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif; background: radial-gradient(circle at 50% 18%, rgba(47, 143, 103, 0.22), transparent 30%), linear-gradient(180deg, #081015 0%, #091015 100%); }
@@ -150,14 +153,14 @@ self.addEventListener("push", (event) => {
150
153
  } catch {
151
154
  payload = {
152
155
  title: "viveworker",
153
- body: event.data ? event.data.text() : "A new Codex item is available.",
156
+ body: event.data ? event.data.text() : fallbackNotificationBody(),
154
157
  data: { url: "/app" },
155
158
  };
156
159
  }
157
160
 
158
161
  const title = payload.title || "viveworker";
159
162
  const options = {
160
- body: payload.body || "A new Codex item is available.",
163
+ body: payload.body || fallbackNotificationBody(),
161
164
  tag: payload.tag || "",
162
165
  data: payload.data || { url: "/app" },
163
166
  };
@@ -175,6 +178,13 @@ self.addEventListener("push", (event) => {
175
178
  })());
176
179
  });
177
180
 
181
+ function fallbackNotificationBody() {
182
+ const language = String(self.navigator?.language || "").toLowerCase();
183
+ return language.startsWith("ja")
184
+ ? "viveworker を開いて確認してください。"
185
+ : "Open viveworker for details.";
186
+ }
187
+
178
188
  self.addEventListener("notificationclick", (event) => {
179
189
  event.preventDefault?.();
180
190
  event.notification.close();