1/** 2 * JusTheRecipe service worker. 3 * 4 * Goals: 5 * 6 * - Make the installed PWA usable when the network is dead. Saved recipes 7 * live in IndexedDB on the client, so the recipe-shell route also has to 8 * work offline; we serve a stale-while-revalidate copy of `/r/<slug>` 9 * pages we've previously seen, plus a hand-built `/offline` fallback for 10 * anything we haven't. 11 * - Cache `/_next/static/*` immutably (Next.js fingerprints filenames so 12 * cache-first is always safe). 13 * - Network-first navigation requests so we don't wedge users onto an old 14 * HTML shell when we've shipped a new build. 15 * - Stay completely off the API surface: `/api/*` never touches a cache, 16 * full stop. 17 * 18 * Bump VERSION whenever the cache shape or precache list changes; the 19 * activate handler will sweep older versions on next install. 20 */ 21 22const VERSION = 'jtr-v3'; 23const SHELL_CACHE = `${VERSION}-shell`; 24const RECIPE_CACHE = `${VERSION}-recipes`; 25const STATIC_CACHE = `${VERSION}-static`; 26const IMAGE_CACHE = `${VERSION}-images`; 27 28const OFFLINE_URL = '/offline'; 29 30// Pages worth eagerly grabbing on first install. Any route the user is 31// likely to hit on a flaky connection. Keep this list short â it has to 32// fit in the browser's preload budget on `install`. 33const SHELL_URLS = [ 34 '/', 35 '/saved', 36 '/list', 37 '/pantry', 38 '/random', 39 '/about', 40 '/manifest.webmanifest', 41 '/favicon.svg', 42 '/icons/icon-192.png', 43 '/icons/icon-512.png', 44 '/icons/icon-maskable-512.png', 45 '/apple-icon.png', 46 OFFLINE_URL, 47]; 48 49// Same-origin image hosts we proxy through `/api/og-image`. We deliberately 50// don't cache the proxy URL itself (it's under /api) but the resulting blob 51// is fine â caches are keyed on Request URL so this still works. 52const HERO_IMAGE_PATH = '/api/og-image'; 53 54self.addEventListener('install', (event) => { 55 event.waitUntil( 56 (async () => { 57 const cache = await caches.open(SHELL_CACHE); 58 // `addAll` is atomic â one failure aborts the whole install. We want 59 // best-effort behaviour so a missing /random redirect or a 404 60 // doesn't poison the install. Cache them one at a time, swallow 61 // failures. 62 await Promise.all( 63 SHELL_URLS.map(async (url) => { 64 try { 65 const response = await fetch(url, { credentials: 'same-origin' }); 66 if (response.ok || response.type === 'opaqueredirect') { 67 await cache.put(url, response); 68 } 69 } catch { 70 // Skip this URL silently; it'll be fetched on demand later. 71 } 72 }), 73 ); 74 self.skipWaiting(); 75 })(), 76 ); 77}); 78 79self.addEventListener('activate', (event) => { 80 event.waitUntil( 81 (async () => { 82 // Enable navigation preload â lets the browser kick off the network 83 // fetch in parallel with our worker boot. Material on cold-start. 84 if (self.registration.navigationPreload) { 85 try { 86 await self.registration.navigationPreload.enable(); 87 } catch { 88 // Some browsers (older Safari) don't support it. No-op. 89 } 90 } 91 const keys = await caches.keys(); 92 await Promise.all( 93 keys.filter((k) => !k.startsWith(VERSION)).map((k) => caches.delete(k)), 94 ); 95 await self.clients.claim(); 96 })(), 97 ); 98}); 99 100self.addEventListener('fetch', (event) => { 101 const req = event.request; 102 if (req.method !== 'GET') return; 103 104 const url = new URL(req.url); 105 106 // Cross-origin: leave alone. We don't proxy third-party assets â the 107 // Next.js Image component already proxies remote hero images through 108 // /_next/image which we DO handle below. 109 if (url.origin !== self.location.origin) return; 110 111 // API routes: pure passthrough. Includes Auth.js endpoints, drive sync, 112 // recipe extraction calls, and the og-image proxy. A stale auth state or 113 // recipe payload is far worse than a network error. 114 if (url.pathname.startsWith('/api/')) return; 115 116 // Hashed Next.js assets: cache-first, immutable. 117 if ( 118 url.pathname.startsWith('/_next/static/') || 119 url.pathname.startsWith('/_next/image') 120 ) { 121 event.respondWith(cacheFirst(req, STATIC_CACHE)); 122 return; 123 } 124 125 // Static brand assets we want offline. 126 if ( 127 url.pathname === '/favicon.svg' || 128 url.pathname.startsWith('/icons/') || 129 url.pathname === '/apple-icon.png' || 130 url.pathname === '/apple-touch-icon.png' || 131 url.pathname === '/manifest.webmanifest' 132 ) { 133 event.respondWith(cacheFirst(req, SHELL_CACHE)); 134 return; 135 } 136 137 // Recipe pages: stale-while-revalidate so the second view of any recipe 138 // is instant even on the slowest connection, and offline-readable so 139 // long as the user opened it once before. 140 if (url.pathname.startsWith('/r/')) { 141 event.respondWith(staleWhileRevalidate(req, RECIPE_CACHE)); 142 return; 143 } 144 145 // Everything HTML-ish: network-first with cached shell + offline fallback. 146 if (req.mode === 'navigate' || req.headers.get('accept')?.includes('text/html')) { 147 event.respondWith(navigationHandler(event)); 148 return; 149 } 150}); 151 152/** 153 * Listen for an explicit "skip waiting" message from the page so a user can 154 * tap our update toast and immediately get the new bundle without quitting 155 * the PWA. The page sends `{type:'SKIP_WAITING'}` after registration sees 156 * an updatefound + installed-and-waiting worker. 157 */ 158self.addEventListener('message', (event) => { 159 if (event.data && event.data.type === 'SKIP_WAITING') {
160 self.skipWaiting(); 161 } 162}); 163 164async function cacheFirst(req, cacheName) { 165 const cache = await caches.open(cacheName); 166 const cached = await cache.match(req); 167 if (cached) return cached; 168 try { 169 const response = await fetch(req); 170 if (response.ok) cache.put(req, response.clone()); 171 return response; 172 } catch (err) { 173 if (cached) return cached; 174 throw err; 175 } 176} 177 178async function staleWhileRevalidate(req, cacheName) { 179 const cache = await caches.open(cacheName); 180 const cached = await cache.match(req); 181 const network = fetch(req) 182 .then((res) => { 183 if (res.ok) cache.put(req, res.clone()); 184 return res; 185 }) 186 .catch(() => cached); 187 return cached || network; 188} 189 190async function navigationHandler(event) { 191 const req = event.request; 192 try { 193 // Use the navigation preload response if the platform raced a fetch 194 // for us, otherwise hit the network directly. 195 const preload = await event.preloadResponse; 196 const response = preload || (await fetch(req)); 197 if (response && response.ok) { 198 const cache = await caches.open(SHELL_CACHE); 199 cache.put(req, response.clone()); 200 } 201 return response; 202 } catch { 203 const cached = await caches.match(req); 204 if (cached) return cached; 205 const offline = await caches.match(OFFLINE_URL); 206 if (offline) return offline; 207 return new Response('Offline', { 208 status: 503, 209 headers: { 'content-type': 'text/plain' }, 210 }); 211 } 212}
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.