From bae87b502336803ad7797ebbaa6499c61fe423d8 Mon Sep 17 00:00:00 2001 From: I Luk Kim Date: Thu, 25 Jun 2026 21:25:38 -0700 Subject: [PATCH] perf: cache static assets in service worker Versioned cache-first for /js, /css, /fonts, manifest so the ~22 no-store assets (+~5MB fonts) aren't re-fetched through the tunnel on every load. Safe via the existing ?v= cache-busting URLs. Co-Authored-By: Claude Opus 4.8 --- public/sw.js | 107 ++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 80 insertions(+), 27 deletions(-) diff --git a/public/sw.js b/public/sw.js index 9c76e33..3dfb715 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,40 +1,93 @@ -// Pass-through service worker — does NOT cache responses (response caching is what -// made us disable the previous SW; assets must always come fresh from the network). +// Service worker with TWO jobs: // -// Its only job: add the `ngrok-skip-browser-warning` header to top-level navigation -// requests, so ngrok's free-tier browser interstitial ("You are about to visit …") -// doesn't appear on repeat visits / PWA launches. ngrok skips the warning whenever -// that header is present (verified). The very first visit on a fresh browser still -// shows it once — the SW can't exist before that initial load — but ngrok's own -// cookie also suppresses it after a single "Visit Site" click. +// 1. ngrok interstitial bypass (navigations) — add the `ngrok-skip-browser-warning` +// header to top-level navigation requests so ngrok's free-tier browser +// interstitial ("You are about to visit …") doesn't appear on repeat visits / +// PWA launches. ngrok skips the warning whenever that header is present. The very +// first visit on a fresh browser still shows it once (the SW can't exist before +// that initial load); ngrok's own cookie also suppresses it after one click. +// +// 2. Versioned asset cache (subresources) — serve /js, /css, /fonts and the manifest +// from a Cache Storage entry instead of re-fetching them through the tunnel on +// every load. The server sends `Cache-Control: no-store` on everything (see +// src/server/app.ts), so the browser HTTP cache is disabled and every page open +// otherwise re-downloads ~20 assets + ~5MB of fonts through ngrok. ngrok's free +// tier allows only 20k HTTP requests/month (ERR_NGROK_727 when exceeded), so this +// is the difference between staying under the cap and blowing it in days. +// +// Safe against stale assets: every asset URL carries a ?v=N that index.html bumps +// whenever that file changes (the project's existing cache-busting convention). A +// changed asset is therefore a NEW url → guaranteed cache miss → fresh fetch. We +// never serve old bytes for a current url. Bump CACHE_VERSION to wipe everything +// (e.g. to clear the few KB of dead old-?v= entries, or to refresh the unversioned +// fonts/manifest if they ever change). + +const CACHE_VERSION = 'cmux-assets-v1'; + +// Same-origin GET requests under these paths are cache-first. Everything else +// (/api, navigations, POST, WebSocket upgrades) is left completely untouched. +function isCacheableAsset(pathname) { + return ( + pathname.startsWith('/js/') || + pathname.startsWith('/css/') || + pathname.startsWith('/fonts/') || + pathname === '/manifest.json' + ); +} + self.addEventListener('install', () => self.skipWaiting()); self.addEventListener('activate', (event) => { event.waitUntil((async () => { - // Drop any caches left behind by the old caching SW so nothing serves stale. + // Drop every cache that isn't the current version (old caching SWs included). const keys = await caches.keys(); - await Promise.all(keys.map((k) => caches.delete(k))); + await Promise.all(keys.filter((k) => k !== CACHE_VERSION).map((k) => caches.delete(k))); await self.clients.claim(); })()); }); +async function cacheFirst(request) { + const cache = await caches.open(CACHE_VERSION); + const hit = await cache.match(request); + if (hit) return hit; + + const response = await fetch(request); + // Only store complete, same-origin 200s. The server's `no-store` header is + // intentionally ignored — the Cache API stores what we put regardless, and the + // ?v= URL versioning (not HTTP caching) is what guards against staleness here. + if (response && response.status === 200 && response.type === 'basic') { + cache.put(request, response.clone()); + } + return response; +} + self.addEventListener('fetch', (event) => { const req = event.request; - // Only top-level navigations trigger ngrok's interstitial. Leave everything else - // (assets, /api, POST, WebSocket upgrades) completely untouched — no rewrite and - // no caching — so behaviour is otherwise identical to having no SW at all. - if (req.mode !== 'navigate') return; - - const headers = new Headers(req.headers); - headers.set('ngrok-skip-browser-warning', 'true'); - event.respondWith( - fetch( - new Request(req.url, { - method: 'GET', - headers, - credentials: req.credentials, - redirect: 'manual', - }) - ).catch(() => fetch(req)) - ); + + // Top-level navigations: network-first, with the ngrok interstitial-bypass header. + // Never cached — HTML must stay fresh so new ?v= asset versions are picked up. + if (req.mode === 'navigate') { + const headers = new Headers(req.headers); + headers.set('ngrok-skip-browser-warning', 'true'); + event.respondWith( + fetch( + new Request(req.url, { + method: 'GET', + headers, + credentials: req.credentials, + redirect: 'manual', + }) + ).catch(() => fetch(req)) + ); + return; + } + + // Versioned static assets: cache-first, keeping them off the tunnel entirely. + const url = new URL(req.url); + if (req.method === 'GET' && url.origin === self.location.origin && isCacheableAsset(url.pathname)) { + event.respondWith(cacheFirst(req)); + return; + } + + // Everything else: untouched — straight to network, no rewrite, no caching. });