Add per-file asset hashes and a rollback-compatible server manifest

This commit is contained in:
Jonathan Sykes
2026-10-07 22:57:41 +08:00
parent ad85cb6eb4
commit 6cb00d5073
8 changed files with 509 additions and 82 deletions

254
frontend/assets.json Normal file
View File

@@ -0,0 +1,254 @@
{
"groups": {
"core": {
"contract": 1,
"eager": true,
"files": [
"/index.html",
"/styles.css",
"/service-ui.css",
"/refresh.css",
"/recommendations.css",
"/flag-ui.js",
"/async-guard.js",
"/visible-timer.js",
"/sw-update.js",
"/fingerprint.js",
"/opfs.js",
"/opfs-worker.js",
"/lyrics-core.js",
"/lyrics-display.js",
"/sha256.js",
"/resume-core.js",
"/loudness-wasm.js",
"/loudness.js",
"/sha256-wasm.js",
"/settings-sections.js",
"/related-videos.js",
"/related-videos.css",
"/audio-continuity.js",
"/fullscreen-orientation.js",
"/fullscreen-orientation.css",
"/download-actions.js",
"/download-actions.css",
"/download-errors.js",
"/downloads.js",
"/downloads-page.js",
"/offline-pages.css",
"/saved-page.js",
"/server-backfill.js",
"/saved-page.css",
"/prewarm-next.js",
"/settings-sections.css",
"/app.js",
"/manifest.webmanifest",
"/icons/icon-192.png",
"/icons/icon-512.png",
"/fonts/fonts.css",
"/fonts/BricolageGrotesque-latin-ext.woff2",
"/fonts/BricolageGrotesque-latin.woff2",
"/fonts/HankenGrotesk-latin-ext.woff2",
"/fonts/HankenGrotesk-latin.woff2",
"/fonts/JetBrainsMono-latin-ext.woff2",
"/fonts/JetBrainsMono-latin.woff2"
]
},
"layout:glass": {
"contract": 1,
"eager": false,
"files": [
"/theme-glass.css"
]
},
"layout:classic": {
"contract": 1,
"eager": false,
"files": [
"/theme-classic.css"
]
},
"layout:glass-controls": {
"contract": 1,
"eager": false,
"files": [
"/theme-glass-controls.css",
"/glass-controls.js"
]
},
"layout:glass-panel": {
"contract": 1,
"eager": false,
"files": [
"/theme-glass-panel.css",
"/glass-panel-layout.js"
]
},
"layout:bento-hub": {
"contract": 1,
"eager": false,
"files": [
"/theme-bento-hub.css",
"/bento-hub.js",
"/fonts/PlusJakartaSans-latin.woff2",
"/fonts/PlusJakartaSans-latin-ext.woff2"
]
},
"layout:f7-swipe": {
"contract": 1,
"eager": false,
"files": [
"/theme-f7.css",
"/f7-layout.js",
"/vendor/framework7-swipe.min.js"
]
},
"feature:piano": {
"contract": 1,
"eager": false,
"files": [
"/piano-core.js",
"/piano.js",
"/piano-engine.mjs"
]
},
"feature:midi": {
"contract": 1,
"eager": false,
"files": [
"/midi.js"
]
},
"feature:party-dj": {
"contract": 1,
"eager": false,
"files": [
"/party-dj.js"
]
},
"feature:lyrics-window": {
"contract": 1,
"eager": false,
"files": [
"/lyrics-window.js"
]
},
"feature:lower-third": {
"contract": 1,
"eager": false,
"files": [
"/lower-third.js",
"/lower-third.css"
]
},
"feature:vocal-reducer": {
"contract": 1,
"eager": false,
"files": [
"/vocal-reducer.js"
]
},
"feature:eq-core": {
"contract": 1,
"eager": false,
"files": [
"/eq-core.js"
]
},
"feature:setlist-import": {
"contract": 1,
"eager": false,
"files": [
"/setlist-import.js"
]
},
"feature:car-mode": {
"contract": 1,
"eager": false,
"files": [
"/car-mode.js",
"/car-mode.css"
]
},
"feature:p2p": {
"contract": 1,
"eager": false,
"files": [
"/p2p-client.js",
"/p2p-core.js",
"/p2p-transfer.js",
"/p2p-recv-worker.js",
"/hash-worker.js",
"/device-db.js"
]
},
"feature:direct": {
"contract": 1,
"eager": false,
"files": [
"/direct-protocol.js",
"/direct-stream.js",
"/direct-media.js",
"/direct-media.css",
"/direct-recv-worker.js"
]
},
"feature:video-edit": {
"contract": 1,
"eager": false,
"files": [
"/video-edit.js"
]
},
"feature:stats": {
"contract": 1,
"eager": false,
"files": [
"/stats-core.js"
]
},
"feature:export": {
"contract": 1,
"eager": false,
"files": [
"/export.js"
]
},
"feature:playlist": {
"contract": 1,
"eager": true,
"files": [],
"embeddedIn": [
"/app.js"
]
},
"feature:settings": {
"contract": 1,
"eager": true,
"files": [],
"embeddedIn": [
"/app.js",
"/settings-sections.js",
"/settings-sections.css"
]
},
"feature:service-mode": {
"contract": 1,
"eager": true,
"files": [],
"embeddedIn": [
"/app.js",
"/service-ui.css"
]
},
"feature:lyrics-editor": {
"contract": 1,
"eager": true,
"files": [],
"embeddedIn": [
"/app.js",
"/lyrics-core.js",
"/lyrics-display.js"
]
}
}
}

