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.
- package/README.md +64 -4
- package/package.json +1 -1
- package/plugins/viveworker-control-plane/.codex-plugin/plugin.json +1 -1
- package/plugins/viveworker-control-plane/DISTRIBUTION.md +96 -0
- package/scripts/a2a-executor.mjs +261 -7
- package/scripts/a2a-handler.mjs +88 -0
- package/scripts/a2a-relay-client.mjs +6 -0
- package/scripts/com.viveworker.moltbook-scout.plist.sample +2 -2
- package/scripts/moltbook-api.mjs +178 -2
- package/scripts/moltbook-cli.mjs +134 -8
- package/scripts/moltbook-scout-auto.sh +100 -10
- package/scripts/moltbook-watcher.mjs +10 -0
- package/scripts/viveworker-bridge.mjs +1909 -183
- package/web/app.css +147 -1
- package/web/app.js +1205 -131
- package/web/build-id.js +1 -1
- package/web/i18n.js +147 -17
- package/web/index.html +1 -1
- package/web/remote-pairing/api-router.js +250 -13
- package/web/remote-pairing/transport.js +67 -2
- package/web/sw.js +14 -4
|
@@ -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
|
|
17
|
-
* requests skip LAN and go straight to relay.
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
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
|
-
|
|
513
|
-
|
|
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
|
|
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
|
-
|
|
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="
|
|
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() :
|
|
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 ||
|
|
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();
|