@ -1,30 +1,72 @@
// Pass-through service worker — does NOT cache responses (response caching is what
// Service worker with TWO jobs:
// made us disable the previous SW; assets must always come fresh from the network).
//
//
// Its only job: add the `ngrok-skip-browser-warning` header to top-level navigation
// 1. ngrok interstitial bypass (navigations) — add the `ngrok-skip-browser-warning`
// requests, so ngrok's free-tier browser interstitial ("You are about to visit …")
// header to top-level navigation requests so ngrok's free-tier browser
// doesn't appear on repeat visits / PWA launches. ngrok skips the warning whenever
// interstitial ("You are about to visit …") doesn't appear on repeat visits /
// that header is present (verified). The very first visit on a fresh browser still
// PWA launches. ngrok skips the warning whenever that header is present. The very
// shows it once — the SW can't exist before that initial load — but ngrok's own
// first visit on a fresh browser still shows it once (the SW can't exist before
// cookie also suppresses it after a single "Visit Site" click.
// 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 ( 'install' , ( ) => self . skipWaiting ( ) ) ;
self . addEventListener ( 'activate' , ( event ) => {
self . addEventListener ( 'activate' , ( event ) => {
event . waitUntil ( ( async ( ) => {
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 ( ) ;
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 ( ) ;
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 ) => {
self . addEventListener ( 'fetch' , ( event ) => {
const req = event . request ;
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 ;
// 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 ) ;
const headers = new Headers ( req . headers ) ;
headers . set ( 'ngrok-skip-browser-warning' , 'true' ) ;
headers . set ( 'ngrok-skip-browser-warning' , 'true' ) ;
event . respondWith (
event . respondWith (
@ -37,4 +79,15 @@ self.addEventListener('fetch', (event) => {
} )
} )
) . catch ( ( ) => fetch ( req ) )
) . 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.
} ) ;
} ) ;