Compare commits

...

6 Commits

15 changed files with 1580 additions and 91 deletions

242
frontend/assets.json Normal file
View File

@@ -0,0 +1,242 @@
{
"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-stage": {
"contract": 1,
"eager": false,
"files": [
"/theme-glass.css",
"/theme-glass-controls.css",
"/glass-controls.js",
"/theme-glass-panel.css",
"/glass-panel-layout.js"
]
},
"layout:classic": {
"contract": 1,
"eager": false,
"files": [
"/theme-classic.css"
]
},
"layout:bento-hub": {
"contract": 1,
"eager": false,
"files": [
"/theme-bento-hub.css",
"/bento-hub.js",
"/fonts/PlusJakartaSans-latin.woff2",
"/fonts/PlusJakartaSans-latin-ext.woff2"
]
},
"layout:f7-swipe": {
"contract": 1,
"eager": false,
"files": [
"/theme-f7.css",
"/f7-layout.js",
"/vendor/framework7-swipe.min.js"
]
},
"feature:piano": {
"contract": 1,
"eager": false,
"files": [
"/piano-core.js",
"/piano.js",
"/piano-engine.mjs"
]
},
"feature:midi": {
"contract": 1,
"eager": false,
"files": [
"/midi.js"
]
},
"feature:party-dj": {
"contract": 1,
"eager": false,
"files": [
"/party-dj.js"
]
},
"feature:lyrics-window": {
"contract": 1,
"eager": false,
"files": [
"/lyrics-window.js"
]
},
"feature:lower-third": {
"contract": 1,
"eager": false,
"files": [
"/lower-third.js",
"/lower-third.css"
]
},
"feature:vocal-reducer": {
"contract": 1,
"eager": false,
"files": [
"/vocal-reducer.js"
]
},
"feature:eq-core": {
"contract": 1,
"eager": false,
"files": [
"/eq-core.js"
]
},
"feature:setlist-import": {
"contract": 1,
"eager": false,
"files": [
"/setlist-import.js"
]
},
"feature:car-mode": {
"contract": 1,
"eager": false,
"files": [
"/car-mode.js",
"/car-mode.css"
]
},
"feature:p2p": {
"contract": 1,
"eager": false,
"files": [
"/p2p-client.js",
"/p2p-core.js",
"/p2p-transfer.js",
"/p2p-recv-worker.js",
"/hash-worker.js",
"/device-db.js"
]
},
"feature:direct": {
"contract": 1,
"eager": false,
"files": [
"/direct-protocol.js",
"/direct-stream.js",
"/direct-media.js",
"/direct-media.css",
"/direct-recv-worker.js"
]
},
"feature:video-edit": {
"contract": 1,
"eager": false,
"files": [
"/video-edit.js"
]
},
"feature:stats": {
"contract": 1,
"eager": false,
"files": [
"/stats-core.js"
]
},
"feature:export": {
"contract": 1,
"eager": false,
"files": [
"/export.js"
]
},
"feature:playlist": {
"contract": 1,
"eager": true,
"files": [],
"embeddedIn": [
"/app.js"
]
},
"feature:settings": {
"contract": 1,
"eager": true,
"files": [],
"embeddedIn": [
"/app.js",
"/settings-sections.js",
"/settings-sections.css"
]
},
"feature:service-mode": {
"contract": 1,
"eager": true,
"files": [],
"embeddedIn": [
"/app.js",
"/service-ui.css"
]
},
"feature:lyrics-editor": {
"contract": 1,
"eager": true,
"files": [],
"embeddedIn": [
"/app.js",
"/lyrics-core.js",
"/lyrics-display.js"
]
}
}
}

View File

