Add per-file asset hashes and a rollback-compatible server manifest
This commit is contained in:
254
frontend/assets.json
Normal file
254
frontend/assets.json
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
{
|
||||||
|
"groups": {
|
||||||
|
"core": {
|
||||||
|
"contract": 1,
|
||||||
|
"eager": true,
|
||||||
|
"files": [
|
||||||
|
"/index.html",
|
||||||
|
"/styles.css",
|
||||||
|
"/service-ui.css",
|
||||||
|
"/refresh.css",
|
||||||
|
"/recommendations.css",
|
||||||
|
"/flag-ui.js",
|
||||||
|
"/async-guard.js",
|
||||||
|
"/visible-timer.js",
|
||||||
|
"/sw-update.js",
|
||||||
|
"/fingerprint.js",
|
||||||
|
"/opfs.js",
|
||||||
|
"/opfs-worker.js",
|
||||||
|
"/lyrics-core.js",
|
||||||
|
"/lyrics-display.js",
|
||||||
|
"/sha256.js",
|
||||||
|
"/resume-core.js",
|
||||||
|
"/loudness-wasm.js",
|
||||||
|
"/loudness.js",
|
||||||
|
"/sha256-wasm.js",
|
||||||
|
"/settings-sections.js",
|
||||||
|
"/related-videos.js",
|
||||||
|
"/related-videos.css",
|
||||||
|
"/audio-continuity.js",
|
||||||
|
"/fullscreen-orientation.js",
|
||||||
|
"/fullscreen-orientation.css",
|
||||||
|
"/download-actions.js",
|
||||||
|
"/download-actions.css",
|
||||||
|
"/download-errors.js",
|
||||||
|
"/downloads.js",
|
||||||
|
"/downloads-page.js",
|
||||||
|
"/offline-pages.css",
|
||||||
|
"/saved-page.js",
|
||||||
|
"/server-backfill.js",
|
||||||
|
"/saved-page.css",
|
||||||
|
"/prewarm-next.js",
|
||||||
|
"/settings-sections.css",
|
||||||
|
"/app.js",
|
||||||
|
"/manifest.webmanifest",
|
||||||
|
"/icons/icon-192.png",
|
||||||
|
"/icons/icon-512.png",
|
||||||
|
"/fonts/fonts.css",
|
||||||
|
"/fonts/BricolageGrotesque-latin-ext.woff2",
|
||||||
|
"/fonts/BricolageGrotesque-latin.woff2",
|
||||||
|
"/fonts/HankenGrotesk-latin-ext.woff2",
|
||||||
|
"/fonts/HankenGrotesk-latin.woff2",
|
||||||
|
"/fonts/JetBrainsMono-latin-ext.woff2",
|
||||||
|
"/fonts/JetBrainsMono-latin.woff2"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"layout:glass": {
|
||||||
|
"contract": 1,
|
||||||
|
"eager": false,
|
||||||
|
"files": [
|
||||||
|
"/theme-glass.css"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"layout:classic": {
|
||||||
|
"contract": 1,
|
||||||
|
"eager": false,
|
||||||
|
"files": [
|
||||||
|
"/theme-classic.css"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"layout:glass-controls": {
|
||||||
|
"contract": 1,
|
||||||
|
"eager": false,
|
||||||
|
"files": [
|
||||||
|
"/theme-glass-controls.css",
|
||||||
|
"/glass-controls.js"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"layout:glass-panel": {
|
||||||
|
"contract": 1,
|
||||||
|
"eager": false,
|
||||||
|
"files": [
|
||||||
|
"/theme-glass-panel.css",
|
||||||
|
"/glass-panel-layout.js"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"layout:bento-hub": {
|
||||||
|
"contract": 1,
|
||||||
|
"eager": false,
|
||||||
|
"files": [
|
||||||
|
"/theme-bento-hub.css",
|
||||||
|
"/bento-hub.js",
|
||||||
|
"/fonts/PlusJakartaSans-latin.woff2",
|
||||||
|
"/fonts/PlusJakartaSans-latin-ext.woff2"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"layout:f7-swipe": {
|
||||||
|
"contract": 1,
|
||||||
|
"eager": false,
|
||||||
|
"files": [
|
||||||
|
"/theme-f7.css",
|
||||||
|
"/f7-layout.js",
|
||||||
|
"/vendor/framework7-swipe.min.js"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"feature:piano": {
|
||||||
|
"contract": 1,
|
||||||
|
"eager": false,
|
||||||
|
"files": [
|
||||||
|
"/piano-core.js",
|
||||||
|
"/piano.js",
|
||||||
|
"/piano-engine.mjs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"feature:midi": {
|
||||||
|
"contract": 1,
|
||||||
|
"eager": false,
|
||||||
|
"files": [
|
||||||
|
"/midi.js"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"feature:party-dj": {
|
||||||
|
"contract": 1,
|
||||||
|
"eager": false,
|
||||||
|
"files": [
|
||||||
|
"/party-dj.js"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"feature:lyrics-window": {
|
||||||
|
"contract": 1,
|
||||||
|
"eager": false,
|
||||||
|
"files": [
|
||||||
|
"/lyrics-window.js"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"feature:lower-third": {
|
||||||
|
"contract": 1,
|
||||||
|
"eager": false,
|
||||||
|
"files": [
|
||||||
|
"/lower-third.js",
|
||||||
|
"/lower-third.css"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"feature:vocal-reducer": {
|
||||||
|
"contract": 1,
|
||||||
|
"eager": false,
|
||||||
|
"files": [
|
||||||
|
"/vocal-reducer.js"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"feature:eq-core": {
|
||||||
|
"contract": 1,
|
||||||
|
"eager": false,
|
||||||
|
"files": [
|
||||||
|
"/eq-core.js"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"feature:setlist-import": {
|
||||||
|
"contract": 1,
|
||||||
|
"eager": false,
|
||||||
|
"files": [
|
||||||
|
"/setlist-import.js"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"feature:car-mode": {
|
||||||
|
"contract": 1,
|
||||||
|
"eager": false,
|
||||||
|
"files": [
|
||||||
|
"/car-mode.js",
|
||||||
|
"/car-mode.css"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"feature:p2p": {
|
||||||
|
"contract": 1,
|
||||||
|
"eager": false,
|
||||||
|
"files": [
|
||||||
|
"/p2p-client.js",
|
||||||
|
"/p2p-core.js",
|
||||||
|
"/p2p-transfer.js",
|
||||||
|
"/p2p-recv-worker.js",
|
||||||
|
"/hash-worker.js",
|
||||||
|
"/device-db.js"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"feature:direct": {
|
||||||
|
"contract": 1,
|
||||||
|
"eager": false,
|
||||||
|
"files": [
|
||||||
|
"/direct-protocol.js",
|
||||||
|
"/direct-stream.js",
|
||||||
|
"/direct-media.js",
|
||||||
|
"/direct-media.css",
|
||||||
|
"/direct-recv-worker.js"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"feature:video-edit": {
|
||||||
|
"contract": 1,
|
||||||
|
"eager": false,
|
||||||
|
"files": [
|
||||||
|
"/video-edit.js"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"feature:stats": {
|
||||||
|
"contract": 1,
|
||||||
|
"eager": false,
|
||||||
|
"files": [
|
||||||
|
"/stats-core.js"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"feature:export": {
|
||||||
|
"contract": 1,
|
||||||
|
"eager": false,
|
||||||
|
"files": [
|
||||||
|
"/export.js"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"feature:playlist": {
|
||||||
|
"contract": 1,
|
||||||
|
"eager": true,
|
||||||
|
"files": [],
|
||||||
|
"embeddedIn": [
|
||||||
|
"/app.js"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"feature:settings": {
|
||||||
|
"contract": 1,
|
||||||
|
"eager": true,
|
||||||
|
"files": [],
|
||||||
|
"embeddedIn": [
|
||||||
|
"/app.js",
|
||||||
|
"/settings-sections.js",
|
||||||
|
"/settings-sections.css"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"feature:service-mode": {
|
||||||
|
"contract": 1,
|
||||||
|
"eager": true,
|
||||||
|
"files": [],
|
||||||
|
"embeddedIn": [
|
||||||
|
"/app.js",
|
||||||
|
"/service-ui.css"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"feature:lyrics-editor": {
|
||||||
|
"contract": 1,
|
||||||
|
"eager": true,
|
||||||
|
"files": [],
|
||||||
|
"embeddedIn": [
|
||||||
|
"/app.js",
|
||||||
|
"/lyrics-core.js",
|
||||||
|
"/lyrics-display.js"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,3 +27,25 @@ test('every SHELL asset exists on disk and there are no duplicate entries', () =
|
|||||||
assert.equal(new Set(shell).size, shell.length, 'SHELL has no duplicate entries');
|
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`);
|
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`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
3. Add importable `server/asset-manifest.js`; integrate boot-time recursive
|
3. Add importable `server/asset-manifest.js`; integrate boot-time recursive
|
||||||
hashing, stamped index, manifest endpoint, per-file headers and rollback into
|
hashing, stamped index, manifest endpoint, per-file headers and rollback into
|
||||||
`server/server.js`. Register module tests in `server/package.json`.
|
`server/server.js`. Register module tests in `server/package.json`.
|
||||||
4. Stamp safe HTML resource URLs (CSS/JS, font preloads, webmanifest). Retain
|
4. Stamp HTML CSS/JS resource URLs. Keep font preloads consistent with
|
||||||
|
fonts.css and retain the existing webmanifest URL. Retain
|
||||||
plain JS-created worker/import URLs: introducing a new helper would require
|
plain JS-created worker/import URLs: introducing a new helper would require
|
||||||
modifying the unchanged worker's shell or frontend callers. Inventory these
|
modifying the unchanged worker's shell or frontend callers. Inventory these
|
||||||
exceptions explicitly in the report. Hash every recursively served file,
|
exceptions explicitly in the report. Hash every recursively served file,
|
||||||
|
|||||||
87
server/asset-manifest.js
Normal file
87
server/asset-manifest.js
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
// Boot-time snapshot of ./public, independent of frontend source locations.
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
export const assetHash = bytes => createHash('sha256').update(bytes).digest('hex').slice(0, 10);
|
||||||
|
const tagHash = bytes => createHash('sha256').update(bytes).digest('hex').slice(0, 12);
|
||||||
|
export const injectBuildTag = (source, tag) => source.replace(
|
||||||
|
/typeof __BUILD_TAG__ !== 'undefined' \? __BUILD_TAG__ : '[^']*'/, JSON.stringify(tag));
|
||||||
|
|
||||||
|
export function stampIndex(source, files, { hashing = true, buildTag = '__BUILD_TAG__' } = {}) {
|
||||||
|
if (!hashing) return source.replace('__BUILD_TAG__', buildTag)
|
||||||
|
.replace(/((?:href|src)=")((?![a-z]+:|\/)[^"?]+\.(?:css|js))(")/g, `$1$2?v=${buildTag}$3`);
|
||||||
|
// Rewrite resource tags only, not anchors. Preserve unrelated query/fragment
|
||||||
|
// components and absolute external URLs; include root-relative local URLs.
|
||||||
|
return source.replace(/<(?:script|link)\b[^>]*>/gi, tag => tag.replace(
|
||||||
|
/\b(href|src)=(['"])([^'"]+)\2/gi, (attribute, name, quote, url) => {
|
||||||
|
if (/^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(url)) return attribute;
|
||||||
|
const parsed = new URL(url, 'https://assets.invalid/');
|
||||||
|
const file = files[decodeURIComponent(parsed.pathname)];
|
||||||
|
// Font preload URLs must stay identical to fonts.css's relative URLs.
|
||||||
|
if (!file || !/\.(?:css|js)$/.test(parsed.pathname) || parsed.pathname === '/sw.js') return attribute;
|
||||||
|
parsed.searchParams.set('v', file.h);
|
||||||
|
const originalPath = url.split(/[?#]/)[0];
|
||||||
|
return `${name}=${quote}${originalPath}${parsed.search}${parsed.hash}${quote}`;
|
||||||
|
})).replace('__BUILD_TAG__', buildTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAssetManifest(publicDir = './public', { hashing = true, buildTag: override } = {}) {
|
||||||
|
const bytes = new Map();
|
||||||
|
const legacy = createHash('sha256');
|
||||||
|
function walk(dir, prefix = '') {
|
||||||
|
for (const name of readdirSync(dir).sort()) {
|
||||||
|
const path = join(dir, name), url = `${prefix}/${name}`;
|
||||||
|
if (statSync(path).isDirectory()) walk(path, url);
|
||||||
|
else {
|
||||||
|
const content = readFileSync(path);
|
||||||
|
bytes.set(url, content);
|
||||||
|
legacy.update(`./public${url}`); legacy.update(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Missing public is the historical fixed fallback used by local tests.
|
||||||
|
if (!existsSync(publicDir)) return { manifest: { buildTag: override || 'dev-build', files: {}, groups: {}, contracts: {} }, index: null, sw: null, bytes };
|
||||||
|
walk(publicDir);
|
||||||
|
const definition = bytes.has('/assets.json') ? JSON.parse(bytes.get('/assets.json').toString()) : {
|
||||||
|
groups: { core: { contract: 1, eager: true, files: [...bytes.keys()].sort() } },
|
||||||
|
};
|
||||||
|
const groups = {}, contracts = {}, membership = new Map();
|
||||||
|
for (const name of Object.keys(definition.groups).sort()) {
|
||||||
|
const group = definition.groups[name];
|
||||||
|
if (!Number.isInteger(group.contract) || group.contract < 1 || typeof group.eager !== 'boolean' || !Array.isArray(group.files)) throw new Error(`Invalid asset group ${name}`);
|
||||||
|
groups[name] = group; contracts[name] = group.contract;
|
||||||
|
for (const url of group.files) {
|
||||||
|
if (!bytes.has(url)) throw new Error(`Asset group ${name}: missing ${url}`);
|
||||||
|
if (membership.has(url)) throw new Error(`Duplicate asset membership: ${url}`);
|
||||||
|
membership.set(url, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const files = {};
|
||||||
|
for (const url of [...bytes.keys()].sort()) files[url] = { h: assetHash(bytes.get(url)), s: bytes.get(url).length, g: membership.get(url) || 'core' };
|
||||||
|
const source = bytes.get('/index.html')?.toString() ?? null;
|
||||||
|
const swSource = bytes.get('/sw.js')?.toString() ?? null;
|
||||||
|
// Derived build metadata cannot be an input to its own hash. Canonicalize
|
||||||
|
// the index meta and SW injected tag, then publish hashes of the final bytes.
|
||||||
|
// All original bytes (including index/SW source) remain inputs via source hashes.
|
||||||
|
let canonicalIndex = source === null ? null : stampIndex(source, files, { hashing });
|
||||||
|
const canonical = { files: { ...files }, groups, contracts };
|
||||||
|
if (canonicalIndex !== null && hashing) canonical.files['/index.html'] = { ...files['/index.html'], h: assetHash(canonicalIndex), s: Buffer.byteLength(canonicalIndex), source: files['/index.html'].h };
|
||||||
|
const buildTag = hashing ? tagHash(JSON.stringify(canonical)) : (override || legacy.digest('hex').slice(0, 12));
|
||||||
|
const index = source === null ? null : stampIndex(source, files, { hashing, buildTag });
|
||||||
|
const sw = swSource === null ? null : injectBuildTag(swSource, buildTag);
|
||||||
|
if (index !== null) files['/index.html'] = { ...files['/index.html'], h: assetHash(index), s: Buffer.byteLength(index) };
|
||||||
|
if (sw !== null) files['/sw.js'] = { ...files['/sw.js'], h: assetHash(sw), s: Buffer.byteLength(sw) };
|
||||||
|
if (index !== null) bytes.set('/index.html', Buffer.from(index));
|
||||||
|
if (sw !== null) bytes.set('/sw.js', Buffer.from(sw));
|
||||||
|
return { manifest: { buildTag, files, groups, contracts }, index, sw, bytes };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assetCacheControl(path, version, manifest, hashing = true) {
|
||||||
|
const current = hashing ? manifest.files[path]?.h : manifest.buildTag;
|
||||||
|
if (version !== undefined) {
|
||||||
|
if (version === current) return 'public, max-age=31536000, immutable';
|
||||||
|
return hashing ? 'no-store' : 'no-cache';
|
||||||
|
}
|
||||||
|
return /(^|\/)(fonts|icons)\//.test(path) && (hashing || !/\.(?:js|css|html|json|webmanifest|svg|txt)$/i.test(path)) ? 'public, max-age=2592000' : 'no-cache';
|
||||||
|
}
|
||||||
62
server/asset-manifest.test.js
Normal file
62
server/asset-manifest.test.js
Normal 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"');
|
||||||
|
}));
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "bun server.js",
|
"start": "bun server.js",
|
||||||
"dev": "bun --hot 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": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.14.0",
|
"@hono/node-server": "^1.14.0",
|
||||||
|
|||||||
100
server/server.js
100
server/server.js
@@ -30,10 +30,10 @@
|
|||||||
* UI code works without modification in WEB mode.
|
* UI code works without modification in WEB mode.
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import { createAssetManifest, assetCacheControl } from './asset-manifest.js';
|
||||||
import { registerDownloadRetryRoute } from './download-retry.js';
|
import { registerDownloadRetryRoute } from './download-retry.js';
|
||||||
import { createResilientYtdlp, BOT_CHECK_RE, DEFAULT_FALLBACK_CLIENTS } from './ytdlp-resilience.js';
|
import { createResilientYtdlp, BOT_CHECK_RE, DEFAULT_FALLBACK_CLIENTS } from './ytdlp-resilience.js';
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { serveStatic } from 'hono/bun';
|
|
||||||
import { logger } from 'hono/logger';
|
import { logger } from 'hono/logger';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import { createServer } from 'node:http';
|
import { createServer } from 'node:http';
|
||||||
@@ -96,48 +96,11 @@ function withSaveSlot(fn) {
|
|||||||
return run;
|
return run;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// Snapshot every shipped public file. Hashes describe the served HTML/SW bytes;
|
||||||
// BUILD_TAG — must be DETERMINISTIC across restarts of identical code.
|
// rollback retains the historical recursive tag and single-tag URL stamping.
|
||||||
//
|
const ASSET_HASHING = process.env.ASSET_HASHING !== '0';
|
||||||
// Previously this was `Date.now().toString(36)`, which changes every time the
|
const assets = createAssetManifest('./public', { hashing: ASSET_HASHING, buildTag: process.env.BUILD_TAG });
|
||||||
// process starts even if nothing was deployed (crash-loop, healthcheck
|
const BUILD_TAG = assets.manifest.buildTag;
|
||||||
// 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();
|
|
||||||
|
|
||||||
// BUILD_TIME — human-readable "when was this image built". Written by the
|
// BUILD_TIME — human-readable "when was this image built". Written by the
|
||||||
// Dockerfile at image build time (never at container start, so restarts
|
// Dockerfile at image build time (never at container start, so restarts
|
||||||
@@ -389,6 +352,14 @@ registerCatalogRoutes(app, { resolveListener: recommendationListener });
|
|||||||
// GET /api/version
|
// GET /api/version
|
||||||
// Returns version string + a build tag that changes on every server restart/deploy.
|
// Returns version string + a build tag that changes on every server restart/deploy.
|
||||||
// Clients poll this to detect when a new build is live and prompt a reload.
|
// 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) =>
|
app.get('/api/version', (c) =>
|
||||||
c.json(
|
c.json(
|
||||||
{ version: APP_VERSION, buildTag: BUILD_TAG, buildTime: BUILD_TIME },
|
{ version: APP_VERSION, buildTag: BUILD_TAG, buildTime: BUILD_TIME },
|
||||||
@@ -2482,7 +2453,7 @@ app.get('/sw.js', (c) => {
|
|||||||
if (src === _swSource) {
|
if (src === _swSource) {
|
||||||
console.error('[sw] BUILD_TAG injection failed — placeholder not found in sw.js');
|
console.error('[sw] BUILD_TAG injection failed — placeholder not found in sw.js');
|
||||||
}
|
}
|
||||||
return sendCompressed(c, compressedEntry('sw.js', Buffer.from(src), MIME.js), 'no-store, no-cache, must-revalidate');
|
return sendCompressed(c, compressedEntry('sw.js', Buffer.from(src), MIME.js), 'no-store, no-cache, must-revalidate', assets.manifest.files['/sw.js']?.h);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -2511,8 +2482,9 @@ function compressedEntry(key, raw, type) {
|
|||||||
compressedCache.set(key, e);
|
compressedCache.set(key, e);
|
||||||
return 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' };
|
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') || '';
|
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 });
|
if (inm.split(',').map((s) => s.trim()).includes(e.etag)) return new Response(null, { status: 304, headers });
|
||||||
const ae = c.req.header('accept-encoding') || '';
|
const ae = c.req.header('accept-encoding') || '';
|
||||||
@@ -2523,42 +2495,28 @@ function sendCompressed(c, e, cacheControl) {
|
|||||||
const MIME = { js: 'text/javascript; charset=utf-8', css: 'text/css; charset=utf-8', html: 'text/html; charset=utf-8',
|
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' };
|
json: 'application/json', webmanifest: 'application/manifest+json', svg: 'image/svg+xml', txt: 'text/plain; charset=utf-8' };
|
||||||
|
|
||||||
let _indexSource = null;
|
|
||||||
function indexHtml(c) {
|
function indexHtml(c) {
|
||||||
if (_indexSource === null) {
|
if (assets.index === null) return c.text('index.html not found', 404);
|
||||||
try { _indexSource = readFileSync('./public/index.html', 'utf8'); }
|
return sendCompressed(c, compressedEntry('index.html', Buffer.from(assets.index), MIME.html),
|
||||||
catch { return c.text('index.html not found', 404); }
|
assetCacheControl('/index.html', c.req.query('v'), assets.manifest, ASSET_HASHING), assets.manifest.files['/index.html']?.h);
|
||||||
}
|
|
||||||
// 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');
|
|
||||||
}
|
}
|
||||||
app.get('/', indexHtml);
|
app.get('/', indexHtml);
|
||||||
app.get('/index.html', indexHtml);
|
app.get('/index.html', indexHtml);
|
||||||
|
|
||||||
// no-cache (revalidate every time) on the shell: the service worker is the
|
// Serve every file with its per-file hash, including binary icons/fonts and
|
||||||
// only cache that should hold app files. With no headers at all, a browser
|
// unversioned URLs requested by old clients/workers. A stale version gets the
|
||||||
// may heuristically cache app.js and hand a new worker's install the old one.
|
// current body with no-store, never immutable caching under a mismatched URL.
|
||||||
app.get('/*', async (c, next) => {
|
app.get('/*', async (c, next) => {
|
||||||
const p = decodeURIComponent(new URL(c.req.url).pathname);
|
const p = decodeURIComponent(new URL(c.req.url).pathname);
|
||||||
if (!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);
|
const file = Bun.file('./public' + p);
|
||||||
if (!(await file.exists())) return next();
|
const raw = assets.bytes.get(p);
|
||||||
const raw = Buffer.from(await file.arrayBuffer());
|
|
||||||
const ext = p.slice(p.lastIndexOf('.') + 1).toLowerCase();
|
const ext = p.slice(p.lastIndexOf('.') + 1).toLowerCase();
|
||||||
// `?v=` equal to the running build → forever (a new build changes the URL).
|
const hash = assets.manifest.files[p].h;
|
||||||
// Any other / missing v keeps revalidating.
|
const cacheControl = assetCacheControl(p, c.req.query('v'), assets.manifest, ASSET_HASHING);
|
||||||
const forever = c.req.query('v') === BUILD_TAG;
|
if (COMPRESSIBLE.test(p)) return sendCompressed(c, compressedEntry(p, raw, MIME[ext] || file.type), cacheControl, hash);
|
||||||
return sendCompressed(c, compressedEntry(p, raw, MIME[ext] || 'application/octet-stream'),
|
return new Response(raw, { headers: { 'Content-Type': file.type, 'Cache-Control': cacheControl, 'X-Asset-Hash': hash } });
|
||||||
forever ? 'public, max-age=31536000, immutable' : 'no-cache');
|
|
||||||
});
|
});
|
||||||
// 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
|
// SPA fallback — return index.html for any unmatched path
|
||||||
app.get('/*', indexHtml);
|
app.get('/*', indexHtml);
|
||||||
|
|
||||||
|
|||||||
@@ -32,11 +32,11 @@ async function freePort() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startServer() {
|
async function startServer(hashing = true) {
|
||||||
const port = await freePort();
|
const port = await freePort();
|
||||||
const child = Bun.spawn([process.execPath, serverFile], {
|
const child = Bun.spawn([process.execPath, serverFile], {
|
||||||
cwd: root,
|
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',
|
stdout: 'pipe', stderr: 'pipe',
|
||||||
});
|
});
|
||||||
const stdout = new Response(child.stdout).text();
|
const stdout = new Response(child.stdout).text();
|
||||||
@@ -97,20 +97,20 @@ describe('static delivery characterization', () => {
|
|||||||
test('index stamps only relative local css/js and serves expected cache headers', async () => {
|
test('index stamps only relative local css/js and serves expected cache headers', async () => {
|
||||||
const index = await fetch(`${server.base}/`);
|
const index = await fetch(`${server.base}/`);
|
||||||
const html = await index.text();
|
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(`<meta name="ytp-build" content="${buildTag}">`);
|
||||||
expect(html).toContain(`href="app.css?v=${buildTag}"`);
|
expect(html).toContain(`href="app.css?v=${files['/app.css'].h}"`);
|
||||||
expect(html).toContain(`src="app.js?v=${buildTag}"`);
|
expect(html).toContain(`src="app.js?v=${files['/app.js'].h}"`);
|
||||||
expect(html).toContain('https://cdn.example/x.js');
|
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(index.headers.get('cache-control')).toBe('no-cache');
|
||||||
expect((await fetch(`${server.base}/index.html`)).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('cache-control')).toBe('public, max-age=31536000, immutable');
|
||||||
expect(current.headers.get('content-encoding')).toBe('br');
|
expect(current.headers.get('content-encoding')).toBe('br');
|
||||||
expect(current.headers.get('etag')).toBeTruthy();
|
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}/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}/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');
|
expect((await fetch(`${server.base}/icons/icon.png`)).headers.get('cache-control')).toBe('public, max-age=2592000');
|
||||||
@@ -118,11 +118,54 @@ describe('static delivery characterization', () => {
|
|||||||
expect((await fetch(`${server.base}${path}`)).headers.get('cache-control')).toBe('no-store, no-cache, must-revalidate');
|
expect((await fetch(`${server.base}${path}`)).headers.get('cache-control')).toBe('no-store, no-cache, must-revalidate');
|
||||||
}
|
}
|
||||||
const etag = current.headers.get('etag');
|
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(notModified.status).toBe(304);
|
||||||
expect(await notModified.text()).toBe('');
|
expect(await notModified.text()).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('manifest API, ETag/304 and hashes cover compressed and binary assets', async () => {
|
||||||
|
const response = await fetch(`${server.base}/api/manifest`);
|
||||||
|
expect(response.headers.get('cache-control')).toBe('no-store');
|
||||||
|
const manifest = await response.json();
|
||||||
|
expect(manifest.buildTag).toBe((await (await fetch(`${server.base}/api/version`)).json()).buildTag);
|
||||||
|
expect(manifest.groups.core.eager).toBe(true);
|
||||||
|
expect(manifest.contracts.core).toBe(1);
|
||||||
|
expect(response.headers.get('etag')).toBe(`"${manifest.buildTag}"`);
|
||||||
|
const unchanged = await fetch(`${server.base}/api/manifest`, { headers: { 'If-None-Match': response.headers.get('etag') } });
|
||||||
|
expect(unchanged.status).toBe(304);
|
||||||
|
expect(await unchanged.text()).toBe('');
|
||||||
|
const { createHash } = await import('node:crypto');
|
||||||
|
for (const path of ['/app.js', '/fonts/font.woff2', '/icons/icon.png', '/index.html', '/sw.js']) {
|
||||||
|
const h = manifest.files[path].h;
|
||||||
|
for (const query of ['', `?v=${h}`, '?v=oldTag', '?__ytpfresh=123', '?v=']) {
|
||||||
|
const asset = await fetch(`${server.base}${path}${query}`);
|
||||||
|
expect(asset.status).toBe(200);
|
||||||
|
expect(asset.headers.get('x-asset-hash')).toBe(h);
|
||||||
|
expect(createHash('sha256').update(Buffer.from(await asset.arrayBuffer())).digest('hex').slice(0, 10)).toBe(h);
|
||||||
|
if (path !== '/sw.js') {
|
||||||
|
if (query === `?v=${h}`) expect(asset.headers.get('cache-control')).toBe('public, max-age=31536000, immutable');
|
||||||
|
if (query === '?v=oldTag' || query === '?v=') expect(asset.headers.get('cache-control')).toBe('no-store');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const canonical = await (await fetch(`${server.base}/`)).text();
|
||||||
|
for (const path of ['/index.html', '/search', '/playlist/x']) expect(await (await fetch(`${server.base}${path}`)).text()).toBe(canonical);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ASSET_HASHING=0 restores single-tag stamping and old cache matrix', async () => {
|
||||||
|
await stopServer(server);
|
||||||
|
server = await startServer(false);
|
||||||
|
try {
|
||||||
|
const { buildTag } = await (await fetch(`${server.base}/api/version`)).json();
|
||||||
|
const html = await (await fetch(`${server.base}/`)).text();
|
||||||
|
expect(html).toContain(`src="app.js?v=${buildTag}"`);
|
||||||
|
expect(html).toContain('href="/root.css"');
|
||||||
|
expect((await fetch(`${server.base}/app.js?v=${buildTag}`)).headers.get('cache-control')).toBe('public, max-age=31536000, immutable');
|
||||||
|
expect((await fetch(`${server.base}/app.js?v=stale`)).headers.get('cache-control')).toBe('no-cache');
|
||||||
|
} finally { await stopServer(server); server = await startServer(); }
|
||||||
|
});
|
||||||
|
|
||||||
test('service worker injection tracks fallback literal and reports missing placeholder', async () => {
|
test('service worker injection tracks fallback literal and reports missing placeholder', async () => {
|
||||||
let sw = await (await fetch(`${server.base}/sw.js`)).text();
|
let sw = await (await fetch(`${server.base}/sw.js`)).text();
|
||||||
const { buildTag } = await (await fetch(`${server.base}/api/version`)).json();
|
const { buildTag } = await (await fetch(`${server.base}/api/version`)).json();
|
||||||
|
|||||||
Reference in New Issue
Block a user