0.2.6-dev: merge claude/bw-l01 — host bandwidth layers 0 and 1 (no BGM download while the music is silent, WebSocket permessage-deflate, lossless WebP 玩法说明 pages, traffic counters, content-hashed asset URLs with immutable caching, brotli q5) on top of the 0.2.5 release; its note becomes DESIGN §31 in docs/history/0.2.6.md, CHANGELOG 0.2.6-dev, version 0.2.6-dev; golden: unchanged

This commit is contained in:
sganggs committed 2026-10-12 18:28:50 +08:00
commit 3d5d6b380d
47 files changed
+2325 -136

No files matched your search

+352
View File
@@ -0,0 +1,352 @@
// server/http/assetIndex.js — the content hashes of the served art (host bandwidth, layer 1; docs/ASSETS.md "The served
// manifests"):
//
// * built in the background shortly after the start (ASSET_INDEX_DELAY_MS): every file under public/assets/ and
// public/fonts/ (dot names skipped like the static server does, linked folders followed once), the first 16 hex
// digits of the SHA-256 of its bytes (WebCrypto, off the main thread);
// * remembered in .cache/asset-hashes.json by path + size + mtime: a restart only stats the files and reads none of
// the unchanged ones again; a file whose size or mtime changed is hashed again (a re-extraction, `setup --force`,
// a Docker rebuild then costs the players only the files whose bytes changed);
// * never trusted beyond what it checked: `hashOf(rel, stat)` answers only while the file still has the size and
// mtime it was hashed with, else null — the caller then serves the plain way (1-day cache, size-mtime ETag) and
// the miss asks for a rescan (at most one every RESCAN_GAP_MS);
// * models: lists of files that share one URL hash — a Spine skeleton, its atlas and its pages (pixi-spine finds the
// atlas next to the skeleton and the pages next to the atlas, so all their URLs carry the same segment). A model's
// hash is the SHA-256 of its members' hashes; assetViews.js hands the lists over (`setModels`), read from the
// manifests. `urlHashOk` accepts a file's own hash or the hash of a model it belongs to.
//
// Rel paths are POSIX and relative to public/ ('assets/char/avatar/x.png', 'fonts/bender-regular.woff2'). A rel that
// differs from a file's name only in case finds that file when no other name matches it that way (Windows and macOS
// serve `wah-y.png` from `WAH-Y.png`; on Linux the request is a 404 anyway).
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import { createHash, webcrypto } from 'node:crypto';
import { ASSET_HASH_HEX } from '../../shared/assetPaths.js';
import { noopLog } from './config.js';
import { IMMUTABLE_CACHE } from './files.js';
/** Folders of public/ whose files are hashed. */
export const ASSET_INDEX_ROOTS = Object.freeze(['assets', 'fonts']);
/** The first build waits this long after the start (a server that is only started and stopped again — tests — reads nothing). */
export const ASSET_INDEX_DELAY_MS = 1000;
/** Shortest time between two rescans a stale or unknown file asks for. */
export const RESCAN_GAP_MS = 10_000;
/** Version of .cache/asset-hashes.json. */
export const HASH_CACHE_VERSION = 1;
const STAT_CONCURRENCY = 64;
const HASH_CONCURRENCY = 4;
const hex = (bytes) => Buffer.from(bytes).toString('hex').slice(0, ASSET_HASH_HEX);
/** First ASSET_HASH_HEX hex digits of the SHA-256 of `data` (synchronous; model hashes, manifests, tests). */
export function hash16(data) {
return createHash('sha256').update(data).digest('hex').slice(0, ASSET_HASH_HEX);
}
/** The same for a file's bytes, computed off the main thread (WebCrypto runs in libuv's thread pool). */
async function digest16(buf) {
return hex(await webcrypto.subtle.digest('SHA-256', buf));
}
/** Run `fn` over `items` with at most `n` at a time; stops early when `stop()` says so. */
async function eachLimit(items, n, fn, stop) {
let i = 0;
const worker = async () => {
while (i < items.length && !stop()) {
const item = items[i++];
await fn(item);
}
};
await Promise.all(Array.from({ length: Math.min(n, items.length) }, worker));
}
/**
* The asset index of one server.
* @param {{ publicDir: string, roots?: readonly string[], cacheFile?: string | null, log?: object, delayMs?: number,
* rescanGapMs?: number, now?: () => number }} opts
* cacheFile: where the hashes persist (null: in memory only — a test's temporary public/)
*/
export function createAssetIndex({ publicDir, roots = ASSET_INDEX_ROOTS, cacheFile = null, log = noopLog, delayMs = ASSET_INDEX_DELAY_MS,
rescanGapMs = RESCAN_GAP_MS, now = Date.now }) {
const base = path.resolve(publicDir);
/** @type {Map<string, { size: number, mtimeMs: number, hash: string }>} */
let records = new Map();
/** @type {Map<string, string | null>} lower-case rel → the rel (null: two names differ only in case) */
let lower = new Map();
/** @type {string[][]} */
let modelLists = [];
/** @type {Map<string, Set<string>>} rel → hashes of the models it belongs to */
let modelOf = new Map();
let generation = 0;
let ready = false;
let closed = false;
let building = null; // the build in flight (a promise)
let again = false; // another build was asked for while one ran
let startTimer = null;
let rescanTimer = null;
let lastBuildAt = -Infinity;
let readyResolve;
const readyPromise = new Promise((r) => { readyResolve = r; });
const listeners = new Set();
const last = { files: 0, bytes: 0, hashed: 0, reused: 0, ms: 0, at: null, error: null };
/** The cache file's records ({} when absent, unreadable or another version). */
function loadCache() {
if (!cacheFile) return new Map();
try {
const doc = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
if (!doc || doc.version !== HASH_CACHE_VERSION || typeof doc.files !== 'object' || !doc.files) return new Map();
const out = new Map();
for (const [rel, v] of Object.entries(doc.files)) {
if (Array.isArray(v) && Number.isFinite(v[0]) && Number.isFinite(v[1]) && typeof v[2] === 'string' && v[2].length === ASSET_HASH_HEX) {
out.set(rel, { size: v[0], mtimeMs: v[1], hash: v[2] });
}
}
return out;
} catch {
return new Map();
}
}
/** Write the records (temp file + rename: a concurrent reader never sees half a file). Failures only log. */
async function saveCache(map) {
if (!cacheFile) return;
const files = {};
for (const [rel, r] of [...map].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))) files[rel] = [r.size, r.mtimeMs, r.hash];
const tmp = `${cacheFile}.${process.pid}.tmp`;
try {
await fsp.mkdir(path.dirname(cacheFile), { recursive: true });
await fsp.writeFile(tmp, JSON.stringify({ version: HASH_CACHE_VERSION, algo: 'sha256/16', files }));
await fsp.rename(tmp, cacheFile);
} catch (e) {
log.debug?.('[assets] hash cache not saved', e?.code || e?.message);
fsp.rm(tmp, { force: true }).catch(() => {});
}
}
/** Every file under the roots: [rel, abs] (dot names skipped, a folder reached twice — a link loop — walked once). */
async function listFiles() {
const out = [];
const seen = new Set();
const walk = async (abs, rel) => {
let real;
try { real = await fsp.realpath(abs); } catch { return; }
if (seen.has(real)) return;
seen.add(real);
let ents;
try { ents = await fsp.readdir(abs, { withFileTypes: true }); } catch { return; }
for (const d of ents) {
if (closed) return;
if (d.name.startsWith('.')) continue;
const childAbs = path.join(abs, d.name);
const childRel = `${rel}/${d.name}`;
if (d.isDirectory()) await walk(childAbs, childRel);
else if (d.isFile()) out.push([childRel, childAbs]);
else if (d.isSymbolicLink()) {
const st = await fsp.stat(childAbs).catch(() => null);
if (st?.isDirectory()) await walk(childAbs, childRel);
else if (st?.isFile()) out.push([childRel, childAbs]);
}
}
};
for (const root of roots) await walk(path.join(base, root), root);
return out;
}
/** Model hashes from the current records (a model with a member the index does not know has none). */
function computeModels() {
const next = new Map();
for (const list of modelLists) {
const h = modelHashOf(list);
if (!h) continue;
for (const rel of list) {
const c = canonical(rel);
let set = next.get(c);
if (!set) next.set(c, (set = new Set()));
set.add(h);
}
}
modelOf = next;
}
/** The indexed name of `rel`: itself, or the one file whose name differs from it only in case (else null). */
function canonical(rel) {
if (typeof rel !== 'string') return null;
if (records.has(rel)) return rel;
return lower.get(rel.toLowerCase()) || null;
}
function recordOf(rel) {
const c = canonical(rel);
return c ? records.get(c) : null;
}
function modelHashOf(list) {
if (!Array.isArray(list) || !list.length) return null;
const parts = [];
for (const rel of list) {
const r = recordOf(rel);
if (!r) return null;
parts.push(r.hash);
}
return hash16(parts.join('\n'));
}
/** One build; true when the records changed (a new generation). */
async function build() {
const t0 = now();
const known = records.size ? records : loadCache();
const next = new Map();
let hashed = 0;
let reused = 0;
let bytes = 0;
const files = await listFiles();
const toHash = [];
await eachLimit(files, STAT_CONCURRENCY, async ([rel, abs]) => {
const st = await fsp.stat(abs).catch(() => null);
if (!st || !st.isFile()) return;
bytes += st.size;
const k = known.get(rel);
if (k && k.size === st.size && k.mtimeMs === st.mtimeMs) { next.set(rel, k); reused++; } else toHash.push([rel, abs, st]);
}, () => closed);
await eachLimit(toHash, HASH_CONCURRENCY, async ([rel, abs, st]) => {
try {
const buf = await fsp.readFile(abs);
const after = await fsp.stat(abs);
// changed while it was read: hashed again by the next build
if (after.size !== st.size || after.mtimeMs !== st.mtimeMs || buf.length !== st.size) return;
next.set(rel, { size: st.size, mtimeMs: st.mtimeMs, hash: await digest16(buf) });
hashed++;
} catch { /* vanished or unreadable: not in the index */ }
}, () => closed);
if (closed) return false;
// commit: records, model hashes and the generation change together (one synchronous step)
let changed = next.size !== records.size;
if (!changed) for (const [rel, r] of next) { const o = records.get(rel); if (!o || o.hash !== r.hash || o.size !== r.size || o.mtimeMs !== r.mtimeMs) { changed = true; break; } }
const cacheChanged = hashed > 0 || next.size !== known.size;
records = next;
lower = new Map();
for (const rel of next.keys()) { const k = rel.toLowerCase(); lower.set(k, lower.has(k) ? null : rel); }
if (changed) { generation++; computeModels(); }
Object.assign(last, { files: next.size, bytes, hashed, reused, ms: now() - t0, at: new Date().toISOString(), error: null });
lastBuildAt = now();
if (cacheChanged) await saveCache(next);
if (hashed) log.info?.(`[assets] content hashes: ${next.size} files, ${hashed} hashed, ${reused} unchanged (${Math.round(last.ms)} ms)`);
return changed;
}
function markReady() {
if (ready) return;
ready = true;
readyResolve();
}
function notify() {
for (const fn of [...listeners]) { try { fn(generation); } catch (e) { log.error?.('[assets] index listener failed', e); } }
}
/** Build now (or once more after the build in flight). */
function refresh() {
if (closed) return Promise.resolve();
if (building) { again = true; return building; }
building = (async () => {
try {
do {
again = false;
const changed = await build();
if (closed) break;
markReady();
if (changed) notify();
} while (again && !closed);
} catch (e) {
last.error = e?.message || String(e);
log.warn?.('[assets] content hash index failed', e?.message || e);
} finally {
building = null;
if (!closed) markReady();
}
})();
return building;
}
return {
/** Start the background build (after `delayMs`); returns at once. Idempotent. */
start() {
if (closed || startTimer || building || ready) return;
if (!(delayMs > 0)) { refresh(); return; }
startTimer = setTimeout(() => { startTimer = null; refresh(); }, delayMs);
startTimer.unref?.();
},
refresh,
/** Resolves once the first build is done (never rejects). */
whenReady() { return readyPromise; },
get ready() { return ready; },
get generation() { return generation; },
/** Files with a hash. */
get size() { return records.size; },
/**
* The file's content hash while it still has the size and mtime it was hashed with (`stat` given), else null.
* Without `stat`: the hash of the last build.
* @param {string} rel @param {{ size: number, mtimeMs: number }} [stat]
*/
hashOf(rel, stat) {
const r = recordOf(rel);
if (!r) return null;
if (stat && (stat.size !== r.size || stat.mtimeMs !== r.mtimeMs)) return null;
return r.hash;
},
/** Does `urlHash` name exactly these bytes: the file's own hash, or the hash of a model it belongs to? */
urlHashOk(rel, stat, urlHash) {
if (typeof urlHash !== 'string') return false;
const own = this.hashOf(rel, stat);
if (!own) return false;
return own === urlHash || !!modelOf.get(canonical(rel))?.has(urlHash);
},
/** The hash of a model (its members' hashes hashed), null while a member is unknown. */
modelHash: (list) => modelHashOf(list),
/** Replace the model lists (assetViews.js, from the manifests). */
setModels(lists) {
modelLists = Array.isArray(lists) ? lists.filter((l) => Array.isArray(l) && l.length) : [];
computeModels();
},
/** A file was served that the index does not know, or no longer matches: rescan (at most one per rescanGapMs). */
noteMiss() {
if (closed || !ready || rescanTimer || building) return;
const wait = Math.max(0, lastBuildAt + rescanGapMs - now());
rescanTimer = setTimeout(() => { rescanTimer = null; refresh(); }, wait);
rescanTimer.unref?.();
},
/** `fn(generation)` after a build changed the records; returns the unsubscribe. */
onChange(fn) { listeners.add(fn); return () => listeners.delete(fn); },
stats() {
return { ready, building: !!building, generation, files: records.size, models: modelLists.length, last: { ...last } };
},
/** Stop: timers cleared, a build in flight abandoned. */
close() {
closed = true;
if (startTimer) { clearTimeout(startTimer); startTimer = null; }
if (rescanTimer) { clearTimeout(rescanTimer); rescanTimer = null; }
readyResolve(); // a waiter is let go; `ready` stays as it was
},
};
}
/**
* The ETag source and cache policy of a file of the asset index's folders (public/assets, public/fonts), for
* serveFile: its content hash when the index has it for these bytes (a miss asks for a rescan); for a URL with a hash
* segment `immutable` when that hash names these bytes, else `no-cache`.
* @param {ReturnType<typeof createAssetIndex> | null} assetIndex
* @param {string} rel the file under public/ ('assets/…', POSIX)
* @param {import('node:fs').Stats} stat
* @param {string | null} urlHash the URL's hash segment
* @returns {{ contentHash?: string | null, cacheControl?: string }}
*/
export function hashedFileOpts(assetIndex, rel, stat, urlHash) {
if (!assetIndex) return urlHash ? { cacheControl: 'no-cache' } : {};
const contentHash = assetIndex.hashOf(rel, stat);
if (!contentHash) assetIndex.noteMiss(rel);
if (!urlHash) return { contentHash };
return { contentHash, cacheControl: contentHash && assetIndex.urlHashOk(rel, stat, urlHash) ? IMMUTABLE_CACHE : 'no-cache' };
}
+185
View File
@@ -0,0 +1,185 @@
// server/http/assetViews.js — the served manifests (host bandwidth, layer 1; docs/ASSETS.md "The served manifests"):
// data/assets.json and data/local-assets.json as this server hands them out, every art URL carrying the content hash
// of its file as its first segment (shared/assetPaths.js) — `/assets/char/avatar/x.png` →
// `/assets/~<hash>/char/avatar/x.png`, `/fonts/~<hash>/…` — so the browser keeps the file for a year and asks again
// only when the bytes change. Audio keeps the `/assets/~<hash>/audio/….mp3` form: public/js/media.js turns it into
// `/media/~<hash>/…` when it fetches it.
//
// * A Spine model's skeleton, atlas and pages carry ONE hash, that of the model (assetIndex.js models): pixi-spine
// finds the atlas by the skeleton's name and the pages next to the atlas, so their URLs share a folder. A model is
// every `{ skel, atlas, textures }` object of data/assets.json (chars' front / back, tokens, enemies) and every
// `spine/…` group of data/local-assets.json (the files a `spineLocal` record names).
// * A URL the index has no hash for (not on disk, not hashed yet) stays as it is.
// * The committed data/assets.json is never written: a view is built in memory from the file and the index, again
// when either changes (the file's size / mtime, the index's generation), and once after every index build, so the
// index knows the model lists before any page asks.
// * No index, or not ready yet: no view (`get` → null) and the files go out as they are.
import fsp from 'node:fs/promises';
import path from 'node:path';
import { hashedPath } from '../../shared/assetPaths.js';
import { hash16 } from './assetIndex.js';
import { noopLog } from './config.js';
/** The manifests that have a view, by their name under data/. */
export const VIEW_MANIFESTS = Object.freeze(['assets.json', 'local-assets.json']);
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
const ART_URL = /^\/(?:assets|fonts)\/./;
/** URL path of a manifest → the index's rel path ('/assets/a%20b.png' → 'assets/a b.png'), or null. */
export function relOf(url) {
if (typeof url !== 'string' || !ART_URL.test(url) || /[?#]/.test(url)) return null;
let p;
try { p = decodeURIComponent(url); } catch { return null; }
if (p.includes('\0') || p.includes('\\')) return null;
const segs = p.slice(1).split('/');
if (segs.some((s) => s === '' || s === '.' || s === '..')) return null;
return segs.join('/');
}
/** Is `o` a Spine object of data/assets.json (`{ skel: '/….skel', atlas, textures? }`)? */
const isSpine = (o) => isObj(o) && typeof o.skel === 'string' && o.skel.startsWith('/') && /\.skel$/.test(o.skel);
/** The URLs of a Spine object: skeleton, atlas, pages (the `<skel name>.png` when it lists none). */
function spineUrls(o) {
const out = [o.skel];
if (typeof o.atlas === 'string' && o.atlas) out.push(o.atlas);
const pages = Array.isArray(o.textures) ? o.textures.filter((u) => typeof u === 'string' && u) : [];
out.push(...(pages.length ? pages : [o.skel.replace(/\.skel$/, '.png')]));
return out;
}
/**
* The served copy of a manifest and its model lists. `index`: hashOf(rel) / modelHash(rels) of assetIndex.js.
* @param {any} doc the parsed manifest (not changed)
* @param {'assets.json'|'local-assets.json'} name
* @param {{ hashOf: (rel: string) => string | null, modelHash: (rels: string[]) => string | null }} index
* @returns {{ doc: any, models: string[][], hashed: number, plain: number }}
*/
export function rewriteManifest(doc, name, index) {
const models = [];
let hashed = 0;
let plain = 0;
const own = (url) => {
const rel = relOf(url);
const h = rel ? index.hashOf(rel) : null;
if (h) { hashed++; return hashedPath(url, h); }
if (rel) plain++;
return url;
};
/** All URLs of one model with the model's hash (or all left plain when a member is unknown). */
const model = (urls) => {
const rels = urls.map(relOf);
if (rels.some((r) => !r)) return null;
models.push(rels);
const h = index.modelHash(rels);
if (!h) { plain += urls.length; return null; }
hashed += urls.length;
return h;
};
const walk = (v) => {
if (typeof v === 'string') return ART_URL.test(v) ? own(v) : v;
if (Array.isArray(v)) return v.map(walk);
if (!isObj(v)) return v;
if (name === 'assets.json' && isSpine(v)) {
const h = model(spineUrls(v));
const out = {};
for (const [k, x] of Object.entries(v)) {
if (h && k === 'skel') out[k] = hashedPath(x, h);
else if (h && k === 'atlas' && typeof x === 'string') out[k] = hashedPath(x, h);
else if (h && k === 'textures' && Array.isArray(x)) out[k] = x.map((u) => (typeof u === 'string' ? hashedPath(u, h) : u));
else out[k] = walk(x);
}
return out;
}
const out = {};
for (const [k, x] of Object.entries(v)) out[k] = walk(x);
return out;
};
if (name === 'local-assets.json' && isObj(doc) && isObj(doc.groups)) {
const groups = {};
for (const [g, entries] of Object.entries(doc.groups)) {
if (g.startsWith('spine/') && isObj(entries)) {
const names = Object.keys(entries).filter((n) => isObj(entries[n]) && typeof entries[n].path === 'string').sort();
const h = names.length ? model(names.map((n) => entries[n].path)) : null;
groups[g] = {};
for (const [n, e] of Object.entries(entries)) {
groups[g][n] = h && isObj(e) && typeof e.path === 'string' ? { ...e, path: hashedPath(e.path, h) } : walk(e);
}
} else groups[g] = walk(entries);
}
return { doc: { ...walk({ ...doc, groups: null }), groups }, models, hashed, plain };
}
return { doc: walk(doc), models, hashed, plain };
}
/**
* The views of one server.
* @param {{ dataDir: string, index: ReturnType<typeof import('./assetIndex.js').createAssetIndex>, log?: object }} opts
*/
export function createManifestViews({ dataDir, index, log = noopLog }) {
const dir = path.resolve(dataDir);
/** @type {Map<string, { key: string, view: { body: Buffer, hash: string, hashed: number, plain: number } | null, models: string[][] }>} */
const cache = new Map();
/** @type {Map<string, Promise<any>>} */
const pending = new Map();
const warned = new Set();
const pushModels = () => index.setModels([...cache.values()].flatMap((c) => c.models));
/** Build (or reuse) the view of `name`; null without the file, while the index is not ready or empty, or for a broken file. */
async function build(name) {
if (!index.ready || !index.size) return null;
const file = path.join(dir, name);
const st = await fsp.stat(file).catch(() => null);
if (!st || !st.isFile()) {
if (cache.delete(name)) pushModels();
return null;
}
const key = `${index.generation}|${st.size}|${st.mtimeMs}`;
const hit = cache.get(name);
if (hit && hit.key === key) return hit.view;
let doc;
try {
doc = JSON.parse(await fsp.readFile(file, 'utf8'));
} catch (e) {
if (!warned.has(name)) { warned.add(name); log.warn?.(`[assets] data/${name} unreadable (${e?.message || e}); served as it is`); }
cache.set(name, { key, view: null, models: [] });
pushModels();
return null;
}
// one synchronous step: the view and the model lists the index checks a model's URLs against
const { doc: out, models, hashed, plain } = rewriteManifest(doc, name, index);
const body = Buffer.from(JSON.stringify(out));
const view = { body, hash: hash16(body), hashed, plain };
cache.set(name, { key, view, models });
pushModels();
return view;
}
/** The view of `name` (one build at a time per manifest). */
function get(name) {
if (!VIEW_MANIFESTS.includes(name)) return Promise.resolve(null);
const running = pending.get(name);
if (running) return running;
const p = build(name).catch((e) => { log.error?.('[assets] manifest view failed', e); return null; }).finally(() => pending.delete(name));
pending.set(name, p);
return p;
}
const rebuildAll = () => Promise.all(VIEW_MANIFESTS.map(get));
// after every build that changed the hashes (and the first one): the views, and with them the index's model lists
index.onChange(() => { rebuildAll(); });
index.whenReady().then(() => rebuildAll(), () => {});
return {
get,
rebuildAll,
stats() {
const out = {};
for (const [name, c] of cache) out[name] = c.view ? { bytes: c.view.body.length, hashed: c.view.hashed, plain: c.view.plain } : null;
return out;
},
};
}
+21
View File
@@ -4,6 +4,8 @@
// * 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;
// * SP_ASSET_INDEX=0 (or the `assetIndex: false` option) → no content-hash index: plain asset URLs, size-mtime ETags
// (assetIndex.js); the index of the default public/ persists in .cache/asset-hashes.json;
// * the served directories (public/, data/, shared/ and the content packs' packs/ of this repository unless the
// options name others), and which startServer() options are handed on to net.js Network and lobby.js Lobby.
@@ -64,6 +66,25 @@ export function serveDirs(opts) {
};
}
/**
* The asset index options out of the startServer() options and the environment, or null for none: `assetIndex: false`
* or SP_ASSET_INDEX=0 / off / false / no switches it off; `assetIndex: { cacheFile, delayMs }` overrides the defaults.
* The hashes persist in .cache/asset-hashes.json only for the repository's own public/ (a test's temporary folder keeps
* them in memory).
* @param {{ assetIndex?: boolean | { cacheFile?: string | null, delayMs?: number } }} opts
* @param {string} publicDir
* @returns {{ cacheFile: string | null, delayMs?: number } | null}
*/
export function assetIndexOptionsFrom(opts, publicDir) {
if (opts.assetIndex === false) return null;
const env = String(process.env.SP_ASSET_INDEX ?? '').trim().toLowerCase();
if (opts.assetIndex == null && ['0', 'off', 'false', 'no'].includes(env)) return null;
const o = typeof opts.assetIndex === 'object' && opts.assetIndex ? opts.assetIndex : {};
const own = path.resolve(publicDir) === path.join(ROOT, 'public');
const cacheFile = o.cacheFile !== undefined ? o.cacheFile : (own ? path.join(ROOT, '.cache', 'asset-hashes.json') : null);
return { cacheFile, ...(Number.isFinite(o.delayMs) ? { delayMs: o.delayMs } : {}) };
}
/** net.js Network options out of the startServer() options; `trustProxy` falls back to TRUST_PROXY. */
export function netOptionsFrom(opts) {
const netOptions = {};
+145 -37
View File
@@ -1,9 +1,12 @@
// server/http/files.js — one file on disk → an HTTP response (static.js and media.js decide which file):
// server/http/files.js — one file on disk (serveFile) or one body in memory (serveBuffer, the served manifests of
// assetViews.js) → 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>).
// brotli (quality 5) for text-like types, .skel and uncompressed fonts when the client accepts it, else gzip
// (level 6) — browsers offer brotli on HTTPS pages and localhost only (small files are compressed once per
// encoding and cached in memory); strong ETag + Last-Modified with 304s — the ETag is the content hash
// when the caller has one (assetIndex.js), else size + mtime; Cache-Control (html & code/data: no-cache +
// revalidate; public/assets|fonts|vendor: 1 day; a URL whose content-hash segment matches the file (static.js,
// media.js): immutable for a year; any `?v=` URL: immutable); single byte-range requests (206/416, used by <audio>).
import fs from 'node:fs';
import fsp from 'node:fs/promises';
@@ -53,28 +56,60 @@ export const MIME = Object.freeze({
'.ttf': 'font/ttf',
});
/** Extensions worth gzipping (text-like, .skel, uncompressed fonts). */
/** Extensions worth compressing (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',
]);
/** Types brotli compresses in its text mode (the rest of COMPRESSIBLE: generic mode). */
const TEXT_LIKE = new Set(['.html', '.htm', '.js', '.mjs', '.css', '.json', '.map', '.webmanifest', '.txt', '.md', '.csv', '.xml', '.atlas', '.svg']);
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 GZIP_CACHE_MAX_FILE = 8 << 20; // larger files are compressed as a stream on the fly
const GZIP_CACHE_MAX_TOTAL = 96 << 20; // both encodings together
/**
* Brotli quality of the on-the-fly compression: faster than gzip-6 and smaller — measured on this repository against
* gzip-6: .skel −27 %, data/*.json −26 %, vendor −14 %, public/js and server/sim −5 %, .atlas −7 %. Quality 11 would gain
* another ~10 % at ~100× the time, so it is not done per request.
*/
export const BROTLI_QUALITY = 5;
// A plain asset URL (no content-hash segment) names a file tools/fetch-assets.mjs / tools/vendor.mjs can rewrite in
// place (atlas + png + skel must stay consistent), so "long" is one day; revalidation after that is a cheap 304. A
// content-hashed URL whose hash names the file is immutable (static.js, media.js).
export const LONG_CACHE = 'public, max-age=86400'; // 1 day
export 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);
const brotliAsync = promisify(zlib.brotliCompress);
/** zlib options of an encoding for a body of `size` bytes of type `ext`. */
function brotliParams(ext, size) {
return {
params: {
[zlib.constants.BROTLI_PARAM_QUALITY]: BROTLI_QUALITY,
[zlib.constants.BROTLI_PARAM_MODE]: TEXT_LIKE.has(ext) ? zlib.constants.BROTLI_MODE_TEXT : zlib.constants.BROTLI_MODE_GENERIC,
...(size > 0 ? { [zlib.constants.BROTLI_PARAM_SIZE_HINT]: size } : {}),
},
};
}
/** `raw` compressed with `encoding` ('br' | 'gzip'). */
function compress(raw, encoding, ext) {
return encoding === 'br' ? brotliAsync(raw, brotliParams(ext, raw.length)) : gzipAsync(raw, { level: 6 });
}
/** A streaming compressor for a file too large for the cache. */
function compressStream(encoding, ext, size) {
return encoding === 'br' ? zlib.createBrotliCompress(brotliParams(ext, size)) : zlib.createGzip();
}
// ---------------------------------------------------------------------------------------------------
// gzip cache (LRU by bytes)
// compression cache (LRU by bytes; one entry per file and encoding)
// ---------------------------------------------------------------------------------------------------
export class GzipCache {
export class CompressCache {
constructor(maxTotal = GZIP_CACHE_MAX_TOTAL) {
this.maxTotal = maxTotal;
this.total = 0;
@@ -82,20 +117,27 @@ export class GzipCache {
/** @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);
/** @returns {Promise<Buffer>} the file identified by (path, size, mtime) compressed with `encoding` ('gzip' by default) */
get(absPath, stat, encoding = 'gzip') {
return this.compressed(`${absPath}\0${stat.size}\0${stat.mtimeMs}`, () => fsp.readFile(absPath), encoding, path.extname(absPath).toLowerCase());
}
/**
* @returns {Promise<Buffer>} the body `read()` returns compressed with `encoding`, cached under `key` + encoding (one
* compression per entry at a time)
*/
compressed(key, read, encoding = 'gzip', ext = '') {
const k = `${encoding}\0${key}`;
const hit = this.map.get(k);
if (hit) { this.map.delete(k); this.map.set(k, hit); return Promise.resolve(hit); }
const pending = this.inflight.get(k);
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);
const out = await compress(await read(), encoding, ext);
this.store(k, out);
return out;
})().finally(() => this.inflight.delete(k));
this.inflight.set(k, p);
return p;
}
@@ -111,10 +153,46 @@ export class GzipCache {
}
}
/** The cache's former name (it held gzip only). */
export const GzipCache = CompressCache;
// ---------------------------------------------------------------------------------------------------
// HTTP helpers
// ---------------------------------------------------------------------------------------------------
/** Accept-Encoding → { token: q } (lower-case tokens; a malformed q counts as 0). */
function acceptedEncodings(header) {
const out = {};
if (!header || typeof header !== 'string') return out;
for (const part of header.split(',')) {
const [token, ...params] = part.trim().toLowerCase().split(';');
if (!token) continue;
let q = 1;
for (const p of params) {
const m = /^\s*q=([0-9.]+)\s*$/.exec(p);
if (m) q = Number(m[1]);
}
out[token.trim()] = Number.isFinite(q) ? q : 0;
}
return out;
}
/**
* The content coding to answer with: 'br' when the client names brotli (q > 0) at least as high as gzip, else 'gzip'
* when it accepts gzip (or `*`), else null (identity). `*` alone never picks brotli. Browsers name `br` on HTTPS pages
* and localhost only, so a LAN `http://` address gets gzip.
* @param {string | undefined} header
* @returns {'br' | 'gzip' | null}
*/
export function pickEncoding(header) {
const acc = acceptedEncodings(header);
const brQ = acc.br ?? 0;
const gzQ = acc.gzip ?? acc['x-gzip'] ?? acc['*'] ?? 0;
if (brQ > 0 && brQ >= gzQ) return 'br';
if (gzQ > 0) return 'gzip';
return null;
}
/** Does the Accept-Encoding header allow gzip (q > 0)? @param {string | undefined} header */
export function acceptsGzip(header) {
if (!header || typeof header !== 'string') return false;
@@ -190,20 +268,25 @@ function cacheControlFor(ext, mountName, segments, query) {
return 'no-cache';
}
export async function serveFile(req, res, absPath, stat, mountName, segments, query, gzipCache, log) {
/**
* One file → the response.
* @param {{ contentHash?: string | null, cacheControl?: string | null }} [opts] contentHash: the file's content hash
* (assetIndex.js) — the ETag then; cacheControl: overrides the policy of its path (a content-hashed URL)
*/
export async function serveFile(req, res, absPath, stat, mountName, segments, query, gzipCache, log, opts = {}) {
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 encoding = compressible && stat.size >= GZIP_MIN_BYTES && !rangeHeader ? pickEncoding(req.headers['accept-encoding']) : null;
const baseTag = opts.contentHash || `${stat.size.toString(16)}-${Math.floor(stat.mtimeMs).toString(16)}`;
const etag = `"${baseTag}${encoding === 'br' ? '-br' : encoding === 'gzip' ? '-gz' : ''}"`;
const lastModified = stat.mtime.toUTCString();
const isHead = req.method === 'HEAD';
const headers = {
'Content-Type': type,
'Cache-Control': cacheControlFor(ext, mountName, segments, query),
'Cache-Control': opts.cacheControl || cacheControlFor(ext, mountName, segments, query),
ETag: etag,
'Last-Modified': lastModified,
};
@@ -215,18 +298,18 @@ export async function serveFile(req, res, absPath, stat, mountName, segments, qu
return;
}
if (useGzip) {
headers['Content-Encoding'] = 'gzip';
if (encoding) {
headers['Content-Encoding'] = encoding;
if (stat.size <= GZIP_CACHE_MAX_FILE) {
const gz = await gzipCache.get(absPath, stat);
headers['Content-Length'] = gz.length;
const body = await gzipCache.get(absPath, stat, encoding);
headers['Content-Length'] = body.length;
res.writeHead(200, headers);
res.end(isHead ? undefined : gz);
res.end(isHead ? undefined : body);
return;
}
res.writeHead(200, headers);
if (isHead) { res.end(); return; }
await streamTo(fs.createReadStream(absPath), res, log, zlib.createGzip());
await streamTo(fs.createReadStream(absPath), res, log, compressStream(encoding, ext, stat.size));
return;
}
@@ -253,6 +336,31 @@ export async function serveFile(req, res, absPath, stat, mountName, segments, qu
await streamTo(fs.createReadStream(absPath, { start, end }), res, log);
}
/**
* One body in memory → the response (the served manifests): its ETag from `hash`, brotli or gzip when accepted (cached
* under `key` + `hash`), 304 on a matching If-None-Match, no ranges.
* @param {{ body: Buffer, hash: string, type: string, cacheControl: string, key: string, cache: CompressCache }} o
*/
export async function serveBuffer(req, res, { body, hash, type, cacheControl, key, cache }) {
const encoding = body.length >= GZIP_MIN_BYTES ? pickEncoding(req.headers['accept-encoding']) : null;
const etag = `"${hash}${encoding === 'br' ? '-br' : encoding === 'gzip' ? '-gz' : ''}"`;
const headers = { 'Content-Type': type, 'Cache-Control': cacheControl, ETag: etag, Vary: 'Accept-Encoding' };
const inm = req.headers['if-none-match'];
if (typeof inm === 'string' && (inm.trim() === '*' || inm.split(',').some((t) => stripWeak(t) === etag))) {
res.writeHead(304, headers);
res.end();
return;
}
let out = body;
if (encoding) {
out = await cache.compressed(`${key}\0${hash}`, async () => body, encoding, path.extname(key).toLowerCase());
headers['Content-Encoding'] = encoding;
}
headers['Content-Length'] = out.length;
res.writeHead(200, headers);
res.end(req.method === 'HEAD' ? undefined : out);
}
async function streamTo(src, res, log, transform) {
try {
if (transform) await pipeline(src, transform, res);
+10 -3
View File
@@ -6,6 +6,7 @@ import path from 'node:path';
import { AUDIO_EXTS } from '../../shared/media.js';
import { sendError } from './common.js';
import { serveFile } from './files.js';
import { hashedFileOpts } from './assetIndex.js';
/**
* Extension-less audio route: `/media/bgm/act1` → `public/assets/audio/bgm/act1.mp3`.
@@ -15,8 +16,12 @@ import { serveFile } from './files.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.
* `/media/~<hash>/bgm/act1` (a served manifest's `/assets/~<hash>/audio/bgm/act1.mp3`, public/js/media.js): static.js
* strips the segment and hands it over as `urlHash`; the answer is `immutable` when it names the resolved file's bytes
* (assetIndex.js), else `no-cache`, and the ETag is the file's content hash when the index has it.
* @param {{ urlHash?: string | null, assetIndex?: ReturnType<typeof import('./assetIndex.js').createAssetIndex> | null }} [hashing]
*/
export async function serveMedia(req, res, rest, query, publicDir, gzipCache, log) {
export async function serveMedia(req, res, rest, query, publicDir, gzipCache, log, { urlHash = null, assetIndex = null } = {}) {
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; }
@@ -44,9 +49,11 @@ export async function serveMedia(req, res, rest, query, publicDir, gzipCache, lo
} 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).
// Cache policy is that of the public path the client would otherwise have asked for (`/assets/audio/…`, 1 day),
// or that of its content-hashed form.
const rel = ['assets', 'audio', ...segments.slice(0, -1), stem + ext].join('/');
// eslint-disable-next-line no-await-in-loop
await serveFile(req, res, absPath, stat, 'public', ['assets', 'audio', ...segments], query, gzipCache, log);
await serveFile(req, res, absPath, stat, 'public', ['assets', 'audio', ...segments], query, gzipCache, log, hashedFileOpts(assetIndex, rel, stat, urlHash));
return;
}
sendError(req, res, 404, '页面不存在 · Not found');
+20 -7
View File
@@ -4,9 +4,11 @@
// * 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;
// rooms, matches), never cached; `?traffic=1` adds the traffic counters (traffic.js: bytes by category since the
// start, WebSocket wire and payload bytes);
// * everything else → the static files (static.js).
// A route that throws is logged and answers 500.
// Every response's body bytes count in the traffic counters (traffic.js meter). A route that throws is logged and
// answers 500.
import { PROTOCOL_VERSION, APP_VERSION } from '../../shared/constants.js';
import { buildTag } from './buildTag.js';
@@ -15,20 +17,30 @@ import { setSecurityHeaders, sendError, sendJson, splitUrl } from './common.js';
const MAX_URL_LENGTH = 4096;
/**
* The GET /healthz body.
* The GET /healthz body (`withTraffic`: plus the traffic counters and the asset index's state, `?traffic=1`).
* @param {{ startedAt: number, network: import('../net.js').Network, registry: import('../net.js').SessionRegistry,
* lobby: import('../lobby.js').Lobby }} health
* lobby: import('../lobby.js').Lobby, traffic?: ReturnType<typeof import('./traffic.js').createTrafficCounter>,
* assetIndex?: ReturnType<typeof import('./assetIndex.js').createAssetIndex> | null,
* views?: ReturnType<typeof import('./assetViews.js').createManifestViews> | null }} health
* @param {boolean} [withTraffic]
*/
export function healthReport({ startedAt, network, registry, lobby }) {
return {
export function healthReport({ startedAt, network, registry, lobby, traffic, assetIndex, views }, withTraffic = false) {
const report = {
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(),
};
if (withTraffic && traffic) report.traffic = traffic.snapshot();
// content-hashed asset URLs (assetIndex.js / assetViews.js): off, building, or ready with how many files and URLs
if (withTraffic) report.assetHashes = assetIndex ? { ...assetIndex.stats(), views: views ? views.stats() : null } : null;
return report;
}
/** Does the query ask for the traffic counters (`traffic`, `traffic=1`, `traffic=true`)? */
const wantsTraffic = (query) => /(?:^|&)traffic(?:=(?:1|true|yes))?(?:&|$)/i.test(query || '');
/**
* The request listener for `http.createServer`.
* @param {{ serveStatic: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse,
@@ -48,7 +60,7 @@ export function createRequestHandler({ serveStatic, health, log }) {
return;
}
if (parts.rawPath === '/healthz') {
sendJson(req, res, 200, healthReport(health));
sendJson(req, res, 200, healthReport(health, wantsTraffic(parts.query)));
return;
}
await serveStatic(req, res, parts.rawPath, parts.query);
@@ -56,6 +68,7 @@ export function createRequestHandler({ serveStatic, health, log }) {
return (req, res) => {
setSecurityHeaders(res);
if (health.traffic) health.traffic.meter(res, splitUrl(req.url || '/')?.rawPath || '/');
handleRequest(req, res).catch((e) => {
log.error('[http] request failed', e);
sendError(req, res, 500, '服务器内部错误 · Internal error');
+38 -9
View File
@@ -15,16 +15,27 @@
// /packs/index.json → the content packs of this server (server/packs.js: the language packs of
// public/i18n/ and the pack folders of packs/, re-read when they change; never cached)
// /packs/<id>/<file> → a file of a pack folder — only one its manifest names (server/packs.js servable)
// /assets/~<hash>/…, /fonts/~<hash>/…, /media/~<hash>/… → the same files: the content-hash segment
// (shared/assetPaths.js) is stripped. A hash that names the file's current bytes (assetIndex.js
// urlHashOk: its own hash, or its Spine model's) answers `immutable` for a year; any other hash —
// an old page, a URL derived from another file's — the current file with `no-cache`
// /data/assets.json, /data/local-assets.json → the served manifests (assetViews.js: every art URL with its
// content-hash segment; no-cache, the ETag of the view) once the asset index is ready, the files as
// they are before that or without an index
//
// 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.
// Files under public/assets/ and public/fonts/ get their content hash as ETag when the index has it (a file whose
// size or mtime changed since asks for a rescan), others size + mtime. 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 { splitHashedPath } from '../../shared/assetPaths.js';
import { ROOT, noopLog } from './config.js';
import { sendError, sendJson } from './common.js';
import { MIME, GzipCache, isNotModified, serveFile } from './files.js';
import { MIME, CompressCache, isNotModified, serveFile, serveBuffer } from './files.js';
import { ASSET_INDEX_ROOTS, hashedFileOpts } from './assetIndex.js';
import { serveMedia } from './media.js';
import { createPackRegistry } from '../packs.js';
import { PACKS_URL, PACK_INDEX_FILE } from '../../shared/packs.js';
@@ -51,11 +62,15 @@ const EMPTY_LOCAL_ART = Buffer.from(JSON.stringify({ version: 1, source: 'none',
/**
* Create the static request handler.
* @param {{ publicDir: string, dataDir: string, sharedDir: string, simDir?: string, packsDir?: string,
* packs?: ReturnType<typeof createPackRegistry>, log?: object }} dirs
* packs: the server's pack registry (default: one over publicDir, dataDir and packsDir — ROOT/packs)
* packs?: ReturnType<typeof createPackRegistry>, log?: object,
* assetIndex?: ReturnType<typeof import('./assetIndex.js').createAssetIndex> | null,
* views?: ReturnType<typeof import('./assetViews.js').createManifestViews> | null }} dirs
* packs: the server's pack registry (default: one over publicDir, dataDir and packsDir — ROOT/packs);
* assetIndex / views: the content hashes and the served manifests (none: plain URLs, size-mtime ETags)
* @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'), packsDir = path.join(ROOT, 'packs'), packs = null, log = noopLog }) {
export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = path.join(ROOT, 'server', 'sim'), packsDir = path.join(ROOT, 'packs'), packs = null, log = noopLog,
assetIndex = null, views = null }) {
const registry = packs || createPackRegistry({ publicDir, dataDir, packsDir }, { log: /** @type {any} */ (log) });
const mounts = [
{ prefix: '/data/', name: 'data', dir: path.resolve(dataDir) },
@@ -66,7 +81,7 @@ export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = pa
];
const shimBody = Buffer.from(DATA_SHIM_JS);
const shimTag = `"shim-${shimBody.length.toString(16)}"`;
const gzipCache = new GzipCache();
const gzipCache = new CompressCache(); // brotli and gzip bodies (files.js)
return async function serveStatic(req, res, rawPath, query) {
let decoded;
@@ -75,6 +90,10 @@ export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = pa
sendError(req, res, 400, '请求地址无效 · Bad request');
return;
}
// /assets/~<hash>/x → /assets/x (the hash decides the cache policy below)
const hashed = splitHashedPath(decoded);
const urlHash = hashed.hash;
decoded = hashed.path;
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; }
@@ -94,9 +113,17 @@ export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = pa
}
// 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);
await serveMedia(req, res, decoded.slice(MEDIA_PREFIX.length), query, publicDir, gzipCache, log, { urlHash, assetIndex });
return;
}
// The served manifests: every art URL with its content hash (assetViews.js), once the index is ready
if (views && (decoded === '/data/assets.json' || decoded === '/data/local-assets.json')) {
const view = await views.get(decoded.slice('/data/'.length));
if (view) {
await serveBuffer(req, res, { body: view.body, hash: view.hash, type: MIME['.json'], cacheControl: 'no-cache', key: `view:${decoded}`, cache: gzipCache });
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) : '';
@@ -154,6 +181,8 @@ export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = pa
}
return;
}
await serveFile(req, res, absPath, stat, mount.name, segments, query, gzipCache, log);
const indexed = mount.name === 'public' && segments.length > 1 && ASSET_INDEX_ROOTS.includes(segments[0]);
const opts = indexed ? hashedFileOpts(assetIndex, segments.join('/'), stat, urlHash) : (urlHash ? { cacheControl: 'no-cache' } : {});
await serveFile(req, res, absPath, stat, mount.name, segments, query, gzipCache, log, opts);
};
}
+102
View File
@@ -0,0 +1,102 @@
// server/http/traffic.js — what this server has sent and received since it started (host bandwidth, layer 0):
//
// * HTTP: per category of shared/assetPaths.js trafficCategory (assets / audio / code / data / other) the responses
// and the body bytes actually written (after gzip / brotli; headers not counted), and how many were 304s;
// * WebSocket: the connections, the bytes on the wire both ways (the sockets' own counters, after permessage-deflate,
// frame headers included, the HTTP upgrade not) and the message payloads before compression — their ratio is what
// the compression saves.
//
// routes.js meters every response (`meter`), websocket.js every socket (`wsOpen` / `wsClose` / `wsSent` / `wsReceived`);
// `snapshot()` is the `traffic` object of GET /healthz?traffic=1 (docs/DEPLOY.md §5). Nothing is stored or sent
// anywhere else; the counters start at zero with the process.
import { TRAFFIC_CATEGORIES, trafficCategory } from '../../shared/assetPaths.js';
/** Byte length of a chunk handed to res.write / res.end (a string in `encoding`, utf8 by default). */
function chunkBytes(chunk, encoding) {
if (chunk == null || typeof chunk === 'function') return 0;
if (typeof chunk === 'string') return Buffer.byteLength(chunk, typeof encoding === 'string' && Buffer.isEncoding(encoding) ? encoding : 'utf8');
return typeof chunk.byteLength === 'number' ? chunk.byteLength : 0;
}
/** Length of a ws 'message' payload (a Buffer, an ArrayBuffer or, fragmented, a Buffer list). */
function payloadBytes(data) {
if (Array.isArray(data)) return data.reduce((n, b) => n + payloadBytes(b), 0);
return data && typeof data.byteLength === 'number' ? data.byteLength : typeof data === 'string' ? Buffer.byteLength(data) : 0;
}
/**
* A traffic counter for one server.
* @param {{ now?: () => number }} [opts]
*/
export function createTrafficCounter({ now = Date.now } = {}) {
const startedAt = now();
const http = Object.fromEntries(TRAFFIC_CATEGORIES.map((c) => [c, { requests: 0, bytes: 0, notModified: 0 }]));
const ws = { connections: 0, wireOut: 0, wireIn: 0, payloadOut: 0, payloadIn: 0, messagesOut: 0, messagesIn: 0 };
/** @type {Map<import('node:net').Socket, { out: number, in: number }>} live sockets and their counters at the upgrade */
const live = new Map();
/** One finished HTTP response. */
function record(path, status, bytes) {
const c = http[trafficCategory(path)] || http.other;
c.requests++;
c.bytes += bytes;
if (status === 304) c.notModified++;
}
return {
/**
* Count the body bytes of `res` (every res.write / res.end) under the category of `path`, once the response is
* done or its connection gone.
* @param {import('node:http').ServerResponse} res
* @param {string} path the request's URL path
*/
meter(res, path) {
let bytes = 0;
const { write, end } = res;
res.write = function meteredWrite(chunk, encoding, cb) {
bytes += chunkBytes(chunk, encoding);
return write.call(this, chunk, encoding, cb);
};
res.end = function meteredEnd(chunk, encoding, cb) {
bytes += chunkBytes(chunk, encoding);
return end.call(this, chunk, encoding, cb);
};
res.once('close', () => record(path, res.statusCode, bytes));
},
/** A WebSocket connection opened on `socket` (after its upgrade answer was written). */
wsOpen(socket) {
ws.connections++;
if (socket && !live.has(socket)) live.set(socket, { out: socket.bytesWritten || 0, in: socket.bytesRead || 0 });
},
/** …and closed: its wire bytes move to the totals. */
wsClose(socket) {
const base = live.get(socket);
if (!base) return;
live.delete(socket);
ws.wireOut += Math.max(0, (socket.bytesWritten || 0) - base.out);
ws.wireIn += Math.max(0, (socket.bytesRead || 0) - base.in);
},
/** A message handed to ws.send (its payload before compression). */
wsSent(data) { ws.messagesOut++; ws.payloadOut += payloadBytes(data); },
/** A message received (its payload after inflating). */
wsReceived(data) { ws.messagesIn++; ws.payloadIn += payloadBytes(data); },
/** The counters now (plain JSON). */
snapshot() {
let wireOut = ws.wireOut;
let wireIn = ws.wireIn;
for (const [socket, base] of live) {
wireOut += Math.max(0, (socket.bytesWritten || 0) - base.out);
wireIn += Math.max(0, (socket.bytesRead || 0) - base.in);
}
const byCat = Object.fromEntries(Object.entries(http).map(([k, v]) => [k, { ...v }]));
const total = Object.values(http).reduce((t, v) => ({ requests: t.requests + v.requests, bytes: t.bytes + v.bytes }), { requests: 0, bytes: 0 });
return {
since: new Date(startedAt).toISOString(),
seconds: Math.max(0, Math.round((now() - startedAt) / 1000)),
http: { ...byCat, total },
ws: { ...ws, open: live.size, wireOut, wireIn },
};
},
};
}
+61 -6
View File
@@ -2,8 +2,8 @@
//
// * 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
// * WebSocket (ws) at /ws, maxPayload 64 KB, permessage-deflate (WS_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';
@@ -15,6 +15,38 @@ import { netOptionsFrom, lobbyOptionsFrom } from './config.js';
/** Inbound WebSocket frame limit (DESIGN §8). */
export const WS_MAX_PAYLOAD = 64 * 1024;
/**
* permessage-deflate (RFC 7692) for the JSON text frames. A message from 1 KB up (m.public, m.private, b.start, welcome
* …) is compressed on its own — no context is kept from one message to the next (`serverNoContextTakeover`) — with a
* 2 KB window and zlib memLevel 7, so a connection holds about 72 KB of zlib state; smaller frames (pong, ok) go out
* as they are. Measured on a solo session (rounds 3–5, the server's own counters, traffic.js): 173 KB of payload →
* 60 KB on the wire; replaying a session's frames with larger windows gained nothing on these messages. Browsers
* negotiate it by themselves; a client that offers none gets plain frames. The inflated size of a client frame still
* counts against WS_MAX_PAYLOAD.
*/
export const WS_DEFLATE = Object.freeze({
threshold: 1024,
serverNoContextTakeover: true,
serverMaxWindowBits: 11,
zlibDeflateOptions: Object.freeze({ memLevel: 7 }),
});
/**
* Does every permessage-deflate offer of this `Sec-WebSocket-Extensions` header cap the server window below
* WS_DEFLATE.serverMaxWindowBits? ws cannot accept such an offer and refuses the whole handshake (400), so the upgrade
* drops the header instead and the connection goes uncompressed. No browser sends `server_max_window_bits`.
* @param {string | string[] | undefined} header
*/
export function narrowDeflateOffer(header) {
if (typeof header !== 'string') return false;
const offers = header.split(',').map((s) => s.trim()).filter((s) => /^permessage-deflate\b/i.test(s));
if (!offers.length) return false;
return offers.every((offer) => {
const m = /(?:^|;)\s*server_max_window_bits\s*=\s*"?(\d+)"?/i.exec(offer);
return !!m && Number(m[1]) < WS_DEFLATE.serverMaxWindowBits;
});
}
/**
* The session stack of one server.
* @param {{ MatchClass?: Function, seedFn?: () => number, [option: string]: any }} opts startServer() options
@@ -30,15 +62,37 @@ export function createSessionStack(opts, { data, log }) {
return { registry, lobby, network };
}
/**
* Count a socket in the traffic counters (traffic.js): its wire bytes from now until it closes, the payload of every
* message it sends (ws.send wrapped) and receives.
* @param {import('ws').WebSocket} ws
* @param {import('node:http').IncomingMessage} req
* @param {ReturnType<typeof import('./traffic.js').createTrafficCounter>} traffic
*/
function meterSocket(ws, req, traffic) {
const socket = req.socket;
traffic.wsOpen(socket);
const send = ws.send;
ws.send = function meteredSend(data, ...rest) {
traffic.wsSent(data);
return send.call(this, data, ...rest);
};
ws.on('message', (data) => traffic.wsReceived(data));
ws.once('close', () => traffic.wsClose(socket));
}
/**
* Serve the WebSocket endpoint /ws on `server` (its 'upgrade' event).
* @param {import('node:http').Server} server
* @param {{ network: Network, log: object }} deps
* @param {{ network: Network, log: object, traffic?: ReturnType<typeof import('./traffic.js').createTrafficCounter> }} 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));
export function attachWebSocket(server, { network, log, traffic = null }) {
const wss = new WebSocketServer({ noServer: true, maxPayload: WS_MAX_PAYLOAD, perMessageDeflate: { ...WS_DEFLATE }, clientTracking: false });
wss.on('connection', (ws, req) => {
if (traffic) meterSocket(ws, req, traffic);
network.handleConnection(ws, req);
});
wss.on('error', (e) => log.error('[ws] server error', e));
server.on('upgrade', (req, socket, head) => {
@@ -51,6 +105,7 @@ export function attachWebSocket(server, { network, log }) {
const refused = network.admission(req);
if (refused === 'per-address') { reject(429, 'Too Many Requests'); return; }
if (refused) { reject(503, 'Service Unavailable'); return; }
if (narrowDeflateOffer(req.headers['sec-websocket-extensions'])) delete req.headers['sec-websocket-extensions'];
try {
wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
} catch (e) {
+31 -11
View File
@@ -3,14 +3,20 @@
//
// http/config.js ROOT, the served directories, the environment (PORT 3000, HOST '::' dual-stack, 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/websocket.js session wiring (SessionRegistry → Lobby → Network) and the WebSocket at /ws (maxPayload 64 KB,
// permessage-deflate from 1 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,
// the content packs (/packs/index.json, /packs/<id>/<file> — the registry is packs.js)
// 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/files.js one file → response: MIME, brotli / gzip + memory cache, ETag / Last-Modified / 304, Cache-Control, ranges
// http/assetIndex.js the content hashes of public/assets and public/fonts (built in the background, cached in
// .cache/asset-hashes.json by path + size + mtime; Spine models share one hash)
// http/assetViews.js the served /data/assets.json and /data/local-assets.json: every art URL with its hash segment
// (/assets/~<hash>/…, immutable when the hash matches); the committed files stay unchanged
// 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/traffic.js the traffic counters: bytes sent per category (assets / audio / code / data) and on the
// WebSocket, since the start (GET /healthz?traffic=1)
// http/common.js what every answer shares: security headers, URL split, error page, JSON replies, bare 400
// http/boot.js a pending update package first (update.js: old files deleted, the install verified against
// MANIFEST.json), banner (Local / LAN / tunnel URLs), port-in-use hint, graceful shutdown on SIGINT /
@@ -24,20 +30,23 @@
import http from 'node:http';
import { getData, loadData } from './data.js';
import { ROOT, listenAddress, bindCandidates, serveDirs, makeLogger, parseTrustProxy } from './http/config.js';
import { ROOT, listenAddress, bindCandidates, serveDirs, makeLogger, parseTrustProxy, assetIndexOptionsFrom } from './http/config.js';
import { WS_MAX_PAYLOAD, createSessionStack, attachWebSocket } from './http/websocket.js';
import { DATA_SHIM_JS, createStaticHandler } from './http/static.js';
import { createPackRegistry } from './packs.js';
import { MIME, COMPRESSIBLE, acceptsGzip, parseRange } from './http/files.js';
import { MIME, COMPRESSIBLE, acceptsGzip, pickEncoding, parseRange } from './http/files.js';
import { BUILD_INPUTS, computeBuildTag, buildTag, resetBuildTag } from './http/buildTag.js';
import { createRequestHandler } from './http/routes.js';
import { createTrafficCounter } from './http/traffic.js';
import { createAssetIndex } from './http/assetIndex.js';
import { createManifestViews } from './http/assetViews.js';
import { answerClientError } from './http/common.js';
import { lanUrls, displayHost, isProcessEntry, runMain } from './http/boot.js';
// The public API of this module (tests and tools import it from here); the code lives in ./http/.
export {
ROOT, WS_MAX_PAYLOAD, DATA_SHIM_JS, MIME, COMPRESSIBLE, BUILD_INPUTS, computeBuildTag, buildTag, resetBuildTag,
acceptsGzip, parseRange, createStaticHandler, lanUrls, parseTrustProxy,
acceptsGzip, pickEncoding, parseRange, createStaticHandler, lanUrls, parseTrustProxy,
};
/**
@@ -50,11 +59,14 @@ export {
* ratePerSec?: number, rateBurst?: number, maxConnections?: number, maxRooms?: number,
* maxConnectionsPerAddr?: number, maxRoomsPerAddr?: number, maxMatchesPerAddr?: number, resyncMinGapMs?: number,
* heavyPerSec?: number, heavyBurst?: number, trustProxy?: 'auto' | boolean, soloReconnectWindowMs?: number,
* assetIndex?: boolean | { cacheFile?: string | null, delayMs?: number },
* }} [opts]
* assetIndex: the content-hash index (default on; false or SP_ASSET_INDEX=0: off — config.js assetIndexOptionsFrom)
* @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, packs: ReturnType<typeof createPackRegistry>,
* close: () => Promise<void> }>}
* traffic: ReturnType<typeof createTrafficCounter>, assetIndex: ReturnType<typeof createAssetIndex> | null,
* views: ReturnType<typeof createManifestViews> | null, close: () => Promise<void> }>}
*/
export async function startServer(opts = {}) {
const { port, host } = listenAddress(opts);
@@ -67,15 +79,20 @@ export async function startServer(opts = {}) {
// content packs (docs/PACKS.md): scanned now — the start log names them — and again whenever their folders change
const packs = createPackRegistry({ publicDir, dataDir, packsDir }, { log });
packs.refresh(true);
const serveStatic = createStaticHandler({ publicDir, dataDir, sharedDir, packsDir, packs, log });
// the content hashes of the art (built in the background once listening) and the manifests served with them
const indexOptions = assetIndexOptionsFrom(opts, publicDir);
const assetIndex = indexOptions ? createAssetIndex({ publicDir, log, ...indexOptions }) : null;
const views = assetIndex ? createManifestViews({ dataDir, index: assetIndex, log }) : null;
const serveStatic = createStaticHandler({ publicDir, dataDir, sharedDir, packsDir, packs, log, assetIndex, views });
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(createRequestHandler({ serveStatic, health: { startedAt, network, registry, lobby }, log }));
const traffic = createTrafficCounter();
const server = http.createServer(createRequestHandler({ serveStatic, health: { startedAt, network, registry, lobby, traffic, assetIndex, views }, log }));
server.on('clientError', answerClientError);
const wss = attachWebSocket(server, { network, log });
const wss = attachWebSocket(server, { network, log, traffic });
// The address actually bound. The default may fall back to IPv4; the returned host and url follow that.
let boundHost;
@@ -108,9 +125,11 @@ export async function startServer(opts = {}) {
boundHost = bound;
} catch (e) {
network.close(); // stop heartbeat/sweep timers of the half-built server
assetIndex?.close();
throw e;
}
server.on('error', (e) => log.error('[http] server error', e));
assetIndex?.start();
const addr = server.address();
const actualPort = typeof addr === 'object' && addr ? addr.port : port;
@@ -122,6 +141,7 @@ export async function startServer(opts = {}) {
closing = (async () => {
try { lobby.shutdown('shutdown'); } catch (e) { log.error('[shutdown] lobby', e); }
network.close();
assetIndex?.close();
await new Promise((resolve) => {
server.close(() => resolve());
server.closeIdleConnections?.();
@@ -132,7 +152,7 @@ export async function startServer(opts = {}) {
return closing;
}
return { port: actualPort, host: boundHost, url, server, wss, lobby, network, registry, packs, close };
return { port: actualPort, host: boundHost, url, server, wss, lobby, network, registry, packs, traffic, assetIndex, views, close };
}
// `node server/index.js` / npm start: listen, print the banner, stop on SIGINT / SIGTERM (http/boot.js).