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 *
78 * We always ask for the same subdomain (`TUNNEL_NAME`, default `mahjong-table`), so
79 * a restart lands back on the URL people already have and we spend one name
80 * rather than a fresh one per run. If that name is taken — a second copy of
81 * the server, a tunnel the last run left behind — the first attempt dies
82 * without ever printing a URL, and the retry goes out nameless.
83 */
84const TUNNEL_RETRY = 10_000;
85/** How long a join will wait on a tunnel still shaking hands. */
86const TUNNEL_WAIT = 3_000;
87/** The subdomain we ask for: {name}.vibe.richardscollin.com. */
88const TUNNEL_NAME = process.env.TUNNEL_NAME ?? 'mahjong-table';
89
90export function attachRooms(server: UpgradeServer, path = '/ws'): void {
91 const rooms = new Map<string, RoomRec>();
92 const wss = new WebSocketServer({ noServer: true, maxPayload: 1 << 20 });
93
94 const ownPort = (): number | null => {
95 const addr = server.address?.();
96 return addr && typeof addr === 'object' ? ((addr as { port?: number }).port ?? null) : null;
97 };
98
99 let tunnel: ChildProcess | null = null;
100 let tunnelUrl = '';
101 let tunnelDiedAt = 0;
102 /** Cleared once a named attempt dies URL-less: the name is someone else's. */
103 let tunnelName = TUNNEL_NAME;
104 let waiters: Array<() => void> = [];
105 const wake = () => {
106 for (const w of waiters) w();
107 waiters = [];
108 };
109
110 const ensureTunnel = (): void => {
111 if (process.env.PUBLIC_URL || tunnel || Date.now() - tunnelDiedAt < TUNNEL_RETRY) return;
112 const port = ownPort();
113 if (port === null) return;
114 const named = tunnelName;
115 const child = spawn(
116 process.env.RSGROK_BIN ?? 'rsgrok',
117 // No :4040 inspection API — another agent may already own that port.
118 ['http', `127.0.0.1:${port}`, '--web-addr', 'false', ...(named ? ['-n', named] : [])],
119 { stdio: ['ignore', 'pipe', 'inherit'] },
120 );
121 tunnel = child;
122 let out = '';
123 child.stdout!.on('data', (chunk: Buffer) => {
124 out += chunk.toString();
125 const up = /^rsgrok: (https:\/\/\S+) ->/m.exec(out);
126 if (up && up[1] !== tunnelUrl) {
127 tunnelUrl = up[1];
128 // The tunnel-up line lands in our pipe, not the terminal — re-say it.
129 console.log(`public link: ${tunnelUrl}`);
130 wake();
131 }
132 });
133 const gone = () => {
134 if (tunnel === child) {
135 // Died before it ever said a URL, and we had asked for a name: read
136 // that as the name being spoken for, and go nameless from here.
137 if (!tunnelUrl && named) tunnelName = '';
138 tunnel = null;
139 tunnelUrl = '';
140 tunnelDiedAt = Date.now();
141 }
142 wake();
143 };
144 child.on('error', gone); // rsgrok not installed, say
145 child.on('exit', gone);
146 };
147
148 // Open the tunnel as soon as there is a port to aim it at, not on the
149 // first join — by the time anyone scans a QR the link should exist.
150 if (ownPort() !== null) ensureTunnel();
151 else server.on('listening', ensureTunnel);
152
153 const publicBase = async (): Promise<string> => {
154 if (process.env.PUBLIC_URL) return process.env.PUBLIC_URL;
155 ensureTunnel();
156 if (!tunnelUrl && tunnel) {
157 await new Promise<void>((resolve) => {
158 waiters.push(resolve);
159 setTimeout(resolve, TUNNEL_WAIT).unref();
160 });
161 }
162 return tunnelUrl;
163 };
164
165 server.on('upgrade', (req, socket, head) => {
166 let pathname: string;
167 try {
168 pathname = new URL(req.url ?? '/', 'http://localhost').pathname;
169 } catch {
170 socket.destroy();
171 return;
172 }
173 // Not ours (vite's HMR socket, say) — leave it for whoever else listens.
174 if (pathname !== path) return;
175 wss.handleUpgrade(req, socket, head, (ws) => handle(ws));
176 });
177
178 // Dead-connection sweep: ws's usual isAlive/ping dance. A phone that fell
179 // off the wifi closes nothing; this is what finally vacates its seat.
180 const alive = new WeakMap<WebSocket, boolean>();
181 const sweep = setInterval(() => {
182 for (const ws of wss.clients) {
183 if (alive.get(ws) === false) {
184 ws.terminate();
185 continue;
186 }
187 alive.set(ws, false);
188 ws.ping();
189 }
190 }, PING_MS);
191 server.on('close', () => {
192 clearInterval(sweep);
193 wss.close();
194 tunnel?.kill();
195 });
196
197 const send = (ws: WebSocket, msg: unknown) => {
198 if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg));
199 };
200 const broadcast = (room: RoomRec, msg: unknown, except?: string) => {
201 for (const m of room.members.values()) if (m.id !== except) send(m.ws, msg);
202 };
203 const hostOf = (room: RoomRec) => room.order[0] ?? '';
204 const roster = (room: RoomRec) =>
205 room.order.map((id) => {
206 const m = room.members.get(id)!;
207 return { id: m.id, name: m.name };
208 });
209
210 function handle(ws: WebSocket) {
211 let room: RoomRec | null = null;
212 let me: Member | null = null;
213 alive.set(ws, true);
214 ws.on('pong', () => alive.set(ws, true));
215 ws.on('error', () => {});
216
217 // Messages are handled strictly in arrival order even though hello is
218 // async (it may wait on the tunnel's public link) — a chain, not a race.
219 let chain = Promise.resolve();
220 ws.on('message', (data) => {
221 chain = chain.then(() => onMessage(data)).catch(() => {});
222 });
223 const onMessage = async (data: unknown) => {
224 let msg: ClientMsg;
225 try {
226 msg = JSON.parse(String(data));
227 } catch {
228 return send(ws, {
229 t: 'err',
230 code: 'BAD_MESSAGE',
231 message: 'not JSON',
232 hint: 'every frame is one JSON object',
233 });
234 }
235
236 if (msg.t === 'hello') {
237 if (room) return; // one hello per connection
238 if (typeof msg.id !== 'string' || !msg.id) {
239 return send(ws, {
240 t: 'err',
241 code: 'BAD_MESSAGE',
242 message: 'hello without an id',
243 hint: 'send { t: "hello", id: <your uuid>, room?: <code> }',
244 });
245 }
246 // Join by code, creating the room if it does not exist — a code
247 // nobody knows is a room nobody made, and recreating on join is what
248 // lets a table pick itself back up after the server restarts.
249 const code =
250 typeof msg.room === 'string' && msg.room
251 ? msg.room.toUpperCase()
252 : newCode((c) => rooms.has(c));
253 let r = rooms.get(code);
254 if (!r) {
255 r = { code, members: new Map(), order: [], state: {}, guests: 0 };
256 rooms.set(code, r);
257 }
258 room = r;
259
260 const existing = r.members.get(msg.id);
261 if (existing) {
262 // The same device again — a reconnect, or a zombie socket it gave
263 // up on. The new socket takes over; the old one's close handler
264 // sees it no longer speaks for the member and stays quiet.
265 const old = existing.ws;
266 existing.ws = ws;
267 me = existing;
268 if (old !== ws && old.readyState === old.OPEN) old.close(4001, 'replaced');
269 } else {
270 const name =
271 typeof msg.name === 'string' && msg.name ? msg.name : `Guest ${++r.guests}`;
272 me = { id: msg.id, name, ws };
273 r.members.set(me.id, me);
274 r.order.push(me.id);
275 broadcast(r, { t: 'join', player: { id: me.id, name: me.name } }, me.id);
276 }
277 return send(ws, {
278 t: 'joined',
279 code: r.code,
280 self: { id: me.id, name: me.name },
281 hostId: hostOf(r),
282 players: roster(r),
283 state: r.state,
284 link: await publicBase(),
285 });
286 }
287
288 if (!room || !me) {
289 return send(ws, {
290 t: 'err',
291 code: 'NOT_JOINED',
292 message: 'say hello first',
293 hint: 'the first frame on a connection is { t: "hello", ... }',
294 });
295 }
296
297 switch (msg.t) {
298 case 'set': {
299 if (me.id !== hostOf(room)) {
300 return send(ws, {
301 t: 'err',
302 code: 'NOT_HOST',
303 message: 'only the host writes room state',
304 hint: 'send an event and let the host do the writing',
305 });
306 }
307 if (!msg.patch || typeof msg.patch !== 'object') return;
308 Object.assign(room.state, msg.patch);
309 broadcast(room, { t: 'set', patch: msg.patch }, me.id);
310 return;
311 }
312 case 'ev': {
313 if (typeof msg.type !== 'string') return;
314 broadcast(room, { t: 'ev', type: msg.type, payload: msg.payload, from: me.id }, me.id);
315 return;
316 }
317 }
318 };
319
320 ws.on('close', () => {
321 if (!room || !me) return;
322 // A replaced socket no longer speaks for the member.
323 if (room.members.get(me.id)?.ws !== ws) return;
324 const wasHost = hostOf(room) === me.id;
325 room.members.delete(me.id);
326 room.order = room.order.filter((id) => id !== me!.id);
327 if (room.members.size === 0) {
328 rooms.delete(room.code);
329 return;
330 }
331 broadcast(room, { t: 'leave', id: me.id });
332 if (wasHost) broadcast(room, { t: 'host', id: hostOf(room) });
333 });
334 }
335}