Merge manifest hygiene and reconcile runtime exclusions with CSP response capture

This commit is contained in:
Jonathan Sykes
2026-10-09 15:12:39 +08:00
14 changed files with 22494 additions and 15 deletions

View File

@@ -112,3 +112,11 @@ test('the last core script owns boot without an extra request or unsafe stale pa
assert.ok(html.includes("'sha256-__APP_SCRIPT_HASH__'")); assert.ok(html.includes("'sha256-__APP_SCRIPT_HASH__'"));
assert.ok(readFileSync(join(__dirname,'section-rail.js'),'utf8').includes('root.AppBootstrap={ready}')); assert.ok(readFileSync(join(__dirname,'section-rail.js'),'utf8').includes('root.AppBootstrap={ready}'));
}); });
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);
});

View File

@@ -369,3 +369,19 @@ app execution is complete; compare the existing actual boot-done landmark.
`--scenario warm,offline,update-js,update-css,update-feature` runs a regression `--scenario warm,offline,update-js,update-css,update-feature` runs a regression
sweep without repeating an already-completed cold acceptance run. sweep without repeating an already-completed cold acceptance run.
## Runtime manifest hygiene
`server/asset-manifest.js` excludes unit tests (`*.test.js`, `*.test.mjs`),
Markdown, text licences/data (`*.txt`), C build sources, `*.entry.js` build
entries, `/admin.html`, and files below any `perf/` directory from the runtime
manifest and both build-tag modes. They remain directly servable with the usual
hash and cache headers. If a matching file is genuinely needed at runtime, add
its exact root-relative path to the optional `include` array in
`frontend/assets.json`; group declarations referencing excluded files fail at boot
unless explicitly included.
The offline completeness harness compares cache contents with the runtime
manifest dynamically; it must not hard-code the old 151-file inventory. See
`plans/manifest-hygiene-report.md` and `perf/results/hygiene-*.json` for the
before/after measurements and compatibility checks.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,260 @@
{
"before": {
"count": 151,
"rawBytes": 2044337,
"manifestBytes": 15601
},
"after": {
"count": 102,
"rawBytes": 1807059,
"manifestBytes": 12551
},
"excluded": [
{
"path": "/admin.html",
"rawBytes": 102005,
"brBytes": 24050
},
{
"path": "/app-seams.test.js",
"rawBytes": 9786,
"brBytes": 2457
},
{
"path": "/asset-sync-core.test.js",
"rawBytes": 9430,
"brBytes": 2525
},
{
"path": "/asset-worker.test.js",
"rawBytes": 8219,
"brBytes": 1984
},
{
"path": "/async-guard.test.js",
"rawBytes": 2543,
"brBytes": 765
},
{
"path": "/audio-continuity.test.js",
"rawBytes": 2899,
"brBytes": 813
},
{
"path": "/bento-hub.test.js",
"rawBytes": 868,
"brBytes": 283
},
{
"path": "/car-mode.test.js",
"rawBytes": 2556,
"brBytes": 973
},
{
"path": "/direct-protocol.test.js",
"rawBytes": 1279,
"brBytes": 521
},
{
"path": "/direct-stream.test.js",
"rawBytes": 891,
"brBytes": 390
},
{
"path": "/download-errors.test.js",
"rawBytes": 1783,
"brBytes": 566
},
{
"path": "/downloads-page.test.js",
"rawBytes": 2453,
"brBytes": 699
},
{
"path": "/downloads.test.js",
"rawBytes": 1608,
"brBytes": 639
},
{
"path": "/eq-core.test.js",
"rawBytes": 1055,
"brBytes": 428
},
{
"path": "/export.test.js",
"rawBytes": 521,
"brBytes": 259
},
{
"path": "/f7-layout.test.js",
"rawBytes": 1400,
"brBytes": 380
},
{
"path": "/fonts/PlusJakartaSans-OFL.txt",
"rawBytes": 4402,
"brBytes": 1428
},
{
"path": "/fullscreen-orientation.test.js",
"rawBytes": 1278,
"brBytes": 404
},
{
"path": "/glass-controls.test.js",
"rawBytes": 1177,
"brBytes": 412
},
{
"path": "/glass-panel-layout.test.js",
"rawBytes": 782,
"brBytes": 259
},
{
"path": "/lazy.test.js",
"rawBytes": 7117,
"brBytes": 2030
},
{
"path": "/loudness.test.js",
"rawBytes": 1626,
"brBytes": 706
},
{
"path": "/lower-third.test.js",
"rawBytes": 845,
"brBytes": 338
},
{
"path": "/lyrics-core.test.js",
"rawBytes": 4996,
"brBytes": 1430
},
{
"path": "/lyrics-display.test.js",
"rawBytes": 494,
"brBytes": 244
},
{
"path": "/lyrics-window.test.js",
"rawBytes": 617,
"brBytes": 264
},
{
"path": "/midi.test.js",
"rawBytes": 987,
"brBytes": 348
},
{
"path": "/p2p-core.test.js",
"rawBytes": 2116,
"brBytes": 675
},
{
"path": "/party-dj.test.js",
"rawBytes": 550,
"brBytes": 234
},
{
"path": "/piano-core.test.js",
"rawBytes": 2102,
"brBytes": 731
},
{
"path": "/prewarm-next.test.js",
"rawBytes": 1268,
"brBytes": 434
},
{
"path": "/related-videos.test.js",
"rawBytes": 1514,
"brBytes": 556
},
{
"path": "/resume-core.test.js",
"rawBytes": 1531,
"brBytes": 484
},
{
"path": "/saved-page.test.js",
"rawBytes": 553,
"brBytes": 224
},
{
"path": "/server-backfill.test.js",
"rawBytes": 2118,
"brBytes": 683
},
{
"path": "/setlist-import.test.js",
"rawBytes": 1134,
"brBytes": 508
},
{
"path": "/settings-sections.test.js",
"rawBytes": 1209,
"brBytes": 405
},
{
"path": "/sha256.test.js",
"rawBytes": 3251,
"brBytes": 1153
},
{
"path": "/shell-consistency.test.js",
"rawBytes": 5265,
"brBytes": 1507
},
{
"path": "/stats-core.test.js",
"rawBytes": 3165,
"brBytes": 929
},
{
"path": "/sw-update.test.js",
"rawBytes": 17825,
"brBytes": 4099
},
{
"path": "/sw.test.js",
"rawBytes": 6219,
"brBytes": 1849
},
{
"path": "/vendor/framework7-swipe.entry.js",
"rawBytes": 1040,
"brBytes": 290
},
{
"path": "/video-edit.test.js",
"rawBytes": 3396,
"brBytes": 855
},
{
"path": "/visible-timer.test.js",
"rawBytes": 2827,
"brBytes": 687
},
{
"path": "/vocal-reducer.test.js",
"rawBytes": 648,
"brBytes": 235
},
{
"path": "/wasm/loudness.c",
"rawBytes": 2132,
"brBytes": 861
},
{
"path": "/wasm/sha256.c",
"rawBytes": 2428,
"brBytes": 1064
},
{
"path": "/worker-imports.test.js",
"rawBytes": 1370,
"brBytes": 636
}
],
"excludedBrBytes": 64694
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,41 @@
{
"commit": "b77938a",
"results": [
{
"browser": "chromium",
"oldTag": "1175f1a1d2c1",
"newTag": "604f8f8d8a68",
"banners": 1,
"updateReloads": 1,
"cachedFiles": 124,
"durationMs": 26801,
"wireBytes": 638444,
"offline": true,
"legacyURLs": 21,
"profile": "lossy",
"userInitiatedOnly": true,
"playbackGuard": true,
"evictionRepair": true,
"incrementalRefresh": true,
"offlineTransportErrors": 0
},
{
"browser": "webkit",
"oldTag": "1175f1a1d2c1",
"newTag": "604f8f8d8a68",
"banners": 1,
"updateReloads": 1,
"cachedFiles": 124,
"durationMs": 24322,
"wireBytes": 743954,
"offline": true,
"legacyURLs": 21,
"profile": "lossy",
"userInitiatedOnly": true,
"playbackGuard": true,
"evictionRepair": true,
"incrementalRefresh": true,
"offlineTransportErrors": 4
}
]
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,150 @@
# Runtime manifest hygiene
Based on main/offline-fix tip `0b8e398`; offline-fix was already merged when this
worktree was created. Implementation: `dd09f66`. No frontend runtime, loading,
service-worker, playback or layout code changed.
## Policy and delivery
`server/asset-manifest.js` now selects runtime inputs separately from delivery
metadata. It excludes `*.test.js`, `*.test.mjs`, `*.md`, `*.txt`, `*.c`,
`*.entry.js`, `/admin.html`, and all files beneath a `perf/` directory. The
optional `assets.json` `include` array accepts exact root-relative existing file
paths to override this policy. An explicitly declared group cannot silently lose
an excluded dependency: boot rejects it unless the path is included.
The 49 excluded files here are 44 unit tests, admin HTML, the Plus Jakarta Sans
font licence, two WASM C build sources, and the Framework7 source build entry.
WASM binaries, fonts, icons, workers and the Framework7 runtime bundle remain.
Excluded files remain directly servable, with the existing content hash, ETag,
compression and cache rules: plain requests revalidate (font-folder requests
keep their 30-day cache rule), matching `?v` is immutable, and stale `?v` is
no-store in per-file mode. Delivery hashes are computed separately; they never
enter `manifest.files` or build identity. `ASSET_HASHING=0` retains single-tag
stamping and its legacy header matrix, but its tag also uses only runtime
inputs. `ASSET_SYNC=0` still selects the legacy worker.
Fresh installs cache only runtime assets. On an incremental upgrade, excluded
entries from the previous manifest may survive for one build under existing
N-1 retention, then are pruned as N-2. No new excluded entries are downloaded.
This preserves retention for already-open pages instead of changing worker
pruning or contracts/pinning.
## Inventory
Measured from the untouched before snapshot and the fixed frontend using the
respective real server manifest modules. Counts include stamped index and SW.
| Metric | Before | After | Reduction |
| --- | ---: | ---: | ---: |
| Manifest files | 151 | 102 | 49 (32.5%) |
| Summed manifest file sizes | 2,044,337 B | 1,807,059 B | 237,278 B |
| JSON manifest body | 15,601 B | 12,551 B | 3,050 B |
Excluded source bodies total 237,278 B, or 64,694 B using per-file Brotli
compression. Actual install/update measurements below include protocol and
manifest/index effects; compression estimates are not substituted for those runs.
Full inventory: `perf/results/hygiene-assets.json`.
## Verification
Commands:
```sh
node --test frontend/*.test.js
cd server && bun install && bun run test
node perf/offline-complete.mjs --browser all --conditions --out perf/results/hygiene-offline.json
node perf/migration.mjs --browser all --profile lossy --out perf/results/hygiene-migration.json
node perf/offline-complete.mjs --browser all --legacy --out perf/results/hygiene-rollback-offline.json
```
The server test command used a worktree-local Python venv on PATH with yt-dlp
installed so both environment-sensitive worker tests ran successfully.
Frontend: 209 passed, zero failures. Server: 186 passed, zero failures.
Manifest/static/shell tests cover excluded-file identity invariance in both hash
modes, app.js identity changes, explicit inclusion, group validation, direct
bodies/hash/header behavior, SPA delivery and rollback flags.
Offline completeness: Chromium and WebKit each report manifest count 102,
missing `[]`, failures `[]`, across all four layouts and lazy features. Page
closure/interrupted completion, worker restart/re-registration, Save-Data, two
tabs and online-event resumption pass. Linux WebKit uses same-context worker
re-registration because persistent CacheStorage restart is broken there; its
offline proxy blocks transport because native setOffline breaks cached navigation.
Native OPFS-dependent video-edit behavior cannot be exercised in Linux WebKit.
Lossy legacy migration passes on both engines: one banner, one explicit-user
reload, offline launch, playback guard, legacy URLs, eviction repair and
incremental refresh. WebKit records four tolerated offline API transport errors,
with the offline assertions passing. Migration caches include retained N-1
entries, so their raw key count is deliberately greater than manifest count.
## Rollback limitation found during extra verification
An additional `--legacy` offline-completeness run disables both hashing and sync.
It fails on both the untouched base and fixed build: the legacy SHELL omits
`piano-engine.mjs`, so its direct offline request returns 503 across layouts.
The legacy shell also intentionally does not cache its own `sw.js`; the harness
therefore marks that inventory entry missing too, without a corresponding offline
UI failure. Before: 151 manifest entries / 51 missing; after: 102 / 2 missing.
The other 49 before-only misses are precisely the excluded non-runtime files.
Normal sync-mode completeness is 102/102 with no failures. Header/injection
rollback tests pass; this change neither fixes nor worsens the existing legacy
piano coverage gap. Results: `hygiene-before-rollback-offline.json` and
`hygiene-rollback-offline.json`.
## Reduced performance measurements
Three runs per scenario/engine, LTE. Before used an isolated untouched snapshot
of `0b8e398` inside this worktree; after used the fixed server. No external
worktree was modified. Cold wire totals include page requests and install/completion
fetches; these are not claimed to be worker-only bytes. Chromium uses CDP LTE
throttling; WebKit uses the LTE proxy.
```sh
node perf/.tmp/before/perf/baseline.mjs --runs 3 --browser all --profile lte --scenario all --out perf/results/hygiene-before.json
node perf/baseline.mjs --runs 3 --browser all --profile lte --scenario all --out perf/results/hygiene-after.json
```
All values below are actual medians; bytes are wire bytes.
| Engine | Scenario | Before B | After B | Before ms | After ms |
| --- | --- | ---: | ---: | ---: | ---: |
| chromium | Cold visit + install | 728,266 | 660,390 | 7101 | 7090 |
| chromium | Warm reload | 8,132 | 8,159 | 647 | 445 |
| chromium | Offline reload | 0 | 0 | 285 | 209 |
| chromium | Core app.js edit | 126,358 | 125,729 | 1008 | 753 |
| chromium | theme-glass.css edit | 44,027 | 43,434 | 1600 | 1372 |
| chromium | Presenter edit + idle fill | 42,065 | 41,436 | 1392 | 1376 |
| webkit | Cold visit + install | 1,189,813 | 1,111,241 | 2321 | 2310 |
| webkit | Warm reload | 0 | 0 | 92 | 89 |
| webkit | Offline reload | 0 | 0 | 100 | 88 |
| webkit | Core app.js edit | 148,709 | 147,955 | 2013 | 2013 |
| webkit | theme-glass.css edit | 57,732 | 56,976 | 1479 | 1466 |
| webkit | Presenter edit + idle fill | 55,382 | 54,625 | 1494 | 1557 |
Cold requests fall 165 → 116 (Chromium) and 209 → 160 (WebKit), exactly 49
fewer each. Cold wire savings are 67,876 B (9.3%) and 78,572 B (6.6%).
Ordinary runtime updates already shared unchanged files before this fix, so
their savings are mainly smaller manifest metadata: approximately 0.6–0.8 KB.
Test-only edits now leave the tag unchanged and trigger no build update.
Milliseconds for cold/warm/offline are boot-done; update milliseconds are completion
duration. These three-run timing differences are descriptive, not a performance
claim. Warm Chromium API traffic varies independently of the asset set.
Real output excerpts (after):
```text
run 3/3... done (660390 bytes, 116 reqs, boot=7090ms)
run 3/3... done (1111241 bytes, 160 reqs, boot=2310ms)
run 3/3... done (147955 bytes, 5 reqs, dur=2014ms)
run 3/3... done (56976 bytes, 9 reqs, dur=1483ms)
```
## Real iPhone checks
Confirm installed-PWA offline launch and feature/layout first use after update;
completion after suspension, storage pressure/OPFS sharing, and old excluded
cache entries disappearing after the subsequent build. Linux automation cannot
prove iOS worker lifetime, quota eviction or native OPFS behavior.

View File

@@ -35,6 +35,14 @@ export function stampIndex(source, files, { hashing = true, buildTag = '__BUILD_
})).replace('__BUILD_TAG__', buildTag); })).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 } = {}) { export function createAssetManifest(publicDir = './public', { hashing = true, buildTag: override, assetSync = true } = {}) {
// Single-tag URLs cannot be verified against per-file URL hashes. // Single-tag URLs cannot be verified against per-file URL hashes.
assetSync = hashing && assetSync; assetSync = hashing && assetSync;
@@ -47,35 +55,43 @@ export function createAssetManifest(publicDir = './public', { hashing = true, bu
else { else {
const content = readFileSync(path); const content = readFileSync(path);
bytes.set(url, content); bytes.set(url, content);
legacy.update(`./public${url}`); legacy.update(content);
} }
} }
} }
// Missing public is the historical fixed fallback used by local tests. // 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); walk(publicDir);
const definition = bytes.has('/assets.json') ? JSON.parse(bytes.get('/assets.json').toString()) : { const definition = bytes.has('/assets.json') ? JSON.parse(bytes.get('/assets.json').toString()) : {
groups: { core: { contract: 1, eager: true, files: [...bytes.keys()].sort() } }, 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(); const groups = {}, contracts = {}, membership = new Map();
for (const name of Object.keys(definition.groups).sort()) { for (const name of Object.keys(definition.groups).sort()) {
const group = definition.groups[name]; 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}`); 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; groups[name] = bytes.has('/assets.json') ? group : { ...group, files: group.files.filter(path => isRuntimeAsset(path, definition)) }; contracts[name] = group.contract;
for (const url of group.files) { 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 (!bytes.has(url)) throw new Error(`Asset group ${name}: missing ${url}`);
if (membership.has(url)) throw new Error(`Duplicate asset membership: ${url}`); if (membership.has(url)) throw new Error(`Duplicate asset membership: ${url}`);
membership.set(url, name); membership.set(url, name);
} }
} }
const files = {}; 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() const source = bytes.get('/index.html')?.toString()
.replaceAll('__APP_SCRIPT_HASH__', createHash('sha256').update(bytes.get('/app.js') || '').digest('base64')) ?? null; .replaceAll('__APP_SCRIPT_HASH__', createHash('sha256').update(bytes.get('/app.js') || '').digest('base64')) ?? null;
const swSource = bytes.get('/sw.js')?.toString() ?? null; const swSource = bytes.get('/sw.js')?.toString() ?? null;
// Derived build metadata cannot be an input to its own hash. Canonicalize // 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. // 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. // All runtime source bytes (including index/SW source) remain inputs via source hashes.
let canonicalIndex = source === null ? null : stampIndex(embedAssets(source, files, groups, '__BUILD_TAG__', hashing, assetSync), files, { hashing }); let canonicalIndex = source === null ? null : stampIndex(embedAssets(source, files, groups, '__BUILD_TAG__', hashing, assetSync), files, { hashing });
const canonical = { assetSync, files: { ...files }, groups, contracts }; const canonical = { assetSync, 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 }; if (canonicalIndex !== null && hashing) canonical.files['/index.html'] = { ...files['/index.html'], h: assetHash(canonicalIndex), s: Buffer.byteLength(canonicalIndex), source: files['/index.html'].h };
@@ -88,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 (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 (index !== null) bytes.set('/index.html', Buffer.from(index));
if (sw !== null) bytes.set('/sw.js', Buffer.from(sw)); 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'; const shortCache = /(^|\/)(fonts|icons)\//.test(path) ? 'public, max-age=2592000' : 'no-cache';
// The legacy index route always revalidates; legacy binary delivery ignored v. // The legacy index route always revalidates; legacy binary delivery ignored v.
if (!hashing && path === '/index.html') return 'no-cache'; if (!hashing && path === '/index.html') return 'no-cache';
if (!hashing && !/\.(?:js|css|html|json|webmanifest|svg|txt)$/i.test(path)) return shortCache; 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 !== undefined) {
if (version === current) return 'public, max-age=31536000, immutable'; if (version === current) return 'public, max-age=31536000, immutable';
return hashing ? 'no-store' : 'no-cache'; return hashing ? 'no-store' : 'no-cache';

View File

@@ -1,5 +1,5 @@
import { test, expect } from 'bun:test'; 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 { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
@@ -100,3 +100,31 @@ test('app bootstrap CSP hash follows exact app bytes and rollback keeps external
writeFileSync(join(dir,'app.js'),'window.app=2'); writeFileSync(join(dir,'app.js'),'window.app=2');
expect(createAssetManifest(dir).index).not.toContain(digest); expect(createAssetManifest(dir).index).not.toContain(digest);
})); }));
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', 'sha256.c', 'framework.entry.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'));
}));

View File

@@ -2498,12 +2498,12 @@ app.get('/index.html', indexHtml);
// current body with no-store, never immutable caching under a mismatched URL. // 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 (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 file = Bun.file('./public' + p);
const raw = assets.bytes.get(p); const raw = assets.bytes.get(p);
const ext = p.slice(p.lastIndexOf('.') + 1).toLowerCase(); const ext = p.slice(p.lastIndexOf('.') + 1).toLowerCase();
const hash = assets.manifest.files[p].h; const hash = assets.deliveryFiles[p].h;
const cacheControl = assetCacheControl(p, c.req.query('v'), assets.manifest, ASSET_HASHING); 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); 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 } }); return new Response(raw, { headers: { 'Content-Type': file.type, 'Cache-Control': cacheControl, 'X-Asset-Hash': hash } });
}); });

View File

@@ -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, 'sw.js'), "const BUILD_TAG = typeof __BUILD_TAG__ !== 'undefined' ? __BUILD_TAG__ : 'v-test';\n");
writeFileSync(join(publicDir, 'fonts', 'font.woff2'), 'font'); writeFileSync(join(publicDir, 'fonts', 'font.woff2'), 'font');
writeFileSync(join(publicDir, 'icons', 'icon.png'), 'icon'); 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() { async function freePort() {
return await new Promise((resolvePort, reject) => { 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 }); }); afterAll(async () => { await stopServer(server); rmSync(root, { recursive: true, force: true }); });
describe('static delivery characterization', () => { 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(); const first = await (await fetch(`${server.base}/api/version`)).json();
await stopServer(server); await stopServer(server);
server = await startServer(); server = await startServer();
const same = await (await fetch(`${server.base}/api/version`)).json(); const same = await (await fetch(`${server.base}/api/version`)).json();
expect(same.buildTag).toBe(first.buildTag); 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); await stopServer(server);
server = await startServer(); server = await startServer();
const changed = await (await fetch(`${server.base}/api/version`)).json(); const changed = await (await fetch(`${server.base}/api/version`)).json();
expect(changed.buildTag).not.toBe(first.buildTag); 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 () => { test('unreadable public tree uses the fixed dev-build fallback', async () => {
const moved = join(root, 'public.saved'); const moved = join(root, 'public.saved');
Bun.spawnSync(['mv', publicDir, moved]); Bun.spawnSync(['mv', publicDir, moved]);