// Service worker with TWO jobs: // // 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 every cache that isn't the current version (old caching SWs included). const keys = await caches.keys(); 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; // 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. });