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.js ADDED
@@ -0,0 +1,879 @@
1
+ // @bun
2
+ // webrtc.ts
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "fs";
4
+ import { join } from "path";
5
+ import { homedir } from "os";
6
+ import { randomBytes as randomBytes2 } from "crypto";
7
+
8
+ // secret.ts
9
+ import { hkdfSync, randomBytes, timingSafeEqual } from "crypto";
10
+ var ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
11
+ var B58 = "[1-9A-HJ-NP-Za-km-z]";
12
+ var BODY_LEN = 22;
13
+ var SUM_LEN = 6;
14
+ var SECRET_BYTES = 16;
15
+ var TOKEN_PATTERN = new RegExp(`\\brech1_${B58}{${BODY_LEN + SUM_LEN}}\\b`, "g");
16
+ var EXACT = new RegExp(`^rech1_(${B58}{${BODY_LEN}})(${B58}{${SUM_LEN}})$`);
17
+ function base58(bytes, width) {
18
+ let n = 0n;
19
+ for (const b of bytes)
20
+ n = n << 8n | BigInt(b);
21
+ let s = "";
22
+ while (n > 0n) {
23
+ s = ALPHABET[Number(n % 58n)] + s;
24
+ n /= 58n;
25
+ }
26
+ if (s.length > width)
27
+ throw new Error("base58 value wider than its field");
28
+ return s.padStart(width, "1");
29
+ }
30
+ function unbase58(text, length) {
31
+ let n = 0n;
32
+ for (const c of text) {
33
+ const i = ALPHABET.indexOf(c);
34
+ if (i < 0)
35
+ return null;
36
+ n = n * 58n + BigInt(i);
37
+ }
38
+ const out = new Uint8Array(length);
39
+ for (let i = length - 1;i >= 0; i--) {
40
+ out[i] = Number(n & 0xffn);
41
+ n >>= 8n;
42
+ }
43
+ return n === 0n ? out : null;
44
+ }
45
+ var CRC_TABLE = (() => {
46
+ const t = new Uint32Array(256);
47
+ for (let i = 0;i < 256; i++) {
48
+ let c = i;
49
+ for (let k = 0;k < 8; k++)
50
+ c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
51
+ t[i] = c >>> 0;
52
+ }
53
+ return t;
54
+ })();
55
+ function crc32(bytes) {
56
+ let c = 4294967295;
57
+ for (const b of bytes)
58
+ c = CRC_TABLE[(c ^ b) & 255] ^ c >>> 8;
59
+ return (c ^ 4294967295) >>> 0;
60
+ }
61
+ var u32 = (n) => new Uint8Array([n >>> 24, n >>> 16 & 255, n >>> 8 & 255, n & 255]);
62
+ function parseToken(token) {
63
+ const m = EXACT.exec(token.trim());
64
+ if (!m)
65
+ return { ok: false, reason: "format" };
66
+ const secret = unbase58(m[1], SECRET_BYTES), sum = unbase58(m[2], 4);
67
+ if (!secret || !sum)
68
+ return { ok: false, reason: "format" };
69
+ const expected = u32(crc32(secret));
70
+ return sum.every((b, i) => b === expected[i]) ? { ok: true, secret } : { ok: false, reason: "checksum" };
71
+ }
72
+ function secretOf(token) {
73
+ const parsed = parseToken(token);
74
+ if (!parsed.ok)
75
+ throw new Error(parsed.reason === "checksum" ? "This rech1_ link is damaged (checksum mismatch): it was copied incompletely or mistyped." : "Not a rech1_ link.");
76
+ return parsed.secret;
77
+ }
78
+ var derive = (token, label, bytes) => new Uint8Array(hkdfSync("sha256", secretOf(token), new Uint8Array(0), `rech/${label}`, bytes));
79
+ var roomOf = (token) => base58(derive(token, "room", 12), 17);
80
+ var roomAuthOf = (token) => Buffer.from(derive(token, "auth", 32)).toString("hex");
81
+
82
+ // e2e.ts
83
+ var PROTO = "rech-e2e-1";
84
+ var MAX_CHUNK = 12000;
85
+ var CONFIRM_TIMEOUT_MS = 5000;
86
+ var FLAG_CONFIRM = 1;
87
+ var VER = 1;
88
+ var HEADER_LEN = 14;
89
+ var NONCE_LEN = 12;
90
+ var TAG_LEN = 16;
91
+ var COUNTER_MAX = (1n << 64n) - 1n;
92
+ var subtle = globalThis.crypto.subtle;
93
+ var enc = new TextEncoder;
94
+ var dec = new TextDecoder;
95
+ var concat = (...parts) => {
96
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
97
+ let o = 0;
98
+ for (const p of parts) {
99
+ out.set(p, o);
100
+ o += p.length;
101
+ }
102
+ return out;
103
+ };
104
+ var sha256 = async (bytes) => new Uint8Array(await subtle.digest("SHA-256", bytes));
105
+ function ikmOf(token) {
106
+ const parsed = parseToken(token);
107
+ if (!parsed.ok)
108
+ throw new Error("e2e: invalid share secret");
109
+ return parsed.secret;
110
+ }
111
+ async function hkdfKey(ikm, salt, info) {
112
+ const base = await subtle.importKey("raw", ikm, "HKDF", false, ["deriveBits"]);
113
+ const bits = await subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt, info: enc.encode(info) }, base, 256);
114
+ return subtle.importKey("raw", bits, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
115
+ }
116
+ async function deriveDirKeys(token, transcriptHash) {
117
+ const ikm = ikmOf(token);
118
+ return {
119
+ h2c: await hkdfKey(ikm, transcriptHash, `rech/${PROTO}/key/host->client`),
120
+ c2h: await hkdfKey(ikm, transcriptHash, `rech/${PROTO}/key/client->host`)
121
+ };
122
+ }
123
+ var fingerprints = (sdp) => [...sdp.matchAll(/^a=fingerprint:(.*)$/gim)].map((m) => m[1].trim().toLowerCase()).sort();
124
+ var firstAttr = (sdp, name) => (new RegExp(`^a=${name}:(.*)$`, "im").exec(sdp)?.[1] ?? "").trim().toLowerCase();
125
+ async function transcriptHash(offerSdp, answerSdp) {
126
+ const offer = fingerprints(offerSdp), answer = fingerprints(answerSdp);
127
+ if (!offer.length || !answer.length)
128
+ throw new Error("e2e: missing DTLS fingerprint");
129
+ if ([...offer, ...answer].some((fp) => !fp.startsWith("sha-256")))
130
+ throw new Error("e2e: non-sha-256 DTLS fingerprint");
131
+ return sha256(enc.encode(`${PROTO}
132
+ ` + `offer=${offer.join(",")};setup=${firstAttr(offerSdp, "setup")};ufrag=${firstAttr(offerSdp, "ice-ufrag")}
133
+ ` + `answer=${answer.join(",")};setup=${firstAttr(answerSdp, "setup")};ufrag=${firstAttr(answerSdp, "ice-ufrag")}`));
134
+ }
135
+ var newSendState = () => ({ ctr: 0n });
136
+ var newRecvState = () => ({ last: -1n });
137
+ function nonceOf(ctr) {
138
+ const n = new Uint8Array(NONCE_LEN);
139
+ new DataView(n.buffer).setBigUint64(4, ctr, false);
140
+ return n;
141
+ }
142
+ async function seal(key, state, flags, th, plaintext) {
143
+ const ctr = state.ctr;
144
+ if (ctr >= COUNTER_MAX)
145
+ throw new Error("e2e: nonce counter overflow");
146
+ state.ctr = ctr + 1n;
147
+ const nonce = nonceOf(ctr);
148
+ const header = new Uint8Array(HEADER_LEN);
149
+ header[0] = VER;
150
+ header[1] = flags & 255;
151
+ header.set(nonce, 2);
152
+ const sealed = new Uint8Array(await subtle.encrypt({ name: "AES-GCM", iv: nonce, additionalData: concat(header, th), tagLength: 128 }, key, plaintext));
153
+ return concat(header, sealed).buffer;
154
+ }
155
+ async function open(key, frame, th, state) {
156
+ const buf = frame instanceof Uint8Array ? frame : new Uint8Array(frame);
157
+ if (buf.length < HEADER_LEN + TAG_LEN)
158
+ throw new Error("e2e: short frame");
159
+ if (buf[0] !== VER)
160
+ throw new Error("e2e: bad version");
161
+ const header = buf.subarray(0, HEADER_LEN), nonce = buf.subarray(2, HEADER_LEN);
162
+ const view = new DataView(nonce.buffer, nonce.byteOffset, NONCE_LEN);
163
+ if (view.getUint32(0, false) !== 0)
164
+ throw new Error("e2e: bad epoch");
165
+ const ctr = view.getBigUint64(4, false);
166
+ const plain = await subtle.decrypt({ name: "AES-GCM", iv: nonce, additionalData: concat(header, th), tagLength: 128 }, key, buf.subarray(HEADER_LEN));
167
+ if (state.last === -1n && ctr !== 0n)
168
+ throw new Error("e2e: first frame must be counter-0");
169
+ if (ctr <= state.last)
170
+ throw new Error("e2e: replay/reorder");
171
+ state.last = ctr;
172
+ return { flags: header[1], plaintext: new Uint8Array(plain) };
173
+ }
174
+ var pack = (obj) => enc.encode(JSON.stringify(obj));
175
+ var unpack = (bytes) => JSON.parse(dec.decode(bytes));
176
+ function randomHex(n) {
177
+ return Buffer.from(globalThis.crypto.getRandomValues(new Uint8Array(n))).toString("hex");
178
+ }
179
+
180
+ // webrtc.ts
181
+ var SIGNAL_PROTO = "rech-signal-1";
182
+ var PING = '{"type":"ping"}';
183
+ var KEEPALIVE_MS = 25000;
184
+ var CONNECT_TIMEOUT_MS = 25000;
185
+ var MAX_PEERS_PER_ROOM = 8;
186
+ var DEFAULT_STUN = [{ urls: "stun:stun.cloudflare.com:3478" }, { urls: "stun:stun.l.google.com:19302" }];
187
+ var WEBRTC_RUNTIME_DIR = join(homedir(), ".rechrome", "webrtc");
188
+ var rtcModule;
189
+ function loadRTC(log = (m) => console.error(m)) {
190
+ return rtcModule ??= (async () => {
191
+ try {
192
+ return await import("node-datachannel/polyfill");
193
+ } catch {}
194
+ const entry = () => {
195
+ try {
196
+ return Bun.resolveSync("node-datachannel/polyfill", WEBRTC_RUNTIME_DIR);
197
+ } catch {
198
+ return null;
199
+ }
200
+ };
201
+ let path = entry();
202
+ if (path)
203
+ try {
204
+ return await import(path);
205
+ } catch {
206
+ path = null;
207
+ }
208
+ log(`[rech] one-time setup: installing WebRTC support (node-datachannel) into ${WEBRTC_RUNTIME_DIR}`);
209
+ mkdirSync(WEBRTC_RUNTIME_DIR, { recursive: true });
210
+ writeFileSync(join(WEBRTC_RUNTIME_DIR, "package.json"), JSON.stringify({
211
+ private: true,
212
+ dependencies: { "node-datachannel": "^0.32.3" },
213
+ trustedDependencies: ["node-datachannel"]
214
+ }, null, 2));
215
+ const proc = Bun.spawn([process.execPath, "install"], { cwd: WEBRTC_RUNTIME_DIR, stdout: "ignore", stderr: "pipe", env: { ...process.env, BUN_BE_BUN: "1" } });
216
+ const [status, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()]);
217
+ path = entry();
218
+ if (status !== 0 || !path)
219
+ throw new Error(`Could not install WebRTC support (node-datachannel): ${stderr.trim().slice(-400) || `bun install exited ${status}`}`);
220
+ return import(path);
221
+ })();
222
+ }
223
+ function iceServersFromEnv(env = process.env) {
224
+ if (env.RECH_ICE_SERVERS) {
225
+ try {
226
+ return JSON.parse(env.RECH_ICE_SERVERS);
227
+ } catch {
228
+ throw new Error("RECH_ICE_SERVERS must be a JSON array of {urls, username?, credential?}");
229
+ }
230
+ }
231
+ return DEFAULT_STUN;
232
+ }
233
+ var turnCache = null;
234
+ async function hostIceServers(log, env = process.env) {
235
+ const base = iceServersFromEnv(env);
236
+ const { CF_TURN_KEY_ID: keyId, CF_TURN_API_TOKEN: apiToken } = env;
237
+ if (!keyId || !apiToken)
238
+ return base;
239
+ if (turnCache && turnCache.exp > Date.now())
240
+ return [...base, ...turnCache.servers];
241
+ try {
242
+ const r = await fetch(`https://rtc.live.cloudflare.com/v1/turn/keys/${keyId}/credentials/generate-ice-servers`, {
243
+ method: "POST",
244
+ headers: { Authorization: `Bearer ${apiToken}`, "Content-Type": "application/json" },
245
+ body: JSON.stringify({ ttl: 3600 }),
246
+ signal: AbortSignal.timeout(5000)
247
+ });
248
+ if (!r.ok)
249
+ throw new Error(`HTTP ${r.status}`);
250
+ const body = await r.json();
251
+ const servers = [body.iceServers].flat();
252
+ turnCache = { servers, exp: Date.now() + 3000000 };
253
+ return [...base, ...servers];
254
+ } catch (error) {
255
+ log(`Cloudflare TURN credentials unavailable, using STUN only: ${error}`);
256
+ return base;
257
+ }
258
+ }
259
+ function signalSocketUrl(signal, room) {
260
+ const u = new URL(signal);
261
+ u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
262
+ u.pathname = `${u.pathname.replace(/\/+$/, "")}/${room}`;
263
+ u.search = "";
264
+ u.hash = "";
265
+ return u.toString();
266
+ }
267
+
268
+ class SealedChannel {
269
+ dc;
270
+ role;
271
+ send = newSendState();
272
+ recv = newRecvState();
273
+ sendChain = Promise.resolve();
274
+ recvChain = Promise.resolve();
275
+ keys;
276
+ keysSet;
277
+ keysReady = new Promise((r) => this.keysSet = r);
278
+ confirmed = false;
279
+ confirmWaiters = [];
280
+ onFrame = () => {};
281
+ onClose = () => {};
282
+ closed = false;
283
+ constructor(dc, role) {
284
+ this.dc = dc;
285
+ this.role = role;
286
+ dc.binaryType = "arraybuffer";
287
+ dc.onmessage = (e) => {
288
+ this.recvChain = this.recvChain.then(() => this.receive(e.data)).catch(() => this.close());
289
+ };
290
+ dc.onclose = () => this.close();
291
+ }
292
+ async setKeys(token, th) {
293
+ const { h2c, c2h } = await deriveDirKeys(token, th);
294
+ this.keys = this.role === "host" ? { mine: h2c, theirs: c2h, th } : { mine: c2h, theirs: h2c, th };
295
+ this.keysSet();
296
+ }
297
+ confirm() {
298
+ const done = new Promise((resolve) => {
299
+ this.confirmWaiters.push(resolve);
300
+ setTimeout(() => resolve(false), CONFIRM_TIMEOUT_MS);
301
+ });
302
+ this.sendFrame({ t: "confirm", nonce: randomHex(16), proto: "rech-e2e-1" }, FLAG_CONFIRM);
303
+ return done;
304
+ }
305
+ sendFrame(obj, flags = 0) {
306
+ this.sendChain = this.sendChain.then(async () => {
307
+ if (this.closed || !this.keys || this.dc.readyState !== "open")
308
+ return;
309
+ const frame = await seal(this.keys.mine, this.send, flags, this.keys.th, pack(obj));
310
+ while (this.dc.bufferedAmount > 4194304 && this.dc.readyState === "open")
311
+ await new Promise((r) => setTimeout(r, 10));
312
+ try {
313
+ this.dc.send(new Uint8Array(frame));
314
+ } catch {
315
+ this.close();
316
+ }
317
+ }).catch(() => this.close());
318
+ return this.sendChain;
319
+ }
320
+ async receive(data) {
321
+ if (this.closed)
322
+ return;
323
+ if (typeof data === "string")
324
+ return this.close();
325
+ await Promise.race([this.keysReady, new Promise((r) => setTimeout(r, CONFIRM_TIMEOUT_MS))]);
326
+ if (!this.keys)
327
+ return this.close();
328
+ const { plaintext } = await open(this.keys.theirs, data, this.keys.th, this.recv);
329
+ const frame = unpack(plaintext);
330
+ if (!this.confirmed) {
331
+ if (frame?.t !== "confirm")
332
+ return this.close();
333
+ this.confirmed = true;
334
+ for (const w of this.confirmWaiters.splice(0))
335
+ w(true);
336
+ return;
337
+ }
338
+ if (frame?.t === "confirm")
339
+ return;
340
+ this.onFrame(frame);
341
+ }
342
+ get open() {
343
+ return !this.closed && this.dc.readyState === "open";
344
+ }
345
+ close() {
346
+ if (this.closed)
347
+ return;
348
+ this.closed = true;
349
+ for (const w of this.confirmWaiters.splice(0))
350
+ w(false);
351
+ try {
352
+ this.dc.close();
353
+ } catch {}
354
+ this.onClose();
355
+ }
356
+ }
357
+
358
+ class SignalSocket {
359
+ url;
360
+ hello;
361
+ onMessage;
362
+ onClose;
363
+ ws;
364
+ timer;
365
+ constructor(url, hello, onMessage, onClose) {
366
+ this.url = url;
367
+ this.hello = hello;
368
+ this.onMessage = onMessage;
369
+ this.onClose = onClose;
370
+ }
371
+ open() {
372
+ return new Promise((resolve, reject) => {
373
+ const ws = new WebSocket(this.url, [SIGNAL_PROTO]);
374
+ this.ws = ws;
375
+ let opened = false;
376
+ ws.onopen = () => {
377
+ opened = true;
378
+ ws.send(JSON.stringify({ type: "hello", ...this.hello }));
379
+ this.timer = setInterval(() => {
380
+ try {
381
+ ws.send(PING);
382
+ } catch {}
383
+ }, KEEPALIVE_MS);
384
+ resolve();
385
+ };
386
+ ws.onmessage = (e) => {
387
+ try {
388
+ const m = JSON.parse(String(e.data));
389
+ if (m?.type !== "pong")
390
+ this.onMessage(m);
391
+ } catch {}
392
+ };
393
+ ws.onclose = (e) => {
394
+ clearInterval(this.timer);
395
+ if (!opened)
396
+ reject(new Error(`signal server ${new URL(this.url).host}: ${e.reason || `closed (${e.code})`}`));
397
+ this.onClose(e.code, e.reason);
398
+ };
399
+ ws.onerror = () => {};
400
+ });
401
+ }
402
+ send(obj) {
403
+ try {
404
+ this.ws?.send(JSON.stringify(obj));
405
+ } catch {}
406
+ }
407
+ close() {
408
+ clearInterval(this.timer);
409
+ try {
410
+ this.ws?.close();
411
+ } catch {}
412
+ }
413
+ }
414
+
415
+ class WebrtcHost {
416
+ handler;
417
+ log;
418
+ rooms = new Map;
419
+ constructor(handler, log) {
420
+ this.handler = handler;
421
+ this.log = log;
422
+ }
423
+ sync(creds) {
424
+ const wanted = new Map(creds.map((c) => [`${c.signal}\x00${c.token}`, c]));
425
+ for (const [key, room] of this.rooms)
426
+ if (!wanted.has(key)) {
427
+ this.stopRoom(room);
428
+ this.rooms.delete(key);
429
+ }
430
+ for (const [key, cred] of wanted) {
431
+ if (this.rooms.has(key))
432
+ continue;
433
+ const room = { cred, peers: new Map, backoff: 1000, stopped: false };
434
+ this.rooms.set(key, room);
435
+ this.connect(room);
436
+ }
437
+ }
438
+ stop() {
439
+ for (const room of this.rooms.values())
440
+ this.stopRoom(room);
441
+ this.rooms.clear();
442
+ }
443
+ get size() {
444
+ return this.rooms.size;
445
+ }
446
+ stopRoom(room) {
447
+ room.stopped = true;
448
+ clearTimeout(room.retry);
449
+ room.signal?.close();
450
+ for (const peer of room.peers.values()) {
451
+ peer.channel?.close();
452
+ try {
453
+ peer.pc.close();
454
+ } catch {}
455
+ }
456
+ room.peers.clear();
457
+ }
458
+ async connect(room) {
459
+ if (room.stopped)
460
+ return;
461
+ const { token, signal } = room.cred;
462
+ const sock = new SignalSocket(signalSocketUrl(signal, roomOf(token)), { role: "host", auth: roomAuthOf(token) }, (m) => void this.onSignal(room, m).catch((e) => this.log(`webrtc ${room.cred.label}: ${e}`)), (code, reason) => {
463
+ if (room.stopped)
464
+ return;
465
+ if (code === 1000 && /replaced/.test(reason)) {
466
+ this.log(`webrtc ${room.cred.label}: another host took this room over; not reconnecting`);
467
+ return;
468
+ }
469
+ room.retry = setTimeout(() => void this.connect(room), room.backoff);
470
+ room.backoff = Math.min(room.backoff * 2, 30000);
471
+ });
472
+ room.signal = sock;
473
+ try {
474
+ await sock.open();
475
+ room.backoff = 1000;
476
+ } catch (error) {
477
+ this.log(`webrtc ${room.cred.label}: ${error instanceof Error ? error.message : error}`);
478
+ }
479
+ }
480
+ async onSignal(room, m) {
481
+ if (m.type === "peer-join" && typeof m.peer === "string" && m.from === undefined) {
482
+ if (room.peers.has(m.peer))
483
+ this.closePeer(room, m.peer);
484
+ if (room.peers.size >= MAX_PEERS_PER_ROOM) {
485
+ this.log(`webrtc ${room.cred.label}: too many peers connecting; ignoring ${m.peer}`);
486
+ return;
487
+ }
488
+ return this.startPeer(room, m.peer);
489
+ }
490
+ const peer = room.peers.get(m.from);
491
+ if (!peer)
492
+ return;
493
+ if (m.type === "answer" && typeof m.sdp === "string") {
494
+ await peer.pc.setRemoteDescription({ type: "answer", sdp: m.sdp });
495
+ const th = await transcriptHash(peer.pc.localDescription.sdp, m.sdp);
496
+ peer.keysReady(th);
497
+ } else if (m.type === "candidate" && m.candidate) {
498
+ await peer.pc.addIceCandidate(m.candidate).catch(() => {});
499
+ } else if (m.type === "peer-leave" && m.from === undefined && !peer.channel?.confirmed) {
500
+ this.closePeer(room, m.from);
501
+ }
502
+ }
503
+ closePeer(room, id) {
504
+ const peer = room.peers.get(id);
505
+ if (!peer)
506
+ return;
507
+ room.peers.delete(id);
508
+ peer.channel?.close();
509
+ try {
510
+ peer.pc.close();
511
+ } catch {}
512
+ }
513
+ async startPeer(room, peerId) {
514
+ const { RTCPeerConnection } = await loadRTC(this.log);
515
+ const ice = await hostIceServers(this.log);
516
+ const pc = new RTCPeerConnection({ iceServers: ice });
517
+ let keysReady;
518
+ const keys = new Promise((r) => keysReady = r);
519
+ const peer = { pc, keysReady };
520
+ room.peers.set(peerId, peer);
521
+ const send = (obj) => room.signal?.send({ ...obj, to: peerId });
522
+ pc.onicecandidate = (e) => {
523
+ if (e.candidate)
524
+ send({ type: "candidate", candidate: { candidate: e.candidate.candidate, sdpMid: e.candidate.sdpMid } });
525
+ };
526
+ pc.onconnectionstatechange = () => {
527
+ if (["failed", "closed"].includes(pc.connectionState))
528
+ this.closePeer(room, peerId);
529
+ };
530
+ const dc = pc.createDataChannel("rech");
531
+ const channel = new SealedChannel(dc, "host");
532
+ peer.channel = channel;
533
+ const aborts = new Map;
534
+ channel.onClose = () => {
535
+ for (const a of aborts.values())
536
+ a.abort();
537
+ this.closePeer(room, peerId);
538
+ };
539
+ channel.onFrame = (frame) => void this.onFrame(channel, room.cred.token, aborts, frame);
540
+ dc.onopen = async () => {
541
+ await channel.setKeys(room.cred.token, await keys);
542
+ if (await channel.confirm())
543
+ this.log(`webrtc ${room.cred.label}: peer ${peerId} connected`);
544
+ else {
545
+ this.log(`webrtc ${room.cred.label}: peer ${peerId} failed key confirmation`);
546
+ channel.close();
547
+ }
548
+ };
549
+ setTimeout(() => {
550
+ if (!channel.confirmed)
551
+ this.closePeer(room, peerId);
552
+ }, CONNECT_TIMEOUT_MS + 5000);
553
+ const offer = await pc.createOffer();
554
+ await pc.setLocalDescription(offer);
555
+ send({ type: "offer", sdp: pc.localDescription.sdp, ice });
556
+ }
557
+ async onFrame(channel, token, aborts, f) {
558
+ if (f?.t === "abort") {
559
+ aborts.get(f.id)?.abort();
560
+ aborts.delete(f.id);
561
+ return;
562
+ }
563
+ if (f?.t !== "req" || typeof f.id !== "string" || typeof f.path !== "string" || !f.path.startsWith("/"))
564
+ return;
565
+ const ac = new AbortController;
566
+ aborts.set(f.id, ac);
567
+ try {
568
+ const req = new Request(`http://webrtc.rech${f.path}`, {
569
+ method: f.method || "GET",
570
+ headers: f.ct ? { "Content-Type": f.ct } : {},
571
+ body: f.body != null && !["GET", "HEAD"].includes(f.method) ? f.body : undefined,
572
+ signal: ac.signal
573
+ });
574
+ const res = await this.handler(req, token);
575
+ await channel.sendFrame({ t: "res", id: f.id, status: res.status, ct: res.headers.get("content-type") ?? "" });
576
+ if (res.body) {
577
+ const reader = res.body.getReader();
578
+ for (;; ) {
579
+ const { done, value } = await reader.read();
580
+ if (done || ac.signal.aborted)
581
+ break;
582
+ for (let i = 0;i < value.length; i += MAX_CHUNK)
583
+ channel.sendFrame({ t: "data", id: f.id, b64: Buffer.from(value.subarray(i, i + MAX_CHUNK)).toString("base64") });
584
+ }
585
+ }
586
+ await channel.sendFrame({ t: "end", id: f.id });
587
+ } catch (error) {
588
+ if (!ac.signal.aborted)
589
+ await channel.sendFrame({ t: "end", id: f.id, error: String(error instanceof Error ? error.message : error) });
590
+ } finally {
591
+ aborts.delete(f.id);
592
+ }
593
+ }
594
+ }
595
+
596
+ class WebrtcClient {
597
+ token;
598
+ signal;
599
+ log;
600
+ channel;
601
+ pc;
602
+ pending = new Map;
603
+ onClose = () => {};
604
+ constructor(token, signal, log = () => {}) {
605
+ this.token = token;
606
+ this.signal = signal;
607
+ this.log = log;
608
+ }
609
+ get connected() {
610
+ return !!this.channel?.open && this.channel.confirmed;
611
+ }
612
+ async connect() {
613
+ const { RTCPeerConnection } = await loadRTC(this.log);
614
+ const token = this.token;
615
+ let sock;
616
+ const result = new Promise((resolve, reject) => {
617
+ const fail = (message) => {
618
+ reject(new Error(message));
619
+ };
620
+ 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);
621
+ sock = new SignalSocket(signalSocketUrl(this.signal, roomOf(token)), { role: "client", auth: roomAuthOf(token) }, async (m) => {
622
+ try {
623
+ if (m.type === "welcome" && m.hostPresent === false)
624
+ fail("the host is offline (its rech daemon isn't connected to the signal server)");
625
+ else if (m.type === "host-leave" && !this.connected)
626
+ fail("the host left before the connection finished");
627
+ else if (m.type === "offer" && typeof m.sdp === "string" && !this.pc) {
628
+ const pc = new RTCPeerConnection({ iceServers: Array.isArray(m.ice) ? m.ice : iceServersFromEnv() });
629
+ this.pc = pc;
630
+ pc.onicecandidate = (e) => {
631
+ if (e.candidate)
632
+ sock.send({ type: "candidate", candidate: { candidate: e.candidate.candidate, sdpMid: e.candidate.sdpMid } });
633
+ };
634
+ pc.onconnectionstatechange = () => {
635
+ if (["failed", "closed"].includes(pc.connectionState))
636
+ this.close();
637
+ };
638
+ const th = new Promise((r) => {
639
+ pc.ondatachannel = (e) => {
640
+ const channel = new SealedChannel(e.channel, "client");
641
+ this.channel = channel;
642
+ channel.onFrame = (f) => this.onFrame(f);
643
+ channel.onClose = () => this.close();
644
+ const ready = async () => {
645
+ await channel.setKeys(token, await th);
646
+ if (await channel.confirm()) {
647
+ clearTimeout(timer);
648
+ resolve();
649
+ } else
650
+ fail("the host could not prove it holds this link's secret (key confirmation failed)");
651
+ };
652
+ if (e.channel.readyState === "open")
653
+ ready();
654
+ else
655
+ e.channel.onopen = ready;
656
+ };
657
+ (async () => {
658
+ await pc.setRemoteDescription({ type: "offer", sdp: m.sdp });
659
+ const answer = await pc.createAnswer();
660
+ await pc.setLocalDescription(answer);
661
+ sock.send({ type: "answer", sdp: pc.localDescription.sdp });
662
+ r(await transcriptHash(m.sdp, pc.localDescription.sdp));
663
+ })().catch((e) => fail(String(e)));
664
+ });
665
+ } else if (m.type === "candidate" && m.candidate && this.pc) {
666
+ await this.pc.addIceCandidate(m.candidate).catch(() => {});
667
+ }
668
+ } catch (error) {
669
+ fail(String(error));
670
+ }
671
+ }, (code, reason) => {
672
+ if (!this.connected)
673
+ 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})`);
674
+ });
675
+ sock.open().catch((e) => fail(e.message));
676
+ });
677
+ try {
678
+ await result;
679
+ } catch (error) {
680
+ sock?.close();
681
+ this.close();
682
+ throw error;
683
+ }
684
+ sock?.close();
685
+ }
686
+ onFrame(f) {
687
+ const p = this.pending.get(f?.id);
688
+ if (!p)
689
+ return;
690
+ if (f.t === "res")
691
+ p.onRes(f.status, f.ct);
692
+ else if (f.t === "data" && typeof f.b64 === "string")
693
+ p.onData(new Uint8Array(Buffer.from(f.b64, "base64")));
694
+ else if (f.t === "end") {
695
+ this.pending.delete(f.id);
696
+ p.onEnd(f.error);
697
+ }
698
+ }
699
+ request(method, path, body, ct, signal) {
700
+ if (!this.channel || !this.connected)
701
+ return Promise.reject(new Error("not connected"));
702
+ const id = randomHex(8), channel = this.channel;
703
+ return new Promise((resolve, reject) => {
704
+ let controller;
705
+ const stream = new ReadableStream({ start: (c) => {
706
+ controller = c;
707
+ }, cancel: () => {
708
+ channel.sendFrame({ t: "abort", id });
709
+ this.pending.delete(id);
710
+ } });
711
+ let answered = false;
712
+ this.pending.set(id, {
713
+ onRes: (status, resCt) => {
714
+ answered = true;
715
+ resolve(new Response(stream, { status, headers: resCt ? { "Content-Type": resCt } : {} }));
716
+ },
717
+ onData: (chunk) => {
718
+ try {
719
+ controller.enqueue(chunk);
720
+ } catch {}
721
+ },
722
+ onEnd: (error) => {
723
+ if (!answered)
724
+ reject(new Error(error || "the host closed the request"));
725
+ try {
726
+ error ? controller.error(new Error(error)) : controller.close();
727
+ } catch {}
728
+ }
729
+ });
730
+ signal?.addEventListener("abort", () => {
731
+ channel.sendFrame({ t: "abort", id });
732
+ this.pending.get(id)?.onEnd("aborted");
733
+ });
734
+ channel.sendFrame({ t: "req", id, method, path, ct, body });
735
+ });
736
+ }
737
+ close() {
738
+ const had = !!this.pc;
739
+ this.channel?.close();
740
+ try {
741
+ this.pc?.close();
742
+ } catch {}
743
+ this.channel = undefined;
744
+ this.pc = undefined;
745
+ for (const p of this.pending.values())
746
+ p.onEnd("connection closed");
747
+ this.pending.clear();
748
+ if (had)
749
+ this.onClose();
750
+ }
751
+ }
752
+ var BRIDGE_DIR = join(homedir(), ".rechrome", "bridges");
753
+ var BRIDGE_IDLE_MS = Number(process.env.RECH_BRIDGE_IDLE_MS) || 900000;
754
+ var bridgeFile = (token, dir = BRIDGE_DIR) => join(dir, `${roomOf(token)}.json`);
755
+ function readBridge(token, dir = BRIDGE_DIR) {
756
+ try {
757
+ return JSON.parse(readFileSync(bridgeFile(token, dir), "utf8"));
758
+ } catch {
759
+ return null;
760
+ }
761
+ }
762
+ async function runBridge(token, signal, opts = {}) {
763
+ const log = opts.log ?? (() => {});
764
+ const dir = opts.dir ?? BRIDGE_DIR;
765
+ const client = new WebrtcClient(token, signal, log);
766
+ let connecting = null;
767
+ const ensure = async () => {
768
+ if (client.connected)
769
+ return;
770
+ connecting ??= client.connect().finally(() => {
771
+ connecting = null;
772
+ });
773
+ await connecting;
774
+ };
775
+ const key = randomBytes2(24).toString("base64url");
776
+ let lastUsed = Date.now();
777
+ const server = Bun.serve({
778
+ hostname: "127.0.0.1",
779
+ port: opts.port ?? 0,
780
+ idleTimeout: 0,
781
+ async fetch(req) {
782
+ const url = new URL(req.url);
783
+ if (url.pathname === "/__bridge")
784
+ return Response.json({ ok: true, connected: client.connected, pid: process.pid, room: roomOf(token) });
785
+ if (req.headers.get("authorization") !== `Bearer ${key}`)
786
+ return new Response("Unauthorized", { status: 401 });
787
+ lastUsed = Date.now();
788
+ try {
789
+ await ensure();
790
+ } catch (error) {
791
+ return Response.json({ status: 1, stdout: "", stderr: `[rech] WebRTC: ${error instanceof Error ? error.message : error}
792
+ ` }, { status: 502 });
793
+ }
794
+ const body = ["GET", "HEAD"].includes(req.method) ? undefined : await req.text();
795
+ try {
796
+ return await client.request(req.method, url.pathname + url.search, body, req.headers.get("content-type") ?? undefined, req.signal);
797
+ } catch (error) {
798
+ return Response.json({ status: 1, stdout: "", stderr: `[rech] WebRTC: ${error instanceof Error ? error.message : error}
799
+ ` }, { status: 502 });
800
+ }
801
+ }
802
+ });
803
+ const info = { pid: process.pid, port: server.port, key, signal, room: roomOf(token) };
804
+ mkdirSync(dir, { recursive: true, mode: 448 });
805
+ const path = bridgeFile(token, dir);
806
+ writeFileSync(path, JSON.stringify(info), { mode: 384 });
807
+ const idle = setInterval(() => {
808
+ if (Date.now() - lastUsed > BRIDGE_IDLE_MS)
809
+ stop();
810
+ }, 30000);
811
+ const stop = () => {
812
+ clearInterval(idle);
813
+ client.close();
814
+ server.stop(true);
815
+ try {
816
+ if (readBridge(token, dir)?.pid === process.pid)
817
+ unlinkSync(path);
818
+ } catch {}
819
+ opts.onStop?.();
820
+ };
821
+ ensure().catch((error) => log(`[rech] WebRTC: ${error instanceof Error ? error.message : error}`));
822
+ return { info, stop };
823
+ }
824
+ async function liveBridge(token, dir = BRIDGE_DIR) {
825
+ const info = readBridge(token, dir);
826
+ if (!info)
827
+ return null;
828
+ const body = await fetch(`http://127.0.0.1:${info.port}/__bridge`, { signal: AbortSignal.timeout(1000) }).then((r) => r.ok ? r.json() : null, () => null);
829
+ if (body?.room === info.room && body.pid === info.pid)
830
+ return info;
831
+ try {
832
+ unlinkSync(bridgeFile(token, dir));
833
+ } catch {}
834
+ return null;
835
+ }
836
+ async function spawnBridge(token, signal, entry, dir = BRIDGE_DIR) {
837
+ const existing = await liveBridge(token, dir);
838
+ if (existing)
839
+ return existing;
840
+ mkdirSync(dir, { recursive: true, mode: 448 });
841
+ const logPath = join(dir, `${roomOf(token)}.log`);
842
+ const proc = Bun.spawn([...entry, "bridge"], {
843
+ env: { ...process.env, RECH_BRIDGE_TOKEN: token, RECH_BRIDGE_SIGNAL: signal },
844
+ stdin: "ignore",
845
+ stdout: Bun.file(logPath),
846
+ stderr: Bun.file(logPath),
847
+ windowsHide: true
848
+ });
849
+ proc.unref();
850
+ for (let i = 0;i < 100; i++) {
851
+ const info = await liveBridge(token, dir);
852
+ if (info)
853
+ return info;
854
+ if (proc.exitCode !== null)
855
+ break;
856
+ await new Promise((r) => setTimeout(r, 100));
857
+ }
858
+ const tail = existsSync(logPath) ? readFileSync(logPath, "utf8").trim().split(`
859
+ `).slice(-3).join(`
860
+ `) : "";
861
+ throw new Error(`the local WebRTC bridge did not start${tail ? `:
862
+ ${tail}` : ""}`);
863
+ }
864
+ export {
865
+ BRIDGE_DIR,
866
+ SIGNAL_PROTO,
867
+ WEBRTC_RUNTIME_DIR,
868
+ WebrtcClient,
869
+ WebrtcHost,
870
+ bridgeFile,
871
+ hostIceServers,
872
+ iceServersFromEnv,
873
+ liveBridge,
874
+ loadRTC,
875
+ readBridge,
876
+ runBridge,
877
+ signalSocketUrl,
878
+ spawnBridge
879
+ };