Ported only the dual-stack listen from PR #291 (the change accepted as #188). With HOST unset the server binds :: and, if that fails with EAFNOSUPPORT, EADDRNOTAVAIL or EINVAL, retries 0.0.0.0. An explicit HOST, including 0.0.0.0 and 127.0.0.1, is tried once. The boot banner and doctor list shareable IPv6 addresses in brackets, skip fe80::, and keep one URL per /64. An explicit IPv6 literal in the local URL is bracketed. The Windows service listing no longer drops those bracketed lines. Not ported: /dev/grant, the settings-default migration, and the firewall scripts. The Docker image stays on 0.0.0.0; the alpine fallback was not booted. Tests: node --test test/ipv6-bind.test.js test/doctor.test.js test/ci.test.js — 20 pass, 0 fail (8 bind, 4 doctor, 8 ci).
140 lines
7.7 KiB
JavaScript
140 lines
7.7 KiB
JavaScript
// server/index.js — process entry & boot (DESIGN §1, §2). Plain node:http + ws, no framework: startServer() below
|
|
// wires the modules under server/http/, in this order —
|
|
//
|
|
// http/config.js ROOT, the served directories, the environment (PORT 3000, HOST '::' dual-stack, TRUST_PROXY auto, DEBUG),
|
|
// which startServer() options go to net.js / lobby.js, the console logger
|
|
// http/websocket.js session wiring (SessionRegistry → Lobby → Network) and the WebSocket at /ws (maxPayload 64 KB;
|
|
// refused at upgrade with 404 / 429 per network / 503)
|
|
// http/static.js the static mounts (/ → public/, /data/, /shared/, /sim/ `.js` only), the /data.js browser stand-in,
|
|
// the content packs (/packs/index.json, /packs/<id>/<file> — the registry is packs.js)
|
|
// http/media.js /media/bgm/act1 → public/assets/audio/bgm/act1.mp3 (audio addressed without its extension)
|
|
// http/files.js one file → response: MIME, gzip + memory cache, ETag / Last-Modified / 304, Cache-Control, ranges
|
|
// http/buildTag.js the build tag of the served browser runtime (/healthz `build`, public/js/ui/buildGuard.js)
|
|
// http/routes.js the request listener: security headers, 414 / 400 / 405, GET /healthz → JSON status, else static
|
|
// http/common.js what every answer shares: security headers, URL split, error page, JSON replies, bare 400
|
|
// http/boot.js a pending update package first (update.js: old files deleted, the install verified against
|
|
// MANIFEST.json), banner (Local / LAN / tunnel URLs), port-in-use hint, graceful shutdown on SIGINT /
|
|
// SIGTERM
|
|
//
|
|
// Per-network limits for internet clients (see net.js clientAddress; local/LAN peers are exempt): open sockets
|
|
// (maxConnectionsPerAddr, refused at upgrade with 429), rooms and running matches (lobby.js).
|
|
//
|
|
// Programmatic use (tests): `const srv = await startServer({ port: 0, quiet: true }); … await srv.close();`
|
|
// The server only auto-listens when this file is the process entry point.
|
|
|
|
import http from 'node:http';
|
|
import { getData, loadData } from './data.js';
|
|
import { ROOT, listenAddress, bindCandidates, serveDirs, makeLogger, parseTrustProxy } from './http/config.js';
|
|
import { WS_MAX_PAYLOAD, createSessionStack, attachWebSocket } from './http/websocket.js';
|
|
import { DATA_SHIM_JS, createStaticHandler } from './http/static.js';
|
|
import { createPackRegistry } from './packs.js';
|
|
import { MIME, COMPRESSIBLE, acceptsGzip, parseRange } from './http/files.js';
|
|
import { BUILD_INPUTS, computeBuildTag, buildTag, resetBuildTag } from './http/buildTag.js';
|
|
import { createRequestHandler } from './http/routes.js';
|
|
import { answerClientError } from './http/common.js';
|
|
import { lanUrls, displayHost, isProcessEntry, runMain } from './http/boot.js';
|
|
|
|
// The public API of this module (tests and tools import it from here); the code lives in ./http/.
|
|
export {
|
|
ROOT, WS_MAX_PAYLOAD, DATA_SHIM_JS, MIME, COMPRESSIBLE, BUILD_INPUTS, computeBuildTag, buildTag, resetBuildTag,
|
|
acceptsGzip, parseRange, createStaticHandler, lanUrls, parseTrustProxy,
|
|
};
|
|
|
|
/**
|
|
* Build and start the HTTP + WebSocket server.
|
|
* @param {{
|
|
* port?: number, host?: string, quiet?: boolean, log?: object,
|
|
* publicDir?: string, dataDir?: string, sharedDir?: string, packsDir?: string,
|
|
* MatchClass?: Function, seedFn?: () => number,
|
|
* lobbyGraceMs?: number, reconnectWindowMs?: number, heartbeatMs?: number, helloTimeoutMs?: number,
|
|
* ratePerSec?: number, rateBurst?: number, maxConnections?: number, maxRooms?: number,
|
|
* maxConnectionsPerAddr?: number, maxRoomsPerAddr?: number, maxMatchesPerAddr?: number, resyncMinGapMs?: number,
|
|
* heavyPerSec?: number, heavyBurst?: number, trustProxy?: 'auto' | boolean, soloReconnectWindowMs?: number,
|
|
* }} [opts]
|
|
* @returns {Promise<{ port: number, host: string, url: string, server: http.Server, wss: import('ws').WebSocketServer,
|
|
* lobby: import('./lobby.js').Lobby, network: import('./net.js').Network,
|
|
* registry: import('./net.js').SessionRegistry, packs: ReturnType<typeof createPackRegistry>,
|
|
* close: () => Promise<void> }>}
|
|
*/
|
|
export async function startServer(opts = {}) {
|
|
const { port, host } = listenAddress(opts);
|
|
const log = opts.log || makeLogger(!!opts.quiet);
|
|
const { publicDir, dataDir, sharedDir, packsDir } = serveDirs(opts);
|
|
|
|
// The process-wide singleton serves the default data dir; a custom dir (tests) gets its own copy.
|
|
const data = opts.dataDir ? loadData(dataDir, { log }) : getData({ dir: dataDir, log });
|
|
const { registry, lobby, network } = createSessionStack(opts, { data, log });
|
|
// content packs (docs/PACKS.md): scanned now — the start log names them — and again whenever their folders change
|
|
const packs = createPackRegistry({ publicDir, dataDir, packsDir }, { log });
|
|
packs.refresh(true);
|
|
const serveStatic = createStaticHandler({ publicDir, dataDir, sharedDir, packsDir, packs, log });
|
|
const startedAt = Date.now();
|
|
// The tag is per process (see buildTag): read the browser runtime once, here, not on every /healthz.
|
|
resetBuildTag();
|
|
buildTag();
|
|
|
|
const server = http.createServer(createRequestHandler({ serveStatic, health: { startedAt, network, registry, lobby }, log }));
|
|
server.on('clientError', answerClientError);
|
|
const wss = attachWebSocket(server, { network, log });
|
|
|
|
// The address actually bound. The default may fall back to IPv4; the returned host and url follow that.
|
|
let boundHost;
|
|
try {
|
|
// A host with IPv6 switched off refuses '::'. Fall back to IPv4 rather than not booting. Only the default is
|
|
// retried: an explicit HOST is literal (server/http/config.js bindCandidates).
|
|
let bound = null;
|
|
let lastError = null;
|
|
const candidates = bindCandidates(host);
|
|
for (let i = 0; i < candidates.length; i++) {
|
|
const candidate = candidates[i];
|
|
try {
|
|
await new Promise((resolve, reject) => {
|
|
const onError = (e) => { server.off('listening', onListening); reject(e); };
|
|
const onListening = () => { server.off('error', onError); resolve(); };
|
|
server.once('error', onError);
|
|
server.once('listening', onListening);
|
|
server.listen(port, candidate);
|
|
});
|
|
bound = candidate;
|
|
break;
|
|
} catch (e) {
|
|
lastError = e;
|
|
const retry = ['EAFNOSUPPORT', 'EADDRNOTAVAIL', 'EINVAL'].includes(e.code) && i < candidates.length - 1;
|
|
if (!retry) break;
|
|
log.warn(`[boot] cannot bind ${candidate} (${e.code}) — falling back to IPv4 only`);
|
|
}
|
|
}
|
|
if (bound === null) throw lastError;
|
|
boundHost = bound;
|
|
} catch (e) {
|
|
network.close(); // stop heartbeat/sweep timers of the half-built server
|
|
throw e;
|
|
}
|
|
server.on('error', (e) => log.error('[http] server error', e));
|
|
|
|
const addr = server.address();
|
|
const actualPort = typeof addr === 'object' && addr ? addr.port : port;
|
|
const url = `http://${displayHost(boundHost)}:${actualPort}`;
|
|
|
|
let closing = null;
|
|
async function close() {
|
|
if (closing) return closing;
|
|
closing = (async () => {
|
|
try { lobby.shutdown('shutdown'); } catch (e) { log.error('[shutdown] lobby', e); }
|
|
network.close();
|
|
await new Promise((resolve) => {
|
|
server.close(() => resolve());
|
|
server.closeIdleConnections?.();
|
|
setTimeout(() => { server.closeAllConnections?.(); }, 500).unref();
|
|
});
|
|
try { wss.close(); } catch { /* ignore */ }
|
|
})();
|
|
return closing;
|
|
}
|
|
|
|
return { port: actualPort, host: boundHost, url, server, wss, lobby, network, registry, packs, close };
|
|
}
|
|
|
|
// `node server/index.js` / npm start: listen, print the banner, stop on SIGINT / SIGTERM (http/boot.js).
|
|
if (isProcessEntry(import.meta.url)) runMain(startServer);
|