rechrome 1.36.0 → 1.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/webrtc.ts ADDED
@@ -0,0 +1,535 @@
1
+ // WebRTC transport for shares: the same HTTP API (/ping, /run, /files, …) carried over an
2
+ // end-to-end-encrypted DataChannel, so a host behind NAT needs no open port.
3
+ //
4
+ // host (rech serve) ── hello{role:host} ──▶ signal room ◀── hello{role:client} ── client
5
+ // offer, candidates ─────────────────────────────▶ answer, candidates
6
+ // ═════════════ DataChannel, peer to peer, sealed with keys the signal server never had ═════════════
7
+ //
8
+ // One room per credential (an unused invite or a device): room id and room auth are one-way
9
+ // HKDF outputs of that credential's rech1_ secret (secret.ts), and so are the AES keys (e2e.ts).
10
+ // The client side runs as a small loopback HTTP "bridge", so every existing fetch-based client
11
+ // path works unchanged against it.
12
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "fs";
13
+ import { join } from "path";
14
+ import { homedir } from "os";
15
+ import { randomBytes } from "crypto";
16
+ import { roomAuthOf, roomOf } from "./secret.ts";
17
+ import { CONFIRM_TIMEOUT_MS, FLAG_CONFIRM, MAX_CHUNK, deriveDirKeys, newRecvState, newSendState, open, pack, randomHex, seal, transcriptHash, unpack, type RecvState, type SendState } from "./e2e.ts";
18
+
19
+ export const SIGNAL_PROTO = "rech-signal-1";
20
+ const PING = '{"type":"ping"}';
21
+ const KEEPALIVE_MS = 25_000;
22
+ const CONNECT_TIMEOUT_MS = 25_000;
23
+ const MAX_PEERS_PER_ROOM = 8;
24
+ const DEFAULT_STUN = [{ urls: "stun:stun.cloudflare.com:3478" }, { urls: "stun:stun.l.google.com:19302" }];
25
+
26
+ type IceServer = { urls: string | string[]; username?: string; credential?: string };
27
+ type Log = (msg: string) => void;
28
+
29
+ // ---- runtime: node-datachannel ---------------------------------------------------------
30
+ // A native module. Inside this repo it is a (trusted) dev dependency; a global `bun i -g
31
+ // rechrome` can't run its postinstall, so on first use we install it once into
32
+ // ~/.rechrome/webrtc, where our own package.json marks it trusted.
33
+ export const WEBRTC_RUNTIME_DIR = join(homedir(), ".rechrome", "webrtc");
34
+ let rtcModule: Promise<any> | undefined;
35
+ export function loadRTC(log: Log = m => console.error(m)): Promise<{ RTCPeerConnection: any }> {
36
+ return rtcModule ??= (async () => {
37
+ try { return await import("node-datachannel/polyfill"); } catch { /* fall through to the runtime dir */ }
38
+ const entry = () => { try { return Bun.resolveSync("node-datachannel/polyfill", WEBRTC_RUNTIME_DIR); } catch { return null; } };
39
+ let path = entry();
40
+ if (path) try { return await import(path); } catch { path = null; }
41
+ log(`[rech] one-time setup: installing WebRTC support (node-datachannel) into ${WEBRTC_RUNTIME_DIR}`);
42
+ mkdirSync(WEBRTC_RUNTIME_DIR, { recursive: true });
43
+ writeFileSync(join(WEBRTC_RUNTIME_DIR, "package.json"), JSON.stringify({
44
+ private: true, dependencies: { "node-datachannel": "^0.32.3" }, trustedDependencies: ["node-datachannel"],
45
+ }, null, 2));
46
+ const proc = Bun.spawn([process.execPath, "install"], { cwd: WEBRTC_RUNTIME_DIR, stdout: "ignore", stderr: "pipe", env: { ...process.env, BUN_BE_BUN: "1" } });
47
+ const [status, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()]);
48
+ path = entry();
49
+ if (status !== 0 || !path) throw new Error(`Could not install WebRTC support (node-datachannel): ${stderr.trim().slice(-400) || `bun install exited ${status}`}`);
50
+ return import(path);
51
+ })();
52
+ }
53
+
54
+ export function iceServersFromEnv(env = process.env): IceServer[] {
55
+ if (env.RECH_ICE_SERVERS) {
56
+ try { return JSON.parse(env.RECH_ICE_SERVERS); } catch { throw new Error("RECH_ICE_SERVERS must be a JSON array of {urls, username?, credential?}"); }
57
+ }
58
+ return DEFAULT_STUN;
59
+ }
60
+
61
+ // Short-lived Cloudflare TURN credentials from the HOST owner's own TURN key (CF_TURN_KEY_ID +
62
+ // CF_TURN_API_TOKEN), handed to each peer in the offer. Without them: STUN only.
63
+ let turnCache: { servers: IceServer[]; exp: number } | null = null;
64
+ export async function hostIceServers(log: Log, env = process.env): Promise<IceServer[]> {
65
+ const base = iceServersFromEnv(env);
66
+ const keyId = env.CF_TURN_KEY_ID, apiToken = env.CF_TURN_API_TOKEN;
67
+ if (!keyId || !apiToken) return base;
68
+ if (turnCache && turnCache.exp > Date.now()) return [...base, ...turnCache.servers];
69
+ try {
70
+ const r = await fetch(`https://rtc.live.cloudflare.com/v1/turn/keys/${keyId}/credentials/generate-ice-servers`, {
71
+ method: "POST", headers: { Authorization: `Bearer ${apiToken}`, "Content-Type": "application/json" },
72
+ body: JSON.stringify({ ttl: 3600 }), signal: AbortSignal.timeout(5000),
73
+ });
74
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
75
+ const body = await r.json() as { iceServers: IceServer | IceServer[] };
76
+ const servers = [body.iceServers].flat();
77
+ turnCache = { servers, exp: Date.now() + 50 * 60_000 };
78
+ return [...base, ...servers];
79
+ } catch (error) {
80
+ log(`Cloudflare TURN credentials unavailable, using STUN only: ${error}`);
81
+ return base;
82
+ }
83
+ }
84
+
85
+ export function signalSocketUrl(signal: string, room: string): string {
86
+ const u = new URL(signal);
87
+ u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
88
+ u.pathname = `${u.pathname.replace(/\/+$/, "")}/${room}`;
89
+ u.search = ""; u.hash = "";
90
+ return u.toString();
91
+ }
92
+
93
+ // ---- sealed channel (shared by both ends) -------------------------------------------------
94
+ type Role = "host" | "client";
95
+
96
+ /** A DataChannel with the e2e seal/open and confirmation handshake on top. */
97
+ class SealedChannel {
98
+ private send = newSendState() as SendState;
99
+ private recv = newRecvState() as RecvState;
100
+ private sendChain = Promise.resolve();
101
+ private recvChain = Promise.resolve();
102
+ private keys?: { mine: CryptoKey; theirs: CryptoKey; th: Uint8Array };
103
+ // Frames can arrive the moment the channel opens, before this side has derived its keys
104
+ // (an async HKDF): hold them until then rather than dropping the connection.
105
+ private keysSet!: () => void;
106
+ private keysReady = new Promise<void>(r => (this.keysSet = r));
107
+ confirmed = false;
108
+ private confirmWaiters: Array<(ok: boolean) => void> = [];
109
+ onFrame: (frame: any) => void = () => {};
110
+ onClose: () => void = () => {};
111
+ private closed = false;
112
+
113
+ constructor(private dc: any, private role: Role) {
114
+ dc.binaryType = "arraybuffer";
115
+ dc.onmessage = (e: { data: ArrayBuffer | Uint8Array | string }) => {
116
+ this.recvChain = this.recvChain.then(() => this.receive(e.data)).catch(() => this.close());
117
+ };
118
+ dc.onclose = () => this.close();
119
+ }
120
+
121
+ async setKeys(token: string, th: Uint8Array): Promise<void> {
122
+ const { h2c, c2h } = await deriveDirKeys(token, th);
123
+ this.keys = this.role === "host" ? { mine: h2c, theirs: c2h, th } : { mine: c2h, theirs: h2c, th };
124
+ this.keysSet();
125
+ }
126
+
127
+ /** Send our confirm frame (counter 0) and wait for the peer's; false on timeout or failure. */
128
+ confirm(): Promise<boolean> {
129
+ const done = new Promise<boolean>(resolve => {
130
+ this.confirmWaiters.push(resolve);
131
+ setTimeout(() => resolve(false), CONFIRM_TIMEOUT_MS);
132
+ });
133
+ void this.sendFrame({ t: "confirm", nonce: randomHex(16), proto: "rech-e2e-1" }, FLAG_CONFIRM);
134
+ return done;
135
+ }
136
+
137
+ sendFrame(obj: unknown, flags = 0): Promise<void> {
138
+ this.sendChain = this.sendChain.then(async () => {
139
+ if (this.closed || !this.keys || this.dc.readyState !== "open") return;
140
+ const frame = await seal(this.keys.mine, this.send, flags, this.keys.th, pack(obj));
141
+ // A DataChannel queues without bound; wait out a large backlog (screenshots) instead.
142
+ while (this.dc.bufferedAmount > 4 * 1024 * 1024 && this.dc.readyState === "open") await new Promise(r => setTimeout(r, 10));
143
+ try { this.dc.send(new Uint8Array(frame)); } catch { this.close(); }
144
+ }).catch(() => this.close());
145
+ return this.sendChain;
146
+ }
147
+
148
+ private async receive(data: ArrayBuffer | Uint8Array | string): Promise<void> {
149
+ if (this.closed) return;
150
+ if (typeof data === "string") return this.close(); // plaintext is never accepted
151
+ await Promise.race([this.keysReady, new Promise(r => setTimeout(r, CONFIRM_TIMEOUT_MS))]);
152
+ if (!this.keys) return this.close();
153
+ const { plaintext } = await open(this.keys.theirs, data, this.keys.th, this.recv);
154
+ const frame = unpack(plaintext);
155
+ if (!this.confirmed) {
156
+ // The first frame must be the peer's confirm: decrypting it proves the same secret and the
157
+ // same DTLS transcript (no relay in the middle); the transcript makes it unreplayable.
158
+ if (frame?.t !== "confirm") return this.close();
159
+ this.confirmed = true;
160
+ for (const w of this.confirmWaiters.splice(0)) w(true);
161
+ return;
162
+ }
163
+ if (frame?.t === "confirm") return;
164
+ this.onFrame(frame);
165
+ }
166
+
167
+ get open(): boolean { return !this.closed && this.dc.readyState === "open"; }
168
+
169
+ close(): void {
170
+ if (this.closed) return;
171
+ this.closed = true;
172
+ for (const w of this.confirmWaiters.splice(0)) w(false);
173
+ try { this.dc.close(); } catch {}
174
+ this.onClose();
175
+ }
176
+ }
177
+
178
+ // Wire frames inside the sealed channel:
179
+ // client → host : {t:"req", id, method, path, ct?, body?} | {t:"abort", id}
180
+ // host → client : {t:"res", id, status, ct} | {t:"data", id, b64} | {t:"end", id, error?}
181
+
182
+ // ---- signal socket ---------------------------------------------------------------------
183
+ class SignalSocket {
184
+ ws?: WebSocket;
185
+ private timer?: ReturnType<typeof setInterval>;
186
+ constructor(private url: string, private hello: { role: Role; auth: string }, private onMessage: (m: any) => void, private onClose: (code: number, reason: string) => void) {}
187
+ open(): Promise<void> {
188
+ return new Promise((resolve, reject) => {
189
+ const ws = new WebSocket(this.url, [SIGNAL_PROTO]);
190
+ this.ws = ws;
191
+ let opened = false;
192
+ ws.onopen = () => {
193
+ opened = true;
194
+ ws.send(JSON.stringify({ type: "hello", ...this.hello }));
195
+ this.timer = setInterval(() => { try { ws.send(PING); } catch {} }, KEEPALIVE_MS);
196
+ resolve();
197
+ };
198
+ ws.onmessage = e => { try { const m = JSON.parse(String(e.data)); if (m?.type !== "pong") this.onMessage(m); } catch {} };
199
+ ws.onclose = e => { clearInterval(this.timer); if (!opened) reject(new Error(`signal server ${new URL(this.url).host}: ${e.reason || `closed (${e.code})`}`)); this.onClose(e.code, e.reason); };
200
+ ws.onerror = () => {};
201
+ });
202
+ }
203
+ send(obj: unknown): void { try { this.ws?.send(JSON.stringify(obj)); } catch {} }
204
+ close(): void { clearInterval(this.timer); try { this.ws?.close(); } catch {} }
205
+ }
206
+
207
+ // ---- host ------------------------------------------------------------------------------
208
+ export type HostCredential = { token: string; signal: string; label: string };
209
+ export type HostHandler = (req: Request, token: string) => Promise<Response>;
210
+
211
+ /** Keeps one signal room per credential and serves each joining peer through `handler`. */
212
+ export class WebrtcHost {
213
+ private rooms = new Map<string, { cred: HostCredential; signal?: SignalSocket; peers: Map<string, { pc: any; channel?: SealedChannel }>; backoff: number; stopped: boolean; retry?: ReturnType<typeof setTimeout> }>();
214
+ constructor(private handler: HostHandler, private log: Log) {}
215
+
216
+ /** Open rooms for new credentials, close rooms for ones that are gone. */
217
+ sync(creds: HostCredential[]): void {
218
+ const wanted = new Map(creds.map(c => [`${c.signal}\0${c.token}`, c]));
219
+ for (const [key, room] of this.rooms) if (!wanted.has(key)) { this.stopRoom(room); this.rooms.delete(key); }
220
+ for (const [key, cred] of wanted) {
221
+ if (this.rooms.has(key)) continue;
222
+ const room = { cred, peers: new Map(), backoff: 1000, stopped: false } as any;
223
+ this.rooms.set(key, room);
224
+ void this.connect(room);
225
+ }
226
+ }
227
+
228
+ stop(): void { for (const room of this.rooms.values()) this.stopRoom(room); this.rooms.clear(); }
229
+
230
+ get size(): number { return this.rooms.size; }
231
+
232
+ private stopRoom(room: any): void {
233
+ room.stopped = true;
234
+ clearTimeout(room.retry);
235
+ room.signal?.close();
236
+ for (const peer of room.peers.values()) { peer.channel?.close(); try { peer.pc.close(); } catch {} }
237
+ room.peers.clear();
238
+ }
239
+
240
+ private async connect(room: any): Promise<void> {
241
+ if (room.stopped) return;
242
+ const { token, signal } = room.cred;
243
+ const sock = new SignalSocket(signalSocketUrl(signal, roomOf(token)), { role: "host", auth: roomAuthOf(token) },
244
+ m => void this.onSignal(room, m).catch(e => this.log(`webrtc ${room.cred.label}: ${e}`)),
245
+ (code, reason) => {
246
+ if (room.stopped) return;
247
+ if (code === 1000 && /replaced/.test(reason)) { this.log(`webrtc ${room.cred.label}: another host took this room over; not reconnecting`); return; }
248
+ room.retry = setTimeout(() => void this.connect(room), room.backoff);
249
+ room.backoff = Math.min(room.backoff * 2, 30_000);
250
+ });
251
+ room.signal = sock;
252
+ try { await sock.open(); room.backoff = 1000; }
253
+ catch (error) { this.log(`webrtc ${room.cred.label}: ${error instanceof Error ? error.message : error}`); }
254
+ }
255
+
256
+ private async onSignal(room: any, m: any): Promise<void> {
257
+ // The relay tags everything a client sends with `from`; only the relay's own notices lack it,
258
+ // so a client can't forge peer-join to make this host open connections.
259
+ if (m.type === "peer-join" && typeof m.peer === "string" && m.from === undefined) {
260
+ if (room.peers.has(m.peer)) this.closePeer(room, m.peer);
261
+ if (room.peers.size >= MAX_PEERS_PER_ROOM) { this.log(`webrtc ${room.cred.label}: too many peers connecting; ignoring ${m.peer}`); return; }
262
+ return this.startPeer(room, m.peer);
263
+ }
264
+ const peer = room.peers.get(m.from);
265
+ if (!peer) return;
266
+ if (m.type === "answer" && typeof m.sdp === "string") {
267
+ await peer.pc.setRemoteDescription({ type: "answer", sdp: m.sdp });
268
+ const th = await transcriptHash(peer.pc.localDescription.sdp, m.sdp);
269
+ peer.keysReady(th);
270
+ } else if (m.type === "candidate" && m.candidate) {
271
+ await peer.pc.addIceCandidate(m.candidate).catch(() => {});
272
+ } else if (m.type === "peer-leave" && m.from === undefined && !peer.channel?.confirmed) {
273
+ this.closePeer(room, m.from);
274
+ }
275
+ }
276
+
277
+ private closePeer(room: any, id: string): void {
278
+ const peer = room.peers.get(id);
279
+ if (!peer) return;
280
+ room.peers.delete(id);
281
+ peer.channel?.close();
282
+ try { peer.pc.close(); } catch {}
283
+ }
284
+
285
+ private async startPeer(room: any, peerId: string): Promise<void> {
286
+ const { RTCPeerConnection } = await loadRTC(this.log);
287
+ const ice = await hostIceServers(this.log);
288
+ const pc = new RTCPeerConnection({ iceServers: ice });
289
+ let keysReady!: (th: Uint8Array) => void;
290
+ const keys = new Promise<Uint8Array>(r => (keysReady = r));
291
+ const peer: any = { pc, keysReady };
292
+ room.peers.set(peerId, peer);
293
+ const send = (obj: object) => room.signal?.send({ ...obj, to: peerId });
294
+ pc.onicecandidate = (e: any) => { if (e.candidate) send({ type: "candidate", candidate: { candidate: e.candidate.candidate, sdpMid: e.candidate.sdpMid } }); };
295
+ pc.onconnectionstatechange = () => { if (["failed", "closed"].includes(pc.connectionState)) this.closePeer(room, peerId); };
296
+ const dc = pc.createDataChannel("rech");
297
+ const channel = new SealedChannel(dc, "host");
298
+ peer.channel = channel;
299
+ const aborts = new Map<string, AbortController>();
300
+ channel.onClose = () => { for (const a of aborts.values()) a.abort(); this.closePeer(room, peerId); };
301
+ channel.onFrame = frame => void this.onFrame(channel, room.cred.token, aborts, frame);
302
+ dc.onopen = async () => {
303
+ await channel.setKeys(room.cred.token, await keys);
304
+ if (await channel.confirm()) this.log(`webrtc ${room.cred.label}: peer ${peerId} connected`);
305
+ else { this.log(`webrtc ${room.cred.label}: peer ${peerId} failed key confirmation`); channel.close(); }
306
+ };
307
+ // Give up on a peer that never completes the handshake (e.g. ICE never connects).
308
+ setTimeout(() => { if (!channel.confirmed) this.closePeer(room, peerId); }, CONNECT_TIMEOUT_MS + 5000);
309
+ const offer = await pc.createOffer();
310
+ await pc.setLocalDescription(offer);
311
+ send({ type: "offer", sdp: pc.localDescription.sdp, ice });
312
+ }
313
+
314
+ private async onFrame(channel: SealedChannel, token: string, aborts: Map<string, AbortController>, f: any): Promise<void> {
315
+ if (f?.t === "abort") { aborts.get(f.id)?.abort(); aborts.delete(f.id); return; }
316
+ if (f?.t !== "req" || typeof f.id !== "string" || typeof f.path !== "string" || !f.path.startsWith("/")) return;
317
+ const ac = new AbortController();
318
+ aborts.set(f.id, ac);
319
+ try {
320
+ const req = new Request(`http://webrtc.rech${f.path}`, {
321
+ method: f.method || "GET",
322
+ headers: f.ct ? { "Content-Type": f.ct } : {},
323
+ body: f.body != null && !["GET", "HEAD"].includes(f.method) ? f.body : undefined,
324
+ signal: ac.signal,
325
+ });
326
+ const res = await this.handler(req, token);
327
+ await channel.sendFrame({ t: "res", id: f.id, status: res.status, ct: res.headers.get("content-type") ?? "" });
328
+ if (res.body) {
329
+ const reader = res.body.getReader();
330
+ for (;;) {
331
+ const { done, value } = await reader.read();
332
+ if (done || ac.signal.aborted) break;
333
+ for (let i = 0; i < value.length; i += MAX_CHUNK)
334
+ void channel.sendFrame({ t: "data", id: f.id, b64: Buffer.from(value.subarray(i, i + MAX_CHUNK)).toString("base64") });
335
+ }
336
+ }
337
+ await channel.sendFrame({ t: "end", id: f.id });
338
+ } catch (error) {
339
+ if (!ac.signal.aborted) await channel.sendFrame({ t: "end", id: f.id, error: String(error instanceof Error ? error.message : error) });
340
+ } finally { aborts.delete(f.id); }
341
+ }
342
+ }
343
+
344
+ // ---- client ----------------------------------------------------------------------------
345
+ type Pending = { onRes: (status: number, ct: string) => void; onData: (chunk: Uint8Array) => void; onEnd: (error?: string) => void };
346
+
347
+ /** One client connection to a host's room. `request` sends an HTTP-shaped request over it. */
348
+ export class WebrtcClient {
349
+ private channel?: SealedChannel;
350
+ private pc?: any;
351
+ private pending = new Map<string, Pending>();
352
+ onClose: () => void = () => {};
353
+ constructor(private token: string, private signal: string, private log: Log = () => {}) {}
354
+
355
+ get connected(): boolean { return !!this.channel?.open && this.channel.confirmed; }
356
+
357
+ async connect(): Promise<void> {
358
+ const { RTCPeerConnection } = await loadRTC(this.log);
359
+ const token = this.token;
360
+ let sock: SignalSocket | undefined;
361
+ const result = new Promise<void>((resolve, reject) => {
362
+ const fail = (message: string) => { reject(new Error(message)); };
363
+ const timer = setTimeout(() => fail("timed out connecting to the host over WebRTC (no direct path; the host may be behind a strict NAT: ask them to enable TURN, or use a Tailscale/LAN share)"), CONNECT_TIMEOUT_MS);
364
+ sock = new SignalSocket(signalSocketUrl(this.signal, roomOf(token)), { role: "client", auth: roomAuthOf(token) }, async m => {
365
+ try {
366
+ if (m.type === "welcome" && m.hostPresent === false) fail("the host is offline (its rech daemon isn't connected to the signal server)");
367
+ else if (m.type === "host-leave" && !this.connected) fail("the host left before the connection finished");
368
+ else if (m.type === "offer" && typeof m.sdp === "string" && !this.pc) {
369
+ const pc = new RTCPeerConnection({ iceServers: Array.isArray(m.ice) ? m.ice : iceServersFromEnv() });
370
+ this.pc = pc;
371
+ pc.onicecandidate = (e: any) => { if (e.candidate) sock!.send({ type: "candidate", candidate: { candidate: e.candidate.candidate, sdpMid: e.candidate.sdpMid } }); };
372
+ pc.onconnectionstatechange = () => { if (["failed", "closed"].includes(pc.connectionState)) this.close(); };
373
+ const th = new Promise<Uint8Array>(r => {
374
+ pc.ondatachannel = (e: any) => {
375
+ const channel = new SealedChannel(e.channel, "client");
376
+ this.channel = channel;
377
+ channel.onFrame = f => this.onFrame(f);
378
+ channel.onClose = () => this.close();
379
+ const ready = async () => {
380
+ await channel.setKeys(token, await th);
381
+ if (await channel.confirm()) { clearTimeout(timer); resolve(); }
382
+ else fail("the host could not prove it holds this link's secret (key confirmation failed)");
383
+ };
384
+ if (e.channel.readyState === "open") void ready(); else e.channel.onopen = ready;
385
+ };
386
+ void (async () => {
387
+ await pc.setRemoteDescription({ type: "offer", sdp: m.sdp });
388
+ const answer = await pc.createAnswer();
389
+ await pc.setLocalDescription(answer);
390
+ sock!.send({ type: "answer", sdp: pc.localDescription.sdp });
391
+ r(await transcriptHash(m.sdp, pc.localDescription.sdp));
392
+ })().catch(e => fail(String(e)));
393
+ });
394
+ } else if (m.type === "candidate" && m.candidate && this.pc) {
395
+ await this.pc.addIceCandidate(m.candidate).catch(() => {});
396
+ }
397
+ } catch (error) { fail(String(error)); }
398
+ }, (code, reason) => { if (!this.connected) fail(code === 4404 ? "the host is offline (its rech daemon isn't connected to the signal server)" : code === 1008 ? `the signal server refused this link (${reason || "forbidden"})` : `signal server closed the connection (${reason || code})`); });
399
+ sock.open().catch(e => fail(e.message));
400
+ });
401
+ try { await result; }
402
+ catch (error) { sock?.close(); this.close(); throw error; }
403
+ // Connected peer to peer: the signal room is no longer needed.
404
+ sock?.close();
405
+ }
406
+
407
+ private onFrame(f: any): void {
408
+ const p = this.pending.get(f?.id);
409
+ if (!p) return;
410
+ if (f.t === "res") p.onRes(f.status, f.ct);
411
+ else if (f.t === "data" && typeof f.b64 === "string") p.onData(new Uint8Array(Buffer.from(f.b64, "base64")));
412
+ else if (f.t === "end") { this.pending.delete(f.id); p.onEnd(f.error); }
413
+ }
414
+
415
+ /** Send a request; resolves with a streaming Response once the host answers. */
416
+ request(method: string, path: string, body?: string, ct?: string, signal?: AbortSignal): Promise<Response> {
417
+ if (!this.channel || !this.connected) return Promise.reject(new Error("not connected"));
418
+ const id = randomHex(8), channel = this.channel;
419
+ return new Promise((resolve, reject) => {
420
+ let controller!: ReadableStreamDefaultController<Uint8Array>;
421
+ const stream = new ReadableStream<Uint8Array>({ start: c => { controller = c; }, cancel: () => { void channel.sendFrame({ t: "abort", id }); this.pending.delete(id); } });
422
+ let answered = false;
423
+ this.pending.set(id, {
424
+ onRes: (status, resCt) => { answered = true; resolve(new Response(stream, { status, headers: resCt ? { "Content-Type": resCt } : {} })); },
425
+ onData: chunk => { try { controller.enqueue(chunk); } catch {} },
426
+ onEnd: error => {
427
+ if (!answered) reject(new Error(error || "the host closed the request"));
428
+ try { error ? controller.error(new Error(error)) : controller.close(); } catch {}
429
+ },
430
+ });
431
+ signal?.addEventListener("abort", () => { void channel.sendFrame({ t: "abort", id }); this.pending.get(id)?.onEnd("aborted"); });
432
+ void channel.sendFrame({ t: "req", id, method, path, ct, body });
433
+ });
434
+ }
435
+
436
+ close(): void {
437
+ const had = !!this.pc;
438
+ this.channel?.close();
439
+ try { this.pc?.close(); } catch {}
440
+ this.channel = undefined; this.pc = undefined;
441
+ for (const p of this.pending.values()) p.onEnd("connection closed");
442
+ this.pending.clear();
443
+ if (had) this.onClose();
444
+ }
445
+ }
446
+
447
+ // ---- client bridge: loopback HTTP in front of a WebrtcClient ----------------------------------
448
+ export const BRIDGE_DIR = join(homedir(), ".rechrome", "bridges");
449
+ export type BridgeInfo = { pid: number; port: number; key: string; signal: string; room: string };
450
+ const BRIDGE_IDLE_MS = Number(process.env.RECH_BRIDGE_IDLE_MS) || 15 * 60_000;
451
+
452
+ export const bridgeFile = (token: string, dir = BRIDGE_DIR) => join(dir, `${roomOf(token)}.json`);
453
+
454
+ export function readBridge(token: string, dir = BRIDGE_DIR): BridgeInfo | null {
455
+ try { return JSON.parse(readFileSync(bridgeFile(token, dir), "utf8")); } catch { return null; }
456
+ }
457
+
458
+ /** Run a bridge in this process until idle. Writes its port and key to the bridge file. */
459
+ export async function runBridge(token: string, signal: string, opts: { dir?: string; log?: Log; port?: number; onStop?: () => void } = {}): Promise<{ info: BridgeInfo; stop: () => void }> {
460
+ const log = opts.log ?? (() => {});
461
+ const dir = opts.dir ?? BRIDGE_DIR;
462
+ const client = new WebrtcClient(token, signal, log);
463
+ let connecting: Promise<void> | null = null;
464
+ const ensure = async () => {
465
+ if (client.connected) return;
466
+ connecting ??= client.connect().finally(() => { connecting = null; });
467
+ await connecting;
468
+ };
469
+ const key = randomBytes(24).toString("base64url");
470
+ let lastUsed = Date.now();
471
+ const server = Bun.serve({
472
+ hostname: "127.0.0.1",
473
+ port: opts.port ?? 0,
474
+ idleTimeout: 0,
475
+ async fetch(req) {
476
+ const url = new URL(req.url);
477
+ if (url.pathname === "/__bridge") return Response.json({ ok: true, connected: client.connected, pid: process.pid, room: roomOf(token) });
478
+ if (req.headers.get("authorization") !== `Bearer ${key}`) return new Response("Unauthorized", { status: 401 });
479
+ lastUsed = Date.now();
480
+ try { await ensure(); }
481
+ catch (error) { return Response.json({ status: 1, stdout: "", stderr: `[rech] WebRTC: ${error instanceof Error ? error.message : error}\n` }, { status: 502 }); }
482
+ const body = ["GET", "HEAD"].includes(req.method) ? undefined : await req.text();
483
+ try { return await client.request(req.method, url.pathname + url.search, body, req.headers.get("content-type") ?? undefined, req.signal); }
484
+ catch (error) { return Response.json({ status: 1, stdout: "", stderr: `[rech] WebRTC: ${error instanceof Error ? error.message : error}\n` }, { status: 502 }); }
485
+ },
486
+ });
487
+ const info: BridgeInfo = { pid: process.pid, port: server.port!, key, signal, room: roomOf(token) };
488
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
489
+ const path = bridgeFile(token, dir);
490
+ writeFileSync(path, JSON.stringify(info), { mode: 0o600 });
491
+ const idle = setInterval(() => { if (Date.now() - lastUsed > BRIDGE_IDLE_MS) stop(); }, 30_000);
492
+ const stop = () => {
493
+ clearInterval(idle);
494
+ client.close();
495
+ server.stop(true);
496
+ try { if (readBridge(token, dir)?.pid === process.pid) unlinkSync(path); } catch {}
497
+ opts.onStop?.();
498
+ };
499
+ // Connect eagerly so the first command doesn't pay for the handshake twice.
500
+ void ensure().catch(error => log(`[rech] WebRTC: ${error instanceof Error ? error.message : error}`));
501
+ return { info, stop };
502
+ }
503
+
504
+ /** A live bridge for this credential, if one answers. */
505
+ export async function liveBridge(token: string, dir = BRIDGE_DIR): Promise<BridgeInfo | null> {
506
+ const info = readBridge(token, dir);
507
+ if (!info) return null;
508
+ // Something else may hold a dead bridge's port by now: it must be the bridge for this room.
509
+ const body = await fetch(`http://127.0.0.1:${info.port}/__bridge`, { signal: AbortSignal.timeout(1000) }).then(r => r.ok ? r.json() : null, () => null) as { room?: string; pid?: number } | null;
510
+ if (body?.room === info.room && body.pid === info.pid) return info;
511
+ try { unlinkSync(bridgeFile(token, dir)); } catch {}
512
+ return null;
513
+ }
514
+
515
+ /** Start a detached bridge process (`rech bridge`) and wait for it to answer. */
516
+ export async function spawnBridge(token: string, signal: string, entry: string[], dir = BRIDGE_DIR): Promise<BridgeInfo> {
517
+ const existing = await liveBridge(token, dir);
518
+ if (existing) return existing;
519
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
520
+ const logPath = join(dir, `${roomOf(token)}.log`);
521
+ // The token goes through the environment, not argv, so `ps` never shows it.
522
+ const proc = Bun.spawn([...entry, "bridge"], {
523
+ env: { ...process.env, RECH_BRIDGE_TOKEN: token, RECH_BRIDGE_SIGNAL: signal },
524
+ stdin: "ignore", stdout: Bun.file(logPath), stderr: Bun.file(logPath), windowsHide: true,
525
+ });
526
+ proc.unref();
527
+ for (let i = 0; i < 100; i++) {
528
+ const info = await liveBridge(token, dir);
529
+ if (info) return info;
530
+ if (proc.exitCode !== null) break;
531
+ await new Promise(r => setTimeout(r, 100));
532
+ }
533
+ const tail = existsSync(logPath) ? readFileSync(logPath, "utf8").trim().split("\n").slice(-3).join("\n") : "";
534
+ throw new Error(`the local WebRTC bridge did not start${tail ? `:\n${tail}` : ""}`);
535
+ }