diff --git a/frontend/assets.json b/frontend/assets.json new file mode 100644 index 0000000..0e1c9f3 --- /dev/null +++ b/frontend/assets.json @@ -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" + ] + } + } +} diff --git a/frontend/shell-consistency.test.js b/frontend/shell-consistency.test.js index 752875f..1aa4082 100644 --- a/frontend/shell-consistency.test.js +++ b/frontend/shell-consistency.test.js @@ -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'); 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`); + } +}); diff --git a/plans/phase1-plan.md b/plans/phase1-plan.md index 55b03e2..f322dd7 100644 --- a/plans/phase1-plan.md +++ b/plans/phase1-plan.md @@ -8,7 +8,8 @@ 3. Add importable `server/asset-manifest.js`; integrate boot-time recursive hashing, stamped index, manifest endpoint, per-file headers and rollback into `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 modifying the unchanged worker's shell or frontend callers. Inventory these exceptions explicitly in the report. Hash every recursively served file, diff --git a/server/asset-manifest.js b/server/asset-manifest.js new file mode 100644 index 0000000..c9734e2 --- /dev/null +++ b/server/asset-manifest.js @@ -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'; +} diff --git a/server/asset-manifest.test.js b/server/asset-manifest.test.js new file mode 100644 index 0000000..71d59b8 --- /dev/null +++ b/server/asset-manifest.test.js @@ -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'), ''); + 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"'); +})); diff --git a/server/package.json b/server/package.json index b2371ae..a4e37cf 100644 --- a/server/package.json +++ b/server/package.json @@ -6,7 +6,7 @@ "scripts": { "start": "bun 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": { "@hono/node-server": "^1.14.0", diff --git a/server/server.js b/server/server.js index 7596424..d98bcc0 100644 --- a/server/server.js +++ b/server/server.js @@ -30,10 +30,10 @@ * UI code works without modification in WEB mode. * ========================================================================== */ +import { createAssetManifest, assetCacheControl } from './asset-manifest.js'; import { registerDownloadRetryRoute } from './download-retry.js'; import { createResilientYtdlp, BOT_CHECK_RE, DEFAULT_FALLBACK_CLIENTS } from './ytdlp-resilience.js'; import { Hono } from 'hono'; -import { serveStatic } from 'hono/bun'; import { logger } from 'hono/logger'; import { spawn } from 'node:child_process'; import { createServer } from 'node:http'; @@ -96,48 +96,11 @@ function withSaveSlot(fn) { return run; } -// ---------------------------------------------------------------------------- -// BUILD_TAG — must be DETERMINISTIC across restarts of identical code. -// -// Previously this was `Date.now().toString(36)`, which changes every time the -// process starts even if nothing was deployed (crash-loop, healthcheck -// 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(); +// Snapshot every shipped public file. Hashes describe the served HTML/SW bytes; +// rollback retains the historical recursive tag and single-tag URL stamping. +const ASSET_HASHING = process.env.ASSET_HASHING !== '0'; +const assets = createAssetManifest('./public', { hashing: ASSET_HASHING, buildTag: process.env.BUILD_TAG }); +const BUILD_TAG = assets.manifest.buildTag; // BUILD_TIME — human-readable "when was this image built". Written by the // Dockerfile at image build time (never at container start, so restarts @@ -389,6 +352,14 @@ registerCatalogRoutes(app, { resolveListener: recommendationListener }); // GET /api/version // 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. +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) => c.json( { version: APP_VERSION, buildTag: BUILD_TAG, buildTime: BUILD_TIME }, @@ -2482,7 +2453,7 @@ app.get('/sw.js', (c) => { if (src === _swSource) { 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); 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' }; + if (hash) headers['X-Asset-Hash'] = hash; 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 }); 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', json: 'application/json', webmanifest: 'application/manifest+json', svg: 'image/svg+xml', txt: 'text/plain; charset=utf-8' }; -let _indexSource = null; function indexHtml(c) { - if (_indexSource === null) { - try { _indexSource = readFileSync('./public/index.html', 'utf8'); } - catch { return c.text('index.html not found', 404); } - } - // Local css/js get `?v=`: 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'); + if (assets.index === null) return c.text('index.html not found', 404); + return sendCompressed(c, compressedEntry('index.html', Buffer.from(assets.index), MIME.html), + assetCacheControl('/index.html', c.req.query('v'), assets.manifest, ASSET_HASHING), assets.manifest.files['/index.html']?.h); } app.get('/', indexHtml); app.get('/index.html', indexHtml); -// no-cache (revalidate every time) on the shell: the service worker is the -// only cache that should hold app files. With no headers at all, a browser -// may heuristically cache app.js and hand a new worker's install the old one. +// Serve every file with its per-file hash, including binary icons/fonts and +// unversioned URLs requested by old clients/workers. A stale version gets the +// current body with no-store, never immutable caching under a mismatched URL. app.get('/*', async (c, next) => { 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); - if (!(await file.exists())) return next(); - const raw = Buffer.from(await file.arrayBuffer()); + const raw = assets.bytes.get(p); const ext = p.slice(p.lastIndexOf('.') + 1).toLowerCase(); - // `?v=` equal to the running build → forever (a new build changes the URL). - // Any other / missing v keeps revalidating. - const forever = c.req.query('v') === BUILD_TAG; - return sendCompressed(c, compressedEntry(p, raw, MIME[ext] || 'application/octet-stream'), - forever ? 'public, max-age=31536000, immutable' : 'no-cache'); + const hash = assets.manifest.files[p].h; + const cacheControl = assetCacheControl(p, c.req.query('v'), assets.manifest, ASSET_HASHING); + if (COMPRESSIBLE.test(p)) return sendCompressed(c, compressedEntry(p, raw, MIME[ext] || file.type), cacheControl, hash); + return new Response(raw, { headers: { 'Content-Type': file.type, 'Cache-Control': cacheControl, 'X-Asset-Hash': hash } }); }); -// 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 app.get('/*', indexHtml); diff --git a/server/static-delivery.test.js b/server/static-delivery.test.js index f3d7a0a..0aafb79 100644 --- a/server/static-delivery.test.js +++ b/server/static-delivery.test.js @@ -32,11 +32,11 @@ async function freePort() { }); } -async function startServer() { +async function startServer(hashing = true) { const port = await freePort(); const child = Bun.spawn([process.execPath, serverFile], { 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', }); 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 () => { const index = await fetch(`${server.base}/`); 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(``); - expect(html).toContain(`href="app.css?v=${buildTag}"`); - expect(html).toContain(`src="app.js?v=${buildTag}"`); + expect(html).toContain(`href="app.css?v=${files['/app.css'].h}"`); + expect(html).toContain(`src="app.js?v=${files['/app.js'].h}"`); 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((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('content-encoding')).toBe('br'); 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}/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'); @@ -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'); } 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(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 () => { let sw = await (await fetch(`${server.base}/sw.js`)).text(); const { buildTag } = await (await fetch(`${server.base}/api/version`)).json();