@@ -27,3 +27,33 @@ 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`);
}
});
test('the selected glass-stage layout includes its controls and panel dependencies', () => {
const { groups } = JSON.parse(readFileSync(join(__dirname, 'assets.json'), 'utf8'));
assert.deepEqual(groups['layout:glass-stage'].files, [
'/theme-glass.css', '/theme-glass-controls.css', '/glass-controls.js',
'/theme-glass-panel.css', '/glass-panel-layout.js',
]);
});

View File

@@ -141,3 +141,43 @@ node perf/hidden-timers.mjs
```
Exits with code 0 on success, or code 1 if any interval fails to pause, fails to resume, or if page errors occur.
## Phase 1 production-client migration
Build the production fixture locally without contacting the unavailable production
site (COMMON.md takes precedence over the older live-tag instructions above):
```bash
perf/make-shell-fixture.sh b77938a 1175f1a1d2c1
node perf/migration.mjs # Chromium and WebKit
node perf/migration.mjs --browser webkit # one browser
```
The harness archives the actual `b77938a` server and uses the reconstructed old
frontend, isolated database/media/upload paths, and an ephemeral origin behind the
`lossy` throttle/stall proxy. It restarts onto the current server and frontend at
that same origin, then invokes the OLD application's update check and clicks its
actual Refresh UI button. Assertions cover one banner, exactly one reload, cleared
update-attempt state, all new SHELL entries with correct hashes, no script errors,
and offline boot. Plain, stale `?v=<oldTag>` and `?__ytpfresh=` asset responses are
also compared byte-for-byte with the current files. Results are stored in
`perf/results/phase1-migration-2026-10-07.json`; scratch trees are removed on exit.
No service-worker source is patched or simulated. Offline checks stop Bun and
block the proxy; WebKit avoids its broken native setOffline API. Known WebKit
transport errors for disconnected version/recommendation requests are verified
separately from script errors.
The current server snapshots static bytes at boot; restart it after frontend
changes. `ASSET_HASHING=0` restores the previous single-tag stamp/cache behavior.
The manifest's tag hashes sorted canonical metadata: CSS/JS URLs are stamped
before hashing the index, with its derived build-meta placeholder intact. The
published index/SW hashes describe their final served bytes after tag injection;
these derived hashes are not fed recursively into their own build identity.
JS-created workers/importScripts/dynamic imports retain their existing plain URLs
in Phase 1. Adding a separate loader would require changing the intentionally
unchanged SHELL; worker import URL propagation belongs with the client migration.
WASM binaries are embedded in the existing `sha256-wasm.js`/`loudness-wasm.js`,
which receive hashed URLs as HTML scripts. Font preloads retain plain URLs to match
`fonts.css`; the webmanifest and its icon URLs also stay plain and precached.
`piano-engine.mjs` is manifested but is historically absent from SHELL; this
existing offline piano limitation is deferred to the loader phase.

176
perf/migration.mjs Normal file
View File