View File

@@ -27,3 +27,25 @@ test('every SHELL asset exists on disk and there are no duplicate entries', () =
assert.equal(new Set(shell).size, shell.length, 'SHELL has no duplicate entries'); assert.equal(new Set(shell).size, shell.length, 'SHELL has no duplicate entries');
for (const asset of shell) assert.ok(existsSync(join(__dirname, asset.slice(1))), `${asset} exists`); for (const asset of shell) assert.ok(existsSync(join(__dirname, asset.slice(1))), `${asset} exists`);
}); });
test('assets.json describes the complete existing shell exactly once with eager core features', () => {
const { groups } = JSON.parse(readFileSync(join(__dirname, 'assets.json'), 'utf8'));
const described = new Set();
for (const [name, group] of Object.entries(groups)) {
assert.equal(group.contract, 1, `${name} initial contract`);
assert.equal(typeof group.eager, 'boolean');
for (const asset of group.files) {
assert.ok(!described.has(asset), `${asset} has one group`);
described.add(asset);
assert.ok(existsSync(join(__dirname, asset.slice(1))), `${asset} exists`);
}
}
for (const asset of shell) if (asset !== '/') assert.ok(described.has(asset), `${asset} is described`);
// piano-engine is dynamically imported and historically absent from SHELL.
for (const asset of described) assert.ok(listed.has(asset) || asset === '/piano-engine.mjs', `${asset} shell or documented dynamic import`);
assert.equal(groups.core.eager, true);
for (const name of ['playlist', 'settings', 'service-mode', 'lyrics-editor']) {
assert.equal(groups[`feature:${name}`].eager, true);
for (const asset of groups[`feature:${name}`].embeddedIn) assert.ok(groups.core.files.includes(asset), `${name} remains in core`);
}
});

View File

@@ -8,7 +8,8 @@
3. Add importable `server/asset-manifest.js`; integrate boot-time recursive 3. Add importable `server/asset-manifest.js`; integrate boot-time recursive
hashing, stamped index, manifest endpoint, per-file headers and rollback into hashing, stamped index, manifest endpoint, per-file headers and rollback into
`server/server.js`. Register module tests in `server/package.json`. `server/server.js`. Register module tests in `server/package.json`.
4. Stamp safe HTML resource URLs (CSS/JS, font preloads, webmanifest). Retain 4. Stamp HTML CSS/JS resource URLs. Keep font preloads consistent with
fonts.css and retain the existing webmanifest URL. Retain
plain JS-created worker/import URLs: introducing a new helper would require plain JS-created worker/import URLs: introducing a new helper would require
modifying the unchanged worker's shell or frontend callers. Inventory these modifying the unchanged worker's shell or frontend callers. Inventory these
exceptions explicitly in the report. Hash every recursively served file, exceptions explicitly in the report. Hash every recursively served file,

87
server/asset-manifest.js Normal file
View File

