1// Service worker for iwant2eat. 2// 3// The live app is Blazor Server, so the regular pages can't render 4// without a SignalR connection. When the network drops, the worker 5// answers the user's same-URL navigations with a cached static shell 6// at /offline-shell.html â that shell reads location.pathname and 7// renders the matching view from IndexedDB. The user's address bar 8// keeps showing /recipes/<id> etc.; no separate "offline mode" URL 9// to navigate to. When the network comes back, normal fetches succeed 10// and the live Blazor app takes over again. 11// 12// What this worker caches: 13// 1. The offline shell (HTML + JS) and its dependency /js/sync.js â 14// together they form the read-only fallback used while offline. 15// 2. The branded /offline.html (kept as a last-resort fallback if 16// the shell isn't in cache yet, e.g. very first SW install). 17// 3. App icons + manifest so install UX stays instant. 18// 19// What this worker does NOT cache: the Blazor app's hashed assets 20// (would brick the app on the next deploy), API responses, recipe 21// images by URL, or the bulk of the user's recipe data â recipe data 22// lives in IndexedDB after a sync, which is the right place for it. 23 24// Cache name. The deploy workflows rewrite the literal between the 25// SW_CACHE_NAME markers below to bake the just-published git short 26// SHA in. That gives every deploy a fresh cache key, the activate 27// handler below drops the old cache on the user's next page load, 28// and no one has to hard-refresh. Locally / in dev the literal stays 29// at 'dev' which is fine â local SW invalidation is a non-issue. 30// 31// If you ever need to manually bust the cache without a deploy 32// (emergency hotpatch from inside the deployed JS bundle), bumping 33// the literal string by hand still works; the marker comments are 34// only there to anchor the sed substitution in the workflow. 35const CACHE_NAME = 'iwant2eat-dev'; // SW_CACHE_NAME â auto-replaced at deploy with the short SHA 36const STATIC_ASSETS = [ 37 '/offline.html', 38 '/shell', 39 '/offline-shell.html', 40 '/js/offline-shell.js', 41 '/js/sync.js', 42 '/app.css', 43 '/brand/icon-192.png', 44 '/brand/icon-512.png', 45 '/site.webmanifest', 46 // /wasm-test's prerender response doubles as a "Blazor bootstrap" 47 // HTML used to revive any WASM page after an offline reload. 48 // Whatever URL the user reloads to, the SW returns this cached 49 // HTML; the browser parses it, blazor.web.js streams in (also 50 // cached under /_framework/*), the WASM runtime boots, and 51 // Blazor's router resolves the actual location.pathname to the 52 // matching component. Brief flash of /wasm-test content first, 53 // then the right page. 54 '/wasm-test', 55 // Pre-cache the WASM mirror's index page itself. Without this, the 56 // first offline navigation to /recipes (which the navigate handler 57 // 302-redirects to /offline/recipes) lands on the /wasm-test shell 58 // instead â and on iOS WebKit the prerender-vs-URL mismatch 59 // confuses Blazor's hydration enough that WASM never finishes 60 // booting (the page stays on "WASM pipeline check" with dead 61 // buttons). Caching /offline/recipes with its own prerendered 62 // HTML â already routed to the WasmRecipes component â sidesteps 63 // the whole shell-fallback path. 64 '/offline/recipes', 65 // Pre-cache the static help page so the topbar's "?" link works 66 // offline. /help is a server-rendered page but the prerender HTML 67 // is plain enough to read without an interactive circuit â good 68 // enough for an FAQ. 69 '/help', 70 // Favicons + apple-touch-icon. Browsers fetch these eagerly from 71 // the <link> tags in App.razor; without pre-cache, the first 72 // offline page load shows a blank tab icon (the SW's image handler 73 // returns the placeholder SVG, which the browser doesn't render 74 // as a tab favicon). 75 '/favicon.ico', 76 '/brand/favicon-32.png', 77 '/brand/apple-touch-icon.png', 78 // JS modules dynamically imported by MyRecipes / RecipeDetail / 79 // AddRecipe. Without pre-cache, hydrating those pages offline 80 // throws "Importing a module script failed" (the unhandled 81 // rejection that surfaced in the offline error queue) â Blazor 82 // runs `import('/js/card-drop.js')` etc., the fetch fails, and 83 // the rejection bubbles up. 84 '/js/card-drop.js', 85 '/js/add-btn-drop.js', 86 '/js/cook-tts.js', 87 '/js/cook-listen.js', 88 '/js/recipe-drop.js', 89 '/js/ing-edit-delay.js', 90 '/js/recipe-format.js', 91 '/js/sw-reload.js', 92 '/js/paste-handler.js', 93 // Pre-cache iwe-offline.js + error-reporter.js so a CACHE_NAME 94 // bump forces the install handler to fetch the fresh bytes. 95 // Without this, the cache-first static-asset handler keeps 96 // serving whatever version was cached on first hit, and the only 97 // way to dislodge it is the user manually revisiting online â 98 // which on an iPhone PWA can stay stuck for surprisingly long. 99 // Including them in STATIC_ASSETS ties their refresh to the SW 100 // version: bumping CACHE_NAME guarantees fresh JS on next install. 101 '/js/iwe-offline.js', 102 '/js/error-reporter.js', 103 // Self-hosted Caveat handwriting font for the recipe-detail 104 // original-text pane. Pre-cached so the offline render gets the 105 // proper handwriting face instead of falling back to Comic Sans 106 // when Google Fonts is unreachable. 107 '/fonts/caveat/caveat.css', 108 '/fonts/caveat/caveat-latin.woff2', 109 '/fonts/caveat/caveat-latin-ext.woff2', 110 '/fonts/caveat/caveat-cyrillic.woff2', 111 '/fonts/caveat/caveat-cyrillic-ext.woff2', 112 // Self-hosted Lora â body serif for the recipe step / ingredient 113 // lists. Same offline-availability rationale as Caveat above. 114 '/fonts/lora/lora.css', 115 '/fonts/lora/lora-latin-normal-400.woff2', 116 '/fonts/lora/lora-latin-italic-400.woff2', 117 '/fonts/lora/lora-latin-ext-normal-400.woff2', 118 '/fonts/lora/lora-latin-ext-italic-400.woff2', 119 '/fonts/lora/lora-cyrillic-normal-400.woff2', 120 '/fonts/lora/lora-cyrillic-italic-400.woff2', 121 '/fonts/lora/lora-cyrillic-ext-normal-400.woff2', 122 '/fonts/lora/lora-cyrillic-ext-italic-400.woff2', 123]; 124 125// Dev/stage fake-offline hook. Paired with /js/dev-fake-offline.js's 126// floating toggle on the page side. When the page postMessages this 127// SW with { type: 'iwe-fake-offline', active: true }, every internal 128// fetch() call inside the SW's handlers below rejects with a synthetic 129// network error. The handlers' existing offline-fallback chains then 130// run unmodified â cache match, mirror-redirect, /wasm-test fallback, 131// hardcoded HTML â so the user sees the EXACT same behaviour they'd 132// get from real airplane mode without flipping wifi off. 133// 134// Persisted in IDB so SW restarts (idle timeout) pick the flag back up. 135// Both the message listener and the IDB load are gated to dev/stage 136// hosts; on prod the SW pays no per-fetch cost and never enters the 137// fake-offline branch. 138const _isDevOrStageHost = (() => { 139 const h = self.location.hostname.toLowerCase(); 140 return h === 'localhost' || h === '127.0.0.1' || h === '::1' 141 || h.startsWith('stage.') || h.endsWith('.trycloudflare.com'); 142})(); 143let _fakeOffline = false;
144let _fakeOfflineLoaded = false; 145const _DEV_FLAGS_DB = 'iwe-dev-flags'; 146const _DEV_FLAG_KEY = 'iwe-fake-offline'; 147 148function _openDevFlagsDb() { 149 return new Promise((resolve, reject) => { 150 const req = indexedDB.open(_DEV_FLAGS_DB, 1); 151 req.onupgradeneeded = () => { 152 req.result.createObjectStore('flags', { keyPath: 'k' }); 153 }; 154 req.onsuccess = () => resolve(req.result); 155 req.onerror = () => reject(req.error); 156 }); 157} 158async function _loadFakeOfflineFlag() { 159 if (!_isDevOrStageHost) return false; 160 try { 161 const db = await _openDevFlagsDb(); 162 try { 163 return await new Promise((resolve) => { 164 const tx = db.transaction('flags', 'readonly'); 165 const r = tx.objectStore('flags').get(_DEV_FLAG_KEY); 166 r.onsuccess = () => resolve(!!(r.result && r.result.v === true)); 167 r.onerror = () => resolve(false); 168 }); 169 } finally { try { db.close(); } catch (_) {} } 170 } catch (_) { return false; } 171} 172async function _saveFakeOfflineFlag(v) { 173 if (!_isDevOrStageHost) return; 174 try { 175 const db = await _openDevFlagsDb(); 176 try { 177 await new Promise((resolve) => { 178 const tx = db.transaction('flags', 'readwrite'); 179 tx.objectStore('flags').put({ k: _DEV_FLAG_KEY, v: !!v }); 180 tx.oncomplete = () => resolve(); 181 tx.onerror = () => resolve(); 182 tx.onabort = () => resolve(); 183 }); 184 } finally { try { db.close(); } catch (_) {} } 185 } catch (_) { /* swallow */ } 186} 187 188self.addEventListener('message', (e) => { 189 if (e.data && e.data.type === 'iwe-session-lost') { 190 // The page discovered the session is gone (a 401 from /api/sync/*). 191 // Every cached NAVIGATION was captured while signed in and has the 192 // user's identity baked into the HTML â the <meta name="iwe-user-id"> 193 // that iwe-offline.js turns into an avatar, the initials in the 194 // topbar. Serving those to a now-anonymous browser is what made an 195 // expired session look like a working one: the chrome said "signed 196 // in as YS" while every API call underneath it 401'd, and there was 197 // no Sign In button anywhere because the cached page didn't have one. 198 // 199 // Drop only the HTML. Framework, brand and image entries carry no 200 // identity and are expensive to refetch, so they stay. 201 e.waitUntil((async () => { 202 try { 203 const names = await caches.keys(); 204 for (const name of names) { 205 const c = await caches.open(name); 206 for (const req of await c.keys()) { 207 if (req.mode === 'navigate') { await c.delete(req); continue; } 208 const p = new URL(req.url).pathname; 209 if (/^\/(offline\/recipes|recipes|wasm-test|shell)/i.test(p) 210 || p.startsWith('/avatar/')) { 211 await c.delete(req); 212 } 213 } 214 } 215 swTrace('session-lost: identity-bearing cache entries dropped'); 216 } catch (_) { /* best-effort */ } 217 })()); 218 return; 219 } 220 if (!e.data || e.data.type !== 'iwe-fake-offline') return; 221 if (!_isDevOrStageHost) return; 222 _fakeOffline = !!e.data.active; 223 _fakeOfflineLoaded = true; 224 _saveFakeOfflineFlag(_fakeOffline); 225}); 226 227// Wrap the global fetch so every internal call from the handlers below 228// (cache pre-warm, network-first shell, navigation passthrough) goes 229// through the fake-offline gate. Synchronous-looking from the caller â 230// only the first call after a SW restart pays the IDB read. 231// Synced-thumbnail IDB lookup. The offline-sync flow (sync.js) 232// downloads recipe thumbnails into the same DB it uses for recipe 233// records â `iwe2eat-offline-{hostname}`, store name `thumbs`, 234// keyPath `id`, value shape `{ id, blob, mime }`. When a /recipe- 235// images/{guid} fetch fails offline, the SW image handler calls this 236// to pull the blob straight out of IDB so the offline grid renders 237// real photos instead of placeholders. 238// 239// Read-only, never writes. Returns null on any failure path so the 240// caller can fall through to the placeholder SVG. 241// Synced-avatar IDB lookup. Mirror of readSyncedThumbBlob, but for the 242// user's profile photo. sync.js stores the avatar blob in the meta 243// store under key='avatar' alongside { userId, blob, mime }. On 244// /avatar/{userId} fetch failure (real offline / fake-offline / SW
245// cache miss after a CACHE_NAME bump) the image handler pulls it 246// straight from IDB so the topbar avatar keeps rendering. 247function readSyncedAvatarBlob(userId) { 248 return new Promise((resolve) => { 249 try { 250 const dbName = 'iwe2eat-offline-' + (self.location.hostname || 'unknown'); 251 const req = indexedDB.open(dbName); 252 req.onsuccess = () => { 253 let db; 254 try { 255 db = req.result; 256 if (!db.objectStoreNames.contains('meta')) { 257 try { db.close(); } catch (_) {} 258 return resolve(null); 259 } 260 const tx = db.transaction('meta', 'readonly'); 261 const r = tx.objectStore('meta').get('avatar'); 262 r.onsuccess = () => { 263 try { db.close(); } catch (_) {} 264 const rec = r.result && r.result.value; 265 if (rec && rec.blob && (!userId || rec.userId === userId)) { 266 resolve(rec.blob); 267 } else { 268 resolve(null); 269 } 270 }; 271 r.onerror = () => { 272 try { db.close(); } catch (_) {} 273 resolve(null); 274 }; 275 } catch (_) { 276 try { if (db) db.close(); } catch (__) {} 277 resolve(null); 278 } 279 }; 280 req.onerror = () => resolve(null); 281 req.onblocked = () => resolve(null); 282 setTimeout(() => resolve(null), 1500); 283 } catch (_) { 284 resolve(null); 285 } 286 }); 287} 288 289function readSyncedThumbBlob(thumbId) { 290 return new Promise((resolve) => { 291 try { 292 const dbName = 'iwe2eat-offline-' + (self.location.hostname || 'unknown'); 293 const req = indexedDB.open(dbName); 294 req.onsuccess = () => { 295 let db; 296 try { 297 db = req.result; 298 if (!db.objectStoreNames.contains('thumbs')) { 299 try { db.close(); } catch (_) {} 300 return resolve(null); 301 } 302 const tx = db.transaction('thumbs', 'readonly'); 303 const r = tx.objectStore('thumbs').get(thumbId); 304 r.onsuccess = () => { 305 try { db.close(); } catch (_) {} 306 const rec = r.result; 307 resolve(rec && rec.blob ? rec.blob : null); 308 }; 309 r.onerror = () => { 310 try { db.close(); } catch (_) {} 311 resolve(null); 312 }; 313 } catch (_) { 314 try { if (db) db.close(); } catch (__) {} 315 resolve(null); 316 } 317 }; 318 req.onerror = () => resolve(null); 319 req.onblocked = () => resolve(null); 320 // Watchdog â IDB has been observed to silently never fire 321 // onsuccess on iOS PWAs. Don't hang the image fetch on it. 322 setTimeout(() => resolve(null), 1500); 323 } catch (_) { 324 resolve(null); 325 } 326 }); 327} 328 329const _origSwFetch = self.fetch.bind(self); 330self.fetch = async function () { 331 if (_isDevOrStageHost) { 332 if (!_fakeOfflineLoaded) { 333 _fakeOffline = await _loadFakeOfflineFlag(); 334 _fakeOfflineLoaded = true; 335 } 336 if (_fakeOffline) { 337 throw new TypeError('fake-offline (dev tool)'); 338 } 339 } 340 return _origSwFetch.apply(self, arguments); 341}; 342 343self.addEventListener('install', (event) => { 344 // Cache each asset individually so a single 404 / network blip 345 // doesn't abort the entire SW install (cache.addAll rejects on the 346 // first failure, which would leave the SW unactivated and the user 347 // with a stale or absent worker â exactly the kind of "PWA can't 348 // start offline" failure mode this whole feature is trying to 349 // prevent). 350 // 351 // We ALSO walk /_framework/blazor.boot.json and pre-cache every 352 // runtime asset listed there. Without this, a user who's never 353 // hit a WASM page online ends up offline-stuck on the wasm-test 354 // bootstrap because the dotnet.* runtime files are missing â and 355 // the only thing they see is the prerendered "Server" mode of 356 // wasm-test instead of the WasmRecipe component they wanted. 357 // Doing it at SW install time means every deploy re-warms the
358 // runtime cache automatically, no dependence on the user visiting 359 // a particular page online first. 360 event.waitUntil((async () => { 361 const cache = await caches.open(CACHE_NAME); 362 for (const url of STATIC_ASSETS) { 363 try { await cache.add(url); } 364 catch (e) { console.warn('SW: failed to cache', url, e); } 365 } 366 367 // Walk every cached HTML page and pre-cache the assets it 368 // references. This is the ONLY way to get fingerprinted 369 // framework assets like /_framework/blazor.web.<hash>.js into 370 // the cache: blazor.boot.json doesn't list them, Razor's 371 // @Assets[] generates the hashed URL on the fly, and the 372 // browser never fetches them online before the install runs. 373 // Without this step, the user lands on /offline/recipes 374 // offline, the cached HTML references blazor.web.<hash>.js, 375 // the SW cache misses, fetch fails offline, WASM never boots, 376 // and the bootstrap pivot to <WasmRecipe> never happens. 377 const HTML_PAGES_TO_PARSE = ['/offline/recipes', '/wasm-test', '/help', '/shell']; 378 for (const page of HTML_PAGES_TO_PARSE) { 379 try { 380 const cached = await cache.match(page); 381 if (!cached) continue; 382 const html = await cached.text(); 383 // Catch src= and href= references that point at 384 // /_framework/* OR same-origin .js / .css / .woff2 / 385 // .wasm assets. Both http(s)://...domain... and root- 386 // relative forms are possible. 387 const matches = [ 388 ...html.matchAll(/(?:src|href)=["']([^"']+)["']/g) 389 ].map(m => m[1]).filter(href => { 390 // Skip external URLs entirely. 391 if (/^https?:/i.test(href)) return false; 392 if (/^\/\//.test(href)) return false; 393 // Catch /_framework/* and *.js/css/woff/wasm/json 394 // with or without leading slash. Razor sometimes 395 // outputs relative paths (no leading slash) for 396 // fingerprinted assets â that's why the previous 397 // ^\/_framework regex missed blazor.web.<hash>.js. 398 return /(^\/?_framework\/)/.test(href) 399 || /\.(?:js|mjs|css|woff2?|wasm|json)(?:\?[^"']*)?$/i.test(href); 400 }).map(href => { 401 // Normalise to root-relative so cache keys are 402 // consistent regardless of how Razor rendered them. 403 if (!href.startsWith('/')) return '/' + href; 404 return href; 405 }); 406 // ALSO parse <script type="importmap">â¦</script>. .NET 10 407 // Blazor's <ImportMap /> emits one of these mapping 408 // unfingerprinted module names to fingerprinted URLs 409 // ('_framework/dotnet.js' â '_framework/dotnet.<hash>.js' 410 // etc). Those URLs are JSON values, NOT src=/href=, so 411 // the regex above misses them â and they're exactly the 412 // assets blazor.web.js needs to boot WASM. Without them 413 // cached, WASM never starts offline. 414 try { 415 const importMapMatch = html.match( 416 /<script[^>]*type=["']importmap["'][^>]*>([\s\S]*?)<\/script>/i); 417 if (importMapMatch) { 418 const map = JSON.parse(importMapMatch[1]); 419 const collect = (obj) => { 420 if (!obj || typeof obj !== 'object') return; 421 Object.values(obj).forEach(v => { 422 if (typeof v === 'string') { 423 let u = v; 424 if (!u.startsWith('/') && !/^https?:/.test(u)) u = '/' + u; 425 if (/^\//.test(u)) matches.push(u); 426 } else if (typeof v === 'object') { 427 collect(v); // scopes 428 } 429 }); 430 }; 431 collect(map.imports); 432 collect(map.scopes); 433 } 434 } catch (_) { /* importmap parse failed, skip */ } 435 const urls = [...new Set(matches)]; 436 // Throttle so we don't hammer Safari's parallel- 437 // connection cap. 438 for (let i = 0; i < urls.length; i += 4) { 439 const batch = urls.slice(i, i + 4); 440 await Promise.all(batch.map(u => 441 cache.add(u).catch(() => { /* per-asset failures swallowed */ }) 442 )); 443 } 444 } catch (_) { /* page not parseable, skip */ } 445 } 446 447 // .NET 10 Blazor doesn't ship blazor.boot.json anymore â the 448 // boot config is fetched dynamically by dotnet.js and the 449 // app DLLs aren't enumerated anywhere the SW can read at 450 // install time. Workaround: server exposes a manifest at 451 // /api/sw-framework-manifest that lists every registered 452 // /_framework/* route, fingerprint and all. Fetch + walk that 453 // list to populate the cache fully. 454 try { 455 const fwResp = await fetch('/api/sw-framework-manifest', 456 { cache: 'no-store' }); 457 if (fwResp && fwResp.ok) { 458 const fwUrls = await fwResp.json(); 459 if (Array.isArray(fwUrls) && fwUrls.length) { 460 for (let i = 0; i < fwUrls.length; i += 6) { 461 const batch = fwUrls.slice(i, i + 6); 462 await Promise.all(batch.map(u => 463 cache.add(u).catch(() => { /* swallow per-asset */ }) 464 )); 465 } 466 } 467 } 468 } catch (_) { /* manifest unavailable, fall through */ } 469 470 try { 471 const boot = await fetch('/_framework/blazor.boot.json', 472 { cache: 'no-store' }).then(r => r.ok ? r.json() : null); 473 if (boot && boot.resources) { 474 const urls = []; 475 ['assembly', 'coreAssembly', 'runtime', 'wasmNative', 'runtimeAssets'] 476 .forEach(k => { 477 const group = boot.resources[k]; 478 if (!group) return;
479 Object.keys(group).forEach(name => { 480 urls.push('/_framework/' + name); 481 }); 482 }); 483 // Throttle to avoid hammering Safari's parallel-request 484 // limit. SW install runs once per deploy; latency here 485 // doesn't affect any in-flight request. 486 for (let i = 0; i < urls.length; i += 4) { 487 const batch = urls.slice(i, i + 4); 488 await Promise.all(batch.map(u => 489 cache.add(u).catch(() => { /* swallow per-asset failures */ }) 490 )); 491 } 492 } 493 } catch (_) { /* swallow â sync didn't preload, runtime loads on first use */ } 494 })()); 495 self.skipWaiting(); 496}); 497 498self.addEventListener('activate', (event) => { 499 event.waitUntil((async () => { 500 // Drop every cache from prior deploys. The new SW's 501 // CACHE_NAME (build-SHA-stamped at deploy time) is the only 502 // one we keep. 503 const keys = await caches.keys(); 504 await Promise.all( 505 keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)) 506 ); 507 508 // Take over open clients (tabs + PWA) immediately so the next 509 // navigation goes through this SW's cache, not the previous 510 // one's. 511 await self.clients.claim(); 512 513 // PWA-update notification. iOS standalone PWAs hang on to the 514 // JS that was loaded when the page first opened â closing and 515 // reopening the PWA often restores the session without a real 516 // navigation, so the new SW activates but the page keeps 517 // executing the OLD JS bytes. Result: bug fixes shipped via 518 // /js/* stay invisible until the user manually deletes and 519 // re-adds the PWA, which we hit hard during the inline-qty 520 // dwell-fix iterations. 521 // 522 // Fix: tell every client a new SW just took over, with the 523 // expectation that the client-side script /js/sw-reload.js 524 // turns that message into a location.reload(). Reload only 525 // fires when CACHE_NAME actually changed (which it does on 526 // every deploy thanks to the build-SHA bake-in), so we don't 527 // loop. One disruptive reload per deploy is the cost of 528 // having every PWA / open tab automatically pick up new 529 // code without manual intervention. 530 try { 531 const cs = await self.clients.matchAll({ includeUncontrolled: true }); 532 for (const client of cs) { 533 client.postMessage({ type: 'iwe-sw-activated', cacheName: CACHE_NAME }); 534 } 535 } catch (_) { /* swallow â postMessage is best-effort */ } 536 })()); 537}); 538 539// When the SW returns a cached fallback Response under a DIFFERENT 540// URL than the one originally cached for (e.g. serving /shell as the 541// fallback for a navigate to /recipes), the browser's HTTP cache 542// would happily store the response under the requested URL with 543// whatever Cache-Control the original shell had â including the long 544// max-age that's appropriate for /shell itself but not at all for 545// /recipes. Strip headers and force no-store before returning so the 546// browser HTTP cache leaves the URL alone, only the SW cache holds 547// the actual content. 548// Script injected into every offline-served HTML response. Captures 549// every click on the page BEFORE Blazor's enhanced-nav listener fires, 550// and forces a hard browser navigation for: 551// 1. <div data-recipe-id="..."> â the legacy Blazor @onclick cards 552// that can't fire their handlers without a live SignalR circuit. 553// We synthesise a /recipes/{id} navigation from the data attr. 554// 2. Any same-origin <a href> click â Blazor's enhanced-nav uses 555// fetch() which falls through SW's "network only" branch and 556// silently fails offline; window.location.href routes through 557// the SW's navigation handler instead, which has the redirect- 558// to-/offline/* fallback. 559// Capture phase + stopImmediatePropagation ensures we win even when 560// cached HTML predates anything else we ship. 561// Build the shim with the current SW version baked in so the on-screen 562// diagnostic panel can show the user which SW served the page. 563function buildOfflineNavShim(servedBy) { 564 return `<script>(function(){ 565 if (window.__iweOfflineNav) return; 566 window.__iweOfflineNav = true; 567 568 // CRITICAL: close Blazor's <dialog id="components-reconnect-modal"> 569 // when it opens. A modal <dialog> enters the browser top layer and 570 // captures every pointer event regardless of CSS â display:none 571 // does NOT remove it from top layer, only .close() does. Without 572 // this fix, every offline tap landed on the invisible modal 573 // backdrop and the page felt dead. Diagnosed via USB DevTools. 574 // Watches the DOM in three layers: existing element + new mounts + 575 // periodic poll, because Blazor mounts/opens the dialog at slightly 576 // unpredictable times depending on circuit state. 577 (function watchReconnectDialog(){ 578 function tryClose(){ 579 try { 580 var d = document.getElementById('components-reconnect-modal'); 581 if (d && typeof d.close === 'function' && d.open
581) d.close(); 582 } catch(_) {} 583 } 584 function attach(d){ 585 if (!d || d.__iweCloseObserver) return; 586 d.__iweCloseObserver = true; 587 tryClose(); 588 try { 589 var ao = new MutationObserver(tryClose); 590 ao.observe(d, { attributes: true, attributeFilter: ['open'] }); 591 } catch(_) {} 592 } 593 attach(document.getElementById('components-reconnect-modal')); 594 try { 595 var bo = new MutationObserver(function(){ 596 attach(document.getElementById('components-reconnect-modal')); 597 }); 598 if (document.body) { 599 bo.observe(document.body, { childList: true, subtree: true }); 600 } else { 601 document.addEventListener('DOMContentLoaded', function(){ 602 attach(document.getElementById('components-reconnect-modal')); 603 bo.observe(document.body, { childList: true, subtree: true }); 604 }); 605 } 606 } catch(_) {} 607 setInterval(tryClose, 500); 608 })(); 609 610 // No-op dlog so the click-handler / error-listener code below stays 611 // simple. App.razor's inline script owns the diagnostic panel and 612 // gates it behind ?iwedebug=1 / localStorage.iweDebug â we don't 613 // duplicate it here. 614 function dlog() {} 615 616 document.addEventListener('click', function(e){ 617 try { 618 var t = e.target; 619 var card = t && t.closest && t.closest('[data-recipe-id]'); 620 if (card) { 621 var id = card.getAttribute('data-recipe-id'); 622 if (id) { 623 e.preventDefault(); e.stopImmediatePropagation(); 624 window.location.href = '/recipes/' + id; 625 return; 626 } 627 } 628 var a = t && t.closest && t.closest('a[href]'); 629 if (!a) return; 630 var h = a.getAttribute('href'); 631 if (!h || h.charAt(0) === '#' || /^javascript:/i.test(h)) return; 632 if (a.target && a.target !== '' && a.target !== '_self') return; 633 try { 634 var u = new URL(h, location.origin); 635 if (u.origin !== location.origin) return; 636 } catch (_) { return; } 637 e.preventDefault(); e.stopImmediatePropagation(); 638 window.location.href = h; 639 } catch (err) {} 640 }, true); 641 642 window.addEventListener('error', function(e){ 643 try { dlog('window.error: ' + (e.message || e.error)); } catch(_) {} 644 }); 645 window.addEventListener('unhandledrejection', function(e){ 646 try { dlog('unhandledrejection: ' + (e.reason && e.reason.message ? e.reason.message : e.reason)); } catch(_) {} 647 }); 648})();<\/script>`; 649} 650 651async function injectOfflineNavShim(response, servedBy, rewriteToPath) { 652 if (!response) return response; 653 const ct = response.headers.get('content-type') || ''; 654 if (!ct.includes('text/html')) return response; 655 try { 656 let text = await response.clone().text(); 657 const shim = buildOfflineNavShim(servedBy || 'unknown'); 658 const idx = text.lastIndexOf('</body>'); 659 text = idx >= 0 660 ? text.slice(0, idx) + shim + text.slice(idx) 661 : text + shim; 662 // When the SW serves cached /offline/recipes content under a 663 // different URL (e.g. the /recipes/{guid} we couldn't redirect 664 // to without iOS Safari PWA dropping the response), tag the 665 // body with the canonical /offline/* path. The inline App.razor 666 // script reads data-iwe-rewrite-to on every page load and does 667 // a replaceState BEFORE Blazor boots, so the WASM router sees 668 // the right URL and routes to WasmRecipe instead of falling 669 // through to NotFound. 670 if (rewriteToPath) { 671 const safePath = rewriteToPath 672 .replace(/&/g, '&') 673 .replace(/"/g, '"'); 674 text = text.replace(/<body(\s|>)/, '<body data-iwe-rewrite-to="' + safePath + '"$1'); 675 } 676 const headers = new Headers(response.headers); 677 headers.set('X-Iwe-Sw-Served-By', servedBy || 'unknown'); 678 headers.set('X-Iwe-Sw-Cache', CACHE_NAME); 679 return new Response(text, { 680 status: response.status, 681 statusText: response.statusText, 682 headers: headers, 683 }); 684 } catch (_) { 685 return response; 686 } 687} 688 689// Hard-coded last-resort offline page. Used when every cache layer 690// has missed (cold install + airplane mode at the same time, or 691// some catastrophic cache eviction). Always renders SOMETHING with
692// a working button to navigate to the WASM grid â never lets the 693// browser fall through to Safari's "Can't open this page" error. 694// 695// Static HTML â does NOT depend on Blazor, _framework, or any 696// other cached asset. Pure offline read of recipe data isn't 697// available from this page; it's just a graceful escape hatch. 698const HARDCODED_OFFLINE_HTML = `<!doctype html> 699<html lang="en"><head> 700<meta charset="utf-8"> 701<meta name="viewport" content="width=device-width, initial-scale=1.0"> 702<title>Offline â iwant2eat</title> 703<style> 704body { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; 705 max-width: 480px; margin: 0 auto; padding: 2rem 1rem; 706 background: #fdfaf0; color: #2c2c2c; } 707h1 { color: #5a9e2f; font-size: 1.4rem; margin: 0 0 0.5rem; } 708p { line-height: 1.5; } 709.iwe-banner { background: #fbebe9; color: #7a2720; 710 border-radius: 6px; padding: 0.6rem 0.9rem; 711 margin: 0 0 1rem; font-size: 0.9rem; } 712a { display: inline-block; background: #5a9e2f; color: #fff; 713 padding: 0.6rem 1.2rem; border-radius: 6px; 714 text-decoration: none; font-weight: 600; 715 margin-top: 1rem; } 716a:hover { background: #4a8525; } 717small { color: #777; } 718</style> 719</head><body> 720<div class="iwe-banner"><strong>Offline.</strong> The page you tried to open isn't cached on this device.</div> 721<h1>iwant2eat</h1> 722<p>You're offline and this URL hasn't been visited online before, so the app can't render it from cache.</p> 723<p>From the recipe grid you can still browse anything that synced down to this device.</p> 724<a href="/offline/recipes">Go to my recipes</a> 725<p><small>Reconnect to the internet to use the full app.</small></p> 726</body></html>`; 727 728// Trace stub. The on-page diagnostic panel that consumed these 729// broadcasts has been removed (deferred-email tap reports replace 730// it for ET users). Calls inline through the navigate handler are 731// kept so we can re-add tracing later without touching every site; 732// for now this is a no-op. 733function swTrace(_msg) { /* no-op */ } 734 735// JS-based redirect alternative to Response.redirect(). iOS Safari 736// standalone PWAs have a long-standing bug where a Response.redirect() 737// returned from a service worker in offline mode is silently dropped 738// â the browser shows a blank page or stays on the previous one with 739// no error. A 200 response carrying a tiny script that calls 740// location.replace() (with a <meta refresh> backstop) sidesteps the 741// bug entirely: the browser sees a successful navigation, parses the 742// HTML, and the script issues a fresh navigation that the SW 743// intercepts as normal. The intermediate paint is invisible â the 744// page has no body content and the redirect fires synchronously 745// during HTML parse. 746function jsRedirectResponse(target) { 747 const safe = String(target) 748 .replace(/&/g, '&') 749 .replace(/</g, '<') 750 .replace(/>/g, '>') 751 .replace(/"/g, '"') 752 .replace(/'/g, '''); 753 const html = '<!doctype html><html><head>' 754 + '<meta charset="utf-8">' 755 + '<meta http-equiv="refresh" content="0; url=' + safe + '">' 756 + '<title>â¦</title>' 757 + '<script>location.replace(' + JSON.stringify(target) + ');</script>' 758 + '</head><body></body></html>'; 759 return new Response(html, { 760 status: 200, 761 headers: { 762 'Content-Type': 'text/html; charset=utf-8', 763 'Cache-Control': 'no-store', 764 }, 765 }); 766} 767 768function withNoStore(response) { 769 if (!response) return response; 770 const headers = new Headers(response.headers); 771 headers.set('Cache-Control', 'no-store'); 772 headers.delete('Pragma'); 773 headers.delete('Expires'); 774 return response.clone().body 775 ? new Response(response.clone().body, { 776 status: response.status, statusText: response.statusText, headers, 777 }) 778 : response; 779} 780 781// Match URLs whose response should come from cache when present (and be 782// silently cached on first network success). The offline shell + sync.js 783// + offline-shell.js need to work without the network, so they're 784// cache-first. 785function isShellAsset(url) { 786 return url.pathname === '/shell' 787 || url.pathname === '/offline-shell.html' 788 || url.pathname === '/js/offline-shell.js' 789 || url.pathname === '/js/sync.js' 790 || url.pathname === '/app.css'; 791} 792 793// Blazor framework assets: blazor.web.js, dotnet.*.wasm, the runtime 794// bundle, and the IWant2Eat.Web.Client DLL/wasm. URLs include a content 795// hash so they're effectively immutable â perfect for cache-first. 796// This is what makes a WASM-mode page reload while offline. 797function isFrameworkAsset(url) { 798 return url.pathname.startsWith('/_framework/'); 799} 800 801// Images we want to keep available offline so cards and the topbar
802// don't show broken/placeholder boxes. /recipe-images/{guid} are 803// per-recipe thumbs; /brand/* are app-wide logos and icons. Cache- 804// first with offline fallback to whatever's cached, plus a placeholder 805// transparent SVG so a missing image doesn't show a broken-image icon. 806function isCacheableImage(url) { 807 return url.pathname.startsWith('/recipe-images/') 808 || url.pathname.startsWith('/brand/') 809 || url.pathname.startsWith('/avatar/') 810 || url.pathname === '/favicon.ico'; 811} 812 813// Static asset extensions worth caching automatically. Razor's 814// MapStaticAssets adds version-hashed URLs for CSS / JS / fonts which 815// otherwise wouldn't match anything in our hand-curated STATIC_ASSETS 816// list. Without this, an offline cold-start references hashed asset 817// URLs that aren't in cache and the page renders unstyled. 818function isStaticAsset(url) { 819 if (url.origin !== self.location.origin) return false; 820 return /\.(css|js|mjs|woff2?|ttf|otf|png|jpe?g|gif|svg|webp|ico|map|webmanifest|json)$/i 821 .test(url.pathname); 822} 823 824const PLACEHOLDER_SVG = 825 '<svg xmlns="http://www.w3.org/2000/svg" width="160" height="160">' + 826 '<rect width="100%" height="100%" fill="#f1ece4"/>' + 827 '<text x="50%" y="50%" font-family="sans-serif" font-size="14" fill="#aaa" ' + 828 'text-anchor="middle" dominant-baseline="middle">offline</text></svg>'; 829 830// Routes whose components live in the WASM client and should boot 831// from the cached Blazor-bootstrap HTML when offline. The live 832// /recipes and /recipes/{guid} are server-rendered; the WASM 833// mirrors live at /offline/recipes and /offline/recipes/{guid}. 834// On offline navigation we rewrite the live URL to its /offline/* 835// twin via offlineMirrorPath() below. 836function isWasmRoute(pathname) { 837 if (pathname === '/wasm-test') return true; 838 if (/^\/offline\/recipes\/?$/i.test(pathname)) return true; 839 return /^\/offline\/recipes\/[0-9a-f-]{36}\/?$/i.test(pathname); 840} 841 842// Map a "live" recipe URL to the offline-mirror URL the WASM client 843// is registered at. Returns null when the path has no offline mirror 844// (e.g. /recipes/new, /recipes/import â those need the server and 845// are simply unreachable offline). Reserved-segment guard mirrors 846// the live route map so a path like /recipes/manage/foo can't 847// accidentally collide with the {guid} branch. 848function offlineMirrorPath(pathname) { 849 if (/^\/recipes\/?$/i.test(pathname)) return '/offline/recipes'; 850 if (/^\/recipes\/(manage|new|import|trash|edit)/i.test(pathname)) return null; 851 const m = pathname.match(/^\/recipes\/([0-9a-f-]{36})\/?$/i); 852 if (m) return `/offline/recipes/${m[1]}`; 853 return null; 854} 855 856self.addEventListener('fetch', (event) => { 857 const req = event.request; 858 859 if (req.method !== 'GET') return; 860 861 const url = new URL(req.url); 862 863 // Auth paths bypass the service worker entirely â they must al
863ways 864 // reach the network natively. The full OAuth round trip touches 865 // multiple URLs that all need to dodge the SW: 866 // 867 // 1. /Account/ExternalLogin?provider=X â Phase 1 challenge that 868 // returns a 302 to the provider's domain (cross-origin). 869 // 2. /signin-google, /signin-facebook, /signin-apple â the 870 // provider callback paths registered by AddGoogle / AddFacebook 871 // / AddOpenIdConnect. Each is hit by the BROWSER coming back 872 // from the OAuth provider with ?code=...&state=...; ASP.NET 873 // Core's auth middleware processes them, sets the external 874 // cookie, and returns a 302 to /Account/ExternalLogin?handler= 875 // callback. Even though that 302 is same-origin, navigation 876 // requests run with redirect mode 'manual' by default â so 877 // the SW's `fetch(req)` sees the 302 as a Response with 878 // status === 0 and type === 'opaqueredirect'. 879 // 3. Phase 2: /Account/ExternalLogin?handler=callback again. 880 // 4. /set-lang?code=â¦&returnUrl=⦠â the anonymous footer 881 // language picker. Same opaqueredirect-misclassified-as- 882 // offline trap: SW saw the 302 to "/" as status=0, ran the 883 // offline-fallback chain, served /offline/recipes HTML, and 884 // the visitor wound up at /offline/recipes?code=ru&returnUrl=%2F 885 // with no cookie set. 886 // 887 // The navigation handler's offline-failure heuristic further down 888 // (`!res || res.type === 'error' || res.status === 0`) was 889 // misclassifying every status=0 opaque-redirect as "offline" and 890 // running the offline-fallback chain, which served the cached 891 // /offline/recipes HTML and rewrote the URL via 892 // injectOfflineNavShim. The auth middleware never got to run, no 893 // cookie was set, and the user ended up at /offline/recipes (or 894 // /recipes after secondary client-side routing) with code+state 895 // still in the query string â landing as anonymous. 896 // 897 // Skipping the SW for /Account/*, /signin-*, AND /set-lang means 898 // the browser handles every link in those chains exactly like any 899 // other plain HTTP navigation. These endpoints have no offline 900 // equivalent and should never be cached anyway, so this is also 901 // semantically right. 902 if (/^\/Account\//i.test(url.pathname) 903 || /^\/signin-/i.test(url.pathname) 904 || url.pathname === '/set-lang') { 905 return; 906 } 907 908 // Network-first for shell assets: when online, always fetch fresh, 909 // so a deploy that changes /js/sync.js or the shell HTML propagates 910 // to existing PWAs on the very next request â no waiting for the 911 // user to install a new SW. Falls back to cache only when the 912 // network is actually unreachable (the offline use case the SW 913 // exists for). 914 // Cache-first for /_framework/* â Blazor's hashed runtime assets 915 // never change content under a given URL, so once we've fetched 916 // them they're free to serve from cache forever. This is what 917 // unlocks the InteractiveAuto promise: a WASM-mode page reloads 918 // and runs identically online or offline, no SignalR required. 919 if (isFrameworkAsset(url)) { 920 event.respondWith( 921 caches.match(req).then((cached) => { 922 if (cached) return cached; 923 // Bypass the dev fake-offline override here â Blazor 924 // framework assets are infrastructure, not user- 925 // perceivable traffic. After a CACHE_NAME bump the new 926 // hashed URLs aren't pre-cached, so blocking the real 927 // fetch would leave the page unable to boot. The dev 928 // tool still blocks navigation, API, and image fetches 929 // (where offline-mode emulation matters). 930 return _origSwFetch(req).then((res) => { 931 if (res && res.ok) { 932 const copy = res.clone(); 933 caches.open(CACHE_NAME).then((c) => c.put(req, copy)); 934 } 935 return res; 936 }); 937 // A framework fetch can outright REJECT (iOS "Load failed"), 938 // typically when a page holding a PRIOR build's HTML lazily 939 // requests an old hashed asset that the atomic deploy swap has 940 // already removed. Returning a plain 504 instead of letting the 941 // promise reject keeps respondWith from throwing the 942 // "FetchEvent.respondWith received an error" unhandled rejection 943 // (which otherwise spams the JS-error reporter during every 944 // deploy); the stale page recovers on the SW-activate reload, 945 // which fetches fresh HTML with the current hashes. Mirrors the 946 // static-asset handler's catch below. 947 }).catch(() => new Response('', { status: 504, statusText: 'Framework asset unavailable' })) 948 ); 949 return; 950 } 951 952 if (isStaticAsset(url) && !isCacheableImage(url)) { 953 // Plain cache-first for CSS/JS/fonts/etc. Razor's MapStaticAssets 954 // uses content-hash URLs so a content change yields a new URL
955 // (new cache key). The old hashed URL stays cached but goes 956 // unused â no SWR background-refresh per request needed. This 957 // matters: with SWR, every page load triggered ~30+ extra 958 // background fetches that competed for Safari's parallel 959 // connection limit and visibly slowed navigations. 960 event.respondWith( 961 caches.match(req, { ignoreVary: true }).then((cached) => { 962 if (cached) return cached; 963 // Bypass fake-offline for static-asset cache fills â 964 // CSS / JS / fonts are infrastructure. After a 965 // CACHE_NAME bump the fingerprinted URL referenced by 966 // the page (e.g. /app.{hash}.css) isn't yet in the 967 // new cache, and the install handler only seeds the 968 // bare path. Without the real fetch, fake-offline 969 // would leave the page unstyled. See the framework- 970 // asset handler above for the same rationale. 971 return _origSwFetch(req).then((res) => { 972 if (res && res.ok) { 973 const copy = res.clone(); 974 caches.open(CACHE_NAME).then((c) => c.put(req, copy)).catch(() => {}); 975 } 976 return res; 977 }).catch(() => new Response('', { status: 504, statusText: 'Asset offline' })); 978 }) 979 ); 980 return; 981 } 982 983 if (isCacheableImage(url)) { 984 // Same plain cache-first treatment for /recipe-images/{guid}, 985 // /brand/*, /avatar/{userId}. Image URLs are already keyed by 986 // content (the thumb-image GUID changes when a user uploads a 987 // new photo), so once cached they stay valid until the URL
988 // changes. On cache miss + offline, fall back to the 989 // sync-mirror IDB's thumbs store before serving the placeholder 990 // SVG â the offline-sync flow already saved the thumbnail blob 991 // there, so we can render the actual photo without a network 992 // round-trip. Without this fallback, freshly-bumped CACHE_NAME 993 // wipes the SW image cache and offline (or fake-offline) users 994 // see grey placeholder cards until they go online again. 995 event.respondWith((async () => { 996 const cached = await caches.match(req, { ignoreVary: true }); 997 if (cached) return cached; 998 try { 999 const res = await fetch(req); 1000 if (res && res.ok) { 1001 const copy = res.clone(); 1002 caches.open(CACHE_NAME).then((c) => c.put(req, copy)).catch(() => {}); 1003 } 1004 return res; 1005 } catch (_) { 1006 // Network failed (real offline OR dev fake-offline). 1007 // Recipe-image URLs encode the thumb GUID in the path â 1008 // /recipe-images/{guid}?w=NNN. Look up the corresponding 1009 // blob in the offline IDB. /brand/* and /avatar/* don't 1010 // sync, so they fall straight through to the placeholder. 1011 const m = url.pathname.match(/^\/recipe-images\/([0-9a-f-]{36})/i); 1012 if (m) { 1013 try { 1014 const blob = await readSyncedThumbBlob(m[1]); 1015 if (blob) { 1016 return new Response(blob, { 1017 status: 200, 1018 headers: { 1019 'Content-Type': blob.type || 'image/jpeg', 1020 'Cache-Control': 'no-store', 1021 }, 1022 }); 1023 } 1024 } catch (_) { /* fall through to placeholder */ } 1025 } 1026 const am = url.pathname.match(/^\/avatar\/([0-9a-f-]{36})/i); 1027 if (am) { 1028 try { 1029 const blob = await readSyncedAvatarBlob(am[1]); 1030 if (blob) { 1031 return new Response(blob, { 1032 status: 200, 1033 headers: { 1034 'Content-Type': blob.type || 'image/png', 1035 'Cache-Control': 'no-store', 1036 }, 1037 }); 1038 } 1039 } catch (_) { /* fall through to placeholder */ } 1040 } 1041 return new Response(PLACEHOLDER_SVG, { 1042 status: 200, 1043 headers: { 1044 'Content-Type': 'image/svg+xml; charset=utf-8', 1045 'Cache-Control': 'no-store', 1046 }, 1047 }); 1048 } 1049 })()); 1050 return; 1051 } 1052 1053 if (isShellAsset(url)) { 1054 event.respondWith( 1055 fetch(req).then((res) => { 1056 if (res && res.ok) { 1057 const copy = res.clone(); 1058 caches.open(CACHE_NAME).then((c) => c.put(req, copy)); 1059 } 1060 return res; 1061 }).catch(() => caches.match(req).then((cached) => 1062 cached || caches.match('/offline-shell.html'))) 1063 ); 1064 return; 1065 } 1066 1067 // For any GET navigation that fails (offline / server unreachable), 1068 // serve the offline shell. The browser keeps the original URL in the 1069 // address bar, and offline-shell.js reads location.pathname to render 1070 // the matching view from IndexedDB. 1071 if (req.mode === 'navigate') { 1072 const _navStart = Date.now(); 1073 swTrace('NAV ' + req.method + ' ' + url.pathname + (url.search || '')); 1074 // For WASM-routed pages we use a "stale-while-revalidate"-style 1075 // pattern: stash every successful navigation response in cache 1076 // (keyed by the actual URL â different recipe IDs get separate 1077 // entries), then on offline reload we can serve THAT specific 1078 // URL's HTML straight from cache. Falls back to a generic 1079 // /wasm-test bootstrap for first-ever offline access to a URL 1080 // we've never visited online. 1081 if (isWasmRoute(url.pathname)) { 1082 // Stale-while-revalidate: serve the cached response 1083 // IMMEDIATELY if we have one (no network attempt, no 1084 // chance for an offline-reload to fall through to a 1085 // network error), and refresh the cache in the
1086 // background. Only when there's no cached entry do we 1087 // wait on the network. This is the strategy most likely 1088 // to survive iOS's various offline-navigation quirks. 1089 event.respondWith((async () => { 1090 const cached = await caches.match(req, { ignoreVary: true }); 1091 const networkUpdate = fetch(req).then(async (res) => { 1092 if (res && res.ok) { 1093 const copy = res.clone(); 1094 const c = await caches.open(CACHE_NAME); 1095 await c.put(req, copy); 1096 } 1097 return res; 1098 }).catch(() => null); 1099 // Keep the SW alive until the refresh lands. respondWith 1100 // settles the moment the cached copy is handed back, and 1101 // without this the browser is free to kill the worker 1102 // before the fetch and put() finish â so the "revalidate" 1103 // half of stale-while-revalidate silently never ran and a 1104 // page cached while signed in was served forever. That is 1105 // what kept an expired session looking live across reloads, 1106 // and across a PWA uninstall/reinstall (which leaves Cache 1107 // Storage intact). 1108 event.waitUntil(networkUpdate); 1109 1110 if (cached) { 1111 swTrace('NAV-DONE ' + url.pathname + ' wasm-cache ' + (Date.now() - _navStart) + 'ms'); 1112 return await injectOfflineNavShim(cached, 'wasm-cache:' + url.pathname); 1113 } 1114 1115 const fresh = await networkUpdate; 1116 if (fresh) { 1117 swTrace('NAV-DONE ' + url.pathname + ' wasm-net ' + (Date.now() - _navStart) + 'ms'); 1118 return fresh; 1119 } 1120 1121 // GUID URLs (e.g. /offline/recipes/{guid}) can't be 1122 // pre-cached individually, so the cache miss above is 1123 // expected. Use the offline-grid prerender as a generic 1124 // Blazor bootstrap shell: once blazor.web.js boots and 1125 // the WASM router runs, it sees location.pathname is 1126 // /offline/recipes/{guid} and re-renders the WasmRecipe 1127 // component, replacing the grid prerender. The grid is 1128 // a real WASM-backed page so its prerender plays nicely 1129 // with hydration; /wasm-test was confusing iOS WebKit 1130 // so badly that the page got stuck on its sentinel 1131 // "WASM pipeline check" markup with dead clicks. 1132 const gridShell = await caches.match('/offline/recipes', { ignoreVary: true }); 1133 if (gridShell) return await injectOfflineNavShim(withNoStore(gridShell), 'offline-grid-fallback:' + url.pathname); 1134 const wasmShell = await caches.match('/wasm-test', { ignoreVary: true }); 1135 if (wasmShell) return await injectOfflineNavShim(withNoStore(wasmShell), 'wasm-test-fallback:' + url.pathname); 1136 const shell = await caches.match('/shell', { ignoreVary: true }) 1137 || await caches.match('/offline-shell.html', { ignoreVary: true }); 1138 if (shell) return await injectOfflineNavShim(withNoStore(shell), 'shell-fallback:' + url.pathname); 1139 // Absolute last resort â hard-coded inline HTML. NEVER 1140 // fall through to a 503 or Safari's "Can't open page". 1141 return new Response(HARDCODED_OFFLINE_HTML, { 1142 status: 200, 1143 headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }, 1144 }); 1145 })()); 1146 return; 1147 } 1148 1149 // Helper that runs the offline-fallback chain. Used both from 1150 // .catch (network rejection) and .then when the response is 1151 // an offline error (some iOS WebKit builds return a canned 1152 // "offline" response object instead of rejecting fetch). 1153 const offlineFallback = async () => { 1154 swTrace(' offlineFallback for ' + url.pathname); 1155 // PREFER the offline WASM mirror over the live cached HTML 1156 // when one exists. The live-cached page is server-rendered 1157 // Blazor â without a SignalR circuit it can't start (fake- 1158 // offline / real-offline blocks the negotiate fetch) and 1159 // without an interactive circuit the @onclick handlers on 1160 // cards / topbar buttons are dead. The /offline/* WASM 1161 // mirror runs entirely in the browser and works offline by 1162 // design, so it's the better fallback for any URL with a 1163 // mirror. Live-cache is reserved for paths that have no 1164 // mirror (anything outside the recipe-list / recipe-detail 1165 // routes). 1166 const mirror = offlineMirrorPath(url.pathname); 1167 if (mirror) { 1168 // Don't redirect â iOS Safari PWA standalone has been 1169 // observed to silently drop both Response.redirect and 1170 // SW-served HTML containing only a location.replace 1171 // script. Instead serve the mirror's cached content 1172 // INLINE under the requested URL with a body data 1173 // attribute that the inline App.razor script uses to 1174 // fix up the URL via history.replaceState before 1175 // Blazor boots. Net effect is the same as a redirect 1176 // but the browser stays on a single navigation. 1177 swTrace(' -> inline mirror ' + mirror + ' (no-redirect path)'); 1178 // Try the mirror's exact entry first; fall back to the 1179 // grid shell since /offline/recipes/{guid} URLs aren't 1180 // pre-cached individually.
1181 const mirrorReq = new Request(new URL(mirror, url.origin).toString(), 1182 { credentials: req.credentials }); 1183 const mirrorCached = await caches.match(mirrorReq, { ignoreVary: true }) 1184 || await caches.match('/offline/recipes', { ignoreVary: true }); 1185 if (mirrorCached) { 1186 return await injectOfflineNavShim( 1187 withNoStore(mirrorCached), 1188 'inline-mirror:' + url.pathname, 1189 mirror); 1190 } 1191 // Mirror cache cold â fall through to the rest of the 1192 // fallback chain (live-cache, /shell, hardcoded HTML). 1193 } 1194 const liveCached = await caches.match(req, { ignoreVary: true }); 1195 if (liveCached) { 1196 swTrace(' -> live-cache HIT, serving cached HTML (no mirror for this URL)'); 1197 return await injectOfflineNavShim(liveCached, 'live-cache:' + url.pathname); 1198 } 1199 swTrace(' no mirror, no live-cache for ' + url.pathname); 1200 if (url.pathname !== '/offline/recipes' && !/^\/offline\//i.test(url.pathname)) { 1201 swTrace(' -> inline /offline/recipes (universal, no-redirect)'); 1202 const gridCached = await caches.match('/offline/recipes', { ignoreVary: true }); 1203 if (gridCached) { 1204 return await injectOfflineNavShim( 1205 withNoStore(gridCached), 1206 'inline-grid:' + url.pathname, 1207 '/offline/recipes'); 1208 } 1209 } 1210 swTrace(' already on /offline/* â trying bootstrap fallbacks'); 1211 const wasm = await caches.match('/wasm-test', { ignoreVary: true }); 1212 if (wasm) { 1213 swTrace(' -> /wasm-test cache HIT'); 1214 return await injectOfflineNavShim(withNoStore(wasm), 'wasm-test-final:' + url.pathname); 1215 } 1216 swTrace(' /wasm-test cache MISS'); 1217 const shell = await caches.match('/shell') || await caches.match('/offline-shell.html'); 1218 if (shell) { 1219 swTrace(' -> /shell cache HIT'); 1220 return await injectOfflineNavShim(withNoStore(shell), 'shell-fallback:' + url.pathname); 1221 } 1222 swTrace(' /shell cache MISS â emitting hardcoded HTML'); 1223 return new Response(HARDCODED_OFFLINE_HTML, { 1224 status: 200, 1225 headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }, 1226 }); 1227 }; 1228 1229 event.respondWith( 1230 fetch(req).then(async (res) => { 1231 swTrace(' fetch returned status=' + (res && res.status) + ' type=' + (res && res.type)); 1232 // If the fetch came back without succeeding (some iOS 1233 // WebKit builds return a stub "offline" response with 1234 // status 0 or as an error response instead of rejecting) 1235 // run the offline-fallback chain rather than handing the 1236 // user Safari's "Can't open this page". 1237 // A same-origin 3xx on a NAVIGATION arrives here as an opaque 1238 // redirect â status 0, type 'opaqueredirect' â because 1239 // navigation requests run with redirect:'manual'. That is the 1240 // opposite of being offline: there IS a response, and handing 1241 // it straight back makes the browser follow it. 1242 // 1243 // It has to be tested BEFORE the status===0 check below, which 1244 // otherwise swallows every server-side redirect and serves the 1245 // offline shell at the original URL. That is what happened the 1246 // moment a redirect started firing on ordinary pages (stripping 1247 // ?fbclid= from Facebook links): the address bar kept the 1248 // tracking param and the visitor got the cached offline page 1249 // instead of their recipes. The same trap previously ate the 1250 // OAuth callbacks and /set-lang, which were worked around 1251 // one path at a time â this fixes the cause for all of them. 1252 if (res && res.type === 'opaqueredirect') { 1253 swTrace(' opaque redirect â handing back for the browser to follow'); 1254 return res; 1255 } 1256 if (!res || res.type === 'error' || res.status === 0) { 1257 swTrace(' fetch was offline-error response, falling back'); 1258 const fb = await offlineFallback(); 1259 swTrace('NAV-DONE ' + url.pathname + ' fallback ' + (Date.now() - _navStart) + 'ms'); 1260 return fb; 1261 } 1262 // Online success on a live recipe URL â cache the full 1263 // server-rendered HTML for later offline replay. The 1264 // mirror pre-warm that used to fire here as a parallel 1265 // background fetch was visibly slowing online navigation 1266 // by competing for Safari's parallel-connection slots. 1267 // Trade-off: never-visited recipes opened offline get 1268 // the WASM mirror via the redirect chain instead of a 1269 // pre-cached body. That's still a working page. 1270 const mirror = offlineMirrorPath(url.pathname); 1271 if (res.ok && mirror) { 1272 const liveCopy = res.clone(); 1273 caches.open(CACHE_NAME).then((c) => c.put(req, liveCopy)).catch(() => {}); 1274 } 1275 swTrace('NAV-DONE ' + url.pathname + ' net ' + (Date.now() - _navStart) + 'ms'); 1276 return res; 1277 }).catch(async (err) => { 1278 swTrace(' fetch REJECTED: ' + (err && err.message ? err.message : err)); 1279 const fb = await offlineFallback(); 1280 swTrace('NAV-DONE ' + url.pathname + ' rejectedâfallback ' + (Date.now() - _navStart) + 'ms'); 1281 return fb; 1282 }) 1283 ); 1284 return; 1285 } 1286 1287 // Everything else (XHR / SignalR / images / etc.): network only. 1288});
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.