anvilsign in

collin/mahjong

1import { spawn, type ChildProcess } from 'node:child_process';
2import type { IncomingMessage } from 'node:http';
3import type { Duplex } from 'node:stream';
4import { WebSocketServer, type WebSocket } from 'ws';
5
6/**
7 * The room relay: everything the game needs from a server, which is very
8 * little. Rooms hold members in join order; the earliest one still connected
9 * is the host. The relay never reads the game — it keeps one bag of shared
10 * state that only the host may write, fans out patches and events to everyone
11 * else, and announces joins, leaves, and host changes. All the mahjong lives
12 * in the clients (src/net/), exactly as it did on antics.
13 *
14 * Attachable to any http.Server — vite's dev server in development
15 * (vite.config.ts), our own static server in production (server/index.ts) —
16 * so the client can always reach it at wss://<same-origin>/ws.
17 *
18 * Identity is client-chosen: a device sends its own id (a UUID it keeps in
19 * sessionStorage) and the relay takes it at its word. That is what lets a
20 * reload or a wifi blip land back in the same seat — the seat map in shared
21 * state is keyed on these ids. No auth; this is a home server for a table of
22 * friends, and fairness at this table is social anyway (see README).
23 */
24
25interface Member {
26 id: string;
27 name: string;
28 ws: WebSocket;
29}
30
31interface RoomRec {
32 code: string;
33 members: Map<string, Member>;
34 /** Join order. The first id still present is the host. */
35 order: string[];
36 state: Record<string, unknown>;
37 /** How many "Guest N" names this room has handed out. */
38 guests: number;
39}
40
41/** What a client may say. `hello` must come first; the rest need a room. */
42type ClientMsg =
43 | { t: 'hello'; id?: string; room?: string; name?: string }
44 | { t: 'set'; patch?: Record<string, unknown> }
45 | { t: 'ev'; type?: string; payload?: unknown };
46
47/** No 0/O/1/I/L: these codes get read aloud across a table. */
48const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
49const PING_MS = 30_000;
50
51function newCode(taken: (code: string) => boolean): string {
52 for (;;) {
53 let code = '';
54 for (let i = 0; i < 6; i++)
55 code += CODE_ALPHABET[Math.floor(Math.random() * CODE_ALPHABET.length)];
56 if (!taken(code)) return code;
57 }
58}
59
60/** Just the few events we hang off a server — http and http2 servers both
61 * fit, which is what lets the relay ride vite's as easily as our own. */
62export interface UpgradeServer {
63 on(event: 'upgrade', cb: (req: IncomingMessage, socket: Duplex, head: Buffer) => void): unknown;
64 on(event: 'listening', cb: () => void): unknown;
65 on(event: 'close', cb: () => void): unknown;
66 /** node's Server.address() when there is one — how we learn our own port. */
67 address?(): unknown;
68}
69
70/**
71 * The public face of this server: a QR pointing at localhost is a QR only the
72 * host's own machine can scan. `PUBLIC_URL` names a face outright; otherwise
73 * we open our own rsgrok tunnel (`RSGROK_BIN` overrides the binary) the
74 * moment the server knows its port, read the https URL off the tunnel-up
75 * line, and hand it to every client on join. A tunnel that dies — network
76 * gone, rsgrok missing — is retried on later joins, at most once per window.
77 */
78const TUNNEL_RETRY = 10_000;
79/** How long a join will wait on a tunnel still shaking hands. */
80const TUNNEL_WAIT = 3_000;
81
82export function attachRooms(server: UpgradeServer, path = '/ws'): void {
83 const rooms = new Map<string, RoomRec>();
84 const wss = new WebSocketServer({ noServer: true, maxPayload: 1 << 20 });
85
86 const ownPort = (): number | null => {
87 const addr = server.address?.();
88 return addr && typeof addr === 'object' ? ((addr as { port?: number }).port ?? null) : null;
89 };
90
91 let tunnel: ChildProcess | null = null;
92 let tunnelUrl = '';
93 let tunnelDiedAt = 0;
94 let waiters: Array<() => void> = [];
95 const wake = () => {
96 for (const w of waiters) w();
97 waiters = [];
98 };
99
100 const ensureTunnel = (): void => {
101 if (process.env.PUBLIC_URL || tunnel || Date.now() - tunnelDiedAt < TUNNEL_RETRY) return;
102 const port = ownPort();
103 if (port === null) return;
104 const child = spawn(
105 process.env.RSGROK_BIN ?? 'rsgrok',
106 // No :4040 inspection API — another agent may already own that port.
107 ['http', `127.0.0.1:${port}`, '--web-addr', 'false'],
108 { stdio: ['ignore', 'pipe', 'inherit'] },
109 );
110 tunnel = child;
111 let out = '';
112 child.stdout!.on('data', (chunk: Buffer) => {
113 out += chunk.toString();
114 const up = /^rsgrok: (https:\/\/\S+) ->/m.exec(out);
115 if (up && up[1] !== tunnelUrl) {
116 tunnelUrl = up[1];
117 // The tunnel-up line lands in our pipe, not the terminal — re-say it.
118 console.log(`public link: ${tunnelUrl}`);
119 wake();
120 }
121 });
122 const gone = () => {
123 if (tunnel === child) {
124 tunnel = null;
125 tunnelUrl = '';
126 tunnelDiedAt = Date.now();
127 }
128 wake();
129 };
130 child.on('error', gone); // rsgrok not installed, say
131 child.on('exit', gone);
132 };
133
134 // Open the tunnel as soon as there is a port to aim it at, not on the
135 // first join — by the time anyone scans a QR the link should exist.
136 if (ownPort() !== null) ensureTunnel();
137 else server.on('listening', ensureTunnel);
138
139 const publicBase = async (): Promise<string> => {
140 if (process.env.PUBLIC_URL) return process.env.PUBLIC_URL;
141 ensureTunnel();
142 if (!tunnelUrl && tunnel) {
143 await new Promise<void>((resolve) => {
144 waiters.push(resolve);
145 setTimeout(resolve, TUNNEL_WAIT).unref();
146 });
147 }
148 return tunnelUrl;
149 };
150
151 server.on('upgrade', (req, socket, head) => {
152 let pathname: string;
153 try {
154 pathname = new URL(req.url ?? '/', 'http://localhost').pathname;
155 } catch {
156 socket.destroy();
157 return;
158 }
159 // Not ours (vite's HMR socket, say) — leave it for whoever else listens.
160 if (pathname !== path) return;
161 wss.handleUpgrade(req, socket, head, (ws) => handle(ws));
162 });
163
164 // Dead-connection sweep: ws's usual isAlive/ping dance. A phone that fell
165 // off the wifi closes nothing; this is what finally vacates its seat.
166 const alive = new WeakMap<WebSocket, boolean>();
167 const sweep = setInterval(() => {
168 for (const ws of wss.clients) {
169 if (alive.get(ws) === false) {
170 ws.terminate();
171 continue;
172 }
173 alive.set(ws, false);
174 ws.ping();
175 }
176 }, PING_MS);
177 server.on('close', () => {
178 clearInterval(sweep);
179 wss.close();
180 tunnel?.kill();
181 });
182
183 const send = (ws: WebSocket, msg: unknown) => {
184 if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg));
185 };
186 const broadcast = (room: RoomRec, msg: unknown, except?: string) => {
187 for (const m of room.members.values()) if (m.id !== except) send(m.ws, msg);
188 };
189 const hostOf = (room: RoomRec) => room.order[0] ?? '';
190 const roster = (room: RoomRec) =>
191 room.order.map((id) => {
192 const m = room.members.get(id)!;
193 return { id: m.id, name: m.name };
194 });
195
196 function handle(ws: WebSocket) {
197 let room: RoomRec | null = null;
198 let me: Member | null = null;
199 alive.set(ws, true);
200 ws.on('pong', () => alive.set(ws, true));
201 ws.on('error', () => {});
202
203 // Messages are handled strictly in arrival order even though hello is
204 // async (it may wait on the tunnel's public link) — a chain, not a race.
205 let chain = Promise.resolve();
206 ws.on('message', (data) => {
207 chain = chain.then(() => onMessage(data)).catch(() => {});
208 });
209 const onMessage = async (data: unknown) => {
210 let msg: ClientMsg;
211 try {
212 msg = JSON.parse(String(data));
213 } catch {
214 return send(ws, {
215 t: 'err',
216 code: 'BAD_MESSAGE',
217 message: 'not JSON',
218 hint: 'every frame is one JSON object',
219 });
220 }
221
222 if (msg.t === 'hello') {
223 if (room) return; // one hello per connection
224 if (typeof msg.id !== 'string' || !msg.id) {
225 return send(ws, {
226 t: 'err',
227 code: 'BAD_MESSAGE',
228 message: 'hello without an id',
229 hint: 'send { t: "hello", id: <your uuid>, room?: <code> }',
230 });
231 }
232 // Join by code, creating the room if it does not exist — a code
233 // nobody knows is a room nobody made, and recreating on join is what
234 // lets a table pick itself back up after the server restarts.
235 const code =
236 typeof msg.room === 'string' && msg.room
237 ? msg.room.toUpperCase()
238 : newCode((c) => rooms.has(c));
239 let r = rooms.get(code);
240 if (!r) {
241 r = { code, members: new Map(), order: [], state: {}, guests: 0 };
242 rooms.set(code, r);
243 }
244 room = r;
245
246 const existing = r.members.get(msg.id);
247 if (existing) {
248 // The same device again — a reconnect, or a zombie socket it gave
249 // up on. The new socket takes over; the old one's close handler
250 // sees it no longer speaks for the member and stays quiet.
251 const old = existing.ws;
252 existing.ws = ws;
253 me = existing;
254 if (old !== ws && old.readyState === old.OPEN) old.close(4001, 'replaced');
255 } else {
256 const name =
257 typeof msg.name === 'string' && msg.name ? msg.name : `Guest ${++r.guests}`;
258 me = { id: msg.id, name, ws };
259 r.members.set(me.id, me);
260 r.order.push(me.id);
261 broadcast(r, { t: 'join', player: { id: me.id, name: me.name } }, me.id);
262 }
263 return send(ws, {
264 t: 'joined',
265 code: r.code,
266 self: { id: me.id, name: me.name },
267 hostId: hostOf(r),
268 players: roster(r),
269 state: r.state,
270 link: await publicBase(),
271 });
272 }
273
274 if (!room || !me) {
275 return send(ws, {
276 t: 'err',
277 code: 'NOT_JOINED',
278 message: 'say hello first',
279 hint: 'the first frame on a connection is { t: "hello", ... }',
280 });
281 }
282
283 switch (msg.t) {
284 case 'set': {
285 if (me.id !== hostOf(room)) {
286 return send(ws, {
287 t: 'err',
288 code: 'NOT_HOST',
289 message: 'only the host writes room state',
290 hint: 'send an event and let the host do the writing',
291 });
292 }
293 if (!msg.patch || typeof msg.patch !== 'object') return;
294 Object.assign(room.state, msg.patch);
295 broadcast(room, { t: 'set', patch: msg.patch }, me.id);
296 return;
297 }
298 case 'ev': {
299 if (typeof msg.type !== 'string') return;
300 broadcast(room, { t: 'ev', type: msg.type, payload: msg.payload, from: me.id }, me.id);
301 return;
302 }
303 }
304 };
305
306 ws.on('close', () => {
307 if (!room || !me) return;
308 // A replaced socket no longer speaks for the member.
309 if (room.members.get(me.id)?.ws !== ws) return;
310 const wasHost = hostOf(room) === me.id;
311 room.members.delete(me.id);
312 room.order = room.order.filter((id) => id !== me!.id);
313 if (room.members.size === 0) {
314 rooms.delete(room.code);
315 return;
316 }
317 broadcast(room, { t: 'leave', id: me.id });
318 if (wasHost) broadcast(room, { t: 'host', id: hostOf(room) });
319 });
320 }
321}