diff --git a/server/http/boot.js b/server/http/boot.js
new file mode 100644
index 00000000..901a3dd9
--- /dev/null
+++ b/server/http/boot.js
@@ -0,0 +1,14 @@
+// server/http/boot.js — what the process prints when it boots (moved from server/index.js).
+
+import os from 'node:os';
+
+/** 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;
+}
diff --git a/server/http/buildTag.js b/server/http/buildTag.js
new file mode 100644
index 00000000..74d73ccd
--- /dev/null
+++ b/server/http/buildTag.js
@@ -0,0 +1,71 @@
+// server/http/buildTag.js — the build tag of the browser runtime this process serves (/healthz `build`).
+// Moved from server/index.js.
+
+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; }
diff --git a/server/http/common.js b/server/http/common.js
new file mode 100644
index 00000000..ac644288
--- /dev/null
+++ b/server/http/common.js
@@ -0,0 +1,37 @@
+// server/http/common.js — the helpers every HTTP route shares: URL split, the error page and JSON replies.
+// Moved from server/index.js.
+
+const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
+
+function errorPage(status, title, detail = '') {
+ return `
+${status} · 卫戍协议:盟约 ${status} ${escapeHtml(title)}
+${detail ? `${escapeHtml(detail)}
` : ''}返回首页 · Back to home
`;
+}
+
+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: '' };
+}
diff --git a/server/http/config.js b/server/http/config.js
new file mode 100644
index 00000000..b07e78db
--- /dev/null
+++ b/server/http/config.js
@@ -0,0 +1,28 @@
+// server/http/config.js — the repository root and the settings the server reads from the environment
+// (TRUST_PROXY → net.js trustProxy, DEBUG → the console logger's debug level). Moved from server/index.js.
+
+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() {} };
+
+/** 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';
+}
+
+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) : () => {},
+ };
+}
diff --git a/server/http/files.js b/server/http/files.js
new file mode 100644
index 00000000..2e64ec84
--- /dev/null
+++ b/server/http/files.js
@@ -0,0 +1,264 @@
+// server/http/files.js — one file on disk → an HTTP response (moved from server/index.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 ).
+
+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} */ this.map = new Map();
+ /** @type {Map>} */ this.inflight = new Map();
+ }
+
+ /** @returns {Promise} 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();
+ }
+}
diff --git a/server/http/media.js b/server/http/media.js
new file mode 100644
index 00000000..71cf872e
--- /dev/null
+++ b/server/http/media.js
@@ -0,0 +1,52 @@
+// server/http/media.js — the extension-less audio route /media/… → public/assets/audio (moved from server/index.js).
+
+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');
+}
diff --git a/server/http/static.js b/server/http/static.js
new file mode 100644
index 00000000..f53f209e
--- /dev/null
+++ b/server/http/static.js
@@ -0,0 +1,137 @@
+// server/http/static.js — the static file server (moved from server/index.js):
+// * 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)
+// traversal & dotfile protection; 404 page. 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). */
+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
+// ---------------------------------------------------------------------------------------------------
+
+/**
+ * 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}
+ */
+/** 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);
+ };
+}
diff --git a/server/http/websocket.js b/server/http/websocket.js
new file mode 100644
index 00000000..43df6a7c
--- /dev/null
+++ b/server/http/websocket.js
@@ -0,0 +1,4 @@
+// server/http/websocket.js — the WebSocket endpoint (moved from server/index.js).
+
+/** Inbound WebSocket frame limit (DESIGN §8). */
+export const WS_MAX_PAYLOAD = 64 * 1024;
diff --git a/server/index.js b/server/index.js
index 7a8a8347..baefe9e9 100644
--- a/server/index.js
+++ b/server/index.js
@@ -30,573 +30,33 @@
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, parseTrustProxy, makeLogger } from './http/config.js';
+import { sendError, sendJson, splitUrl } from './http/common.js';
+import { MIME, COMPRESSIBLE, acceptsGzip, parseRange } from './http/files.js';
+import { DATA_SHIM_JS, createStaticHandler } from './http/static.js';
+import { BUILD_INPUTS, computeBuildTag, buildTag, resetBuildTag } from './http/buildTag.js';
+import { WS_MAX_PAYLOAD } from './http/websocket.js';
+import { lanUrls } from './http/boot.js';
-/** Repository root. */
-export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+// 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,
+};
-/** 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} */ this.map = new Map();
- /** @type {Map>} */ this.inflight = new Map();
- }
-
- /** @returns {Promise} 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 `
-${status} · 卫戍协议:盟约 ${status} ${escapeHtml(title)}
-${detail ? `${escapeHtml(detail)}
` : ''}返回首页 · Back to home
`;
-}
-
-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}
- */
-/** 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) : () => {},
- };
-}
-
/**
* Build and start the HTTP + WebSocket server.
* @param {{