PageSourceSearch

https://www.iwant2eat.com/service-worker.js

js iwant2eat.com collected 2026-10-02 01:33:59 UTC 64,498 bytes, 1,288 lines download raw bytes

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, '&amp;')
673                .replace(/"/g, '&quot;');
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, '&amp;')
749        .replace(/</g, '&lt;')
750        .replace(/>/g, '&gt;')
751        .replace(/"/g, '&quot;')
752        .replace(/'/g, '&#39;');
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.