PageSourceSearch

https://www.melodigging.com/sw.js

js melodigging.com collected 2026-10-02 10:23:05 UTC 13,868 bytes, 386 lines download raw bytes

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.