1/* eslint-env serviceworker */ 2 3/** 4 * Melodigging service worker â hand-rolled, zero dependencies, no build step. 5 * 6 * SCOPE: static assets + an offline fallback page. It deliberately never caches 7 * Supabase data, /api responses, RSC payloads or DAW audio. 8 * 9 * VERSIONING: bump VERSION whenever this file changes. A deploy alone does NOT 10 * require a bump: /_next/static/* is content-hashed by Next, so a new build 11 * emits new URLs that simply miss the cache. Bumping only evicts the orphaned 12 * entries left behind by previous builds. 13 */ 14 15const VERSION = 'v1' 16 17const STATIC_CACHE = `melodigging-static-${VERSION}` 18const PAGES_CACHE = `melodigging-pages-${VERSION}` 19const IMAGES_CACHE = `melodigging-images-${VERSION}` 20 21const EXPECTED_CACHES = [STATIC_CACHE, PAGES_CACHE, IMAGES_CACHE] 22 23const OFFLINE_URL = '/offline' 24 25// Kept deliberately tiny. /offline is the fallback shell, /icon.webp is the logo 26// <Header> renders on every page. Everything else is either already 27// content-hashed (and cached on demand) or not worth the install-time cost. 28const PRECACHE_URLS = [ 29 OFFLINE_URL, 30 '/icon.webp' 31] 32 33const MAX_PAGE_ENTRIES = 30 34const MAX_IMAGE_ENTRIES = 80 35 36// Always straight to the network, never inspected, never stored. 37const NETWORK_ONLY_PREFIXES = [ 38 '/api/', // mutating routes: emails, uploads, raw query exec 39 '/redirect/', // google-auth-callback: must never be intercepted 40 '/scripts/', // clarity.js / hotjar.js: same-origin but NOT content-hashed 41 '/daw/', // hundreds of MB of wav/mp3 sample banks 42 '/.well-known/', // assetlinks.json for the Android TWA 43 '/sitemap.xml', 44 '/robots.txt' 45] 46 47// Navigations whose HTML we never store: auth and user-specific shells. 48// IMPORTANT: if a server-rendered, user-specific page is ever added, it must be 49// added here in the same commit. 50const NEVER_CACHE_PAGES = [ 51 '/sign-in', 52 '/onboarding', 53 '/user' 54] 55 56const IMAGE_EXTENSION_RE = /\.(?:png|jpe?g|webp|avif|gif|svg|ico)$/i 57 58/* ------------------------------------------------------------------ */ 59/* install */ 60/* ------------------------------------------------------------------ */ 61 62self.addEventListener('install', (event) => { 63 event.waitUntil((async () => { 64 const cache = await caches.open(STATIC_CACHE) 65 66 // NOT cache.addAll(): that is all-or-nothing, so a single 404 aborts the 67 // whole install and the worker never takes over. Precache entry by entry. 68 await Promise.all(PRECACHE_URLS.map(async (url) => { 69 try { 70 const response = await fetch(url, { cache: 'reload' }) 71 72 if (response.ok) { 73 await cache.put(url, response) 74 } 75 } catch (error) { 76 // Swallow: a failed precache degrades the offline UX, it must not block 77 // installation. 78 } 79 })) 80 })()) 81 82 // Intentionally NO self.skipWaiting() â the new worker parks in `waiting` 83 // until the user accepts the update toast. See the `message` handler. 84}) 85 86/* ------------------------------------------------------------------ */ 87/* activate */ 88/* ------------------------------------------------------------------ */ 89 90self.addEventListener('activate', (event) => { 91 event.waitUntil((async () => { 92 const names = await caches.keys() 93 94 await Promise.all( 95 names 96 .filter((name) => name.startsWith('melodigging-') && !EXPECTED_CACHES.includes(name)) 97 .map(async (name) => await caches.delete(name)) 98 ) 99 100 // Lets the browser start the navigation request before this worker has even 101 // booted. Consumed in navigationHandler via event.preloadResponse. 102 if (self.registration.navigationPreload !== undefined) { 103 try { 104 await self.registration.navigationPreload.enable() 105 } catch (error) { 106 // Not supported (Safari) â the handler falls back to plain fetch(). 107 } 108 } 109 110 await self.clients.claim() 111 })()) 112}) 113 114/* ------------------------------------------------------------------ */ 115/* message */ 116/* ------------------------------------------------------------------ */ 117 118self.addEventListener('message', (event) => { 119 const data = event.data 120 121 if (data === null || typeof data !== 'object') return 122 123 if (data.type === 'SKIP_WAITING') {
124 self.skipWaiting() 125 } 126}) 127 128/* ------------------------------------------------------------------ */ 129/* fetch */ 130/* ------------------------------------------------------------------ */ 131 132self.addEventListener('fetch', (event) => { 133 const request = event.request 134 135 // 1. GET only. Every mutating call (send-email, image upload, 136 // execute-raw-query, Supabase writes) leaves immediately. 137 if (request.method !== 'GET') return 138 139 let url 140 141 try { 142 url = new URL(request.url) 143 } catch (error) { 144 return 145 } 146 147 // 2. Same-origin only. Excludes Supabase REST + auth + storage, Google 148 // Analytics, Clarity, Vercel Blob, Stripe. All read data bypasses /api and 149 // goes cross-origin to Supabase, so this single line is what keeps the 150 // "no data caching" guarantee true. 151 if (url.origin !== self.location.origin) return 152 153 // 3. http(s) only. chrome-extension:, blob: and data: throw on cache.put(). 154 if (url.protocol !== 'http:' && url.protocol !== 'https:') return 155 156 // 4. Range requests (audio/video seeking, Tone.js sample streaming). Caching a 157 // 200 where the caller expects a 206 breaks playback outright in Safari. 158 if (request.headers.has('range')) return 159 160 // 5. React Server Component payloads. Next 16 emits these for every client 161 // navigation and every <Link> prefetch. They are flight payloads, not HTML 162 // â caching them, or answering one with the offline page, breaks soft 163 // navigation across the whole app. 164 if (isRscRequest(request, url)) return 165 166 // 6. Explicit network-only prefixes. 167 if (NETWORK_ONLY_PREFIXES.some((prefix) => url.pathname.startsWith(prefix))) return 168 169 // 7. Immutable, content-hashed build output (JS chunks, CSS, and the 170 // self-hosted Inter .woff2 files Next emits under /_next/static/media). 171 if (url.pathname.startsWith('/_next/static/')) { 172 event.respondWith(cacheFirst(event, STATIC_CACHE)) 173 return 174 } 175 176 // 8. Images: public/ assets plus the Next image optimizer. Stale-while- 177 // revalidate rather than cache-first, because unlike /_next/static these 178 // URLs are stable while their bytes can change between deploys. 179 if ( 180 request.destination === 'image' || 181 url.pathname.startsWith('/_next/image') || 182 IMAGE_EXTENSION_RE.test(url.pathname) 183 ) { 184 event.respondWith(staleWhileRevalidate(event, IMAGES_CACHE, MAX_IMAGE_ENTRIES)) 185 return 186 } 187 188 // 9. Full document loads (address bar, hard reload, external link, PWA 189 // launch). Soft navigations never reach here â they are RSC fetches, 190 // already dropped at guard 5. 191 if (request.mode === 'navigate') { 192 event.respondWith(navigationHandler(event)) 193 return 194 } 195 196 // Anything else falls through to default browser handling, untouched. 197}) 198 199/* ------------------------------------------------------------------ */ 200/* strategies */ 201/* ------------------------------------------------------------------ */ 202 203function isRscRequest (request, url) { 204 return ( 205 url.searchParams.has('_rsc') || 206 request.headers.get('RSC') !== null || 207 request.headers.get('Next-Router-Prefetch') !== null || 208 request.headers.get('Next-Router-State-Tree') !== null 209 ) 210} 211 212async function cacheFirst (event, cacheName) { 213 const request = event.request 214 const cache = await caches.open(cacheName) 215 const cached = await cache.match(request) 216 217 if (cached !== undefined) return cached 218 219 try { 220 const response = await fetch(request) 221 222 // Only store complete, non-opaque, successful responses. type 'basic' 223 // excludes opaque cross-origin and 'opaqueredirect'; response.ok excludes 224 // 206 partials and error pages. 225 if (response.ok && response.type === 'basic') { 226 event.waitUntil(cache.put(request, response.clone())) 227 } 228 229 return response 230 } catch (error) { 231 // Cache-first MISS plus network failure. There is nothing meaningful to 232 // return for a JS chunk or a font, but we must return *a* Response: letting 233 // this reject surfaces as an opaque "Failed to fetch" with the service 234 // worker blamed in DevTools. A synthetic 504 is diagnosable. 235 return new Response('', { status: 504, statusText: 'Offline (uncached asset)' }) 236 } 237} 238 239async function staleWhileRevalidate (event, cacheName, maxEntries) { 240 const request = event.request
241 const cache = await caches.open(cacheName) 242 const cached = await cache.match(request) 243 244 const networkPromise = fetch(request) 245 .then(async (response) => { 246 if (response.ok && response.type === 'basic') { 247 await cache.put(request, response.clone()) 248 await trimCache(cacheName, maxEntries) 249 } 250 251 return response 252 }) 253 .catch(() => undefined) 254 255 if (cached !== undefined) { 256 // Keep the revalidation alive past respondWith(); otherwise the worker can 257 // be killed mid-flight and the cache never refreshes. 258 event.waitUntil(networkPromise) 259 return cached 260 } 261 262 const response = await networkPromise 263 264 if (response === undefined) { 265 return new Response('', { status: 504, statusText: 'Offline (uncached image)' }) 266 } 267 268 return response 269} 270 271async function navigationHandler (event) { 272 const request = event.request 273 const url = new URL(request.url) 274 const cache = await caches.open(PAGES_CACHE) 275 276 // Never store a shell that carries query state (?redirect=, magic-link 277 // params) or an auth/user route. 278 const isCacheablePage = ( 279 url.search === '' && 280 !NEVER_CACHE_PAGES.some((path) => url.pathname === path || url.pathname.startsWith(`${path}/`)) 281 ) 282 283 try { 284 const preloaded = await event.preloadResponse 285 const response = (preloaded !== undefined && preloaded !== null) 286 ? preloaded 287 : await fetch(request) 288 289 if (isCacheablePage && response.ok && response.type === 'basic') { 290 event.waitUntil((async () => { 291 await cache.put(request, response.clone()) 292 await trimCache(PAGES_CACHE, MAX_PAGE_ENTRIES) 293 })()) 294 } 295 296 event.waitUntil(refreshOfflineShell()) 297 298 return response 299 } catch (error) { 300 // Network-first failed: we are offline (or the origin is down). 301 // 302 // ignoreVary is required â Next serves HTML with 303 // `Vary: RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Url`, and 304 // Cache.match() honours Vary by default. PAGES_CACHE only ever holds full 305 // HTML documents (guard 5 keeps flight payloads out), so ignoring Vary is 306 // safe here. 307 const cached = isCacheablePage 308 ? await cache.match(request, { ignoreVary: true }) 309 : undefined 310 311 if (cached !== undefined) return cached 312 313 const offline = await caches.match(OFFLINE_URL, { ignoreVary: true }) 314 315 if (offline !== undefined) return offline 316 317 // Last resort: the precache failed at install time AND we are offline. 318 return new Response( 319 '<!doctype html><html lang="en"><head><meta charset="utf-8">' + 320 '<meta name="viewport" content="width=device-width, initial-scale=1">' + 321 '<title>Offline | Melodigging</title>' + 322 '<style>body{background:#000;color:#fff;font-family:system-ui,sans-serif;' + 323 'display:flex;min-height:100vh;align-items:center;justify-content:center;' + 324 'margin:0;text-align:center;padding:2rem}</style></head>' + 325 '<body><main><h1>You are offline</h1>' + 326 '<p>Reconnect to keep digging.</p></main></body></html>', 327 { status: 503, headers: { 'Content-Type': 'text/html; charset=utf-8' } } 328 ) 329 } 330} 331 332/* ------------------------------------------------------------------ */ 333/* maintenance */ 334/* ------------------------------------------------------------------ */ 335 336let offlineShellRefreshed = false 337 338/** 339 * The precached /offline document references build-specific 340 * /_next/static/chunks/* URLs. After a deploy where VERSION was NOT bumped that 341 * HTML is stale and its chunks 404 â the page still paints (the prerendered 342 * markup is in the response) but never hydrates. 343 * 344 * Refresh it opportunistically after the first successful navigation of each 345 * worker lifetime. Service workers are terminated after ~30s idle, so this flag 346 * resets naturally and the shell gets refreshed roughly once per browsing 347 * session, at the cost of one extra request. 348 */ 349async function refreshOfflineShell () { 350 if (offlineShellRefreshed) return 351 352 offlineShellRefreshed = true 353 354 try { 355 const response = await fetch(OFFLINE_URL, { cache: 'reload' }) 356 357 if (response.ok) { 358 const cache = await caches.open(STATIC_CACHE) 359 await cache.put(OFFLINE_URL, response) 360 } 361 } catch (error) { 362 offlineShellRefreshed = false 363 } 364} 365 366/** 367 * FIFO entry cap. The Cache API exposes no access timestamps, so true LRU is 368 * impossible without a side index. Cache.keys() returns entries in insertion 369 * order, and Cache.put() on an existing key deletes-then-appends â which means 370 * the stale-while-revalidate path re-inserts on every hit and this degenerates 371 * to approximate LRU for IMAGES_CACHE. PAGES_CACHE, only written on a network 372 * success, stays strictly FIFO. Both hold cheaply-refetchable content, so 373 * eviction is never a correctness issue. 374 */ 375async function trimCache (cacheName, maxEntries) { 376 const cache = await caches.open(cacheName) 377 const keys = await cache.keys() 378 379 if (keys.length <= maxEntries) return 380 381 const excess = keys.length - maxEntries 382 383 for (let index = 0; index < excess; index += 1) { 384 await cache.delete(keys[index]) 385 } 386}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.