Serve the app shell gzip/brotli-compressed with ETags

This commit is contained in:
Claude
2026-09-30 06:45:11 +00:00
parent 7229ca16b9
commit 794410916a
3 changed files with 51 additions and 6 deletions

View File

@@ -9,7 +9,7 @@ green, app boots with no JS errors, P2P on by default, offline boot works).
| Seq | ID | Title | Status | Commit | Notes | | Seq | ID | Title | Status | Commit | Notes |
|-----|----|-------|--------|--------|-------| |-----|----|-------|--------|--------|-------|
| 001 | 001-perf-timing-marks-105acc | Add startup, search and play timing marks plus yt-dlp duration logs | done | Add startup, search and play timing marks plus yt-dlp duration logs | baseline numbers for the rest | | 001 | 001-perf-timing-marks-105acc | Add startup, search and play timing marks plus yt-dlp duration logs | done | Add startup, search and play timing marks plus yt-dlp duration logs | baseline numbers for the rest |
| 002 | 002-compress-and-etag-shell-bd459c | Serve the app shell gzip/brotli-compressed with ETags | in-progress | | app.js 426 KB → 94 KB (br) | | 002 | 002-compress-and-etag-shell-bd459c | Serve the app shell gzip/brotli-compressed with ETags | done | Serve the app shell gzip/brotli-compressed with ETags | app.js 426 KB → 94 KB (br) |
| 003 | 003-self-host-fonts-89466b | Self-host the three web fonts and drop the render-blocking Google Fonts CSS | queued | | needs network once; CSP font-src fix | | 003 | 003-self-host-fonts-89466b | Self-host the three web fonts and drop the render-blocking Google Fonts CSS | queued | | needs network once; CSP font-src fix |
| 004 | 004-coalesce-stream-resolves-a92d40 | Coalesce concurrent resolveStreams calls for the same video | queued | | | | 004 | 004-coalesce-stream-resolves-a92d40 | Coalesce concurrent resolveStreams calls for the same video | queued | | |
| 005 | 005-warm-streams-on-intent-47b3d3 | Warm the stream cache for likely next plays | queued | | cold play 7.5 s → cached 1.2 s | | 005 | 005-warm-streams-on-intent-47b3d3 | Warm the stream cache for likely next plays | queued | | cold play 7.5 s → cached 1.2 s |

View File

@@ -154,3 +154,10 @@ Output ONLY the following, no other prose:
skipped and why. skipped and why.
Do not commit. Do not push. Do not touch files outside the Steps. Do not commit. Do not push. Do not touch files outside the Steps.
## Execution log
- Executor: in-session Agent (haiku). Attempts: 1. Fix rounds: 0.
- Orchestrator re-ran Verification on a fresh port: `Content-Encoding: br`, ETag, `Vary`, 304 on If-None-Match, index stamped (`ytp-build` 1), sw.js gzip + original no-store `Cache-Control`, BUILD_TAG injected (0 placeholders), SPA fallback 200, 52 frontend tests pass. app.js 426 244 -> 93 759 bytes.
- Executor Findings (verbatim): All 5 verification tests pass. All unit tests pass. Changes follow the plan exactly: added brotli/zlib imports, implemented compressedEntry/sendCompressed helpers with ETag and dual-compression support (br preferred, fallback to gzip), updated indexHtml and sw.js handlers to use sendCompressed, and added middleware for compressing all text files under ./public. Cache-Control headers preserved exactly as specified. No changes made to sw.js, sw-update.js, or computeBuildTag.
- Orchestrator note: the executor left a background server on port 3999; killed. Cosmetic: the helper block sits between the old indexHtml comment and `let _indexSource`.

View File

