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.
- package/.agents/plugins/marketplace.json +20 -0
- package/README.md +115 -4
- package/package.json +13 -3
- package/plugins/viveworker-control-plane/.codex-plugin/plugin.json +49 -0
- package/plugins/viveworker-control-plane/.mcp.json +11 -0
- package/plugins/viveworker-control-plane/assets/viveworker-logo-v2.png +0 -0
- package/plugins/viveworker-control-plane/assets/viveworker-logo-v2.svg +39 -0
- package/plugins/viveworker-control-plane/skills/viveworker-control-plane/SKILL.md +83 -0
- package/scripts/lib/markdown-render.mjs +128 -1
- package/scripts/lib/remote-pairing/README.md +164 -0
- package/scripts/lib/remote-pairing/_browser-bundle-entry.mjs +86 -0
- package/scripts/lib/remote-pairing/audit.mjs +122 -0
- package/scripts/lib/remote-pairing/bridge-relay-client.mjs +737 -0
- package/scripts/lib/remote-pairing/control.mjs +156 -0
- package/scripts/lib/remote-pairing/envelope.mjs +224 -0
- package/scripts/lib/remote-pairing/http-dispatch.mjs +530 -0
- package/scripts/lib/remote-pairing/keys-core.mjs +110 -0
- package/scripts/lib/remote-pairing/keys.mjs +181 -0
- package/scripts/lib/remote-pairing/noise.mjs +436 -0
- package/scripts/lib/remote-pairing/orchestrator.mjs +356 -0
- package/scripts/lib/remote-pairing/pairings.mjs +446 -0
- package/scripts/lib/remote-pairing/rpc.mjs +381 -0
- package/scripts/mcp-server.mjs +891 -0
- package/scripts/moltbook-scout-auto.sh +16 -8
- package/scripts/share-cli.mjs +14 -130
- package/scripts/stats-cli.mjs +683 -0
- package/scripts/viveworker-bridge.mjs +1676 -127
- package/scripts/viveworker.mjs +289 -7
- package/skills/viveworker-control-plane/SKILL.md +104 -0
- package/skills/viveworker-control-plane/agents/openai.yaml +12 -0
- package/templates/CLAUDE.viveworker.md +67 -0
- package/web/app.css +683 -9
- package/web/app.js +1780 -187
- package/web/build-id.js +1 -0
- package/web/i18n.js +187 -9
- package/web/icons/apple-touch-icon.png +0 -0
- package/web/icons/viveworker-icon-192.png +0 -0
- package/web/icons/viveworker-icon-512.png +0 -0
- package/web/icons/viveworker-v-pulse.svg +16 -1
- package/web/index.html +32 -2
- package/web/remote-pairing/api-router.js +873 -0
- package/web/remote-pairing/keys.js +237 -0
- package/web/remote-pairing/pairing-state.js +313 -0
- package/web/remote-pairing/rpc-client.js +765 -0
- package/web/remote-pairing/transport.js +804 -0
- package/web/remote-pairing/wake.js +149 -0
- package/web/remote-pairing-test.html +400 -0
- package/web/remote-pairing.bundle.js +3 -0
- package/web/remote-pairing.bundle.js.LEGAL.txt +15 -0
- package/web/remote-pairing.bundle.js.map +7 -0
- package/web/sw.js +190 -20
- package/web/icons/viveworker-beacon-v.svg +0 -19
- package/web/icons/viveworker-icon-1024.png +0 -0
- package/web/icons/viveworker-v-check.svg +0 -19
|
@@ -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
|
+
}
|