From b48a67caec5ba0699097e21f3db4d4dbb35a2aed Mon Sep 17 00:00:00 2001 From: sganggs Date: Mon, 5 Oct 2026 16:02:33 +0800 Subject: [PATCH] =?UTF-8?q?feedback5=20R1:=20pure=20move=20=E2=80=94=20the?= =?UTF-8?q?=20top-level=20code=20of=20server/index.js=20goes=20to=20server?= =?UTF-8?q?/http/=20(config,=20common,=20files,=20static,=20media,=20build?= =?UTF-8?q?Tag,=20websocket,=20boot)=20verbatim;=20index.js=20imports=20it?= =?UTF-8?q?=20and=20re-exports=20the=20same=2015=20names,=20startServer=20?= =?UTF-8?q?and=20main=20are=20untouched?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only changes besides the move: `export` on helpers another module now needs (sendError, sendJson, splitUrl, GzipCache, isNotModified, serveFile, serveMedia, noopLog, makeLogger), ROOT resolves two levels up from server/http/config.js (same path), and short headers on the new files. --- server/http/boot.js | 14 + server/http/buildTag.js | 71 +++++ server/http/common.js | 37 +++ server/http/config.js | 28 ++ server/http/files.js | 264 ++++++++++++++++++ server/http/media.js | 52 ++++ server/http/static.js | 137 ++++++++++ server/http/websocket.js | 4 + server/index.js | 564 +-------------------------------------- 9 files changed, 619 insertions(+), 552 deletions(-) create mode 100644 server/http/boot.js create mode 100644 server/http/buildTag.js create mode 100644 server/http/common.js create mode 100644 server/http/config.js create mode 100644 server/http/files.js create mode 100644 server/http/media.js create mode 100644 server/http/static.js create mode 100644 server/http/websocket.js 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