@@ -0,0 +1,176 @@
#!/usr/bin/env node
// Real production-client migration through the unchanged Refresh UI flow.
import assert from 'node:assert/strict';
import { spawn, execFileSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, symlinkSync, unlinkSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { createServer } from 'node:net';
import { fileURLToPath } from 'node:url';
import { createThrottleProxy } from './proxy.mjs';
import { chromium, webkit } from 'playwright';
const repo = resolve(fileURLToPath(new URL('..', import.meta.url)));
const commit = 'b77938a', oldTag = '1175f1a1d2c1';
const args = process.argv.slice(2);
const browserName = args.includes('--browser') ? args[args.indexOf('--browser') + 1] : 'all';
assert.ok(['all', 'chromium', 'webkit'].includes(browserName));
execFileSync('bash', [join(repo, 'perf/make-shell-fixture.sh'), commit, oldTag], { cwd: repo, stdio: 'inherit' });
const scratch = mkdtempSync(join(repo, 'perf/.tmp/migration-'));
const archive = join(scratch, 'old-server.tar');
writeFileSync(archive, execFileSync('git', ['archive', commit, 'server'], { cwd: repo, maxBuffer: 20 * 1024 * 1024 }));
execFileSync('tar', ['-xf', archive, '-C', scratch]);
symlinkSync(join(repo, 'server/node_modules'), join(scratch, 'server/node_modules'));
// Archived server modules resolve their Docker-style public imports relative
// to the module, while static routes resolve ./public relative to cwd.
symlinkSync(join(repo, `perf/fixtures/shell-${oldTag}`), join(scratch, 'server/public'));
const shell = [...readFileSync(join(repo, 'frontend/sw.js'), 'utf8').match(/const SHELL = \[([\s\S]*?)\n\];/)[1].matchAll(/['"](\/[^'"]+)['"]/g)].map(m => m[1]);
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
async function freePort() {
const server = createServer();
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const port = server.address().port;
await new Promise(resolve => server.close(resolve));
return port;
}
async function run(name) {
const cwd = join(scratch, name);
mkdirSync(cwd);
const port = await freePort();
let child, output = '', browser, proxy;
async function stop() {
if (!child) return;
const process = child; child = null;
await new Promise(resolve => {
if (process.exitCode !== null) return resolve();
process.once('exit', resolve); process.kill('SIGTERM');
});
}
async function start(old) {
symlinkSync(old ? join(repo, `perf/fixtures/shell-${oldTag}`) : join(repo, 'frontend'), join(cwd, 'public'));
output = '';
child = spawn('bun', [old ? join(scratch, 'server/server.js') : join(repo, 'server/server.js')], {
cwd, env: { ...process.env, BUILD_TAG: '', ASSET_HASHING: old ? '0' : '1', PORT: String(port),
DATA_DIR: join(cwd, 'data'), DB_PATH: join(cwd, 'db.sqlite'), MEDIA_DIR: join(cwd, 'media'), UPLOAD_DIR: join(cwd, 'uploads') },
stdio: ['ignore', 'pipe', 'pipe'],
});
for (const stream of [child.stdout, child.stderr]) stream.on('data', chunk => { output = (output + chunk).slice(-12000); });
for (let i = 0; i < 200; i++) {
if (child.exitCode !== null) throw new Error(`Bun server exited: ${output}`);
try { if ((await fetch(`http://127.0.0.1:${port}/api/version`)).ok) return; } catch {}
await sleep(100);
}
throw new Error(`Bun startup timeout: ${output}`);
}
try {
await start(true);
assert.equal((await (await fetch(`http://127.0.0.1:${port}/api/version`)).json()).buildTag, oldTag);
proxy = createThrottleProxy({ targetPort: port, profile: 'lossy' });
const proxyPort = await proxy.listen();
const origin = `http://127.0.0.1:${proxyPort}`;
browser = await ({ chromium, webkit }[name]).launch({ headless: true });
const context = await browser.newContext();
// Keep this harness entirely local, including any UI third-party requests.
await context.route('**/*', route => route.request().url().startsWith(origin) ? route.continue() : route.abort());
let banners = 0, navigations = 0;
const errors = [];
const failedRequests = [];
let disconnected = false;
const page = await context.newPage();
await page.exposeFunction('migrationBanner', () => { banners++; });
await page.addInitScript(() => {
let visible = false;
new MutationObserver(() => {
const modal = document.querySelector('#modal');
const now = !!modal && !modal.classList.contains('hidden') && /Update available/.test(document.querySelector('#modalTitle')?.textContent || '');
if (now && !visible) window.migrationBanner();
visible = now;
}).observe(document, { subtree: true, childList: true, attributes: true, characterData: true });
});
page.on('pageerror', error => errors.push({ message: error.message, disconnected }));
page.on('requestfailed', request => failedRequests.push(request.url()));
page.on('framenavigated', frame => { if (frame === page.mainFrame()) navigations++; });
await page.goto(origin, { waitUntil: 'load', timeout: 90000 });
await page.waitForFunction(() => !!navigator.serviceWorker.controller, { timeout: 90000 });
assert.equal(await page.locator('meta[name="ytp-build"]').getAttribute('content'), oldTag);
assert.equal(banners, 0, 'no first-install banner');
await stop(); unlinkSync(join(cwd, 'public')); await start(false);
const backend = `http://127.0.0.1:${port}`;
const manifest = await (await fetch(`${backend}/api/manifest`)).json();
assert.notEqual(manifest.buildTag, oldTag);
// Old URLs must continue delivering actual NEW bytes, not error/fallback HTML.
for (const path of ['/app.js', '/theme-glass.css', '/opfs-worker.js', '/sha256-wasm.js', '/manifest.webmanifest', '/icons/icon-192.png', '/fonts/HankenGrotesk-latin.woff2']) {
for (const query of ['', '?__ytpfresh=migration', `?v=${oldTag}`]) {
const response = await fetch(`${backend}${path}${query}`);
assert.equal(response.status, 200);
assert.equal(response.headers.get('x-asset-hash'), manifest.files[path].h);
assert.deepEqual(Buffer.from(await response.arrayBuffer()), readFileSync(join(repo, 'frontend', path.slice(1))));
if (query.includes('?v=')) assert.equal(response.headers.get('cache-control'), 'no-store');
}
}
proxy.resetStats(); proxy.startTracking();
const started = Date.now();
// Use the old application's public functions; no simulated update helper.
await page.evaluate(async () => {
await checkBuildTag();
await (await navigator.serviceWorker.getRegistration()).update();
});
await page.waitForFunction(async () => !!(await navigator.serviceWorker.getRegistration())?.waiting, { timeout: 90000 });
await page.waitForFunction(() => /Update available/.test(document.querySelector('#modalTitle')?.textContent || '') && !document.querySelector('#modal').classList.contains('hidden'));
assert.equal(banners, 1, 'exactly one offer before refresh');
const before = navigations;
await page.locator('#modal').getByRole('button', { name: 'Refresh UI', exact: true }).click();
await page.waitForFunction(tag => document.querySelector('meta[name="ytp-build"]')?.content === tag && typeof checkBuildTag === 'function', manifest.buildTag, { timeout: 90000 });
await page.waitForFunction(() => !sessionStorage.getItem('ytpUpdateAttempt'));
await page.evaluate(async () => { await checkBuildTag(); await checkBuildTag(); await checkBuildTag(); });
await sleep(2000);
assert.equal(navigations - before, 1, 'one reload, no update loop');
assert.equal(banners, 1, 'no banner after applying the current build');
const cacheResult = await page.evaluate(async ({ tag, shell, files }) => {
const cache = await caches.open(`ytplayer-${tag}`);
const missing = [], incorrect = [];
for (const path of shell) {
const response = await cache.match(path);
if (!response) { missing.push(path); continue; }
if (response.headers.get('x-asset-hash') !== files[path === '/' ? '/index.html' : path]?.h) incorrect.push(path);
}
return { missing, incorrect, count: (await cache.keys()).length };
}, { tag: manifest.buildTag, shell, files: manifest.files });
assert.deepEqual(cacheResult.missing, []);
assert.deepEqual(cacheResult.incorrect, [], 'every cached shell asset belongs to the new build');
proxy.stopTracking();
const stats = proxy.getStats();
const durationMs = Date.now() - started;
assert.deepEqual(errors, [], 'no script failures during installation and update');
disconnected = true;
// Match baseline.mjs: Linux WebKit's setOffline breaks even SW navigation.
// Cut the proxy AND stop Bun so offline boot cannot use network bytes.
if (name !== 'webkit') await context.setOffline(true);
proxy.setOffline(true);
await stop();
await page.reload({ waitUntil: 'load', timeout: 60000 });
assert.equal(await page.locator('meta[name="ytp-build"]').getAttribute('content'), manifest.buildTag);
await page.waitForFunction(() => typeof checkBuildTag === 'function' && !!document.querySelector('#cards')?.dataset.view);
assert.equal(banners, 1);
// WebKit surfaces rejected SW network fetches as pageerrors even when the
// app catches them. Check only the exact, intentional offline API failures;
// syntax/reference/runtime exceptions must still fail this harness.
for (const error of errors) {
assert.equal(name, 'webkit', 'only WebKit has offline transport pageerrors');
assert.equal(error.disconnected, true, 'transport errors only after disconnection');
assert.ok(error.message === 'TypeError: Load failed' || /\/api\/(?:recommendations\?[^\s]*|version)\.$/.test(error.message), `unexpected offline error: ${error.message}`);
}
if (errors.length) assert.ok(failedRequests.some(url => /\/api\/(?:recommendations|version)/.test(url)), 'transport errors correspond to disconnected API requests');
const result = { browser: name, oldTag, newTag: manifest.buildTag, banners, updateReloads: 1, cachedFiles: cacheResult.count, durationMs, wireBytes: stats.totalWireBytes, offline: true, legacyURLs: 21, profile: 'lossy', offlineTransportErrors: errors.length };
console.log(JSON.stringify(result));
return result;
} finally {
await browser?.close();
if (proxy) { proxy.server.closeAllConnections(); await proxy.close(); }
await stop();
}
}
try {
const results = [];
for (const name of browserName === 'all' ? ['chromium', 'webkit'] : [browserName]) results.push(await run(name));
writeFileSync(join(repo, 'perf/results/phase1-migration-2026-10-07.json'), JSON.stringify({ commit, results }, null, 2) + '\n');
} finally { rmSync(scratch, { recursive: true, force: true }); }

View File

@@ -0,0 +1,128 @@
{
"date": "2026-10-07T14:58:19.058Z",
"commit": "6cb00d5073931325b15ec5b773bf15dc2b53816e",
"runsConfigured": 3,
"browsers": {
"chromium": {
"lte": {
"cold": {
"requestCount": {
"median": 168,
"min": 168,
"max": 168,
"values": [
168,
168,
168
]
},
"wireBytes": {
"median": 1042903,
"min": 1042903,
"max": 1042903,
"values": [
1042903,
1042903,
1042903
]
},
"fcp": {
"median": 1576,
"min": 1536,
"max": 1604,
"values": [
1604,
1536,
1576
]
},
"lcp": {
"median": 1924,
"min": 1892,
"max": 1960,
"values": [
1960,
1892,
1924
]
},
"domContentLoaded": {
"median": 10331,
"min": 10304,
"max": 11004,
"values": [
11004,
10304,
10331
]
},
"bootDone": {
"median": 10466,
"min": 10425,
"max": 11184,
"values": [
11184,
10425,
10466
]
},
"longTaskTotal": {
"median": 450,
"min": 379,
"max": 481,
"values": [
481,
379,
450
]
},
"swInstallDuration": {
"median": 40643,
"min": 40439,
"max": 41259,
"values": [
41259,
40439,
40643
]
},
"swInstallBytes": {
"median": 1042903,
"min": 1042903,
"max": 1042903,
"values": [
1042903,
1042903,
1042903
]
},
"storageEstimate": {
"usage": 3490858,
"quota": 5372199978
},
"mediaReady": {
"median": null,
"min": null,
"max": null,
"values": [
null,
null,
null
]
},
"autoplayBlocked": true,
"wireBytesByType": {
"html": 28587,
"js": 479281,
"css": 130718,
"fonts": 397072,
"images": 6278,
"media": 0,
"api": 336,
"other": 631
}
}
}
}
}
}

View File

@@ -0,0 +1,33 @@
{
"commit": "b77938a",
"results": [
{
"browser": "chromium",
"oldTag": "1175f1a1d2c1",
"newTag": "ee1f3de99a02",
"banners": 1,
"updateReloads": 1,
"cachedFiles": 87,
"durationMs": 19381,
"wireBytes": 1191657,
"offline": true,
"legacyURLs": 21,
"profile": "lossy",
"offlineTransportErrors": 0
},
{
"browser": "webkit",
"oldTag": "1175f1a1d2c1",
"newTag": "ee1f3de99a02",
"banners": 1,
"updateReloads": 1,
"cachedFiles": 87,
"durationMs": 15257,
"wireBytes": 668490,
"offline": true,
"legacyURLs": 21,
"profile": "lossy",
"offlineTransportErrors": 4
}
]
}

View File

@@ -0,0 +1,296 @@
{
"date": "2026-10-07T15:08:29.812Z",
"commit": "d18d17cf29778f0f3afaccb9df308de0f3a1e46c",
"runsConfigured": 1,
"browsers": {
"chromium": {
"lte": {
"cold": {
"requestCount": {
"median": 168,
"min": 168,
"max": 168,
"values": [
168
]
},
"wireBytes": {
"median": 1042858,
"min": 1042858,
"max": 1042858,
"values": [
1042858
]
},
"fcp": {
"median": 1628,
"min": 1628,
"max": 1628,
"values": [
1628
]
},
"lcp": {
"median": 1976,
"min": 1976,
"max": 1976,
"values": [
1976
]
},
"domContentLoaded": {
"median": 11020,
"min": 11020,
"max": 11020,
"values": [
11020
]
},
"bootDone": {
"median": 11149,
"min": 11149,
"max": 11149,
"values": [
11149
]
},
"longTaskTotal": {
"median": 460,
"min": 460,
"max": 460,
"values": [
460
]
},
"swInstallDuration": {
"median": 41250,
"min": 41250,
"max": 41250,
"values": [
41250
]
},
"swInstallBytes": {
"median": 1042858,
"min": 1042858,
"max": 1042858,
"values": [
1042858
]
},
"storageEstimate": {
"usage": 3490858,
"quota": 5372199978
},
"mediaReady": {
"median": null,
"min": null,
"max": null,
"values": [
null
]
},
"autoplayBlocked": true,
"wireBytesByType": {
"html": 28542,
"js": 479281,
"css": 130718,
"fonts": 397072,
"images": 6278,
"media": 0,
"api": 336,
"other": 631
}
},
"warm": {
"requestCount": {
"median": 5,
"min": 5,
"max": 5,
"values": [
5
]
},
"wireBytes": {
"median": 10581,
"min": 10581,
"max": 10581,
"values": [
10581
]
},
"fcp": {
"median": 280,
"min": 280,
"max": 280,
"values": [
280
]
},
"lcp": {
"median": 992,
"min": 992,
"max": 992,
"values": [
992
]
},
"domContentLoaded": {
"median": 787,
"min": 787,
"max": 787,
"values": [
787
]
},
"bootDone": {
"median": 974,
"min": 974,
"max": 974,
"values": [
974
]
},
"longTaskTotal": {
"median": 287,
"min": 287,
"max": 287,
"values": [
287
]
}
},
"offline": {
"requestCount": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0
]
},
"wireBytes": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0
]
},
"fcp": {
"median": 100,
"min": 100,
"max": 100,
"values": [
100
]
},
"lcp": {
"median": 100,
"min": 100,
"max": 100,
"values": [
100
]
},
"domContentLoaded": {
"median": 296,
"min": 296,
"max": 296,
"values": [
296
]
},
"bootDone": {
"median": 327,
"min": 327,
"max": 327,
"values": [
327
]
},
"reloadDuration": {
"median": 349,
"min": 349,
"max": 349,
"values": [
349
]
}
},
"updateJs": {
"requestCount": {
"median": 90,
"min": 90,
"max": 90,
"values": [
90
]
},
"wireBytes": {
"median": 613029,
"min": 613029,
"max": 613029,
"values": [
613029
]
},
"duration": {
"median": 2088,
"min": 2088,
"max": 2088,
"values": [
2088
]
},
"wireBytesByType": {
"html": 18996,
"js": 267655,
"css": 65359,
"fonts": 254048,
"images": 6278,
"media": 0,
"api": 62,
"other": 631
}
},
"updateCss": {
"requestCount": {
"median": 90,
"min": 90,
"max": 90,
"values": [
90
]
},
"wireBytes": {
"median": 613046,
"min": 613046,
"max": 613046,
"values": [
613046
]
},
"duration": {
"median": 1997,
"min": 1997,
"max": 1997,
"values": [
1997
]
},
"wireBytesByType": {
"html": 18996,
"js": 267644,
"css": 65387,
"fonts": 254048,
"images": 6278,
"media": 0,
"api": 62,
"other": 631
}
}
}
}
}
}

