Serve the app shell gzip/brotli-compressed with ETags
This commit is contained in:
@@ -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 |
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user