viveworker 0.7.0-beta.3 → 0.8.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.
Files changed (34) hide show
  1. package/README.md +33 -4
  2. package/package.json +7 -3
  3. package/scripts/lib/remote-pairing/README.md +164 -0
  4. package/scripts/lib/remote-pairing/_browser-bundle-entry.mjs +86 -0
  5. package/scripts/lib/remote-pairing/audit.mjs +122 -0
  6. package/scripts/lib/remote-pairing/bridge-relay-client.mjs +737 -0
  7. package/scripts/lib/remote-pairing/control.mjs +156 -0
  8. package/scripts/lib/remote-pairing/envelope.mjs +224 -0
  9. package/scripts/lib/remote-pairing/http-dispatch.mjs +530 -0
  10. package/scripts/lib/remote-pairing/keys-core.mjs +110 -0
  11. package/scripts/lib/remote-pairing/keys.mjs +181 -0
  12. package/scripts/lib/remote-pairing/noise.mjs +436 -0
  13. package/scripts/lib/remote-pairing/orchestrator.mjs +356 -0
  14. package/scripts/lib/remote-pairing/pairings.mjs +446 -0
  15. package/scripts/lib/remote-pairing/rpc.mjs +381 -0
  16. package/scripts/moltbook-scout-auto.sh +16 -8
  17. package/scripts/share-cli.mjs +14 -130
  18. package/scripts/viveworker-bridge.mjs +1696 -223
  19. package/scripts/viveworker.mjs +27 -6
  20. package/web/app.css +727 -9
  21. package/web/app.js +1810 -232
  22. package/web/i18n.js +207 -17
  23. package/web/index.html +115 -1
  24. package/web/remote-pairing/api-router.js +873 -0
  25. package/web/remote-pairing/keys.js +237 -0
  26. package/web/remote-pairing/pairing-state.js +313 -0
  27. package/web/remote-pairing/rpc-client.js +765 -0
  28. package/web/remote-pairing/transport.js +804 -0
  29. package/web/remote-pairing/wake.js +149 -0
  30. package/web/remote-pairing-test.html +400 -0
  31. package/web/remote-pairing.bundle.js +3 -0
  32. package/web/remote-pairing.bundle.js.LEGAL.txt +15 -0
  33. package/web/remote-pairing.bundle.js.map +7 -0
  34. package/web/sw.js +190 -20
