/** * TRXTD game server: static frontend (dist/) + multiplayer WebSocket relay. * Single port for everything – ideal for Docker deployments. * * Dev: npm run server (ws on :3001 next to vite on :5173) * Prod: serves dist/ + ws on PORT (default 3001) * * Security hardening: * - WebSocket maxPayload limit (prevents DoS) * - Heartbeat (ping/pong) to clean dead sockets * - Rate-limiting per connection (action flood protection) * - Strict message schema validation * - Static file serving with path-traversal protection * - Inactive room cleanup timeout */ import http from 'node:http' import { promises as fs } from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' import { WebSocketServer } from 'ws' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const DIST_DIR = process.env.DIST_DIR || path.join(__dirname, '..', 'dist') const PORT = Number(process.env.PORT || 3001) const MAX_PAYLOAD_BYTES = 4096 const MAX_ACTIONS_PER_SEC = 30 const ROOM_TIMEOUT_MS = 30 * 60 * 1000 // ------------------------------------------------------------------ static files const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.woff': 'font/woff', '.woff2': 'font/woff2', '.txt': 'text/plain; charset=utf-8', '.map': 'application/json', } const DIST_ROOT = path.normalize(DIST_DIR) async function serveStatic(req, res) { const url = new URL(req.url, 'http://localhost') if (url.pathname === '/health') { res.writeHead(200, { 'Content-Type': 'text/plain' }) res.end('ok') return } if (req.method !== 'GET' && req.method !== 'HEAD') { res.writeHead(405, { 'Content-Type': 'text/plain' }) res.end('Method Not Allowed') return } let rel try { rel = decodeURIComponent(url.pathname) } catch { res.writeHead(400) res.end('Bad Request') return } if (rel.includes('\0')) { res.writeHead(400) res.end('Bad Request') return } let filePath = path.normalize(path.join(DIST_ROOT, rel)) if (filePath !== DIST_ROOT && !filePath.startsWith(DIST_ROOT + path.sep)) { res.writeHead(403) res.end('Forbidden') return } let data = await fs.readFile(filePath).catch(() => null) if (!data) { // SPA fallback: unknown paths without file extension get index.html if (!path.extname(rel)) { filePath = path.join(DIST_ROOT, 'index.html') data = await fs.readFile(filePath).catch(() => null) } if (!data) { res.writeHead(404, { 'Content-Type': 'text/plain' }) res.end('Not Found') return } } const ext = path.extname(filePath).toLowerCase() const type = MIME[ext] || 'application/octet-stream' const isHashedAsset = url.pathname.startsWith('/assets/') res.writeHead(200, { 'Content-Type': type, 'Cache-Control': isHashedAsset ? 'public, max-age=31536000, immutable' : 'no-cache', 'X-Content-Type-Options': 'nosniff', }) res.end(req.method === 'HEAD' ? undefined : data) } const httpServer = http.createServer((req, res) => { serveStatic(req, res).catch(() => { if (!res.headersSent) res.writeHead(500) res.end('Internal Server Error') }) }) // ------------------------------------------------------------------ game rooms const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_PAYLOAD_BYTES, }) httpServer.on('upgrade', (req, socket, head) => { wss.handleUpgrade(req, socket, head, (ws) => { wss.emit('connection', ws, req) }) }) /** code -> { code, mode, created, seq, players: [{ ws, id, name }] } */ const rooms = new Map() const CODE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' function makeCode() { let code let attempts = 0 do { code = Array.from({ length: 4 }, () => CODE_CHARS[Math.floor(Math.random() * CODE_CHARS.length)]).join('') attempts++ if (attempts > 1000) break } while (rooms.has(code)) return code } function sanitizeName(raw) { if (typeof raw !== 'string') return 'Spieler' const clean = raw.trim().replace(/[^\p{L}\p{N}_\- ]/gu, '').slice(0, 12) return clean || 'Spieler' } function send(ws, obj) { if (ws && ws.readyState === 1) { try { ws.send(JSON.stringify(obj)) } catch { /* ignore socket send failure */ } } } function roomOf(ws) { const code = ws._room return code ? rooms.get(code) : undefined } function broadcast(room, obj, exceptWs) { for (const p of room.players) { if (p.ws !== exceptWs) send(p.ws, obj) } } function playerInfo(room) { return room.players.map((p) => ({ id: p.id, name: p.name })) } function leaveRoom(ws) { const room = roomOf(ws) if (!room) return room.players = room.players.filter((p) => p.ws !== ws) ws._room = undefined if (room.players.length === 0) { rooms.delete(room.code) } else { broadcast(room, { t: 'peer-left' }) } } function validateAction(a) { if (!a || typeof a !== 'object' || Array.isArray(a)) return false switch (a.type) { case 'build': return ( ['arrow', 'cannon', 'frost', 'tesla', 'laser'].includes(a.kind) && Number.isInteger(a.tx) && a.tx >= 0 && a.tx < 20 && Number.isInteger(a.ty) && a.ty >= 0 && a.ty < 11 ) case 'upgrade': case 'sell': return Number.isInteger(a.towerId) && a.towerId > 0 case 'targeting': return ( Number.isInteger(a.towerId) && a.towerId > 0 && ['first', 'last', 'strong', 'close'].includes(a.mode) ) case 'obstacle': return Number.isInteger(a.tx) && a.tx >= 0 && a.tx < 20 && Number.isInteger(a.ty) && a.ty >= 0 && a.ty < 11 case 'wave': case 'rush': return true case 'speed': return [1, 2, 3].includes(a.s) default: return false } } // heartbeat + room timeout const pingInterval = setInterval(() => { const now = Date.now() for (const [code, room] of rooms.entries()) { if (now - room.created > ROOM_TIMEOUT_MS) { for (const p of room.players) { send(p.ws, { t: 'error', msg: 'Raum-Zeitüberschreitung.' }) p.ws._room = undefined } rooms.delete(code) } } wss.clients.forEach((ws) => { if (ws.isAlive === false) { leaveRoom(ws) ws.terminate() return } ws.isAlive = false try { ws.ping() } catch { leaveRoom(ws) } }) }, 15000) wss.on('close', () => { clearInterval(pingInterval) }) wss.on('connection', (ws) => { ws.isAlive = true ws._actBucket = { count: 0, resetAt: Date.now() + 1000 } ws.on('pong', () => { ws.isAlive = true }) ws.on('message', (data, isBinary) => { ws.isAlive = true if (isBinary) return // only JSON text frames are allowed let m try { m = JSON.parse(data.toString()) } catch { return } if (!m || typeof m !== 'object' || typeof m.t !== 'string') return // rate limiting const now = Date.now() if (now > ws._actBucket.resetAt) { ws._actBucket.count = 0 ws._actBucket.resetAt = now + 1000 } ws._actBucket.count++ if (ws._actBucket.count > MAX_ACTIONS_PER_SEC) { send(ws, { t: 'error', msg: 'Zu viele Anfragen gesendet.' }) return } switch (m.t) { case 'create': { leaveRoom(ws) if (m.mode !== 'coop' && m.mode !== 'duel') return const code = makeCode() const room = { code, mode: m.mode, created: Date.now(), seq: 0, players: [{ ws, id: 0, name: sanitizeName(m.name) }] } rooms.set(code, room) ws._room = code send(ws, { t: 'room', code, mode: room.mode, players: playerInfo(room), you: 0 }) break } case 'join': { leaveRoom(ws) const code = String(m.code || '').toUpperCase().trim() const room = rooms.get(code) if (!room) return send(ws, { t: 'error', msg: 'Raum nicht gefunden.' }) if (room.players.length >= 2) return send(ws, { t: 'error', msg: 'Raum ist bereits voll.' }) room.players.push({ ws, id: 1, name: sanitizeName(m.name) }) ws._room = code for (const p of room.players) { send(p.ws, { t: 'room', code, mode: room.mode, players: playerInfo(room), you: p.id }) } break } case 'start': { const room = roomOf(ws) if (!room || room.players.length !== 2) return if (room.players[0].ws !== ws) return // only the host may start const seed = (Math.random() * 1e9) | 0 for (const p of room.players) { send(p.ws, { t: 'start', seed, mode: room.mode, players: playerInfo(room), you: p.id }) } break } case 'act': { const room = roomOf(ws) if (!room || room.players.length < 2) return const from = room.players.findIndex((p) => p.ws === ws) if (from === -1) return if (!validateAction(m.a)) return if (!Number.isInteger(m.tick) || m.tick < 0) return room.seq = (room.seq || 0) + 1 for (const p of room.players) { send(p.ws, { t: 'act', seq: room.seq, from, tick: m.tick, a: m.a }) } break } case 'prog': { const room = roomOf(ws) if (!room) return const from = room.players.findIndex((p) => p.ws === ws) if (from === -1) return if (typeof m.tick !== 'number' || !Number.isFinite(m.tick)) return broadcast(room, { t: 'prog', from, tick: m.tick }) break } case 'leave': { leaveRoom(ws) break } } }) ws.on('close', () => leaveRoom(ws)) ws.on('error', () => leaveRoom(ws)) }) httpServer.listen(PORT, () => { console.log(`TRXTD-Server läuft auf http://localhost:${PORT} (Static: ${DIST_DIR})`) })