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:
1 parent
521dd984e7
commit
893d17e725
12 files changed
+571
-34
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user