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.
@@ -0,0 +1,112 @@
1
+ // @bun
2
+ // signal-relay.ts
3
+ var SIGNAL_PROTO = "rech-signal-1";
4
+ var MAX_CLIENTS = 32;
5
+ var MAX_MSG = 64 * 1024;
6
+ var PING = '{"type":"ping"}';
7
+ var PONG = '{"type":"pong"}';
8
+ var ROOM = /^\/([1-9A-HJ-NP-Za-km-z]{8,32})$/;
9
+ var LANDING = `<!doctype html><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1">
10
+ <title>rech signal</title><body style="font:16px/1.6 system-ui;max-width:40em;margin:12vh auto;padding:0 16px">
11
+ <h1>rech signal server</h1><p>A WebRTC rendezvous for <a href="https://github.com/snomiao/rechrome">rechrome</a> shares.</p>
12
+ <p id=c></p><script>if(/^#rech1_/.test(location.hash)){document.getElementById("c").textContent=
13
+ "Connect from a terminal: bunx rechrome connect '"+location.href+"'";}</script>`;
14
+ function startSignalRelay(opts = {}) {
15
+ const rooms = new Map;
16
+ const log = opts.log ?? (() => {});
17
+ const send = (ws, obj) => ws?.send(JSON.stringify(obj));
18
+ return Bun.serve({
19
+ hostname: opts.host ?? "127.0.0.1",
20
+ port: opts.port ?? 8787,
21
+ fetch(req, server) {
22
+ const url = new URL(req.url);
23
+ if (url.pathname === "/ping")
24
+ return Response.json({ "rech-signal": 1 });
25
+ const m = ROOM.exec(url.pathname);
26
+ if (m && req.headers.get("upgrade")?.toLowerCase() === "websocket") {
27
+ const offered = (req.headers.get("sec-websocket-protocol") ?? "").split(",").map((s) => s.trim());
28
+ if (!offered.includes(SIGNAL_PROTO))
29
+ return new Response("bad subprotocol", { status: 400 });
30
+ if (server.upgrade(req, { data: { room: m[1], authed: false }, headers: { "Sec-WebSocket-Protocol": SIGNAL_PROTO } }))
31
+ return;
32
+ return new Response("upgrade failed", { status: 400 });
33
+ }
34
+ return new Response(LANDING, { headers: { "content-type": "text/html; charset=utf-8", "referrer-policy": "no-referrer" } });
35
+ },
36
+ websocket: {
37
+ maxPayloadLength: MAX_MSG,
38
+ idleTimeout: 120,
39
+ message(ws, raw) {
40
+ if (raw === PING)
41
+ return void ws.send(PONG);
42
+ let msg;
43
+ try {
44
+ msg = JSON.parse(String(raw));
45
+ } catch {
46
+ return;
47
+ }
48
+ if (!msg || typeof msg !== "object")
49
+ return;
50
+ const self = ws.data;
51
+ if (!self.authed) {
52
+ if (msg.type !== "hello" || typeof msg.auth !== "string" || !msg.auth)
53
+ return ws.close(1008, "expected hello");
54
+ const role = msg.role === "host" ? "host" : "client";
55
+ let room = rooms.get(self.room);
56
+ if (!room) {
57
+ if (role !== "host")
58
+ return ws.close(4404, "host offline");
59
+ room = { auth: msg.auth, clients: new Map };
60
+ rooms.set(self.room, room);
61
+ } else if (room.auth !== msg.auth)
62
+ return ws.close(1008, "forbidden");
63
+ if (role === "host") {
64
+ if (room.host && room.host !== ws)
65
+ room.host.close(1000, "replaced by a newer host connection");
66
+ room.host = ws;
67
+ self.peer = "host";
68
+ } else {
69
+ if (room.clients.size >= MAX_CLIENTS)
70
+ return ws.close(1013, "room full");
71
+ self.peer = crypto.randomUUID().slice(0, 8);
72
+ room.clients.set(self.peer, ws);
73
+ }
74
+ self.authed = true;
75
+ self.role = role;
76
+ send(ws, { type: "welcome", peer: self.peer, hostPresent: !!room.host });
77
+ if (role === "client")
78
+ send(room.host, { type: "peer-join", peer: self.peer });
79
+ log(`${role} joined a room (${rooms.size} rooms)`);
80
+ return;
81
+ }
82
+ const room = rooms.get(self.room);
83
+ if (!room)
84
+ return;
85
+ if (self.role === "client")
86
+ send(room.host, { ...msg, from: self.peer });
87
+ else if (typeof msg.to === "string")
88
+ send(room.clients.get(msg.to), msg);
89
+ },
90
+ close(ws) {
91
+ const self = ws.data;
92
+ const room = rooms.get(self.room);
93
+ if (!self.authed || !room)
94
+ return;
95
+ if (self.role === "host" && room.host === ws) {
96
+ room.host = undefined;
97
+ for (const c of room.clients.values())
98
+ send(c, { type: "host-leave" });
99
+ } else if (self.role === "client" && self.peer) {
100
+ room.clients.delete(self.peer);
101
+ send(room.host, { type: "peer-leave", peer: self.peer });
102
+ }
103
+ if (!room.host && !room.clients.size)
104
+ rooms.delete(self.room);
105
+ }
106
+ }
107
+ });
108
+ }
109
+ export {
110
+ SIGNAL_PROTO,
111
+ startSignalRelay
112
+ };
@@ -0,0 +1,99 @@
1
+ // The rech-signal-1 relay: `rech signal serve`. A rendezvous only: it forwards hello / offer /
2
+ // answer / candidate frames between one host and its clients in a room, in memory, and forgets
3
+ // the room when everyone has left. It never sees a share secret (room ids and auth tokens are
4
+ // one-way HKDF outputs of it), and after the handshake traffic goes peer to peer, so running
5
+ // your own changes availability and who sees connection metadata, not who can read the browser.
6
+ // signal/cloudflare/worker.ts is the same protocol on Cloudflare Workers.
7
+ import type { Server, ServerWebSocket } from "bun";
8
+
9
+ export const SIGNAL_PROTO = "rech-signal-1";
10
+ const MAX_CLIENTS = 32;
11
+ const MAX_MSG = 64 * 1024;
12
+ const PING = '{"type":"ping"}', PONG = '{"type":"pong"}';
13
+ const ROOM = /^\/([1-9A-HJ-NP-Za-km-z]{8,32})$/; // base58 room id
14
+
15
+ type Peer = { room: string; authed: boolean; role?: "host" | "client"; peer?: string };
16
+ type Room = { auth: string; host?: ServerWebSocket<Peer>; clients: Map<string, ServerWebSocket<Peer>> };
17
+
18
+ const LANDING = `<!doctype html><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1">
19
+ <title>rech signal</title><body style="font:16px/1.6 system-ui;max-width:40em;margin:12vh auto;padding:0 16px">
20
+ <h1>rech signal server</h1><p>A WebRTC rendezvous for <a href="https://github.com/snomiao/rechrome">rechrome</a> shares.</p>
21
+ <p id=c></p><script>if(/^#rech1_/.test(location.hash)){document.getElementById("c").textContent=
22
+ "Connect from a terminal: bunx rechrome connect '"+location.href+"'";}</script>`;
23
+
24
+ export function startSignalRelay(opts: { host?: string; port?: number; log?: (m: string) => void } = {}): Server<Peer> {
25
+ const rooms = new Map<string, Room>();
26
+ const log = opts.log ?? (() => {});
27
+ const send = (ws: ServerWebSocket<Peer> | undefined, obj: unknown) => ws?.send(JSON.stringify(obj));
28
+ return Bun.serve<Peer>({
29
+ hostname: opts.host ?? "127.0.0.1",
30
+ port: opts.port ?? 8787,
31
+ fetch(req, server) {
32
+ const url = new URL(req.url);
33
+ // `rech connect` probes this to tell a signal host from a rech daemon.
34
+ if (url.pathname === "/ping") return Response.json({ "rech-signal": 1 });
35
+ const m = ROOM.exec(url.pathname);
36
+ if (m && req.headers.get("upgrade")?.toLowerCase() === "websocket") {
37
+ const offered = (req.headers.get("sec-websocket-protocol") ?? "").split(",").map(s => s.trim());
38
+ if (!offered.includes(SIGNAL_PROTO)) return new Response("bad subprotocol", { status: 400 });
39
+ if (server.upgrade(req, { data: { room: m[1], authed: false }, headers: { "Sec-WebSocket-Protocol": SIGNAL_PROTO } })) return;
40
+ return new Response("upgrade failed", { status: 400 });
41
+ }
42
+ // The share secret rides in the #fragment, which browsers never send here.
43
+ return new Response(LANDING, { headers: { "content-type": "text/html; charset=utf-8", "referrer-policy": "no-referrer" } });
44
+ },
45
+ websocket: {
46
+ maxPayloadLength: MAX_MSG,
47
+ idleTimeout: 120, // hosts ping every 25 s
48
+ message(ws, raw) {
49
+ if (raw === PING) return void ws.send(PONG);
50
+ let msg: any;
51
+ try { msg = JSON.parse(String(raw)); } catch { return; }
52
+ if (!msg || typeof msg !== "object") return;
53
+ const self = ws.data;
54
+ if (!self.authed) {
55
+ if (msg.type !== "hello" || typeof msg.auth !== "string" || !msg.auth) return ws.close(1008, "expected hello");
56
+ const role = msg.role === "host" ? "host" : "client";
57
+ let room = rooms.get(self.room);
58
+ if (!room) {
59
+ if (role !== "host") return ws.close(4404, "host offline");
60
+ room = { auth: msg.auth, clients: new Map() };
61
+ rooms.set(self.room, room);
62
+ } else if (room.auth !== msg.auth) return ws.close(1008, "forbidden");
63
+ if (role === "host") {
64
+ if (room.host && room.host !== ws) room.host.close(1000, "replaced by a newer host connection");
65
+ room.host = ws;
66
+ self.peer = "host";
67
+ } else {
68
+ if (room.clients.size >= MAX_CLIENTS) return ws.close(1013, "room full");
69
+ self.peer = crypto.randomUUID().slice(0, 8);
70
+ room.clients.set(self.peer, ws);
71
+ }
72
+ self.authed = true;
73
+ self.role = role;
74
+ send(ws, { type: "welcome", peer: self.peer, hostPresent: !!room.host });
75
+ if (role === "client") send(room.host, { type: "peer-join", peer: self.peer });
76
+ log(`${role} joined a room (${rooms.size} rooms)`);
77
+ return;
78
+ }
79
+ const room = rooms.get(self.room);
80
+ if (!room) return;
81
+ if (self.role === "client") send(room.host, { ...msg, from: self.peer });
82
+ else if (typeof msg.to === "string") send(room.clients.get(msg.to), msg);
83
+ },
84
+ close(ws) {
85
+ const self = ws.data;
86
+ const room = rooms.get(self.room);
87
+ if (!self.authed || !room) return;
88
+ if (self.role === "host" && room.host === ws) {
89
+ room.host = undefined;
90
+ for (const c of room.clients.values()) send(c, { type: "host-leave" });
91
+ } else if (self.role === "client" && self.peer) {
92
+ room.clients.delete(self.peer);
93
+ send(room.host, { type: "peer-leave", peer: self.peer });
94
+ }
95
+ if (!room.host && !room.clients.size) rooms.delete(self.room);
96
+ },
97
+ },
98
+ });
99
+ }