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.
- package/README.md +33 -4
- package/package.json +7 -3
- 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/moltbook-scout-auto.sh +16 -8
- package/scripts/share-cli.mjs +14 -130
- package/scripts/viveworker-bridge.mjs +1696 -223
- package/scripts/viveworker.mjs +27 -6
- package/web/app.css +727 -9
- package/web/app.js +1810 -232
- package/web/i18n.js +207 -17
- package/web/index.html +115 -1
- 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
|
@@ -0,0 +1,804 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* web/remote-pairing/transport.js — Browser WS client with reconnect + RESUME.
|
|
3
|
+
*
|
|
4
|
+
* Owns the lifecycle of one relay WebSocket: open → Noise IK handshake →
|
|
5
|
+
* encrypted transport → graceful or surprise disconnect → reconnect (with
|
|
6
|
+
* RESUME_REQ if we have a live noise session) → repeat. Application code
|
|
7
|
+
* sees `connect()` / `send()` / `close()` and a stream of `onMessage` /
|
|
8
|
+
* `onStateChange` / `onError` callbacks.
|
|
9
|
+
*
|
|
10
|
+
* State machine:
|
|
11
|
+
*
|
|
12
|
+
* disconnected ──connect()──▶ opening
|
|
13
|
+
* │ ws onopen
|
|
14
|
+
* ▼
|
|
15
|
+
* ┌─── have session ───▶ resuming
|
|
16
|
+
* │ │ RESUME_OK ▶ connected
|
|
17
|
+
* │ │ RESUME_FAIL ▶ handshaking
|
|
18
|
+
* └── no session ────▶ handshaking ─finish─▶ connected
|
|
19
|
+
*
|
|
20
|
+
* {opening, handshaking, resuming, connected} ──ws onclose──▶ disconnected
|
|
21
|
+
* └─backoff─▶ opening
|
|
22
|
+
* {handshaking, resuming} ──crypto/decrypt failure──▶ failed (terminal)
|
|
23
|
+
* close() → disconnected (terminal until next connect()).
|
|
24
|
+
*
|
|
25
|
+
* Sequencing:
|
|
26
|
+
* `_outboundSeq` and `_lastSeenPeerSeq` are monotonic across the entire
|
|
27
|
+
* transport lifetime — including across re-handshakes triggered by
|
|
28
|
+
* RESUME_FAIL. The relay's per-peer outbox is append-only; restarting
|
|
29
|
+
* counters mid-transport would race with stale ACKs.
|
|
30
|
+
*
|
|
31
|
+
* Why ACK every inbound DATA:
|
|
32
|
+
* The relay doesn't know whether we've successfully consumed a frame
|
|
33
|
+
* (it can't peek inside the Noise envelope). Per-frame ACKs let the DO
|
|
34
|
+
* GC the peer's outbox tightly, which matters because the buffer ages
|
|
35
|
+
* out at 5 minutes — we want to keep it small for the times we DO need
|
|
36
|
+
* to replay (Web Push wake, cellular handoff, lid-close).
|
|
37
|
+
*
|
|
38
|
+
* Why no client-side outbox:
|
|
39
|
+
* The relay holds the replay buffer (see worker-pairing/pairing-do.js).
|
|
40
|
+
* On reconnect we send RESUME_REQ(lastSeenPeerSeq) and the relay replays
|
|
41
|
+
* anything we missed. Adding a second outbox on the client would just
|
|
42
|
+
* duplicate state and risk the two diverging.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import {
|
|
46
|
+
createInitiator,
|
|
47
|
+
createResponder,
|
|
48
|
+
decode,
|
|
49
|
+
encodeData,
|
|
50
|
+
encodeAck,
|
|
51
|
+
encodePing,
|
|
52
|
+
encodeResumeReq,
|
|
53
|
+
generateMid,
|
|
54
|
+
FRAME_DATA,
|
|
55
|
+
FRAME_ACK,
|
|
56
|
+
FRAME_PING,
|
|
57
|
+
FRAME_PONG,
|
|
58
|
+
FRAME_RESUME_OK,
|
|
59
|
+
FRAME_RESUME_FAIL,
|
|
60
|
+
RESUME_FAIL_BUFFER_EXPIRED,
|
|
61
|
+
RESUME_FAIL_UNKNOWN_PAIRING,
|
|
62
|
+
RESUME_FAIL_HIBERNATED,
|
|
63
|
+
} from "../remote-pairing.bundle.js";
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Constants
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Public state strings — surfaced to callers via `transport.state` and the
|
|
71
|
+
* `onStateChange` callback. Frozen so consumers can compare with `===`.
|
|
72
|
+
*/
|
|
73
|
+
export const STATE = Object.freeze({
|
|
74
|
+
DISCONNECTED: "disconnected",
|
|
75
|
+
OPENING: "opening",
|
|
76
|
+
HANDSHAKING: "handshaking",
|
|
77
|
+
RESUMING: "resuming",
|
|
78
|
+
CONNECTED: "connected",
|
|
79
|
+
FAILED: "failed",
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const DEFAULT_PING_INTERVAL_MS = 30_000; // CF idle timeout is ~100s
|
|
83
|
+
const DEFAULT_BACKOFF_MS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000];
|
|
84
|
+
const DEFAULT_HANDSHAKE_TIMEOUT_MS = 30_000;
|
|
85
|
+
const MAX_PRE_CONNECT_BACKOFF_MS = 4_000;
|
|
86
|
+
const RELAY_RESET_RECONNECT_MS = 250;
|
|
87
|
+
const DEFAULT_PROLOGUE = new TextEncoder().encode("viveworker/remote-pairing/v1");
|
|
88
|
+
|
|
89
|
+
// CloseEvent codes we emit. 1000 is normal; 4xxx is application-defined.
|
|
90
|
+
const CLOSE_NORMAL = 1000;
|
|
91
|
+
const CLOSE_HANDSHAKE_TIMEOUT = 4001;
|
|
92
|
+
const CLOSE_FATAL = 4002;
|
|
93
|
+
const CLOSE_RELAY_RESET_SESSION = 4004;
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// RemotePairingTransport
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @typedef {Object} TransportOptions
|
|
101
|
+
* @property {string} relayUrl e.g. "wss://pairing.viveworker.com"
|
|
102
|
+
* @property {string} pairingId pairing slot identifier
|
|
103
|
+
* @property {string} relayToken relay capability token
|
|
104
|
+
* @property {"phone" | "bridge"} role
|
|
105
|
+
* @property {{priv: Uint8Array, pub: Uint8Array}} identityKeypair
|
|
106
|
+
* @property {Uint8Array} [remoteStatic] required for the initiator role
|
|
107
|
+
* @property {boolean} [initiator] defaults true if role==="phone"
|
|
108
|
+
* @property {Uint8Array} [prologue] bound into the handshake transcript
|
|
109
|
+
* @property {(plaintext: Uint8Array) => void} [onMessage]
|
|
110
|
+
* @property {(state: string, prev: string, info?: object) => void} [onStateChange]
|
|
111
|
+
* @property {(err: Error) => void} [onError]
|
|
112
|
+
* @property {(info: { channelBinding: Uint8Array, remoteStatic: Uint8Array }) => void} [onHandshakeComplete]
|
|
113
|
+
* @property {number} [pingIntervalMs]
|
|
114
|
+
* @property {number[]} [backoffMs]
|
|
115
|
+
* @property {number} [handshakeTimeoutMs]
|
|
116
|
+
* @property {typeof WebSocket} [WebSocketImpl] injectable for Node-side tests
|
|
117
|
+
* @property {{debug?: Function, warn?: Function}} [logger]
|
|
118
|
+
*/
|
|
119
|
+
|
|
120
|
+
export class RemotePairingTransport {
|
|
121
|
+
/** @param {TransportOptions} opts */
|
|
122
|
+
constructor(opts) {
|
|
123
|
+
if (!opts?.relayUrl) throw new TypeError("relayUrl required");
|
|
124
|
+
if (!opts.pairingId) throw new TypeError("pairingId required");
|
|
125
|
+
if (!opts.relayToken) throw new TypeError("relayToken required");
|
|
126
|
+
if (opts.role !== "phone" && opts.role !== "bridge") {
|
|
127
|
+
throw new TypeError(`role must be "phone" or "bridge", got ${JSON.stringify(opts.role)}`);
|
|
128
|
+
}
|
|
129
|
+
if (!opts.identityKeypair?.priv || !opts.identityKeypair?.pub) {
|
|
130
|
+
throw new TypeError("identityKeypair { priv, pub } required");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Normalize URL: strip trailing slash so we can `${relayUrl}/v1/...` cleanly.
|
|
134
|
+
this._relayUrl = opts.relayUrl.replace(/\/+$/, "");
|
|
135
|
+
this._pairingId = opts.pairingId;
|
|
136
|
+
this._relayToken = opts.relayToken;
|
|
137
|
+
this._role = opts.role;
|
|
138
|
+
this._identityKeypair = opts.identityKeypair;
|
|
139
|
+
// Default: phone is the initiator (knows bridge's static pubkey from LAN
|
|
140
|
+
// pairing), bridge is the responder. Allow override for symmetry.
|
|
141
|
+
this._initiator = opts.initiator ?? (opts.role === "phone");
|
|
142
|
+
if (this._initiator && !opts.remoteStatic) {
|
|
143
|
+
throw new TypeError("initiator requires remoteStatic");
|
|
144
|
+
}
|
|
145
|
+
this._remoteStatic = opts.remoteStatic ? new Uint8Array(opts.remoteStatic) : null;
|
|
146
|
+
this._prologue = opts.prologue ?? DEFAULT_PROLOGUE;
|
|
147
|
+
|
|
148
|
+
this._onMessage = opts.onMessage ?? noop;
|
|
149
|
+
this._onStateChange = opts.onStateChange ?? noop;
|
|
150
|
+
this._onError = opts.onError ?? noop;
|
|
151
|
+
this._onHandshakeComplete = opts.onHandshakeComplete ?? noop;
|
|
152
|
+
|
|
153
|
+
this._pingIntervalMs = opts.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS;
|
|
154
|
+
this._backoffMs = (opts.backoffMs ?? DEFAULT_BACKOFF_MS).slice();
|
|
155
|
+
if (this._backoffMs.length === 0) this._backoffMs = [1_000];
|
|
156
|
+
this._handshakeTimeoutMs = opts.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS;
|
|
157
|
+
|
|
158
|
+
this._WebSocketImpl = opts.WebSocketImpl ?? globalThis.WebSocket;
|
|
159
|
+
if (typeof this._WebSocketImpl !== "function") {
|
|
160
|
+
throw new TypeError(
|
|
161
|
+
"no WebSocket implementation available — pass `WebSocketImpl` for non-browser environments",
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
this._log = normalizeLogger(opts.logger);
|
|
166
|
+
|
|
167
|
+
// ---- mutable state ----
|
|
168
|
+
/** @type {string} */
|
|
169
|
+
this._state = STATE.DISCONNECTED;
|
|
170
|
+
/** Set true once close() runs; blocks any further reconnect attempts. */
|
|
171
|
+
this._closed = false;
|
|
172
|
+
/**
|
|
173
|
+
* True after the first connect() call, false after close(). Guards kick()
|
|
174
|
+
* so wake events that fire before the application opted into the
|
|
175
|
+
* transport (e.g., visibilitychange during initial page paint) can't
|
|
176
|
+
* spontaneously open a WebSocket.
|
|
177
|
+
*/
|
|
178
|
+
this._started = false;
|
|
179
|
+
/** @type {{promise: Promise<void>, resolve: () => void, reject: (e: Error) => void} | null} */
|
|
180
|
+
this._connectPromise = null;
|
|
181
|
+
/** @type {WebSocket | null} */
|
|
182
|
+
this._ws = null;
|
|
183
|
+
/** Index into _backoffMs; reset on successful CONNECTED. */
|
|
184
|
+
this._reconnectAttempt = 0;
|
|
185
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
186
|
+
this._reconnectTimer = null;
|
|
187
|
+
/** @type {ReturnType<typeof setInterval> | null} */
|
|
188
|
+
this._pingTimer = null;
|
|
189
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
190
|
+
this._handshakeTimer = null;
|
|
191
|
+
|
|
192
|
+
// ---- crypto state (persists across reconnects until RESUME_FAIL) ----
|
|
193
|
+
/** @type {import("../remote-pairing.bundle.js").NoiseSession | null} */
|
|
194
|
+
this._session = null;
|
|
195
|
+
/** @type {import("../remote-pairing.bundle.js").HandshakeState | null} */
|
|
196
|
+
this._handshake = null;
|
|
197
|
+
|
|
198
|
+
// ---- sequencing (monotonic across the transport lifetime) ----
|
|
199
|
+
this._outboundSeq = 0;
|
|
200
|
+
this._lastSeenPeerSeq = 0;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// -------------------------------------------------------------------------
|
|
204
|
+
// Public API
|
|
205
|
+
// -------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
/** Current state machine label (one of STATE.*). */
|
|
208
|
+
get state() {
|
|
209
|
+
return this._state;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Snapshot of the channel binding (handshake hash). `null` until the first
|
|
214
|
+
* handshake completes. Use to pin higher-level auth (e.g., Passkey challenges)
|
|
215
|
+
* to this Noise session.
|
|
216
|
+
*/
|
|
217
|
+
get channelBinding() {
|
|
218
|
+
return this._session ? this._session.getChannelBinding() : null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** True iff the encrypted channel is up and `send()` will succeed. */
|
|
222
|
+
get isConnected() {
|
|
223
|
+
return this._state === STATE.CONNECTED && this._session != null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Open the WebSocket, run the Noise handshake, and resolve when the
|
|
228
|
+
* encrypted channel is up.
|
|
229
|
+
*
|
|
230
|
+
* Subsequent reconnects happen automatically — the returned promise is
|
|
231
|
+
* settled only by the FIRST successful connection (or by `close()` /
|
|
232
|
+
* fatal failure).
|
|
233
|
+
*/
|
|
234
|
+
connect() {
|
|
235
|
+
if (this._state === STATE.FAILED) {
|
|
236
|
+
return Promise.reject(new Error("transport in failed state — construct a new one"));
|
|
237
|
+
}
|
|
238
|
+
if (this._state === STATE.CONNECTED) {
|
|
239
|
+
return Promise.resolve();
|
|
240
|
+
}
|
|
241
|
+
this._closed = false;
|
|
242
|
+
this._started = true;
|
|
243
|
+
if (!this._connectPromise) {
|
|
244
|
+
this._connectPromise = deferred();
|
|
245
|
+
}
|
|
246
|
+
// Only kick off a fresh _open() if we're not already mid-attempt and no
|
|
247
|
+
// reconnect timer is queued. Otherwise the in-flight cycle will
|
|
248
|
+
// eventually transition to CONNECTED (or FAILED) and settle the promise.
|
|
249
|
+
if (this._state === STATE.DISCONNECTED && this._reconnectTimer == null) {
|
|
250
|
+
this._open();
|
|
251
|
+
}
|
|
252
|
+
return this._connectPromise.promise;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Force an immediate reconnect attempt, bypassing any queued backoff.
|
|
257
|
+
* Useful for visibilitychange / Web Push wake events where we want to
|
|
258
|
+
* shortcut the exponential backoff window.
|
|
259
|
+
*
|
|
260
|
+
* Idempotent. No-ops if we're already opening/connected/closed/failed.
|
|
261
|
+
*/
|
|
262
|
+
kick() {
|
|
263
|
+
if (!this._started) return; // application never called connect() — nothing to kick
|
|
264
|
+
if (this._closed || this._state === STATE.FAILED) return;
|
|
265
|
+
if (this._state === STATE.CONNECTED) return;
|
|
266
|
+
this._reconnectAttempt = 0;
|
|
267
|
+
this._cancelReconnectTimer();
|
|
268
|
+
if (this._state === STATE.DISCONNECTED) {
|
|
269
|
+
this._open();
|
|
270
|
+
}
|
|
271
|
+
// If state is OPENING/HANDSHAKING/RESUMING we leave the in-flight
|
|
272
|
+
// attempt alone — it'll either succeed soon or fall back to reconnect.
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Encrypt + frame `plaintext` and send. Optional `ad` (additional data)
|
|
277
|
+
* is bound into the AEAD so a tampered envelope can't pair with valid
|
|
278
|
+
* ciphertext.
|
|
279
|
+
*
|
|
280
|
+
* Throws synchronously if the transport isn't connected — application
|
|
281
|
+
* code is responsible for awaiting `connect()` first (or queuing).
|
|
282
|
+
*
|
|
283
|
+
* @param {Uint8Array} plaintext
|
|
284
|
+
* @param {Uint8Array} [ad]
|
|
285
|
+
*/
|
|
286
|
+
send(plaintext, ad) {
|
|
287
|
+
if (!this.isConnected) {
|
|
288
|
+
throw new Error(`transport not connected (state=${this._state})`);
|
|
289
|
+
}
|
|
290
|
+
const ciphertext = this._session.send(plaintext, ad ?? new Uint8Array(0));
|
|
291
|
+
this._sendData(ciphertext);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Tear down the transport. Idempotent. After `close()`, no reconnects
|
|
296
|
+
* will fire and the connect promise (if any) is rejected.
|
|
297
|
+
*/
|
|
298
|
+
close() {
|
|
299
|
+
this._closed = true;
|
|
300
|
+
this._started = false;
|
|
301
|
+
this._cancelReconnectTimer();
|
|
302
|
+
this._stopPing();
|
|
303
|
+
this._stopHandshakeTimer();
|
|
304
|
+
if (this._ws) {
|
|
305
|
+
try { this._ws.close(CLOSE_NORMAL, "client closing"); } catch {}
|
|
306
|
+
this._ws = null;
|
|
307
|
+
}
|
|
308
|
+
if (this._connectPromise) {
|
|
309
|
+
this._connectPromise.reject(new Error("transport closed before first connect"));
|
|
310
|
+
this._connectPromise = null;
|
|
311
|
+
}
|
|
312
|
+
if (this._state !== STATE.FAILED) {
|
|
313
|
+
this._setState(STATE.DISCONNECTED, { reason: "closed" });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// -------------------------------------------------------------------------
|
|
318
|
+
// WS lifecycle
|
|
319
|
+
// -------------------------------------------------------------------------
|
|
320
|
+
|
|
321
|
+
_open() {
|
|
322
|
+
this._cancelReconnectTimer();
|
|
323
|
+
this._setState(STATE.OPENING);
|
|
324
|
+
|
|
325
|
+
const url =
|
|
326
|
+
`${this._relayUrl}/v1/pairing/${encodeURIComponent(this._pairingId)}` +
|
|
327
|
+
`/ws?role=${encodeURIComponent(this._role)}` +
|
|
328
|
+
`&token=${encodeURIComponent(this._relayToken)}`;
|
|
329
|
+
|
|
330
|
+
let ws;
|
|
331
|
+
try {
|
|
332
|
+
ws = new this._WebSocketImpl(url);
|
|
333
|
+
} catch (err) {
|
|
334
|
+
this._onError(err);
|
|
335
|
+
this._setState(STATE.DISCONNECTED, { reason: "open-threw", error: err });
|
|
336
|
+
this._scheduleReconnect();
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
// Receive binary as ArrayBuffer (works for both browser WebSocket and the
|
|
340
|
+
// Node `ws` package). Default for `ws` is Buffer; default for browsers
|
|
341
|
+
// is Blob — neither is what `decode()` wants.
|
|
342
|
+
try { ws.binaryType = "arraybuffer"; } catch {}
|
|
343
|
+
|
|
344
|
+
this._ws = ws;
|
|
345
|
+
ws.addEventListener("open", () => this._handleOpen());
|
|
346
|
+
ws.addEventListener("message", (evt) => this._handleMessage(evt));
|
|
347
|
+
ws.addEventListener("close", (evt) => this._handleClose(evt));
|
|
348
|
+
ws.addEventListener("error", (evt) => this._handleError(evt));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
_handleOpen() {
|
|
352
|
+
if (this._closed) {
|
|
353
|
+
try { this._ws?.close(CLOSE_NORMAL, "closed during open"); } catch {}
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (this._session) {
|
|
357
|
+
// We have a live noise session from a previous connection — try to
|
|
358
|
+
// resume rather than re-handshaking. `lastSeenPeerSeq` tells the relay
|
|
359
|
+
// which peer-side frames we still need replayed.
|
|
360
|
+
this._setState(STATE.RESUMING, { lastSeenPeerSeq: this._lastSeenPeerSeq });
|
|
361
|
+
this._sendResumeReq(this._lastSeenPeerSeq);
|
|
362
|
+
this._startHandshakeTimer(); // doubles as resume timeout
|
|
363
|
+
} else {
|
|
364
|
+
// No session — process restart, fresh PWA install, etc. We still
|
|
365
|
+
// announce ourselves with RESUME_REQ(0) so the relay can detect the
|
|
366
|
+
// state-loss case (peer.lastSent > 0 vs lastSeenSeq = 0) and force the
|
|
367
|
+
// counterparty into a fresh handshake too. Without this, a peer that
|
|
368
|
+
// still has a live session keeps sending transport DATA encrypted
|
|
369
|
+
// with the old keys, and the responder side reads them as malformed
|
|
370
|
+
// msg1 frames in a tight AEAD-failure loop.
|
|
371
|
+
//
|
|
372
|
+
// We don't enter RESUMING state for this case — there's no session to
|
|
373
|
+
// feed replay frames into anyway. RESUME_OK / RESUME_FAIL responses
|
|
374
|
+
// arrive while we're already in HANDSHAKING and get warn-logged-and-
|
|
375
|
+
// ignored by the existing `state !== RESUMING` guards.
|
|
376
|
+
this._sendResumeReq(0);
|
|
377
|
+
this._beginHandshake();
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
_handleMessage(evt) {
|
|
382
|
+
let frame;
|
|
383
|
+
try {
|
|
384
|
+
frame = decode(asU8(evt.data));
|
|
385
|
+
} catch (err) {
|
|
386
|
+
this._onError(new Error(`envelope decode failed: ${err.message}`));
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
switch (frame.type) {
|
|
391
|
+
case FRAME_DATA:
|
|
392
|
+
this._handleDataFrame(frame);
|
|
393
|
+
break;
|
|
394
|
+
case FRAME_ACK:
|
|
395
|
+
// The relay forwards peer ACKs only as outbox-GC signals on its end.
|
|
396
|
+
// We don't run a client outbox, so nothing to do.
|
|
397
|
+
break;
|
|
398
|
+
case FRAME_PING:
|
|
399
|
+
// The relay handles its own keepalive; we shouldn't see PINGs from
|
|
400
|
+
// it. Defensive ignore.
|
|
401
|
+
break;
|
|
402
|
+
case FRAME_PONG:
|
|
403
|
+
// Reply to our PING. We don't currently watchdog this — a dead
|
|
404
|
+
// connection will surface via `close` shortly.
|
|
405
|
+
break;
|
|
406
|
+
case FRAME_RESUME_OK:
|
|
407
|
+
this._handleResumeOk(frame);
|
|
408
|
+
break;
|
|
409
|
+
case FRAME_RESUME_FAIL:
|
|
410
|
+
this._handleResumeFail(frame);
|
|
411
|
+
break;
|
|
412
|
+
default:
|
|
413
|
+
this._onError(new Error(`unexpected frame type 0x${frame.type.toString(16)}`));
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
_handleDataFrame(frame) {
|
|
418
|
+
// Always ACK first so the relay can tighten its outbox even if our
|
|
419
|
+
// crypto step throws.
|
|
420
|
+
if (frame.seq > this._lastSeenPeerSeq) {
|
|
421
|
+
this._lastSeenPeerSeq = frame.seq;
|
|
422
|
+
}
|
|
423
|
+
this._sendAck(frame.seq);
|
|
424
|
+
|
|
425
|
+
try {
|
|
426
|
+
this._dispatchData(frame);
|
|
427
|
+
} catch (err) {
|
|
428
|
+
// Crypto / state-machine errors during a handshake are fatal — re-
|
|
429
|
+
// running the handshake won't help if the static keys are wrong.
|
|
430
|
+
// During CONNECTED state we treat them the same to avoid silently
|
|
431
|
+
// skipping bad frames; the user will need to re-pair.
|
|
432
|
+
this._fail(err);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
_handleResumeOk(frame) {
|
|
437
|
+
if (this._state !== STATE.RESUMING) {
|
|
438
|
+
if (this._state === STATE.HANDSHAKING && !this._session) {
|
|
439
|
+
this._log.debug?.(`ignoring RESUME_OK during fresh handshake currentSeq=${frame.currentSeq}`);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
this._log.warn?.(`unexpected RESUME_OK in state=${this._state}`);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
this._stopHandshakeTimer();
|
|
446
|
+
this._setState(STATE.CONNECTED, { resumed: true, currentSeq: frame.currentSeq });
|
|
447
|
+
this._startPing();
|
|
448
|
+
this._resolveConnect();
|
|
449
|
+
// Any DATA frames buffered on the relay arrive after this RESUME_OK and
|
|
450
|
+
// are processed by the regular CONNECTED branch in _dispatchData.
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
_handleResumeFail(frame) {
|
|
454
|
+
if (this._state !== STATE.RESUMING) {
|
|
455
|
+
if (this._state === STATE.HANDSHAKING && !this._session) {
|
|
456
|
+
this._log.debug?.(`ignoring RESUME_FAIL during fresh handshake reason=${describeResumeFail(frame.reason)}`);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
this._log.warn?.(`unexpected RESUME_FAIL in state=${this._state}`);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
this._stopHandshakeTimer();
|
|
463
|
+
this._log.debug?.(`RESUME_FAIL reason=${describeResumeFail(frame.reason)} — re-handshaking`);
|
|
464
|
+
// Drop the stale session. Counters stay monotonic so any leftover
|
|
465
|
+
// entries in the relay's outbox naturally GC under the new session's
|
|
466
|
+
// ACK flow (or simply age out at the 5-minute TTL).
|
|
467
|
+
this._session = null;
|
|
468
|
+
this._beginHandshake();
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
_handleClose(evt) {
|
|
472
|
+
this._stopPing();
|
|
473
|
+
this._stopHandshakeTimer();
|
|
474
|
+
this._ws = null;
|
|
475
|
+
|
|
476
|
+
if (this._closed) return; // user-initiated; close() already settled
|
|
477
|
+
if (this._state === STATE.FAILED) return; // already terminal
|
|
478
|
+
|
|
479
|
+
const isRelayReset = Number(evt?.code) === CLOSE_RELAY_RESET_SESSION;
|
|
480
|
+
if (isRelayReset) {
|
|
481
|
+
this._log.debug?.(`relay requested fresh handshake reason=${evt?.reason || ""}`);
|
|
482
|
+
this._dropNoiseState();
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
this._log.debug?.(`ws closed code=${evt?.code} reason=${evt?.reason}`);
|
|
486
|
+
this._setState(STATE.DISCONNECTED, { code: evt?.code, reason: evt?.reason });
|
|
487
|
+
if (isRelayReset) {
|
|
488
|
+
this._scheduleRelayResetReconnect();
|
|
489
|
+
} else {
|
|
490
|
+
this._scheduleReconnect();
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
_handleError(evt) {
|
|
495
|
+
// Browsers don't surface details on the `error` event; bubble up a
|
|
496
|
+
// generic notice and rely on the `close` event (which fires immediately
|
|
497
|
+
// afterward) to drive recovery.
|
|
498
|
+
const err = evt?.error ?? new Error("websocket error");
|
|
499
|
+
this._onError(err);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// -------------------------------------------------------------------------
|
|
503
|
+
// Handshake
|
|
504
|
+
// -------------------------------------------------------------------------
|
|
505
|
+
|
|
506
|
+
_beginHandshake() {
|
|
507
|
+
this._setState(STATE.HANDSHAKING);
|
|
508
|
+
this._handshake = this._initiator
|
|
509
|
+
? createInitiator({
|
|
510
|
+
staticKeypair: this._identityKeypair,
|
|
511
|
+
remoteStatic: this._remoteStatic,
|
|
512
|
+
prologue: this._prologue,
|
|
513
|
+
})
|
|
514
|
+
: createResponder({
|
|
515
|
+
staticKeypair: this._identityKeypair,
|
|
516
|
+
prologue: this._prologue,
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
if (this._initiator) {
|
|
520
|
+
this._startHandshakeTimer();
|
|
521
|
+
// Send msg1 immediately. We don't piggy-back application data on the
|
|
522
|
+
// handshake transcript — the encrypted channel carries that after
|
|
523
|
+
// CONNECTED. Empty payload keeps the wire shape predictable.
|
|
524
|
+
let msg1;
|
|
525
|
+
try {
|
|
526
|
+
msg1 = this._handshake.writeMessage(new Uint8Array(0));
|
|
527
|
+
} catch (err) {
|
|
528
|
+
this._fail(err);
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
this._sendData(msg1);
|
|
532
|
+
} else {
|
|
533
|
+
// The bridge/responder can legitimately sit here waiting for a phone
|
|
534
|
+
// to arrive through the relay. Do not start the handshake timeout until
|
|
535
|
+
// msg1 is actually received; keep the relay socket alive instead.
|
|
536
|
+
this._startPing();
|
|
537
|
+
}
|
|
538
|
+
// Responder waits passively — _dispatchData picks up msg1 when it arrives.
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
_dispatchData(frame) {
|
|
542
|
+
if (this._state === STATE.CONNECTED) {
|
|
543
|
+
const pt = this._session.recv(frame.payload);
|
|
544
|
+
this._onMessage(pt);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (this._state === STATE.HANDSHAKING && this._handshake) {
|
|
549
|
+
if (this._initiator) {
|
|
550
|
+
// Inbound msg2 from responder.
|
|
551
|
+
this._handshake.readMessage(frame.payload);
|
|
552
|
+
} else {
|
|
553
|
+
// Inbound msg1 from initiator → reply with msg2.
|
|
554
|
+
this._startHandshakeTimer();
|
|
555
|
+
this._handshake.readMessage(frame.payload);
|
|
556
|
+
const msg2 = this._handshake.writeMessage(new Uint8Array(0));
|
|
557
|
+
this._sendData(msg2);
|
|
558
|
+
}
|
|
559
|
+
if (this._handshake.isHandshakeFinished()) {
|
|
560
|
+
const session = this._handshake.intoSession();
|
|
561
|
+
this._session = session;
|
|
562
|
+
this._handshake = null;
|
|
563
|
+
this._stopHandshakeTimer();
|
|
564
|
+
this._setState(STATE.CONNECTED, { resumed: false });
|
|
565
|
+
this._startPing();
|
|
566
|
+
// Snapshot the binding + remote pub for the caller — both are
|
|
567
|
+
// commonly used to bind higher-level auth (Passkey, etc.).
|
|
568
|
+
const channelBinding = session.getChannelBinding();
|
|
569
|
+
const remoteStatic = session.remoteStatic
|
|
570
|
+
? new Uint8Array(session.remoteStatic)
|
|
571
|
+
: null;
|
|
572
|
+
try {
|
|
573
|
+
this._onHandshakeComplete({ channelBinding, remoteStatic });
|
|
574
|
+
} catch (err) {
|
|
575
|
+
this._log.warn?.(`onHandshakeComplete threw: ${err?.message}`);
|
|
576
|
+
}
|
|
577
|
+
this._resolveConnect();
|
|
578
|
+
}
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (this._state === STATE.RESUMING && this._session) {
|
|
583
|
+
// The relay sometimes sends replayed DATA frames before / interleaved
|
|
584
|
+
// with the RESUME_OK control frame. We just decrypt + emit them on the
|
|
585
|
+
// existing session; the resume flow completes when RESUME_OK arrives.
|
|
586
|
+
const pt = this._session.recv(frame.payload);
|
|
587
|
+
this._onMessage(pt);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
throw new Error(`unexpected DATA frame in state=${this._state}`);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
_startHandshakeTimer() {
|
|
595
|
+
this._stopHandshakeTimer();
|
|
596
|
+
this._handshakeTimer = setTimeout(() => {
|
|
597
|
+
this._handshakeTimer = null;
|
|
598
|
+
if (this._state === STATE.HANDSHAKING || this._state === STATE.RESUMING) {
|
|
599
|
+
this._log.warn?.(`handshake/resume timed out (state=${this._state})`);
|
|
600
|
+
// Closing the WS lets the regular onclose path schedule a retry.
|
|
601
|
+
try { this._ws?.close(CLOSE_HANDSHAKE_TIMEOUT, "handshake-timeout"); } catch {}
|
|
602
|
+
}
|
|
603
|
+
}, this._handshakeTimeoutMs);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
_stopHandshakeTimer() {
|
|
607
|
+
if (this._handshakeTimer != null) {
|
|
608
|
+
clearTimeout(this._handshakeTimer);
|
|
609
|
+
this._handshakeTimer = null;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// -------------------------------------------------------------------------
|
|
614
|
+
// Reconnect / ping
|
|
615
|
+
// -------------------------------------------------------------------------
|
|
616
|
+
|
|
617
|
+
_scheduleReconnect() {
|
|
618
|
+
if (this._closed) return;
|
|
619
|
+
const idx = Math.min(this._reconnectAttempt, this._backoffMs.length - 1);
|
|
620
|
+
const waitingForFirstConnect = this._connectPromise != null && this._session == null;
|
|
621
|
+
const rawDelay = this._backoffMs[idx];
|
|
622
|
+
const delay = waitingForFirstConnect
|
|
623
|
+
? Math.min(rawDelay, MAX_PRE_CONNECT_BACKOFF_MS)
|
|
624
|
+
: rawDelay;
|
|
625
|
+
this._reconnectAttempt += 1;
|
|
626
|
+
this._log.debug?.(`reconnect in ${delay}ms (attempt ${this._reconnectAttempt})`);
|
|
627
|
+
this._reconnectTimer = setTimeout(() => {
|
|
628
|
+
this._reconnectTimer = null;
|
|
629
|
+
if (!this._closed) this._open();
|
|
630
|
+
}, delay);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
_scheduleRelayResetReconnect() {
|
|
634
|
+
if (this._closed) return;
|
|
635
|
+
// 4004 means the relay wants both peers to discard stale Noise state and
|
|
636
|
+
// rendezvous again. Treat it as a protocol reset, not a network failure,
|
|
637
|
+
// so app boot does not sit behind the exponential backoff ladder.
|
|
638
|
+
this._reconnectAttempt = 0;
|
|
639
|
+
const delay = RELAY_RESET_RECONNECT_MS;
|
|
640
|
+
this._log.debug?.(`reconnect in ${delay}ms (relay reset)`);
|
|
641
|
+
this._reconnectTimer = setTimeout(() => {
|
|
642
|
+
this._reconnectTimer = null;
|
|
643
|
+
if (!this._closed) this._open();
|
|
644
|
+
}, delay);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
_cancelReconnectTimer() {
|
|
648
|
+
if (this._reconnectTimer != null) {
|
|
649
|
+
clearTimeout(this._reconnectTimer);
|
|
650
|
+
this._reconnectTimer = null;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
_startPing() {
|
|
655
|
+
this._stopPing();
|
|
656
|
+
this._pingTimer = setInterval(() => {
|
|
657
|
+
this._sendPing();
|
|
658
|
+
}, this._pingIntervalMs);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
_stopPing() {
|
|
662
|
+
if (this._pingTimer != null) {
|
|
663
|
+
clearInterval(this._pingTimer);
|
|
664
|
+
this._pingTimer = null;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// -------------------------------------------------------------------------
|
|
669
|
+
// Frame sends
|
|
670
|
+
// -------------------------------------------------------------------------
|
|
671
|
+
|
|
672
|
+
_sendData(payload) {
|
|
673
|
+
this._outboundSeq += 1;
|
|
674
|
+
const wire = encodeData({
|
|
675
|
+
seq: this._outboundSeq,
|
|
676
|
+
mid: generateMid(),
|
|
677
|
+
payload,
|
|
678
|
+
});
|
|
679
|
+
this._wireSend(wire);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
_sendAck(seq) {
|
|
683
|
+
this._wireSend(encodeAck(seq));
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
_sendPing() {
|
|
687
|
+
this._wireSend(encodePing());
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
_sendResumeReq(lastSeenSeq) {
|
|
691
|
+
this._wireSend(encodeResumeReq(lastSeenSeq));
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
_wireSend(bytes) {
|
|
695
|
+
if (!this._ws || this._ws.readyState !== 1 /* OPEN */) {
|
|
696
|
+
this._log.warn?.("send while WS not open — dropping");
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
try {
|
|
700
|
+
this._ws.send(bytes);
|
|
701
|
+
} catch (err) {
|
|
702
|
+
this._onError(err);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// -------------------------------------------------------------------------
|
|
707
|
+
// State / errors
|
|
708
|
+
// -------------------------------------------------------------------------
|
|
709
|
+
|
|
710
|
+
_setState(newState, info) {
|
|
711
|
+
if (this._state === newState) return;
|
|
712
|
+
const prev = this._state;
|
|
713
|
+
this._state = newState;
|
|
714
|
+
if (newState === STATE.CONNECTED) {
|
|
715
|
+
this._reconnectAttempt = 0;
|
|
716
|
+
}
|
|
717
|
+
try {
|
|
718
|
+
this._onStateChange(newState, prev, info);
|
|
719
|
+
} catch (err) {
|
|
720
|
+
this._log.warn?.(`onStateChange threw: ${err?.message}`);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
_resolveConnect() {
|
|
725
|
+
if (this._connectPromise) {
|
|
726
|
+
this._connectPromise.resolve();
|
|
727
|
+
this._connectPromise = null;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
_fail(err) {
|
|
732
|
+
this._setState(STATE.FAILED, { error: err });
|
|
733
|
+
this._stopPing();
|
|
734
|
+
this._stopHandshakeTimer();
|
|
735
|
+
this._cancelReconnectTimer();
|
|
736
|
+
if (this._ws) {
|
|
737
|
+
try { this._ws.close(CLOSE_FATAL, "fatal-error"); } catch {}
|
|
738
|
+
this._ws = null;
|
|
739
|
+
}
|
|
740
|
+
try { this._onError(err); } catch {}
|
|
741
|
+
if (this._connectPromise) {
|
|
742
|
+
this._connectPromise.reject(err);
|
|
743
|
+
this._connectPromise = null;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
_dropNoiseState() {
|
|
748
|
+
this._session = null;
|
|
749
|
+
this._handshake = null;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// ---------------------------------------------------------------------------
|
|
754
|
+
// Helpers
|
|
755
|
+
// ---------------------------------------------------------------------------
|
|
756
|
+
|
|
757
|
+
function noop() {}
|
|
758
|
+
|
|
759
|
+
function normalizeLogger(logger) {
|
|
760
|
+
// Silent default — opting into prefixed console output is the caller's
|
|
761
|
+
// call. Only pull `debug` / `warn` so we don't accidentally invoke
|
|
762
|
+
// arbitrary methods.
|
|
763
|
+
if (!logger) return {};
|
|
764
|
+
return {
|
|
765
|
+
debug: typeof logger.debug === "function" ? logger.debug.bind(logger) : undefined,
|
|
766
|
+
warn: typeof logger.warn === "function" ? logger.warn.bind(logger) : undefined,
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function asU8(data) {
|
|
771
|
+
if (data instanceof Uint8Array) return data;
|
|
772
|
+
if (data instanceof ArrayBuffer) return new Uint8Array(data);
|
|
773
|
+
if (ArrayBuffer.isView(data)) {
|
|
774
|
+
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
775
|
+
}
|
|
776
|
+
if (typeof Blob !== "undefined" && data instanceof Blob) {
|
|
777
|
+
throw new Error("Blob frames not supported; ensure ws.binaryType is 'arraybuffer'");
|
|
778
|
+
}
|
|
779
|
+
if (typeof data === "string") {
|
|
780
|
+
// Shouldn't happen on a binary protocol, but encode to UTF-8 so the
|
|
781
|
+
// decoder fails with a "wrong frame" error instead of a type confusion.
|
|
782
|
+
return new TextEncoder().encode(data);
|
|
783
|
+
}
|
|
784
|
+
throw new TypeError(`unsupported WS message data type: ${typeof data}`);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function deferred() {
|
|
788
|
+
let resolve;
|
|
789
|
+
let reject;
|
|
790
|
+
const promise = new Promise((res, rej) => {
|
|
791
|
+
resolve = res;
|
|
792
|
+
reject = rej;
|
|
793
|
+
});
|
|
794
|
+
return { promise, resolve, reject };
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function describeResumeFail(reason) {
|
|
798
|
+
switch (reason) {
|
|
799
|
+
case RESUME_FAIL_BUFFER_EXPIRED: return "BUFFER_EXPIRED";
|
|
800
|
+
case RESUME_FAIL_UNKNOWN_PAIRING: return "UNKNOWN_PAIRING";
|
|
801
|
+
case RESUME_FAIL_HIBERNATED: return "HIBERNATED";
|
|
802
|
+
default: return `0x${(reason ?? 0).toString(16)}`;
|
|
803
|
+
}
|
|
804
|
+
}
|