viveworker 0.7.0 → 0.8.1

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.
Files changed (54) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/README.md +115 -4
  3. package/package.json +13 -3
  4. package/plugins/viveworker-control-plane/.codex-plugin/plugin.json +49 -0
  5. package/plugins/viveworker-control-plane/.mcp.json +11 -0
  6. package/plugins/viveworker-control-plane/assets/viveworker-logo-v2.png +0 -0
  7. package/plugins/viveworker-control-plane/assets/viveworker-logo-v2.svg +39 -0
  8. package/plugins/viveworker-control-plane/skills/viveworker-control-plane/SKILL.md +83 -0
  9. package/scripts/lib/markdown-render.mjs +128 -1
  10. package/scripts/lib/remote-pairing/README.md +164 -0
  11. package/scripts/lib/remote-pairing/_browser-bundle-entry.mjs +86 -0
  12. package/scripts/lib/remote-pairing/audit.mjs +122 -0
  13. package/scripts/lib/remote-pairing/bridge-relay-client.mjs +737 -0
  14. package/scripts/lib/remote-pairing/control.mjs +156 -0
  15. package/scripts/lib/remote-pairing/envelope.mjs +224 -0
  16. package/scripts/lib/remote-pairing/http-dispatch.mjs +530 -0
  17. package/scripts/lib/remote-pairing/keys-core.mjs +110 -0
  18. package/scripts/lib/remote-pairing/keys.mjs +181 -0
  19. package/scripts/lib/remote-pairing/noise.mjs +436 -0
  20. package/scripts/lib/remote-pairing/orchestrator.mjs +356 -0
  21. package/scripts/lib/remote-pairing/pairings.mjs +446 -0
  22. package/scripts/lib/remote-pairing/rpc.mjs +381 -0
  23. package/scripts/mcp-server.mjs +891 -0
  24. package/scripts/moltbook-scout-auto.sh +16 -8
  25. package/scripts/share-cli.mjs +14 -130
  26. package/scripts/stats-cli.mjs +683 -0
  27. package/scripts/viveworker-bridge.mjs +1676 -127
  28. package/scripts/viveworker.mjs +289 -7
  29. package/skills/viveworker-control-plane/SKILL.md +104 -0
  30. package/skills/viveworker-control-plane/agents/openai.yaml +12 -0
  31. package/templates/CLAUDE.viveworker.md +67 -0
  32. package/web/app.css +683 -9
  33. package/web/app.js +1780 -187
  34. package/web/build-id.js +1 -0
  35. package/web/i18n.js +187 -9
  36. package/web/icons/apple-touch-icon.png +0 -0
  37. package/web/icons/viveworker-icon-192.png +0 -0
  38. package/web/icons/viveworker-icon-512.png +0 -0
  39. package/web/icons/viveworker-v-pulse.svg +16 -1
  40. package/web/index.html +32 -2
  41. package/web/remote-pairing/api-router.js +873 -0
  42. package/web/remote-pairing/keys.js +237 -0
  43. package/web/remote-pairing/pairing-state.js +313 -0
  44. package/web/remote-pairing/rpc-client.js +765 -0
  45. package/web/remote-pairing/transport.js +804 -0
  46. package/web/remote-pairing/wake.js +149 -0
  47. package/web/remote-pairing-test.html +400 -0
  48. package/web/remote-pairing.bundle.js +3 -0
  49. package/web/remote-pairing.bundle.js.LEGAL.txt +15 -0
  50. package/web/remote-pairing.bundle.js.map +7 -0
  51. package/web/sw.js +190 -20
  52. package/web/icons/viveworker-beacon-v.svg +0 -19
  53. package/web/icons/viveworker-icon-1024.png +0 -0
  54. package/web/icons/viveworker-v-check.svg +0 -19
