| 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}`); |
| }); |
|
|