feedback5 R1: startServer and main become wiring — PORT / HOST / TRUST_PROXY and the option picks go to http/config.js, the session stack and the /ws upgrade to http/websocket.js, the request listener and /healthz to http/routes.js (new), the security headers and the clientError 400 to http/common.js, the banner, signals and entry check to http/boot.js
Same order of construction, listeners and headers as before; a scripted fingerprint (export list, 51 HTTP requests' status / header order / body hash, raw upgrade and malformed-request replies) and the process run (banner, port in use, SIGINT) match the pre-split server exactly. test/version.test.js reads the banner and /healthz from their new files; module headers describe each piece.
This commit is contained in:
1 parent
b48a67caec
commit
1b38e30819
11 files changed
+323
-202
No files matched your search
+33
-169
@@ -1,49 +1,34 @@
|
||||
// server/index.js — process entry & boot (DESIGN §1, §2).
|
||||
// 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 —
|
||||
//
|
||||
// * node:http static server: / → public/ (index.html for directories)
|
||||
// /data/ → data/ (generated game data)
|
||||
// /shared/ → shared/ (ESM shared with the browser)
|
||||
// /sim/ → server/sim/ (the battle simulation, read-only, `.js` only — client-side
|
||||
// combat, DESIGN §14; the Node-only loader nodeData.js is not served)
|
||||
// /data.js → a generated browser stand-in of server/data.js (the sim's content modules
|
||||
// import `../../../data.js`; in the browser it serves the data injected with
|
||||
// /sim/simdata.js setSimData). No other server file is ever served.
|
||||
// /media/bgm/act1 → public/assets/audio/bgm/act1.mp3 — the same audio files, addressed
|
||||
// **without** an extension so download managers (IDM / 迅雷 …) stop popping a
|
||||
// "下载文件信息" dialog for every BGM track (shared/media.js, public/js/media.js)
|
||||
// MIME types incl. .mjs/.js text/javascript, .skel application/octet-stream, .atlas text/plain;
|
||||
// gzip for text-like types, .skel and uncompressed fonts when the client accepts it (small files are
|
||||
// compressed once and cached in memory); strong ETag + Last-Modified with 304s; Cache-Control
|
||||
// (html & code/data: no-cache + revalidate; public/assets|fonts|vendor: 1 day; any `?v=` URL: immutable);
|
||||
// single byte-range requests (206/416, used by <audio>); traversal & dotfile protection; 404 page.
|
||||
// * GET /healthz → JSON status (protocol `version`, release `app`, rooms, matches, sessions, sockets).
|
||||
// * WebSocket (ws) at /ws, maxPayload 64 KB → server/net.js Network → server/lobby.js Lobby.
|
||||
// * Env: PORT (default 3000), HOST (default 0.0.0.0), TRUST_PROXY ('auto' default: honour CF-Connecting-IP /
|
||||
// X-Real-IP / X-Forwarded-For only from loopback/private peers such as a local cloudflared; '1' always; '0' never).
|
||||
// Prints LAN URLs on boot.
|
||||
// * 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).
|
||||
// * Graceful shutdown on SIGINT/SIGTERM (rooms get room.closed{reason:'shutdown'}, sockets close 1001).
|
||||
// http/config.js ROOT, the served directories, the environment (PORT 3000, HOST 0.0.0.0, 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
|
||||
// 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 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 fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { Network, SessionRegistry, NET_DEFAULTS } from './net.js';
|
||||
import { Lobby } from './lobby.js';
|
||||
import { getData, loadData } from './data.js';
|
||||
import { PROTOCOL_VERSION, APP_VERSION } from '../shared/constants.js';
|
||||
import { ROOT, parseTrustProxy, makeLogger } from './http/config.js';
|
||||
import { sendError, sendJson, splitUrl } from './http/common.js';
|
||||
import { MIME, COMPRESSIBLE, acceptsGzip, parseRange } from './http/files.js';
|
||||
import { ROOT, listenAddress, 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 { MIME, COMPRESSIBLE, acceptsGzip, parseRange } from './http/files.js';
|
||||
import { BUILD_INPUTS, computeBuildTag, buildTag, resetBuildTag } from './http/buildTag.js';
|
||||
import { WS_MAX_PAYLOAD } from './http/websocket.js';
|
||||
import { lanUrls } from './http/boot.js';
|
||||
import { createRequestHandler } from './http/routes.js';
|
||||
import { answerClientError } from './http/common.js';
|
||||
import { lanUrls, 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 {
|
||||
@@ -51,12 +36,6 @@ export {
|
||||
acceptsGzip, parseRange, createStaticHandler, lanUrls, parseTrustProxy,
|
||||
};
|
||||
|
||||
const MAX_URL_LENGTH = 4096;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// Server assembly
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build and start the HTTP + WebSocket server.
|
||||
* @param {{
|
||||
@@ -68,100 +47,27 @@ const MAX_URL_LENGTH = 4096;
|
||||
* 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: WebSocketServer,
|
||||
* lobby: Lobby, network: Network, registry: SessionRegistry, close: () => Promise<void> }>}
|
||||
* @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, close: () => Promise<void> }>}
|
||||
*/
|
||||
export async function startServer(opts = {}) {
|
||||
const port = opts.port ?? (process.env.PORT != null && process.env.PORT !== '' ? Number(process.env.PORT) : 3000);
|
||||
const host = opts.host ?? process.env.HOST ?? '0.0.0.0';
|
||||
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new RangeError(`invalid PORT ${port}`);
|
||||
const { port, host } = listenAddress(opts);
|
||||
const log = opts.log || makeLogger(!!opts.quiet);
|
||||
const publicDir = opts.publicDir || path.join(ROOT, 'public');
|
||||
const dataDir = opts.dataDir || path.join(ROOT, 'data');
|
||||
const sharedDir = opts.sharedDir || path.join(ROOT, 'shared');
|
||||
const { publicDir, dataDir, sharedDir } = 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 netOptions = {};
|
||||
for (const k of ['reconnectWindowMs', 'heartbeatMs', 'helloTimeoutMs', 'ratePerSec', 'rateBurst', 'maxConnections', 'abuseDropsPerSec',
|
||||
'maxConnectionsPerAddr', 'heavyPerSec', 'heavyBurst', 'trustProxy']) {
|
||||
if (opts[k] != null) netOptions[k] = opts[k];
|
||||
}
|
||||
if (netOptions.trustProxy == null) netOptions.trustProxy = parseTrustProxy(process.env.TRUST_PROXY);
|
||||
const registry = new SessionRegistry({ reconnectWindowMs: netOptions.reconnectWindowMs ?? NET_DEFAULTS.reconnectWindowMs });
|
||||
const lobbyOptions = {};
|
||||
for (const k of ['lobbyGraceMs', 'maxRooms', 'maxRoomsPerAddr', 'maxMatchesPerAddr', 'resyncMinGapMs', 'soloReconnectWindowMs']) {
|
||||
if (opts[k] != null) lobbyOptions[k] = opts[k];
|
||||
}
|
||||
const lobby = new Lobby({ registry, log, MatchClass: opts.MatchClass, getData: () => data, seedFn: opts.seedFn, options: lobbyOptions });
|
||||
const network = new Network({ registry, handler: lobby, log, options: netOptions });
|
||||
const { registry, lobby, network } = createSessionStack(opts, { data, log });
|
||||
const serveStatic = createStaticHandler({ publicDir, dataDir, sharedDir, 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((req, res) => {
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('Referrer-Policy', 'same-origin');
|
||||
handleRequest(req, res).catch((e) => {
|
||||
log.error('[http] request failed', e);
|
||||
sendError(req, res, 500, '服务器内部错误 · Internal error');
|
||||
});
|
||||
});
|
||||
|
||||
async function handleRequest(req, res) {
|
||||
const url = req.url || '/';
|
||||
if (url.length > MAX_URL_LENGTH) { sendError(req, res, 414, '请求地址过长 · URI too long'); return; }
|
||||
const parts = splitUrl(url);
|
||||
if (!parts) { sendError(req, res, 400, '请求地址无效 · Bad request'); return; }
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
res.setHeader('Allow', 'GET, HEAD');
|
||||
sendError(req, res, 405, '不支持的请求方法 · Method not allowed');
|
||||
return;
|
||||
}
|
||||
if (parts.rawPath === '/healthz') {
|
||||
sendJson(req, res, 200, {
|
||||
ok: true, version: PROTOCOL_VERSION, app: APP_VERSION, uptimeSec: Math.round((Date.now() - startedAt) / 1000),
|
||||
// the runtime the server is serving right now (public/js/ui/buildGuard.js): a page whose own build is
|
||||
// older than this reloads itself, so a deploy reaches clients that never reload
|
||||
build: buildTag(),
|
||||
sockets: network.connectionCount, sessions: registry.size, ...lobby.stats(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await serveStatic(req, res, parts.rawPath, parts.query);
|
||||
}
|
||||
|
||||
server.on('clientError', (err, socket) => {
|
||||
if (err && err.code === 'ECONNRESET') { socket.destroy(); return; }
|
||||
try {
|
||||
if (socket.writable) socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
|
||||
else socket.destroy();
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
const wss = new WebSocketServer({ noServer: true, maxPayload: WS_MAX_PAYLOAD, perMessageDeflate: false, clientTracking: false });
|
||||
wss.on('connection', (ws, req) => network.handleConnection(ws, req));
|
||||
wss.on('error', (e) => log.error('[ws] server error', e));
|
||||
|
||||
server.on('upgrade', (req, socket, head) => {
|
||||
socket.on('error', () => {});
|
||||
const parts = splitUrl(req.url || '/');
|
||||
const reject = (status, text) => {
|
||||
try { socket.end(`HTTP/1.1 ${status} ${text}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`); } catch { socket.destroy(); }
|
||||
};
|
||||
if (!parts || parts.rawPath !== '/ws') { reject(404, 'Not Found'); return; }
|
||||
const refused = network.admission(req);
|
||||
if (refused === 'per-address') { reject(429, 'Too Many Requests'); return; }
|
||||
if (refused) { reject(503, 'Service Unavailable'); return; }
|
||||
try {
|
||||
wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
|
||||
} catch (e) {
|
||||
log.error('[ws] upgrade failed', e);
|
||||
socket.destroy();
|
||||
}
|
||||
});
|
||||
const server = http.createServer(createRequestHandler({ serveStatic, health: { startedAt, network, registry, lobby }, log }));
|
||||
server.on('clientError', answerClientError);
|
||||
const wss = attachWebSocket(server, { network, log });
|
||||
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
@@ -200,47 +106,5 @@ export async function startServer(opts = {}) {
|
||||
return { port: actualPort, host, url, server, wss, lobby, network, registry, close };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
|
||||
function isMain() {
|
||||
if (!process.argv[1]) return false;
|
||||
try {
|
||||
return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
process.on('unhandledRejection', (e) => console.error('[process] unhandled rejection', e));
|
||||
process.on('uncaughtException', (e) => console.error('[process] uncaught exception', e));
|
||||
let srv;
|
||||
try {
|
||||
srv = await startServer();
|
||||
} catch (e) {
|
||||
if (e && e.code === 'EADDRINUSE') console.error(`端口已被占用 / port in use: ${e.port ?? process.env.PORT ?? 3000}. Try PORT=3001 npm start`);
|
||||
else console.error('[boot] failed to start', e);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`\n 卫戍协议:盟约 · Stronghold Protocol: Alliance v${APP_VERSION}`);
|
||||
console.log(` Local: ${srv.url}`);
|
||||
if (srv.host === '0.0.0.0' || srv.host === '::') {
|
||||
for (const u of lanUrls(srv.port)) console.log(` LAN: ${u}`);
|
||||
}
|
||||
console.log(' Internet: cloudflared tunnel --url ' + `http://localhost:${srv.port}` + '\n');
|
||||
|
||||
let stopping = false;
|
||||
const stop = (signal) => {
|
||||
if (stopping) { console.log('forced exit'); process.exit(1); }
|
||||
stopping = true;
|
||||
console.log(`\n[${signal}] shutting down…`);
|
||||
setTimeout(() => process.exit(0), 5000).unref();
|
||||
srv.close().then(() => process.exit(0), () => process.exit(1));
|
||||
};
|
||||
process.on('SIGINT', () => stop('SIGINT'));
|
||||
process.on('SIGTERM', () => stop('SIGTERM'));
|
||||
}
|
||||
|
||||
if (isMain()) main();
|
||||
// `node server/index.js` / npm start: listen, print the banner, stop on SIGINT / SIGTERM (http/boot.js).
|
||||
if (isProcessEntry(import.meta.url)) runMain(startServer);
|
||||
Reference in new issue
Block a user