@@ -0,0 +1,87 @@
// Boot-time snapshot of ./public, independent of frontend source locations.
import { createHash } from 'node:crypto';
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
import { join } from 'node:path';
export const assetHash = bytes => createHash('sha256').update(bytes).digest('hex').slice(0, 10);
const tagHash = bytes => createHash('sha256').update(bytes).digest('hex').slice(0, 12);
export const injectBuildTag = (source, tag) => source.replace(
/typeof __BUILD_TAG__ !== 'undefined' \? __BUILD_TAG__ : '[^']*'/, JSON.stringify(tag));
export function stampIndex(source, files, { hashing = true, buildTag = '__BUILD_TAG__' } = {}) {
if (!hashing) return source.replace('__BUILD_TAG__', buildTag)
.replace(/((?:href|src)=")((?![a-z]+:|\/)[^"?]+\.(?:css|js))(")/g, `$1$2?v=${buildTag}$3`);
// Rewrite resource tags only, not anchors. Preserve unrelated query/fragment
// components and absolute external URLs; include root-relative local URLs.
return source.replace(/<(?:script|link)\b[^>]*>/gi, tag => tag.replace(
/\b(href|src)=(['"])([^'"]+)\2/gi, (attribute, name, quote, url) => {
if (/^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(url)) return attribute;
const parsed = new URL(url, 'https://assets.invalid/');
const file = files[decodeURIComponent(parsed.pathname)];
// Font preload URLs must stay identical to fonts.css's relative URLs.
if (!file || !/\.(?:css|js)$/.test(parsed.pathname) || parsed.pathname === '/sw.js') return attribute;
parsed.searchParams.set('v', file.h);
const originalPath = url.split(/[?#]/)[0];
return `${name}=${quote}${originalPath}${parsed.search}${parsed.hash}${quote}`;
})).replace('__BUILD_TAG__', buildTag);
}
export function createAssetManifest(publicDir = './public', { hashing = true, buildTag: override } = {}) {
const bytes = new Map();
const legacy = createHash('sha256');
function walk(dir, prefix = '') {
for (const name of readdirSync(dir).sort()) {
const path = join(dir, name), url = `${prefix}/${name}`;
if (statSync(path).isDirectory()) walk(path, url);
else {
const content = readFileSync(path);
bytes.set(url, content);
legacy.update(`./public${url}`); legacy.update(content);
}
}
}
// Missing public is the historical fixed fallback used by local tests.
if (!existsSync(publicDir)) return { manifest: { buildTag: override || 'dev-build', files: {}, groups: {}, contracts: {} }, index: null, sw: null, bytes };
walk(publicDir);
const definition = bytes.has('/assets.json') ? JSON.parse(bytes.get('/assets.json').toString()) : {
groups: { core: { contract: 1, eager: true, files: [...bytes.keys()].sort() } },
};
const groups = {}, contracts = {}, membership = new Map();
for (const name of Object.keys(definition.groups).sort()) {
const group = definition.groups[name];
if (!Number.isInteger(group.contract) || group.contract < 1 || typeof group.eager !== 'boolean' || !Array.isArray(group.files)) throw new Error(`Invalid asset group ${name}`);
groups[name] = group; contracts[name] = group.contract;
for (const url of group.files) {
if (!bytes.has(url)) throw new Error(`Asset group ${name}: missing ${url}`);
if (membership.has(url)) throw new Error(`Duplicate asset membership: ${url}`);
membership.set(url, name);
}
}
const files = {};
for (const url of [...bytes.keys()].sort()) files[url] = { h: assetHash(bytes.get(url)), s: bytes.get(url).length, g: membership.get(url) || 'core' };
const source = bytes.get('/index.html')?.toString() ?? null;
const swSource = bytes.get('/sw.js')?.toString() ?? null;
// Derived build metadata cannot be an input to its own hash. Canonicalize
// the index meta and SW injected tag, then publish hashes of the final bytes.
// All original bytes (including index/SW source) remain inputs via source hashes.
let canonicalIndex = source === null ? null : stampIndex(source, files, { hashing });
const canonical = { files: { ...files }, groups, contracts };
if (canonicalIndex !== null && hashing) canonical.files['/index.html'] = { ...files['/index.html'], h: assetHash(canonicalIndex), s: Buffer.byteLength(canonicalIndex), source: files['/index.html'].h };
const buildTag = hashing ? tagHash(JSON.stringify(canonical)) : (override || legacy.digest('hex').slice(0, 12));
const index = source === null ? null : stampIndex(source, files, { hashing, buildTag });
const sw = swSource === null ? null : injectBuildTag(swSource, buildTag);
if (index !== null) files['/index.html'] = { ...files['/index.html'], h: assetHash(index), s: Buffer.byteLength(index) };
if (sw !== null) files['/sw.js'] = { ...files['/sw.js'], h: assetHash(sw), s: Buffer.byteLength(sw) };
if (index !== null) bytes.set('/index.html', Buffer.from(index));
if (sw !== null) bytes.set('/sw.js', Buffer.from(sw));
return { manifest: { buildTag, files, groups, contracts }, index, sw, bytes };
}
export function assetCacheControl(path, version, manifest, hashing = true) {
const current = hashing ? manifest.files[path]?.h : manifest.buildTag;
if (version !== undefined) {
if (version === current) return 'public, max-age=31536000, immutable';
return hashing ? 'no-store' : 'no-cache';
}
return /(^|\/)(fonts|icons)\//.test(path) && (hashing || !/\.(?:js|css|html|json|webmanifest|svg|txt)$/i.test(path)) ? 'public, max-age=2592000' : 'no-cache';
}

View File

@@ -0,0 +1,62 @@
import { test, expect } from 'bun:test';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import { createAssetManifest } from './asset-manifest.js';
const hash = value => createHash('sha256').update(value).digest('hex').slice(0, 10);
function fixture(fn) {
const dir = mkdtempSync(join(tmpdir(), 'ytp-manifest-'));
try {
writeFileSync(join(dir, 'index.html'), '<meta name="ytp-build" content="__BUILD_TAG__"><link href="/app.css"><script src="app.js?old=1"></script><script src="https://example.com/x.js"></script>');
writeFileSync(join(dir, 'app.js'), 'window.app=1');
writeFileSync(join(dir, 'app.css'), 'body{}');
writeFileSync(join(dir, 'sw.js'), "const BUILD_TAG = typeof __BUILD_TAG__ !== 'undefined' ? __BUILD_TAG__ : 'old';");
writeFileSync(join(dir, 'assets.json'), JSON.stringify({ groups: { core: { contract: 1, eager: true, files: ['/index.html', '/app.js', '/app.css'] } } }));
fn(dir);
} finally { rmSync(dir, { recursive: true, force: true }); }
}
test('manifest determinism, final served byte hashes and one-file URL isolation', () => fixture(dir => {
const first = createAssetManifest(dir);
expect(createAssetManifest(dir)).toEqual(first);
expect(first.manifest.buildTag).toMatch(/^[a-f0-9]{12}$/);
expect(first.manifest.files['/app.js']).toEqual({ h: hash('window.app=1'), s: 12, g: 'core' });
expect(first.manifest.contracts.core).toBe(1);
expect(first.manifest.groups.core.eager).toBe(true);
expect(first.index).toContain(`/app.css?v=${first.manifest.files['/app.css'].h}`);
expect(first.index).toContain(`app.js?old=1&v=${first.manifest.files['/app.js'].h}`);
expect(first.index).toContain('https://example.com/x.js');
expect(first.index).toContain(`content="${first.manifest.buildTag}"`);
expect(first.manifest.files['/index.html'].h).toBe(hash(first.index));
expect(first.manifest.files['/sw.js'].h).toBe(hash(first.sw));
writeFileSync(join(dir, 'app.js'), 'window.app=2');
const second = createAssetManifest(dir);
expect(second.manifest.buildTag).not.toBe(first.manifest.buildTag);
for (const path of ['/app.css', '/assets.json']) expect(second.manifest.files[path]).toEqual(first.manifest.files[path]);
expect(second.manifest.files['/app.js'].h).not.toBe(first.manifest.files['/app.js'].h);
}));
test('unlisted files affect build identity; group contracts and order affect identity', () => fixture(dir => {
const first = createAssetManifest(dir);
writeFileSync(join(dir, 'other.bin'), 'shipped');
const second = createAssetManifest(dir);
expect(second.manifest.buildTag).not.toBe(first.manifest.buildTag);
expect(second.manifest.files['/other.bin'].h).toBe(hash('shipped'));
writeFileSync(join(dir, 'assets.json'), JSON.stringify({ groups: { core: { contract: 2, eager: true, files: ['/app.css', '/app.js', '/index.html'] } } }));
expect(createAssetManifest(dir).manifest.buildTag).not.toBe(second.manifest.buildTag);
}));
test('invalid group definitions fail visibly', () => fixture(dir => {
writeFileSync(join(dir, 'assets.json'), JSON.stringify({ groups: { core: { contract: 1, eager: true, files: ['/missing.js'] } } }));
expect(() => createAssetManifest(dir)).toThrow('missing');
}));
test('rollback retains legacy tag algorithm and relative-only single-tag stamping', () => fixture(dir => {
const result = createAssetManifest(dir, { hashing: false });
const expected = createHash('sha256');
for (const name of ['app.css', 'app.js', 'assets.json', 'index.html', 'sw.js']) {
expected.update(`./public/${name}`);
const { readFileSync } = require('node:fs');
expected.update(readFileSync(join(dir, name)));
}
expect(result.manifest.buildTag).toBe(expected.digest('hex').slice(0, 12));
expect(result.index).toContain('href="/app.css"');
}));

View File

@@ -6,7 +6,7 @@
"scripts": { "scripts": {
"start": "bun server.js", "start": "bun server.js",
"dev": "bun --hot server.js", "dev": "bun --hot server.js",
"test": "bun test ./recommendations.test.js && bun test --timeout 60000 ./media-cache.test.js && bun test ./notes.test.js && bun test ./transcriptions.test.js && bun test ./admin-analytics.test.js && bun test ./remote.test.js && bun test ./party.test.js && bun test ./uploads.test.js && bun test ./innertube.test.js && bun test ./related.test.js && bun test ./ytdlp-pool.test.js && bun test ./warm-queue.test.js && bun test ./p2p-db.test.js && bun test ./p2p-admit.test.js && bun test ./p2p-retention.test.js && bun test ./p2p-routes.test.js && bun test ./p2p-hub.test.js && bun test --timeout 60000 ./p2p-intake.test.js && bun test ./flags.test.js && bun test ./static-delivery.test.js" "test": "bun test ./recommendations.test.js && bun test --timeout 60000 ./media-cache.test.js && bun test ./notes.test.js && bun test ./transcriptions.test.js && bun test ./admin-analytics.test.js && bun test ./remote.test.js && bun test ./party.test.js && bun test ./uploads.test.js && bun test ./innertube.test.js && bun test ./related.test.js && bun test ./ytdlp-pool.test.js && bun test ./warm-queue.test.js && bun test ./p2p-db.test.js && bun test ./p2p-admit.test.js && bun test ./p2p-retention.test.js && bun test ./p2p-routes.test.js && bun test ./p2p-hub.test.js && bun test --timeout 60000 ./p2p-intake.test.js && bun test ./flags.test.js && bun test ./asset-manifest.test.js && bun test ./static-delivery.test.js"
}, },
"dependencies": { "dependencies": {
"@hono/node-server": "^1.14.0", "@hono/node-server": "^1.14.0",

View File

@@ -30,10 +30,10 @@
* UI code works without modification in WEB mode. * UI code works without modification in WEB mode.
* ========================================================================== */ * ========================================================================== */
import { createAssetManifest, assetCacheControl } from './asset-manifest.js';
import { registerDownloadRetryRoute } from './download-retry.js'; import { registerDownloadRetryRoute } from './download-retry.js';
import { createResilientYtdlp, BOT_CHECK_RE, DEFAULT_FALLBACK_CLIENTS } from './ytdlp-resilience.js'; import { createResilientYtdlp, BOT_CHECK_RE, DEFAULT_FALLBACK_CLIENTS } from './ytdlp-resilience.js';
import { Hono } from 'hono'; import { Hono } from 'hono';
import { serveStatic } from 'hono/bun';
import { logger } from 'hono/logger'; import { logger } from 'hono/logger';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { createServer } from 'node:http'; import { createServer } from 'node:http';
@@ -96,48 +96,11 @@ function withSaveSlot(fn) {
return run; return run;
} }
// ---------------------------------------------------------------------------- // Snapshot every shipped public file. Hashes describe the served HTML/SW bytes;
// BUILD_TAG — must be DETERMINISTIC across restarts of identical code. // rollback retains the historical recursive tag and single-tag URL stamping.
// const ASSET_HASHING = process.env.ASSET_HASHING !== '0';
// Previously this was `Date.now().toString(36)`, which changes every time the const assets = createAssetManifest('./public', { hashing: ASSET_HASHING, buildTag: process.env.BUILD_TAG });
// process starts even if nothing was deployed (crash-loop, healthcheck const BUILD_TAG = assets.manifest.buildTag;
// restart, container reschedule). The frontend's checkBuildTag() polls
// /api/version and re-shows the "Update available" modal the instant the tag
// drifts — so a restarting-but-unchanged server kept re-announcing an update
// that never actually happened, and clicking "Refresh UI" (which itself
// reloads the page and re-polls) never made the prompt go away for good.
//
// Fix: hash the actual served frontend files. Identical code → identical
// hash → identical tag, no matter how many times the process restarts. A
// real deploy (changed files) still produces a new tag as intended.
// process.env.BUILD_TAG still wins if a CI pipeline already injects a git
// SHA — that's an even better source of truth than a content hash.
// ----------------------------------------------------------------------------
function computeBuildTag() {
try {
// Hash EVERY served frontend file (recursively, in sorted order), not a
// hand-picked subset — a change to any shell file (e.g. sw-update.js or
// opfs.js) must produce a new tag, or clients keep their old SW cache
// and never receive the change.
const hash = createHash('sha256');
const walk = (dir) => {
for (const name of readdirSync(dir).sort()) {
const path = `${dir}/${name}`;
if (statSync(path).isDirectory()) walk(path);
else { hash.update(path); hash.update(readFileSync(path)); }
}
};
walk('./public');
return hash.digest('hex').slice(0, 12);
} catch {
// Frontend files not readable (e.g. unit tests run outside ./public) —
// fall back to a fixed tag rather than Date.now(), so it still never
// drifts spuriously between restarts.
return 'dev-build';
}
}
const BUILD_TAG = process.env.BUILD_TAG || computeBuildTag();
// BUILD_TIME — human-readable "when was this image built". Written by the // BUILD_TIME — human-readable "when was this image built". Written by the
// Dockerfile at image build time (never at container start, so restarts // Dockerfile at image build time (never at container start, so restarts
@@ -389,6 +352,14 @@ registerCatalogRoutes(app, { resolveListener: recommendationListener });
// GET /api/version // GET /api/version
// Returns version string + a build tag that changes on every server restart/deploy. // Returns version string + a build tag that changes on every server restart/deploy.
// Clients poll this to detect when a new build is live and prompt a reload. // Clients poll this to detect when a new build is live and prompt a reload.
app.get('/api/manifest', (c) => {
const etag = `"${BUILD_TAG}"`;
const headers = { 'Cache-Control': 'no-store', ETag: etag };
const matches = (c.req.header('if-none-match') || '').split(',').map(value => value.trim().replace(/^W\//, ''));
if (matches.includes(etag) || matches.includes('*')) return new Response(null, { status: 304, headers });
return c.json(assets.manifest, 200, headers);
});
app.get('/api/version', (c) => app.get('/api/version', (c) =>
c.json( c.json(
{ version: APP_VERSION, buildTag: BUILD_TAG, buildTime: BUILD_TIME }, { version: APP_VERSION, buildTag: BUILD_TAG, buildTime: BUILD_TIME },
@@ -2482,7 +2453,7 @@ app.get('/sw.js', (c) => {
if (src === _swSource) { if (src === _swSource) {
console.error('[sw] BUILD_TAG injection failed — placeholder not found in sw.js'); console.error('[sw] BUILD_TAG injection failed — placeholder not found in sw.js');
} }
return sendCompressed(c, compressedEntry('sw.js', Buffer.from(src), MIME.js), 'no-store, no-cache, must-revalidate'); return sendCompressed(c, compressedEntry('sw.js', Buffer.from(src), MIME.js), 'no-store, no-cache, must-revalidate', assets.manifest.files['/sw.js']?.h);
}); });
// ============================================================================ // ============================================================================
@@ -2511,8 +2482,9 @@ function compressedEntry(key, raw, type) {
compressedCache.set(key, e); compressedCache.set(key, e);
return e; return e;
} }
function sendCompressed(c, e, cacheControl) { function sendCompressed(c, e, cacheControl, hash) {
const headers = { 'Content-Type': e.type, 'Cache-Control': cacheControl, ETag: e.etag, Vary: 'Accept-Encoding' }; const headers = { 'Content-Type': e.type, 'Cache-Control': cacheControl, ETag: e.etag, Vary: 'Accept-Encoding' };
if (hash) headers['X-Asset-Hash'] = hash;
const inm = c.req.header('if-none-match') || ''; const inm = c.req.header('if-none-match') || '';
if (inm.split(',').map((s) => s.trim()).includes(e.etag)) return new Response(null, { status: 304, headers }); if (inm.split(',').map((s) => s.trim()).includes(e.etag)) return new Response(null, { status: 304, headers });
const ae = c.req.header('accept-encoding') || ''; const ae = c.req.header('accept-encoding') || '';
@@ -2523,42 +2495,28 @@ function sendCompressed(c, e, cacheControl) {
const MIME = { js: 'text/javascript; charset=utf-8', css: 'text/css; charset=utf-8', html: 'text/html; charset=utf-8', const MIME = { js: 'text/javascript; charset=utf-8', css: 'text/css; charset=utf-8', html: 'text/html; charset=utf-8',
json: 'application/json', webmanifest: 'application/manifest+json', svg: 'image/svg+xml', txt: 'text/plain; charset=utf-8' }; json: 'application/json', webmanifest: 'application/manifest+json', svg: 'image/svg+xml', txt: 'text/plain; charset=utf-8' };
let _indexSource = null;
function indexHtml(c) { function indexHtml(c) {
if (_indexSource === null) { if (assets.index === null) return c.text('index.html not found', 404);
try { _indexSource = readFileSync('./public/index.html', 'utf8'); } return sendCompressed(c, compressedEntry('index.html', Buffer.from(assets.index), MIME.html),
catch { return c.text('index.html not found', 404); } assetCacheControl('/index.html', c.req.query('v'), assets.manifest, ASSET_HASHING), assets.manifest.files['/index.html']?.h);
}
// Local css/js get `?v=<build>`: that URL's bytes never change, so the static
// handler below lets browsers keep it forever; a new build is a new URL.
const html = _indexSource.replace('__BUILD_TAG__', BUILD_TAG)
.replace(/((?:href|src)=")((?![a-z]+:|\/)[^"?]+\.(?:css|js))(")/g, `$1$2?v=${BUILD_TAG}$3`);
return sendCompressed(c, compressedEntry('index.html', Buffer.from(html), MIME.html), 'no-cache');
} }
app.get('/', indexHtml); app.get('/', indexHtml);
app.get('/index.html', indexHtml); app.get('/index.html', indexHtml);
// no-cache (revalidate every time) on the shell: the service worker is the // Serve every file with its per-file hash, including binary icons/fonts and
// only cache that should hold app files. With no headers at all, a browser // unversioned URLs requested by old clients/workers. A stale version gets the
// may heuristically cache app.js and hand a new worker's install the old one. // current body with no-store, never immutable caching under a mismatched URL.
app.get('/*', async (c, next) => { app.get('/*', async (c, next) => {
const p = decodeURIComponent(new URL(c.req.url).pathname); const p = decodeURIComponent(new URL(c.req.url).pathname);
if (!COMPRESSIBLE.test(p) || p.includes('..') || p.startsWith('/api/')) return next(); if (p.includes('..') || p.startsWith('/api/') || !assets.manifest.files[p]) return next();
const file = Bun.file('./public' + p); const file = Bun.file('./public' + p);
if (!(await file.exists())) return next(); const raw = assets.bytes.get(p);
const raw = Buffer.from(await file.arrayBuffer());
const ext = p.slice(p.lastIndexOf('.') + 1).toLowerCase(); const ext = p.slice(p.lastIndexOf('.') + 1).toLowerCase();
// `?v=` equal to the running build → forever (a new build changes the URL). const hash = assets.manifest.files[p].h;
// Any other / missing v keeps revalidating. const cacheControl = assetCacheControl(p, c.req.query('v'), assets.manifest, ASSET_HASHING);
const forever = c.req.query('v') === BUILD_TAG; if (COMPRESSIBLE.test(p)) return sendCompressed(c, compressedEntry(p, raw, MIME[ext] || file.type), cacheControl, hash);
return sendCompressed(c, compressedEntry(p, raw, MIME[ext] || 'application/octet-stream'), return new Response(raw, { headers: { 'Content-Type': file.type, 'Cache-Control': cacheControl, 'X-Asset-Hash': hash } });
forever ? 'public, max-age=31536000, immutable' : 'no-cache');
}); });
// Self-hosted fonts and icons rarely change: let the browser keep them for 30 days
// (the service worker precaches the shell anyway); everything else revalidates.
app.use('/*', serveStatic({ root: './public', onFound: (path, c) => {
c.header('Cache-Control', /(^|\/)(fonts|icons)\//.test(path) ? 'public, max-age=2592000' : 'no-cache');
} }));
// SPA fallback — return index.html for any unmatched path // SPA fallback — return index.html for any unmatched path
app.get('/*', indexHtml); app.get('/*', indexHtml);

View File

@@ -32,11 +32,11 @@ async function freePort() {
}); });
} }
async function startServer() { async function startServer(hashing = true) {
const port = await freePort(); const port = await freePort();
const child = Bun.spawn([process.execPath, serverFile], { const child = Bun.spawn([process.execPath, serverFile], {
cwd: root, cwd: root,
env: { ...process.env, PORT: String(port), DB_PATH: join(dataDir, `db-${port}.sqlite`), MEDIA_DIR: join(dataDir, 'media'), UPLOAD_DIR: join(dataDir, 'uploads') }, env: { ...process.env, PORT: String(port), ASSET_HASHING: hashing ? '1' : '0', BUILD_TAG: '', DB_PATH: join(dataDir, `db-${port}.sqlite`), MEDIA_DIR: join(dataDir, 'media'), UPLOAD_DIR: join(dataDir, 'uploads') },
stdout: 'pipe', stderr: 'pipe', stdout: 'pipe', stderr: 'pipe',
}); });
const stdout = new Response(child.stdout).text(); const stdout = new Response(child.stdout).text();
@@ -97,20 +97,20 @@ describe('static delivery characterization', () => {
test('index stamps only relative local css/js and serves expected cache headers', async () => { test('index stamps only relative local css/js and serves expected cache headers', async () => {
const index = await fetch(`${server.base}/`); const index = await fetch(`${server.base}/`);
const html = await index.text(); const html = await index.text();
const { buildTag } = await (await fetch(`${server.base}/api/version`)).json(); const { buildTag, files } = await (await fetch(`${server.base}/api/manifest`)).json();
expect(html).toContain(`<meta name="ytp-build" content="${buildTag}">`); expect(html).toContain(`<meta name="ytp-build" content="${buildTag}">`);
expect(html).toContain(`href="app.css?v=${buildTag}"`); expect(html).toContain(`href="app.css?v=${files['/app.css'].h}"`);
expect(html).toContain(`src="app.js?v=${buildTag}"`); expect(html).toContain(`src="app.js?v=${files['/app.js'].h}"`);
expect(html).toContain('https://cdn.example/x.js'); expect(html).toContain('https://cdn.example/x.js');
expect(html).toContain('href="/root.css"'); expect(html).toContain(`href="/root.css?v=${files['/root.css'].h}"`);
expect(index.headers.get('cache-control')).toBe('no-cache'); expect(index.headers.get('cache-control')).toBe('no-cache');
expect((await fetch(`${server.base}/index.html`)).headers.get('cache-control')).toBe('no-cache'); expect((await fetch(`${server.base}/index.html`)).headers.get('cache-control')).toBe('no-cache');
const current = await fetch(`${server.base}/app.js?v=${buildTag}`, { headers: { 'Accept-Encoding': 'br' } }); const current = await fetch(`${server.base}/app.js?v=${files['/app.js'].h}`, { headers: { 'Accept-Encoding': 'br' } });
expect(current.headers.get('cache-control')).toBe('public, max-age=31536000, immutable'); expect(current.headers.get('cache-control')).toBe('public, max-age=31536000, immutable');
expect(current.headers.get('content-encoding')).toBe('br'); expect(current.headers.get('content-encoding')).toBe('br');
expect(current.headers.get('etag')).toBeTruthy(); expect(current.headers.get('etag')).toBeTruthy();
expect((await fetch(`${server.base}/app.js?v=stale`)).headers.get('cache-control')).toBe('no-cache'); expect((await fetch(`${server.base}/app.js?v=stale`)).headers.get('cache-control')).toBe('no-store');
expect((await fetch(`${server.base}/app.js`)).headers.get('cache-control')).toBe('no-cache'); expect((await fetch(`${server.base}/app.js`)).headers.get('cache-control')).toBe('no-cache');
expect((await fetch(`${server.base}/fonts/font.woff2`)).headers.get('cache-control')).toBe('public, max-age=2592000'); expect((await fetch(`${server.base}/fonts/font.woff2`)).headers.get('cache-control')).toBe('public, max-age=2592000');
expect((await fetch(`${server.base}/icons/icon.png`)).headers.get('cache-control')).toBe('public, max-age=2592000'); expect((await fetch(`${server.base}/icons/icon.png`)).headers.get('cache-control')).toBe('public, max-age=2592000');
@@ -118,11 +118,54 @@ describe('static delivery characterization', () => {
expect((await fetch(`${server.base}${path}`)).headers.get('cache-control')).toBe('no-store, no-cache, must-revalidate'); expect((await fetch(`${server.base}${path}`)).headers.get('cache-control')).toBe('no-store, no-cache, must-revalidate');
} }
const etag = current.headers.get('etag'); const etag = current.headers.get('etag');
const notModified = await fetch(`${server.base}/app.js?v=${buildTag}`, { headers: { 'If-None-Match': etag } }); const notModified = await fetch(`${server.base}/app.js?v=${files['/app.js'].h}`, { headers: { 'If-None-Match': etag } });
expect(notModified.status).toBe(304); expect(notModified.status).toBe(304);
expect(await notModified.text()).toBe(''); expect(await notModified.text()).toBe('');
}); });
test('manifest API, ETag/304 and hashes cover compressed and binary assets', async () => {
const response = await fetch(`${server.base}/api/manifest`);
expect(response.headers.get('cache-control')).toBe('no-store');
const manifest = await response.json();
expect(manifest.buildTag).toBe((await (await fetch(`${server.base}/api/version`)).json()).buildTag);
expect(manifest.groups.core.eager).toBe(true);
expect(manifest.contracts.core).toBe(1);
expect(response.headers.get('etag')).toBe(`"${manifest.buildTag}"`);
const unchanged = await fetch(`${server.base}/api/manifest`, { headers: { 'If-None-Match': response.headers.get('etag') } });
expect(unchanged.status).toBe(304);
expect(await unchanged.text()).toBe('');
const { createHash } = await import('node:crypto');
for (const path of ['/app.js', '/fonts/font.woff2', '/icons/icon.png', '/index.html', '/sw.js']) {
const h = manifest.files[path].h;
for (const query of ['', `?v=${h}`, '?v=oldTag', '?__ytpfresh=123', '?v=']) {
const asset = await fetch(`${server.base}${path}${query}`);
expect(asset.status).toBe(200);
expect(asset.headers.get('x-asset-hash')).toBe(h);
expect(createHash('sha256').update(Buffer.from(await asset.arrayBuffer())).digest('hex').slice(0, 10)).toBe(h);
if (path !== '/sw.js') {
if (query === `?v=${h}`) expect(asset.headers.get('cache-control')).toBe('public, max-age=31536000, immutable');
if (query === '?v=oldTag' || query === '?v=') expect(asset.headers.get('cache-control')).toBe('no-store');
}
}
}
const canonical = await (await fetch(`${server.base}/`)).text();
for (const path of ['/index.html', '/search', '/playlist/x']) expect(await (await fetch(`${server.base}${path}`)).text()).toBe(canonical);
});
test('ASSET_HASHING=0 restores single-tag stamping and old cache matrix', async () => {
await stopServer(server);
server = await startServer(false);
try {
const { buildTag } = await (await fetch(`${server.base}/api/version`)).json();
const html = await (await fetch(`${server.base}/`)).text();
expect(html).toContain(`src="app.js?v=${buildTag}"`);
expect(html).toContain('href="/root.css"');
expect((await fetch(`${server.base}/app.js?v=${buildTag}`)).headers.get('cache-control')).toBe('public, max-age=31536000, immutable');
expect((await fetch(`${server.base}/app.js?v=stale`)).headers.get('cache-control')).toBe('no-cache');
} finally { await stopServer(server); server = await startServer(); }
});
test('service worker injection tracks fallback literal and reports missing placeholder', async () => { test('service worker injection tracks fallback literal and reports missing placeholder', async () => {
let sw = await (await fetch(`${server.base}/sw.js`)).text(); let sw = await (await fetch(`${server.base}/sw.js`)).text();
const { buildTag } = await (await fetch(`${server.base}/api/version`)).json(); const { buildTag } = await (await fetch(`${server.base}/api/version`)).json();