feat: initialize sunrise with 100-player shared rooms

This commit is contained in:
2026-10-30 16:36:35 +09:00
commit 31d6eac4c2
257 changed files with 66063 additions and 0 deletions
+114
View File
@@ -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();
})();
} };
}