Merge manifest hygiene and reconcile runtime exclusions with CSP response capture
This commit is contained in:
@@ -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(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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
|
||||
2893
perf/results/hygiene-after.json
Normal file
2893
perf/results/hygiene-after.json
Normal file
File diff suppressed because it is too large
Load Diff
260
perf/results/hygiene-assets.json
Normal file
260
perf/results/hygiene-assets.json
Normal 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
|
||||
}
|
||||
5013
perf/results/hygiene-before-rollback-offline.json
Normal file
5013
perf/results/hygiene-before-rollback-offline.json
Normal file
File diff suppressed because it is too large
Load Diff
3383
perf/results/hygiene-before.json
Normal file
3383
perf/results/hygiene-before.json
Normal file
File diff suppressed because it is too large
Load Diff
41
perf/results/hygiene-migration.json
Normal file
41
perf/results/hygiene-migration.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
5836
perf/results/hygiene-offline.json
Normal file
5836
perf/results/hygiene-offline.json
Normal file
File diff suppressed because it is too large
Load Diff
4817
perf/results/hygiene-rollback-offline.json
Normal file
4817
perf/results/hygiene-rollback-offline.json
Normal file
File diff suppressed because it is too large
Load Diff
150
plans/manifest-hygiene-report.md
Normal file
150
plans/manifest-hygiene-report.md
Normal 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.
|
||||
@@ -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,35 +55,43 @@ 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()
|
||||
.replaceAll('__APP_SCRIPT_HASH__', createHash('sha256').update(bytes.get('/app.js') || '').digest('base64')) ?? 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.
|
||||
// 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 });
|
||||
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 };
|
||||
@@ -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 (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';
|
||||
|
||||
@@ -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';
|
||||
@@ -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');
|
||||
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'));
|
||||
}));
|
||||
|
||||
@@ -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 } });
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user