0.2.6-dev: /data/assets.json and /data/local-assets.json are served with content-hashed URLs (/assets/~<hash>/…, one hash per Spine model, the committed files unchanged); the ~hash routes of /assets/ /fonts/ /media/ answer immutable when the hash names the file and the current file with no-cache otherwise; content ETags — host bandwidth layer 1.2; golden: unchanged

This commit is contained in:
sganggs committed 2026-10-12 16:24:03 +08:00
1 parent 521dd984e7
commit 893d17e725
12 files changed
+571 -34

No files matched your search

+185
View File
@@ -0,0 +1,185 @@
// server/http/assetViews.js — the served manifests (host bandwidth, layer 1; docs/ASSETS.md "The served manifests"):
// data/assets.json and data/local-assets.json as this server hands them out, every art URL carrying the content hash
// of its file as its first segment (shared/assetPaths.js) — `/assets/char/avatar/x.png` →
// `/assets/~<hash>/char/avatar/x.png`, `/fonts/~<hash>/…` — so the browser keeps the file for a year and asks again
// only when the bytes change. Audio keeps the `/assets/~<hash>/audio/….mp3` form: public/js/media.js turns it into
// `/media/~<hash>/…` when it fetches it.
//
// * A Spine model's skeleton, atlas and pages carry ONE hash, that of the model (assetIndex.js models): pixi-spine
// finds the atlas by the skeleton's name and the pages next to the atlas, so their URLs share a folder. A model is
// every `{ skel, atlas, textures }` object of data/assets.json (chars' front / back, tokens, enemies) and every
// `spine/…` group of data/local-assets.json (the files a `spineLocal` record names).
// * A URL the index has no hash for (not on disk, not hashed yet) stays as it is.
// * The committed data/assets.json is never written: a view is built in memory from the file and the index, again
// when either changes (the file's size / mtime, the index's generation), and once after every index build, so the
// index knows the model lists before any page asks.
// * No index, or not ready yet: no view (`get` → null) and the files go out as they are.
import fsp from 'node:fs/promises';
import path from 'node:path';
import { hashedPath } from '../../shared/assetPaths.js';
import { hash16 } from './assetIndex.js';
import { noopLog } from './config.js';
/** The manifests that have a view, by their name under data/. */
export const VIEW_MANIFESTS = Object.freeze(['assets.json', 'local-assets.json']);
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
const ART_URL = /^\/(?:assets|fonts)\/./;
/** URL path of a manifest → the index's rel path ('/assets/a%20b.png' → 'assets/a b.png'), or null. */
export function relOf(url) {
if (typeof url !== 'string' || !ART_URL.test(url) || /[?#]/.test(url)) return null;
let p;
try { p = decodeURIComponent(url); } catch { return null; }
if (p.includes('\0') || p.includes('\\')) return null;
const segs = p.slice(1).split('/');
if (segs.some((s) => s === '' || s === '.' || s === '..')) return null;
return segs.join('/');
}
/** Is `o` a Spine object of data/assets.json (`{ skel: '/….skel', atlas, textures? }`)? */
const isSpine = (o) => isObj(o) && typeof o.skel === 'string' && o.skel.startsWith('/') && /\.skel$/.test(o.skel);
/** The URLs of a Spine object: skeleton, atlas, pages (the `<skel name>.png` when it lists none). */
function spineUrls(o) {
const out = [o.skel];
if (typeof o.atlas === 'string' && o.atlas) out.push(o.atlas);
const pages = Array.isArray(o.textures) ? o.textures.filter((u) => typeof u === 'string' && u) : [];
out.push(...(pages.length ? pages : [o.skel.replace(/\.skel$/, '.png')]));
return out;
}
/**
* The served copy of a manifest and its model lists. `index`: hashOf(rel) / modelHash(rels) of assetIndex.js.
* @param {any} doc the parsed manifest (not changed)
* @param {'assets.json'|'local-assets.json'} name
* @param {{ hashOf: (rel: string) => string | null, modelHash: (rels: string[]) => string | null }} index
* @returns {{ doc: any, models: string[][], hashed: number, plain: number }}
*/
export function rewriteManifest(doc, name, index) {
const models = [];
let hashed = 0;
let plain = 0;
const own = (url) => {
const rel = relOf(url);
const h = rel ? index.hashOf(rel) : null;
if (h) { hashed++; return hashedPath(url, h); }
if (rel) plain++;
return url;
};
/** All URLs of one model with the model's hash (or all left plain when a member is unknown). */
const model = (urls) => {
const rels = urls.map(relOf);
if (rels.some((r) => !r)) return null;
models.push(rels);
const h = index.modelHash(rels);
if (!h) { plain += urls.length; return null; }
hashed += urls.length;
return h;
};
const walk = (v) => {
if (typeof v === 'string') return ART_URL.test(v) ? own(v) : v;
if (Array.isArray(v)) return v.map(walk);
if (!isObj(v)) return v;
if (name === 'assets.json' && isSpine(v)) {
const h = model(spineUrls(v));
const out = {};
for (const [k, x] of Object.entries(v)) {
if (h && k === 'skel') out[k] = hashedPath(x, h);
else if (h && k === 'atlas' && typeof x === 'string') out[k] = hashedPath(x, h);
else if (h && k === 'textures' && Array.isArray(x)) out[k] = x.map((u) => (typeof u === 'string' ? hashedPath(u, h) : u));
else out[k] = walk(x);
}
return out;
}
const out = {};
for (const [k, x] of Object.entries(v)) out[k] = walk(x);
return out;
};
if (name === 'local-assets.json' && isObj(doc) && isObj(doc.groups)) {
const groups = {};
for (const [g, entries] of Object.entries(doc.groups)) {
if (g.startsWith('spine/') && isObj(entries)) {
const names = Object.keys(entries).filter((n) => isObj(entries[n]) && typeof entries[n].path === 'string').sort();
const h = names.length ? model(names.map((n) => entries[n].path)) : null;
groups[g] = {};
for (const [n, e] of Object.entries(entries)) {
groups[g][n] = h && isObj(e) && typeof e.path === 'string' ? { ...e, path: hashedPath(e.path, h) } : walk(e);
}
} else groups[g] = walk(entries);
}
return { doc: { ...walk({ ...doc, groups: null }), groups }, models, hashed, plain };
}
return { doc: walk(doc), models, hashed, plain };
}
/**
* The views of one server.
* @param {{ dataDir: string, index: ReturnType<typeof import('./assetIndex.js').createAssetIndex>, log?: object }} opts
*/
export function createManifestViews({ dataDir, index, log = noopLog }) {
const dir = path.resolve(dataDir);
/** @type {Map<string, { key: string, view: { body: Buffer, hash: string, hashed: number, plain: number } | null, models: string[][] }>} */
const cache = new Map();
/** @type {Map<string, Promise<any>>} */
const pending = new Map();
const warned = new Set();
const pushModels = () => index.setModels([...cache.values()].flatMap((c) => c.models));
/** Build (or reuse) the view of `name`; null without the file, while the index is not ready or empty, or for a broken file. */
async function build(name) {
if (!index.ready || !index.size) return null;
const file = path.join(dir, name);
const st = await fsp.stat(file).catch(() => null);
if (!st || !st.isFile()) {
if (cache.delete(name)) pushModels();
return null;
}
const key = `${index.generation}|${st.size}|${st.mtimeMs}`;
const hit = cache.get(name);
if (hit && hit.key === key) return hit.view;
let doc;
try {
doc = JSON.parse(await fsp.readFile(file, 'utf8'));
} catch (e) {
if (!warned.has(name)) { warned.add(name); log.warn?.(`[assets] data/${name} unreadable (${e?.message || e}); served as it is`); }
cache.set(name, { key, view: null, models: [] });
pushModels();
return null;
}
// one synchronous step: the view and the model lists the index checks a model's URLs against
const { doc: out, models, hashed, plain } = rewriteManifest(doc, name, index);
const body = Buffer.from(JSON.stringify(out));
const view = { body, hash: hash16(body), hashed, plain };
cache.set(name, { key, view, models });
pushModels();
return view;
}
/** The view of `name` (one build at a time per manifest). */
function get(name) {
if (!VIEW_MANIFESTS.includes(name)) return Promise.resolve(null);
const running = pending.get(name);
if (running) return running;
const p = build(name).catch((e) => { log.error?.('[assets] manifest view failed', e); return null; }).finally(() => pending.delete(name));
pending.set(name, p);
return p;
}
const rebuildAll = () => Promise.all(VIEW_MANIFESTS.map(get));
// after every build that changed the hashes (and the first one): the views, and with them the index's model lists
index.onChange(() => { rebuildAll(); });
index.whenReady().then(() => rebuildAll(), () => {});
return {
get,
rebuildAll,
stats() {
const out = {};
for (const [name, c] of cache) out[name] = c.view ? { bytes: c.view.body.length, hashed: c.view.hashed, plain: c.view.plain } : null;
return out;
},
};
}
+45 -9
View File
@@ -1,9 +1,11 @@
// 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>).
// compressed once 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';
@@ -84,13 +86,17 @@ export class GzipCache {
/** @returns {Promise<Buffer>} gzip of the file identified by (path, size, mtime) */
get(absPath, stat) {
const key = `${absPath}\0${stat.size}\0${stat.mtimeMs}`;
return this.compressed(`${absPath}\0${stat.size}\0${stat.mtimeMs}`, () => fsp.readFile(absPath));
}
/** @returns {Promise<Buffer>} gzip of the body `read()` returns, cached under `key` (one compression per key at a time) */
compressed(key, read) {
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);
if (pending) return pending;
const p = (async () => {
const raw = await fsp.readFile(absPath);
const raw = await read();
const gz = await gzipAsync(raw, { level: 6 });
this.store(key, gz);
return gz;
@@ -190,20 +196,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 baseTag = opts.contentHash || `${stat.size.toString(16)}-${Math.floor(stat.mtimeMs).toString(16)}`;
const etag = `"${baseTag}${useGzip ? '-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,
};
@@ -253,6 +264,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`, gzip when accepted (cached under
* `key`), 304 on a matching If-None-Match, no ranges.
* @param {{ body: Buffer, hash: string, type: string, cacheControl: string, key: string, cache: GzipCache }} o
*/
export async function serveBuffer(req, res, { body, hash, type, cacheControl, key, cache }) {
const useGzip = body.length >= GZIP_MIN_BYTES && acceptsGzip(req.headers['accept-encoding']);
const etag = `"${hash}${useGzip ? '-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 (useGzip) {
out = await cache.compressed(`${key}\0${hash}`, async () => body);
headers['Content-Encoding'] = 'gzip';
}
headers['Content-Length'] = out.length;
res.writeHead(200, headers);
res.end(req.method === 'HEAD' ? undefined : out);
}
async function streamTo(src, res, log, transform) {
try {
if (transform) await pipeline(src, transform, res);
+10 -3
View File
@@ -6,6 +6,7 @@ import path from 'node:path';
import { AUDIO_EXTS } from '../../shared/media.js';
import { sendError } from './common.js';
import { serveFile } from './files.js';
import { hashedFileOpts } from './assetIndex.js';
/**
* Extension-less audio route: `/media/bgm/act1` → `public/assets/audio/bgm/act1.mp3`.
@@ -15,8 +16,12 @@ import { serveFile } from './files.js';
* Requests for the direct `/assets/audio/…` URLs keep working (they are the fallback for plain static hosts).
* `MEDIA_PREFIX` / `AUDIO_EXTS` live in `shared/media.js`: the browser decides which URLs to rewrite with the
* same two values, and they must not drift apart.
* `/media/~<hash>/bgm/act1` (a served manifest's `/assets/~<hash>/audio/bgm/act1.mp3`, public/js/media.js): static.js
* strips the segment and hands it over as `urlHash`; the answer is `immutable` when it names the resolved file's bytes
* (assetIndex.js), else `no-cache`, and the ETag is the file's content hash when the index has it.
* @param {{ urlHash?: string | null, assetIndex?: ReturnType<typeof import('./assetIndex.js').createAssetIndex> | null }} [hashing]
*/
export async function serveMedia(req, res, rest, query, publicDir, gzipCache, log) {
export async function serveMedia(req, res, rest, query, publicDir, gzipCache, log, { urlHash = null, assetIndex = null } = {}) {
const root = path.join(path.resolve(publicDir), 'assets', 'audio');
const segments = String(rest || '').split('/').filter((s) => s.length > 0);
if (!segments.length || rest.endsWith('/')) { sendError(req, res, 404, '页面不存在 · Not found'); return; }
@@ -44,9 +49,11 @@ export async function serveMedia(req, res, rest, query, publicDir, gzipCache, lo
} catch { continue; }
if (!stat.isFile()) continue;
// serveFile decides Content-Type from the resolved name (`.mp3` → audio/mpeg) — Range/ETag handling is shared.
// Cache policy is that of the public path the client would otherwise have asked for (`/assets/audio/…`, 1 day).
// Cache policy is that of the public path the client would otherwise have asked for (`/assets/audio/…`, 1 day),
// or that of its content-hashed form.
const rel = ['assets', 'audio', ...segments.slice(0, -1), stem + ext].join('/');
// eslint-disable-next-line no-await-in-loop
await serveFile(req, res, absPath, stat, 'public', ['assets', 'audio', ...segments], query, gzipCache, log);
await serveFile(req, res, absPath, stat, 'public', ['assets', 'audio', ...segments], query, gzipCache, log, hashedFileOpts(assetIndex, rel, stat, urlHash));
return;
}
sendError(req, res, 404, '页面不存在 · Not found');
+37 -8
View File
@@ -15,16 +15,27 @@
// /packs/index.json → the content packs of this server (server/packs.js: the language packs of
// public/i18n/ and the pack folders of packs/, re-read when they change; never cached)
// /packs/<id>/<file> → a file of a pack folder — only one its manifest names (server/packs.js servable)
// /assets/~<hash>/…, /fonts/~<hash>/…, /media/~<hash>/… → the same files: the content-hash segment
// (shared/assetPaths.js) is stripped. A hash that names the file's current bytes (assetIndex.js
// urlHashOk: its own hash, or its Spine model's) answers `immutable` for a year; any other hash —
// an old page, a URL derived from another file's — the current file with `no-cache`
// /data/assets.json, /data/local-assets.json → the served manifests (assetViews.js: every art URL with its
// content-hash segment; no-cache, the ETag of the view) once the asset index is ready, the files as
// they are before that or without an index
//
// Traversal & dotfile protection, a directory without its trailing slash → 301, 404 page; an absent
// data/local-assets.json is answered with an empty manifest. Files go out through files.js serveFile.
// Files under public/assets/ and public/fonts/ get their content hash as ETag when the index has it (a file whose
// size or mtime changed since asks for a rescan), others size + mtime. Traversal & dotfile protection, a directory
// without its trailing slash → 301, 404 page; an absent data/local-assets.json is answered with an empty manifest.
// Files go out through files.js serveFile.
import fsp from 'node:fs/promises';
import path from 'node:path';
import { MEDIA_PREFIX } from '../../shared/media.js';
import { splitHashedPath } from '../../shared/assetPaths.js';
import { ROOT, noopLog } from './config.js';
import { sendError, sendJson } from './common.js';
import { MIME, GzipCache, isNotModified, serveFile } from './files.js';
import { MIME, GzipCache, 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) },
@@ -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);
};
}