anvilsign in

collin/mahjong

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