// The front door for duels between people. Plain HTTP for actions, a // websocket only to say "something changed, fetch the view again". // // POST /api/rooms {name, size?} create a duel and take seat A // POST /api/rooms/:id/join {name} take the next seat // POST /api/rooms/:id/bot {token} seat a bot (any seated player may) // GET /api/rooms/:id?token= the view for that seat // POST /api/rooms/:id/turn {token, input} this seat's move // WS /ws?room=:id&token= {type:"update", seq} whenever the room changes // // Caddy serves the static site and proxies /api and /ws here. import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; import { WebSocketServer, WebSocket } from 'ws'; import { RoomError, Rooms } from './rooms'; import { Store } from './store'; const PORT = Number(process.env.PORT ?? 8788); const HOST = process.env.HOST ?? '127.0.0.1'; const DATA_DIR = process.env.WH_DATA_DIR ?? './data/rooms'; const BODY_LIMIT = 16 * 1024; const rooms = new Rooms(new Store(DATA_DIR)); function send(res: ServerResponse, status: number, body: unknown): void { const json = JSON.stringify(body); res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }); res.end(json); } function readBody(req: IncomingMessage): Promise> { return new Promise((resolve, reject) => { let size = 0; const chunks: Buffer[] = []; req.on('data', (chunk: Buffer) => { size += chunk.length; if (size > BODY_LIMIT) { reject(new RoomError('That is more than a turn needs.', 413)); req.destroy(); return; } chunks.push(chunk); }); req.on('end', () => { if (chunks.length === 0) return resolve({}); try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); } catch { reject(new RoomError('The request was not JSON.')); } }); req.on('error', reject); }); } async function handle(req: IncomingMessage, res: ServerResponse): Promise { const url = new URL(req.url ?? '/', 'http://localhost'); const parts = url.pathname.split('/').filter(Boolean); if (parts[0] !== 'api' || parts[1] !== 'rooms') throw new RoomError('Not here.', 404); if (parts.length === 2 && req.method === 'POST') { const body = await readBody(req); const { room, seat } = rooms.create(String(body.name ?? ''), Number(body.size ?? 2)); return send(res, 201, { seat: seat.id, token: seat.token, view: rooms.view(room, seat.id) }); } const room = rooms.get(parts[2] ?? ''); const action = parts[3]; if (!action && req.method === 'GET') { const seat = rooms.seatOf(room, url.searchParams.get('token') ?? undefined); return send(res, 200, rooms.view(room, seat.id)); } if (req.method !== 'POST') throw new RoomError('Not here.', 404); const body = await readBody(req); if (action === 'join') { const seat = rooms.join(room, String(body.name ?? '')); return send(res, 200, { seat: seat.id, token: seat.token, view: rooms.view(room, seat.id) }); } if (action === 'bot') { rooms.addBot(room, typeof body.token === 'string' ? body.token : undefined); const seat = rooms.seatOf(room, body.token as string); return send(res, 200, rooms.view(room, seat.id)); } if (action === 'turn') { const token = typeof body.token === 'string' ? body.token : undefined; rooms.submit(room, token, body.input); const seat = rooms.seatOf(room, token); return send(res, 200, rooms.view(room, seat.id)); } throw new RoomError('Not here.', 404); } const server = createServer((req, res) => { handle(req, res).catch((err: unknown) => { if (err instanceof RoomError) return send(res, err.status, { error: err.message }); console.error(err); send(res, 500, { error: 'Something went wrong on the server.' }); }); }); const wss = new WebSocketServer({ noServer: true }); server.on('upgrade', (req, socket, head) => { const url = new URL(req.url ?? '/', 'http://localhost'); if (url.pathname !== '/ws') return socket.destroy(); let seatId: string; let roomId: string; try { roomId = url.searchParams.get('room') ?? ''; seatId = rooms.seatOf(rooms.get(roomId), url.searchParams.get('token') ?? undefined).id; } catch { return socket.destroy(); } wss.handleUpgrade(req, socket, head, (ws: WebSocket) => { const unsubscribe = rooms.subscribe(roomId, (room) => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'update', seq: room.seq })); }); ws.on('close', unsubscribe); ws.send(JSON.stringify({ type: 'hello', seat: seatId })); }); }); server.listen(PORT, HOST, () => { console.log(`waving-hands server listening on http://${HOST}:${PORT}, rooms in ${DATA_DIR}`); });