diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 00000000..b8df9954 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,7 @@ +# Commits that only move code. Blame across them with +# git blame -C -C --ignore-revs-file .git-blame-ignore-revs +# (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 diff --git a/README.md b/README.md index f3777127..da9a0f17 100644 --- a/README.md +++ b/README.md @@ -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` | diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 54821f00..2909813b 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -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 diff --git a/public/js/media.js b/public/js/media.js index 7d125407..1d716ac1 100644 --- a/public/js/media.js +++ b/public/js/media.js @@ -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 diff --git a/public/js/ui/buildGuard.js b/public/js/ui/buildGuard.js index c9ed37ec..2be1c196 100644 --- a/public/js/ui/buildGuard.js +++ b/public/js/ui/buildGuard.js @@ -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: diff --git a/server/http/boot.js b/server/http/boot.js new file mode 100644 index 00000000..68002e64 --- /dev/null +++ b/server/http/boot.js @@ -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 }>} 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')); +} diff --git a/server/http/buildTag.js b/server/http/buildTag.js new file mode 100644 index 00000000..ebff88c0 --- /dev/null +++ b/server/http/buildTag.js @@ -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; } diff --git a/server/http/common.js b/server/http/common.js new file mode 100644 index 00000000..b3c8fcce --- /dev/null +++ b/server/http/common.js @@ -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 ` +${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: '' }; +} + +/** 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 */ } +} diff --git a/server/http/config.js b/server/http/config.js new file mode 100644 index 00000000..4dd86a23 --- /dev/null +++ b/server/http/config.js @@ -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) : () => {}, + }; +} diff --git a/server/http/files.js b/server/http/files.js new file mode 100644 index 00000000..c15de0e6 --- /dev/null +++ b/server/http/files.js @@ -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