viveworker 0.7.0 → 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 +1324 -103
  19. package/scripts/viveworker.mjs +27 -6
  20. package/web/app.css +634 -9
  21. package/web/app.js +1731 -187
  22. package/web/i18n.js +187 -9
  23. package/web/index.html +32 -2
  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,765 @@
1
+ /**
2
+ * web/remote-pairing/rpc-client.js — Phone-side fetch-shaped client over the
3
+ * Noise+envelope transport.
4
+ *
5
+ * The phone PWA used to talk to the bridge over LAN HTTP. Now, when the
6
+ * phone is off-LAN, the same calls go through `RemotePairingTransport`
7
+ * (Noise + envelope over a relay WebSocket). This module is the application
8
+ * layer that turns transport.send/onMessage into a fetch-like API:
9
+ *
10
+ * const client = new RemotePairingRpcClient({
11
+ * relayUrl: "wss://pairing.viveworker.com",
12
+ * pairingId,
13
+ * relayToken,
14
+ * identityKeypair, // phone's static X25519 keypair
15
+ * remoteStatic, // bridge's static pub (from LAN pairing)
16
+ * onConnected: () => {},
17
+ * onDisconnected: ({ reason }) => {},
18
+ * onEvent: (topic, data) => {}, // optional global event handler
19
+ * });
20
+ * await client.connect();
21
+ * const res = await client.fetch({
22
+ * method: "POST",
23
+ * path: "/api/foo",
24
+ * headers: { "content-type": "application/json" },
25
+ * body: JSON.stringify({...}),
26
+ * signal: ac.signal, // cancels via cancel-frame
27
+ * timeoutMs: 30_000, // optional override
28
+ * });
29
+ * // res = { status, headers, text, json, bytes, arrayBuffer }
30
+ *
31
+ * Design choices:
32
+ * - Composition, not inheritance. The client owns a RemotePairingTransport
33
+ * internally; callers don't construct one themselves. This keeps the
34
+ * onMessage/onStateChange callbacks tightly bound to id-correlation
35
+ * bookkeeping.
36
+ * - In-flight pending requests survive transient reconnects. The transport
37
+ * handles RESUME — the relay replays missed frames — so a response that
38
+ * arrives after a disconnect/reconnect still lands on the right id.
39
+ * - On *fatal* (FAILED) state the client rejects every pending request.
40
+ * The transport considers RESUME_FAIL "permanent" and pushes itself to
41
+ * FAILED; we mirror that by failing all pending fetches.
42
+ * - Body encoding is auto-detected: string → utf8, Uint8Array/ArrayBuffer
43
+ * → base64. Response bodies preserve their bodyEncoding and the result
44
+ * object exposes `.text()` / `.json()` / `.bytes()` / `.arrayBuffer()`
45
+ * helpers so callers don't have to think about it.
46
+ */
47
+
48
+ import {
49
+ RemotePairingTransport,
50
+ STATE,
51
+ } from "./transport.js";
52
+ import {
53
+ encodeRequest,
54
+ encodeCancel,
55
+ decodeRpc,
56
+ RPC,
57
+ MAX_RPC_ID_LEN,
58
+ } from "../remote-pairing.bundle.js";
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Constants
62
+ // ---------------------------------------------------------------------------
63
+
64
+ /** Default timeout for an individual fetch — 60s covers long-poll inboxes. */
65
+ const DEFAULT_FETCH_TIMEOUT_MS = 60_000;
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // RemotePairingRpcClient
69
+ // ---------------------------------------------------------------------------
70
+
71
+ /**
72
+ * @typedef {Object} RpcClientOptions
73
+ * Mostly forwarded to RemotePairingTransport (see transport.js for details).
74
+ *
75
+ * @property {string} relayUrl
76
+ * @property {string} pairingId
77
+ * @property {string} relayToken
78
+ * @property {{priv: Uint8Array, pub: Uint8Array}} identityKeypair
79
+ * @property {Uint8Array} remoteStatic bridge's static pub
80
+ * @property {"phone" | "bridge"} [role] defaults "phone"
81
+ * @property {boolean} [initiator] defaults true (phone is initiator)
82
+ * @property {Uint8Array} [prologue]
83
+ * @property {number} [pingIntervalMs]
84
+ * @property {number[]} [backoffMs]
85
+ * @property {number} [handshakeTimeoutMs]
86
+ * @property {typeof WebSocket} [WebSocketImpl]
87
+ *
88
+ * Application hooks (optional):
89
+ * @property {() => void} [onConnected] fires every time CONNECTED is reached
90
+ * @property {(info: object) => void} [onDisconnected] fires when leaving CONNECTED
91
+ * @property {(state: string, prev: string, info?: object) => void} [onStateChange]
92
+ * @property {(err: Error) => void} [onError]
93
+ * @property {(topic: string, data: unknown) => void} [onEvent]
94
+ *
95
+ * Other:
96
+ * @property {number} [defaultTimeoutMs] default per-fetch timeout
97
+ * @property {{debug?:Function, warn?:Function, error?:Function}} [logger]
98
+ *
99
+ * Test seam:
100
+ * @property {(transportOpts: object) => TransportLike} [transportFactory]
101
+ * Override the default `RemotePairingTransport` constructor with
102
+ * a fake. The factory is invoked with the same options shape the
103
+ * transport accepts, including `onMessage` / `onStateChange` /
104
+ * `onError` callbacks bound to the client's internal handlers.
105
+ * Used by unit tests to drive the RPC layer without a real WS.
106
+ */
107
+
108
+ /**
109
+ * @typedef {Object} TransportLike
110
+ * Minimal subset of RemotePairingTransport the RPC client uses.
111
+ * @property {string} state
112
+ * @property {boolean} isConnected
113
+ * @property {Uint8Array | null} channelBinding
114
+ * @property {() => Promise<void>} connect
115
+ * @property {(plaintext: Uint8Array) => void} send
116
+ * @property {() => void} close
117
+ * @property {() => void} kick
118
+ */
119
+
120
+ /**
121
+ * @typedef {Object} RpcResponse
122
+ * @property {number} status
123
+ * @property {Record<string,string>} headers
124
+ * @property {() => string} text
125
+ * @property {() => unknown} json throws if body isn't JSON
126
+ * @property {() => Uint8Array} bytes
127
+ * @property {() => ArrayBuffer} arrayBuffer
128
+ * @property {string} bodyRaw raw body string (utf8 or base64)
129
+ * @property {"utf8" | "base64"} bodyEncoding
130
+ */
131
+
132
+ export class RemotePairingRpcClient {
133
+ /** @param {RpcClientOptions} opts */
134
+ constructor(opts) {
135
+ if (!opts) throw new TypeError("RemotePairingRpcClient: opts required");
136
+
137
+ this._log = normalizeLogger(opts.logger);
138
+ this._defaultTimeoutMs = opts.defaultTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
139
+
140
+ // Hooks
141
+ this._onConnected = opts.onConnected ?? noop;
142
+ this._onDisconnected = opts.onDisconnected ?? noop;
143
+ this._onStateChange = opts.onStateChange ?? noop;
144
+ this._onError = opts.onError ?? noop;
145
+ this._onEvent = opts.onEvent ?? noop;
146
+
147
+ /** @type {Map<string, PendingSlot>} pending fetches by id */
148
+ this._pending = new Map();
149
+ /** @type {Map<string, Set<(data: unknown) => void>>} per-topic listeners */
150
+ this._listeners = new Map();
151
+ this._closed = false;
152
+
153
+ /**
154
+ * Counter for request id generation. Combined with a random prefix per
155
+ * client instance, this keeps ids unique across instances without
156
+ * needing a heavyweight UUID. The relay won't see ids (they're inside
157
+ * the encrypted channel), so collision risk is purely client-internal.
158
+ */
159
+ this._idCounter = 0;
160
+ this._idPrefix = randHex(6); // 12 hex chars
161
+
162
+ // Build the transport with our callbacks wired in. A test-only
163
+ // `transportFactory` option lets unit tests substitute a fake.
164
+ const factory = typeof opts.transportFactory === "function"
165
+ ? opts.transportFactory
166
+ : (transportOpts) => new RemotePairingTransport(transportOpts);
167
+
168
+ this._transport = factory({
169
+ relayUrl: opts.relayUrl,
170
+ pairingId: opts.pairingId,
171
+ relayToken: opts.relayToken,
172
+ role: opts.role ?? "phone",
173
+ initiator: opts.initiator,
174
+ identityKeypair: opts.identityKeypair,
175
+ remoteStatic: opts.remoteStatic,
176
+ prologue: opts.prologue,
177
+ pingIntervalMs: opts.pingIntervalMs,
178
+ backoffMs: opts.backoffMs,
179
+ handshakeTimeoutMs: opts.handshakeTimeoutMs,
180
+ WebSocketImpl: opts.WebSocketImpl,
181
+ logger: opts.logger,
182
+
183
+ onMessage: (pt) => this._onMessage(pt),
184
+ onStateChange: (next, prev, info) => this._handleStateChange(next, prev, info),
185
+ onError: (err) => this._onError(err),
186
+ });
187
+ }
188
+
189
+ // -------------------------------------------------------------------------
190
+ // Public API
191
+ // -------------------------------------------------------------------------
192
+
193
+ /** Current transport state (one of STATE.*). */
194
+ get state() {
195
+ return this._transport.state;
196
+ }
197
+
198
+ /** True iff the encrypted channel is up and `fetch()` will actually send. */
199
+ get isConnected() {
200
+ return this._transport.isConnected;
201
+ }
202
+
203
+ /** Channel binding (null until the first handshake completes). */
204
+ get channelBinding() {
205
+ return this._transport.channelBinding;
206
+ }
207
+
208
+ /** Number of currently in-flight fetch() calls. */
209
+ get pendingCount() {
210
+ return this._pending.size;
211
+ }
212
+
213
+ /**
214
+ * Open the relay WebSocket and run the Noise handshake. Resolves when the
215
+ * encrypted channel is up; reconnects after that are handled by the
216
+ * transport automatically.
217
+ */
218
+ async connect() {
219
+ if (this._closed) throw new RpcClientClosedError("rpc client closed");
220
+ return this._transport.connect();
221
+ }
222
+
223
+ /**
224
+ * Force an immediate reconnect attempt (skip backoff). Useful for
225
+ * visibilitychange / Web Push wake events.
226
+ */
227
+ kick() {
228
+ if (this._closed) return;
229
+ this._transport.kick();
230
+ }
231
+
232
+ /**
233
+ * Tear down. Rejects every pending fetch with `RpcClientClosedError`,
234
+ * closes the transport, and stops accepting new fetches/connects.
235
+ * Idempotent.
236
+ */
237
+ close() {
238
+ if (this._closed) return;
239
+ this._closed = true;
240
+
241
+ // Fail every in-flight request.
242
+ const err = new RpcClientClosedError("rpc client closed");
243
+ for (const slot of this._pending.values()) {
244
+ this._cleanupSlot(slot);
245
+ slot.reject(err);
246
+ }
247
+ this._pending.clear();
248
+
249
+ // Drop topic listeners — they aren't pending requests but callers
250
+ // shouldn't keep getting events post-close.
251
+ this._listeners.clear();
252
+
253
+ try { this._transport.close(); } catch (e) {
254
+ this._log.warn?.(`transport.close threw: ${e?.message}`);
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Send a fetch-shaped request. The promise resolves with an `RpcResponse`
260
+ * once the bridge's response arrives, or rejects on timeout / abort /
261
+ * transport failure / cancel-by-peer.
262
+ *
263
+ * @param {{
264
+ * method: string,
265
+ * path: string,
266
+ * headers?: Record<string,string>,
267
+ * body?: string | Uint8Array | ArrayBuffer | null,
268
+ * bodyEncoding?: "utf8" | "base64",
269
+ * signal?: AbortSignal,
270
+ * timeoutMs?: number,
271
+ * }} req
272
+ * @returns {Promise<RpcResponse>}
273
+ */
274
+ fetch(req) {
275
+ if (this._closed) {
276
+ return Promise.reject(new RpcClientClosedError("rpc client closed"));
277
+ }
278
+ if (!req || typeof req !== "object") {
279
+ return Promise.reject(new TypeError("fetch: req object required"));
280
+ }
281
+ if (typeof req.method !== "string" || typeof req.path !== "string") {
282
+ return Promise.reject(new TypeError("fetch: method + path required (strings)"));
283
+ }
284
+ if (req.signal?.aborted) {
285
+ return Promise.reject(abortError(req.signal));
286
+ }
287
+
288
+ const id = this._nextId();
289
+ let body, bodyEncoding;
290
+ try {
291
+ ({ body, bodyEncoding } = normalizeBody(req.body, req.bodyEncoding));
292
+ } catch (err) {
293
+ return Promise.reject(err);
294
+ }
295
+
296
+ let frame;
297
+ try {
298
+ frame = encodeRequest({
299
+ id,
300
+ method: req.method,
301
+ path: req.path,
302
+ headers: req.headers,
303
+ body,
304
+ bodyEncoding,
305
+ });
306
+ } catch (err) {
307
+ return Promise.reject(err);
308
+ }
309
+
310
+ return new Promise((resolve, reject) => {
311
+ const timeoutMs = req.timeoutMs ?? this._defaultTimeoutMs;
312
+ /** @type {ReturnType<typeof setTimeout> | null} */
313
+ const timer = timeoutMs > 0
314
+ ? setTimeout(() => this._timeoutSlot(id), timeoutMs)
315
+ : null;
316
+
317
+ /** @type {(() => void) | null} */
318
+ let abortHandler = null;
319
+ if (req.signal) {
320
+ abortHandler = () => this._abortSlot(id, req.signal);
321
+ req.signal.addEventListener("abort", abortHandler, { once: true });
322
+ }
323
+
324
+ /** @type {PendingSlot} */
325
+ const slot = {
326
+ id,
327
+ resolve,
328
+ reject,
329
+ timer,
330
+ signal: req.signal ?? null,
331
+ abortHandler,
332
+ sent: false,
333
+ };
334
+ this._pending.set(id, slot);
335
+
336
+ // Ensure the encrypted channel is up before sending. This lets callers
337
+ // issue fetch() immediately after constructing the client; the request
338
+ // waits for the first handshake instead of failing with "not connected".
339
+ Promise.resolve()
340
+ .then(() => this.connect())
341
+ .then(() => {
342
+ if (!this._pending.has(id)) return; // timed out / aborted / closed while connecting
343
+ if (req.signal?.aborted) {
344
+ this._abortSlot(id, req.signal);
345
+ return;
346
+ }
347
+ try {
348
+ this._transport.send(frame);
349
+ slot.sent = true;
350
+ } catch (err) {
351
+ this._cleanupSlot(slot);
352
+ this._pending.delete(id);
353
+ reject(wrapTransportError(err));
354
+ }
355
+ })
356
+ .catch((err) => {
357
+ if (!this._pending.has(id)) return;
358
+ this._cleanupSlot(slot);
359
+ this._pending.delete(id);
360
+ reject(err instanceof RpcClientClosedError ? err : wrapTransportError(err));
361
+ });
362
+ });
363
+ }
364
+
365
+ /**
366
+ * Subscribe to server-pushed events on a given topic. Returns an
367
+ * unsubscribe function. The constructor's `onEvent` hook also receives
368
+ * every event — `on()` is for topic-scoped subscribers.
369
+ *
370
+ * @param {string} topic
371
+ * @param {(data: unknown) => void} handler
372
+ * @returns {() => void}
373
+ */
374
+ on(topic, handler) {
375
+ if (typeof topic !== "string" || topic.length === 0) {
376
+ throw new TypeError("on: topic required (non-empty string)");
377
+ }
378
+ if (typeof handler !== "function") {
379
+ throw new TypeError("on: handler required (function)");
380
+ }
381
+ let set = this._listeners.get(topic);
382
+ if (!set) {
383
+ set = new Set();
384
+ this._listeners.set(topic, set);
385
+ }
386
+ set.add(handler);
387
+ return () => this.off(topic, handler);
388
+ }
389
+
390
+ /** @param {string} topic @param {(data: unknown) => void} handler */
391
+ off(topic, handler) {
392
+ const set = this._listeners.get(topic);
393
+ if (!set) return;
394
+ set.delete(handler);
395
+ if (set.size === 0) this._listeners.delete(topic);
396
+ }
397
+
398
+ // -------------------------------------------------------------------------
399
+ // Internals
400
+ // -------------------------------------------------------------------------
401
+
402
+ /** @returns {string} new request id */
403
+ _nextId() {
404
+ // Format: "<prefix>-<counter-base36>" — short, unique per client instance.
405
+ const id = `${this._idPrefix}-${(this._idCounter++).toString(36)}`;
406
+ if (id.length > MAX_RPC_ID_LEN) {
407
+ // Counter wraparound is wildly improbable (would need 36^N requests in
408
+ // one client) but defensively reset rather than blow past the cap.
409
+ this._idPrefix = randHex(6);
410
+ this._idCounter = 0;
411
+ return `${this._idPrefix}-0`;
412
+ }
413
+ return id;
414
+ }
415
+
416
+ /** @param {Uint8Array} plaintext */
417
+ _onMessage(plaintext) {
418
+ /** @type {ReturnType<typeof decodeRpc>} */
419
+ let frame;
420
+ try {
421
+ frame = decodeRpc(plaintext);
422
+ } catch (err) {
423
+ this._log.warn?.(`rpc decode failed: ${err?.message}`);
424
+ this._onError(new Error(`rpc decode failed: ${err?.message}`));
425
+ return;
426
+ }
427
+
428
+ switch (frame.type) {
429
+ case RPC.RESPONSE: {
430
+ const slot = this._pending.get(frame.id);
431
+ if (!slot) {
432
+ // Late or unknown response — bridge cancelled but we already
433
+ // gave up locally, or the id is from an older client run that
434
+ // RESUMEd into us. Either way, drop quietly.
435
+ this._log.debug?.(`rpc: dropping response for unknown id ${frame.id}`);
436
+ return;
437
+ }
438
+ this._pending.delete(frame.id);
439
+ this._cleanupSlot(slot);
440
+ slot.resolve(makeResponse(frame));
441
+ return;
442
+ }
443
+ case RPC.EVENT: {
444
+ // Per-topic subscribers
445
+ const set = this._listeners.get(frame.topic);
446
+ if (set) {
447
+ for (const fn of [...set]) {
448
+ try { fn(frame.data); }
449
+ catch (err) {
450
+ this._log.warn?.(`event handler for ${frame.topic} threw: ${err?.message}`);
451
+ }
452
+ }
453
+ }
454
+ // Global hook — every event regardless of topic
455
+ try { this._onEvent(frame.topic, frame.data); }
456
+ catch (err) {
457
+ this._log.warn?.(`onEvent hook threw: ${err?.message}`);
458
+ }
459
+ return;
460
+ }
461
+ case RPC.CANCEL: {
462
+ // Server-initiated cancel for a request the bridge previously
463
+ // answered with a streaming/incremental response. Our v1 only
464
+ // supports unary req/res, so a cancel from the bridge means the
465
+ // bridge is telling us it gave up — fail the matching slot.
466
+ const slot = this._pending.get(frame.id);
467
+ if (slot) {
468
+ this._pending.delete(frame.id);
469
+ this._cleanupSlot(slot);
470
+ slot.reject(new RpcCancelledByPeerError(`bridge cancelled request ${frame.id}`));
471
+ }
472
+ return;
473
+ }
474
+ case RPC.REQUEST:
475
+ // Phone shouldn't receive RPC requests from the bridge today —
476
+ // dispatch is one-directional (phone → bridge). Log and drop.
477
+ this._log.warn?.("rpc: ignoring unexpected request from bridge");
478
+ return;
479
+ }
480
+ }
481
+
482
+ /** @param {string} state @param {string} prev @param {object} [info] */
483
+ _handleStateChange(state, prev, info) {
484
+ // Surface to caller's general state-change hook first.
485
+ try { this._onStateChange(state, prev, info); }
486
+ catch (err) {
487
+ this._log.warn?.(`onStateChange hook threw: ${err?.message}`);
488
+ }
489
+
490
+ // Specific entry/exit hooks.
491
+ if (state === STATE.CONNECTED && prev !== STATE.CONNECTED) {
492
+ try { this._onConnected(); } catch (err) {
493
+ this._log.warn?.(`onConnected hook threw: ${err?.message}`);
494
+ }
495
+ }
496
+ if (prev === STATE.CONNECTED && state !== STATE.CONNECTED) {
497
+ try { this._onDisconnected(info ?? {}); } catch (err) {
498
+ this._log.warn?.(`onDisconnected hook threw: ${err?.message}`);
499
+ }
500
+ }
501
+
502
+ // FAILED is terminal — fail every pending request immediately. The
503
+ // transport won't reconnect from FAILED so the response would never
504
+ // arrive, and callers want a real error rather than a stuck promise.
505
+ if (state === STATE.FAILED) {
506
+ const err = new RpcTransportFailedError(
507
+ `transport entered FAILED state (${describeInfo(info)})`,
508
+ );
509
+ for (const [id, slot] of this._pending) {
510
+ this._cleanupSlot(slot);
511
+ slot.reject(err);
512
+ this._pending.delete(id);
513
+ }
514
+ }
515
+ }
516
+
517
+ _timeoutSlot(id) {
518
+ const slot = this._pending.get(id);
519
+ if (!slot) return;
520
+ this._pending.delete(id);
521
+ this._cleanupSlot(slot);
522
+ // Best-effort cancel to free bridge-side resources.
523
+ this._safeSendCancel(id);
524
+ slot.reject(new RpcTimeoutError(`rpc timed out (id=${id})`));
525
+ }
526
+
527
+ /** @param {string} id @param {AbortSignal} signal */
528
+ _abortSlot(id, signal) {
529
+ const slot = this._pending.get(id);
530
+ if (!slot) return;
531
+ this._pending.delete(id);
532
+ this._cleanupSlot(slot);
533
+ this._safeSendCancel(id);
534
+ slot.reject(abortError(signal));
535
+ }
536
+
537
+ /** @param {string} id */
538
+ _safeSendCancel(id) {
539
+ if (!this._transport.isConnected) return; // can't send; bridge will GC anyway
540
+ try {
541
+ this._transport.send(encodeCancel(id));
542
+ } catch (err) {
543
+ this._log.debug?.(`cancel-send failed (id=${id}): ${err?.message}`);
544
+ }
545
+ }
546
+
547
+ /** @param {PendingSlot} slot */
548
+ _cleanupSlot(slot) {
549
+ if (slot.timer) {
550
+ clearTimeout(slot.timer);
551
+ slot.timer = null;
552
+ }
553
+ if (slot.signal && slot.abortHandler) {
554
+ try { slot.signal.removeEventListener("abort", slot.abortHandler); } catch {}
555
+ slot.abortHandler = null;
556
+ }
557
+ }
558
+ }
559
+
560
+ // ===========================================================================
561
+ // Error types — typed so callers can `instanceof` and react accordingly.
562
+ // ===========================================================================
563
+
564
+ export class RpcTimeoutError extends Error {
565
+ constructor(message) { super(message); this.name = "RpcTimeoutError"; }
566
+ }
567
+
568
+ export class RpcClientClosedError extends Error {
569
+ constructor(message) { super(message); this.name = "RpcClientClosedError"; }
570
+ }
571
+
572
+ export class RpcTransportFailedError extends Error {
573
+ constructor(message) { super(message); this.name = "RpcTransportFailedError"; }
574
+ }
575
+
576
+ export class RpcCancelledByPeerError extends Error {
577
+ constructor(message) { super(message); this.name = "RpcCancelledByPeerError"; }
578
+ }
579
+
580
+ export class RpcTransportError extends Error {
581
+ constructor(message, cause) {
582
+ super(message);
583
+ this.name = "RpcTransportError";
584
+ if (cause) this.cause = cause;
585
+ }
586
+ }
587
+
588
+ // ===========================================================================
589
+ // Helpers
590
+ // ===========================================================================
591
+
592
+ /**
593
+ * @typedef {Object} PendingSlot
594
+ * @property {string} id
595
+ * @property {(value: RpcResponse) => void} resolve
596
+ * @property {(reason: Error) => void} reject
597
+ * @property {ReturnType<typeof setTimeout> | null} timer
598
+ * @property {AbortSignal | null} signal
599
+ * @property {(() => void) | null} abortHandler
600
+ * @property {boolean} sent
601
+ */
602
+
603
+ function noop() {}
604
+
605
+ function normalizeLogger(l) {
606
+ if (!l) return {};
607
+ return {
608
+ debug: typeof l.debug === "function" ? l.debug.bind(l) : undefined,
609
+ warn: typeof l.warn === "function" ? l.warn.bind(l) : undefined,
610
+ error: typeof l.error === "function" ? l.error.bind(l) : undefined,
611
+ };
612
+ }
613
+
614
+ function abortError(signal) {
615
+ // Browser DOMException("Aborted", "AbortError") matches fetch() semantics.
616
+ if (typeof DOMException === "function") {
617
+ return new DOMException(signal?.reason?.message ?? "Aborted", "AbortError");
618
+ }
619
+ // Node fallback (DOMException is a global in modern Node but be safe).
620
+ const err = new Error(signal?.reason?.message ?? "Aborted");
621
+ err.name = "AbortError";
622
+ return err;
623
+ }
624
+
625
+ function wrapTransportError(err) {
626
+ if (err instanceof RpcTransportError) return err;
627
+ return new RpcTransportError(`transport.send failed: ${err?.message ?? err}`, err);
628
+ }
629
+
630
+ /**
631
+ * Normalize body input: accept string / Uint8Array / ArrayBuffer / null /
632
+ * undefined, and emit `{ body: string | undefined, bodyEncoding: "utf8" | "base64" | undefined }`
633
+ * suitable for `encodeRequest`.
634
+ */
635
+ function normalizeBody(body, bodyEncoding) {
636
+ if (body == null || body === "") return { body: undefined, bodyEncoding: undefined };
637
+ if (typeof body === "string") {
638
+ return { body, bodyEncoding: bodyEncoding ?? "utf8" };
639
+ }
640
+ // Binary inputs → base64
641
+ let bytes;
642
+ if (body instanceof Uint8Array) {
643
+ bytes = body;
644
+ } else if (body instanceof ArrayBuffer) {
645
+ bytes = new Uint8Array(body);
646
+ } else if (ArrayBuffer.isView(body)) {
647
+ bytes = new Uint8Array(body.buffer, body.byteOffset, body.byteLength);
648
+ } else {
649
+ throw new TypeError(
650
+ "fetch: body must be string, Uint8Array, ArrayBuffer, or null",
651
+ );
652
+ }
653
+ return { body: bytesToBase64(bytes), bodyEncoding: "base64" };
654
+ }
655
+
656
+ /**
657
+ * Build the {status, headers, text(), json(), bytes(), arrayBuffer(), bodyRaw,
658
+ * bodyEncoding} response object from a decoded RPC response frame.
659
+ */
660
+ function makeResponse(frame) {
661
+ const bodyRaw = frame.body ?? "";
662
+ const bodyEncoding = frame.bodyEncoding ?? "utf8";
663
+ /** @type {Uint8Array | null} */
664
+ let cachedBytes = null;
665
+ /** @type {string | null} */
666
+ let cachedText = null;
667
+
668
+ function bytes() {
669
+ if (cachedBytes) return cachedBytes;
670
+ if (bodyEncoding === "base64") {
671
+ cachedBytes = base64ToBytes(bodyRaw);
672
+ } else {
673
+ cachedBytes = new TextEncoder().encode(bodyRaw);
674
+ }
675
+ return cachedBytes;
676
+ }
677
+
678
+ function text() {
679
+ if (cachedText !== null) return cachedText;
680
+ if (bodyEncoding === "utf8") {
681
+ cachedText = bodyRaw;
682
+ } else {
683
+ cachedText = new TextDecoder("utf-8", { fatal: false }).decode(bytes());
684
+ }
685
+ return cachedText;
686
+ }
687
+
688
+ function arrayBuffer() {
689
+ const b = bytes();
690
+ return b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength);
691
+ }
692
+
693
+ function json() {
694
+ const t = text();
695
+ if (t === "") return undefined;
696
+ return JSON.parse(t);
697
+ }
698
+
699
+ return {
700
+ status: frame.status,
701
+ headers: { ...(frame.headers ?? {}) },
702
+ bodyRaw,
703
+ bodyEncoding,
704
+ text,
705
+ json,
706
+ bytes,
707
+ arrayBuffer,
708
+ };
709
+ }
710
+
711
+ /**
712
+ * Best-effort description of a state-change `info` payload for error
713
+ * messages. Pulls the first useful field rather than dumping JSON.
714
+ */
715
+ function describeInfo(info) {
716
+ if (!info || typeof info !== "object") return "no info";
717
+ if (typeof info.reason === "string") return `reason=${info.reason}`;
718
+ if (info.error && typeof info.error.message === "string") {
719
+ return `error=${info.error.message}`;
720
+ }
721
+ return "no info";
722
+ }
723
+
724
+ /** @param {number} bytes @returns {string} hex string */
725
+ function randHex(bytes) {
726
+ const buf = new Uint8Array(bytes);
727
+ if (typeof globalThis.crypto?.getRandomValues === "function") {
728
+ globalThis.crypto.getRandomValues(buf);
729
+ } else {
730
+ for (let i = 0; i < bytes; i++) buf[i] = (Math.random() * 256) | 0;
731
+ }
732
+ let out = "";
733
+ for (let i = 0; i < buf.length; i++) out += buf[i].toString(16).padStart(2, "0");
734
+ return out;
735
+ }
736
+
737
+ // ---------------------------------------------------------------------------
738
+ // Base64 — small browser/Node-safe helpers, shared with the bridge response
739
+ // path. We can't use Node's Buffer in the browser and atob/btoa are
740
+ // browser-only, so handle both.
741
+ // ---------------------------------------------------------------------------
742
+
743
+ function bytesToBase64(bytes) {
744
+ // btoa path (browser)
745
+ if (typeof btoa === "function") {
746
+ let bin = "";
747
+ for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
748
+ return btoa(bin);
749
+ }
750
+ // Node path
751
+ return Buffer.from(bytes).toString("base64");
752
+ }
753
+
754
+ function base64ToBytes(b64) {
755
+ if (typeof atob === "function") {
756
+ const bin = atob(b64);
757
+ const out = new Uint8Array(bin.length);
758
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
759
+ return out;
760
+ }
761
+ return new Uint8Array(Buffer.from(b64, "base64"));
762
+ }
763
+
764
+ // Re-export STATE so consumers don't need a separate import for state checks.
765
+ export { STATE };