// Websocket front door. Protocol (JSON messages): // client -> server: // {type:"create", name} create a room, become host // {type:"join", roomId, name} join (or rejoin) a room // {type:"start"} host starts the game // {type:"command", command} a game Command for the engine // server -> client: // {type:"welcome"} on connect // {type:"room", roomId, players, hostId, started} // {type:"events", events} redacted for this recipient // {type:"state", view} redacted full view (after every change) // {type:"error", message} import { createServer } from "node:http"; import { readFileSync, existsSync, realpathSync } from "node:fs"; import { extname, join, normalize, sep } from "node:path"; import { WebSocketServer, WebSocket } from "ws"; import type { Command, PlayerId } from "@wizwar/engine"; import { catchUpSteps, claimTransferCode, createRoom, engagementStats, pickColor, getRoom, joinRoom, loadPersistedRooms, makeTransferCode, redactFor, roomCount, runCommand, seatTokenValid, startGame, summarize, viewForPlayer, type Room, } from "./rooms"; // --- Abuse limits: this is a public server on a small box. ----------------- const MAX_SOCKETS = 300; // concurrent connections const MAX_ROOMS = 5000; // total rooms on the server const MAX_ROOMS_PER_CONN = 10; // rooms one connection may create const MAX_COMMAND_BYTES = 16384; // serialized game command const MAX_MYGAMES_SEATS = 50; // seats checked per myGames request const CATCHUP_COOLDOWN_MS = 3000; // full-game replays are CPU-heavy const NAME_MAX = 24; /** Player/room names: printable, trimmed, bounded. */ function cleanName(raw: unknown): string { // eslint-disable-next-line no-control-regex return String(raw ?? "").replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, NAME_MAX); } const port = Number(process.env.PORT ?? 8787); // In production a TLS proxy fronts us: bind loopback so the plaintext port // is not reachable from the internet. Dev default stays LAN-friendly. const host = process.env.HOST ?? "0.0.0.0"; loadPersistedRooms(); // One process serves both the built client and the websocket, so production // needs only a TLS proxy in front (or nothing, on a LAN). const STATIC_DIR = process.env.WIZWAR_STATIC_DIR ?? join(process.cwd(), "..", "web", "dist"); const MIME: Record = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".png": "image/png", ".svg": "image/svg+xml", ".ico": "image/x-icon", ".woff2": "font/woff2", ".json": "application/json", }; // The client build may be absent in development (vite serves it instead); // serve a 404 for static requests in that case rather than dying on boot. const staticRoot = existsSync(STATIC_DIR) ? realpathSync(normalize(STATIC_DIR)) : null; const httpServer = createServer((req, res) => { try { if (req.method !== "GET" && req.method !== "HEAD") { res.writeHead(405).end(); return; } res.setHeader("x-content-type-options", "nosniff"); if (!staticRoot) { res.writeHead(404).end("client not built"); return; } let url: string; try { url = decodeURIComponent((req.url ?? "/").split("?")[0]!); } catch { res.writeHead(400).end(); return; } if (url.includes("\0")) { res.writeHead(400).end(); return; } let file = normalize(join(staticRoot, url === "/" ? "index.html" : url)); if (file !== staticRoot && !file.startsWith(staticRoot + sep)) { res.writeHead(403).end(); return; } if (!existsSync(file)) file = join(staticRoot, "index.html"); // SPA fallback // Resolve symlinks and re-verify the real location stays inside the root. const real = realpathSync(file); if (real !== staticRoot && !real.startsWith(staticRoot + sep)) { res.writeHead(403).end(); return; } const body = readFileSync(real); res.writeHead(200, { "content-type": MIME[extname(real)] ?? "application/octet-stream" }); res.end(body); } catch { res.writeHead(500).end(); } }); const wss = new WebSocketServer({ server: httpServer, path: undefined, maxPayload: 64 * 1024 }); httpServer.listen(port, host); interface Session { socket: WebSocket; playerId: PlayerId | null; roomId: string | null; /** The raw seat token this connection authenticated with (memory only). */ token: string | null; claimFails: number; // Token bucket: refills at 15 msg/s up to a burst of 30. bucket: number; lastRefill: number; overLimitStrikes: number; roomsCreated: number; lastCatchUpAt: number; } function underRateLimit(s: Session): boolean { const now = Date.now(); s.bucket = Math.min(30, s.bucket + ((now - s.lastRefill) / 1000) * 15); s.lastRefill = now; if (s.bucket < 1) return false; s.bucket -= 1; return true; } const sessions = new Set(); function send(socket: WebSocket, message: unknown): void { if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message)); } function roomInfo(room: Room) { return { type: "room", roomId: room.id, players: room.players, hostId: room.hostId, started: room.state !== null, colors: Object.fromEntries(room.colorChoices), }; } function broadcast(room: Room, makeMessage: (playerId: PlayerId) => unknown): void { for (const s of sessions) { if (s.roomId === room.id && s.playerId) { send(s.socket, makeMessage(s.playerId)); } } } function broadcastRoomState(room: Room): void { broadcast(room, (playerId) => roomInfo(room)); if (room.state) { broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length })); } } wss.on("connection", (socket) => { if (sessions.size >= MAX_SOCKETS) { send(socket, { type: "error", message: "the tavern is packed — try again shortly" }); socket.close(); return; } const session: Session = { socket, playerId: null, roomId: null, token: null, claimFails: 0, bucket: 30, lastRefill: Date.now(), overLimitStrikes: 0, roomsCreated: 0, lastCatchUpAt: 0, }; sessions.add(session); send(socket, { type: "welcome", game: "wizwar" }); socket.on("close", () => sessions.delete(session)); socket.on("message", (data) => { if (!underRateLimit(session)) { if (++session.overLimitStrikes > 100) socket.close(); return send(socket, { type: "error", message: "slow down" }); } let msg: Record; try { msg = JSON.parse(data.toString()); } catch { return send(socket, { type: "error", message: "invalid JSON" }); } try { switch (msg.type) { case "create": { const name = cleanName(msg.name); if (!name) return send(socket, { type: "error", message: "name required" }); if (session.roomsCreated >= MAX_ROOMS_PER_CONN || roomCount() >= MAX_ROOMS) { return send(socket, { type: "error", message: "no new rooms right now — try again later" }); } session.roomsCreated++; const { room, token } = createRoom(name); session.playerId = name; session.roomId = room.id; session.token = token; send(socket, { type: "seat", playerId: name, token }); broadcastRoomState(room); break; } case "join": { const name = cleanName(msg.name); const roomId = String(msg.roomId ?? "").trim().slice(0, 8); if (!name || !roomId) return send(socket, { type: "error", message: "name and roomId required" }); const room = getRoom(roomId); if (!room) return send(socket, { type: "error", message: "no such room" }); const result = joinRoom(room, name, typeof msg.token === "string" ? msg.token : null); if ("error" in result) return send(socket, { type: "error", message: result.error }); session.playerId = name; session.roomId = room.id; session.token = result.token; send(socket, { type: "seat", playerId: name, token: result.token }); // Rejoining a running game: replay the chronicle so far. if (room.state) { send(socket, { type: "events", events: redactFor(room.events, name) }); } broadcastRoomState(room); break; } case "start": { const room = session.roomId ? getRoom(session.roomId) : undefined; if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" }); if (session.playerId !== room.hostId) return send(socket, { type: "error", message: "only the host can start" }); const result = startGame(room, msg.expansion === true); if ("error" in result) return send(socket, { type: "error", message: result.error }); broadcast(room, (playerId) => ({ type: "events", events: redactFor(result.events, playerId) })); broadcastRoomState(room); break; } case "command": { const room = session.roomId ? getRoom(session.roomId) : undefined; if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" }); if (JSON.stringify(msg.command ?? null).length > MAX_COMMAND_BYTES) { return send(socket, { type: "error", message: "command too large" }); } const result = runCommand(room, session.playerId, msg.command as Command); if ("error" in result) return send(socket, { type: "error", message: result.error }); broadcast(room, (playerId) => ({ type: "events", events: redactFor(result.events, playerId) })); broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length })); break; } case "makeTransfer": { const room = session.roomId ? getRoom(session.roomId) : undefined; if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" }); const result = makeTransferCode(room, session.playerId, session.token); if ("error" in result) return send(socket, { type: "error", message: result.error }); send(socket, { type: "transferCode", code: result.code, expiresAt: result.expiresAt }); break; } case "claimTransfer": { if (session.claimFails >= 5) { return send(socket, { type: "error", message: "too many attempts on this connection — reconnect and mint a fresh phrase" }); } const result = claimTransferCode(String(msg.code ?? "")); if ("error" in result) { session.claimFails++; return send(socket, { type: "error", message: result.error }); } session.claimFails = 0; send(socket, { type: "transferClaimed", seat: result }); break; } case "catchUp": { const room = session.roomId ? getRoom(session.roomId) : undefined; if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" }); // A catch-up replays the whole game server-side; one at a time, please. const now = Date.now(); if (now - session.lastCatchUpAt < CATCHUP_COOLDOWN_MS) { return send(socket, { type: "error", message: "catching up already — one moment" }); } session.lastCatchUpAt = now; const steps = catchUpSteps(room, session.playerId, Number(msg.sinceSeq ?? 0)); if ("error" in steps) return send(socket, { type: "error", message: steps.error }); send(socket, { type: "catchUp", steps }); break; } case "pickColor": { const room = session.roomId ? getRoom(session.roomId) : undefined; if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" }); const problem = pickColor(room, session.playerId, Number(msg.color)); if (problem) return send(socket, { type: "error", message: problem }); broadcastRoomState(room); break; } case "stats": { send(socket, { type: "stats", stats: engagementStats() }); break; } case "myGames": { // {seats: [{roomId, name, token}]} -> summaries for valid seats. const seats = Array.isArray(msg.seats) ? msg.seats.slice(0, MAX_MYGAMES_SEATS) : []; const games = []; for (const seat of seats) { if (typeof seat !== "object" || seat === null) continue; const room = getRoom(String(seat.roomId ?? "")); if (!room) continue; const name = String(seat.name ?? ""); if (!seatTokenValid(room, name, typeof seat.token === "string" ? seat.token : null)) continue; games.push(summarize(room, name)); } send(socket, { type: "games", games }); break; } default: send(socket, { type: "error", message: `unknown message type: ${String(msg.type)}` }); } } catch (e) { // Expected failures travel as result.error values; anything thrown is a // bug, and its message (paths, internals) is not for strangers' eyes. console.error("unhandled protocol error:", e); send(socket, { type: "error", message: "internal error" }); } }); }); console.log(`wizwar serving client + websocket on port ${port}`);