@@ -0,0 +1,873 @@
1
+ /**
2
+ * web/remote-pairing/api-router.js — LAN-first / relay-fallback fetch router.
3
+ *
4
+ * The PWA's `apiGet` / `apiPost` helpers used to call `fetch()` directly
5
+ * against the bridge's same-origin LAN URL. That's the right thing as long
6
+ * as the phone is on LAN. When the phone is off-LAN we want the same call
7
+ * sites to transparently route through the encrypted relay tunnel
8
+ * (Cloudflare Worker + Durable Object → bridge) instead.
9
+ *
10
+ * Strategy: probe-on-failure with sticky relay window.
11
+ *
12
+ * 1. By default, try LAN `fetch()` first.
13
+ * 2. If LAN throws a `TypeError` (network failure, DNS, connect refused —
14
+ * i.e. the bridge isn't reachable), fall back to the relay's
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.
22
+ * 5. AbortError (caller cancelled) is never treated as a LAN failure —
23
+ * we re-throw immediately rather than waste a relay attempt.
24
+ *
25
+ * Singleton RpcClient lifecycle:
26
+ *
27
+ * - Lazy-created on the first relay attempt that has a valid pairing
28
+ * record in localStorage (`pairing-state.js`).
29
+ * - Reused across every subsequent relay fetch — opening a Noise IK
30
+ * handshake per call would be absurd.
31
+ * - Torn down + rebuilt when `pairingId` changes (re-pair under a
32
+ * different bridge identity). Detected by comparing the current
33
+ * localStorage record's pairingId to the one the live client was
34
+ * built for.
35
+ * - Tied to platform wake events (`visibilitychange`, `online`,
36
+ * `pageshow` w/ persisted, SW push of type "remote-pairing-wake")
37
+ * via `bindWakeEvents()` — those nudge `client.kick()` so a phone
38
+ * that just came out of background reconnects instead of waiting
39
+ * for the next outbound fetch to discover the dead WS.
40
+ *
41
+ * Response shape:
42
+ *
43
+ * The router returns a *minimal Fetch-Response-compatible* object on
44
+ * success — `{ ok, status, statusText, headers, json(), text(),
45
+ * arrayBuffer() }` — so the existing `apiGet` / `apiPost` code paths in
46
+ * `app.js` work unchanged, and binary assets such as timeline images can
47
+ * also ride the relay. HTTP error statuses (4xx/5xx) are NOT thrown; the
48
+ * caller checks `response.ok` exactly as with native fetch.
49
+ *
50
+ * Out of scope for this module:
51
+ *
52
+ * - Streaming uploads. FormData uploads are supported by serializing
53
+ * them to a buffered multipart body before handing them to the RPC
54
+ * layer; truly streaming request bodies remain out of scope.
55
+ * - Server-Sent Events / streaming responses. The RpcClient delivers
56
+ * whole bodies; long-poll endpoints work fine, but `text/event-stream`
57
+ * does not.
58
+ * - Bridge-side relay request authorization. The bridge is assumed to
59
+ * unwrap incoming Noise frames and dispatch them to the same HTTP
60
+ * handlers, with the channel binding standing in for cookie auth.
61
+ * If a relayed request comes back as 401, that's a bridge gap to
62
+ * fix on that side, not here.
63
+ */
64
+
65
+ import {
66
+ RemotePairingRpcClient,
67
+ RpcTransportError,
68
+ RpcTransportFailedError,
69
+ } from "./rpc-client.js";
70
+ import { bindWakeEvents } from "./wake.js";
71
+ import { ensureIdentityKeypair, hexToBytes } from "./keys.js";
72
+ import { loadPairingState } from "./pairing-state.js";
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Constants
76
+ // ---------------------------------------------------------------------------
77
+
78
+ /**
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.
84
+ */
85
+ const STICKY_RELAY_MS = 5 * 60 * 1000;
86
+
87
+ /**
88
+ * Default timeout for a single relay fetch. Matches RpcClient's own default
89
+ * (60s) — long-poll inboxes need this much; ordinary mutating calls return
90
+ * sub-second.
91
+ */
92
+ const DEFAULT_RELAY_TIMEOUT_MS = 60_000;
93
+
94
+ /**
95
+ * Default timeout for the LAN probe before falling back to relay.
96
+ * Off-LAN `.local` fetches can hang for a long time on iOS instead of failing
97
+ * quickly, which leaves boot() stuck on the splash screen. Keep this short:
98
+ * LAN is the fast path, and slow/unreachable LAN should become relay.
99
+ */
100
+ const DEFAULT_LAN_TIMEOUT_MS = 2_500;
101
+ const PAIRING_STATE_STORAGE_KEY = "viveworker.remote-pairing.state";
102
+ const PAIRING_STATE_SCHEMA_VERSION = 2;
103
+ const PAIRING_STATE_LEGACY_SCHEMA_VERSION = 1;
104
+ const ROUTING_STATUS_EVENT = "viveworker:remote-routing-status";
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Module state (singleton client + telemetry)
108
+ // ---------------------------------------------------------------------------
109
+
110
+ /** @type {import("./rpc-client.js").RemotePairingRpcClient | null} */
111
+ let _client = null;
112
+
113
+ /** pairing capability the live `_client` was built for. Used to detect re-pair. */
114
+ let _clientPairingKey = null;
115
+
116
+ /** Returned by `bindWakeEvents`; called on client teardown. */
117
+ let _wakeUnbind = null;
118
+
119
+ /** Until this timestamp (ms), prefer relay over LAN. 0 = no preference. */
120
+ let _stickyRelayUntilMs = 0;
121
+
122
+ /** Counters for the diagnostics overlay / tests. */
123
+ let _telemetry = newTelemetry();
124
+
125
+ /** Last reason getOrInitClient could not build a relay client. */
126
+ let _lastPairingStateStatus = null;
127
+
128
+ /** Last transport route that completed successfully. */
129
+ let _lastSuccessfulRoute = null;
130
+
131
+ function newTelemetry() {
132
+ return {
133
+ lanOk: 0,
134
+ lanFail: 0,
135
+ relayOk: 0,
136
+ relayFail: 0,
137
+ lastLanFailAt: 0,
138
+ lastRelayFailAt: 0,
139
+ clientResets: 0,
140
+ };
141
+ }
142
+
143
+ function emitRoutingStatus(phase, opts = {}, detail = {}) {
144
+ if (opts.suppressRoutingStatus === true) {
145
+ return;
146
+ }
147
+ const payload = {
148
+ phase,
149
+ ...detail,
150
+ atMs: nowMs(opts),
151
+ };
152
+ if (typeof opts.onRouteStatus === "function") {
153
+ try {
154
+ opts.onRouteStatus(payload);
155
+ } catch {
156
+ // Status updates must never affect network routing.
157
+ }
158
+ }
159
+ const target = globalThis;
160
+ if (
161
+ typeof target?.dispatchEvent === "function" &&
162
+ typeof target?.CustomEvent === "function"
163
+ ) {
164
+ try {
165
+ target.dispatchEvent(new target.CustomEvent(ROUTING_STATUS_EVENT, { detail: payload }));
166
+ } catch {
167
+ // Ignore non-browser/test environments.
168
+ }
169
+ }
170
+ }
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // RpcClient lifecycle
174
+ // ---------------------------------------------------------------------------
175
+
176
+ /**
177
+ * Build a fresh `RemotePairingRpcClient` for the given pairing record.
178
+ * Returns null if we can't (no IndexedDB, no identity keypair, etc.).
179
+ *
180
+ * Does NOT await `connect()` — the rpc layer queues the first `fetch()`
181
+ * until the transport is up, so callers don't block here on a slow
182
+ * relay handshake.
183
+ *
184
+ * @param {ReturnType<typeof loadPairingState>} record
185
+ * @param {{
186
+ * logger?: object,
187
+ * RemotePairingRpcClient?: typeof RemotePairingRpcClient,
188
+ * ensureIdentityKeypair?: typeof ensureIdentityKeypair,
189
+ * }} opts
190
+ */
191
+ async function buildRpcClient(record, opts = {}) {
192
+ const ensureKp = opts.ensureIdentityKeypair ?? ensureIdentityKeypair;
193
+ let kp;
194
+ try {
195
+ kp = await ensureKp();
196
+ } catch (err) {
197
+ console.warn("[api-router] ensureIdentityKeypair failed:", err?.message || err);
198
+ return null;
199
+ }
200
+ if (!kp || !kp.priv || !kp.pub) {
201
+ console.warn("[api-router] no identity keypair available");
202
+ return null;
203
+ }
204
+
205
+ let remoteStatic;
206
+ try {
207
+ remoteStatic = hexToBytes(record.bridgePubHex);
208
+ } catch (err) {
209
+ console.warn("[api-router] bad bridgePubHex on pairing record:", err?.message);
210
+ return null;
211
+ }
212
+
213
+ const ClientCtor = opts.RemotePairingRpcClient ?? RemotePairingRpcClient;
214
+ const logger = opts.logger ?? console;
215
+
216
+ let client;
217
+ try {
218
+ client = new ClientCtor({
219
+ relayUrl: record.relayUrl,
220
+ pairingId: record.pairingId,
221
+ relayToken: record.relayToken,
222
+ identityKeypair: { priv: kp.priv, pub: kp.pub },
223
+ remoteStatic,
224
+ logger,
225
+ onStateChange: (next, previous, info = {}) => {
226
+ emitRoutingStatus("remote-transport-state", opts, {
227
+ state: String(next || ""),
228
+ previousState: String(previous || ""),
229
+ reason: String(info?.reason || ""),
230
+ code: Number(info?.code) || undefined,
231
+ resumed: typeof info?.resumed === "boolean" ? info.resumed : undefined,
232
+ });
233
+ },
234
+ onError: (err) => {
235
+ emitRoutingStatus("remote-transport-error", opts, {
236
+ reason: err?.message || String(err || ""),
237
+ });
238
+ logger.warn?.("[api-router] rpc onError:", err?.message || err);
239
+ },
240
+ });
241
+ } catch (err) {
242
+ console.warn("[api-router] RpcClient construction failed:", err?.message);
243
+ return null;
244
+ }
245
+
246
+ // Kick off the connection in the background. The first fetch() call
247
+ // blocks on the transport state machine internally, so awaiting here
248
+ // would just delay the caller without changing behavior.
249
+ client.connect().catch((err) => {
250
+ logger.warn?.("[api-router] rpc connect failed:", err?.message || err);
251
+ });
252
+
253
+ return client;
254
+ }
255
+
256
+ /**
257
+ * Return the singleton `RemotePairingRpcClient`, lazy-creating it if
258
+ * needed and resetting it if the local pairing record has changed.
259
+ * Returns null if no pairing exists or client construction failed.
260
+ *
261
+ * @param {object} [opts] forwarded to `buildRpcClient`
262
+ */
263
+ async function getOrInitClient(opts = {}) {
264
+ const record = (opts.loadPairingState ?? loadPairingState)();
265
+ if (!record) {
266
+ // No pairing → nothing to do. Tear down any stale client (e.g. user
267
+ // unpaired between calls).
268
+ if (_client) {
269
+ _telemetry.clientResets++;
270
+ try { _client.close(); } catch { /* ignore */ }
271
+ _client = null;
272
+ _clientPairingKey = null;
273
+ if (_wakeUnbind) { try { _wakeUnbind(); } catch { /* ignore */ } _wakeUnbind = null; }
274
+ }
275
+ _lastPairingStateStatus = inspectPairingStateForRelay(opts);
276
+ return null;
277
+ }
278
+ _lastPairingStateStatus = { status: "ready", needsEnrollment: false };
279
+
280
+ const pairingKey = `${record.pairingId}:${record.relayToken}`;
281
+
282
+ // Re-pair under a different bridge/token → throw away the old client so we
283
+ // re-handshake with the new bridge static key and relay capability.
284
+ if (_client && _clientPairingKey && _clientPairingKey !== pairingKey) {
285
+ _telemetry.clientResets++;
286
+ try { _client.close(); } catch { /* ignore */ }
287
+ _client = null;
288
+ if (_wakeUnbind) { try { _wakeUnbind(); } catch { /* ignore */ } _wakeUnbind = null; }
289
+ }
290
+
291
+ if (_client) return _client;
292
+
293
+ _client = await buildRpcClient(record, opts);
294
+ if (!_client) {
295
+ _lastPairingStateStatus = { status: "client-unavailable", needsEnrollment: false };
296
+ return null;
297
+ }
298
+ _clientPairingKey = pairingKey;
299
+
300
+ // Wire wake events. If `bindWakeEvents` throws (e.g. weird test env),
301
+ // log and continue — we'd rather have a working client without
302
+ // automatic wake than no client at all.
303
+ const bind = opts.bindWakeEvents ?? bindWakeEvents;
304
+ try {
305
+ _wakeUnbind = bind(_client, {
306
+ onWake: (reason) => {
307
+ (opts.logger ?? console).debug?.("[api-router] wake:", reason);
308
+ },
309
+ });
310
+ } catch (err) {
311
+ console.warn("[api-router] bindWakeEvents failed:", err?.message);
312
+ _wakeUnbind = null;
313
+ }
314
+
315
+ return _client;
316
+ }
317
+
318
+ function inspectPairingStateForRelay(opts) {
319
+ const inspect = opts.inspectPairingState ?? inspectBrowserPairingState;
320
+ try {
321
+ return inspect();
322
+ } catch {
323
+ return { status: "malformed", needsEnrollment: true };
324
+ }
325
+ }
326
+
327
+ function inspectBrowserPairingState() {
328
+ let store;
329
+ try {
330
+ store = globalThis.localStorage ?? null;
331
+ } catch {
332
+ return { status: "storage-unavailable", needsEnrollment: false };
333
+ }
334
+ if (!store) {
335
+ return { status: "storage-unavailable", needsEnrollment: false };
336
+ }
337
+ let raw;
338
+ try {
339
+ raw = store.getItem(PAIRING_STATE_STORAGE_KEY);
340
+ } catch {
341
+ return { status: "storage-unavailable", needsEnrollment: false };
342
+ }
343
+ if (!raw) {
344
+ return { status: "missing", needsEnrollment: true };
345
+ }
346
+ let parsed;
347
+ try {
348
+ parsed = JSON.parse(raw);
349
+ } catch {
350
+ return { status: "malformed", needsEnrollment: true };
351
+ }
352
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
353
+ return { status: "malformed", needsEnrollment: true };
354
+ }
355
+ if (parsed.version === PAIRING_STATE_LEGACY_SCHEMA_VERSION) {
356
+ return { status: "legacy-v1", needsEnrollment: true };
357
+ }
358
+ if (parsed.version !== PAIRING_STATE_SCHEMA_VERSION) {
359
+ return { status: "unsupported-version", needsEnrollment: true };
360
+ }
361
+ if (typeof parsed.relayToken !== "string" || parsed.relayToken.length === 0) {
362
+ return { status: "missing-token", needsEnrollment: true };
363
+ }
364
+ return { status: "ready", needsEnrollment: false };
365
+ }
366
+
367
+ function remotePairingEnrollmentRequiredError(info) {
368
+ const status = info?.status || "missing";
369
+ const err = new Error(
370
+ "Remote connection needs to be refreshed. Open viveworker once on the same Wi-Fi as your PC, then try again off-LAN.",
371
+ );
372
+ err.name = "RemotePairingEnrollmentRequiredError";
373
+ err.code = "remote-pairing-enrollment-required";
374
+ err.reason = status;
375
+ return err;
376
+ }
377
+
378
+ function relayClientUnavailableError(info) {
379
+ if (info?.needsEnrollment !== false) {
380
+ return remotePairingEnrollmentRequiredError(info);
381
+ }
382
+ const err = new Error("remote relay client unavailable");
383
+ err.name = "RemotePairingUnavailableError";
384
+ err.code = "remote-pairing-unavailable";
385
+ err.reason = info?.status || "client-unavailable";
386
+ return err;
387
+ }
388
+
389
+ function isRemotePairingEnrollmentRequiredError(err) {
390
+ return err?.code === "remote-pairing-enrollment-required" ||
391
+ err?.name === "RemotePairingEnrollmentRequiredError";
392
+ }
393
+
394
+ function resetRelayClientForRetry() {
395
+ if (_client) {
396
+ _telemetry.clientResets++;
397
+ try { _client.close(); } catch { /* ignore */ }
398
+ }
399
+ _client = null;
400
+ _clientPairingKey = null;
401
+ if (_wakeUnbind) {
402
+ try { _wakeUnbind(); } catch { /* ignore */ }
403
+ }
404
+ _wakeUnbind = null;
405
+ }
406
+
407
+ // ---------------------------------------------------------------------------
408
+ // Response adapter — RpcResponse → minimal Fetch-Response shape
409
+ // ---------------------------------------------------------------------------
410
+
411
+ /**
412
+ * Adapt an `RpcResponse` (from rpc-client.js) into the subset of fetch's
413
+ * Response interface that `apiGet` / `apiPost` actually touch:
414
+ *
415
+ * - `ok` bool, derived from status
416
+ * - `status` number
417
+ * - `statusText` string (rpc layer doesn't carry one — empty)
418
+ * - `headers` plain object (rpc carries headers as a Record)
419
+ * - `json()` async, throws on non-JSON body
420
+ * - `text()` async
421
+ * - `arrayBuffer()` async
422
+ *
423
+ * Other Fetch-Response APIs (Body.bodyUsed, Body.body stream, redirected,
424
+ * type, url, clone()) are deliberately not provided — none of them are
425
+ * used by apiGet/apiPost, and faking them would just hide bugs.
426
+ *
427
+ * @param {import("./rpc-client.js").RpcResponse} rpcRes
428
+ */
429
+ function adaptRpcResponse(rpcRes) {
430
+ const status = Number(rpcRes.status) || 0;
431
+ return {
432
+ ok: status >= 200 && status < 300,
433
+ status,
434
+ statusText: "",
435
+ headers: rpcRes.headers ?? {},
436
+ json: async () => rpcRes.json(),
437
+ text: async () => rpcRes.text(),
438
+ arrayBuffer: async () => rpcRes.arrayBuffer(),
439
+ };
440
+ }
441
+
442
+ /**
443
+ * Convert a same-origin URL (as written in app.js, e.g. "/api/items/foo"
444
+ * or "https://localhost:8810/api/items/foo") into the path+query string
445
+ * the relay protocol expects (e.g. "/api/items/foo?bar=baz").
446
+ */
447
+ function urlToRelayPath(url) {
448
+ if (typeof url !== "string") {
449
+ throw new TypeError("url must be a string");
450
+ }
451
+ // URL constructor needs an absolute base. In the browser we have one
452
+ // via location; in tests we accept relative URLs and synthesize a base.
453
+ const base = (typeof location !== "undefined" && location.origin)
454
+ ? location.origin
455
+ : "https://localhost";
456
+ const u = new URL(url, base);
457
+ return u.pathname + u.search;
458
+ }
459
+
460
+ // ---------------------------------------------------------------------------
461
+ // Inner LAN / relay fetch attempts
462
+ // ---------------------------------------------------------------------------
463
+
464
+ async function attemptLanFetch(url, init, opts) {
465
+ emitRoutingStatus("lan-checking", opts, { url: String(url || "") });
466
+ const fetchImpl = opts.fetch ?? globalThis.fetch.bind(globalThis);
467
+ const timeoutMs = opts.lanTimeoutMs ?? DEFAULT_LAN_TIMEOUT_MS;
468
+ const timeoutEnabled = Number.isFinite(timeoutMs) && timeoutMs > 0 && typeof AbortController !== "undefined";
469
+ let didTimeout = false;
470
+ let timer = null;
471
+ let controller = null;
472
+ let externalAbortHandler = null;
473
+ let fetchInit = init;
474
+ let timeoutPromise = null;
475
+
476
+ if (timeoutEnabled) {
477
+ if (init.signal?.aborted) {
478
+ throw abortError(init.signal);
479
+ }
480
+ controller = new AbortController();
481
+ if (init.signal) {
482
+ externalAbortHandler = () => controller.abort(init.signal.reason);
483
+ init.signal.addEventListener("abort", externalAbortHandler, { once: true });
484
+ }
485
+ timeoutPromise = new Promise((_, reject) => {
486
+ timer = setTimeout(() => {
487
+ didTimeout = true;
488
+ const err = new TypeError("LAN fetch timed out");
489
+ try { controller.abort(err); } catch { /* ignore */ }
490
+ reject(err);
491
+ }, timeoutMs);
492
+ });
493
+ fetchInit = { ...init, signal: controller.signal };
494
+ }
495
+
496
+ try {
497
+ const fetchPromise = fetchImpl(url, fetchInit);
498
+ const response = timeoutPromise
499
+ ? await Promise.race([fetchPromise, timeoutPromise])
500
+ : await fetchPromise;
501
+ _telemetry.lanOk++;
502
+ _lastSuccessfulRoute = "lan";
503
+ emitRoutingStatus("lan-connected", opts, { url: String(url || "") });
504
+ return { ok: true, response };
505
+ } catch (err) {
506
+ // Caller cancelled → re-throw so we don't keep wasting time on relay.
507
+ if (err && err.name === "AbortError" && !didTimeout) {
508
+ throw err;
509
+ }
510
+ const lanErr = didTimeout ? new TypeError("LAN fetch timed out") : err;
511
+ _telemetry.lanFail++;
512
+ _telemetry.lastLanFailAt = nowMs(opts);
513
+ _stickyRelayUntilMs = _telemetry.lastLanFailAt + STICKY_RELAY_MS;
514
+ emitRoutingStatus("lan-failed", opts, {
515
+ url: String(url || ""),
516
+ reason: lanErr?.message || String(lanErr),
517
+ });
518
+ return { ok: false, err: lanErr };
519
+ } finally {
520
+ if (timer) clearTimeout(timer);
521
+ if (init.signal && externalAbortHandler) {
522
+ init.signal.removeEventListener("abort", externalAbortHandler);
523
+ }
524
+ }
525
+ }
526
+
527
+ function abortError(signal) {
528
+ if (signal?.reason instanceof Error) {
529
+ const err = signal.reason;
530
+ if (!err.name) err.name = "AbortError";
531
+ return err;
532
+ }
533
+ const err = new Error("aborted");
534
+ err.name = "AbortError";
535
+ return err;
536
+ }
537
+
538
+ /**
539
+ * Whether the request body is a shape the relay's RPC layer can carry.
540
+ * The rpc layer accepts string / Uint8Array / ArrayBuffer / null.
541
+ * FormData is handled separately by serializing it to multipart bytes.
542
+ */
543
+ function isRelayCompatibleBody(body) {
544
+ if (body === null || body === undefined) return true;
545
+ if (typeof body === "string") return true;
546
+ if (body instanceof Uint8Array) return true;
547
+ if (body instanceof ArrayBuffer) return true;
548
+ return false;
549
+ }
550
+
551
+ function isFormDataBody(body) {
552
+ return typeof FormData !== "undefined" && body instanceof FormData;
553
+ }
554
+
555
+ function headersToPlainObject(headers) {
556
+ const out = {};
557
+ if (!headers) return out;
558
+
559
+ if (typeof Headers !== "undefined" && headers instanceof Headers) {
560
+ headers.forEach((value, key) => {
561
+ out[String(key).toLowerCase()] = String(value);
562
+ });
563
+ return out;
564
+ }
565
+
566
+ if (Array.isArray(headers)) {
567
+ for (const pair of headers) {
568
+ if (!pair || pair.length < 2) continue;
569
+ out[String(pair[0]).toLowerCase()] = String(pair[1]);
570
+ }
571
+ return out;
572
+ }
573
+
574
+ if (typeof headers[Symbol.iterator] === "function" && typeof headers !== "string") {
575
+ for (const [key, value] of headers) {
576
+ out[String(key).toLowerCase()] = String(value);
577
+ }
578
+ return out;
579
+ }
580
+
581
+ for (const [key, value] of Object.entries(headers)) {
582
+ if (value === undefined || value === null) continue;
583
+ out[String(key).toLowerCase()] = String(value);
584
+ }
585
+ return out;
586
+ }
587
+
588
+ function setRelayHeader(headers, name, value) {
589
+ headers[String(name).toLowerCase()] = String(value);
590
+ }
591
+
592
+ function escapeMultipartHeaderValue(value) {
593
+ return String(value)
594
+ .replace(/\\/g, "\\\\")
595
+ .replace(/"/g, "\\\"")
596
+ .replace(/[\r\n]/g, " ");
597
+ }
598
+
599
+ function safeMultipartContentType(value) {
600
+ const type = String(value || "application/octet-stream").replace(/[\r\n]/g, "").trim();
601
+ return type || "application/octet-stream";
602
+ }
603
+
604
+ function randomBoundary() {
605
+ const bytes = new Uint8Array(12);
606
+ const cryptoObj = globalThis.crypto;
607
+ if (cryptoObj?.getRandomValues) {
608
+ cryptoObj.getRandomValues(bytes);
609
+ } else {
610
+ for (let i = 0; i < bytes.length; i++) {
611
+ bytes[i] = Math.floor(Math.random() * 256);
612
+ }
613
+ }
614
+ return `----viveworker-relay-${Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")}`;
615
+ }
616
+
617
+ function concatBytes(chunks, totalLength) {
618
+ const out = new Uint8Array(totalLength);
619
+ let offset = 0;
620
+ for (const chunk of chunks) {
621
+ out.set(chunk, offset);
622
+ offset += chunk.byteLength;
623
+ }
624
+ return out;
625
+ }
626
+
627
+ function isBlobLike(value) {
628
+ return Boolean(
629
+ value &&
630
+ typeof value === "object" &&
631
+ typeof value.arrayBuffer === "function" &&
632
+ typeof value.type === "string",
633
+ );
634
+ }
635
+
636
+ async function encodeFormDataForRelay(formData, signal) {
637
+ if (!formData || typeof formData.entries !== "function") {
638
+ throw new TypeError("relay FormData body must be iterable");
639
+ }
640
+
641
+ const boundary = randomBoundary();
642
+ const encoder = new TextEncoder();
643
+ const chunks = [];
644
+ let totalLength = 0;
645
+ const pushText = (text) => {
646
+ const bytes = encoder.encode(text);
647
+ chunks.push(bytes);
648
+ totalLength += bytes.byteLength;
649
+ };
650
+ const pushBytes = (bytes) => {
651
+ chunks.push(bytes);
652
+ totalLength += bytes.byteLength;
653
+ };
654
+
655
+ for (const [name, value] of formData.entries()) {
656
+ if (signal?.aborted) throw abortError(signal);
657
+
658
+ pushText(`--${boundary}\r\n`);
659
+ if (isBlobLike(value)) {
660
+ const filename = value.name || "blob";
661
+ pushText(
662
+ `Content-Disposition: form-data; name="${escapeMultipartHeaderValue(name)}"; filename="${escapeMultipartHeaderValue(filename)}"\r\n`,
663
+ );
664
+ pushText(`Content-Type: ${safeMultipartContentType(value.type)}\r\n\r\n`);
665
+ pushBytes(new Uint8Array(await value.arrayBuffer()));
666
+ pushText("\r\n");
667
+ } else {
668
+ pushText(`Content-Disposition: form-data; name="${escapeMultipartHeaderValue(name)}"\r\n\r\n`);
669
+ pushText(String(value));
670
+ pushText("\r\n");
671
+ }
672
+ }
673
+
674
+ pushText(`--${boundary}--\r\n`);
675
+ return {
676
+ body: concatBytes(chunks, totalLength),
677
+ contentType: `multipart/form-data; boundary=${boundary}`,
678
+ };
679
+ }
680
+
681
+ async function attemptRelayFetch(url, init, opts) {
682
+ emitRoutingStatus("remote-connecting", opts, { url: String(url || "") });
683
+ const client = await getOrInitClient(opts);
684
+ if (!client) {
685
+ return { ok: false, err: relayClientUnavailableError(_lastPairingStateStatus) };
686
+ }
687
+
688
+ let body = init.body ?? null;
689
+ const headers = headersToPlainObject(init.headers ?? {});
690
+ if (isFormDataBody(body)) {
691
+ try {
692
+ const encoded = await encodeFormDataForRelay(body, init.signal);
693
+ body = encoded.body;
694
+ setRelayHeader(headers, "content-type", encoded.contentType);
695
+ setRelayHeader(headers, "content-length", String(encoded.body.byteLength));
696
+ } catch (err) {
697
+ return { ok: false, err };
698
+ }
699
+ }
700
+ if (!isRelayCompatibleBody(body)) {
701
+ // Defensive — FormData is encoded above, so reaching this branch means
702
+ // a programmer handed us a body shape the RPC layer still cannot carry.
703
+ return {
704
+ ok: false,
705
+ err: new TypeError("relay body must be string, Uint8Array, ArrayBuffer, or null"),
706
+ };
707
+ }
708
+
709
+ let path;
710
+ try {
711
+ path = urlToRelayPath(url);
712
+ } catch (err) {
713
+ return { ok: false, err };
714
+ }
715
+
716
+ try {
717
+ const rpcRes = await client.fetch({
718
+ method: (init.method || "GET").toUpperCase(),
719
+ path,
720
+ headers,
721
+ body,
722
+ signal: init.signal,
723
+ timeoutMs: opts.timeoutMs ?? DEFAULT_RELAY_TIMEOUT_MS,
724
+ });
725
+ _telemetry.relayOk++;
726
+ _lastSuccessfulRoute = "relay";
727
+ emitRoutingStatus("remote-connected", opts, { url: String(url || "") });
728
+ return { ok: true, response: adaptRpcResponse(rpcRes) };
729
+ } catch (err) {
730
+ if (err && err.name === "AbortError") throw err;
731
+ if (!opts.__relayRetried && shouldResetRelayClient(err)) {
732
+ resetRelayClientForRetry();
733
+ return attemptRelayFetch(url, init, { ...opts, __relayRetried: true });
734
+ }
735
+ _telemetry.relayFail++;
736
+ _telemetry.lastRelayFailAt = nowMs(opts);
737
+ emitRoutingStatus("remote-failed", opts, {
738
+ url: String(url || ""),
739
+ reason: err?.message || String(err),
740
+ });
741
+ return { ok: false, err };
742
+ }
743
+ }
744
+
745
+ function shouldResetRelayClient(err) {
746
+ return err instanceof RpcTransportError ||
747
+ err instanceof RpcTransportFailedError ||
748
+ err?.name === "RpcTransportError" ||
749
+ err?.name === "RpcTransportFailedError";
750
+ }
751
+
752
+ function nowMs(opts) {
753
+ return (opts.now ?? Date.now)();
754
+ }
755
+
756
+ // ---------------------------------------------------------------------------
757
+ // Public API
758
+ // ---------------------------------------------------------------------------
759
+
760
+ /**
761
+ * Fetch-shaped router. Returns a Fetch-Response-compatible object on
762
+ * success; throws on transport-level failure of *both* paths.
763
+ *
764
+ * HTTP error statuses (4xx/5xx) are NOT thrown — the caller checks
765
+ * `response.ok`, exactly as with native fetch.
766
+ *
767
+ * `opts` is for tests / advanced callers; production code calls with a
768
+ * single `init` like normal fetch.
769
+ *
770
+ * @param {string} url
771
+ * @param {RequestInit} [init]
772
+ * @param {{
773
+ * fetch?: typeof globalThis.fetch,
774
+ * now?: () => number,
775
+ * logger?: object,
776
+ * timeoutMs?: number,
777
+ * lanTimeoutMs?: number,
778
+ * loadPairingState?: typeof loadPairingState,
779
+ * inspectPairingState?: () => { status?: string, needsEnrollment?: boolean },
780
+ * ensureIdentityKeypair?: typeof ensureIdentityKeypair,
781
+ * RemotePairingRpcClient?: typeof RemotePairingRpcClient,
782
+ * bindWakeEvents?: typeof bindWakeEvents,
783
+ * onRouteStatus?: (event: { phase: string, atMs: number, url?: string, sticky?: boolean, reason?: string, state?: string, previousState?: string, code?: number, resumed?: boolean }) => void,
784
+ * suppressRoutingStatus?: boolean,
785
+ * preferRelayError?: boolean,
786
+ * }} [opts]
787
+ * @returns {Promise<{
788
+ * ok: boolean,
789
+ * status: number,
790
+ * statusText: string,
791
+ * headers: Record<string,string>,
792
+ * json: () => Promise<unknown>,
793
+ * text: () => Promise<string>,
794
+ * }>}
795
+ */
796
+ export async function routedFetch(url, init = {}, opts = {}) {
797
+ const t = nowMs(opts);
798
+
799
+ // Sticky-relay path: LAN just failed, prefer relay for a while.
800
+ if (_stickyRelayUntilMs > t) {
801
+ emitRoutingStatus("remote-switching", opts, { url: String(url || ""), sticky: true });
802
+ const r = await attemptRelayFetch(url, init, opts);
803
+ if (r.ok) return r.response;
804
+ // Relay also failed. Drop sticky preference and try LAN once — maybe
805
+ // we're back on the network.
806
+ _stickyRelayUntilMs = 0;
807
+ const lan = await attemptLanFetch(url, init, opts);
808
+ if (lan.ok) return lan.response;
809
+ if (isRemotePairingEnrollmentRequiredError(r.err)) throw r.err;
810
+ if (opts.preferRelayError && r.err) throw r.err;
811
+ // Both dead. Surface LAN error since it's typically the more
812
+ // actionable one ("Failed to fetch" → "obviously offline").
813
+ throw lan.err;
814
+ }
815
+
816
+ // Happy path: LAN first.
817
+ const lan = await attemptLanFetch(url, init, opts);
818
+ if (lan.ok) return lan.response;
819
+
820
+ // Try relay once before giving up.
821
+ emitRoutingStatus("remote-switching", opts, { url: String(url || ""), sticky: false });
822
+ const r = await attemptRelayFetch(url, init, opts);
823
+ if (r.ok) return r.response;
824
+ if (isRemotePairingEnrollmentRequiredError(r.err)) throw r.err;
825
+ if (opts.preferRelayError && r.err) throw r.err;
826
+
827
+ throw lan.err;
828
+ }
829
+
830
+ /**
831
+ * For tests + the diagnostics overlay. Reads internal counters and the
832
+ * sticky-relay window expiry. Does not allocate fresh objects on the hot
833
+ * path — only invoke from instrumentation code.
834
+ */
835
+ export function __getTelemetry() {
836
+ return {
837
+ ..._telemetry,
838
+ stickyRelayUntilMs: _stickyRelayUntilMs,
839
+ lastRoute: _lastSuccessfulRoute,
840
+ hasClient: Boolean(_client),
841
+ clientPairingKey: _clientPairingKey,
842
+ };
843
+ }
844
+
845
+ export function getRoutingTelemetry() {
846
+ return __getTelemetry();
847
+ }
848
+
849
+ /**
850
+ * Reset all internal state. Used by tests; safe but pointless in
851
+ * production. Closes the live RpcClient and unbinds wake events.
852
+ */
853
+ export function __resetForTest() {
854
+ if (_client) {
855
+ try { _client.close(); } catch { /* ignore */ }
856
+ }
857
+ _client = null;
858
+ _clientPairingKey = null;
859
+ if (_wakeUnbind) {
860
+ try { _wakeUnbind(); } catch { /* ignore */ }
861
+ }
862
+ _wakeUnbind = null;
863
+ _stickyRelayUntilMs = 0;
864
+ _telemetry = newTelemetry();
865
+ _lastPairingStateStatus = null;
866
+ _lastSuccessfulRoute = null;
867
+ }
868
+
869
+ // Test-visible constants for tests that want to assert behavior at the
870
+ // sticky-window boundary without hard-coding 5 minutes in two places.
871
+ export const __STICKY_RELAY_MS = STICKY_RELAY_MS;
872
+ export const __DEFAULT_RELAY_TIMEOUT_MS = DEFAULT_RELAY_TIMEOUT_MS;
873
+ export const __DEFAULT_LAN_TIMEOUT_MS = DEFAULT_LAN_TIMEOUT_MS;