anvilsign in

collin/mahjong

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