const crypto = require("crypto"); const fs = require("fs"); const http = require("http"); const path = require("path"); const appPort = Number(process.env.APP_PORT || 8787); const root = __dirname; const boardSize = 5; const winTarget = 4; const empty = ""; const maxClients = Number(process.env.MAX_CLIENTS || 200); const maxClientsPerIp = Number(process.env.MAX_CLIENTS_PER_IP || 12); const maxMessageBytes = Number(process.env.MAX_MESSAGE_BYTES || 1024); const messageWindowMs = Number(process.env.MESSAGE_WINDOW_MS || 10_000); const maxMessagesPerWindow = Number(process.env.MAX_MESSAGES_PER_WINDOW || 40); const minActionIntervalMs = Number(process.env.MIN_ACTION_INTERVAL_MS || 180); const idleSocketTimeoutMs = Number(process.env.IDLE_SOCKET_TIMEOUT_MS || 120_000); const allowedOrigins = (process.env.ALLOWED_ORIGINS || "") .split(",") .map((origin) => origin.trim()) .filter(Boolean); const trustProxy = process.env.TRUST_PROXY === "true"; const clients = new Map(); const rooms = new Map(); const clientsByIp = new Map(); let waitingClientId = ""; let nextClientId = 1; let nextRoomId = 1; const staticFiles = new Map([ ["/", "index.html"], ["/index.html", "index.html"], ["/styles.css", "styles.css"], ["/game.js", "game.js"], ]); const mimeTypes = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8", }; const directions = [ [-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1], ]; const winAxes = [ [0, 1], [1, 0], [1, 1], [1, -1], ]; const server = http.createServer((request, response) => { setSecurityHeaders(response); if (request.method !== "GET" && request.method !== "HEAD") { response.writeHead(405, { Allow: "GET, HEAD" }); response.end("Method not allowed"); return; } const requestUrl = new URL(request.url, `http://${request.headers.host}`); if (requestUrl.pathname === "/healthz") { response.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); response.end(request.method === "HEAD" ? undefined : JSON.stringify({ ok: true })); return; } if (requestUrl.pathname === "/lobby") { response.writeHead(200, { "Cache-Control": "no-store", "Content-Type": "application/json; charset=utf-8", }); response.end(request.method === "HEAD" ? undefined : JSON.stringify(lobbyStatus())); return; } const staticName = staticFiles.get(requestUrl.pathname); if (!staticName) { response.writeHead(404); response.end("Not found"); return; } const filePath = path.join(root, staticName); fs.readFile(filePath, (error, content) => { if (error) { response.writeHead(404); response.end("Not found"); return; } response.writeHead(200, { "Cache-Control": "no-store", "Content-Type": mimeTypes[path.extname(filePath)] || "application/octet-stream", }); response.end(request.method === "HEAD" ? undefined : content); }); }); server.on("upgrade", (request, socket) => { if (request.url !== "/ws") { rejectUpgrade(socket, 404, "Not found"); return; } if (!isAllowedOrigin(request)) { rejectUpgrade(socket, 403, "Forbidden"); return; } if (clients.size >= maxClients) { rejectUpgrade(socket, 503, "Server full"); return; } const ip = clientIp(request); if ((clientsByIp.get(ip) || 0) >= maxClientsPerIp) { rejectUpgrade(socket, 429, "Too many connections"); return; } const key = request.headers["sec-websocket-key"]; if (!isValidWebSocketKey(key) || request.headers["sec-websocket-version"] !== "13") { rejectUpgrade(socket, 400, "Bad websocket request"); return; } const accept = crypto .createHash("sha1") .update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`) .digest("base64"); socket.write([ "HTTP/1.1 101 Switching Protocols", "Upgrade: websocket", "Connection: Upgrade", `Sec-WebSocket-Accept: ${accept}`, "", "", ].join("\r\n")); addClient(socket, ip); }); function setSecurityHeaders(response) { response.setHeader("Content-Security-Policy", "default-src 'self'; connect-src 'self'; script-src 'self'; style-src 'self'; base-uri 'none'; frame-ancestors 'none'"); response.setHeader("Cross-Origin-Opener-Policy", "same-origin"); response.setHeader("Referrer-Policy", "no-referrer"); response.setHeader("X-Content-Type-Options", "nosniff"); response.setHeader("X-Frame-Options", "DENY"); } function rejectUpgrade(socket, status, message) { socket.write(`HTTP/1.1 ${status} ${message}\r\nConnection: close\r\n\r\n`); socket.destroy(); } function isValidWebSocketKey(key) { if (typeof key !== "string") { return false; } try { return Buffer.from(key, "base64").length === 16; } catch { return false; } } function isAllowedOrigin(request) { const origin = request.headers.origin; if (!origin) { return true; } if (allowedOrigins.length > 0) { return allowedOrigins.includes(origin); } try { return new URL(origin).host === request.headers.host; } catch { return false; } } function clientIp(request) { const forwardedFor = request.headers["x-forwarded-for"]; if (trustProxy && typeof forwardedFor === "string" && forwardedFor.length > 0) { return forwardedFor.split(",")[0].trim(); } return request.socket.remoteAddress || "unknown"; } function addClient(socket, ip) { const id = String(nextClientId); nextClientId += 1; const client = { id, ip, socket, roomId: "", player: "", buffer: Buffer.alloc(0), messageTimestamps: [], lastActionAt: 0, }; clients.set(id, client); clientsByIp.set(ip, (clientsByIp.get(ip) || 0) + 1); socket.setNoDelay(true); socket.setTimeout(idleSocketTimeoutMs, () => closeClient(client, 1001, "Idle timeout")); socket.on("data", (buffer) => { try { handleSocketData(client, buffer); } catch { closeClient(client, 1002, "Protocol error"); } }); socket.on("close", () => removeClient(client)); socket.on("error", () => removeClient(client)); if (waitingClientId && clients.has(waitingClientId)) { pairClients(clients.get(waitingClientId), client); waitingClientId = ""; return; } waitingClientId = id; send(client, { type: "waiting" }); } function pairClients(xClient, oClient) { const roomId = String(nextRoomId); nextRoomId += 1; xClient.roomId = roomId; xClient.player = "X"; oClient.roomId = roomId; oClient.player = "O"; rooms.set(roomId, { id: roomId, players: [xClient.id, oClient.id], board: createBoard(), captures: { X: 0, O: 0 }, currentPlayer: "X", gameOver: false, }); send(xClient, { type: "paired", player: "X", roomId }); send(oClient, { type: "paired", player: "O", roomId }); } function lobbyStatus() { return { activeGames: rooms.size, waitingPlayers: waitingClientId && clients.has(waitingClientId) ? 1 : 0, }; } function handleSocketData(client, buffer) { if (!clients.has(client.id)) { return; } if (client.buffer.length + buffer.length > maxMessageBytes) { closeClient(client, 1009, "Message too large"); return; } client.buffer = Buffer.concat([client.buffer, buffer]); while (client.buffer.length > 0) { const frame = readFrame(client.buffer); if (!frame) { return; } client.buffer = client.buffer.subarray(frame.consumed); if (frame.opcode === 0x8) { closeClient(client, 1000, "Closing"); return; } if (frame.opcode === 0x9) { client.socket.write(Buffer.from([0x8a, 0x00])); continue; } if (frame.opcode !== 0x1) { closeClient(client, 1003, "Unsupported frame"); return; } handleTextMessage(client, frame.payload.toString("utf8")); } } function handleTextMessage(client, message) { if (!checkMessageRate(client)) { closeClient(client, 1008, "Rate limit exceeded"); return; } let payload; try { payload = JSON.parse(message); } catch { send(client, { type: "error", message: "Invalid message." }); return; } if (!isObject(payload) || !isSafeAction(client)) { send(client, { type: "error", message: "Slow down." }); return; } const room = rooms.get(client.roomId); if (!room || payload.roomId !== client.roomId) { send(client, { type: "error", message: "You are not currently paired." }); return; } if (payload.type === "move") { const result = applyMove(room, client, payload); if (!result.ok) { send(client, { type: "error", message: result.message }); return; } relayToOpponent(client, { type: "move", row: payload.row, col: payload.col, player: client.player, }); return; } if (payload.type === "reset") { room.board = createBoard(); room.captures = { X: 0, O: 0 }; room.currentPlayer = "X"; room.gameOver = false; relayToOpponent(client, { type: "reset" }); return; } send(client, { type: "error", message: "Unsupported message type." }); } function checkMessageRate(client) { const now = Date.now(); client.messageTimestamps = client.messageTimestamps.filter((timestamp) => now - timestamp < messageWindowMs); client.messageTimestamps.push(now); return client.messageTimestamps.length <= maxMessagesPerWindow; } function isSafeAction(client) { const now = Date.now(); if (now - client.lastActionAt < minActionIntervalMs) { return false; } client.lastActionAt = now; return true; } function applyMove(room, client, payload) { if (room.gameOver) { return { ok: false, message: "Game is already over." }; } if (client.player !== room.currentPlayer || payload.player !== client.player) { return { ok: false, message: "Not your turn." }; } if (!isBoardCoordinate(payload.row) || !isBoardCoordinate(payload.col)) { return { ok: false, message: "Move is out of bounds." }; } if (room.board[payload.row][payload.col] !== empty) { return { ok: false, message: "Cell is already occupied." }; } room.board[payload.row][payload.col] = client.player; const captured = captureFrom(room.board, payload.row, payload.col, client.player); room.captures[client.player] += captured.length; if (findWin(room.board, client.player).length > 0 || isDraw(room.board)) { room.gameOver = true; } else { room.currentPlayer = opponentOf(client.player); } return { ok: true }; } function removeClient(client) { if (!clients.has(client.id)) { return; } clients.delete(client.id); const ipCount = (clientsByIp.get(client.ip) || 1) - 1; if (ipCount <= 0) { clientsByIp.delete(client.ip); } else { clientsByIp.set(client.ip, ipCount); } if (waitingClientId === client.id) { waitingClientId = ""; } if (!client.roomId) { return; } const room = rooms.get(client.roomId); rooms.delete(client.roomId); if (!room) { return; } for (const playerId of room.players) { if (playerId === client.id || !clients.has(playerId)) { continue; } const opponent = clients.get(playerId); opponent.roomId = ""; opponent.player = ""; send(opponent, { type: "opponent-left", message: "Opponent disconnected. Waiting for a new player...", }); waitingClientId = opponent.id; } } function relayToOpponent(client, payload) { const room = rooms.get(client.roomId); if (!room) { send(client, { type: "error", message: "Room no longer exists." }); return; } for (const playerId of room.players) { if (playerId !== client.id && clients.has(playerId)) { send(clients.get(playerId), payload); } } } function send(client, payload) { if (client.socket.destroyed) { return; } client.socket.write(encodeFrame(JSON.stringify(payload))); } function closeClient(client, code, reason) { if (client.socket.destroyed) { removeClient(client); return; } client.socket.write(encodeCloseFrame(code, reason)); client.socket.end(); removeClient(client); } function readFrame(buffer) { if (buffer.length < 2) { return null; } const firstByte = buffer[0]; const secondByte = buffer[1]; const masked = (secondByte & 0x80) === 0x80; let length = secondByte & 0x7f; let offset = 2; if (!masked) { throwAndClose(); } if (length === 126) { if (buffer.length < offset + 2) { return null; } length = buffer.readUInt16BE(offset); offset += 2; } else if (length === 127) { if (buffer.length < offset + 8) { return null; } length = Number(buffer.readBigUInt64BE(offset)); offset += 8; } if (length > maxMessageBytes) { throwAndClose(); } const maskEnd = offset + 4; const payloadEnd = maskEnd + length; if (buffer.length < payloadEnd) { return null; } const mask = buffer.subarray(offset, maskEnd); const payload = buffer.subarray(maskEnd, payloadEnd); const decoded = Buffer.alloc(payload.length); for (let index = 0; index < payload.length; index += 1) { decoded[index] = payload[index] ^ mask[index % 4]; } return { consumed: payloadEnd, opcode: firstByte & 0x0f, payload: decoded, }; } function throwAndClose() { throw new Error("Invalid websocket frame"); } function encodeFrame(message) { const payload = Buffer.from(message); const length = payload.length; if (length < 126) { return Buffer.concat([Buffer.from([0x81, length]), payload]); } const header = Buffer.alloc(4); header[0] = 0x81; header[1] = 126; header.writeUInt16BE(length, 2); return Buffer.concat([header, payload]); } function encodeCloseFrame(code, reason) { const reasonBuffer = Buffer.from(reason.slice(0, 80)); const payload = Buffer.alloc(2 + reasonBuffer.length); payload.writeUInt16BE(code, 0); reasonBuffer.copy(payload, 2); return Buffer.concat([Buffer.from([0x88, payload.length]), payload]); } function createBoard() { return Array.from({ length: boardSize }, () => Array(boardSize).fill(empty)); } function captureFrom(board, row, col, player) { const opponent = opponentOf(player); const captured = []; for (const [rowStep, colStep] of directions) { const candidates = []; let nextRow = row + rowStep; let nextCol = col + colStep; while (isInside(nextRow, nextCol) && board[nextRow][nextCol] === opponent) { candidates.push([nextRow, nextCol]); nextRow += rowStep; nextCol += colStep; } if (candidates.length > 0 && isInside(nextRow, nextCol) && board[nextRow][nextCol] === player) { captured.push(...candidates); } } for (const [captureRow, captureCol] of captured) { board[captureRow][captureCol] = empty; } return captured; } function findWin(board, player) { for (let row = 0; row < boardSize; row += 1) { for (let col = 0; col < boardSize; col += 1) { if (board[row][col] !== player) { continue; } for (const [rowStep, colStep] of winAxes) { const cells = [[row, col]]; for (let index = 1; index < winTarget; index += 1) { const nextRow = row + rowStep * index; const nextCol = col + colStep * index; if (!isInside(nextRow, nextCol) || board[nextRow][nextCol] !== player) { break; } cells.push([nextRow, nextCol]); } if (cells.length === winTarget) { return cells; } } } } return []; } function isDraw(board) { return board.every((row) => row.every((cell) => cell !== empty)); } function opponentOf(player) { return player === "X" ? "O" : "X"; } function isInside(row, col) { return row >= 0 && row < boardSize && col >= 0 && col < boardSize; } function isBoardCoordinate(value) { return Number.isInteger(value) && value >= 0 && value < boardSize; } function isObject(value) { return value !== null && typeof value === "object" && !Array.isArray(value); } server.on("clientError", (_error, socket) => { socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n"); }); server.listen(appPort, () => { console.log(`Tictactics multiplayer server running at http://localhost:${appPort}`); });