import { createServer } from 'node:http'; import { createReadStream } from 'node:fs'; import { realpath, stat } from 'node:fs/promises'; import { resolve, relative, isAbsolute, extname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { pipeline } from 'node:stream'; import { Server } from 'socket.io'; import { MULTIPLAYER } from '../src/constants/multiplayer.ts'; import { observer } from '../src/config/observer.ts'; import { WeatherController } from '../src/weather/weather.ts'; import type { ClientEvents, ServerEvents } from '../src/multiplayer/protocol.ts'; import type { VisitStore } from './visits.ts'; import { SummitRoom } from './room.ts'; import { SERVER_LIMITS } from './constants/multiplayer.ts'; import { LOBBY, LOBBY_SERVERS } from '../src/constants/lobby.ts'; export interface AppOptions { visitStore: VisitStore; weather?: boolean; distDirectory?: string; allowedOrigins?: string[]; onError?: (error: unknown) => void; } const mime: Record = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.ico': 'image/x-icon', '.woff2': 'font/woff2' }; function contained(root: string, file: string) { const path = relative(root, file); return !path.startsWith('..') && !isAbsolute(path); } /** Single-instance factory. Caller owns listen(); close() drains visits and closes the injected store. */ export function createApp(options: AppOptions) { const dist = resolve(options.distDirectory ?? fileURLToPath(new URL('../dist/', import.meta.url))); const onError = options.onError ?? (error => console.error('Server operation failed', error)); const origins = options.allowedOrigins ?? [...SERVER_LIMITS.defaultOrigins]; const allowed = (origin: string | undefined) => origin === undefined || origins.includes(origin); const httpServer = createServer((request, response) => { response.setHeader('X-Content-Type-Options', 'nosniff'); const serve = async () => { const rawPath = (request.url ?? '/').split('?')[0]; if (rawPath === '/health') { response.statusCode = room.healthy ? 200 : 503; response.setHeader('Content-Type', 'application/json'); response.setHeader('Cache-Control', 'no-store'); response.end(request.method === 'HEAD' ? undefined : JSON.stringify({ ok: room.healthy })); return; } if (request.method !== 'GET' && request.method !== 'HEAD') { response.writeHead(405, { Allow: 'GET, HEAD' }); response.end(); return; } if (rawPath === LOBBY.endpoint) { response.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); response.end(request.method === 'HEAD' ? undefined : JSON.stringify({ servers: LOBBY_SERVERS, channel: { id: MULTIPLAYER.roomId, name: LOBBY.channelName, capacity: MULTIPLAYER.capacity, players: room.size, available: room.healthy && room.reserved < MULTIPLAYER.capacity }, })); return; } let path: string; try { path = decodeURIComponent(rawPath); } catch { response.writeHead(400); response.end(); return; } if (/[\\\u0000:]/.test(path) || path.split('/').some(segment => segment.startsWith('.'))) { response.writeHead(404); response.end(); return; } try { const root = await realpath(dist); let file = await realpath(resolve(root, '.' + (path === '/' ? '/index.html' : path))); if (!contained(root, file)) { response.writeHead(404); response.end(); return; } const type = mime[extname(file)] ?? 'application/octet-stream'; // Precompressed assets only, with the same realpath containment check as identity files. const encodings = (request.headers['accept-encoding'] ?? '').toLowerCase().split(',').map(entry => { const [name, ...params] = entry.trim().split(';'); const q = params.find(p => p.trim().startsWith('q=')); return { name, q: q ? Number(q.trim().slice(2)) : 1 }; }); const gzip = encodings.find(e => e.name === 'gzip') ?? encodings.find(e => e.name === '*'); response.setHeader('Vary', 'Accept-Encoding'); if (gzip && gzip.q > 0 && !request.headers.range) { try { const compressed = await realpath(file + '.gz'); if (contained(root, compressed) && (await stat(compressed)).isFile()) { file = compressed; response.setHeader('Content-Encoding', 'gzip'); } } catch { /* Identity file remains available. */ } } const info = await stat(file); if (!info.isFile()) { response.writeHead(404); response.end(); return; } response.writeHead(200, { 'Content-Type': type, 'Content-Length': info.size, 'Cache-Control': 'no-cache' }); if (request.method === 'HEAD') response.end(); else pipeline(createReadStream(file), response, error => { if (error) response.destroy(); }); } catch { response.writeHead(404); response.end(); } }; void serve().catch(error => { onError(error); if (!response.headersSent) response.writeHead(500); response.end(); }); }); const io = new Server(httpServer, { maxHttpBufferSize: MULTIPLAYER.maxPacketBytes, cors: { origin(origin, callback) { callback(null, allowed(origin)); } }, allowRequest(request, callback) { callback(null, allowed(request.headers.origin)); }, }); const room = new SummitRoom(io, options.visitStore, onError); const timers = [setInterval(() => room.broadcast(), MULTIPLAYER.tickMs), setInterval(() => room.broadcast(true), MULTIPLAYER.snapshotMs), setInterval(() => { void room.heartbeat(); }, MULTIPLAYER.visitHeartbeatMs)]; const weather = options.weather === false ? undefined : new WeatherController(observer, state => room.setWeather(state)); if (weather) { weather.update(new Date()); timers.push(setInterval(() => weather.update(new Date()), SERVER_LIMITS.weatherSampleMs)); } for (const timer of timers) timer.unref(); let closing: Promise | undefined; return { httpServer, io, room, close(): Promise { return closing ??= (async () => { timers.forEach(clearInterval); weather?.dispose(); await room.close(); await new Promise(resolve => io.close(() => resolve())); await options.visitStore.close(); })(); } }; }