feat: initialize sunrise with 100-player shared rooms
This commit is contained in:
+114
@@ -0,0 +1,114 @@
|
||||
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();
|
||||
})();
|
||||
} };
|
||||
}
|
||||
Reference in New Issue
Block a user