@@ -0,0 +1,737 @@
1
+ /**
2
+ * bridge-relay-client.mjs — Bridge-side glue between RemotePairingTransport
3
+ * and the application's request dispatcher.
4
+ *
5
+ * Role in the system:
6
+ * The bridge runs ONE WebSocket per paired phone (one per `pairingId`),
7
+ * each holding a long-lived RemotePairingTransport in **responder** role.
8
+ * When a phone connects through the relay, the Noise IK handshake runs to
9
+ * completion and the static pubkey on the wire is checked against the
10
+ * on-disk paired-phones allowlist (`pairings.mjs`). Anything not in the
11
+ * allowlist gets dropped — Noise IK only proves "the peer holds this
12
+ * private key", it does not by itself say "we want to talk to this peer".
13
+ *
14
+ * Once a session is up, every encrypted DATA plaintext is decoded as an
15
+ * RPC frame (`rpc.mjs`) and routed to a caller-supplied `dispatch()`
16
+ * function. The dispatcher's response is encoded, encrypted, and written
17
+ * back over the same channel. Cancel frames abort in-flight requests via
18
+ * AbortSignal. The bridge can also push events (`broadcast(topic, data)`)
19
+ * to all connected sessions for SSE-style notifications.
20
+ *
21
+ * Lifetime:
22
+ * `client.start()` loads the pairings list and opens one
23
+ * `BridgePairingSession` per pairing. Later, `client.reload()` re-reads
24
+ * the file and reconciles: new entries get a fresh session, removed
25
+ * entries are torn down, untouched entries keep their live connection.
26
+ *
27
+ * Concurrency model:
28
+ * Requests are dispatched fire-and-forget. Multiple long-running ones can
29
+ * be in flight per pairing. A defensive cap (`MAX_INFLIGHT_PER_SESSION`)
30
+ * protects the bridge from a misbehaving (or compromised) phone flooding
31
+ * the dispatcher; past the cap the bridge replies 503 immediately.
32
+ *
33
+ * Error handling:
34
+ * Per-frame errors are logged and dropped — one bad RPC frame doesn't
35
+ * tear down the session. Per-session fatal errors (handshake mismatch,
36
+ * pubkey not in allowlist) close that session but leave others running.
37
+ * The transport itself handles reconnect/RESUME internally; this module
38
+ * doesn't second-guess it.
39
+ */
40
+
41
+ import { promises as fs } from "node:fs";
42
+ import { dirname } from "node:path";
43
+ import { timingSafeEqual as nodeTimingSafeEqual } from "node:crypto";
44
+
45
+ import { RemotePairingTransport, STATE } from "../../../web/remote-pairing/transport.js";
46
+ import { bytesToHex, hexToBytes } from "./keys-core.mjs";
47
+ import {
48
+ loadPairings,
49
+ REMOTE_PAIRINGS_FILE,
50
+ findByPairingId,
51
+ findByPub,
52
+ } from "./pairings.mjs";
53
+ import {
54
+ RPC,
55
+ decode as decodeRpc,
56
+ encodeResponse,
57
+ encodeEvent,
58
+ } from "./rpc.mjs";
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Tunables
62
+ // ---------------------------------------------------------------------------
63
+
64
+ /**
65
+ * Per-session in-flight RPC cap. Beyond this we 503 immediately so a
66
+ * runaway client can't exhaust bridge memory by piling up open handlers.
67
+ * Real PWA traffic is dominated by a handful of concurrent requests
68
+ * (long-poll + a few short ones), so 64 is way above the working set.
69
+ */
70
+ export const MAX_INFLIGHT_PER_SESSION = 64;
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // BridgeRelayClient — coordinates many BridgePairingSessions
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /**
77
+ * @typedef {Object} BridgeRelayClientOptions
78
+ * @property {string} relayUrl wss URL, e.g. "wss://pairing.viveworker.com"
79
+ * @property {{priv: Uint8Array, pub: Uint8Array}} identityKeypair bridge's own static keypair
80
+ * @property {string} [pairingsFile] path to pairings JSON; defaults to REMOTE_PAIRINGS_FILE
81
+ * @property {Pairing[]} [pairings] pre-loaded list (skips file read on start)
82
+ * @property {DispatchFn} dispatch request handler — see typedef below
83
+ * @property {OnSeenFn} [onSeen] stamp last-seen on a pairing (caller decides persistence)
84
+ * @property {OnSessionStateFn} [onSessionState] per-session state-change observer
85
+ * @property {OnErrorFn} [onError] error sink (logs / metrics)
86
+ * @property {{debug?: Function, warn?: Function, error?: Function}} [logger]
87
+ * @property {typeof WebSocket} [WebSocketImpl] injected `ws` for Node
88
+ * @property {number} [pingIntervalMs] forwarded to the transport
89
+ * @property {number[]} [backoffMs] forwarded to the transport
90
+ * @property {number} [handshakeTimeoutMs] forwarded to the transport
91
+ * @property {Uint8Array} [prologue] forwarded to the transport
92
+ */
93
+
94
+ /**
95
+ * @callback DispatchFn
96
+ * @param {{
97
+ * method: string,
98
+ * path: string,
99
+ * headers: Record<string,string>,
100
+ * body: string | undefined,
101
+ * bodyEncoding: "utf8" | "base64" | undefined,
102
+ * signal: AbortSignal,
103
+ * pairing: Pairing,
104
+ * channelBinding: Uint8Array,
105
+ * }} req
106
+ * @returns {Promise<{
107
+ * status: number,
108
+ * headers?: Record<string,string>,
109
+ * body?: string,
110
+ * bodyEncoding?: "utf8" | "base64",
111
+ * }>}
112
+ */
113
+
114
+ /**
115
+ * @callback OnSeenFn
116
+ * @param {{ pairing: Pairing, atMs: number, channelBinding: Uint8Array }} info
117
+ * @returns {void | Promise<void>}
118
+ */
119
+
120
+ /** @callback OnSessionStateFn @param {{ pairingId: string, phoneFingerprint?: string, label?: string, deviceId?: string, state: string, prev: string, info?: object }} ev */
121
+ /** @callback OnErrorFn @param {Error} err @param {{ pairingId?: string, phoneFingerprint?: string, label?: string, deviceId?: string, phase?: string }} [ctx] */
122
+
123
+ /**
124
+ * @typedef {import("./pairings.mjs").Pairing} Pairing
125
+ */
126
+
127
+ export class BridgeRelayClient {
128
+ /** @param {BridgeRelayClientOptions} opts */
129
+ constructor(opts) {
130
+ if (!opts?.relayUrl) throw new TypeError("relayUrl required");
131
+ if (!opts.identityKeypair?.priv || !opts.identityKeypair?.pub) {
132
+ throw new TypeError("identityKeypair { priv, pub } required");
133
+ }
134
+ if (typeof opts.dispatch !== "function") {
135
+ throw new TypeError("dispatch function required");
136
+ }
137
+
138
+ this._opts = opts;
139
+ this._relayUrl = opts.relayUrl.replace(/\/+$/, "");
140
+ this._identityKeypair = opts.identityKeypair;
141
+ this._pairingsFile = opts.pairingsFile ?? REMOTE_PAIRINGS_FILE;
142
+ this._dispatch = opts.dispatch;
143
+ this._onSeen = opts.onSeen ?? noop;
144
+ this._onSessionState = opts.onSessionState ?? noop;
145
+ this._onError = opts.onError ?? noop;
146
+ this._log = normalizeLogger(opts.logger);
147
+
148
+ /** @type {Map<string, BridgePairingSession>} */
149
+ this._sessions = new Map();
150
+ /** @type {Pairing[] | null} */
151
+ this._initialPairings = opts.pairings ?? null;
152
+ this._started = false;
153
+ this._closed = false;
154
+ }
155
+
156
+ // -------------------------------------------------------------------------
157
+ // Public API
158
+ // -------------------------------------------------------------------------
159
+
160
+ /**
161
+ * Load the pairings list (from disk or the constructor override) and open
162
+ * a session for each entry. Idempotent: calling again is a no-op.
163
+ */
164
+ async start() {
165
+ if (this._closed) throw new Error("client already closed");
166
+ if (this._started) return;
167
+ this._started = true;
168
+
169
+ const list =
170
+ this._initialPairings ??
171
+ (await loadPairings(this._pairingsFile).catch((err) => {
172
+ this._log.warn?.(`pairings load failed (${err.message}) — starting with empty list`);
173
+ this._onError(err, { phase: "start" });
174
+ return [];
175
+ }));
176
+
177
+ for (const p of list) {
178
+ this._spawnSession(p);
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Re-read the pairings file and reconcile sessions:
184
+ * - new entry → start a session
185
+ * - removed entry → close its session
186
+ * - modified entry (label change, etc.) → leave the live session alone
187
+ * (we track sessions by `pairingId` + `phonePub`; either changing
188
+ * means it's effectively a new entry)
189
+ */
190
+ async reload() {
191
+ if (!this._started || this._closed) return;
192
+ const list = await loadPairings(this._pairingsFile).catch((err) => {
193
+ this._log.warn?.(`pairings reload failed: ${err.message}`);
194
+ this._onError(err, { phase: "reload" });
195
+ return null;
196
+ });
197
+ if (list === null) return;
198
+ this._reconcile(list);
199
+ }
200
+
201
+ /** Force an immediate reconnect attempt on every session. */
202
+ kick() {
203
+ for (const s of this._sessions.values()) s.kick();
204
+ }
205
+
206
+ /**
207
+ * Push an event to every connected session.
208
+ *
209
+ * @param {string} topic
210
+ * @param {unknown} [data]
211
+ */
212
+ broadcast(topic, data) {
213
+ const wire = encodeEvent({ topic, data });
214
+ for (const s of this._sessions.values()) s.sendEncoded(wire);
215
+ }
216
+
217
+ /**
218
+ * Push an event to a single session by pairingId. Returns true iff the
219
+ * session was found AND currently connected.
220
+ *
221
+ * @param {string} pairingId
222
+ * @param {string} topic
223
+ * @param {unknown} [data]
224
+ */
225
+ sendEvent(pairingId, topic, data) {
226
+ const s = this._sessions.get(pairingId);
227
+ if (!s) return false;
228
+ return s.sendEncoded(encodeEvent({ topic, data }));
229
+ }
230
+
231
+ /**
232
+ * Snapshot of the current sessions for introspection / a debug page.
233
+ * @returns {Array<{ pairingId: string, label: string, phonePub: string, state: string,
234
+ * lastSeenAtMs: number | null, channelBindingHex: string | null,
235
+ * phoneFingerprint: string }>}
236
+ */
237
+ getSessions() {
238
+ return [...this._sessions.values()].map((s) => s.snapshot());
239
+ }
240
+
241
+ /** Tear down every session and stop accepting new ones. */
242
+ close() {
243
+ if (this._closed) return;
244
+ this._closed = true;
245
+ for (const s of this._sessions.values()) s.close();
246
+ this._sessions.clear();
247
+ }
248
+
249
+ // -------------------------------------------------------------------------
250
+ // Internals
251
+ // -------------------------------------------------------------------------
252
+
253
+ /** @param {Pairing} pairing */
254
+ _spawnSession(pairing) {
255
+ if (this._sessions.has(pairing.pairingId)) {
256
+ this._log.warn?.(`duplicate ${pairingLogLabel(pairing)} — replacing`);
257
+ this._sessions.get(pairing.pairingId)?.close();
258
+ }
259
+ const session = new BridgePairingSession({
260
+ relayUrl: this._relayUrl,
261
+ pairing,
262
+ identityKeypair: this._identityKeypair,
263
+ dispatch: this._dispatch,
264
+ onSeen: this._onSeen,
265
+ onStateChange: (state, prev, info) =>
266
+ this._onSessionState({
267
+ pairingId: pairing.pairingId,
268
+ phoneFingerprint: pairing.phoneFingerprint,
269
+ label: pairing.label,
270
+ deviceId: pairing.deviceId,
271
+ state,
272
+ prev,
273
+ info,
274
+ }),
275
+ onError: (err) => this._onError(err, {
276
+ pairingId: pairing.pairingId,
277
+ phoneFingerprint: pairing.phoneFingerprint,
278
+ label: pairing.label,
279
+ deviceId: pairing.deviceId,
280
+ }),
281
+ logger: this._log,
282
+ WebSocketImpl: this._opts.WebSocketImpl,
283
+ pingIntervalMs: this._opts.pingIntervalMs,
284
+ backoffMs: this._opts.backoffMs,
285
+ handshakeTimeoutMs: this._opts.handshakeTimeoutMs,
286
+ prologue: this._opts.prologue,
287
+ });
288
+ this._sessions.set(pairing.pairingId, session);
289
+ session.start().catch((err) => {
290
+ this._onError(err, { pairingId: pairing.pairingId });
291
+ });
292
+ }
293
+
294
+ /** @param {Pairing[]} list */
295
+ _reconcile(list) {
296
+ const byId = new Map(list.map((p) => [p.pairingId, p]));
297
+
298
+ // Drop sessions whose pairing was removed OR whose pubkey was reassigned
299
+ // to a different pairingId in the new list.
300
+ for (const [pairingId, session] of this._sessions) {
301
+ const next = byId.get(pairingId);
302
+ if (!next || next.phonePub !== session.pairing.phonePub || next.relayToken !== session.pairing.relayToken) {
303
+ this._log.debug?.(`closing ${pairingLogLabel(session.pairing)} (removed or credentials rotated)`);
304
+ session.close();
305
+ this._sessions.delete(pairingId);
306
+ }
307
+ }
308
+
309
+ // Spawn sessions for any new pairings.
310
+ for (const p of list) {
311
+ if (!this._sessions.has(p.pairingId)) {
312
+ this._spawnSession(p);
313
+ }
314
+ }
315
+ }
316
+ }
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // BridgePairingSession — one transport + RPC dispatch for a single pairing
320
+ // ---------------------------------------------------------------------------
321
+
322
+ class BridgePairingSession {
323
+ constructor({
324
+ relayUrl,
325
+ pairing,
326
+ identityKeypair,
327
+ dispatch,
328
+ onSeen,
329
+ onStateChange,
330
+ onError,
331
+ logger,
332
+ WebSocketImpl,
333
+ pingIntervalMs,
334
+ backoffMs,
335
+ handshakeTimeoutMs,
336
+ prologue,
337
+ }) {
338
+ this.pairing = pairing;
339
+ this._dispatch = dispatch;
340
+ this._onSeen = onSeen;
341
+ this._onStateChange = onStateChange;
342
+ this._onError = onError;
343
+ this._log = logger;
344
+
345
+ /** @type {Map<string, AbortController>} */
346
+ this._inFlight = new Map();
347
+ /** @type {Uint8Array | null} */
348
+ this._channelBinding = null;
349
+ /** @type {number | null} */
350
+ this._lastSeenAtMs = null;
351
+ this._closed = false;
352
+ this._restartTimer = null;
353
+ this._restartAttempt = 0;
354
+ this._transportOpts = {
355
+ relayUrl,
356
+ pairingId: pairing.pairingId,
357
+ relayToken: pairing.relayToken,
358
+ role: "bridge",
359
+ initiator: false,
360
+ identityKeypair,
361
+ // Responder doesn't need to know the remoteStatic ahead of time —
362
+ // Noise IK reveals it during the handshake. We then validate against
363
+ // the allowlist in `_handleHandshakeComplete`.
364
+ onMessage: (pt) => this._handleMessage(pt),
365
+ onStateChange: (state, prev, info) => this._handleTransportState(state, prev, info),
366
+ onError: (err) => this._onError(err),
367
+ onHandshakeComplete: (info) => this._handleHandshakeComplete(info),
368
+ WebSocketImpl,
369
+ pingIntervalMs,
370
+ backoffMs,
371
+ handshakeTimeoutMs,
372
+ prologue,
373
+ logger: logger,
374
+ };
375
+ this._transport = this._createTransport();
376
+ }
377
+
378
+ async start() {
379
+ if (this._closed) return;
380
+ try {
381
+ await this._transport.connect();
382
+ } catch (err) {
383
+ // The first connect failed (likely couldn't open the WS at all). The
384
+ // transport will keep retrying internally; the rejection here is just
385
+ // the first-attempt promise. Log and keep going.
386
+ this._log.debug?.(`initial connect rejected for ${this._logLabel()}: ${err.message}`);
387
+ }
388
+ }
389
+
390
+ kick() {
391
+ if (!this._closed) this._transport.kick();
392
+ }
393
+
394
+ /**
395
+ * Send an already-encoded RPC frame (encoded by the caller, e.g. for
396
+ * `broadcast`). Returns true iff the transport was connected and the
397
+ * call didn't throw.
398
+ *
399
+ * @param {Uint8Array} bytes
400
+ */
401
+ sendEncoded(bytes) {
402
+ if (this._closed || !this._transport.isConnected) return false;
403
+ try {
404
+ this._transport.send(bytes);
405
+ return true;
406
+ } catch (err) {
407
+ this._log.warn?.(`send failed on ${this._logLabel()}: ${err.message}`);
408
+ return false;
409
+ }
410
+ }
411
+
412
+ close() {
413
+ if (this._closed) return;
414
+ this._closed = true;
415
+ this._clearRestartTimer();
416
+ // Abort any in-flight request handlers so awaited dispatchers can return
417
+ // promptly. We don't bother sending responses for these — the peer
418
+ // already lost the connection.
419
+ for (const ctrl of this._inFlight.values()) ctrl.abort(new Error("session closing"));
420
+ this._inFlight.clear();
421
+ this._transport.close();
422
+ }
423
+
424
+ snapshot() {
425
+ return {
426
+ pairingId: this.pairing.pairingId,
427
+ label: this.pairing.label,
428
+ phonePub: this.pairing.phonePub,
429
+ state: this._transport.state,
430
+ lastSeenAtMs: this._lastSeenAtMs,
431
+ channelBindingHex: this._channelBinding ? bytesToHex(this._channelBinding) : null,
432
+ phoneFingerprint: this.pairing.phoneFingerprint,
433
+ };
434
+ }
435
+
436
+ // -------------------------------------------------------------------------
437
+ // Handshake completion — verify allowlist
438
+ // -------------------------------------------------------------------------
439
+
440
+ _createTransport() {
441
+ return new RemotePairingTransport(this._transportOpts);
442
+ }
443
+
444
+ _handleTransportState(state, prev, info) {
445
+ this._onStateChange(state, prev, info);
446
+ if (state === STATE.CONNECTED) {
447
+ this._restartAttempt = 0;
448
+ if (this._channelBinding) {
449
+ this._markSeen();
450
+ }
451
+ return;
452
+ }
453
+ if (state === STATE.FAILED && !this._closed) {
454
+ this._scheduleTransportRestart(info?.error);
455
+ }
456
+ }
457
+
458
+ _scheduleTransportRestart(error) {
459
+ if (this._restartTimer || this._closed) return;
460
+ for (const ctrl of this._inFlight.values()) ctrl.abort(new Error("transport restarting"));
461
+ this._inFlight.clear();
462
+ this._channelBinding = null;
463
+
464
+ const delays = [500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000];
465
+ const delay = delays[Math.min(this._restartAttempt, delays.length - 1)];
466
+ this._restartAttempt += 1;
467
+ this._log.warn?.(
468
+ `transport failed for ${this._logLabel()}; rebuilding in ${delay}ms` +
469
+ `${error?.message ? ` (${error.message})` : ""}`,
470
+ );
471
+ this._restartTimer = setTimeout(() => {
472
+ this._restartTimer = null;
473
+ if (this._closed) return;
474
+ try {
475
+ this._transport.close();
476
+ } catch {
477
+ // Failed transports may already have closed their socket.
478
+ }
479
+ this._transport = this._createTransport();
480
+ this.start().catch((err) => {
481
+ this._onError(err);
482
+ });
483
+ }, delay);
484
+ }
485
+
486
+ _clearRestartTimer() {
487
+ if (this._restartTimer != null) {
488
+ clearTimeout(this._restartTimer);
489
+ this._restartTimer = null;
490
+ }
491
+ }
492
+
493
+ _handleHandshakeComplete({ channelBinding, remoteStatic }) {
494
+ if (this._closed) return;
495
+ if (!remoteStatic) {
496
+ this._fatal(new Error("handshake completed without revealing remoteStatic"));
497
+ return;
498
+ }
499
+ // Constant-time pubkey check. The previous `peerPubHex !== ...` form
500
+ // short-circuits on the first mismatching hex char, leaking how far
501
+ // an attacker's guess matched via WS handshake-completion latency. The
502
+ // attack window is narrow (relay jitter dominates) but the fix is
503
+ // mechanical, so the prudent default is timing-safe equality.
504
+ const expected = hexToBytes(this.pairing.phonePub);
505
+ if (
506
+ remoteStatic.length !== expected.length ||
507
+ !nodeTimingSafeEqual(remoteStatic, expected)
508
+ ) {
509
+ // Possible causes:
510
+ // - The relay routed the wrong phone here (server bug).
511
+ // - The phone rotated keys without updating the LAN-side allowlist.
512
+ // - Someone with stolen credentials is impersonating the slot.
513
+ // None are recoverable by waiting; close the session.
514
+ this._fatal(new Error(
515
+ `peer pubkey mismatch for ${this._logLabel()}`,
516
+ ));
517
+ return;
518
+ }
519
+ this._channelBinding = new Uint8Array(channelBinding);
520
+ this._markSeen();
521
+ }
522
+
523
+ _markSeen() {
524
+ if (!this._channelBinding) return;
525
+ this._lastSeenAtMs = Date.now();
526
+ try {
527
+ const maybe = this._onSeen({
528
+ pairing: this.pairing,
529
+ atMs: this._lastSeenAtMs,
530
+ channelBinding: this._channelBinding,
531
+ });
532
+ if (maybe && typeof maybe.then === "function") {
533
+ maybe.catch((err) => this._log.warn?.(`onSeen rejected: ${err?.message}`));
534
+ }
535
+ } catch (err) {
536
+ this._log.warn?.(`onSeen threw: ${err?.message}`);
537
+ }
538
+ }
539
+
540
+ _fatal(err) {
541
+ this._onError(err);
542
+ // close() also cancels reconnects so this session stays down rather than
543
+ // re-handshaking into the same failure on the next backoff tick.
544
+ this.close();
545
+ }
546
+
547
+ // -------------------------------------------------------------------------
548
+ // Inbound RPC dispatch
549
+ // -------------------------------------------------------------------------
550
+
551
+ _handleMessage(plaintext) {
552
+ if (this._closed) return;
553
+ let frame;
554
+ try {
555
+ frame = decodeRpc(plaintext);
556
+ } catch (err) {
557
+ // A malformed RPC frame inside an authenticated channel is suspicious
558
+ // but recoverable — log and drop, don't tear the session down.
559
+ this._log.warn?.(`rpc decode failed on ${this._logLabel()}: ${err.message}`);
560
+ return;
561
+ }
562
+ switch (frame.type) {
563
+ case RPC.REQUEST:
564
+ this._handleRequest(frame);
565
+ break;
566
+ case RPC.CANCEL:
567
+ this._handleCancel(frame);
568
+ break;
569
+ case RPC.RESPONSE:
570
+ case RPC.EVENT:
571
+ // Bridge is the responder of RPC requests and the producer of events.
572
+ // Receiving these from the phone means the phone confused roles —
573
+ // log and drop.
574
+ this._log.warn?.(`unexpected rpc type ${frame.type} from ${this._logLabel()}`);
575
+ break;
576
+ default:
577
+ this._log.warn?.(`unhandled rpc type ${frame.type}`);
578
+ }
579
+ }
580
+
581
+ _handleRequest(req) {
582
+ if (this._inFlight.size >= MAX_INFLIGHT_PER_SESSION) {
583
+ // Defensive: avoid runaway dispatcher pile-up.
584
+ this._sendResponse({
585
+ id: req.id,
586
+ status: 503,
587
+ headers: { "content-type": "application/json" },
588
+ body: JSON.stringify({ error: "too many in-flight requests" }),
589
+ });
590
+ return;
591
+ }
592
+
593
+ const ctrl = new AbortController();
594
+ this._inFlight.set(req.id, ctrl);
595
+
596
+ // Fire-and-forget — the dispatcher runs concurrently with other
597
+ // requests on the same session.
598
+ Promise.resolve()
599
+ .then(() => this._dispatch({
600
+ method: req.method,
601
+ path: req.path,
602
+ headers: req.headers,
603
+ body: req.body,
604
+ bodyEncoding: req.bodyEncoding,
605
+ signal: ctrl.signal,
606
+ pairing: this.pairing,
607
+ channelBinding: this._channelBinding,
608
+ }))
609
+ .then((result) => {
610
+ if (this._closed) return;
611
+ if (ctrl.signal.aborted) {
612
+ // Cancelled mid-flight; don't send a response — the peer
613
+ // explicitly told us not to. Cleanup happens in finally().
614
+ return;
615
+ }
616
+ const { status, headers, body, bodyEncoding } = validateDispatchResult(result);
617
+ this._sendResponse({ id: req.id, status, headers, body, bodyEncoding });
618
+ })
619
+ .catch((err) => {
620
+ if (this._closed) return;
621
+ if (ctrl.signal.aborted) {
622
+ // The dispatcher likely threw because of the abort. Don't bother
623
+ // sending a response — see comment above.
624
+ return;
625
+ }
626
+ this._log.warn?.(
627
+ `dispatch error on ${this._logLabel()} ${requestLogLabel(req)}: ${err?.message}`,
628
+ );
629
+ this._sendResponse({
630
+ id: req.id,
631
+ status: 500,
632
+ headers: { "content-type": "application/json" },
633
+ body: JSON.stringify({ error: "internal error" }),
634
+ });
635
+ })
636
+ .finally(() => {
637
+ this._inFlight.delete(req.id);
638
+ });
639
+ }
640
+
641
+ _handleCancel(frame) {
642
+ const ctrl = this._inFlight.get(frame.id);
643
+ if (!ctrl) return;
644
+ ctrl.abort(new Error("cancel from peer"));
645
+ // We do NOT delete from _inFlight here — the dispatcher's `.finally`
646
+ // owns that. Otherwise a slow handler could collide with a future
647
+ // request that reuses the same id.
648
+ }
649
+
650
+ /** @param {{id:string,status:number,headers?:object,body?:string,bodyEncoding?:string}} res */
651
+ _sendResponse(res) {
652
+ let bytes;
653
+ try {
654
+ bytes = encodeResponse(res);
655
+ } catch (err) {
656
+ this._log.error?.(
657
+ `failed to encode response for ${this._logLabel()} id=${res.id}: ${err.message}`,
658
+ );
659
+ return;
660
+ }
661
+ if (!this._transport.isConnected) {
662
+ // Lost the channel between dispatch start and response. The phone-side
663
+ // RPC client is expected to time out and retry; we drop silently.
664
+ this._log.debug?.(
665
+ `dropped response id=${res.id} on ${this._logLabel()} — transport not connected`,
666
+ );
667
+ return;
668
+ }
669
+ try {
670
+ this._transport.send(bytes);
671
+ } catch (err) {
672
+ this._log.warn?.(`response send failed on ${this._logLabel()}: ${err.message}`);
673
+ }
674
+ }
675
+
676
+ _logLabel() {
677
+ return pairingLogLabel(this.pairing);
678
+ }
679
+ }
680
+
681
+ // Re-export so callers can `import { STATE } from "./bridge-relay-client.mjs"`
682
+ // without separately reaching into transport.js.
683
+ export { STATE };
684
+
685
+ // ---------------------------------------------------------------------------
686
+ // Helpers
687
+ // ---------------------------------------------------------------------------
688
+
689
+ function noop() {}
690
+
691
+ function normalizeLogger(logger) {
692
+ if (!logger) return {};
693
+ return {
694
+ debug: typeof logger.debug === "function" ? logger.debug.bind(logger) : undefined,
695
+ warn: typeof logger.warn === "function" ? logger.warn.bind(logger) : undefined,
696
+ error: typeof logger.error === "function" ? logger.error.bind(logger) : undefined,
697
+ };
698
+ }
699
+
700
+ function pairingLogLabel(pairing) {
701
+ if (pairing?.phoneFingerprint) {
702
+ return `phone:${pairing.phoneFingerprint}`;
703
+ }
704
+ return redactPairingId(pairing?.pairingId);
705
+ }
706
+
707
+ function redactPairingId(pairingId) {
708
+ const value = String(pairingId || "");
709
+ return value ? `pairing:${value.slice(0, 6)}…` : "pairing:unknown";
710
+ }
711
+
712
+ function requestLogLabel(req) {
713
+ const method = String(req?.method || "REQUEST").toUpperCase();
714
+ try {
715
+ const pathname = new URL(String(req?.path || "/"), "http://viveworker.local").pathname;
716
+ const parts = pathname.split("/").filter(Boolean).slice(0, 2);
717
+ return `${method} /${parts.join("/")}`;
718
+ } catch {
719
+ return method;
720
+ }
721
+ }
722
+
723
+ function validateDispatchResult(result) {
724
+ if (!result || typeof result !== "object") {
725
+ throw new TypeError("dispatch must return { status, headers?, body?, bodyEncoding? }");
726
+ }
727
+ const status = result.status;
728
+ if (!Number.isInteger(status) || status < 100 || status > 599) {
729
+ throw new RangeError(`dispatch result.status must be in [100, 599] (got ${status})`);
730
+ }
731
+ return {
732
+ status,
733
+ headers: result.headers ?? undefined,
734
+ body: result.body ?? undefined,
735
+ bodyEncoding: result.bodyEncoding ?? undefined,
736
+ };
737
+ }