0.2.6-dev: a background content-hash index of public/assets and public/fonts (SHA-256, 16 hex; cached by path + size + mtime in .cache/asset-hashes.json; Spine models share one hash; SP_ASSET_INDEX=0 turns it off) — host bandwidth layer 1.1; golden: unchanged
This commit is contained in:
1 parent
fb5f4493f2
commit
521dd984e7
8 files changed
+524
-10
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' };
|
||||
}
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -64,8 +64,8 @@ const GZIP_CACHE_MAX_FILE = 8 << 20; // larger files are gzip-streamed on t
|
||||
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';
|
||||
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);
|
||||
|
||||
@@ -17,12 +17,14 @@ import { setSecurityHeaders, sendError, sendJson, splitUrl } from './common.js';
|
||||
const MAX_URL_LENGTH = 4096;
|
||||
|
||||
/**
|
||||
* The GET /healthz body (`withTraffic`: plus the traffic counters, `?traffic=1`).
|
||||
* 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, traffic?: ReturnType<typeof import('./traffic.js').createTrafficCounter> }} 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, traffic }, withTraffic = false) {
|
||||
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
|
||||
@@ -31,6 +33,8 @@ export function healthReport({ startedAt, network, registry, lobby, traffic }, w
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
+16
-4
@@ -9,6 +9,8 @@
|
||||
// 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/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/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
|
||||
@@ -26,7 +28,7 @@
|
||||
|
||||
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';
|
||||
@@ -34,6 +36,7 @@ import { MIME, COMPRESSIBLE, acceptsGzip, 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 { answerClientError } from './http/common.js';
|
||||
import { lanUrls, displayHost, isProcessEntry, runMain } from './http/boot.js';
|
||||
|
||||
@@ -53,11 +56,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>,
|
||||
* traffic: ReturnType<typeof createTrafficCounter>, close: () => Promise<void> }>}
|
||||
* traffic: ReturnType<typeof createTrafficCounter>, assetIndex: ReturnType<typeof createAssetIndex> | null,
|
||||
* close: () => Promise<void> }>}
|
||||
*/
|
||||
export async function startServer(opts = {}) {
|
||||
const { port, host } = listenAddress(opts);
|
||||
@@ -70,6 +76,9 @@ 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);
|
||||
// the content hashes of the art (built in the background once listening)
|
||||
const indexOptions = assetIndexOptionsFrom(opts, publicDir);
|
||||
const assetIndex = indexOptions ? createAssetIndex({ publicDir, log, ...indexOptions }) : null;
|
||||
const serveStatic = createStaticHandler({ publicDir, dataDir, sharedDir, packsDir, packs, log });
|
||||
const startedAt = Date.now();
|
||||
// The tag is per process (see buildTag): read the browser runtime once, here, not on every /healthz.
|
||||
@@ -77,7 +86,7 @@ export async function startServer(opts = {}) {
|
||||
buildTag();
|
||||
|
||||
const traffic = createTrafficCounter();
|
||||
const server = http.createServer(createRequestHandler({ serveStatic, health: { startedAt, network, registry, lobby, traffic }, log }));
|
||||
const server = http.createServer(createRequestHandler({ serveStatic, health: { startedAt, network, registry, lobby, traffic, assetIndex }, log }));
|
||||
server.on('clientError', answerClientError);
|
||||
const wss = attachWebSocket(server, { network, log, traffic });
|
||||
|
||||
@@ -112,9 +121,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;
|
||||
@@ -126,6 +137,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?.();
|
||||
@@ -136,7 +148,7 @@ export async function startServer(opts = {}) {
|
||||
return closing;
|
||||
}
|
||||
|
||||
return { port: actualPort, host: boundHost, url, server, wss, lobby, network, registry, packs, traffic, close };
|
||||
return { port: actualPort, host: boundHost, url, server, wss, lobby, network, registry, packs, traffic, assetIndex, 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