turbo-lap / server.js
2nhyn's picture
Raise sky roads and strengthen kart impacts
afafc4b
Raw
History Blame Contribute Delete
8.29 kB
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}`);
});