View File

@@ -0,0 +1,296 @@
{
"date": "2026-10-07T15:10:34.781Z",
"commit": "84546eeb7da50fdfcfadb5368cbdf9054b014b9e",
"runsConfigured": 1,
"browsers": {
"chromium": {
"lte": {
"cold": {
"requestCount": {
"median": 168,
"min": 168,
"max": 168,
"values": [
168
]
},
"wireBytes": {
"median": 1042858,
"min": 1042858,
"max": 1042858,
"values": [
1042858
]
},
"fcp": {
"median": 1564,
"min": 1564,
"max": 1564,
"values": [
1564
]
},
"lcp": {
"median": 1920,
"min": 1920,
"max": 1920,
"values": [
1920
]
},
"domContentLoaded": {
"median": 11001,
"min": 11001,
"max": 11001,
"values": [
11001
]
},
"bootDone": {
"median": 11137,
"min": 11137,
"max": 11137,
"values": [
11137
]
},
"longTaskTotal": {
"median": 430,
"min": 430,
"max": 430,
"values": [
430
]
},
"swInstallDuration": {
"median": 41164,
"min": 41164,
"max": 41164,
"values": [
41164
]
},
"swInstallBytes": {
"median": 1042858,
"min": 1042858,
"max": 1042858,
"values": [
1042858
]
},
"storageEstimate": {
"usage": 3490858,
"quota": 5372199978
},
"mediaReady": {
"median": null,
"min": null,
"max": null,
"values": [
null
]
},
"autoplayBlocked": true,
"wireBytesByType": {
"html": 28542,
"js": 479281,
"css": 130718,
"fonts": 397072,
"images": 6278,
"media": 0,
"api": 336,
"other": 631
}
},
"warm": {
"requestCount": {
"median": 5,
"min": 5,
"max": 5,
"values": [
5
]
},
"wireBytes": {
"median": 10581,
"min": 10581,
"max": 10581,
"values": [
10581
]
},
"fcp": {
"median": 172,
"min": 172,
"max": 172,
"values": [
172
]
},
"lcp": {
"median": 648,
"min": 648,
"max": 648,
"values": [
648
]
},
"domContentLoaded": {
"median": 531,
"min": 531,
"max": 531,
"values": [
531
]
},
"bootDone": {
"median": 641,
"min": 641,
"max": 641,
"values": [
641
]
},
"longTaskTotal": {
"median": 148,
"min": 148,
"max": 148,
"values": [
148
]
}
},
"offline": {
"requestCount": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0
]
},
"wireBytes": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0
]
},
"fcp": {
"median": 80,
"min": 80,
"max": 80,
"values": [
80
]
},
"lcp": {
"median": 240,
"min": 240,
"max": 240,
"values": [
240
]
},
"domContentLoaded": {
"median": 211,
"min": 211,
"max": 211,
"values": [
211
]
},
"bootDone": {
"median": 232,
"min": 232,
"max": 232,
"values": [
232
]
},
"reloadDuration": {
"median": 254,
"min": 254,
"max": 254,
"values": [
254
]
}
},
"updateJs": {
"requestCount": {
"median": 90,
"min": 90,
"max": 90,
"values": [
90
]
},
"wireBytes": {
"median": 613029,
"min": 613029,
"max": 613029,
"values": [
613029
]
},
"duration": {
"median": 1891,
"min": 1891,
"max": 1891,
"values": [
1891
]
},
"wireBytesByType": {
"html": 18996,
"js": 267655,
"css": 65359,
"fonts": 254048,
"images": 6278,
"media": 0,
"api": 62,
"other": 631
}
},
"updateCss": {
"requestCount": {
"median": 88,
"min": 88,
"max": 88,
"values": [
88
]
},
"wireBytes": {
"median": 607743,
"min": 607743,
"max": 607743,
"values": [
607743
]
},
"duration": {
"median": 1890,
"min": 1890,
"max": 1890,
"values": [
1890
]
},
"wireBytesByType": {
"html": 18996,
"js": 262403,
"css": 65387,
"fonts": 254048,
"images": 6278,
"media": 0,
"api": 0,
"other": 631
}
}
}
}
}
}

