From dd09f665782db5f9e0211c1ad4ab1a90898dd967 Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Fri, 9 Oct 2026 14:40:46 +0800 Subject: [PATCH] Separate runtime manifest inputs from directly served files --- frontend/shell-consistency.test.js | 10 +++++++++ server/asset-manifest.js | 34 +++++++++++++++++++++++------- server/asset-manifest.test.js | 31 ++++++++++++++++++++++++++- server/server.js | 6 +++--- server/static-delivery.test.js | 21 ++++++++++++++++-- 5 files changed, 88 insertions(+), 14 deletions(-) diff --git a/frontend/shell-consistency.test.js b/frontend/shell-consistency.test.js index bbdfbff..c57eb27 100644 --- a/frontend/shell-consistency.test.js +++ b/frontend/shell-consistency.test.js @@ -88,3 +88,13 @@ test('layout-independent visibility and transport defaults stay in eager CSS',() for(const selector of ['.stg-overlay','.deck-chips','.np-btn.done .i-save','.sleep-status .sl-ic','.ab-group','.mc-tune'])assert.ok(shared.includes(selector)); assert.ok(html.includes('href="layout-base.css"')); }); + + +test('runtime exclusion rules never remove shell or declared dynamic assets', async () => { + const { isRuntimeAsset } = await import('../server/asset-manifest.js'); + const definition = JSON.parse(readFileSync(join(__dirname, 'assets.json'), 'utf8')); + for (const asset of new Set([...shell.filter(p => p !== '/'), ...Object.values(definition.groups).flatMap(g => g.files)])) { + assert.ok(isRuntimeAsset(asset, definition), `${asset} remains a runtime asset`); + } + for (const asset of ['/app.test.js', '/nested/probe.test.mjs', '/README.md', '/fonts/font-OFL.txt', '/admin.html', '/perf/fixture.js']) assert.equal(isRuntimeAsset(asset, definition), false, asset); +}); diff --git a/server/asset-manifest.js b/server/asset-manifest.js index 82127d2..4f91f08 100644 --- a/server/asset-manifest.js +++ b/server/asset-manifest.js @@ -35,6 +35,14 @@ export function stampIndex(source, files, { hashing = true, buildTag = '__BUILD_ })).replace('__BUILD_TAG__', buildTag); } +// Runtime set: all public files except developer material and the online-only admin. +// Exact root-relative includes in assets.json override these rules for real runtime data. +export function isRuntimeAsset(path, definition = {}) { + if (definition.include?.includes(path)) return true; + return path !== '/admin.html' && !/(?:^|\/)perf(?:\/|$)/i.test(path) && + !/\.(?:test\.(?:js|mjs)|md|txt|c|entry\.js)$/i.test(path); +} + export function createAssetManifest(publicDir = './public', { hashing = true, buildTag: override, assetSync = true } = {}) { // Single-tag URLs cannot be verified against per-file URL hashes. assetSync = hashing && assetSync; @@ -47,29 +55,38 @@ export function createAssetManifest(publicDir = './public', { hashing = true, bu 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, swSource: null, bytes }; + if (!existsSync(publicDir)) return { manifest: { buildTag: override || 'dev-build', files: {}, groups: {}, contracts: {} }, index: null, sw: null, swSource: null, bytes, deliveryFiles: {} }; 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() } }, }; + if (definition.include !== undefined && (!Array.isArray(definition.include) || definition.include.some(path => typeof path !== 'string' || !path.startsWith('/') || path.includes('..') || !bytes.has(path)))) throw new Error('Invalid runtime asset include'); + const runtimePaths = [...bytes.keys()].filter(path => isRuntimeAsset(path, definition)).sort(); + for (const path of runtimePaths) { legacy.update(`./public${path}`); legacy.update(bytes.get(path)); } 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) { + groups[name] = bytes.has('/assets.json') ? group : { ...group, files: group.files.filter(path => isRuntimeAsset(path, definition)) }; contracts[name] = group.contract; + for (const url of groups[name].files) { + if (!isRuntimeAsset(url, definition)) throw new Error(`Asset group ${name}: excluded ${url}; declare a runtime include if required`); 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 deliveryFiles = {}; + for (const url of [...bytes.keys()].sort()) { + const file = { h: assetHash(bytes.get(url)), s: bytes.get(url).length, g: membership.get(url) || 'core' }; + deliveryFiles[url] = file; + if (isRuntimeAsset(url, definition)) files[url] = file; + } 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 @@ -87,15 +104,16 @@ export function createAssetManifest(publicDir = './public', { hashing = true, bu 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, swSource, bytes }; + Object.assign(deliveryFiles, files); + return { manifest: { buildTag, files, groups, contracts }, index, sw, swSource, bytes, deliveryFiles }; } -export function assetCacheControl(path, version, manifest, hashing = true) { +export function assetCacheControl(path, version, manifest, hashing = true, deliveryFiles = manifest.files) { const shortCache = /(^|\/)(fonts|icons)\//.test(path) ? 'public, max-age=2592000' : 'no-cache'; // The legacy index route always revalidates; legacy binary delivery ignored v. if (!hashing && path === '/index.html') return 'no-cache'; if (!hashing && !/\.(?:js|css|html|json|webmanifest|svg|txt)$/i.test(path)) return shortCache; - const current = hashing ? manifest.files[path]?.h : manifest.buildTag; + const current = hashing ? deliveryFiles[path]?.h : manifest.buildTag; if (version !== undefined) { if (version === current) return 'public, max-age=31536000, immutable'; return hashing ? 'no-store' : 'no-cache'; diff --git a/server/asset-manifest.test.js b/server/asset-manifest.test.js index ca3a58e..49467aa 100644 --- a/server/asset-manifest.test.js +++ b/server/asset-manifest.test.js @@ -1,5 +1,5 @@ import { test, expect } from 'bun:test'; -import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createHash } from 'node:crypto'; @@ -88,3 +88,32 @@ test('index embeds build-local group hashes without recursively embedding index/ const legacy=JSON.parse(off.index.match(/id="ytp-assets">([^<]+)<\/script>/)[1]); expect(legacy.files['/app.js'].h).toBe(off.manifest.buildTag); })); + + +test('non-runtime files stay deliverable but never affect manifest or build identity in either mode', () => fixture(dir => { + for (const hashing of [true, false]) { + const first = createAssetManifest(dir, { hashing }); + mkdirSync(join(dir, 'perf'), { recursive: true }); + for (const name of ['app.test.js', 'app.test.mjs', 'README.md', 'license.txt', 'admin.html', 'perf/fixture.js']) { + writeFileSync(join(dir, name), 'non-runtime'); + const next = createAssetManifest(dir, { hashing }); + expect(next.manifest.buildTag).toBe(first.manifest.buildTag); + expect(next.manifest.files['/' + name]).toBeUndefined(); + expect(next.deliveryFiles['/' + name].h).toBe(hash('non-runtime')); + expect(next.bytes.get('/' + name).toString()).toBe('non-runtime'); + writeFileSync(join(dir, name), 'edited non-runtime'); + expect(createAssetManifest(dir, { hashing }).manifest.buildTag).toBe(first.manifest.buildTag); + } + writeFileSync(join(dir, 'app.js'), 'changed runtime ' + hashing); + expect(createAssetManifest(dir, { hashing }).manifest.buildTag).not.toBe(first.manifest.buildTag); + } +})); + +test('explicit runtime includes override exclusions and excluded group members fail visibly', () => fixture(dir => { + writeFileSync(join(dir, 'runtime.txt'), 'required at runtime'); + const groups = { core: { contract: 1, eager: true, files: ['/index.html', '/app.js', '/runtime.txt'] } }; + writeFileSync(join(dir, 'assets.json'), JSON.stringify({ groups })); + expect(() => createAssetManifest(dir)).toThrow('excluded'); + writeFileSync(join(dir, 'assets.json'), JSON.stringify({ groups, include: ['/runtime.txt'] })); + expect(createAssetManifest(dir).manifest.files['/runtime.txt'].h).toBe(hash('required at runtime')); +})); diff --git a/server/server.js b/server/server.js index b7a7f3f..771d7dc 100644 --- a/server/server.js +++ b/server/server.js @@ -2498,12 +2498,12 @@ app.get('/index.html', indexHtml); // 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 (p.includes('..') || p.startsWith('/api/') || !assets.manifest.files[p]) return next(); + if (p.includes('..') || p.startsWith('/api/') || !assets.deliveryFiles[p]) return next(); const file = Bun.file('./public' + p); const raw = assets.bytes.get(p); const ext = p.slice(p.lastIndexOf('.') + 1).toLowerCase(); - const hash = assets.manifest.files[p].h; - const cacheControl = assetCacheControl(p, c.req.query('v'), assets.manifest, ASSET_HASHING); + const hash = assets.deliveryFiles[p].h; + const cacheControl = assetCacheControl(p, c.req.query('v'), assets.manifest, ASSET_HASHING, assets.deliveryFiles); 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 } }); }); diff --git a/server/static-delivery.test.js b/server/static-delivery.test.js index ed0661e..686148b 100644 --- a/server/static-delivery.test.js +++ b/server/static-delivery.test.js @@ -20,6 +20,7 @@ writeFileSync(join(publicDir, 'root.css'), 'body{color:red}'); writeFileSync(join(publicDir, 'sw.js'), "const BUILD_TAG = typeof __BUILD_TAG__ !== 'undefined' ? __BUILD_TAG__ : 'v-test';\n"); writeFileSync(join(publicDir, 'fonts', 'font.woff2'), 'font'); writeFileSync(join(publicDir, 'icons', 'icon.png'), 'icon'); +for (const name of ['probe.test.js', 'README.md', 'admin.html', 'fonts/font-OFL.txt']) writeFileSync(join(publicDir, name), 'direct-only file'); async function freePort() { return await new Promise((resolvePort, reject) => { @@ -65,19 +66,35 @@ beforeAll(async () => { server = await startServer(); }); afterAll(async () => { await stopServer(server); rmSync(root, { recursive: true, force: true }); }); describe('static delivery characterization', () => { - test('build tag is stable for the same tree and changes when any served file changes', async () => { + test('build tag is stable for the same tree and changes only when runtime files change', async () => { const first = await (await fetch(`${server.base}/api/version`)).json(); await stopServer(server); server = await startServer(); const same = await (await fetch(`${server.base}/api/version`)).json(); expect(same.buildTag).toBe(first.buildTag); - writeFileSync(join(publicDir, 'unlisted.txt'), 'included in recursive build tag'); + writeFileSync(join(publicDir, 'unlisted.js'), 'runtime build input'); await stopServer(server); server = await startServer(); const changed = await (await fetch(`${server.base}/api/version`)).json(); expect(changed.buildTag).not.toBe(first.buildTag); }); + test('excluded files retain direct bodies, hashes, compression and cache headers', async () => { + const { createHash } = await import('node:crypto'); + const expected = createHash('sha256').update('direct-only file').digest('hex').slice(0, 10); + const manifest = await (await fetch(`${server.base}/api/manifest`)).json(); + for (const path of ['/probe.test.js', '/README.md', '/admin.html', '/fonts/font-OFL.txt']) { + expect(manifest.files[path]).toBeUndefined(); + for (const query of ['', `?v=${expected}`, '?v=stale']) { + const response = await fetch(`${server.base}${path}${query}`); + expect(response.status).toBe(200); + expect(response.headers.get('x-asset-hash')).toBe(expected); + expect(await response.text()).toBe('direct-only file'); + expect(response.headers.get('cache-control')).toBe(query === '?v=stale' ? 'no-store' : query ? 'public, max-age=31536000, immutable' : path.startsWith('/fonts/') ? 'public, max-age=2592000' : 'no-cache'); + } + } + }); + test('unreadable public tree uses the fixed dev-build fallback', async () => { const moved = join(root, 'public.saved'); Bun.spawnSync(['mv', publicDir, moved]);