anvilsign in

collin/mahjong

1import { spawn, type ChildProcess } from 'node:child_process';
2import { existsSync } from 'node:fs';
3import type { IncomingMessage } from 'node:http';
4import { networkInterfaces } from 'node:os';
5import type { Duplex } from 'node:stream';
6import { WebSocketServer, type WebSocket } from 'ws';
7
8/**
9 * The room relay: everything the game needs from a server, which is very
10 * little. Rooms hold members in join order; the earliest one still connected
11 * is the host. The relay never reads the game — it keeps one bag of shared
12 * state that only the host may write, fans out patches and events to everyone
13 * else, and announces joins, leaves, and host changes. All the mahjong lives
14 * in the clients (src/net/), exactly as it did on antics.
15 *
16 * Attachable to any http.Server — vite's dev server in development
17 * (vite.config.ts), our own static server in production (server/index.ts) —
18 * so the client can always reach it at wss://<same-origin>/ws.
19 *
20 * Identity is client-chosen: a device sends its own id (a UUID it keeps in
21 * sessionStorage) and the relay takes it at its word. That is what lets a
22 * reload or a wifi blip land back in the same seat — the seat map in shared
23 * state is keyed on these ids. No auth; this is a home server for a table of
24 * friends, and fairness at this table is social anyway (see README).
25 */
26
27interface Member {
28 id: string;
29 name: string;
30 ws: WebSocket;
31}
32
33interface RoomRec {
34 code: string;
35 members: Map<string, Member>;
36 /** Join order. The first id still present is the host. */
37 order: string[];
38 state: Record<string, unknown>;
39 /** How many "Guest N" names this room has handed out. */
40 guests: number;
41}
42
43/** What a client may say. `hello` must come first; the rest need a room. */
44type ClientMsg =
45 | { t: 'hello'; id?: string; room?: string; name?: string }
46 | { t: 'set'; patch?: Record<string, unknown> }
47 | { t: 'ev'; type?: string; payload?: unknown };
48
49/** No 0/O/1/I/L: these codes get read aloud across a table. */
50const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
51const PING_MS = 30_000;
52
53function newCode(taken: (code: string) => boolean): string {
54 for (;;) {
55 let code = '';
56 for (let i = 0; i < 6; i++)
57 code += CODE_ALPHABET[Math.floor(Math.random() * CODE_ALPHABET.length)];
58 if (!taken(code)) return code;
59 }
60}
61
62/** Just the few events we hang off a server — http and http2 servers both
63 * fit, which is what lets the relay ride vite's as easily as our own. */
64export interface UpgradeServer {
65 on(event: 'upgrade', cb: (req: IncomingMessage, socket: Duplex, head: Buffer) => void): unknown;
66 on(event: 'listening', cb: () => void): unknown;
67 on(event: 'close', cb: () => void): unknown;
68 /** node's Server.address() when there is one — how we learn our own port. */
69 address?(): unknown;
70}
71
72/**
73 * The public face of this server: a QR pointing at localhost is a QR only the
74 * host's own machine can scan, which is the one machine that does not need it.
75 * Three answers, in order of how far they carry:
76 *
77 * 1. `PUBLIC_URL` names a face outright.
78 * 2. Our own rsgrok tunnel (`RSGROK_BIN` overrides the binary), opened the
79 * moment the server knows its port; we read the https URL off the tunnel-up
80 * line and hand it to every client on join. A tunnel that dies — network
81 * gone, rsgrok missing — is retried on later joins, at most once per window.
82 * 3. This machine's address on the local network. It only reaches phones on the
83 * same wifi, which is exactly who is in the room when four people are sat
84 * round the laptop — and it needs nothing installed, nothing reachable, and
85 * no name. It is what the QR falls back to rather than falling back to being
86 * unscannable.
87 *
88 * We always ask for the same subdomain (`TUNNEL_NAME`, default `mahjong-table`), so
89 * a restart lands back on the URL people already have and we spend one name
90 * rather than a fresh one per run. If that name is taken — a second copy of
91 * the server, a tunnel the last run left behind — the first attempt dies
92 * without ever printing a URL, and the retry goes out nameless.
93 */
94const TUNNEL_RETRY = 10_000;
95/**
96 * How long a join will wait on a tunnel still shaking hands — and how long it
97 * waits when there is a local-network address to fall back on instead.
98 *
99 * They differ because what is being risked differs. With nothing else to offer,
100 * a wait is the difference between a scannable QR and none, and three seconds
101 * is worth it. With the wifi address already in hand the wait only buys a nicer
102 * link, and the cost is the tile taking that long to appear on the felt — so it
103 * is given a moment rather than a pause. A tunnel that comes up later is used
104 * by every join after it.
105 */
106const TUNNEL_WAIT = 3_000;
107const TUNNEL_GLANCE = 600;
108/** The subdomain we ask for: {name}.vibe.richardscollin.com. */
109const TUNNEL_NAME = process.env.TUNNEL_NAME ?? 'mahjong-table';
110
111/**
112 * This machine's address on the local network.
113 *
114 * A laptop has several — a docker bridge, a VPN, a virtual switch — and only
115 * one of them is the wifi the phones are on, so they are ranked by how likely
116 * they are to be it: the home-router range first, then the other two private
117 * ranges, then anything else that is not loopback. Link-local (169.254) is
118 * what an interface says when it has no network at all, so it never counts.
119 *
120 * Inside a container every one of them is a bridge address on a network that
121 * exists only on the host, so there is no answer and it says so. A deployed
122 * game gets its face from `PUBLIC_URL` — see compose.yaml — and a deploy that
123 * forgot to set one shows no QR at all, which is the truth rather than a code
124 * that leads nowhere.
125 */
126function lanAddress(): string {
127 if (existsSync('/.dockerenv')) return '';
128 const rank = (ip: string): number => {
129 if (ip.startsWith('192.168.')) return 0;
130 if (ip.startsWith('10.')) return 1;
131 if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) return 2;
132 return 3;
133 };
134 let best = '';
135 let bestRank = 9;
136 for (const list of Object.values(networkInterfaces())) {
137 for (const net of list ?? []) {
138 // node 18 reports the family as a number; later ones as a string.
139 const v4 = net.family === 'IPv4' || (net.family as unknown as number) === 4;
140 if (!v4 || net.internal || net.address.startsWith('169.254.')) continue;
141 const r = rank(net.address);
142 if (r < bestRank) {
143 bestRank = r;
144 best = net.address;
145 }
146 }
147 }
148 return best;
149}
150
151export function attachRooms(server: UpgradeServer, path = '/ws'): void {
152 const rooms = new Map<string, RoomRec>();
153 const wss = new WebSocketServer({ noServer: true, maxPayload: 1 << 20 });
154
155 const ownPort = (): number | null => {
156 const addr = server.address?.();
157 return addr && typeof addr === 'object' ? ((addr as { port?: number }).port ?? null) : null;
158 };
159
160 /**
161 * Whether this server is listening on every interface, rather than just
162 * loopback. Bound to 127.0.0.1 it cannot be reached from the wifi however
163 * many addresses this machine has, so there is no local link to give out and
164 * saying otherwise would hand every phone a QR that goes nowhere.
165 */
166 const onEveryInterface = (): boolean => {
167 const addr = server.address?.();
168 const host = addr && typeof addr === 'object' ? (addr as { address?: string }).address : '';
169 return host === '0.0.0.0' || host === '::';
170 };
171
172 /** Worked out once: the interfaces do not move while the server is up. */
173 let lan: string | null = null;
174 const lanBase = (): string => {
175 if (!onEveryInterface()) return '';
176 lan ??= lanAddress();
177 const port = ownPort();
178 if (!lan || port === null) return '';
179 return `http://${lan}:${port}`;
180 };
181
182 let tunnel: ChildProcess | null = null;
183 let tunnelUrl = '';
184 let tunnelDiedAt = 0;
185 /** Cleared once a named attempt dies URL-less: the name is someone else's. */
186 let tunnelName = TUNNEL_NAME;
187 let waiters: Array<() => void> = [];
188 const wake = () => {
189 for (const w of waiters) w();
190 waiters = [];
191 };
192
193 const ensureTunnel = (): void => {
194 if (process.env.PUBLIC_URL || tunnel || Date.now() - tunnelDiedAt < TUNNEL_RETRY) return;
195 const port = ownPort();
196 if (port === null) return;
197 const named = tunnelName;
198 const child = spawn(
199 process.env.RSGROK_BIN ?? 'rsgrok',
200 // No :4040 inspection API — another agent may already own that port.
201 ['http', `127.0.0.1:${port}`, '--web-addr', 'false', ...(named ? ['-n', named] : [])],
202 { stdio: ['ignore', 'pipe', 'inherit'] },
203 );
204 tunnel = child;
205 let out = '';
206 child.stdout!.on('data', (chunk: Buffer) => {
207 out += chunk.toString();
208 const up = /^rsgrok: (https:\/\/\S+) ->/m.exec(out);
209 if (up && up[1] !== tunnelUrl) {
210 tunnelUrl = up[1];
211 // The tunnel-up line lands in our pipe, not the terminal — re-say it.
212 console.log(`public link: ${tunnelUrl}`);
213 wake();
214 }
215 });
216 const gone = () => {
217 if (tunnel === child) {
218 // Died before it ever said a URL, and we had asked for a name: read
219 // that as the name being spoken for, and go nameless from here.
220 if (!tunnelUrl && named) tunnelName = '';
221 tunnel = null;
222 tunnelUrl = '';
223 tunnelDiedAt = Date.now();
224 }
225 wake();
226 };
227 child.on('error', gone); // rsgrok not installed, say
228 child.on('exit', gone);
229 };
230
231 // Open the tunnel as soon as there is a port to aim it at, not on the
232 // first join — by the time anyone scans a QR the link should exist.
233 const announce = () => {
234 ensureTunnel();
235 const base = lanBase();
236 if (base) console.log(`on this network: ${base}`);
237 };
238 if (ownPort() !== null) announce();
239 else server.on('listening', announce);
240
241 const publicBase = async (): Promise<string> => {
242 if (process.env.PUBLIC_URL) return process.env.PUBLIC_URL;
243 ensureTunnel();
244 if (!tunnelUrl && tunnel) {
245 const grace = lanBase() ? TUNNEL_GLANCE : TUNNEL_WAIT;
246 await new Promise<void>((resolve) => {
247 waiters.push(resolve);
248 setTimeout(resolve, grace).unref();
249 });
250 }
251 // The tunnel carries further and carries https, so it wins where it is up.
252 // Where it is not, the wifi everybody is already on is a real answer.
253 return tunnelUrl || lanBase();
254 };
255
256 server.on('upgrade', (req, socket, head) => {
257 let pathname: string;
258 try {
259 pathname = new URL(req.url ?? '/', 'http://localhost').pathname;
260 } catch {
261 socket.destroy();
262 return;
263 }
264 // Not ours (vite's HMR socket, say) — leave it for whoever else listens.
265 if (pathname !== path) return;
266 wss.handleUpgrade(req, socket, head, (ws) => handle(ws));
267 });
268
269 // Dead-connection sweep: ws's usual isAlive/ping dance. A phone that fell
270 // off the wifi closes nothing; this is what finally vacates its seat.
271 const alive = new WeakMap<WebSocket, boolean>();
272 const sweep = setInterval(() => {
273 for (const ws of wss.clients) {
274 if (alive.get(ws) === false) {
275 ws.terminate();
276 continue;
277 }
278 alive.set(ws, false);
279 ws.ping();
280 }
281 }, PING_MS);
282 server.on('close', () => {
283 clearInterval(sweep);
284 wss.close();
285 tunnel?.kill();
286 });
287
288 const send = (ws: WebSocket, msg: unknown) => {
289 if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg));
290 };
291 const broadcast = (room: RoomRec, msg: unknown, except?: string) => {
292 for (const m of room.members.values()) if (m.id !== except) send(m.ws, msg);
293 };
294 const hostOf = (room: RoomRec) => room.order[0] ?? '';
295 const roster = (room: RoomRec) =>
296 room.order.map((id) => {
297 const m = room.members.get(id)!;
298 return { id: m.id, name: m.name };
299 });
300
301 function handle(ws: WebSocket) {
302 let room: RoomRec | null = null;
303 let me: Member | null = null;
304 alive.set(ws, true);
305 ws.on('pong', () => alive.set(ws, true));
306 ws.on('error', () => {});
307
308 // Messages are handled strictly in arrival order even though hello is
309 // async (it may wait on the tunnel's public link) — a chain, not a race.
310 let chain = Promise.resolve();
311 ws.on('message', (data) => {
312 chain = chain.then(() => onMessage(data)).catch(() => {});
313 });
314 const onMessage = async (data: unknown) => {
315 let msg: ClientMsg;
316 try {
317 msg = JSON.parse(String(data));
318 } catch {
319 return send(ws, {
320 t: 'err',
321 code: 'BAD_MESSAGE',
322 message: 'not JSON',
323 hint: 'every frame is one JSON object',
324 });
325 }
326
327 if (msg.t === 'hello') {
328 if (room) return; // one hello per connection
329 if (typeof msg.id !== 'string' || !msg.id) {
330 return send(ws, {
331 t: 'err',
332 code: 'BAD_MESSAGE',
333 message: 'hello without an id',
334 hint: 'send { t: "hello", id: <your uuid>, room?: <code> }',
335 });
336 }
337 // Join by code, creating the room if it does not exist — a code
338 // nobody knows is a room nobody made, and recreating on join is what
339 // lets a table pick itself back up after the server restarts.
340 const code =
341 typeof msg.room === 'string' && msg.room
342 ? msg.room.toUpperCase()
343 : newCode((c) => rooms.has(c));
344 let r = rooms.get(code);
345 if (!r) {
346 r = { code, members: new Map(), order: [], state: {}, guests: 0 };
347 rooms.set(code, r);
348 }
349 room = r;
350
351 const existing = r.members.get(msg.id);
352 if (existing) {
353 // The same device again — a reconnect, or a zombie socket it gave
354 // up on. The new socket takes over; the old one's close handler
355 // sees it no longer speaks for the member and stays quiet.
356 const old = existing.ws;
357 existing.ws = ws;
358 me = existing;
359 if (old !== ws && old.readyState === old.OPEN) old.close(4001, 'replaced');
360 } else {
361 const name =
362 typeof msg.name === 'string' && msg.name ? msg.name : `Guest ${++r.guests}`;
363 me = { id: msg.id, name, ws };
364 r.members.set(me.id, me);
365 r.order.push(me.id);
366 broadcast(r, { t: 'join', player: { id: me.id, name: me.name } }, me.id);
367 }
368 return send(ws, {
369 t: 'joined',
370 code: r.code,
371 self: { id: me.id, name: me.name },
372 hostId: hostOf(r),
373 players: roster(r),
374 state: r.state,
375 link: await publicBase(),
376 });
377 }
378
379 if (!room || !me) {
380 return send(ws, {
381 t: 'err',
382 code: 'NOT_JOINED',
383 message: 'say hello first',
384 hint: 'the first frame on a connection is { t: "hello", ... }',
385 });
386 }
387
388 switch (msg.t) {
389 case 'set': {
390 if (me.id !== hostOf(room)) {
391 return send(ws, {
392 t: 'err',
393 code: 'NOT_HOST',
394 message: 'only the host writes room state',
395 hint: 'send an event and let the host do the writing',
396 });
397 }
398 if (!msg.patch || typeof msg.patch !== 'object') return;
399 Object.assign(room.state, msg.patch);
400 broadcast(room, { t: 'set', patch: msg.patch }, me.id);
401 return;
402 }
403 case 'ev': {
404 if (typeof msg.type !== 'string') return;
405 broadcast(room, { t: 'ev', type: msg.type, payload: msg.payload, from: me.id }, me.id);
406 return;
407 }
408 }
409 };
410
411 ws.on('close', () => {
412 if (!room || !me) return;
413 // A replaced socket no longer speaks for the member.
414 if (room.members.get(me.id)?.ws !== ws) return;
415 const wasHost = hostOf(room) === me.id;
416 room.members.delete(me.id);
417 room.order = room.order.filter((id) => id !== me!.id);
418 if (room.members.size === 0) {
419 rooms.delete(room.code);
420 return;
421 }
422 broadcast(room, { t: 'leave', id: me.id });
423 if (wasHost) broadcast(room, { t: 'host', id: hostOf(room) });
424 });
425 }
426}