40
plans/phase1-plan.md Normal file
View File

@@ -0,0 +1,40 @@
# Phase 1 — per-file asset hashes and server manifest
1. Add tests first in `server/asset-manifest.test.js`, deliberately extend
`server/static-delivery.test.js` and `frontend/shell-consistency.test.js`.
2. Describe the existing shell in `frontend/assets.json`: ordered file groups,
contract 1, eager core/settings/playlist/service-mode/lyrics-editor. Preserve
index script order and both service-worker sources byte-for-byte.
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 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,
including files outside the shell, so the banner never misses a change.
5. Extract `b77938a` using `perf/make-shell-fixture.sh` with an explicitly
computed expected tag (never query production). Add `perf/migration.mjs`
and instructions to `perf/README.md`; test old-client Refresh UI, a single
banner, complete new cache, no loop, offline reload, and legacy URL bodies
on Chromium and WebKit. Run reduced LTE cold/warm/offline baselines.
6. Review against master plan §3.1/§6b; commit `plans/phase1-report.md` (≤350
words) with commit IDs, measurements, exceptions and iPhone checks; write
DONE01 to the queue signal only after verification succeeds.
Hash self-reference: stamp resource URLs first; hash a canonical index with its
build-meta placeholder intact to derive the manifest build tag. Stamp that tag
and expose the actual final index byte hash. Canonical tag computation normalizes
only this derived index field, avoiding a cryptographic fixed-point requirement.
SW response hashing must likewise describe injected bytes rather than raw source.
Rollback: `ASSET_HASHING=0` restores the existing recursive build tag and legacy
single-tag CSS/JS stamping/header behavior. Default ON; test both modes.
Risks: old-worker ignoreSearch, stalled installation, mutable stale URLs,
recursive paths, derived HTML/SW hashes, shared iOS cache/audio quota.
Acceptance: both unit suites green after each implementation commit; no weakened
characterization tests; migration passes in both browsers; reduced baseline cold,
warm and offline boot succeeds with no first-paint regression. Phase 1 still
fetches the whole shell on update; <30 KB CSS-only updates belong to Phase 2.