@@ -39,6 +39,7 @@ import { readFileSync, readdirSync, existsSync, statSync, openSync, unlinkSync,
import { Readable } from 'node:stream'; import { Readable } from 'node:stream';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { brotliCompressSync, constants as zlibConstants } from 'node:zlib';
import { initDb, upsertUser, recordVideoAccess, getUserData, createProfile, getProfile, saveProfile, createSharedPlaylist, getSharedPlaylist, queueInboxPlaylist, listInbox, deleteInboxItem, countInbox, import { initDb, upsertUser, recordVideoAccess, getUserData, createProfile, getProfile, saveProfile, createSharedPlaylist, getSharedPlaylist, queueInboxPlaylist, listInbox, deleteInboxItem, countInbox,
getMedia, upsertMedia, deleteMedia, listMedia, listMediaLru, touchMedia, mediaStats } from './db.js'; getMedia, upsertMedia, deleteMedia, listMedia, listMediaLru, touchMedia, mediaStats } from './db.js';
import { createMediaCache, HIGH, LOW } from './media-cache.js'; import { createMediaCache, HIGH, LOW } from './media-cache.js';
@@ -1881,10 +1882,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 c.text(src, 200, { return sendCompressed(c, compressedEntry('sw.js', Buffer.from(src), MIME.js), 'no-store, no-cache, must-revalidate');
'Content-Type': 'application/javascript; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate',
});
}); });
// ============================================================================ // ============================================================================
@@ -1895,13 +1893,44 @@ app.get('/sw.js', (c) => {
// stamped here at request time the same way /sw.js gets it. The page compares // stamped here at request time the same way /sw.js gets it. The page compares
// that against /api/version, so "Update available" only ever shows when the // that against /api/version, so "Update available" only ever shows when the
// running build really differs from the deployed one (see app.js). // running build really differs from the deployed one (see app.js).
// Compressed + ETagged static text. The shell is ~620 KB raw / ~160 KB gzip
// and every byte crosses the slow VPS→homelab link, so compress once per
// file content and keep it in memory. ETag = sha256 of the RAW bytes, so a
// `no-cache` revalidation costs a 304 instead of the whole file.
const COMPRESSIBLE = /\.(js|css|html|json|webmanifest|svg|txt)$/i;
const compressedCache = new Map(); // key -> { etag, raw, gz, br, type }
function compressedEntry(key, raw, type) {
let e = compressedCache.get(key);
const etag = '"' + createHash('sha256').update(raw).digest('hex').slice(0, 32) + '"';
if (e && e.etag === etag) return e;
e = {
etag, raw, type,
gz: Bun.gzipSync(raw, { level: 9 }),
br: brotliCompressSync(raw, { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 11 } }),
};
compressedCache.set(key, e);
return e;
}
function sendCompressed(c, e, cacheControl) {
const headers = { 'Content-Type': e.type, 'Cache-Control': cacheControl, ETag: e.etag, Vary: 'Accept-Encoding' };
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') || '';
if (/\bbr\b/.test(ae)) return new Response(e.br, { headers: { ...headers, 'Content-Encoding': 'br' } });
if (/\bgzip\b/.test(ae)) return new Response(e.gz, { headers: { ...headers, 'Content-Encoding': 'gzip' } });
return new Response(e.raw, { headers });
}
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; let _indexSource = null;
function indexHtml(c) { function indexHtml(c) {
if (_indexSource === null) { if (_indexSource === null) {
try { _indexSource = readFileSync('./public/index.html', 'utf8'); } try { _indexSource = readFileSync('./public/index.html', 'utf8'); }
catch { return c.text('index.html not found', 404); } catch { return c.text('index.html not found', 404); }
} }
return c.html(_indexSource.replace('__BUILD_TAG__', BUILD_TAG), 200, { 'Cache-Control': 'no-cache' }); const html = _indexSource.replace('__BUILD_TAG__', BUILD_TAG);
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);
@@ -1909,6 +1938,15 @@ app.get('/index.html', indexHtml);
// no-cache (revalidate every time) on the shell: the service worker is the // 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 // 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. // may heuristically cache app.js and hand a new worker's install the old one.
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();
const file = Bun.file('./public' + p);
if (!(await file.exists())) return next();
const raw = Buffer.from(await file.arrayBuffer());
const ext = p.slice(p.lastIndexOf('.') + 1).toLowerCase();
return sendCompressed(c, compressedEntry(p, raw, MIME[ext] || 'application/octet-stream'), 'no-cache');
});
app.use('/*', serveStatic({ root: './public', onFound: (_path, c) => { c.header('Cache-Control', 'no-cache'); } })); app.use('/*', serveStatic({ root: './public', onFound: (_path, c) => { c.header('Cache-Control', '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);