viveworker 0.8.10 → 0.9.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/README.md +3 -2
- package/package.json +1 -1
- package/scripts/lib/immutable-json-patch.mjs +142 -0
- package/scripts/lib/ipc-frame-worker.mjs +30 -0
- package/scripts/lib/ipc-message-projector.mjs +221 -0
- package/scripts/lib/length-prefixed-frame-decoder.mjs +128 -0
- package/scripts/lib/remote-pairing/bridge-relay-client.mjs +24 -0
- package/scripts/lib/remote-pairing/orchestrator.mjs +10 -0
- package/scripts/lib/work-items.mjs +179 -0
- package/scripts/moltbook-api.mjs +27 -0
- package/scripts/moltbook-cli.mjs +12 -4
- package/scripts/moltbook-scout-auto.sh +105 -37
- package/scripts/viveworker-bridge.mjs +472 -206
- package/scripts/viveworker.mjs +5 -0
- package/web/app.css +106 -0
- package/web/app.js +183 -28
- package/web/build-id.js +1 -1
- package/web/i18n.js +44 -10
- package/web/remote-pairing/api-router.js +356 -11
- package/web/remote-pairing/transport.js +7 -1
package/web/i18n.js
CHANGED
|
@@ -22,6 +22,7 @@ const translations = {
|
|
|
22
22
|
"common.logOut": "Log out",
|
|
23
23
|
"common.actionable": "Actionable",
|
|
24
24
|
"common.actionNeeded": "Action needed",
|
|
25
|
+
"common.inProgress": "In progress",
|
|
25
26
|
"common.completed": "Completed",
|
|
26
27
|
"common.clear": "Clear",
|
|
27
28
|
"common.cancel": "Cancel",
|
|
@@ -76,10 +77,10 @@ const translations = {
|
|
|
76
77
|
"tab.pending.label": "Pending",
|
|
77
78
|
"tab.pending.eyebrow": "Action queue",
|
|
78
79
|
"tab.pending.description": "Items that need attention right now.",
|
|
79
|
-
"tab.inbox.title": "
|
|
80
|
-
"tab.inbox.label": "
|
|
81
|
-
"tab.inbox.eyebrow": "Work
|
|
82
|
-
"tab.inbox.description": "
|
|
80
|
+
"tab.inbox.title": "Today",
|
|
81
|
+
"tab.inbox.label": "Today",
|
|
82
|
+
"tab.inbox.eyebrow": "Work today",
|
|
83
|
+
"tab.inbox.description": "See what needs you, what is running, and what finished today.",
|
|
83
84
|
"tab.timeline.title": "Timeline",
|
|
84
85
|
"tab.timeline.label": "Timeline",
|
|
85
86
|
"tab.timeline.eyebrow": "Recent activity",
|
|
@@ -257,6 +258,7 @@ const translations = {
|
|
|
257
258
|
"summary.fileEvent": "Review the files {provider} checked or updated in this step.",
|
|
258
259
|
"summary.commandEvent": "Review the command {provider} ran in this step.",
|
|
259
260
|
"summary.diffThread": "Review the file changes for this thread.",
|
|
261
|
+
"summary.inProgress": "{provider} is working on this now.",
|
|
260
262
|
"summary.default": "Open the item to review its details.",
|
|
261
263
|
"intent.approval": "Approve or reject this request.",
|
|
262
264
|
"intent.plan": "Review this plan before {provider} continues.",
|
|
@@ -269,10 +271,25 @@ const translations = {
|
|
|
269
271
|
"intent.userMessage": "Open this message again.",
|
|
270
272
|
"intent.assistantCommentary": "Read the latest working update.",
|
|
271
273
|
"intent.assistantFinal": "Read {provider}'s final reply for this turn.",
|
|
274
|
+
"intent.inProgress": "{provider} is working on this now.",
|
|
275
|
+
"intent.draftReview": "Review and approve this draft.",
|
|
276
|
+
"intent.delegationReview": "Review this delegated task before it runs.",
|
|
277
|
+
"intent.handoffReview": "Review this handoff before it is sent.",
|
|
272
278
|
"timeline.allThreads": "All",
|
|
273
279
|
"timeline.unknownThread": "Unknown thread",
|
|
274
|
-
"inbox.subtab.
|
|
280
|
+
"inbox.subtab.today": "Today",
|
|
281
|
+
"inbox.subtab.pending": "Needs you",
|
|
275
282
|
"inbox.subtab.completed": "Completed",
|
|
283
|
+
"work.section.needsAction": "Needs you",
|
|
284
|
+
"work.section.inProgress": "In progress",
|
|
285
|
+
"work.section.completedToday": "Completed today",
|
|
286
|
+
"work.today.caughtUp": "Nothing needs your attention right now",
|
|
287
|
+
"work.today.emptyTitle": "You're caught up",
|
|
288
|
+
"work.today.emptyCopy": "New decisions and active work will appear here.",
|
|
289
|
+
"work.today.viewAllCompleted": "View all completed ({count} more)",
|
|
290
|
+
"work.health.title": "Connection needs attention",
|
|
291
|
+
"work.health.connectionRequired": "{provider} is not connected. Pending actions may not be delivered.",
|
|
292
|
+
"work.health.degraded": "A work source is temporarily unavailable.",
|
|
276
293
|
"timeline.filterLabel": "Thread filter",
|
|
277
294
|
"timeline.status.approved": "Approved",
|
|
278
295
|
"timeline.status.rejected": "Rejected",
|
|
@@ -1153,6 +1170,7 @@ const translations = {
|
|
|
1153
1170
|
"common.logOut": "ログアウト",
|
|
1154
1171
|
"common.actionable": "対応が必要",
|
|
1155
1172
|
"common.actionNeeded": "対応が必要",
|
|
1173
|
+
"common.inProgress": "進行中",
|
|
1156
1174
|
"common.completed": "完了",
|
|
1157
1175
|
"common.clear": "クリア",
|
|
1158
1176
|
"common.cancel": "キャンセル",
|
|
@@ -1201,10 +1219,10 @@ const translations = {
|
|
|
1201
1219
|
"tab.pending.label": "未対応",
|
|
1202
1220
|
"tab.pending.eyebrow": "対応待ち",
|
|
1203
1221
|
"tab.pending.description": "いま対応が必要な項目です。",
|
|
1204
|
-
"tab.inbox.title": "
|
|
1205
|
-
"tab.inbox.label": "
|
|
1206
|
-
"tab.inbox.eyebrow": "
|
|
1207
|
-
"tab.inbox.description": "
|
|
1222
|
+
"tab.inbox.title": "今日",
|
|
1223
|
+
"tab.inbox.label": "今日",
|
|
1224
|
+
"tab.inbox.eyebrow": "今日の仕事",
|
|
1225
|
+
"tab.inbox.description": "要対応、進行中、今日完了した仕事をまとめて確認できます。",
|
|
1208
1226
|
"tab.timeline.title": "タイムライン",
|
|
1209
1227
|
"tab.timeline.label": "タイムライン",
|
|
1210
1228
|
"tab.timeline.eyebrow": "最近の動き",
|
|
@@ -1378,6 +1396,7 @@ const translations = {
|
|
|
1378
1396
|
"summary.fileEvent": "このステップで触れたファイルを確認します。",
|
|
1379
1397
|
"summary.commandEvent": "このステップで実行したコマンドを確認します。",
|
|
1380
1398
|
"summary.diffThread": "このスレッドのファイル変更を確認します。",
|
|
1399
|
+
"summary.inProgress": "{provider} が現在この作業を進めています。",
|
|
1381
1400
|
"summary.default": "項目を開いて詳細を確認します。",
|
|
1382
1401
|
"intent.approval": "このリクエストを承認するか拒否します。",
|
|
1383
1402
|
"intent.plan": "{provider} が続行する前にこのプランを確認します。",
|
|
@@ -1390,10 +1409,25 @@ const translations = {
|
|
|
1390
1409
|
"intent.userMessage": "このメッセージを開き直します。",
|
|
1391
1410
|
"intent.assistantCommentary": "最新の途中経過を確認します。",
|
|
1392
1411
|
"intent.assistantFinal": "このターンの {provider} の最終回答を確認します。",
|
|
1412
|
+
"intent.inProgress": "{provider} が現在この作業を進めています。",
|
|
1413
|
+
"intent.draftReview": "この下書きを確認して承認します。",
|
|
1414
|
+
"intent.delegationReview": "委任されたタスクを実行前に確認します。",
|
|
1415
|
+
"intent.handoffReview": "送信前にこの引き継ぎ内容を確認します。",
|
|
1393
1416
|
"timeline.allThreads": "すべて",
|
|
1394
1417
|
"timeline.unknownThread": "不明なスレッド",
|
|
1395
|
-
"inbox.subtab.
|
|
1418
|
+
"inbox.subtab.today": "今日",
|
|
1419
|
+
"inbox.subtab.pending": "要対応",
|
|
1396
1420
|
"inbox.subtab.completed": "完了済み",
|
|
1421
|
+
"work.section.needsAction": "要対応",
|
|
1422
|
+
"work.section.inProgress": "進行中",
|
|
1423
|
+
"work.section.completedToday": "今日の完了",
|
|
1424
|
+
"work.today.caughtUp": "現在、対応が必要な項目はありません",
|
|
1425
|
+
"work.today.emptyTitle": "対応はありません",
|
|
1426
|
+
"work.today.emptyCopy": "新しい判断や進行中の仕事がここに表示されます。",
|
|
1427
|
+
"work.today.viewAllCompleted": "完了済みをすべて表示(ほか {count} 件)",
|
|
1428
|
+
"work.health.title": "接続の確認が必要です",
|
|
1429
|
+
"work.health.connectionRequired": "{provider} に接続されていません。保留中の操作を送信できない可能性があります。",
|
|
1430
|
+
"work.health.degraded": "一部の接続先を一時的に利用できません。",
|
|
1397
1431
|
"timeline.filterLabel": "スレッドフィルター",
|
|
1398
1432
|
"timeline.status.approved": "承認済み",
|
|
1399
1433
|
"timeline.status.rejected": "拒否済み",
|
|
@@ -105,6 +105,11 @@ const AUTO_STICKY_LAN_PROBE_TIMEOUT_MS = 900;
|
|
|
105
105
|
const LAN_REACHABILITY_PROBE_TIMEOUT_MS = 700;
|
|
106
106
|
const LAN_REACHABILITY_PROBE_PATH = "/health";
|
|
107
107
|
const RECENT_LAN_OK_GRACE_MS = 60_000;
|
|
108
|
+
const RECENT_LAN_RESTART_RECOVERY_MS = 5_000;
|
|
109
|
+
const RECENT_LAN_RESTART_PROBE_INTERVAL_MS = 500;
|
|
110
|
+
const COLD_LAN_RECOVERY_MS = 35_000;
|
|
111
|
+
const COLD_LAN_RECOVERY_PROBE_INTERVAL_MS = 250;
|
|
112
|
+
const COLD_LAN_RECOVERY_PROBE_TIMEOUT_MS = 1_500;
|
|
108
113
|
const TRANSIENT_LAN_FAILURE_STICKY_MS = 8_000;
|
|
109
114
|
const TRANSIENT_LAN_FAILURE_THRESHOLD = 2;
|
|
110
115
|
// If LAN was healthy moments ago, a single timed-out local request is more
|
|
@@ -118,6 +123,7 @@ const RELAY_CIRCUIT_BREAKER_MS = 60_000;
|
|
|
118
123
|
const PAIRING_STATE_STORAGE_KEY = "viveworker.remote-pairing.state";
|
|
119
124
|
const PAIRING_STATE_SCHEMA_VERSION = 2;
|
|
120
125
|
const PAIRING_STATE_LEGACY_SCHEMA_VERSION = 1;
|
|
126
|
+
const LAN_SUCCESS_STORAGE_KEY = "viveworker.remote-pairing.last-lan-ok-at-ms";
|
|
121
127
|
const ROUTING_STATUS_EVENT = "viveworker:remote-routing-status";
|
|
122
128
|
|
|
123
129
|
// ---------------------------------------------------------------------------
|
|
@@ -148,6 +154,12 @@ let _lastSuccessfulRoute = null;
|
|
|
148
154
|
/** Last time a LAN request completed successfully. */
|
|
149
155
|
let _lastLanOkAtMs = 0;
|
|
150
156
|
|
|
157
|
+
/** Whether a recent LAN success was restored after this PWA module loaded. */
|
|
158
|
+
let _lastLanOkRestoredFromStorage = false;
|
|
159
|
+
|
|
160
|
+
/** A bridge-restart recovery loop should run at most once per PWA module. */
|
|
161
|
+
let _recentLanRestartRecoveryAttempted = false;
|
|
162
|
+
|
|
151
163
|
/** Last time we tried a LAN probe while sticky relay was active. */
|
|
152
164
|
let _lastStickyLanProbeAtMs = 0;
|
|
153
165
|
|
|
@@ -174,6 +186,49 @@ function newTelemetry() {
|
|
|
174
186
|
};
|
|
175
187
|
}
|
|
176
188
|
|
|
189
|
+
function lanSuccessStorage(opts = {}) {
|
|
190
|
+
if (opts.lanSuccessStorage) {
|
|
191
|
+
return opts.lanSuccessStorage;
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
return globalThis.localStorage ?? null;
|
|
195
|
+
} catch {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function restoreRecentLanSuccess(opts = {}, now = nowMs(opts)) {
|
|
201
|
+
if (_lastLanOkAtMs > 0 || _recentLanRestartRecoveryAttempted) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const storage = lanSuccessStorage(opts);
|
|
205
|
+
if (!storage) return;
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
const atMs = Number(storage.getItem(LAN_SUCCESS_STORAGE_KEY));
|
|
209
|
+
if (!Number.isFinite(atMs) || atMs <= 0 || atMs > now || now - atMs > RECENT_LAN_OK_GRACE_MS) {
|
|
210
|
+
storage.removeItem?.(LAN_SUCCESS_STORAGE_KEY);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
_lastLanOkAtMs = atMs;
|
|
214
|
+
_lastLanOkRestoredFromStorage = true;
|
|
215
|
+
} catch {
|
|
216
|
+
// Storage is only a restart hint; routing remains correct without it.
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function rememberLanSuccess(atMs, opts = {}) {
|
|
221
|
+
_lastLanOkAtMs = atMs;
|
|
222
|
+
_lastLanOkRestoredFromStorage = false;
|
|
223
|
+
const storage = lanSuccessStorage(opts);
|
|
224
|
+
if (!storage) return;
|
|
225
|
+
try {
|
|
226
|
+
storage.setItem(LAN_SUCCESS_STORAGE_KEY, String(atMs));
|
|
227
|
+
} catch {
|
|
228
|
+
// Private browsing or a full storage quota should not affect LAN routing.
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
177
232
|
function emitRoutingStatus(phase, opts = {}, detail = {}) {
|
|
178
233
|
if (opts.suppressRoutingStatus === true) {
|
|
179
234
|
return;
|
|
@@ -601,13 +656,15 @@ async function attemptLanFetch(url, init, opts) {
|
|
|
601
656
|
? await Promise.race([fetchPromise, timeoutPromise])
|
|
602
657
|
: await fetchPromise;
|
|
603
658
|
_telemetry.lanOk++;
|
|
604
|
-
|
|
659
|
+
rememberLanSuccess(nowMs(opts), opts);
|
|
605
660
|
_consecutiveLanFailures = 0;
|
|
606
661
|
_lastSuccessfulRoute = "lan";
|
|
607
662
|
_stickyRelayUntilMs = 0;
|
|
608
663
|
_lastStickyLanProbeAtMs = 0;
|
|
609
664
|
resetRelayFailureCircuit();
|
|
610
|
-
|
|
665
|
+
if (opts.keepRelayClientOnLanSuccess !== true) {
|
|
666
|
+
closeRelayClient("lan connected");
|
|
667
|
+
}
|
|
611
668
|
emitRoutingStatus("lan-connected", opts, { url: String(url || "") });
|
|
612
669
|
return { ok: true, response };
|
|
613
670
|
} catch (err) {
|
|
@@ -670,16 +727,19 @@ function shouldConfirmLanReachability(now, opts = {}) {
|
|
|
670
727
|
if (opts.confirmLanReachabilityBeforeRelay === false) {
|
|
671
728
|
return false;
|
|
672
729
|
}
|
|
730
|
+
if (opts.confirmLanReachabilityBeforeRelay === true) {
|
|
731
|
+
return true;
|
|
732
|
+
}
|
|
673
733
|
return _lastLanOkAtMs > 0 && now - _lastLanOkAtMs <= RECENT_LAN_OK_GRACE_MS;
|
|
674
734
|
}
|
|
675
735
|
|
|
676
|
-
async function
|
|
736
|
+
async function recoverLanBeforeRelay(url, init, err, opts = {}) {
|
|
677
737
|
if (!shouldConfirmLanReachability(nowMs(opts), opts)) {
|
|
678
|
-
return false;
|
|
738
|
+
return { recovered: false, suppressRelay: false };
|
|
679
739
|
}
|
|
680
740
|
const path = urlToRelayPath(url);
|
|
681
741
|
if (path === LAN_REACHABILITY_PROBE_PATH) {
|
|
682
|
-
return false;
|
|
742
|
+
return { recovered: false, suppressRelay: false };
|
|
683
743
|
}
|
|
684
744
|
|
|
685
745
|
const probe = await attemptLanFetch(LAN_REACHABILITY_PROBE_PATH, {
|
|
@@ -691,12 +751,251 @@ async function confirmLanUnavailableBeforeRelay(url, err, opts = {}) {
|
|
|
691
751
|
lanTimeoutMs: opts.lanReachabilityProbeTimeoutMs ?? LAN_REACHABILITY_PROBE_TIMEOUT_MS,
|
|
692
752
|
});
|
|
693
753
|
if (!probe.ok) {
|
|
694
|
-
return false;
|
|
754
|
+
return { recovered: false, suppressRelay: false };
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
if (opts.retryLanAfterReachabilityProbe === true) {
|
|
758
|
+
const retry = await attemptLanFetch(url, init, {
|
|
759
|
+
...opts,
|
|
760
|
+
lanTimeoutMs: opts.lanRecoveryTimeoutMs ?? opts.lanTimeoutMs ?? DEFAULT_LAN_TIMEOUT_MS,
|
|
761
|
+
});
|
|
762
|
+
if (retry.ok) {
|
|
763
|
+
return { recovered: true, response: retry.response, suppressRelay: true };
|
|
764
|
+
}
|
|
695
765
|
}
|
|
696
766
|
|
|
697
767
|
_telemetry.lanReachableAfterFailure++;
|
|
698
768
|
suppressRelayAfterRecentLanFailure(url, err, opts);
|
|
699
|
-
return true;
|
|
769
|
+
return { recovered: false, suppressRelay: true };
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function shouldRecoverRecentLanAfterRestart(now, opts = {}) {
|
|
773
|
+
if (opts.recoverRecentLanAfterRestart === false) return false;
|
|
774
|
+
if (!_lastLanOkRestoredFromStorage || _recentLanRestartRecoveryAttempted) return false;
|
|
775
|
+
return _lastLanOkAtMs > 0 && now - _lastLanOkAtMs <= RECENT_LAN_OK_GRACE_MS;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function waitForLanRecoveryDelay(ms, signal) {
|
|
779
|
+
const delayMs = Math.max(0, Number(ms) || 0);
|
|
780
|
+
if (signal?.aborted) {
|
|
781
|
+
return Promise.reject(abortError(signal));
|
|
782
|
+
}
|
|
783
|
+
return new Promise((resolve, reject) => {
|
|
784
|
+
let timer = null;
|
|
785
|
+
const onAbort = () => {
|
|
786
|
+
if (timer) clearTimeout(timer);
|
|
787
|
+
signal?.removeEventListener?.("abort", onAbort);
|
|
788
|
+
reject(abortError(signal));
|
|
789
|
+
};
|
|
790
|
+
timer = setTimeout(() => {
|
|
791
|
+
signal?.removeEventListener?.("abort", onAbort);
|
|
792
|
+
resolve();
|
|
793
|
+
}, delayMs);
|
|
794
|
+
signal?.addEventListener?.("abort", onAbort, { once: true });
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
async function recoverRecentLanAfterRestart(url, init, opts = {}) {
|
|
799
|
+
const startedAtMs = nowMs(opts);
|
|
800
|
+
if (!shouldRecoverRecentLanAfterRestart(startedAtMs, opts)) {
|
|
801
|
+
return { recovered: false };
|
|
802
|
+
}
|
|
803
|
+
_recentLanRestartRecoveryAttempted = true;
|
|
804
|
+
|
|
805
|
+
const recoveryWindowMs = Math.max(
|
|
806
|
+
0,
|
|
807
|
+
Number(opts.recentLanRestartRecoveryMs ?? RECENT_LAN_RESTART_RECOVERY_MS) || 0,
|
|
808
|
+
);
|
|
809
|
+
const deadlineMs = startedAtMs + recoveryWindowMs;
|
|
810
|
+
const path = urlToRelayPath(url);
|
|
811
|
+
|
|
812
|
+
while (true) {
|
|
813
|
+
const probe = await attemptLanFetch(LAN_REACHABILITY_PROBE_PATH, {
|
|
814
|
+
cache: "no-store",
|
|
815
|
+
credentials: "same-origin",
|
|
816
|
+
headers: { Accept: "application/json" },
|
|
817
|
+
}, {
|
|
818
|
+
...opts,
|
|
819
|
+
lanTimeoutMs: opts.lanReachabilityProbeTimeoutMs ?? LAN_REACHABILITY_PROBE_TIMEOUT_MS,
|
|
820
|
+
});
|
|
821
|
+
if (probe.ok) {
|
|
822
|
+
if (path === LAN_REACHABILITY_PROBE_PATH) {
|
|
823
|
+
return { recovered: true, response: probe.response };
|
|
824
|
+
}
|
|
825
|
+
const retry = await attemptLanFetch(url, init, {
|
|
826
|
+
...opts,
|
|
827
|
+
lanTimeoutMs: opts.lanRecoveryTimeoutMs ?? opts.lanTimeoutMs ?? DEFAULT_LAN_TIMEOUT_MS,
|
|
828
|
+
});
|
|
829
|
+
if (retry.ok) {
|
|
830
|
+
return { recovered: true, response: retry.response };
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const remainingMs = deadlineMs - nowMs(opts);
|
|
835
|
+
if (remainingMs <= 0) {
|
|
836
|
+
return { recovered: false };
|
|
837
|
+
}
|
|
838
|
+
await waitForLanRecoveryDelay(
|
|
839
|
+
Math.min(RECENT_LAN_RESTART_PROBE_INTERVAL_MS, remainingMs),
|
|
840
|
+
init.signal,
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function createLinkedAbortScope(parentSignal) {
|
|
846
|
+
if (typeof AbortController === "undefined") {
|
|
847
|
+
return {
|
|
848
|
+
signal: parentSignal,
|
|
849
|
+
abort() {},
|
|
850
|
+
cleanup() {},
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
const controller = new AbortController();
|
|
855
|
+
const onParentAbort = () => controller.abort(parentSignal?.reason);
|
|
856
|
+
if (parentSignal?.aborted) {
|
|
857
|
+
onParentAbort();
|
|
858
|
+
} else {
|
|
859
|
+
parentSignal?.addEventListener?.("abort", onParentAbort, { once: true });
|
|
860
|
+
}
|
|
861
|
+
return {
|
|
862
|
+
signal: controller.signal,
|
|
863
|
+
abort(reason) {
|
|
864
|
+
if (!controller.signal.aborted) controller.abort(reason);
|
|
865
|
+
},
|
|
866
|
+
cleanup() {
|
|
867
|
+
parentSignal?.removeEventListener?.("abort", onParentAbort);
|
|
868
|
+
},
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function routeWinnerAbortError() {
|
|
873
|
+
const err = new Error("alternate route connected");
|
|
874
|
+
err.name = "AbortError";
|
|
875
|
+
return err;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
async function settleRouteAttempt(route, promise) {
|
|
879
|
+
try {
|
|
880
|
+
return { route, ...(await promise) };
|
|
881
|
+
} catch (err) {
|
|
882
|
+
return { route, ok: false, err };
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
async function recoverColdLanWhileRelayConnects(url, init, initialErr, opts = {}) {
|
|
887
|
+
const startedAtMs = nowMs(opts);
|
|
888
|
+
const recoveryWindowMs = Math.max(
|
|
889
|
+
0,
|
|
890
|
+
Number(opts.coldLanRecoveryMs ?? COLD_LAN_RECOVERY_MS) || 0,
|
|
891
|
+
);
|
|
892
|
+
const deadlineMs = startedAtMs + recoveryWindowMs;
|
|
893
|
+
const path = urlToRelayPath(url);
|
|
894
|
+
let lastErr = initialErr;
|
|
895
|
+
|
|
896
|
+
while (true) {
|
|
897
|
+
const probe = await attemptLanFetch(LAN_REACHABILITY_PROBE_PATH, {
|
|
898
|
+
cache: "no-store",
|
|
899
|
+
credentials: "same-origin",
|
|
900
|
+
headers: { Accept: "application/json" },
|
|
901
|
+
signal: init.signal,
|
|
902
|
+
}, {
|
|
903
|
+
...opts,
|
|
904
|
+
keepRelayClientOnLanSuccess: true,
|
|
905
|
+
lanTimeoutMs: opts.lanReachabilityProbeTimeoutMs ?? COLD_LAN_RECOVERY_PROBE_TIMEOUT_MS,
|
|
906
|
+
});
|
|
907
|
+
if (probe.ok) {
|
|
908
|
+
if (path === LAN_REACHABILITY_PROBE_PATH) {
|
|
909
|
+
return { ok: true, response: probe.response };
|
|
910
|
+
}
|
|
911
|
+
const retry = await attemptLanFetch(url, init, {
|
|
912
|
+
...opts,
|
|
913
|
+
lanTimeoutMs: opts.lanRecoveryTimeoutMs ?? opts.lanTimeoutMs ?? DEFAULT_LAN_TIMEOUT_MS,
|
|
914
|
+
});
|
|
915
|
+
if (retry.ok) {
|
|
916
|
+
return { ok: true, response: retry.response };
|
|
917
|
+
}
|
|
918
|
+
lastErr = retry.err;
|
|
919
|
+
} else {
|
|
920
|
+
lastErr = probe.err;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
const remainingMs = deadlineMs - nowMs(opts);
|
|
924
|
+
if (remainingMs <= 0) {
|
|
925
|
+
return { ok: false, err: lastErr };
|
|
926
|
+
}
|
|
927
|
+
await waitForLanRecoveryDelay(
|
|
928
|
+
Math.min(
|
|
929
|
+
Math.max(0, Number(opts.coldLanRecoveryProbeIntervalMs ?? COLD_LAN_RECOVERY_PROBE_INTERVAL_MS) || 0),
|
|
930
|
+
remainingMs,
|
|
931
|
+
),
|
|
932
|
+
init.signal,
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
async function raceRelayWithColdLanRecovery(url, init, initialLanErr, opts = {}) {
|
|
938
|
+
const lanScope = createLinkedAbortScope(init.signal);
|
|
939
|
+
const relayScope = createLinkedAbortScope(init.signal);
|
|
940
|
+
const lanInit = { ...init, signal: lanScope.signal };
|
|
941
|
+
const relayInit = { ...init, signal: relayScope.signal };
|
|
942
|
+
let lanResult = null;
|
|
943
|
+
let relayResult = null;
|
|
944
|
+
|
|
945
|
+
try {
|
|
946
|
+
const lanPromise = settleRouteAttempt(
|
|
947
|
+
"lan",
|
|
948
|
+
recoverColdLanWhileRelayConnects(url, lanInit, initialLanErr, opts),
|
|
949
|
+
);
|
|
950
|
+
emitRoutingStatus("remote-switching", opts, { url: String(url || ""), sticky: false });
|
|
951
|
+
const relayPromise = settleRouteAttempt("relay", attemptRelayFetch(url, relayInit, opts));
|
|
952
|
+
const first = await Promise.race([lanPromise, relayPromise]);
|
|
953
|
+
|
|
954
|
+
if (first.route === "lan") lanResult = first;
|
|
955
|
+
else relayResult = first;
|
|
956
|
+
|
|
957
|
+
if (first.ok) {
|
|
958
|
+
if (first.route === "lan") {
|
|
959
|
+
relayScope.abort(routeWinnerAbortError());
|
|
960
|
+
closeRelayClient("LAN recovery won route race");
|
|
961
|
+
} else {
|
|
962
|
+
lanScope.abort(routeWinnerAbortError());
|
|
963
|
+
}
|
|
964
|
+
return { ok: true, response: first.response, route: first.route };
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const second = await (first.route === "lan" ? relayPromise : lanPromise);
|
|
968
|
+
if (second.route === "lan") lanResult = second;
|
|
969
|
+
else relayResult = second;
|
|
970
|
+
if (second.ok) {
|
|
971
|
+
if (second.route === "lan") {
|
|
972
|
+
relayScope.abort(routeWinnerAbortError());
|
|
973
|
+
closeRelayClient("LAN recovery won route race");
|
|
974
|
+
} else {
|
|
975
|
+
lanScope.abort(routeWinnerAbortError());
|
|
976
|
+
}
|
|
977
|
+
return { ok: true, response: second.response, route: second.route };
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
return {
|
|
981
|
+
ok: false,
|
|
982
|
+
lanErr: lanResult?.err || initialLanErr,
|
|
983
|
+
relayErr: relayResult?.err || null,
|
|
984
|
+
};
|
|
985
|
+
} finally {
|
|
986
|
+
lanScope.cleanup();
|
|
987
|
+
relayScope.cleanup();
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
function throwRouteRaceFailure(result, opts = {}) {
|
|
992
|
+
if (isRemotePairingEnrollmentRequiredError(result.relayErr)) {
|
|
993
|
+
throw result.relayErr;
|
|
994
|
+
}
|
|
995
|
+
if (opts.preferRelayError && result.relayErr) {
|
|
996
|
+
throw result.relayErr;
|
|
997
|
+
}
|
|
998
|
+
throw result.lanErr || result.relayErr || new TypeError("LAN and relay unavailable");
|
|
700
999
|
}
|
|
701
1000
|
|
|
702
1001
|
async function attemptStickyLanProbe(url, init, opts) {
|
|
@@ -980,8 +1279,17 @@ function nowMs(opts) {
|
|
|
980
1279
|
* autoProbeLanWhileSticky?: boolean,
|
|
981
1280
|
* delayRelayAfterRecentLanFailure?: boolean,
|
|
982
1281
|
* confirmLanReachabilityBeforeRelay?: boolean,
|
|
1282
|
+
* retryLanAfterReachabilityProbe?: boolean,
|
|
1283
|
+
* lanRecoveryTimeoutMs?: number,
|
|
1284
|
+
* lanAfterRelayFailureTimeoutMs?: number,
|
|
983
1285
|
* stickyLanProbeTimeoutMs?: number,
|
|
984
1286
|
* lanReachabilityProbeTimeoutMs?: number,
|
|
1287
|
+
* recoverRecentLanAfterRestart?: boolean,
|
|
1288
|
+
* recentLanRestartRecoveryMs?: number,
|
|
1289
|
+
* raceLanRecoveryWithRelay?: boolean,
|
|
1290
|
+
* coldLanRecoveryMs?: number,
|
|
1291
|
+
* coldLanRecoveryProbeIntervalMs?: number,
|
|
1292
|
+
* lanSuccessStorage?: Storage,
|
|
985
1293
|
* }} [opts]
|
|
986
1294
|
* @returns {Promise<{
|
|
987
1295
|
* ok: boolean,
|
|
@@ -994,17 +1302,31 @@ function nowMs(opts) {
|
|
|
994
1302
|
*/
|
|
995
1303
|
export async function routedFetch(url, init = {}, opts = {}) {
|
|
996
1304
|
const t = nowMs(opts);
|
|
1305
|
+
restoreRecentLanSuccess(opts, t);
|
|
997
1306
|
|
|
998
1307
|
// Sticky-relay path: LAN just failed, prefer relay for a while.
|
|
999
1308
|
if (_stickyRelayUntilMs > t) {
|
|
1309
|
+
if (opts.raceLanRecoveryWithRelay === true) {
|
|
1310
|
+
const lan = await attemptLanFetch(url, init, opts);
|
|
1311
|
+
if (lan.ok) return lan.response;
|
|
1312
|
+
const recovery = await recoverLanBeforeRelay(url, init, lan.err, opts);
|
|
1313
|
+
if (recovery.recovered) return recovery.response;
|
|
1314
|
+
const raced = await raceRelayWithColdLanRecovery(url, init, lan.err, opts);
|
|
1315
|
+
if (raced.ok) return raced.response;
|
|
1316
|
+
return throwRouteRaceFailure(raced, opts);
|
|
1317
|
+
}
|
|
1000
1318
|
if (shouldDelayRelayAfterRecentLanFailure(t, opts)) {
|
|
1001
1319
|
const lan = await attemptLanFetch(url, init, opts);
|
|
1002
1320
|
if (lan.ok) return lan.response;
|
|
1321
|
+
const restartRecovery = await recoverRecentLanAfterRestart(url, init, opts);
|
|
1322
|
+
if (restartRecovery.recovered) return restartRecovery.response;
|
|
1003
1323
|
if (shouldDelayRelayAfterRecentLanFailure(nowMs(opts), opts)) {
|
|
1004
1324
|
suppressRelayAfterRecentLanFailure(url, lan.err, opts);
|
|
1005
1325
|
throw lan.err;
|
|
1006
1326
|
}
|
|
1007
|
-
|
|
1327
|
+
const recovery = await recoverLanBeforeRelay(url, init, lan.err, opts);
|
|
1328
|
+
if (recovery.recovered) return recovery.response;
|
|
1329
|
+
if (recovery.suppressRelay) {
|
|
1008
1330
|
throw lan.err;
|
|
1009
1331
|
}
|
|
1010
1332
|
}
|
|
@@ -1012,7 +1334,9 @@ export async function routedFetch(url, init = {}, opts = {}) {
|
|
|
1012
1334
|
if (opts.probeLanWhileSticky === true || autoProbe) {
|
|
1013
1335
|
const lan = await attemptStickyLanProbe(url, init, stickyLanProbeOpts(opts, autoProbe));
|
|
1014
1336
|
if (lan.ok) return lan.response;
|
|
1015
|
-
|
|
1337
|
+
const recovery = await recoverLanBeforeRelay(url, init, lan.err, opts);
|
|
1338
|
+
if (recovery.recovered) return recovery.response;
|
|
1339
|
+
if (recovery.suppressRelay) {
|
|
1016
1340
|
throw lan.err;
|
|
1017
1341
|
}
|
|
1018
1342
|
}
|
|
@@ -1034,11 +1358,22 @@ export async function routedFetch(url, init = {}, opts = {}) {
|
|
|
1034
1358
|
// Happy path: LAN first.
|
|
1035
1359
|
const lan = await attemptLanFetch(url, init, opts);
|
|
1036
1360
|
if (lan.ok) return lan.response;
|
|
1361
|
+
if (opts.raceLanRecoveryWithRelay === true) {
|
|
1362
|
+
const recovery = await recoverLanBeforeRelay(url, init, lan.err, opts);
|
|
1363
|
+
if (recovery.recovered) return recovery.response;
|
|
1364
|
+
const raced = await raceRelayWithColdLanRecovery(url, init, lan.err, opts);
|
|
1365
|
+
if (raced.ok) return raced.response;
|
|
1366
|
+
return throwRouteRaceFailure(raced, opts);
|
|
1367
|
+
}
|
|
1368
|
+
const restartRecovery = await recoverRecentLanAfterRestart(url, init, opts);
|
|
1369
|
+
if (restartRecovery.recovered) return restartRecovery.response;
|
|
1037
1370
|
if (shouldDelayRelayAfterRecentLanFailure(nowMs(opts), opts)) {
|
|
1038
1371
|
suppressRelayAfterRecentLanFailure(url, lan.err, opts);
|
|
1039
1372
|
throw lan.err;
|
|
1040
1373
|
}
|
|
1041
|
-
|
|
1374
|
+
const recovery = await recoverLanBeforeRelay(url, init, lan.err, opts);
|
|
1375
|
+
if (recovery.recovered) return recovery.response;
|
|
1376
|
+
if (recovery.suppressRelay) {
|
|
1042
1377
|
throw lan.err;
|
|
1043
1378
|
}
|
|
1044
1379
|
|
|
@@ -1046,10 +1381,18 @@ export async function routedFetch(url, init = {}, opts = {}) {
|
|
|
1046
1381
|
emitRoutingStatus("remote-switching", opts, { url: String(url || ""), sticky: false });
|
|
1047
1382
|
const r = await attemptRelayFetch(url, init, opts);
|
|
1048
1383
|
if (r.ok) return r.response;
|
|
1384
|
+
// The bridge may have finished restarting while the relay handshake was
|
|
1385
|
+
// failing. Give the direct LAN route one final chance before surfacing an
|
|
1386
|
+
// off-LAN error, even when this request prefers relay diagnostics.
|
|
1387
|
+
const lanAfterRelay = await attemptLanFetch(url, init, {
|
|
1388
|
+
...opts,
|
|
1389
|
+
lanTimeoutMs: opts.lanAfterRelayFailureTimeoutMs ?? opts.lanRecoveryTimeoutMs ?? DEFAULT_LAN_TIMEOUT_MS,
|
|
1390
|
+
});
|
|
1391
|
+
if (lanAfterRelay.ok) return lanAfterRelay.response;
|
|
1049
1392
|
if (isRemotePairingEnrollmentRequiredError(r.err)) throw r.err;
|
|
1050
1393
|
if (opts.preferRelayError && r.err) throw r.err;
|
|
1051
1394
|
|
|
1052
|
-
throw
|
|
1395
|
+
throw lanAfterRelay.err;
|
|
1053
1396
|
}
|
|
1054
1397
|
|
|
1055
1398
|
/**
|
|
@@ -1094,6 +1437,8 @@ export function __resetForTest() {
|
|
|
1094
1437
|
_lastPairingStateStatus = null;
|
|
1095
1438
|
_lastSuccessfulRoute = null;
|
|
1096
1439
|
_lastLanOkAtMs = 0;
|
|
1440
|
+
_lastLanOkRestoredFromStorage = false;
|
|
1441
|
+
_recentLanRestartRecoveryAttempted = false;
|
|
1097
1442
|
_lastStickyLanProbeAtMs = 0;
|
|
1098
1443
|
_consecutiveLanFailures = 0;
|
|
1099
1444
|
_relayFailureAtMs = [];
|
|
@@ -539,7 +539,10 @@ export class RemotePairingTransport {
|
|
|
539
539
|
if (this._closed) return; // user-initiated; close() already settled
|
|
540
540
|
if (this._state === STATE.FAILED) return; // already terminal
|
|
541
541
|
|
|
542
|
+
const stateBeforeClose = this._state;
|
|
542
543
|
const isRelayReset = Number(evt?.code) === CLOSE_RELAY_RESET_SESSION;
|
|
544
|
+
const relayResetDuringNegotiation =
|
|
545
|
+
isRelayReset && (stateBeforeClose === STATE.HANDSHAKING || stateBeforeClose === STATE.RESUMING);
|
|
543
546
|
if (isRelayReset) {
|
|
544
547
|
this._log.debug?.(`relay requested fresh handshake reason=${evt?.reason || ""}`);
|
|
545
548
|
this._dropNoiseState();
|
|
@@ -547,7 +550,10 @@ export class RemotePairingTransport {
|
|
|
547
550
|
|
|
548
551
|
this._log.debug?.(`ws closed code=${evt?.code} reason=${evt?.reason}`);
|
|
549
552
|
this._setState(STATE.DISCONNECTED, { code: evt?.code, reason: evt?.reason });
|
|
550
|
-
|
|
553
|
+
// A 4004 while handshaking/resuming is the relay coordinating a normal
|
|
554
|
+
// state reset after one peer restarted. Counting it towards the reconnect
|
|
555
|
+
// circuit turns two expected reset hops into minutes of needless outage.
|
|
556
|
+
if (Number(evt?.code) !== CLOSE_NORMAL && !relayResetDuringNegotiation) {
|
|
551
557
|
this._recordReconnectFailure({
|
|
552
558
|
code: Number(evt?.code) || 0,
|
|
553
559
|
reason: String(evt?.reason || ""),
|