44
plans/phase1-report.md Normal file
View File

@@ -0,0 +1,44 @@
# Phase 1 report
Shipped: `6cb00d5` adds asset groups/contracts, boot snapshots, per-file hashes,
HTML stamping, `/api/manifest`, ETag/304, hash headers and `ASSET_HASHING=0`.
`d18d17c` preserves the exact legacy index/binary cache rules and pins SW bytes.
Plan: `ad85cb6`; `84546ee` groups Glass Stage dependencies together.
Migration harness/results: `7c45d09`.
Verification: 156 frontend and 178 server tests pass. Server tests used an
isolated scratch Python environment with yt-dlp installed; none were skipped.
Both service-worker sources, index source, playback and timers remain unchanged.
Production fixture: `b77938a` → `1175f1a1d2c1`, reproduced without production calls.
Chromium/WebKit migration passes through the lossy/stalling proxy: one banner,
one update reload, all 87 shell entries verified, 21 legacy URL/body checks,
and offline boot with Bun stopped. Migration took 19.4/15.3 s respectively.
WebKit's known disconnected API transport errors are checked separately; script
errors during installation/update fail the harness.
Reduced Chromium LTE: three-run cold median before final regrouping 10,466 ms
vs 10,480 ms. Final standalone single-run cold: 11,137 ms (+6.3%); FCP
1,564 ms vs 1,572 ms; wire bytes 1,042,858 (+0.3%). Warm/offline boots:
641/232 ms vs 692/259 ms. Overlapped-run timings are retained separately.
JS-requested assets receiving hashed URLs: **none**. HTML CSS/JS references are
hashed, including sha256-wasm.js/loudness-wasm.js (embedded WASM; no standalone
.wasm fetch). Plain worker URLs: opfs-worker.js, hash-worker.js,
direct-recv-worker.js, p2p-recv-worker.js. Plain worker imports: sha256.js,
resume-core.js, sha256-wasm.js. Plain dynamic loads: piano-engine.mjs,
vendor/framework7-swipe.min.js. Propagating URL maps safely needs caller/worker
changes and a cached bootstrap; retain the unchanged client protocol this phase.
All are manifested; piano-engine.mjs is historically absent from SHELL, an
existing offline limitation deferred to the loader phase.
Also plain: manifest.webmanifest; icons/icon-{192,512}.png;
fonts/{BricolageGrotesque,HankenGrotesk,JetBrainsMono,PlusJakartaSans}-{latin,latin-ext}.woff2.
Font preloads must match fonts.css URLs; manifest icon paths remain unchanged.
Build identity hashes sorted canonical metadata with the index build-meta
placeholder intact; published index/SW hashes describe final served bytes,
avoiding self-reference. Restart after frontend edits; hashing mode ignores
BUILD_TAG overrides. Incremental downloads/lazy parsing await later phases.
Owner iPhone checks: install/update, offline launch, active layout, then 30-minute
locked playback, dropouts, battery use and resume. No push, merge or deployment.

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

