viveworker 0.7.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/plugins/marketplace.json +20 -0
- package/README.md +115 -4
- package/package.json +13 -3
- package/plugins/viveworker-control-plane/.codex-plugin/plugin.json +49 -0
- package/plugins/viveworker-control-plane/.mcp.json +11 -0
- package/plugins/viveworker-control-plane/assets/viveworker-logo-v2.png +0 -0
- package/plugins/viveworker-control-plane/assets/viveworker-logo-v2.svg +39 -0
- package/plugins/viveworker-control-plane/skills/viveworker-control-plane/SKILL.md +83 -0
- package/scripts/lib/markdown-render.mjs +128 -1
- package/scripts/lib/remote-pairing/README.md +164 -0
- package/scripts/lib/remote-pairing/_browser-bundle-entry.mjs +86 -0
- package/scripts/lib/remote-pairing/audit.mjs +122 -0
- package/scripts/lib/remote-pairing/bridge-relay-client.mjs +737 -0
- package/scripts/lib/remote-pairing/control.mjs +156 -0
- package/scripts/lib/remote-pairing/envelope.mjs +224 -0
- package/scripts/lib/remote-pairing/http-dispatch.mjs +530 -0
- package/scripts/lib/remote-pairing/keys-core.mjs +110 -0
- package/scripts/lib/remote-pairing/keys.mjs +181 -0
- package/scripts/lib/remote-pairing/noise.mjs +436 -0
- package/scripts/lib/remote-pairing/orchestrator.mjs +356 -0
- package/scripts/lib/remote-pairing/pairings.mjs +446 -0
- package/scripts/lib/remote-pairing/rpc.mjs +381 -0
- package/scripts/mcp-server.mjs +891 -0
- package/scripts/moltbook-scout-auto.sh +16 -8
- package/scripts/share-cli.mjs +14 -130
- package/scripts/stats-cli.mjs +683 -0
- package/scripts/viveworker-bridge.mjs +1676 -127
- package/scripts/viveworker.mjs +289 -7
- package/skills/viveworker-control-plane/SKILL.md +104 -0
- package/skills/viveworker-control-plane/agents/openai.yaml +12 -0
- package/templates/CLAUDE.viveworker.md +67 -0
- package/web/app.css +683 -9
- package/web/app.js +1780 -187
- package/web/build-id.js +1 -0
- package/web/i18n.js +187 -9
- package/web/icons/apple-touch-icon.png +0 -0
- package/web/icons/viveworker-icon-192.png +0 -0
- package/web/icons/viveworker-icon-512.png +0 -0
- package/web/icons/viveworker-v-pulse.svg +16 -1
- package/web/index.html +32 -2
- package/web/remote-pairing/api-router.js +873 -0
- package/web/remote-pairing/keys.js +237 -0
- package/web/remote-pairing/pairing-state.js +313 -0
- package/web/remote-pairing/rpc-client.js +765 -0
- package/web/remote-pairing/transport.js +804 -0
- package/web/remote-pairing/wake.js +149 -0
- package/web/remote-pairing-test.html +400 -0
- package/web/remote-pairing.bundle.js +3 -0
- package/web/remote-pairing.bundle.js.LEGAL.txt +15 -0
- package/web/remote-pairing.bundle.js.map +7 -0
- package/web/sw.js +190 -20
- package/web/icons/viveworker-beacon-v.svg +0 -19
- package/web/icons/viveworker-icon-1024.png +0 -0
- package/web/icons/viveworker-v-check.svg +0 -19
|
@@ -0,0 +1,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 };
|