115 lines
6.4 KiB
TypeScript
115 lines
6.4 KiB
TypeScript
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<string, string> = { '.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<ClientEvents, ServerEvents>(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<void> | undefined;
|
|
return { httpServer, io, room, close(): Promise<void> {
|
|
return closing ??= (async () => {
|
|
timers.forEach(clearInterval); weather?.dispose();
|
|
await room.close();
|
|
await new Promise<void>(resolve => io.close(() => resolve()));
|
|
await options.visitStore.close();
|
|
})();
|
|
} };
|
|
}
|