@@ -0,0 +1,91 @@
// 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, swSource: 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, swSource, bytes };
}
export function assetCacheControl(path, version, manifest, hashing = true) {
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;
if (version !== undefined) {
if (version === current) return 'public, max-age=31536000, immutable';
return hashing ? 'no-store' : 'no-cache';
}
return /(^|\/)(fonts|icons)\//.test(path) && (hashing || !/\.(?:js|css|html|json|webmanifest|svg|txt)$/i.test(path)) ? 'public, max-age=2592000' : 'no-cache';
}

View File

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

View File

@@ -6,7 +6,7 @@
"scripts": {
"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",

View File

@@ -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
@@ -387,8 +350,16 @@ registerCatalogRoutes(app, { resolveListener: recommendationListener });
// ============================================================================
// GET /api/version
// Returns version string + a build tag that changes on every server restart/deploy.
// Returns version and a deterministic tag that changes only with shipped files.
// 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 },
@@ -2461,15 +2432,9 @@ app.post('/api/admin/p2p/revoke', notes.requireAdminOrToken, async (c) => {
// Served with no-store cache headers so browsers always re-fetch it and
// pick up the substituted value rather than a browser-cached stale copy.
// ============================================================================
let _swSource = null;
const _swSource = assets.swSource;
app.get('/sw.js', (c) => {
if (!_swSource) {
try {
_swSource = readFileSync('./public/sw.js', 'utf8');
} catch {
return c.text('Service worker not found', 404);
}
}
if (_swSource === null) return c.text('Service worker not found', 404);
// Inject the build tag: replace the whole fallback expression with the
// real value. Matched by REGEX, not an exact string — an exact match broke
// the moment the fallback literal in sw.js was bumped ('v1.0.3' → 'v1.0.4'),
@@ -2482,7 +2447,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 +2476,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 +2489,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=<build>`: that URL's bytes never change, so the static
// handler below lets browsers keep it forever; a new build is a new URL.
const html = _indexSource.replace('__BUILD_TAG__', BUILD_TAG)
.replace(/((?:href|src)=")((?![a-z]+:|\/)[^"?]+\.(?:css|js))(")/g, `$1$2?v=${BUILD_TAG}$3`);
return sendCompressed(c, compressedEntry('index.html', Buffer.from(html), MIME.html), 'no-cache');
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);

View File

@@ -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();
@@ -94,23 +94,23 @@ describe('static delivery characterization', () => {
expect(fallback.buildTag).toBe('dev-build');
});
test('index stamps only relative local css/js and serves expected cache headers', async () => {
test('index stamps relative and root-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(`<meta name="ytp-build" content="${buildTag}">`);
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,56 @@ 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');
expect((await fetch(`${server.base}/index.html?v=${buildTag}`)).headers.get('cache-control')).toBe('no-cache');
expect((await fetch(`${server.base}/fonts/font.woff2?v=${buildTag}`)).headers.get('cache-control')).toBe('public, max-age=2592000');
expect((await fetch(`${server.base}/fonts/font.woff2?v=stale`)).headers.get('cache-control')).toBe('public, max-age=2592000');
} 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();
@@ -147,6 +192,20 @@ describe('static delivery characterization', () => {
server = await startServer();
});
test('served bytes remain pinned to their boot-time hashes', async () => {
const before = await (await fetch(`${server.base}/app.js`)).text();
const swBefore = await (await fetch(`${server.base}/sw.js`)).text();
writeFileSync(join(publicDir, 'app.js'), 'changed after boot');
writeFileSync(join(publicDir, 'sw.js'), 'changed after boot');
try {
expect(await (await fetch(`${server.base}/app.js`)).text()).toBe(before);
expect(await (await fetch(`${server.base}/sw.js`)).text()).toBe(swBefore);
} finally {
writeFileSync(join(publicDir, 'app.js'), before);
writeFileSync(join(publicDir, 'sw.js'), "const BUILD_TAG = 'missing-placeholder';\n");
}
});
test('SPA routes fall back to the stamped index', async () => {
const { buildTag } = await (await fetch(`${server.base}/api/version`)).json();
for (const path of ['/search', '/playlist/x']) {