rechrome 1.36.1 → 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/shares.ts ADDED
@@ -0,0 +1,257 @@
1
+ // Shares: who may connect, to which profiles, over which transports.
2
+ //
3
+ // A listener is a transport (an address); a share is a grant on one or more transports. Each
4
+ // share has an invite (a rech1_ token) that is used up as devices redeem it: a redeemed device
5
+ // gets its own rech1_ credential, so a link that leaks after it was used no longer works, and
6
+ // the host can see, approve and drop each device. Legacy listener keys (#key=…) stay valid as
7
+ // they are; shares are an additional way in.
8
+ import { randomBytes } from "crypto";
9
+ import { homedir } from "os";
10
+ import { join } from "path";
11
+ import { mkdir, readFile, rename, rm, stat, writeFile } from "fs/promises";
12
+ import { bearerOf, credentialIdOf, newToken, safeEqual } from "./secret.ts";
13
+
14
+ export const WEBRTC = "webrtc";
15
+ export const DEFAULT_SIGNAL = "https://rechrome.pages.dev/";
16
+ export const SHARES_FILE = join(homedir(), ".rechrome", "shares.json");
17
+
18
+ export type Device = { id: string; name: string; token: string; addedAt: number; lastSeen?: number; approved: boolean };
19
+ export type Share = {
20
+ id: string;
21
+ label: string;
22
+ invite: string; // rech1_ token; replaced by rotate
23
+ inviteExpiresAt?: number; // unused invites stop working after this
24
+ inviteTtlMs?: number; // how long each new invite stays usable (rotate reuses it)
25
+ profiles: string[];
26
+ via: string[]; // listener names, and/or "webrtc"
27
+ signal?: string; // signal server origin, for webrtc
28
+ createdAt: number;
29
+ expiresAt?: number;
30
+ maxDevices: number;
31
+ approve: boolean; // each new device waits for `rech share approve`
32
+ devices: Device[];
33
+ revokedAt?: number;
34
+ };
35
+ export type ShareConfig = { version: 1; shares: Share[] };
36
+
37
+ const LABEL = /^[a-z0-9][a-z0-9_-]{0,39}$/;
38
+
39
+ export function validateShares(value: unknown): ShareConfig {
40
+ const config = value as ShareConfig;
41
+ if (config?.version !== 1 || !Array.isArray(config.shares)) throw new Error("shares.json requires version 1 and a shares list");
42
+ const labels = new Set<string>();
43
+ for (const s of config.shares) {
44
+ if (!s || typeof s.id !== "string" || !LABEL.test(s.label) || typeof s.invite !== "string" || !Array.isArray(s.profiles)
45
+ || !Array.isArray(s.via) || !s.via.length || !Array.isArray(s.devices) || !Number.isInteger(s.maxDevices) || s.maxDevices < 1)
46
+ throw new Error(`shares.json: malformed share ${JSON.stringify(s?.label ?? s?.id ?? "?")}`);
47
+ if (labels.has(s.label)) throw new Error(`shares.json: duplicate share name "${s.label}"`);
48
+ labels.add(s.label);
49
+ }
50
+ return config;
51
+ }
52
+
53
+ export async function readShares(path = SHARES_FILE): Promise<ShareConfig> {
54
+ let raw: string;
55
+ try { raw = await readFile(path, "utf8"); }
56
+ catch (error: any) { if (error.code === "ENOENT") return { version: 1, shares: [] }; throw error; }
57
+ return validateShares(JSON.parse(raw));
58
+ }
59
+
60
+ export async function writeShares(config: ShareConfig, path = SHARES_FILE): Promise<void> {
61
+ validateShares(config);
62
+ await mkdir(join(path, ".."), { recursive: true });
63
+ const temp = `${path}.${randomBytes(6).toString("hex")}.tmp`;
64
+ await writeFile(temp, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
65
+ await rename(temp, path);
66
+ }
67
+
68
+ /**
69
+ * Read-modify-write under a lock directory, so the daemon (redeeming invites, noting device
70
+ * use) and the CLI (allow, revoke, approve) can't drop each other's changes.
71
+ */
72
+ export async function updateShares<T>(fn: (config: ShareConfig) => T | Promise<T>, path = SHARES_FILE): Promise<T> {
73
+ const lock = `${path}.lock`;
74
+ await mkdir(join(path, ".."), { recursive: true });
75
+ for (;;) {
76
+ try { await mkdir(lock); break; }
77
+ catch (error: any) {
78
+ if (error.code !== "EEXIST") throw error;
79
+ // Break a lock only when its owner is gone (crashed) or it is far past any real write.
80
+ if (await staleLock(lock)) { await rm(lock, { recursive: true, force: true }); continue; }
81
+ await new Promise(r => setTimeout(r, 25 + Math.random() * 50));
82
+ }
83
+ }
84
+ await writeFile(join(lock, "owner"), JSON.stringify({ pid: process.pid, at: Date.now() }));
85
+ try {
86
+ const config = await readShares(path);
87
+ const result = await fn(config);
88
+ await writeShares(config, path);
89
+ return result;
90
+ } finally { await rm(lock, { recursive: true, force: true }); }
91
+ }
92
+
93
+ async function staleLock(lock: string): Promise<boolean> {
94
+ try {
95
+ const { pid, at } = JSON.parse(await readFile(join(lock, "owner"), "utf8"));
96
+ if (Date.now() - at > 30_000) return true;
97
+ try { process.kill(pid, 0); return false; } catch (error: any) { return error.code === "ESRCH"; }
98
+ } catch {
99
+ // No owner file yet: the holder is between mkdir and writing it; stale only if old.
100
+ const st = await stat(lock).catch(() => null);
101
+ return !!st && Date.now() - st.mtimeMs > 30_000;
102
+ }
103
+ }
104
+
105
+ export function findShare(config: ShareConfig, name: string): Share {
106
+ const share = config.shares.find(s => s.label === name || s.id === name);
107
+ if (!share) throw new Error(`No share named "${name}". See: rech share ls`);
108
+ return share;
109
+ }
110
+
111
+ export type NewShareOptions = {
112
+ label: string; profiles: string[]; via: string[]; signal?: string;
113
+ expiresAt?: number; inviteTtlMs?: number; maxDevices?: number; approve?: boolean; now?: number;
114
+ };
115
+
116
+ export function createShare(config: ShareConfig, opts: NewShareOptions): Share {
117
+ const now = opts.now ?? Date.now();
118
+ if (!LABEL.test(opts.label)) throw new Error(`Share names use a-z, 0-9, "-" and "_" (up to 40), e.g. alice or ci-bot; got "${opts.label}"`);
119
+ if (config.shares.some(s => s.label === opts.label)) throw new Error(`A share named "${opts.label}" already exists. Pick another --name, or: rech share rotate ${opts.label}`);
120
+ if (!opts.profiles.length) throw new Error("A share needs at least one profile.");
121
+ if (!opts.via.length) throw new Error("A share needs at least one transport (--via).");
122
+ const share: Share = {
123
+ id: randomBytes(4).toString("hex"),
124
+ label: opts.label,
125
+ invite: newToken(),
126
+ inviteExpiresAt: opts.inviteTtlMs ? now + opts.inviteTtlMs : undefined,
127
+ inviteTtlMs: opts.inviteTtlMs,
128
+ profiles: [...new Set(opts.profiles)],
129
+ via: [...new Set(opts.via)],
130
+ signal: opts.via.includes(WEBRTC) ? normalizeSignal(opts.signal ?? DEFAULT_SIGNAL) : undefined,
131
+ createdAt: now,
132
+ expiresAt: opts.expiresAt,
133
+ maxDevices: opts.maxDevices ?? 1,
134
+ approve: !!opts.approve,
135
+ devices: [],
136
+ };
137
+ config.shares.push(share);
138
+ return share;
139
+ }
140
+
141
+ export function normalizeSignal(value: string): string {
142
+ let url: URL;
143
+ try { url = new URL(value); } catch { throw new Error(`Signal server must be an http(s) URL, e.g. https://signal.example.com; got "${value}"`); }
144
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash)
145
+ throw new Error("Signal server must be a plain http(s) URL, without credentials, query or fragment");
146
+ if (!url.pathname.endsWith("/")) url.pathname += "/";
147
+ return url.toString();
148
+ }
149
+
150
+ /** The link to send: the transport's origin plus the invite in the #fragment. */
151
+ export function inviteLink(share: Share, origin: string): string {
152
+ const base = origin.endsWith("/") ? origin : `${origin}/`;
153
+ return `${base}#${share.invite}`;
154
+ }
155
+
156
+ export type Credential = { share: Share; kind: "invite"; token: string } | { share: Share; kind: "device"; token: string; device: Device };
157
+
158
+ const bearerCache = new Map<string, string>();
159
+ const cachedBearer = (token: string) => {
160
+ let b = bearerCache.get(token);
161
+ if (!b) { b = bearerOf(token); bearerCache.set(token, b); }
162
+ return b;
163
+ };
164
+
165
+ /** The share credential an HTTP bearer belongs to, if any. */
166
+ export function credentialForBearer(config: ShareConfig, bearer: string | null | undefined): Credential | null {
167
+ if (!bearer) return null;
168
+ for (const share of config.shares) {
169
+ if (safeEqual(cachedBearer(share.invite), bearer)) return { share, kind: "invite", token: share.invite };
170
+ for (const device of share.devices)
171
+ if (safeEqual(cachedBearer(device.token), bearer)) return { share, kind: "device", token: device.token, device };
172
+ }
173
+ return null;
174
+ }
175
+
176
+ export type Denial = { status: number; message: string; pending?: true };
177
+
178
+ const day = (ms: number) => new Date(ms).toISOString().slice(0, 10);
179
+
180
+ /** Why a share as a whole can't be used right now (revoked, expired), or null. */
181
+ export function shareProblem(share: Share, now = Date.now()): string | null {
182
+ if (share.revokedAt) return `Share "${share.label}" was revoked by its host.`;
183
+ if (share.expiresAt && now >= share.expiresAt) return `Share "${share.label}" expired on ${day(share.expiresAt)}. Ask the host to renew it: rech share renew ${share.label}`;
184
+ return null;
185
+ }
186
+
187
+ /** Why an unused invite can't be redeemed right now, or null. */
188
+ export function inviteProblem(share: Share, now = Date.now()): string | null {
189
+ if (share.inviteExpiresAt && now >= share.inviteExpiresAt) return `This invite expired unused. Ask the host for a fresh one: rech share rotate ${share.label}`;
190
+ if (share.devices.length >= share.maxDevices) return `This invite was already used${share.maxDevices > 1 ? ` by ${share.maxDevices} devices` : ""}. Ask the host for a fresh one (rech share rotate ${share.label}); if you didn't use it, tell them, since someone else did.`;
191
+ return null;
192
+ }
193
+
194
+ /**
195
+ * Whether a credential may make this request. Invites may only ping and redeem; a device may
196
+ * do everything its share allows once approved. `via` is the transport the request came in on.
197
+ */
198
+ export function authorizeCredential(cred: Credential, via: string, route: string, now = Date.now()): Denial | null {
199
+ const problem = shareProblem(cred.share, now);
200
+ if (problem) return { status: 403, message: problem };
201
+ if (!cred.share.via.includes(via)) return { status: 403, message: `Share "${cred.share.label}" is not offered on this connection.` };
202
+ if (cred.kind === "invite") {
203
+ if (route === "ping") return null;
204
+ const inv = inviteProblem(cred.share, now);
205
+ if (inv) return { status: 403, message: inv };
206
+ if (route === "redeem") return null;
207
+ return { status: 403, message: "This link is an invite: run `rech connect '<link>'` once to register this device." };
208
+ }
209
+ if (!cred.device.approved && route !== "ping")
210
+ return { status: 403, pending: true, message: `Waiting for the host to approve this device (${cred.device.name}, ${cred.device.id}). On the host: rech share approve ${cred.share.label} ${cred.device.id}` };
211
+ return null;
212
+ }
213
+
214
+ /** Turn one use of an invite into a device credential. */
215
+ export function redeemInvite(config: ShareConfig, shareId: string, name: string, now = Date.now()): Device {
216
+ const share = config.shares.find(s => s.id === shareId);
217
+ if (!share) throw new Error("This share no longer exists.");
218
+ const problem = shareProblem(share, now) ?? inviteProblem(share, now);
219
+ if (problem) throw new Error(problem);
220
+ const token = newToken();
221
+ const device: Device = {
222
+ id: credentialIdOf(token),
223
+ name: (name || "device").replace(/[^\w .@-]/g, "").slice(0, 40) || "device",
224
+ token, addedAt: now, approved: !share.approve,
225
+ };
226
+ share.devices.push(device);
227
+ return device;
228
+ }
229
+
230
+ export function findDevice(share: Share, id: string): Device {
231
+ const device = share.devices.find(d => d.id === id || d.name === id);
232
+ if (!device) throw new Error(`Share "${share.label}" has no device "${id}". See: rech share devices ${share.label}`);
233
+ return device;
234
+ }
235
+
236
+ /** Parse "30d", "24h", "90m", "2w"; "never"/"none"/"0" mean no limit (undefined). */
237
+ export function parseDuration(text: string): number | undefined {
238
+ const t = text.trim().toLowerCase();
239
+ if (["never", "none", "0", "off"].includes(t)) return undefined;
240
+ const m = /^(\d+(?:\.\d+)?)\s*(m|min|h|d|w)$/.exec(t);
241
+ if (!m) throw new Error(`Durations look like 90m, 24h, 30d or 2w (or "never"); got "${text}"`);
242
+ const unit = { m: 60_000, min: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 }[m[2] as "m"];
243
+ return Math.round(Number(m[1]) * unit);
244
+ }
245
+
246
+ /** Every credential a WebRTC host should be reachable on: unused invites and devices. */
247
+ export function webrtcCredentials(config: ShareConfig, now = Date.now()): Credential[] {
248
+ const out: Credential[] = [];
249
+ for (const share of config.shares) {
250
+ if (!share.via.includes(WEBRTC) || shareProblem(share, now)) continue;
251
+ // A used-up invite keeps its room while the share lives, so whoever tries it again hears
252
+ // "already used" (a sign the link leaked) rather than "host offline".
253
+ if (!(share.inviteExpiresAt && now >= share.inviteExpiresAt)) out.push({ share, kind: "invite", token: share.invite });
254
+ for (const device of share.devices) out.push({ share, kind: "device", token: device.token, device });
255
+ }
256
+ return out;
257
+ }
@@ -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
+ }