feedback5: merge R1 — server/index.js split into server/http/* modules on plain node:http (same exports, headers and responses)
This commit is contained in:
commit
aef8caef3f
21 files changed
+938
-732
No files matched your search
@@ -0,0 +1,7 @@
|
||||
# Commits that only move code. Blame across them with
|
||||
# git blame -C -C --ignore-revs-file .git-blame-ignore-revs <file>
|
||||
# (or once: git config blame.ignoreRevsFile .git-blame-ignore-revs; -C -C follows lines into the new files of a split).
|
||||
# Pure moves only (0.2.0 refactor plan, rule 3): a commit listed here changes nothing but where the code lives.
|
||||
|
||||
# feedback5 R1: server/index.js top-level code → server/http/ (config, common, files, static, media, buildTag, websocket, boot)
|
||||
b48a67caec5ba0699097e21f3db4d4dbb35a2aed
|
||||
@@ -195,7 +195,7 @@ GOLDEN_FULL=1 node --test test/golden.test.js # 黄金结果:固定
|
||||
|
||||
| 路径 | 内容 |
|
||||
|---|---|
|
||||
| `server/` | Node HTTP 静态服务 + WebSocket(`/ws`)、大厅、对局引擎(`match/`)、战斗模拟(`sim/`,浏览器与服务器共用) |
|
||||
| `server/` | 入口 `index.js`;Node HTTP 静态服务 + WebSocket(`/ws`,代码在 `http/`)、大厅、对局引擎(`match/`)、战斗模拟(`sim/`,浏览器与服务器共用) |
|
||||
| `shared/` | 前后端共用的常量与网络协议 |
|
||||
| `public/` | 浏览器客户端(原生 ES 模块,PixiJS + pixi-spine、three.js 3D 棋盘、Preact + htm UI) |
|
||||
| `data/` | 由官方数据表生成的游戏数据与素材清单 `assets.json` |
|
||||
|
||||
+11
-1
@@ -41,7 +41,17 @@ Run: `npm install && npm run assets && npm start` → `http://localhost:3000`. F
|
||||
|
||||
```
|
||||
server/
|
||||
index.js HTTP static server (gzip for .skel/.atlas/.json/.js/.css), WebSocket upgrade at /ws, boot
|
||||
index.js process entry: startServer() wires server/http/ (plain node:http + ws); boots when run directly
|
||||
http/
|
||||
config.js ROOT, served dirs, env (PORT, HOST, TRUST_PROXY, DEBUG), options handed to net.js / lobby.js
|
||||
websocket.js session wiring (SessionRegistry → Lobby → Network), WebSocket upgrade at /ws (64 KB frames)
|
||||
static.js mounts / → public, /data/, /shared/, /sim/ (.js only), the /data.js browser stand-in, path guards
|
||||
media.js /media/… extension-less audio → public/assets/audio
|
||||
files.js one file → response: MIME, gzip (.skel/.atlas/.json/.js/.css …), ETag / 304, cache policy, ranges
|
||||
buildTag.js build tag of the served browser runtime (/healthz build, public/js/ui/buildGuard.js)
|
||||
routes.js request listener: 414 / 400 / 405 guards, GET /healthz, then static files
|
||||
common.js security headers, URL split, error page, JSON replies, bare 400 for unparseable requests
|
||||
boot.js boot banner (Local / LAN / tunnel URLs), port-in-use hint, graceful SIGINT / SIGTERM
|
||||
net.js session registry, send helpers, per-connection rate limit, message validation (uses shared/protocol.js)
|
||||
lobby.js rooms (4-letter codes), seats, host, AI seats, ready/start, reconnect tokens, room→Match wiring
|
||||
data.js loads data/*.json once, builds indexes (getChess, getBond, …); frozen objects
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
//
|
||||
// /assets/audio/bgm/act1.mp3 → /media/bgm/act1
|
||||
//
|
||||
// `server/index.js` resolves /media/… back to the real file under public/assets/audio and still answers with
|
||||
// `server/http/media.js` resolves /media/… back to the real file under public/assets/audio and still answers with
|
||||
// `Content-Type: audio/mpeg` + range support; Web Audio sniffs the container, so the URL is all the same to it.
|
||||
// A host that does not implement /media/ keeps working: `audio.js` falls back to the original URL whenever the
|
||||
// /media/ response is unusable (a 404, or a 200 that is not audio at all — some static hosts answer a missing
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// reload. A client-only battle fix shipped exactly that way and stayed invisible on the reporting player's page: the
|
||||
// nginx log shows the page loaded the PRE-fix module blob and made no module request at all for the rest of the session.
|
||||
//
|
||||
// HOW: the server stamps a short hash of the runtime it serves into `/healthz.build` (server/index.js computeBuildTag).
|
||||
// HOW: the server stamps a short hash of the runtime it serves into `/healthz.build` (server/http/buildTag.js).
|
||||
// The first successful check of a page records that tag as "the build this page runs" — IN MEMORY, because one page is
|
||||
// one set of loaded modules: nothing must survive a reload, and a duplicated tab must not inherit the first tab's tag.
|
||||
// A later check reporting a DIFFERENT tag means the server changed under this page:
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// server/http/boot.js — running the server as a process (`node server/index.js`, npm start, scripts/launch.mjs):
|
||||
//
|
||||
// * the boot banner: release version, the Local URL, the LAN URLs when listening on every interface, the
|
||||
// cloudflared command for internet play;
|
||||
// * a server that cannot start exits 1 (with a hint when the port is in use); unhandled rejections and uncaught
|
||||
// exceptions are logged, not fatal;
|
||||
// * graceful shutdown on SIGINT/SIGTERM (rooms get room.closed{reason:'shutdown'}, sockets close 1001); a second
|
||||
// signal exits at once, and the process exits anyway 5 s after the first.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { APP_VERSION } from '../../shared/constants.js';
|
||||
|
||||
/** Non-internal IPv4 addresses as http URLs. @param {number} port */
|
||||
export function lanUrls(port) {
|
||||
const out = [];
|
||||
for (const addrs of Object.values(os.networkInterfaces())) {
|
||||
for (const a of addrs || []) {
|
||||
if ((a.family === 'IPv4' || a.family === 4) && !a.internal) out.push(`http://${a.address}:${port}`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Is the module whose `import.meta.url` is `metaUrl` the file node was started with? */
|
||||
export function isProcessEntry(metaUrl) {
|
||||
if (!process.argv[1]) return false;
|
||||
try {
|
||||
return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(metaUrl));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Print the boot banner of a started server. @param {{ url: string, host: string, port: number }} srv */
|
||||
export function printBanner(srv) {
|
||||
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');
|
||||
}
|
||||
|
||||
/**
|
||||
* The process main: start the server, print the banner, stop gracefully on SIGINT / SIGTERM.
|
||||
* @param {() => Promise<{ url: string, host: string, port: number, close: () => Promise<void> }>} start index.js startServer
|
||||
*/
|
||||
export async function runMain(start) {
|
||||
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 start();
|
||||
} 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);
|
||||
}
|
||||
printBanner(srv);
|
||||
|
||||
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'));
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// server/http/buildTag.js — the build tag of the browser runtime this process serves (/healthz `build`).
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { ROOT } from './config.js';
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// build tag — the "your page is stale" signal (public/js/ui/buildGuard.js)
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The files that make up the runtime the BROWSER loads. A change in any of them is a new build: an already-open page
|
||||
* keeps the modules it imported at load time (ES modules live in the page's module map for its whole lifetime), so
|
||||
* without this signal a deployed fix could never reach a player who does not reload — a client-only battle fix
|
||||
* shipped exactly that way and stayed invisible on a page that had been opened before the deploy.
|
||||
*
|
||||
* `server/`, `data/` and `shared/` are deliberately NOT in here: this process read them once at startup, so when they
|
||||
* change without a restart the server still runs the old simulation and data — a page that reloaded into the new files
|
||||
* would be out of step with the server that validates its battles (and DEPLOY.md restarts the server for every update).
|
||||
*/
|
||||
export const BUILD_INPUTS = Object.freeze(['public/index.html', 'public/js', 'public/css']);
|
||||
|
||||
/** Names the static server never serves: dot files (`.DS_Store`, `.main.js.swp`) and editor backups (`main.js~`). */
|
||||
const isIgnoredBuildName = (name) => name.startsWith('.') || name.endsWith('~');
|
||||
|
||||
/** @type {{ tag: string|null }|null} */
|
||||
let buildCache = null;
|
||||
|
||||
/** Every file under `abs` (or `abs` itself), as `[relative path, size, mtimeMs]`, sorted by path. Missing → []. */
|
||||
function buildEntries(abs, rel, out) {
|
||||
let stat;
|
||||
try { stat = fs.statSync(abs); } catch { return; }
|
||||
if (stat.isFile()) { out.push([rel, stat.size, stat.mtimeMs]); return; }
|
||||
if (!stat.isDirectory()) return;
|
||||
let names;
|
||||
try { names = fs.readdirSync(abs, { withFileTypes: true }); } catch { return; }
|
||||
for (const d of names.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
|
||||
if (isIgnoredBuildName(d.name)) continue;
|
||||
const child = path.join(abs, d.name);
|
||||
const childRel = rel ? `${rel}/${d.name}` : d.name;
|
||||
if (d.isDirectory()) buildEntries(child, childRel, out);
|
||||
else if (d.isFile()) { try { const s = fs.statSync(child); out.push([childRel, s.size, s.mtimeMs]); } catch { /* ignore */ } }
|
||||
}
|
||||
}
|
||||
|
||||
/** Short hash of the served browser runtime (size + mtime of every BUILD_INPUTS file); null when nothing is readable. */
|
||||
export function computeBuildTag(root = ROOT) {
|
||||
const out = [];
|
||||
for (const rel of BUILD_INPUTS) buildEntries(path.join(root, rel), rel, out);
|
||||
if (!out.length) return null;
|
||||
out.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
||||
const h = createHash('sha1');
|
||||
for (const [rel, size, mtime] of out) h.update(`${rel}\0${size}\0${Math.floor(mtime)}\n`);
|
||||
return h.digest('hex').slice(0, 12);
|
||||
}
|
||||
|
||||
/**
|
||||
* The build tag of THIS process. Computed once (`startServer` warms it at startup): the tag describes the files the
|
||||
* process is actually serving, every update restarts the server (DEPLOY.md), and re-reading the tree on a timer would
|
||||
* let a half-finished deploy — or a file that changed while the process kept running — move the tag under a page.
|
||||
* @param {string} [root] used by the first call only (tests)
|
||||
*/
|
||||
export function buildTag(root = ROOT) {
|
||||
if (buildCache === null) buildCache = { tag: computeBuildTag(root) };
|
||||
return buildCache.tag;
|
||||
}
|
||||
|
||||
/** Drop the cache: the next `buildTag()` re-reads the tree (tests, and `startServer`). */
|
||||
export function resetBuildTag() { buildCache = null; }
|
||||
@@ -0,0 +1,56 @@
|
||||
// server/http/common.js — what every HTTP answer shares:
|
||||
//
|
||||
// * the security headers, set on every response before any route runs (routes.js);
|
||||
// * splitUrl (raw path + query, also from absolute-form URLs), the bilingual error page (sendError) and JSON replies
|
||||
// (sendJson) — both `Cache-Control: no-store`, without a body for HEAD;
|
||||
// * the bare `400 Bad Request` for a request node:http cannot parse (answerClientError).
|
||||
|
||||
/** Headers on every HTTP response. @param {import('node:http').ServerResponse} res */
|
||||
export function setSecurityHeaders(res) {
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('Referrer-Policy', 'same-origin');
|
||||
}
|
||||
|
||||
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
||||
|
||||
function errorPage(status, title, detail = '') {
|
||||
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>${status} · 卫戍协议:盟约</title><style>
|
||||
:root{color-scheme:dark}body{margin:0;min-height:100vh;display:grid;place-items:center;background:#111614;color:#d8e3de;font:16px/1.6 "Noto Sans SC",system-ui,sans-serif}
|
||||
main{border:1px solid #2c3a35;padding:32px 40px;max-width:520px;text-align:center}h1{margin:0;color:#4ed8af;font-size:56px;letter-spacing:4px}
|
||||
p{margin:8px 0}a{color:#4ed8af}</style></head><body><main><h1>${status}</h1><p>${escapeHtml(title)}</p>
|
||||
${detail ? `<p style="opacity:.6">${escapeHtml(detail)}</p>` : ''}<p><a href="/">返回首页 · Back to home</a></p></main></body></html>`;
|
||||
}
|
||||
|
||||
export function sendError(req, res, status, title, detail) {
|
||||
if (res.headersSent) { res.destroy(); return; }
|
||||
const body = Buffer.from(errorPage(status, title, detail));
|
||||
res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Length': body.length, 'Cache-Control': 'no-store' });
|
||||
res.end(req.method === 'HEAD' ? undefined : body);
|
||||
}
|
||||
|
||||
export function sendJson(req, res, status, obj) {
|
||||
const body = Buffer.from(JSON.stringify(obj));
|
||||
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': body.length, 'Cache-Control': 'no-store' });
|
||||
res.end(req.method === 'HEAD' ? undefined : body);
|
||||
}
|
||||
|
||||
/** Split an absolute request URL into raw path + query (also accepts absolute-form URLs). */
|
||||
export function splitUrl(url) {
|
||||
let u = url || '/';
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(u)) {
|
||||
try { const parsed = new URL(u); u = parsed.pathname + parsed.search; } catch { return null; }
|
||||
}
|
||||
const q = u.indexOf('?');
|
||||
const hashless = (s) => { const h = s.indexOf('#'); return h >= 0 ? s.slice(0, h) : s; };
|
||||
return q >= 0 ? { rawPath: hashless(u.slice(0, q)), query: hashless(u.slice(q + 1)) } : { rawPath: hashless(u), query: '' };
|
||||
}
|
||||
|
||||
/** node:http 'clientError' listener: a reset socket is dropped, any other unparseable request gets a bare 400. */
|
||||
export function answerClientError(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 */ }
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// server/http/config.js — where the server's settings come from. startServer() options win over the environment:
|
||||
//
|
||||
// * 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) → net.js trustProxy;
|
||||
// * DEBUG → the console logger's debug level;
|
||||
// * the served directories (public/, data/, shared/ of this repository unless the options name others), and which
|
||||
// startServer() options are handed on to net.js Network and lobby.js Lobby.
|
||||
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/** Repository root. */
|
||||
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
|
||||
export const noopLog = { info() {}, warn() {}, error() {}, debug() {} };
|
||||
|
||||
/** startServer() options handed on to net.js Network / lobby.js Lobby (an absent one keeps that module's default). */
|
||||
const NET_OPTION_KEYS = ['reconnectWindowMs', 'heartbeatMs', 'helloTimeoutMs', 'ratePerSec', 'rateBurst', 'maxConnections', 'abuseDropsPerSec',
|
||||
'maxConnectionsPerAddr', 'heavyPerSec', 'heavyBurst', 'trustProxy'];
|
||||
const LOBBY_OPTION_KEYS = ['lobbyGraceMs', 'maxRooms', 'maxRoomsPerAddr', 'maxMatchesPerAddr', 'resyncMinGapMs', 'soloReconnectWindowMs'];
|
||||
|
||||
/**
|
||||
* Where to listen: the `port` / `host` options, else PORT / HOST, else port 3000 on 0.0.0.0.
|
||||
* @param {{ port?: number, host?: string }} opts
|
||||
* @returns {{ port: number, host: string }}
|
||||
* @throws {RangeError} when the port is not an integer in 0…65535
|
||||
*/
|
||||
export function listenAddress(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}`);
|
||||
return { port, host };
|
||||
}
|
||||
|
||||
/**
|
||||
* The directories the static server reads.
|
||||
* @param {{ publicDir?: string, dataDir?: string, sharedDir?: string }} opts
|
||||
*/
|
||||
export function serveDirs(opts) {
|
||||
return {
|
||||
publicDir: opts.publicDir || path.join(ROOT, 'public'),
|
||||
dataDir: opts.dataDir || path.join(ROOT, 'data'),
|
||||
sharedDir: opts.sharedDir || path.join(ROOT, 'shared'),
|
||||
};
|
||||
}
|
||||
|
||||
/** net.js Network options out of the startServer() options; `trustProxy` falls back to TRUST_PROXY. */
|
||||
export function netOptionsFrom(opts) {
|
||||
const netOptions = {};
|
||||
for (const k of NET_OPTION_KEYS) {
|
||||
if (opts[k] != null) netOptions[k] = opts[k];
|
||||
}
|
||||
if (netOptions.trustProxy == null) netOptions.trustProxy = parseTrustProxy(process.env.TRUST_PROXY);
|
||||
return netOptions;
|
||||
}
|
||||
|
||||
/** lobby.js Lobby options out of the startServer() options. */
|
||||
export function lobbyOptionsFrom(opts) {
|
||||
const lobbyOptions = {};
|
||||
for (const k of LOBBY_OPTION_KEYS) {
|
||||
if (opts[k] != null) lobbyOptions[k] = opts[k];
|
||||
}
|
||||
return lobbyOptions;
|
||||
}
|
||||
|
||||
/** TRUST_PROXY env → net.js trustProxy ('auto' unless explicitly on/off). @param {string | undefined} v */
|
||||
export function parseTrustProxy(v) {
|
||||
const s = String(v ?? '').trim().toLowerCase();
|
||||
if (['1', 'true', 'yes', 'on', 'always'].includes(s)) return true;
|
||||
if (['0', 'false', 'no', 'off', 'never'].includes(s)) return false;
|
||||
return 'auto';
|
||||
}
|
||||
|
||||
/** The console logger (`quiet` → silent; debug lines only with DEBUG set). */
|
||||
export function makeLogger(quiet) {
|
||||
if (quiet) return noopLog;
|
||||
return {
|
||||
info: (...a) => console.log(...a),
|
||||
warn: (...a) => console.warn(...a),
|
||||
error: (...a) => console.error(...a),
|
||||
debug: process.env.DEBUG ? (...a) => console.debug(...a) : () => {},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
// server/http/files.js — one file on disk → an HTTP response (static.js and media.js decide which file):
|
||||
// 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>).
|
||||
|
||||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import zlib from 'node:zlib';
|
||||
import { promisify } from 'node:util';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
|
||||
/** Extension → Content-Type. */
|
||||
export const MIME = Object.freeze({
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.htm': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.mjs': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.map': 'application/json; charset=utf-8',
|
||||
'.webmanifest': 'application/manifest+json; charset=utf-8',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.md': 'text/markdown; charset=utf-8',
|
||||
'.csv': 'text/csv; charset=utf-8',
|
||||
'.xml': 'application/xml; charset=utf-8',
|
||||
'.atlas': 'text/plain; charset=utf-8',
|
||||
'.skel': 'application/octet-stream',
|
||||
'.bin': 'application/octet-stream',
|
||||
'.wasm': 'application/wasm',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.avif': 'image/avif',
|
||||
'.svg': 'image/svg+xml; charset=utf-8',
|
||||
'.ico': 'image/x-icon',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.ogg': 'audio/ogg',
|
||||
'.oga': 'audio/ogg',
|
||||
'.opus': 'audio/ogg',
|
||||
'.wav': 'audio/wav',
|
||||
'.m4a': 'audio/mp4',
|
||||
'.aac': 'audio/aac',
|
||||
'.webm': 'video/webm',
|
||||
'.mp4': 'video/mp4',
|
||||
'.woff2': 'font/woff2',
|
||||
'.woff': 'font/woff',
|
||||
'.otf': 'font/otf',
|
||||
'.ttf': 'font/ttf',
|
||||
});
|
||||
|
||||
/** Extensions worth gzipping (text-like, .skel, uncompressed fonts). */
|
||||
export const COMPRESSIBLE = new Set([
|
||||
'.html', '.htm', '.js', '.mjs', '.css', '.json', '.map', '.webmanifest', '.txt', '.md', '.csv', '.xml',
|
||||
'.atlas', '.skel', '.bin', '.wasm', '.svg', '.ico', '.otf', '.ttf', '.wav',
|
||||
]);
|
||||
|
||||
const GZIP_MIN_BYTES = 512;
|
||||
const GZIP_CACHE_MAX_FILE = 8 << 20; // larger files are gzip-streamed on the fly
|
||||
const GZIP_CACHE_MAX_TOTAL = 96 << 20;
|
||||
// Asset URLs carry no content hash yet, and tools/fetch-assets.mjs / tools/vendor.mjs can rewrite files in
|
||||
// place (atlas + png + skel must stay consistent), so "long" is one day; revalidation after that is a cheap 304.
|
||||
const LONG_CACHE = 'public, max-age=86400'; // 1 day
|
||||
const IMMUTABLE_CACHE = 'public, max-age=31536000, immutable';
|
||||
const LONG_CACHE_DIRS = ['assets', 'fonts', 'vendor']; // first path segment under public/
|
||||
|
||||
const gzipAsync = promisify(zlib.gzip);
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// gzip cache (LRU by bytes)
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
|
||||
export class GzipCache {
|
||||
constructor(maxTotal = GZIP_CACHE_MAX_TOTAL) {
|
||||
this.maxTotal = maxTotal;
|
||||
this.total = 0;
|
||||
/** @type {Map<string, Buffer>} */ this.map = new Map();
|
||||
/** @type {Map<string, Promise<Buffer>>} */ this.inflight = new Map();
|
||||
}
|
||||
|
||||
/** @returns {Promise<Buffer>} gzip of the file identified by (path, size, mtime) */
|
||||
get(absPath, stat) {
|
||||
const key = `${absPath}\0${stat.size}\0${stat.mtimeMs}`;
|
||||
const hit = this.map.get(key);
|
||||
if (hit) { this.map.delete(key); this.map.set(key, hit); return Promise.resolve(hit); }
|
||||
const pending = this.inflight.get(key);
|
||||
if (pending) return pending;
|
||||
const p = (async () => {
|
||||
const raw = await fsp.readFile(absPath);
|
||||
const gz = await gzipAsync(raw, { level: 6 });
|
||||
this.store(key, gz);
|
||||
return gz;
|
||||
})().finally(() => this.inflight.delete(key));
|
||||
this.inflight.set(key, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
store(key, buf) {
|
||||
if (buf.length > this.maxTotal) return;
|
||||
this.map.set(key, buf);
|
||||
this.total += buf.length;
|
||||
for (const [k, v] of this.map) {
|
||||
if (this.total <= this.maxTotal) break;
|
||||
this.map.delete(k);
|
||||
this.total -= v.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// HTTP helpers
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
|
||||
/** Does the Accept-Encoding header allow gzip (q > 0)? @param {string | undefined} header */
|
||||
export function acceptsGzip(header) {
|
||||
if (!header || typeof header !== 'string') return false;
|
||||
let gzipQ = null;
|
||||
let starQ = null;
|
||||
for (const part of header.split(',')) {
|
||||
const [token, ...params] = part.trim().toLowerCase().split(';');
|
||||
let q = 1;
|
||||
for (const p of params) {
|
||||
const m = /^\s*q=([0-9.]+)\s*$/.exec(p);
|
||||
if (m) q = Number(m[1]);
|
||||
}
|
||||
if (!Number.isFinite(q)) q = 0;
|
||||
if (token === 'gzip' || token === 'x-gzip') gzipQ = q;
|
||||
else if (token === '*') starQ = q;
|
||||
}
|
||||
if (gzipQ != null) return gzipQ > 0;
|
||||
return starQ != null && starQ > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single `bytes=` range against a file size.
|
||||
* @returns {{ start: number, end: number } | 'unsatisfiable' | null} null = ignore header (serve 200)
|
||||
*/
|
||||
export function parseRange(header, size) {
|
||||
if (typeof header !== 'string') return null;
|
||||
const m = /^\s*bytes\s*=\s*(\d*)\s*-\s*(\d*)\s*$/i.exec(header);
|
||||
if (!m) return null; // multi-range or malformed → ignore (RFC 9110 permits serving the full body)
|
||||
const [, a, b] = m;
|
||||
if (a === '' && b === '') return null;
|
||||
if (a === '') {
|
||||
const suffix = Number(b);
|
||||
if (suffix === 0 || size === 0) return 'unsatisfiable';
|
||||
return { start: Math.max(0, size - suffix), end: size - 1 };
|
||||
}
|
||||
const start = Number(a);
|
||||
const end = b === '' ? size - 1 : Math.min(Number(b), size - 1);
|
||||
if (b !== '' && Number(b) < start) return null;
|
||||
if (start >= size) return 'unsatisfiable';
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
const stripWeak = (tag) => tag.trim().replace(/^W\//, '');
|
||||
|
||||
/** Conditional GET check (If-None-Match wins over If-Modified-Since). */
|
||||
export function isNotModified(req, etag, mtime) {
|
||||
const inm = req.headers['if-none-match'];
|
||||
if (typeof inm === 'string') {
|
||||
if (inm.trim() === '*') return true;
|
||||
return inm.split(',').some((t) => stripWeak(t) === etag);
|
||||
}
|
||||
const ims = req.headers['if-modified-since'];
|
||||
if (typeof ims === 'string') {
|
||||
const t = Date.parse(ims);
|
||||
if (Number.isFinite(t)) return Math.floor(mtime.getTime() / 1000) * 1000 <= t;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** If-Range: serve the range only when the validator still matches. */
|
||||
function ifRangeMatches(req, etag, lastModified) {
|
||||
const v = req.headers['if-range'];
|
||||
if (typeof v !== 'string') return true;
|
||||
const s = v.trim();
|
||||
if (s.startsWith('"') || s.startsWith('W/')) return s === etag; // strong comparison
|
||||
return s === lastModified;
|
||||
}
|
||||
|
||||
function cacheControlFor(ext, mountName, segments, query) {
|
||||
if (ext === '.html' || ext === '.htm') return 'no-cache';
|
||||
if (/(^|&)v=/.test(query)) return IMMUTABLE_CACHE;
|
||||
if (mountName === 'public' && segments.length > 1 && LONG_CACHE_DIRS.includes(segments[0])) return LONG_CACHE;
|
||||
return 'no-cache';
|
||||
}
|
||||
|
||||
export async function serveFile(req, res, absPath, stat, mountName, segments, query, gzipCache, log) {
|
||||
const ext = path.extname(absPath).toLowerCase();
|
||||
const type = MIME[ext] || 'application/octet-stream';
|
||||
const compressible = COMPRESSIBLE.has(ext);
|
||||
const rangeHeader = req.headers.range;
|
||||
const useGzip = compressible && stat.size >= GZIP_MIN_BYTES && !rangeHeader && acceptsGzip(req.headers['accept-encoding']);
|
||||
const baseTag = `${stat.size.toString(16)}-${Math.floor(stat.mtimeMs).toString(16)}`;
|
||||
const etag = `"${baseTag}${useGzip ? '-gz' : ''}"`;
|
||||
const lastModified = stat.mtime.toUTCString();
|
||||
const isHead = req.method === 'HEAD';
|
||||
|
||||
const headers = {
|
||||
'Content-Type': type,
|
||||
'Cache-Control': cacheControlFor(ext, mountName, segments, query),
|
||||
ETag: etag,
|
||||
'Last-Modified': lastModified,
|
||||
};
|
||||
if (compressible) headers.Vary = 'Accept-Encoding';
|
||||
|
||||
if (isNotModified(req, etag, stat.mtime)) {
|
||||
res.writeHead(304, headers);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (useGzip) {
|
||||
headers['Content-Encoding'] = 'gzip';
|
||||
if (stat.size <= GZIP_CACHE_MAX_FILE) {
|
||||
const gz = await gzipCache.get(absPath, stat);
|
||||
headers['Content-Length'] = gz.length;
|
||||
res.writeHead(200, headers);
|
||||
res.end(isHead ? undefined : gz);
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, headers);
|
||||
if (isHead) { res.end(); return; }
|
||||
await streamTo(fs.createReadStream(absPath), res, log, zlib.createGzip());
|
||||
return;
|
||||
}
|
||||
|
||||
headers['Accept-Ranges'] = 'bytes';
|
||||
let start = 0;
|
||||
let end = stat.size - 1;
|
||||
let status = 200;
|
||||
if (rangeHeader && ifRangeMatches(req, etag, lastModified)) {
|
||||
const r = parseRange(rangeHeader, stat.size);
|
||||
if (r === 'unsatisfiable') {
|
||||
res.writeHead(416, { 'Content-Range': `bytes */${stat.size}`, 'Content-Type': 'text/plain; charset=utf-8', 'Content-Length': 0 });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (r) {
|
||||
({ start, end } = r);
|
||||
status = 206;
|
||||
headers['Content-Range'] = `bytes ${start}-${end}/${stat.size}`;
|
||||
}
|
||||
}
|
||||
headers['Content-Length'] = stat.size === 0 ? 0 : end - start + 1;
|
||||
res.writeHead(status, headers);
|
||||
if (isHead || stat.size === 0) { res.end(); return; }
|
||||
await streamTo(fs.createReadStream(absPath, { start, end }), res, log);
|
||||
}
|
||||
|
||||
async function streamTo(src, res, log, transform) {
|
||||
try {
|
||||
if (transform) await pipeline(src, transform, res);
|
||||
else await pipeline(src, res);
|
||||
} catch (e) {
|
||||
if (e && e.code !== 'ERR_STREAM_PREMATURE_CLOSE') log.debug?.('[http] stream aborted', e.code || e.message);
|
||||
res.destroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// server/http/media.js — the extension-less audio route /media/… → public/assets/audio (see serveMedia).
|
||||
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { AUDIO_EXTS } from '../../shared/media.js';
|
||||
import { sendError } from './common.js';
|
||||
import { serveFile } from './files.js';
|
||||
|
||||
/**
|
||||
* Extension-less audio route: `/media/bgm/act1` → `public/assets/audio/bgm/act1.mp3`.
|
||||
*
|
||||
* Clients ask for audio through this path because download managers (IDM, 迅雷, FDM …) hijack XHR/fetch whose
|
||||
* URL ends in a media extension and pop a "下载文件信息" dialog for every BGM track — see `public/js/media.js`.
|
||||
* Requests for the direct `/assets/audio/…` URLs keep working (they are the fallback for plain static hosts).
|
||||
* `MEDIA_PREFIX` / `AUDIO_EXTS` live in `shared/media.js`: the browser decides which URLs to rewrite with the
|
||||
* same two values, and they must not drift apart.
|
||||
*/
|
||||
export async function serveMedia(req, res, rest, query, publicDir, gzipCache, log) {
|
||||
const root = path.join(path.resolve(publicDir), 'assets', 'audio');
|
||||
const segments = String(rest || '').split('/').filter((s) => s.length > 0);
|
||||
if (!segments.length || rest.endsWith('/')) { sendError(req, res, 404, '页面不存在 · Not found'); return; }
|
||||
if (segments.some((s) => s === '..' || s === '.')) { sendError(req, res, 403, '禁止访问 · Forbidden'); return; }
|
||||
// A leading or trailing dot would address something else (dotfiles, "x..mp3") — and the client never asks for it.
|
||||
if (segments.some((s) => s.startsWith('.') || s.endsWith('.'))) { sendError(req, res, 404, '页面不存在 · Not found'); return; }
|
||||
|
||||
const last = segments[segments.length - 1];
|
||||
const given = path.extname(last).toLowerCase();
|
||||
const wanted = AUDIO_EXTS.includes(given) ? given : '';
|
||||
const stem = wanted ? last.slice(0, -wanted.length) : last;
|
||||
if (!stem || stem.startsWith('.')) { sendError(req, res, 404, '页面不存在 · Not found'); return; }
|
||||
const dir = path.join(root, ...segments.slice(0, -1));
|
||||
if (dir !== root && !dir.startsWith(root + path.sep)) { sendError(req, res, 403, '禁止访问 · Forbidden'); return; }
|
||||
|
||||
// An explicit extension wins (`/media/bgm.ogg` → bgm.ogg), otherwise the usual order decides.
|
||||
const order = wanted ? [wanted, ...AUDIO_EXTS.filter((e) => e !== wanted)] : AUDIO_EXTS;
|
||||
for (const ext of order) {
|
||||
const absPath = path.join(dir, stem + ext);
|
||||
if (!absPath.startsWith(root + path.sep)) continue;
|
||||
let stat;
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
stat = await fsp.stat(absPath);
|
||||
} catch { continue; }
|
||||
if (!stat.isFile()) continue;
|
||||
// serveFile decides Content-Type from the resolved name (`.mp3` → audio/mpeg) — Range/ETag handling is shared.
|
||||
// Cache policy is that of the public path the client would otherwise have asked for (`/assets/audio/…`, 1 day).
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await serveFile(req, res, absPath, stat, 'public', ['assets', 'audio', ...segments], query, gzipCache, log);
|
||||
return;
|
||||
}
|
||||
sendError(req, res, 404, '页面不存在 · Not found');
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// server/http/routes.js — the node:http request listener. Every response gets the security headers (common.js), then:
|
||||
//
|
||||
// * a URL longer than 4096 characters → 414; one that does not parse → 400;
|
||||
// * any method but GET / HEAD → 405 with `Allow: GET, HEAD`;
|
||||
// * GET /healthz → JSON status (protocol `version`, release `app`, uptime, the served `build`, sockets, sessions,
|
||||
// rooms, matches), never cached;
|
||||
// * everything else → the static files (static.js).
|
||||
// A route that throws is logged and answers 500.
|
||||
|
||||
import { PROTOCOL_VERSION, APP_VERSION } from '../../shared/constants.js';
|
||||
import { buildTag } from './buildTag.js';
|
||||
import { setSecurityHeaders, sendError, sendJson, splitUrl } from './common.js';
|
||||
|
||||
const MAX_URL_LENGTH = 4096;
|
||||
|
||||
/**
|
||||
* The GET /healthz body.
|
||||
* @param {{ startedAt: number, network: import('../net.js').Network, registry: import('../net.js').SessionRegistry,
|
||||
* lobby: import('../lobby.js').Lobby }} health
|
||||
*/
|
||||
export function healthReport({ startedAt, network, registry, lobby }) {
|
||||
return {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The request listener for `http.createServer`.
|
||||
* @param {{ serveStatic: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse,
|
||||
* rawPath: string, query: string) => Promise<void>,
|
||||
* health: Parameters<typeof healthReport>[0], log: object }} deps
|
||||
* @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => void}
|
||||
*/
|
||||
export function createRequestHandler({ serveStatic, health, log }) {
|
||||
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, healthReport(health));
|
||||
return;
|
||||
}
|
||||
await serveStatic(req, res, parts.rawPath, parts.query);
|
||||
}
|
||||
|
||||
return (req, res) => {
|
||||
setSecurityHeaders(res);
|
||||
handleRequest(req, res).catch((e) => {
|
||||
log.error('[http] request failed', e);
|
||||
sendError(req, res, 500, '服务器内部错误 · Internal error');
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// server/http/static.js — the static file server (routes.js hands it every GET / HEAD that is not an API route):
|
||||
//
|
||||
// / → 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 (media.js, shared/media.js, public/js/media.js)
|
||||
//
|
||||
// Traversal & dotfile protection, a directory without its trailing slash → 301, 404 page; an absent
|
||||
// data/local-assets.json is answered with an empty manifest. Files go out through files.js serveFile.
|
||||
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { MEDIA_PREFIX } from '../../shared/media.js';
|
||||
import { ROOT, noopLog } from './config.js';
|
||||
import { sendError } from './common.js';
|
||||
import { MIME, GzipCache, isNotModified, serveFile } from './files.js';
|
||||
import { serveMedia } from './media.js';
|
||||
|
||||
/** Browser stand-in of server/data.js, served at /data.js (see the header). Served byte for byte: its first line names the entry, server/index.js. */
|
||||
export const DATA_SHIM_JS = `// Generated by server/index.js — browser stand-in for server/data.js (DESIGN §14 client-side combat).
|
||||
// The simulation's content modules (/sim/content/support/index.js) import getData() from here; it returns the game data
|
||||
// the page injected with /sim/simdata.js setSimData(data).
|
||||
import { getSimData } from './sim/simdata.js';
|
||||
export function getData() { return getSimData() || {}; }
|
||||
export function resetData() {}
|
||||
`;
|
||||
/** Files under server/sim that are never served (Node-only). */
|
||||
const SIM_PRIVATE = new Set(['nodedata.js']); // lower-case (compared case-insensitively)
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// Static file handler
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
|
||||
/** Optional per-machine art manifest (tools/local-extract) and the empty stand-in served when it is absent. */
|
||||
const LOCAL_ART_MANIFEST = 'local-assets.json';
|
||||
const EMPTY_LOCAL_ART = Buffer.from(JSON.stringify({ version: 1, source: 'none', count: 0, groups: {} }));
|
||||
|
||||
/**
|
||||
* Create the static request handler.
|
||||
* @param {{ publicDir: string, dataDir: string, sharedDir: string, simDir?: string, log?: object }} dirs
|
||||
* @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, rawPath: string, query: string) => Promise<void>}
|
||||
*/
|
||||
export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = path.join(ROOT, 'server', 'sim'), log = noopLog }) {
|
||||
const mounts = [
|
||||
{ prefix: '/data/', name: 'data', dir: path.resolve(dataDir) },
|
||||
{ prefix: '/shared/', name: 'shared', dir: path.resolve(sharedDir) },
|
||||
// the simulation: ES modules only (no directory listings, no other file types, no Node-only loader)
|
||||
{ prefix: '/sim/', name: 'sim', dir: path.resolve(simDir), only: new Set(['.js']), deny: SIM_PRIVATE },
|
||||
{ prefix: '/', name: 'public', dir: path.resolve(publicDir) },
|
||||
];
|
||||
const shimBody = Buffer.from(DATA_SHIM_JS);
|
||||
const shimTag = `"shim-${shimBody.length.toString(16)}"`;
|
||||
const gzipCache = new GzipCache();
|
||||
|
||||
return async function serveStatic(req, res, rawPath, query) {
|
||||
let decoded;
|
||||
try { decoded = decodeURIComponent(rawPath); } catch { sendError(req, res, 400, '请求地址无效 · Bad request'); return; }
|
||||
if (!decoded.startsWith('/') || decoded.includes('\0') || decoded.includes('\\')) {
|
||||
sendError(req, res, 400, '请求地址无效 · Bad request');
|
||||
return;
|
||||
}
|
||||
if (decoded === '/data.js') {
|
||||
const headers = { 'Content-Type': MIME['.js'], 'Cache-Control': 'no-cache', ETag: shimTag, 'Content-Length': shimBody.length };
|
||||
if (isNotModified(req, shimTag, new Date(0))) { delete headers['Content-Length']; res.writeHead(304, headers); res.end(); return; }
|
||||
res.writeHead(200, headers);
|
||||
res.end(req.method === 'HEAD' ? undefined : shimBody);
|
||||
return;
|
||||
}
|
||||
// Extension-less audio (download-manager avoidance): /media/bgm/act1 → /assets/audio/bgm/act1.mp3
|
||||
if (decoded.startsWith(MEDIA_PREFIX)) {
|
||||
await serveMedia(req, res, decoded.slice(MEDIA_PREFIX.length), query, publicDir, gzipCache, log);
|
||||
return;
|
||||
}
|
||||
// Bare mount paths (e.g. "/data") → treat as the mount directory.
|
||||
const mount = mounts.find((m) => decoded.startsWith(m.prefix) || decoded === m.prefix.slice(0, -1)) || mounts[mounts.length - 1];
|
||||
const rest = decoded.length > mount.prefix.length ? decoded.slice(mount.prefix.length) : '';
|
||||
const segments = rest.split('/').filter((s) => s.length > 0);
|
||||
if (segments.some((s) => s === '..' || s === '.')) { sendError(req, res, 403, '禁止访问 · Forbidden'); return; }
|
||||
if (segments.some((s) => s.startsWith('.'))) { sendError(req, res, 404, '页面不存在 · Not found'); return; }
|
||||
if (mount.only && (!segments.length || !mount.only.has(path.extname(segments[segments.length - 1]).toLowerCase())
|
||||
// (case-insensitive: the host may be Windows / macOS, where NODEDATA.JS opens nodeData.js)
|
||||
|| (mount.deny && mount.deny.has(segments[segments.length - 1].toLowerCase())))) {
|
||||
sendError(req, res, 404, '页面不存在 · Not found');
|
||||
return;
|
||||
}
|
||||
let absPath = path.join(mount.dir, ...segments);
|
||||
if (absPath !== mount.dir && !absPath.startsWith(mount.dir + path.sep)) { sendError(req, res, 403, '禁止访问 · Forbidden'); return; }
|
||||
|
||||
let stat;
|
||||
let viaDirectory = false;
|
||||
try {
|
||||
stat = await fsp.stat(absPath);
|
||||
if (stat.isDirectory()) {
|
||||
if (!decoded.endsWith('/')) {
|
||||
// Built from normalized segments (never from the raw path) so "//host" can't become an open redirect.
|
||||
const loc = (mount.prefix + segments.map(encodeURIComponent).join('/') + '/').replace(/\/{2,}/g, '/');
|
||||
res.writeHead(301, { Location: loc + (query ? `?${query}` : ''), 'Cache-Control': 'no-cache', 'Content-Length': 0 });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
absPath = path.join(absPath, 'index.html');
|
||||
segments.push('index.html');
|
||||
viaDirectory = true;
|
||||
stat = await fsp.stat(absPath);
|
||||
}
|
||||
// Not a regular file, or a file addressed like a directory ("/app.js/") → 404.
|
||||
if (!stat.isFile() || (decoded.endsWith('/') && !viaDirectory)) {
|
||||
throw Object.assign(new Error('not a file'), { code: 'ENOENT' });
|
||||
}
|
||||
if (mount.deny) {
|
||||
// the on-disk name decides (case-insensitive file systems, Windows 8.3 short names like NODEDA~1.JS)
|
||||
const real = await fsp.realpath(absPath);
|
||||
if (mount.deny.has(path.basename(real).toLowerCase())) throw Object.assign(new Error('private'), { code: 'ENOENT' });
|
||||
}
|
||||
} catch (e) {
|
||||
if (e && e.code === 'ENOENT' && mount.name === 'data' && segments.length === 1 && segments[0] === LOCAL_ART_MANIFEST) {
|
||||
// Optional local-client art (DESIGN §13): an install without it gets an empty manifest instead of a 404,
|
||||
// so browsers don't log an error on every page load. Clients treat empty groups as "no local art".
|
||||
res.writeHead(200, { 'Content-Type': MIME['.json'], 'Cache-Control': 'no-cache', 'Content-Length': EMPTY_LOCAL_ART.length });
|
||||
res.end(req.method === 'HEAD' ? undefined : EMPTY_LOCAL_ART);
|
||||
} else if (e && (e.code === 'ENOENT' || e.code === 'ENOTDIR' || e.code === 'EISDIR' || e.code === 'ENAMETOOLONG')) {
|
||||
sendError(req, res, 404, '页面不存在 · Not found', decoded.length <= 200 ? decoded : '');
|
||||
} else if (e && (e.code === 'EACCES' || e.code === 'EPERM')) {
|
||||
sendError(req, res, 403, '禁止访问 · Forbidden');
|
||||
} else {
|
||||
log.error('[http] stat failed', e);
|
||||
sendError(req, res, 500, '服务器内部错误 · Internal error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
await serveFile(req, res, absPath, stat, mount.name, segments, query, gzipCache, log);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// server/http/websocket.js — the real-time side of the server:
|
||||
//
|
||||
// * session wiring: SessionRegistry (reconnect tokens) → Lobby (rooms, server/lobby.js) → Network (the socket
|
||||
// protocol, server/net.js), built from the startServer() options (config.js decides which go where);
|
||||
// * WebSocket (ws) at /ws, maxPayload 64 KB, no per-message deflate → Network.handleConnection. Refused at the
|
||||
// upgrade: any other path 404; per-network socket limit for internet clients (maxConnectionsPerAddr, see net.js
|
||||
// clientAddress; local/LAN peers are exempt) 429; server full (maxConnections) or shutting down 503.
|
||||
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { Network, SessionRegistry, NET_DEFAULTS } from '../net.js';
|
||||
import { Lobby } from '../lobby.js';
|
||||
import { splitUrl } from './common.js';
|
||||
import { netOptionsFrom, lobbyOptionsFrom } from './config.js';
|
||||
|
||||
/** Inbound WebSocket frame limit (DESIGN §8). */
|
||||
export const WS_MAX_PAYLOAD = 64 * 1024;
|
||||
|
||||
/**
|
||||
* The session stack of one server.
|
||||
* @param {{ MatchClass?: Function, seedFn?: () => number, [option: string]: any }} opts startServer() options
|
||||
* @param {{ data: object, log: object }} deps the game data the lobby's matches use, the logger
|
||||
* @returns {{ registry: SessionRegistry, lobby: Lobby, network: Network }}
|
||||
*/
|
||||
export function createSessionStack(opts, { data, log }) {
|
||||
const netOptions = netOptionsFrom(opts);
|
||||
const registry = new SessionRegistry({ reconnectWindowMs: netOptions.reconnectWindowMs ?? NET_DEFAULTS.reconnectWindowMs });
|
||||
const lobbyOptions = lobbyOptionsFrom(opts);
|
||||
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 });
|
||||
return { registry, lobby, network };
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve the WebSocket endpoint /ws on `server` (its 'upgrade' event).
|
||||
* @param {import('node:http').Server} server
|
||||
* @param {{ network: Network, log: object }} deps
|
||||
* @returns {WebSocketServer}
|
||||
*/
|
||||
export function attachWebSocket(server, { network, log }) {
|
||||
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();
|
||||
}
|
||||
});
|
||||
return wss;
|
||||
}
|
||||
+40
-716
@@ -1,601 +1,40 @@
|
||||
// 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 fsp from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import zlib from 'node:zlib';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { promisify } from 'node:util';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
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 { MEDIA_PREFIX, AUDIO_EXTS } from '../shared/media.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 { createRequestHandler } from './http/routes.js';
|
||||
import { answerClientError } from './http/common.js';
|
||||
import { lanUrls, isProcessEntry, runMain } from './http/boot.js';
|
||||
|
||||
/** Repository root. */
|
||||
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
/** Inbound WebSocket frame limit (DESIGN §8). */
|
||||
export const WS_MAX_PAYLOAD = 64 * 1024;
|
||||
|
||||
/** Browser stand-in of server/data.js, served at /data.js (see the header). */
|
||||
export const DATA_SHIM_JS = `// Generated by server/index.js — browser stand-in for server/data.js (DESIGN §14 client-side combat).
|
||||
// The simulation's content modules (/sim/content/support/index.js) import getData() from here; it returns the game data
|
||||
// the page injected with /sim/simdata.js setSimData(data).
|
||||
import { getSimData } from './sim/simdata.js';
|
||||
export function getData() { return getSimData() || {}; }
|
||||
export function resetData() {}
|
||||
`;
|
||||
/** Files under server/sim that are never served (Node-only). */
|
||||
const SIM_PRIVATE = new Set(['nodedata.js']); // lower-case (compared case-insensitively)
|
||||
|
||||
/** Extension → Content-Type. */
|
||||
export const MIME = Object.freeze({
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.htm': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.mjs': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.map': 'application/json; charset=utf-8',
|
||||
'.webmanifest': 'application/manifest+json; charset=utf-8',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.md': 'text/markdown; charset=utf-8',
|
||||
'.csv': 'text/csv; charset=utf-8',
|
||||
'.xml': 'application/xml; charset=utf-8',
|
||||
'.atlas': 'text/plain; charset=utf-8',
|
||||
'.skel': 'application/octet-stream',
|
||||
'.bin': 'application/octet-stream',
|
||||
'.wasm': 'application/wasm',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.avif': 'image/avif',
|
||||
'.svg': 'image/svg+xml; charset=utf-8',
|
||||
'.ico': 'image/x-icon',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.ogg': 'audio/ogg',
|
||||
'.oga': 'audio/ogg',
|
||||
'.opus': 'audio/ogg',
|
||||
'.wav': 'audio/wav',
|
||||
'.m4a': 'audio/mp4',
|
||||
'.aac': 'audio/aac',
|
||||
'.webm': 'video/webm',
|
||||
'.mp4': 'video/mp4',
|
||||
'.woff2': 'font/woff2',
|
||||
'.woff': 'font/woff',
|
||||
'.otf': 'font/otf',
|
||||
'.ttf': 'font/ttf',
|
||||
});
|
||||
|
||||
/** Extensions worth gzipping (text-like, .skel, uncompressed fonts). */
|
||||
export const COMPRESSIBLE = new Set([
|
||||
'.html', '.htm', '.js', '.mjs', '.css', '.json', '.map', '.webmanifest', '.txt', '.md', '.csv', '.xml',
|
||||
'.atlas', '.skel', '.bin', '.wasm', '.svg', '.ico', '.otf', '.ttf', '.wav',
|
||||
]);
|
||||
|
||||
const GZIP_MIN_BYTES = 512;
|
||||
const GZIP_CACHE_MAX_FILE = 8 << 20; // larger files are gzip-streamed on the fly
|
||||
const GZIP_CACHE_MAX_TOTAL = 96 << 20;
|
||||
// Asset URLs carry no content hash yet, and tools/fetch-assets.mjs / tools/vendor.mjs can rewrite files in
|
||||
// place (atlas + png + skel must stay consistent), so "long" is one day; revalidation after that is a cheap 304.
|
||||
const LONG_CACHE = 'public, max-age=86400'; // 1 day
|
||||
const IMMUTABLE_CACHE = 'public, max-age=31536000, immutable';
|
||||
const LONG_CACHE_DIRS = ['assets', 'fonts', 'vendor']; // first path segment under public/
|
||||
const MAX_URL_LENGTH = 4096;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// build tag — the "your page is stale" signal (public/js/ui/buildGuard.js)
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The files that make up the runtime the BROWSER loads. A change in any of them is a new build: an already-open page
|
||||
* keeps the modules it imported at load time (ES modules live in the page's module map for its whole lifetime), so
|
||||
* without this signal a deployed fix could never reach a player who does not reload — a client-only battle fix
|
||||
* shipped exactly that way and stayed invisible on a page that had been opened before the deploy.
|
||||
*
|
||||
* `server/`, `data/` and `shared/` are deliberately NOT in here: this process read them once at startup, so when they
|
||||
* change without a restart the server still runs the old simulation and data — a page that reloaded into the new files
|
||||
* would be out of step with the server that validates its battles (and DEPLOY.md restarts the server for every update).
|
||||
*/
|
||||
export const BUILD_INPUTS = Object.freeze(['public/index.html', 'public/js', 'public/css']);
|
||||
|
||||
/** Names the static server never serves: dot files (`.DS_Store`, `.main.js.swp`) and editor backups (`main.js~`). */
|
||||
const isIgnoredBuildName = (name) => name.startsWith('.') || name.endsWith('~');
|
||||
|
||||
/** @type {{ tag: string|null }|null} */
|
||||
let buildCache = null;
|
||||
|
||||
/** Every file under `abs` (or `abs` itself), as `[relative path, size, mtimeMs]`, sorted by path. Missing → []. */
|
||||
function buildEntries(abs, rel, out) {
|
||||
let stat;
|
||||
try { stat = fs.statSync(abs); } catch { return; }
|
||||
if (stat.isFile()) { out.push([rel, stat.size, stat.mtimeMs]); return; }
|
||||
if (!stat.isDirectory()) return;
|
||||
let names;
|
||||
try { names = fs.readdirSync(abs, { withFileTypes: true }); } catch { return; }
|
||||
for (const d of names.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
|
||||
if (isIgnoredBuildName(d.name)) continue;
|
||||
const child = path.join(abs, d.name);
|
||||
const childRel = rel ? `${rel}/${d.name}` : d.name;
|
||||
if (d.isDirectory()) buildEntries(child, childRel, out);
|
||||
else if (d.isFile()) { try { const s = fs.statSync(child); out.push([childRel, s.size, s.mtimeMs]); } catch { /* ignore */ } }
|
||||
}
|
||||
}
|
||||
|
||||
/** Short hash of the served browser runtime (size + mtime of every BUILD_INPUTS file); null when nothing is readable. */
|
||||
export function computeBuildTag(root = ROOT) {
|
||||
const out = [];
|
||||
for (const rel of BUILD_INPUTS) buildEntries(path.join(root, rel), rel, out);
|
||||
if (!out.length) return null;
|
||||
out.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
||||
const h = createHash('sha1');
|
||||
for (const [rel, size, mtime] of out) h.update(`${rel}\0${size}\0${Math.floor(mtime)}\n`);
|
||||
return h.digest('hex').slice(0, 12);
|
||||
}
|
||||
|
||||
/**
|
||||
* The build tag of THIS process. Computed once (`startServer` warms it at startup): the tag describes the files the
|
||||
* process is actually serving, every update restarts the server (DEPLOY.md), and re-reading the tree on a timer would
|
||||
* let a half-finished deploy — or a file that changed while the process kept running — move the tag under a page.
|
||||
* @param {string} [root] used by the first call only (tests)
|
||||
*/
|
||||
export function buildTag(root = ROOT) {
|
||||
if (buildCache === null) buildCache = { tag: computeBuildTag(root) };
|
||||
return buildCache.tag;
|
||||
}
|
||||
|
||||
/** Drop the cache: the next `buildTag()` re-reads the tree (tests, and `startServer`). */
|
||||
export function resetBuildTag() { buildCache = null; }
|
||||
|
||||
const gzipAsync = promisify(zlib.gzip);
|
||||
const noopLog = { info() {}, warn() {}, error() {}, debug() {} };
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// gzip cache (LRU by bytes)
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
|
||||
class GzipCache {
|
||||
constructor(maxTotal = GZIP_CACHE_MAX_TOTAL) {
|
||||
this.maxTotal = maxTotal;
|
||||
this.total = 0;
|
||||
/** @type {Map<string, Buffer>} */ this.map = new Map();
|
||||
/** @type {Map<string, Promise<Buffer>>} */ this.inflight = new Map();
|
||||
}
|
||||
|
||||
/** @returns {Promise<Buffer>} gzip of the file identified by (path, size, mtime) */
|
||||
get(absPath, stat) {
|
||||
const key = `${absPath}\0${stat.size}\0${stat.mtimeMs}`;
|
||||
const hit = this.map.get(key);
|
||||
if (hit) { this.map.delete(key); this.map.set(key, hit); return Promise.resolve(hit); }
|
||||
const pending = this.inflight.get(key);
|
||||
if (pending) return pending;
|
||||
const p = (async () => {
|
||||
const raw = await fsp.readFile(absPath);
|
||||
const gz = await gzipAsync(raw, { level: 6 });
|
||||
this.store(key, gz);
|
||||
return gz;
|
||||
})().finally(() => this.inflight.delete(key));
|
||||
this.inflight.set(key, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
store(key, buf) {
|
||||
if (buf.length > this.maxTotal) return;
|
||||
this.map.set(key, buf);
|
||||
this.total += buf.length;
|
||||
for (const [k, v] of this.map) {
|
||||
if (this.total <= this.maxTotal) break;
|
||||
this.map.delete(k);
|
||||
this.total -= v.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// HTTP helpers
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
|
||||
/** Does the Accept-Encoding header allow gzip (q > 0)? @param {string | undefined} header */
|
||||
export function acceptsGzip(header) {
|
||||
if (!header || typeof header !== 'string') return false;
|
||||
let gzipQ = null;
|
||||
let starQ = null;
|
||||
for (const part of header.split(',')) {
|
||||
const [token, ...params] = part.trim().toLowerCase().split(';');
|
||||
let q = 1;
|
||||
for (const p of params) {
|
||||
const m = /^\s*q=([0-9.]+)\s*$/.exec(p);
|
||||
if (m) q = Number(m[1]);
|
||||
}
|
||||
if (!Number.isFinite(q)) q = 0;
|
||||
if (token === 'gzip' || token === 'x-gzip') gzipQ = q;
|
||||
else if (token === '*') starQ = q;
|
||||
}
|
||||
if (gzipQ != null) return gzipQ > 0;
|
||||
return starQ != null && starQ > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single `bytes=` range against a file size.
|
||||
* @returns {{ start: number, end: number } | 'unsatisfiable' | null} null = ignore header (serve 200)
|
||||
*/
|
||||
export function parseRange(header, size) {
|
||||
if (typeof header !== 'string') return null;
|
||||
const m = /^\s*bytes\s*=\s*(\d*)\s*-\s*(\d*)\s*$/i.exec(header);
|
||||
if (!m) return null; // multi-range or malformed → ignore (RFC 9110 permits serving the full body)
|
||||
const [, a, b] = m;
|
||||
if (a === '' && b === '') return null;
|
||||
if (a === '') {
|
||||
const suffix = Number(b);
|
||||
if (suffix === 0 || size === 0) return 'unsatisfiable';
|
||||
return { start: Math.max(0, size - suffix), end: size - 1 };
|
||||
}
|
||||
const start = Number(a);
|
||||
const end = b === '' ? size - 1 : Math.min(Number(b), size - 1);
|
||||
if (b !== '' && Number(b) < start) return null;
|
||||
if (start >= size) return 'unsatisfiable';
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
const stripWeak = (tag) => tag.trim().replace(/^W\//, '');
|
||||
|
||||
/** Conditional GET check (If-None-Match wins over If-Modified-Since). */
|
||||
function isNotModified(req, etag, mtime) {
|
||||
const inm = req.headers['if-none-match'];
|
||||
if (typeof inm === 'string') {
|
||||
if (inm.trim() === '*') return true;
|
||||
return inm.split(',').some((t) => stripWeak(t) === etag);
|
||||
}
|
||||
const ims = req.headers['if-modified-since'];
|
||||
if (typeof ims === 'string') {
|
||||
const t = Date.parse(ims);
|
||||
if (Number.isFinite(t)) return Math.floor(mtime.getTime() / 1000) * 1000 <= t;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** If-Range: serve the range only when the validator still matches. */
|
||||
function ifRangeMatches(req, etag, lastModified) {
|
||||
const v = req.headers['if-range'];
|
||||
if (typeof v !== 'string') return true;
|
||||
const s = v.trim();
|
||||
if (s.startsWith('"') || s.startsWith('W/')) return s === etag; // strong comparison
|
||||
return s === lastModified;
|
||||
}
|
||||
|
||||
function cacheControlFor(ext, mountName, segments, query) {
|
||||
if (ext === '.html' || ext === '.htm') return 'no-cache';
|
||||
if (/(^|&)v=/.test(query)) return IMMUTABLE_CACHE;
|
||||
if (mountName === 'public' && segments.length > 1 && LONG_CACHE_DIRS.includes(segments[0])) return LONG_CACHE;
|
||||
return 'no-cache';
|
||||
}
|
||||
|
||||
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
||||
|
||||
function errorPage(status, title, detail = '') {
|
||||
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>${status} · 卫戍协议:盟约</title><style>
|
||||
:root{color-scheme:dark}body{margin:0;min-height:100vh;display:grid;place-items:center;background:#111614;color:#d8e3de;font:16px/1.6 "Noto Sans SC",system-ui,sans-serif}
|
||||
main{border:1px solid #2c3a35;padding:32px 40px;max-width:520px;text-align:center}h1{margin:0;color:#4ed8af;font-size:56px;letter-spacing:4px}
|
||||
p{margin:8px 0}a{color:#4ed8af}</style></head><body><main><h1>${status}</h1><p>${escapeHtml(title)}</p>
|
||||
${detail ? `<p style="opacity:.6">${escapeHtml(detail)}</p>` : ''}<p><a href="/">返回首页 · Back to home</a></p></main></body></html>`;
|
||||
}
|
||||
|
||||
function sendError(req, res, status, title, detail) {
|
||||
if (res.headersSent) { res.destroy(); return; }
|
||||
const body = Buffer.from(errorPage(status, title, detail));
|
||||
res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Length': body.length, 'Cache-Control': 'no-store' });
|
||||
res.end(req.method === 'HEAD' ? undefined : body);
|
||||
}
|
||||
|
||||
function sendJson(req, res, status, obj) {
|
||||
const body = Buffer.from(JSON.stringify(obj));
|
||||
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': body.length, 'Cache-Control': 'no-store' });
|
||||
res.end(req.method === 'HEAD' ? undefined : body);
|
||||
}
|
||||
|
||||
/** Split an absolute request URL into raw path + query (also accepts absolute-form URLs). */
|
||||
function splitUrl(url) {
|
||||
let u = url || '/';
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(u)) {
|
||||
try { const parsed = new URL(u); u = parsed.pathname + parsed.search; } catch { return null; }
|
||||
}
|
||||
const q = u.indexOf('?');
|
||||
const hashless = (s) => { const h = s.indexOf('#'); return h >= 0 ? s.slice(0, h) : s; };
|
||||
return q >= 0 ? { rawPath: hashless(u.slice(0, q)), query: hashless(u.slice(q + 1)) } : { rawPath: hashless(u), query: '' };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// Static file handler
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create the static request handler.
|
||||
* @param {{ publicDir: string, dataDir: string, sharedDir: string, simDir?: string, log?: object }} dirs
|
||||
* @returns {(req: http.IncomingMessage, res: http.ServerResponse, rawPath: string, query: string) => Promise<void>}
|
||||
*/
|
||||
/** Optional per-machine art manifest (tools/local-extract) and the empty stand-in served when it is absent. */
|
||||
const LOCAL_ART_MANIFEST = 'local-assets.json';
|
||||
const EMPTY_LOCAL_ART = Buffer.from(JSON.stringify({ version: 1, source: 'none', count: 0, groups: {} }));
|
||||
|
||||
export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = path.join(ROOT, 'server', 'sim'), log = noopLog }) {
|
||||
const mounts = [
|
||||
{ prefix: '/data/', name: 'data', dir: path.resolve(dataDir) },
|
||||
{ prefix: '/shared/', name: 'shared', dir: path.resolve(sharedDir) },
|
||||
// the simulation: ES modules only (no directory listings, no other file types, no Node-only loader)
|
||||
{ prefix: '/sim/', name: 'sim', dir: path.resolve(simDir), only: new Set(['.js']), deny: SIM_PRIVATE },
|
||||
{ prefix: '/', name: 'public', dir: path.resolve(publicDir) },
|
||||
];
|
||||
const shimBody = Buffer.from(DATA_SHIM_JS);
|
||||
const shimTag = `"shim-${shimBody.length.toString(16)}"`;
|
||||
const gzipCache = new GzipCache();
|
||||
|
||||
return async function serveStatic(req, res, rawPath, query) {
|
||||
let decoded;
|
||||
try { decoded = decodeURIComponent(rawPath); } catch { sendError(req, res, 400, '请求地址无效 · Bad request'); return; }
|
||||
if (!decoded.startsWith('/') || decoded.includes('\0') || decoded.includes('\\')) {
|
||||
sendError(req, res, 400, '请求地址无效 · Bad request');
|
||||
return;
|
||||
}
|
||||
if (decoded === '/data.js') {
|
||||
const headers = { 'Content-Type': MIME['.js'], 'Cache-Control': 'no-cache', ETag: shimTag, 'Content-Length': shimBody.length };
|
||||
if (isNotModified(req, shimTag, new Date(0))) { delete headers['Content-Length']; res.writeHead(304, headers); res.end(); return; }
|
||||
res.writeHead(200, headers);
|
||||
res.end(req.method === 'HEAD' ? undefined : shimBody);
|
||||
return;
|
||||
}
|
||||
// Extension-less audio (download-manager avoidance): /media/bgm/act1 → /assets/audio/bgm/act1.mp3
|
||||
if (decoded.startsWith(MEDIA_PREFIX)) {
|
||||
await serveMedia(req, res, decoded.slice(MEDIA_PREFIX.length), query, publicDir, gzipCache, log);
|
||||
return;
|
||||
}
|
||||
// Bare mount paths (e.g. "/data") → treat as the mount directory.
|
||||
const mount = mounts.find((m) => decoded.startsWith(m.prefix) || decoded === m.prefix.slice(0, -1)) || mounts[mounts.length - 1];
|
||||
const rest = decoded.length > mount.prefix.length ? decoded.slice(mount.prefix.length) : '';
|
||||
const segments = rest.split('/').filter((s) => s.length > 0);
|
||||
if (segments.some((s) => s === '..' || s === '.')) { sendError(req, res, 403, '禁止访问 · Forbidden'); return; }
|
||||
if (segments.some((s) => s.startsWith('.'))) { sendError(req, res, 404, '页面不存在 · Not found'); return; }
|
||||
if (mount.only && (!segments.length || !mount.only.has(path.extname(segments[segments.length - 1]).toLowerCase())
|
||||
// (case-insensitive: the host may be Windows / macOS, where NODEDATA.JS opens nodeData.js)
|
||||
|| (mount.deny && mount.deny.has(segments[segments.length - 1].toLowerCase())))) {
|
||||
sendError(req, res, 404, '页面不存在 · Not found');
|
||||
return;
|
||||
}
|
||||
let absPath = path.join(mount.dir, ...segments);
|
||||
if (absPath !== mount.dir && !absPath.startsWith(mount.dir + path.sep)) { sendError(req, res, 403, '禁止访问 · Forbidden'); return; }
|
||||
|
||||
let stat;
|
||||
let viaDirectory = false;
|
||||
try {
|
||||
stat = await fsp.stat(absPath);
|
||||
if (stat.isDirectory()) {
|
||||
if (!decoded.endsWith('/')) {
|
||||
// Built from normalized segments (never from the raw path) so "//host" can't become an open redirect.
|
||||
const loc = (mount.prefix + segments.map(encodeURIComponent).join('/') + '/').replace(/\/{2,}/g, '/');
|
||||
res.writeHead(301, { Location: loc + (query ? `?${query}` : ''), 'Cache-Control': 'no-cache', 'Content-Length': 0 });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
absPath = path.join(absPath, 'index.html');
|
||||
segments.push('index.html');
|
||||
viaDirectory = true;
|
||||
stat = await fsp.stat(absPath);
|
||||
}
|
||||
// Not a regular file, or a file addressed like a directory ("/app.js/") → 404.
|
||||
if (!stat.isFile() || (decoded.endsWith('/') && !viaDirectory)) {
|
||||
throw Object.assign(new Error('not a file'), { code: 'ENOENT' });
|
||||
}
|
||||
if (mount.deny) {
|
||||
// the on-disk name decides (case-insensitive file systems, Windows 8.3 short names like NODEDA~1.JS)
|
||||
const real = await fsp.realpath(absPath);
|
||||
if (mount.deny.has(path.basename(real).toLowerCase())) throw Object.assign(new Error('private'), { code: 'ENOENT' });
|
||||
}
|
||||
} catch (e) {
|
||||
if (e && e.code === 'ENOENT' && mount.name === 'data' && segments.length === 1 && segments[0] === LOCAL_ART_MANIFEST) {
|
||||
// Optional local-client art (DESIGN §13): an install without it gets an empty manifest instead of a 404,
|
||||
// so browsers don't log an error on every page load. Clients treat empty groups as "no local art".
|
||||
res.writeHead(200, { 'Content-Type': MIME['.json'], 'Cache-Control': 'no-cache', 'Content-Length': EMPTY_LOCAL_ART.length });
|
||||
res.end(req.method === 'HEAD' ? undefined : EMPTY_LOCAL_ART);
|
||||
} else if (e && (e.code === 'ENOENT' || e.code === 'ENOTDIR' || e.code === 'EISDIR' || e.code === 'ENAMETOOLONG')) {
|
||||
sendError(req, res, 404, '页面不存在 · Not found', decoded.length <= 200 ? decoded : '');
|
||||
} else if (e && (e.code === 'EACCES' || e.code === 'EPERM')) {
|
||||
sendError(req, res, 403, '禁止访问 · Forbidden');
|
||||
} else {
|
||||
log.error('[http] stat failed', e);
|
||||
sendError(req, res, 500, '服务器内部错误 · Internal error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
await serveFile(req, res, absPath, stat, mount.name, segments, query, gzipCache, log);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension-less audio route: `/media/bgm/act1` → `public/assets/audio/bgm/act1.mp3`.
|
||||
*
|
||||
* Clients ask for audio through this path because download managers (IDM, 迅雷, FDM …) hijack XHR/fetch whose
|
||||
* URL ends in a media extension and pop a "下载文件信息" dialog for every BGM track — see `public/js/media.js`.
|
||||
* Requests for the direct `/assets/audio/…` URLs keep working (they are the fallback for plain static hosts).
|
||||
* `MEDIA_PREFIX` / `AUDIO_EXTS` live in `shared/media.js`: the browser decides which URLs to rewrite with the
|
||||
* same two values, and they must not drift apart.
|
||||
*/
|
||||
async function serveMedia(req, res, rest, query, publicDir, gzipCache, log) {
|
||||
const root = path.join(path.resolve(publicDir), 'assets', 'audio');
|
||||
const segments = String(rest || '').split('/').filter((s) => s.length > 0);
|
||||
if (!segments.length || rest.endsWith('/')) { sendError(req, res, 404, '页面不存在 · Not found'); return; }
|
||||
if (segments.some((s) => s === '..' || s === '.')) { sendError(req, res, 403, '禁止访问 · Forbidden'); return; }
|
||||
// A leading or trailing dot would address something else (dotfiles, "x..mp3") — and the client never asks for it.
|
||||
if (segments.some((s) => s.startsWith('.') || s.endsWith('.'))) { sendError(req, res, 404, '页面不存在 · Not found'); return; }
|
||||
|
||||
const last = segments[segments.length - 1];
|
||||
const given = path.extname(last).toLowerCase();
|
||||
const wanted = AUDIO_EXTS.includes(given) ? given : '';
|
||||
const stem = wanted ? last.slice(0, -wanted.length) : last;
|
||||
if (!stem || stem.startsWith('.')) { sendError(req, res, 404, '页面不存在 · Not found'); return; }
|
||||
const dir = path.join(root, ...segments.slice(0, -1));
|
||||
if (dir !== root && !dir.startsWith(root + path.sep)) { sendError(req, res, 403, '禁止访问 · Forbidden'); return; }
|
||||
|
||||
// An explicit extension wins (`/media/bgm.ogg` → bgm.ogg), otherwise the usual order decides.
|
||||
const order = wanted ? [wanted, ...AUDIO_EXTS.filter((e) => e !== wanted)] : AUDIO_EXTS;
|
||||
for (const ext of order) {
|
||||
const absPath = path.join(dir, stem + ext);
|
||||
if (!absPath.startsWith(root + path.sep)) continue;
|
||||
let stat;
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
stat = await fsp.stat(absPath);
|
||||
} catch { continue; }
|
||||
if (!stat.isFile()) continue;
|
||||
// serveFile decides Content-Type from the resolved name (`.mp3` → audio/mpeg) — Range/ETag handling is shared.
|
||||
// Cache policy is that of the public path the client would otherwise have asked for (`/assets/audio/…`, 1 day).
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await serveFile(req, res, absPath, stat, 'public', ['assets', 'audio', ...segments], query, gzipCache, log);
|
||||
return;
|
||||
}
|
||||
sendError(req, res, 404, '页面不存在 · Not found');
|
||||
}
|
||||
|
||||
async function serveFile(req, res, absPath, stat, mountName, segments, query, gzipCache, log) {
|
||||
const ext = path.extname(absPath).toLowerCase();
|
||||
const type = MIME[ext] || 'application/octet-stream';
|
||||
const compressible = COMPRESSIBLE.has(ext);
|
||||
const rangeHeader = req.headers.range;
|
||||
const useGzip = compressible && stat.size >= GZIP_MIN_BYTES && !rangeHeader && acceptsGzip(req.headers['accept-encoding']);
|
||||
const baseTag = `${stat.size.toString(16)}-${Math.floor(stat.mtimeMs).toString(16)}`;
|
||||
const etag = `"${baseTag}${useGzip ? '-gz' : ''}"`;
|
||||
const lastModified = stat.mtime.toUTCString();
|
||||
const isHead = req.method === 'HEAD';
|
||||
|
||||
const headers = {
|
||||
'Content-Type': type,
|
||||
'Cache-Control': cacheControlFor(ext, mountName, segments, query),
|
||||
ETag: etag,
|
||||
'Last-Modified': lastModified,
|
||||
};
|
||||
if (compressible) headers.Vary = 'Accept-Encoding';
|
||||
|
||||
if (isNotModified(req, etag, stat.mtime)) {
|
||||
res.writeHead(304, headers);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (useGzip) {
|
||||
headers['Content-Encoding'] = 'gzip';
|
||||
if (stat.size <= GZIP_CACHE_MAX_FILE) {
|
||||
const gz = await gzipCache.get(absPath, stat);
|
||||
headers['Content-Length'] = gz.length;
|
||||
res.writeHead(200, headers);
|
||||
res.end(isHead ? undefined : gz);
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, headers);
|
||||
if (isHead) { res.end(); return; }
|
||||
await streamTo(fs.createReadStream(absPath), res, log, zlib.createGzip());
|
||||
return;
|
||||
}
|
||||
|
||||
headers['Accept-Ranges'] = 'bytes';
|
||||
let start = 0;
|
||||
let end = stat.size - 1;
|
||||
let status = 200;
|
||||
if (rangeHeader && ifRangeMatches(req, etag, lastModified)) {
|
||||
const r = parseRange(rangeHeader, stat.size);
|
||||
if (r === 'unsatisfiable') {
|
||||
res.writeHead(416, { 'Content-Range': `bytes */${stat.size}`, 'Content-Type': 'text/plain; charset=utf-8', 'Content-Length': 0 });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (r) {
|
||||
({ start, end } = r);
|
||||
status = 206;
|
||||
headers['Content-Range'] = `bytes ${start}-${end}/${stat.size}`;
|
||||
}
|
||||
}
|
||||
headers['Content-Length'] = stat.size === 0 ? 0 : end - start + 1;
|
||||
res.writeHead(status, headers);
|
||||
if (isHead || stat.size === 0) { res.end(); return; }
|
||||
await streamTo(fs.createReadStream(absPath, { start, end }), res, log);
|
||||
}
|
||||
|
||||
async function streamTo(src, res, log, transform) {
|
||||
try {
|
||||
if (transform) await pipeline(src, transform, res);
|
||||
else await pipeline(src, res);
|
||||
} catch (e) {
|
||||
if (e && e.code !== 'ERR_STREAM_PREMATURE_CLOSE') log.debug?.('[http] stream aborted', e.code || e.message);
|
||||
res.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// Server assembly
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
|
||||
/** Non-internal IPv4 addresses as http URLs. @param {number} port */
|
||||
export function lanUrls(port) {
|
||||
const out = [];
|
||||
for (const addrs of Object.values(os.networkInterfaces())) {
|
||||
for (const a of addrs || []) {
|
||||
if ((a.family === 'IPv4' || a.family === 4) && !a.internal) out.push(`http://${a.address}:${port}`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** TRUST_PROXY env → net.js trustProxy ('auto' unless explicitly on/off). @param {string | undefined} v */
|
||||
export function parseTrustProxy(v) {
|
||||
const s = String(v ?? '').trim().toLowerCase();
|
||||
if (['1', 'true', 'yes', 'on', 'always'].includes(s)) return true;
|
||||
if (['0', 'false', 'no', 'off', 'never'].includes(s)) return false;
|
||||
return 'auto';
|
||||
}
|
||||
|
||||
function makeLogger(quiet) {
|
||||
if (quiet) return noopLog;
|
||||
return {
|
||||
info: (...a) => console.log(...a),
|
||||
warn: (...a) => console.warn(...a),
|
||||
error: (...a) => console.error(...a),
|
||||
debug: process.env.DEBUG ? (...a) => console.debug(...a) : () => {},
|
||||
};
|
||||
}
|
||||
// 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.
|
||||
@@ -608,100 +47,27 @@ function makeLogger(quiet) {
|
||||
* 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) => {
|
||||
@@ -740,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);
|
||||
+1
-1
@@ -525,7 +525,7 @@ export class Network {
|
||||
get connectionCount() { return this.conns.size; }
|
||||
|
||||
/**
|
||||
* Upgrade-time admission check (server/index.js): null to accept, otherwise the reason to refuse.
|
||||
* Upgrade-time admission check (server/http/websocket.js): null to accept, otherwise the reason to refuse.
|
||||
* @param {import('node:http').IncomingMessage} req
|
||||
* @returns {null | 'shutdown' | 'full' | 'per-address'}
|
||||
*/
|
||||
|
||||
+2
-2
@@ -6,14 +6,14 @@
|
||||
//
|
||||
// /assets/audio/bgm/act1.mp3 → /media/bgm/act1
|
||||
//
|
||||
// — and `server/index.js` resolves it back to the real file under public/assets/audio, still answering with
|
||||
// — and `server/http/media.js` resolves it back to the real file under public/assets/audio, still answering with
|
||||
// `Content-Type: audio/mpeg`, Range support and the ETag of the file it resolved.
|
||||
//
|
||||
// Both sides import this module: the browser to decide which URLs to rewrite (`public/js/media.js`), the server to
|
||||
// decide which extensions a `/media/…` request may resolve to. A second copy of the list on one side would let them
|
||||
// drift apart silently — the client would rewrite to a path the server no longer accepts.
|
||||
|
||||
/** Prefix of the extension-less audio route served by `server/index.js`. */
|
||||
/** Prefix of the extension-less audio route served by `server/http/media.js`. */
|
||||
export const MEDIA_PREFIX = '/media/';
|
||||
|
||||
/** Extensions a `/media/…` request may resolve to, in the order the server tries them. */
|
||||
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
// test/build.test.js — the served-runtime build tag (server/index.js computeBuildTag / buildTag): the signal that lets
|
||||
// an already-open page notice a deploy (public/js/ui/buildGuard.js; /healthz `build`).
|
||||
// test/build.test.js — the served-runtime build tag (server/http/buildTag.js computeBuildTag / buildTag, exported by
|
||||
// server/index.js): the signal that lets an already-open page notice a deploy (public/js/ui/buildGuard.js;
|
||||
// /healthz `build`).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
|
||||
@@ -36,7 +36,7 @@ function walk(dir, ext, out = []) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** URL path served by the server → file on disk (mirrors server/index.js mounts). */
|
||||
/** URL path served by the server → file on disk (mirrors the server/http/static.js mounts). */
|
||||
function urlPathToFile(urlPath) {
|
||||
const clean = decodeURIComponent(urlPath.split(/[?#]/)[0]);
|
||||
if (clean.startsWith('/shared/')) return path.join(ROOT, clean);
|
||||
|
||||
@@ -23,7 +23,7 @@ const manifest = {
|
||||
'ui/battle': { img_chess_level_3: { path: '/assets/local/ui/battle/img_chess_level_3.png', w: 45, h: 45 } },
|
||||
},
|
||||
};
|
||||
// what server/index.js serves when data/local-assets.json is absent
|
||||
// what the server (server/http/static.js) serves when data/local-assets.json is absent
|
||||
const EMPTY_LOCAL = { version: 1, source: 'none', count: 0, groups: {} };
|
||||
|
||||
let mode = 'ok'; // data/local-assets.json: 'ok' (the manifest above) | 'empty' (the server's stand-in) | 'missing' (404)
|
||||
|
||||
@@ -36,9 +36,9 @@ test('CHANGELOG.md opens with the release version, and the README links it', ()
|
||||
test('the release version is what players see', () => {
|
||||
assert.match(read('public/js/screens/title.js'), /v\$\{APP_VERSION\}/, 'title screen footer');
|
||||
assert.ok(!/PROTOCOL v1/.test(read('public/js/screens/title.js')), 'no protocol number posing as a version');
|
||||
const server = read('server/index.js');
|
||||
assert.match(server, /Stronghold Protocol: Alliance v\$\{APP_VERSION\}/, 'boot banner');
|
||||
assert.match(server, /app: APP_VERSION/, '/healthz');
|
||||
// the server's entry is server/index.js; the banner and /healthz live in server/http/ (boot.js, routes.js)
|
||||
assert.match(read('server/http/boot.js'), /Stronghold Protocol: Alliance v\$\{APP_VERSION\}/, 'boot banner');
|
||||
assert.match(read('server/http/routes.js'), /app: APP_VERSION/, '/healthz');
|
||||
});
|
||||
|
||||
test('the English title is the official one: Stronghold Protocol: Alliance (as in the reply to GitHub issue #38, which stays open)', () => {
|
||||
@@ -47,11 +47,11 @@ test('the English title is the official one: Stronghold Protocol: Alliance (as i
|
||||
const readme = read('README.md');
|
||||
assert.match(readme.split('\n')[0], /^# 卫戍协议:盟约 · Stronghold Protocol: Alliance$/, 'README title');
|
||||
assert.match(readme, /mode \*Stronghold Protocol: Alliance\*/, 'README English summary');
|
||||
assert.match(read('server/index.js'), /卫戍协议:盟约 · Stronghold Protocol: Alliance v/, 'boot banner');
|
||||
assert.match(read('server/http/boot.js'), /卫戍协议:盟约 · Stronghold Protocol: Alliance v/, 'boot banner');
|
||||
assert.equal(pkg.name, 'stronghold-protocol-alliance');
|
||||
assert.equal(lock.name, pkg.name);
|
||||
assert.equal(lock.packages[''].name, pkg.name);
|
||||
for (const f of ['README.md', 'server/index.js', 'package.json', 'package-lock.json', 'NOTICE.md', 'public/index.html', 'docs/DEPLOY.md']) {
|
||||
for (const f of ['README.md', 'server/index.js', 'server/http/boot.js', 'package.json', 'package-lock.json', 'NOTICE.md', 'public/index.html', 'docs/DEPLOY.md']) {
|
||||
assert.ok(!/covenant/i.test(read(f)), `${f}: no "Covenant" title left`);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user