0.2.6-dev: merge claude/bw-l01 — host bandwidth layers 0 and 1 (no BGM download while the music is silent, WebSocket permessage-deflate, lossless WebP 玩法说明 pages, traffic counters, content-hashed asset URLs with immutable caching, brotli q5) on top of the 0.2.5 release; its note becomes DESIGN §31 in docs/history/0.2.6.md, CHANGELOG 0.2.6-dev, version 0.2.6-dev; golden: unchanged

This commit is contained in:
sganggs committed 2026-10-12 18:28:50 +08:00
commit 3d5d6b380d
47 files changed
+2325 -136

No files matched your search

+78
View File
@@ -0,0 +1,78 @@
// Asset URL paths shared by the server and the browser (host bandwidth, docs/ASSETS.md "The served manifests"):
//
// * the content-hash segment. The server hands out data/assets.json and data/local-assets.json with every file URL
// carrying the first 16 hex digits of the file's SHA-256 (of the model's files for a Spine model) as its first
// segment — `/assets/char/avatar/x.png` → `/assets/~0123456789abcdef/char/avatar/x.png`, audio at
// `/media/~<hash>/…` (shared/media.js) — so a browser may keep it for a year. The server strips the segment and
// serves the file at the plain path (server/http/static.js, media.js). A path segment, never `?v=`: several
// loaders look at the URL's ending (`.skel`, `.mp3`) and pixi-spine finds the atlas and its pages next to the
// skeleton, which a query string would not survive.
// * trafficCategory: the category a request path counts under in the traffic counters (server/http/traffic.js,
// /healthz?traffic=1; public/js/diag.js for the browser's own totals).
/** Hex digits of a content hash in a URL. */
export const ASSET_HASH_HEX = 16;
/** The content-hash segment: `~` + ASSET_HASH_HEX lower-case hex digits. */
export const HASH_SEGMENT_RE = /^~([0-9a-f]{16})$/;
/** URL roots whose first segment may be a content hash. */
export const HASHED_ROOTS = Object.freeze(['/assets/', '/fonts/', '/media/']);
/**
* Split the content-hash segment off a URL path.
* '/assets/~0123456789abcdef/x.png' → { hash: '0123456789abcdef', path: '/assets/x.png' }; any other path (no segment,
* a malformed one, another root) → { hash: null, path } unchanged.
* @param {string} path a URL path (no query)
* @returns {{ hash: string | null, path: string }}
*/
export function splitHashedPath(path) {
const p = String(path ?? '');
for (const root of HASHED_ROOTS) {
if (!p.startsWith(root)) continue;
const slash = p.indexOf('/', root.length);
if (slash < 0) break;
const m = HASH_SEGMENT_RE.exec(p.slice(root.length, slash));
return m ? { hash: m[1], path: root + p.slice(slash + 1) } : { hash: null, path: p };
}
return { hash: null, path: p };
}
/**
* The URL path with the content-hash segment after its root ('/assets/x.png', 'abc…' → '/assets/~abc…/x.png'). A
* path outside HASHED_ROOTS, one that already has a segment, or no valid hash → the path unchanged.
* @param {string} path
* @param {string | null | undefined} hash
*/
export function hashedPath(path, hash) {
const p = String(path ?? '');
if (typeof hash !== 'string' || !HASH_SEGMENT_RE.test(`~${hash}`)) return p;
const root = HASHED_ROOTS.find((r) => p.startsWith(r));
if (!root || p.length === root.length || splitHashedPath(p).hash) return p;
return `${root}~${hash}/${p.slice(root.length)}`;
}
/** The URL with its content-hash segment removed (a URL derived from a hashed one names another file). */
export const unhashedUrl = (url) => (typeof url === 'string' ? splitHashedPath(url).path : url);
/** Traffic categories, in report order. */
export const TRAFFIC_CATEGORIES = Object.freeze(['assets', 'audio', 'code', 'data', 'other']);
/**
* The traffic category of a request path (the content-hash segment does not matter): `audio` (/media/,
* /assets/audio/), `assets` (/assets/, /fonts/, /icons/, the art of a content pack), `data` (/data/*.json, the UI
* language packs /i18n/, the pack index, other JSON), `code` (the page, scripts, styles: /, *.js, *.css, *.html,
* /vendor/, /sim/, /shared/), `other` (/healthz, error pages for anything else).
* @param {string} path a URL path (no query)
* @returns {'assets'|'audio'|'code'|'data'|'other'}
*/
export function trafficCategory(path) {
const p = splitHashedPath(path).path;
if (p.startsWith('/media/') || p.startsWith('/assets/audio/')) return 'audio';
if (p.startsWith('/assets/') || p.startsWith('/fonts/') || p.startsWith('/icons/')) return 'assets';
if (p.startsWith('/packs/')) return /\.json$/i.test(p) ? 'data' : 'assets';
if (p.startsWith('/data/') || p.startsWith('/i18n/') || /\.json$/i.test(p)) return 'data';
if (p === '/' || p.endsWith('/') || /\.(?:m?js|css|html?|map|webmanifest)$/i.test(p)
|| p.startsWith('/vendor/') || p.startsWith('/sim/') || p.startsWith('/shared/')) return 'code';
return 'other';
}
+1 -1
View File
@@ -5,7 +5,7 @@ import { N_ } from './i18n.js';
export const PROTOCOL_VERSION = 1;
/** Release version shown to players (title screen, server banner, /healthz). Kept equal to package.json "version"
* (test/version.test.js); PROTOCOL_VERSION above is the separate wire-format number. */
export const APP_VERSION = '0.2.5';
export const APP_VERSION = '0.2.6-dev';
/** A development build (the public `dev` branch): the title screen, the boot banner and the README say so, and
* tools/package.mjs refuses to build a release zip from it without --allow-dev. */
export const DEV_BUILD = /-dev$/.test(APP_VERSION);
+3 -1
View File
@@ -7,7 +7,9 @@
// /assets/audio/bgm/act1.mp3 → /media/bgm/act1
//
// — and `server/http/media.js` resolves it back to the real file under public/assets/audio, still answering with
// `Content-Type: audio/mpeg`, Range support and the ETag of the file it resolved.
// `Content-Type: audio/mpeg`, Range support and the ETag of the file it resolved. The content-hash segment of a served
// manifest's URL (shared/assetPaths.js) comes along: `/assets/~<hash>/audio/bgm/act1.mp3` → `/media/~<hash>/bgm/act1`,
// cached by the browser for a year while the hash names the file.
//
// Both sides import this module: the browser to decide which URLs to rewrite (`public/js/media.js`), the server to
// decide which extensions a `/media/…` request may resolve to. A second copy of the list on one side would let them