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:
commit
3d5d6b380d
47 files changed
+2325
-136
No files matched your search
@@ -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' };
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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).
|
||||
|
||||
Reference in new issue
Block a user