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:
sganggs committed 2026-10-12 16:20:17 +08:00
1 parent fb5f4493f2
commit 521dd984e7
8 files changed
+524 -10

No files matched your search

+352
View File
@@ -0,0 +1,352 @@
// server/http/assetIndex.js — the content hashes of the served art (host bandwidth, layer 1; docs/ASSETS.md "The served
// manifests"):
//
// * built in the background shortly after the start (ASSET_INDEX_DELAY_MS): every file under public/assets/ and
// public/fonts/ (dot names skipped like the static server does, linked folders followed once), the first 16 hex
// digits of the SHA-256 of its bytes (WebCrypto, off the main thread);
// * remembered in .cache/asset-hashes.json by path + size + mtime: a restart only stats the files and reads none of
// the unchanged ones again; a file whose size or mtime changed is hashed again (a re-extraction, `setup --force`,
// a Docker rebuild then costs the players only the files whose bytes changed);
// * never trusted beyond what it checked: `hashOf(rel, stat)` answers only while the file still has the size and
// mtime it was hashed with, else null — the caller then serves the plain way (1-day cache, size-mtime ETag) and
// the miss asks for a rescan (at most one every RESCAN_GAP_MS);
// * models: lists of files that share one URL hash — a Spine skeleton, its atlas and its pages (pixi-spine finds the
// atlas next to the skeleton and the pages next to the atlas, so all their URLs carry the same segment). A model's
// hash is the SHA-256 of its members' hashes; assetViews.js hands the lists over (`setModels`), read from the
// manifests. `urlHashOk` accepts a file's own hash or the hash of a model it belongs to.
//
// Rel paths are POSIX and relative to public/ ('assets/char/avatar/x.png', 'fonts/bender-regular.woff2'). A rel that
// differs from a file's name only in case finds that file when no other name matches it that way (Windows and macOS
// serve `wah-y.png` from `WAH-Y.png`; on Linux the request is a 404 anyway).
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import { createHash, webcrypto } from 'node:crypto';
import { ASSET_HASH_HEX } from '../../shared/assetPaths.js';
import { noopLog } from './config.js';
import { IMMUTABLE_CACHE } from './files.js';
/** Folders of public/ whose files are hashed. */
export const ASSET_INDEX_ROOTS = Object.freeze(['assets', 'fonts']);
/** The first build waits this long after the start (a server that is only started and stopped again — tests — reads nothing). */
export const ASSET_INDEX_DELAY_MS = 1000;
/** Shortest time between two rescans a stale or unknown file asks for. */
export const RESCAN_GAP_MS = 10_000;
/** Version of .cache/asset-hashes.json. */
export const HASH_CACHE_VERSION = 1;
const STAT_CONCURRENCY = 64;
const HASH_CONCURRENCY = 4;
const hex = (bytes) => Buffer.from(bytes).toString('hex').slice(0, ASSET_HASH_HEX);
/** First ASSET_HASH_HEX hex digits of the SHA-256 of `data` (synchronous; model hashes, manifests, tests). */
export function hash16(data) {
return createHash('sha256').update(data).digest('hex').slice(0, ASSET_HASH_HEX);
}
/** The same for a file's bytes, computed off the main thread (WebCrypto runs in libuv's thread pool). */
async function digest16(buf) {
return hex(await webcrypto.subtle.digest('SHA-256', buf));
}
/** Run `fn` over `items` with at most `n` at a time; stops early when `stop()` says so. */
async function eachLimit(items, n, fn, stop) {
let i = 0;
const worker = async () => {
while (i < items.length && !stop()) {
const item = items[i++];
await fn(item);
}
};
await Promise.all(Array.from({ length: Math.min(n, items.length) }, worker));
}
/**
* The asset index of one server.
* @param {{ publicDir: string, roots?: readonly string[], cacheFile?: string | null, log?: object, delayMs?: number,
* rescanGapMs?: number, now?: () => number }} opts
* cacheFile: where the hashes persist (null: in memory only — a test's temporary public/)
*/
export function createAssetIndex({ publicDir, roots = ASSET_INDEX_ROOTS, cacheFile = null, log = noopLog, delayMs = ASSET_INDEX_DELAY_MS,
rescanGapMs = RESCAN_GAP_MS, now = Date.now }) {
const base = path.resolve(publicDir);
/** @type {Map<string, { size: number, mtimeMs: number, hash: string }>} */
let records = new Map();
/** @type {Map<string, string | null>} lower-case rel → the rel (null: two names differ only in case) */
let lower = new Map();
/** @type {string[][]} */
let modelLists = [];
/** @type {Map<string, Set<string>>} rel → hashes of the models it belongs to */
let modelOf = new Map();
let generation = 0;
let ready = false;
let closed = false;
let building = null; // the build in flight (a promise)
let again = false; // another build was asked for while one ran
let startTimer = null;
let rescanTimer = null;
let lastBuildAt = -Infinity;
let readyResolve;
const readyPromise = new Promise((r) => { readyResolve = r; });
const listeners = new Set();
const last = { files: 0, bytes: 0, hashed: 0, reused: 0, ms: 0, at: null, error: null };
/** The cache file's records ({} when absent, unreadable or another version). */
function loadCache() {
if (!cacheFile) return new Map();
try {
const doc = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
if (!doc || doc.version !== HASH_CACHE_VERSION || typeof doc.files !== 'object' || !doc.files) return new Map();
const out = new Map();
for (const [rel, v] of Object.entries(doc.files)) {
if (Array.isArray(v) && Number.isFinite(v[0]) && Number.isFinite(v[1]) && typeof v[2] === 'string' && v[2].length === ASSET_HASH_HEX) {
out.set(rel, { size: v[0], mtimeMs: v[1], hash: v[2] });
}
}
return out;
} catch {
return new Map();
}
}
/** Write the records (temp file + rename: a concurrent reader never sees half a file). Failures only log. */
async function saveCache(map) {
if (!cacheFile) return;
const files = {};
for (const [rel, r] of [...map].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))) files[rel] = [r.size, r.mtimeMs, r.hash];
const tmp = `${cacheFile}.${process.pid}.tmp`;
try {
await fsp.mkdir(path.dirname(cacheFile), { recursive: true });
await fsp.writeFile(tmp, JSON.stringify({ version: HASH_CACHE_VERSION, algo: 'sha256/16', files }));
await fsp.rename(tmp, cacheFile);
} catch (e) {
log.debug?.('[assets] hash cache not saved', e?.code || e?.message);
fsp.rm(tmp, { force: true }).catch(() => {});
}
}
/** Every file under the roots: [rel, abs] (dot names skipped, a folder reached twice — a link loop — walked once). */
async function listFiles() {
const out = [];
const seen = new Set();
const walk = async (abs, rel) => {
let real;
try { real = await fsp.realpath(abs); } catch { return; }
if (seen.has(real)) return;
seen.add(real);
let ents;
try { ents = await fsp.readdir(abs, { withFileTypes: true }); } catch { return; }
for (const d of ents) {
if (closed) return;
if (d.name.startsWith('.')) continue;
const childAbs = path.join(abs, d.name);
const childRel = `${rel}/${d.name}`;
if (d.isDirectory()) await walk(childAbs, childRel);
else if (d.isFile()) out.push([childRel, childAbs]);
else if (d.isSymbolicLink()) {
const st = await fsp.stat(childAbs).catch(() => null);
if (st?.isDirectory()) await walk(childAbs, childRel);
else if (st?.isFile()) out.push([childRel, childAbs]);
}
}
};
for (const root of roots) await walk(path.join(base, root), root);
return out;
}
/** Model hashes from the current records (a model with a member the index does not know has none). */
function computeModels() {
const next = new Map();
for (const list of modelLists) {
const h = modelHashOf(list);
if (!h) continue;
for (const rel of list) {
const c = canonical(rel);
let set = next.get(c);
if (!set) next.set(c, (set = new Set()));
set.add(h);
}
}
modelOf = next;
}
/** The indexed name of `rel`: itself, or the one file whose name differs from it only in case (else null). */
function canonical(rel) {
if (typeof rel !== 'string') return null;
if (records.has(rel)) return rel;
return lower.get(rel.toLowerCase()) || null;
}
function recordOf(rel) {
const c = canonical(rel);
return c ? records.get(c) : null;
}
function modelHashOf(list) {
if (!Array.isArray(list) || !list.length) return null;
const parts = [];
for (const rel of list) {
const r = recordOf(rel);
if (!r) return null;
parts.push(r.hash);
}
return hash16(parts.join('\n'));
}
/** One build; true when the records changed (a new generation). */
async function build() {
const t0 = now();
const known = records.size ? records : loadCache();
const next = new Map();
let hashed = 0;
let reused = 0;
let bytes = 0;
const files = await listFiles();
const toHash = [];
await eachLimit(files, STAT_CONCURRENCY, async ([rel, abs]) => {
const st = await fsp.stat(abs).catch(() => null);
if (!st || !st.isFile()) return;
bytes += st.size;
const k = known.get(rel);
if (k && k.size === st.size && k.mtimeMs === st.mtimeMs) { next.set(rel, k); reused++; } else toHash.push([rel, abs, st]);
}, () => closed);
await eachLimit(toHash, HASH_CONCURRENCY, async ([rel, abs, st]) => {
try {
const buf = await fsp.readFile(abs);
const after = await fsp.stat(abs);
// changed while it was read: hashed again by the next build
if (after.size !== st.size || after.mtimeMs !== st.mtimeMs || buf.length !== st.size) return;
next.set(rel, { size: st.size, mtimeMs: st.mtimeMs, hash: await digest16(buf) });
hashed++;
} catch { /* vanished or unreadable: not in the index */ }
}, () => closed);
if (closed) return false;
// commit: records, model hashes and the generation change together (one synchronous step)
let changed = next.size !== records.size;
if (!changed) for (const [rel, r] of next) { const o = records.get(rel); if (!o || o.hash !== r.hash || o.size !== r.size || o.mtimeMs !== r.mtimeMs) { changed = true; break; } }
const cacheChanged = hashed > 0 || next.size !== known.size;
records = next;
lower = new Map();
for (const rel of next.keys()) { const k = rel.toLowerCase(); lower.set(k, lower.has(k) ? null : rel); }
if (changed) { generation++; computeModels(); }
Object.assign(last, { files: next.size, bytes, hashed, reused, ms: now() - t0, at: new Date().toISOString(), error: null });
lastBuildAt = now();
if (cacheChanged) await saveCache(next);
if (hashed) log.info?.(`[assets] content hashes: ${next.size} files, ${hashed} hashed, ${reused} unchanged (${Math.round(last.ms)} ms)`);
return changed;
}
function markReady() {
if (ready) return;
ready = true;
readyResolve();
}
function notify() {
for (const fn of [...listeners]) { try { fn(generation); } catch (e) { log.error?.('[assets] index listener failed', e); } }
}
/** Build now (or once more after the build in flight). */
function refresh() {
if (closed) return Promise.resolve();
if (building) { again = true; return building; }
building = (async () => {
try {
do {
again = false;
const changed = await build();
if (closed) break;
markReady();
if (changed) notify();
} while (again && !closed);
} catch (e) {
last.error = e?.message || String(e);
log.warn?.('[assets] content hash index failed', e?.message || e);
} finally {
building = null;
if (!closed) markReady();
}
})();
return building;
}
return {
/** Start the background build (after `delayMs`); returns at once. Idempotent. */
start() {
if (closed || startTimer || building || ready) return;
if (!(delayMs > 0)) { refresh(); return; }
startTimer = setTimeout(() => { startTimer = null; refresh(); }, delayMs);
startTimer.unref?.();
},
refresh,
/** Resolves once the first build is done (never rejects). */
whenReady() { return readyPromise; },
get ready() { return ready; },
get generation() { return generation; },
/** Files with a hash. */
get size() { return records.size; },
/**
* The file's content hash while it still has the size and mtime it was hashed with (`stat` given), else null.
* Without `stat`: the hash of the last build.
* @param {string} rel @param {{ size: number, mtimeMs: number }} [stat]
*/
hashOf(rel, stat) {
const r = recordOf(rel);
if (!r) return null;
if (stat && (stat.size !== r.size || stat.mtimeMs !== r.mtimeMs)) return null;
return r.hash;
},
/** Does `urlHash` name exactly these bytes: the file's own hash, or the hash of a model it belongs to? */
urlHashOk(rel, stat, urlHash) {
if (typeof urlHash !== 'string') return false;
const own = this.hashOf(rel, stat);
if (!own) return false;
return own === urlHash || !!modelOf.get(canonical(rel))?.has(urlHash);
},
/** The hash of a model (its members' hashes hashed), null while a member is unknown. */
modelHash: (list) => modelHashOf(list),
/** Replace the model lists (assetViews.js, from the manifests). */
setModels(lists) {
modelLists = Array.isArray(lists) ? lists.filter((l) => Array.isArray(l) && l.length) : [];
computeModels();
},
/** A file was served that the index does not know, or no longer matches: rescan (at most one per rescanGapMs). */
noteMiss() {
if (closed || !ready || rescanTimer || building) return;
const wait = Math.max(0, lastBuildAt + rescanGapMs - now());
rescanTimer = setTimeout(() => { rescanTimer = null; refresh(); }, wait);
rescanTimer.unref?.();
},
/** `fn(generation)` after a build changed the records; returns the unsubscribe. */
onChange(fn) { listeners.add(fn); return () => listeners.delete(fn); },
stats() {
return { ready, building: !!building, generation, files: records.size, models: modelLists.length, last: { ...last } };
},
/** Stop: timers cleared, a build in flight abandoned. */
close() {
closed = true;
if (startTimer) { clearTimeout(startTimer); startTimer = null; }
if (rescanTimer) { clearTimeout(rescanTimer); rescanTimer = null; }
readyResolve(); // a waiter is let go; `ready` stays as it was
},
};
}
/**
* The ETag source and cache policy of a file of the asset index's folders (public/assets, public/fonts), for
* serveFile: its content hash when the index has it for these bytes (a miss asks for a rescan); for a URL with a hash
* segment `immutable` when that hash names these bytes, else `no-cache`.
* @param {ReturnType<typeof createAssetIndex> | null} assetIndex
* @param {string} rel the file under public/ ('assets/…', POSIX)
* @param {import('node:fs').Stats} stat
* @param {string | null} urlHash the URL's hash segment
* @returns {{ contentHash?: string | null, cacheControl?: string }}
*/
export function hashedFileOpts(assetIndex, rel, stat, urlHash) {
if (!assetIndex) return urlHash ? { cacheControl: 'no-cache' } : {};
const contentHash = assetIndex.hashOf(rel, stat);
if (!contentHash) assetIndex.noteMiss(rel);
if (!urlHash) return { contentHash };
return { contentHash, cacheControl: contentHash && assetIndex.urlHashOk(rel, stat, urlHash) ? IMMUTABLE_CACHE : 'no-cache' };
}
+21
View File
@@ -4,6 +4,8 @@
// * TRUST_PROXY ('auto' default: honour CF-Connecting-IP / X-Real-IP / X-Forwarded-For only from loopback/private
// peers such as a local cloudflared; '1' always; '0' never) → net.js trustProxy;
// * DEBUG → the console logger's debug level;
// * SP_ASSET_INDEX=0 (or the `assetIndex: false` option) → no content-hash index: plain asset URLs, size-mtime ETags
// (assetIndex.js); the index of the default public/ persists in .cache/asset-hashes.json;
// * the served directories (public/, data/, shared/ and the content packs' packs/ of this repository unless the
// options name others), and which startServer() options are handed on to net.js Network and lobby.js Lobby.
@@ -64,6 +66,25 @@ export function serveDirs(opts) {
};
}
/**
* The asset index options out of the startServer() options and the environment, or null for none: `assetIndex: false`
* or SP_ASSET_INDEX=0 / off / false / no switches it off; `assetIndex: { cacheFile, delayMs }` overrides the defaults.
* The hashes persist in .cache/asset-hashes.json only for the repository's own public/ (a test's temporary folder keeps
* them in memory).
* @param {{ assetIndex?: boolean | { cacheFile?: string | null, delayMs?: number } }} opts
* @param {string} publicDir
* @returns {{ cacheFile: string | null, delayMs?: number } | null}
*/
export function assetIndexOptionsFrom(opts, publicDir) {
if (opts.assetIndex === false) return null;
const env = String(process.env.SP_ASSET_INDEX ?? '').trim().toLowerCase();
if (opts.assetIndex == null && ['0', 'off', 'false', 'no'].includes(env)) return null;
const o = typeof opts.assetIndex === 'object' && opts.assetIndex ? opts.assetIndex : {};
const own = path.resolve(publicDir) === path.join(ROOT, 'public');
const cacheFile = o.cacheFile !== undefined ? o.cacheFile : (own ? path.join(ROOT, '.cache', 'asset-hashes.json') : null);
return { cacheFile, ...(Number.isFinite(o.delayMs) ? { delayMs: o.delayMs } : {}) };
}
/** net.js Network options out of the startServer() options; `trustProxy` falls back to TRUST_PROXY. */
export function netOptionsFrom(opts) {
const netOptions = {};
+2 -2
View File
@@ -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);
+7 -3
View File
@@ -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
View File
@@ -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).