import { createHash, randomUUID } from 'node:crypto'; import { createReadStream, existsSync } from 'node:fs'; import { stat } from 'node:fs/promises'; import { createServer } from 'node:http'; import { extname, join, normalize, resolve } from 'node:path'; const PORT = Number(process.env.PORT || 8787); const DIST_DIR = resolve('dist'); const rooms = new Map(); const mimeTypes = new Map([ ['.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'], ]); const server = createServer(async (request, response) => { const url = new URL(request.url ?? '/', `http://${request.headers.host}`); const pathname = normalize(decodeURIComponent(url.pathname)).replace(/^(\.\.[/\\])+/, ''); const requestedPath = pathname === '/' ? 'index.html' : pathname.slice(1); let filePath = resolve(join(DIST_DIR, requestedPath)); if (!filePath.startsWith(DIST_DIR) || !existsSync(filePath)) { filePath = resolve(join(DIST_DIR, 'index.html')); } try { const fileStat = await stat(filePath); if (!fileStat.isFile()) throw new Error('Not a file'); response.writeHead(200, { 'content-type': mimeTypes.get(extname(filePath)) ?? 'application/octet-stream', 'cache-control': filePath.endsWith('index.html') ? 'no-cache' : 'public, max-age=31536000, immutable', }); createReadStream(filePath).pipe(response); } catch { response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }); response.end('Not found'); } }); server.on('upgrade', (request, socket) => { const key = request.headers['sec-websocket-key']; if (!key) { socket.destroy(); return; } const acceptKey = 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: ${acceptKey}`, '', '', ].join('\r\n'), ); socket.id = randomUUID(); socket.buffer = Buffer.alloc(0); socket.on('data', (chunk) => handleSocketData(socket, chunk)); socket.on('close', () => leaveRoom(socket)); socket.on('error', () => leaveRoom(socket)); }); function handleSocketData(socket, chunk) { socket.buffer = Buffer.concat([socket.buffer, chunk]); while (socket.buffer.length >= 2) { const frame = readFrame(socket.buffer); if (!frame) return; socket.buffer = socket.buffer.subarray(frame.bytesRead); if (frame.opcode === 0x8) { socket.end(); return; } if (frame.opcode === 0x9) { socket.write(writeFrame(frame.payload, 0xA)); continue; } if (frame.opcode !== 0x1) continue; let message; try { message = JSON.parse(frame.payload.toString('utf8')); } catch { continue; } handleMessage(socket, message); } } function readFrame(buffer) { const first = buffer[0]; const second = buffer[1]; const opcode = first & 0x0f; const masked = (second & 0x80) !== 0; let length = second & 0x7f; let offset = 2; 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; const bigLength = buffer.readBigUInt64BE(offset); if (bigLength > BigInt(1024 * 1024)) return null; length = Number(bigLength); offset += 8; } const maskLength = masked ? 4 : 0; if (buffer.length < offset + maskLength + length) return null; let mask; if (masked) { mask = buffer.subarray(offset, offset + 4); offset += 4; } const payload = Buffer.from(buffer.subarray(offset, offset + length)); if (masked) { for (let i = 0; i < payload.length; i += 1) { payload[i] ^= mask[i % 4]; } } return { opcode, payload, bytesRead: offset + length }; } function writeFrame(payload, opcode = 0x1) { const data = Buffer.isBuffer(payload) ? payload : Buffer.from(String(payload)); const headerLength = data.length < 126 ? 2 : data.length < 65536 ? 4 : 10; const header = Buffer.alloc(headerLength); header[0] = 0x80 | opcode; if (data.length < 126) { header[1] = data.length; } else if (data.length < 65536) { header[1] = 126; header.writeUInt16BE(data.length, 2); } else { header[1] = 127; header.writeBigUInt64BE(BigInt(data.length), 2); } return Buffer.concat([header, data]); } function handleMessage(socket, message) { if (message.type === 'join') { joinRoom(socket, message); return; } if (message.type === 'meta') { updateMeta(socket, message); return; } if (message.type === 'hit' && socket.room) { broadcast(socket.room, { type: 'peer-hit', id: socket.id, hit: { target: String(message.target || '').slice(0, 64), trackIndex: clampNumber(message.trackIndex, 0, 8, 0), x: clampNumber(message.x, -9, 9, 0), z: clampNumber(message.z, -9, 9, 0), strength: clampNumber(message.strength, 0, 12, 0), }, }, socket); return; } if (message.type !== 'state' || !socket.room) { return; } updateMeta(socket, message.state ?? {}, false); broadcast(socket.room, { type: 'peer-state', id: socket.id, state: message.state, }, socket); } function joinRoom(socket, message) { leaveRoom(socket); const room = String(message.room || 'LOBBY').toUpperCase().slice(0, 16); socket.room = room; socket.meta = { name: normalizePeerName(message.name), trackIndex: Number(message.trackIndex || 0), carIndex: Number(message.carIndex || 0), phase: String(message.phase || 'select').slice(0, 16), }; if (!rooms.has(room)) rooms.set(room, new Set()); rooms.get(room).add(socket); send(socket, { type: 'welcome', id: socket.id, room }); send(socket, { type: 'room-peers', peers: [...rooms.get(room)] .filter((peer) => peer !== socket) .map((peer) => serializePeer(peer)), }); broadcast(room, { type: 'peer-joined', id: socket.id, peer: serializePeer(socket) }, socket); } function updateMeta(socket, message, broadcastMeta = true) { if (!socket.room) return; socket.meta = { ...(socket.meta ?? {}), name: normalizePeerName(message.name, socket.meta?.name), trackIndex: Number(message.trackIndex ?? socket.meta?.trackIndex ?? 0), carIndex: Number(message.carIndex ?? socket.meta?.carIndex ?? 0), phase: String(message.phase || socket.meta?.phase || 'select').slice(0, 16), }; if (broadcastMeta) { broadcast(socket.room, { type: 'peer-meta', id: socket.id, peer: serializePeer(socket) }, socket); } } function serializePeer(socket) { return { id: socket.id, name: normalizePeerName(socket.meta?.name), trackIndex: Number(socket.meta?.trackIndex || 0), carIndex: Number(socket.meta?.carIndex || 0), phase: socket.meta?.phase || 'select', }; } function normalizePeerName(value, fallback = 'RACER') { return String(value || fallback) .replace(/\s+/g, ' ') .trim() .slice(0, 12) || fallback; } function clampNumber(value, min, max, fallback) { const number = Number(value); if (!Number.isFinite(number)) return fallback; return Math.min(max, Math.max(min, number)); } function leaveRoom(socket) { if (!socket.room) return; const roomSockets = rooms.get(socket.room); if (roomSockets) { roomSockets.delete(socket); broadcast(socket.room, { type: 'peer-left', id: socket.id }, socket); if (roomSockets.size === 0) rooms.delete(socket.room); } socket.room = null; } function send(socket, message) { if (!socket.writable) return; socket.write(writeFrame(JSON.stringify(message))); } function broadcast(room, message, exceptSocket = null) { const roomSockets = rooms.get(room); if (!roomSockets) return; for (const socket of roomSockets) { if (socket !== exceptSocket) send(socket, message); } } server.listen(PORT, '0.0.0.0', () => { console.log(`Turbo Lap multiplayer server listening on http://localhost:${PORT}`); });