186 lines
8.4 KiB
JavaScript
186 lines
8.4 KiB
JavaScript
// 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;
|
|
},
|
|
};
|
|
}
|