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}
65
66export function attachRooms(server: UpgradeServer, path = '/ws'): void {
67 const rooms = new Map<string, RoomRec>();
68 const wss = new WebSocketServer({ noServer: true, maxPayload: 1 << 20 });
69
70 server.on('upgrade', (req, socket, head) => {
71 let pathname: string;
72 try {
73 pathname = new URL(req.url ?? '/', 'http://localhost').pathname;
74 } catch {
75 socket.destroy();
76 return;
77 }
78 // Not ours (vite's HMR socket, say) — leave it for whoever else listens.
79 if (pathname !== path) return;
80 wss.handleUpgrade(req, socket, head, (ws) => handle(ws));
81 });
82
83 // Dead-connection sweep: ws's usual isAlive/ping dance. A phone that fell
84 // off the wifi closes nothing; this is what finally vacates its seat.
85 const alive = new WeakMap<WebSocket, boolean>();
86 const sweep = setInterval(() => {
87 for (const ws of wss.clients) {
88 if (alive.get(ws) === false) {
89 ws.terminate();
90 continue;
91 }
92 alive.set(ws, false);
93 ws.ping();
94 }
95 }, PING_MS);
96 server.on('close', () => {
97 clearInterval(sweep);
98 wss.close();
99 });
100
101 const send = (ws: WebSocket, msg: unknown) => {
102 if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg));
103 };
104 const broadcast = (room: RoomRec, msg: unknown, except?: string) => {
105 for (const m of room.members.values()) if (m.id !== except) send(m.ws, msg);
106 };
107 const hostOf = (room: RoomRec) => room.order[0] ?? '';
108 const roster = (room: RoomRec) =>
109 room.order.map((id) => {
110 const m = room.members.get(id)!;
111 return { id: m.id, name: m.name };
112 });
113
114 function handle(ws: WebSocket) {
115 let room: RoomRec | null = null;
116 let me: Member | null = null;
117 alive.set(ws, true);
118 ws.on('pong', () => alive.set(ws, true));
119 ws.on('error', () => {});
120
121 ws.on('message', (data) => {
122 let msg: ClientMsg;
123 try {
124 msg = JSON.parse(String(data));
125 } catch {
126 return send(ws, {
127 t: 'err',
128 code: 'BAD_MESSAGE',
129 message: 'not JSON',
130 hint: 'every frame is one JSON object',
131 });
132 }
133
134 if (msg.t === 'hello') {
135 if (room) return; // one hello per connection
136 if (typeof msg.id !== 'string' || !msg.id) {
137 return send(ws, {
138 t: 'err',
139 code: 'BAD_MESSAGE',
140 message: 'hello without an id',
141 hint: 'send { t: "hello", id: <your uuid>, room?: <code> }',
142 });
143 }
144 // Join by code, creating the room if it does not exist — a code
145 // nobody knows is a room nobody made, and recreating on join is what
146 // lets a table pick itself back up after the server restarts.
147 const code =
148 typeof msg.room === 'string' && msg.room
149 ? msg.room.toUpperCase()
150 : newCode((c) => rooms.has(c));
151 let r = rooms.get(code);
152 if (!r) {
153 r = { code, members: new Map(), order: [], state: {}, guests: 0 };
154 rooms.set(code, r);
155 }
156 room = r;
157
158 const existing = r.members.get(msg.id);
159 if (existing) {
160 // The same device again — a reconnect, or a zombie socket it gave
161 // up on. The new socket takes over; the old one's close handler
162 // sees it no longer speaks for the member and stays quiet.
163 const old = existing.ws;
164 existing.ws = ws;
165 me = existing;
166 if (old !== ws && old.readyState === old.OPEN) old.close(4001, 'replaced');
167 } else {
168 const name =
169 typeof msg.name === 'string' && msg.name ? msg.name : `Guest ${++r.guests}`;
170 me = { id: msg.id, name, ws };
171 r.members.set(me.id, me);
172 r.order.push(me.id);
173 broadcast(r, { t: 'join', player: { id: me.id, name: me.name } }, me.id);
174 }
175 return send(ws, {
176 t: 'joined',
177 code: r.code,
178 self: { id: me.id, name: me.name },
179 hostId: hostOf(r),
180 players: roster(r),
181 state: r.state,
182 });
183 }
184
185 if (!room || !me) {
186 return send(ws, {
187 t: 'err',
188 code: 'NOT_JOINED',
189 message: 'say hello first',
190 hint: 'the first frame on a connection is { t: "hello", ... }',
191 });
192 }
193
194 switch (msg.t) {
195 case 'set': {
196 if (me.id !== hostOf(room)) {
197 return send(ws, {
198 t: 'err',
199 code: 'NOT_HOST',
200 message: 'only the host writes room state',
201 hint: 'send an event and let the host do the writing',
202 });
203 }
204 if (!msg.patch || typeof msg.patch !== 'object') return;
205 Object.assign(room.state, msg.patch);
206 broadcast(room, { t: 'set', patch: msg.patch }, me.id);
207 return;
208 }
209 case 'ev': {
210 if (typeof msg.type !== 'string') return;
211 broadcast(room, { t: 'ev', type: msg.type, payload: msg.payload, from: me.id }, me.id);
212 return;
213 }
214 }
215 });
216
217 ws.on('close', () => {
218 if (!room || !me) return;
219 // A replaced socket no longer speaks for the member.
220 if (room.members.get(me.id)?.ws !== ws) return;
221 const wasHost = hostOf(room) === me.id;
222 room.members.delete(me.id);
223 room.order = room.order.filter((id) => id !== me!.id);
224 if (room.members.size === 0) {
225 rooms.delete(room.code);
226 return;
227 }
228 broadcast(room, { t: 'leave', id: me.id });
229 if (wasHost) broadcast(room, { t: 'host', id: hostOf(room) });
230 });
231 }
232}