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,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
+ }