fix(offline): fall back to cached shell on upstream 5xx

Service worker previously only fell back to cache when fetch threw
(network unreachable). A 502/503/504 from the origin returned
successfully with !response.ok, so the bad page was passed through to
the user. Now upstream 5xx is treated like a network failure: try
cached page, then offline-shell redirect for recipe routes, then the
styled offline page. 4xx still passes through unchanged.
This commit is contained in:
2026-05-07 07:52:29 +02:00
parent 9a97e41c28
commit eb2ffac536
2 changed files with 52 additions and 40 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "homepage", "name": "homepage",
"version": "1.67.3", "version": "1.67.4",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+51 -39
View File
@@ -117,30 +117,34 @@ sw.addEventListener('fetch', (event) => {
// SvelteKit adds ?x-sveltekit-invalidated=... which we need to ignore // SvelteKit adds ?x-sveltekit-invalidated=... which we need to ignore
const cacheKey = url.pathname; const cacheKey = url.pathname;
let response: Response | undefined;
try { try {
// Try network first response = await fetch(event.request);
const response = await fetch(event.request);
// Cache successful responses for offline use (using pathname as key)
if (response.ok) {
cache.put(cacheKey, response.clone());
}
return response;
} catch { } catch {
// Network failed - try to serve from cache (ignoring query params) // Network unreachable — fall through to cache fallback below.
const cached = await cache.match(cacheKey);
if (cached) {
return cached;
}
// No cached data available - return error response
// The page will need to handle this gracefully
return new Response(JSON.stringify({ error: 'offline' }), {
status: 503,
headers: { 'Content-Type': 'application/json' }
});
} }
// Cache successful responses for offline use (using pathname as key)
if (response?.ok) {
cache.put(cacheKey, response.clone());
return response;
}
// Network unreachable OR upstream 5xx (502/503/504 etc.) — serve
// stale cached data so the PWA stays usable when the origin is down.
if (!response || response.status >= 500) {
const cached = await cache.match(cacheKey);
if (cached) return cached;
}
// Pass through non-5xx errors (404, 401, ...) untouched.
if (response) return response;
// No response and no cache — synthetic offline error.
return new Response(JSON.stringify({ error: 'offline' }), {
status: 503,
headers: { 'Content-Type': 'application/json' }
});
})() })()
); );
return; return;
@@ -222,26 +226,31 @@ sw.addEventListener('fetch', (event) => {
// Use pathname only for cache key (ignore query params) // Use pathname only for cache key (ignore query params)
const cacheKey = url.pathname; const cacheKey = url.pathname;
let response: Response | undefined;
try { try {
// Try network first response = await fetch(event.request);
const response = await fetch(event.request); } catch {
// Network unreachable — fall through to fallback below.
}
// Cache successful HTML responses for cacheable pages (using pathname as key) // Cache successful HTML responses for cacheable pages (using pathname as key)
const isCacheablePage = response.ok && ( if (response?.ok) {
const isCacheablePage =
url.pathname.match(/^\/(rezepte|recipes|glaube|faith|fitness)(\/|$)/) || url.pathname.match(/^\/(rezepte|recipes|glaube|faith|fitness)(\/|$)/) ||
url.pathname === '/' url.pathname === '/';
);
if (isCacheablePage) { if (isCacheablePage) {
cache.put(cacheKey, response.clone()); cache.put(cacheKey, response.clone());
} }
return response; return response;
} catch { }
// Network failed - try to serve from cache (ignoring query params)
// Network unreachable OR upstream 5xx (502 Bad Gateway, 503, 504, ...) —
// serve stale shell so the PWA stays usable when the origin is down.
const upstreamDown = !response || response.status >= 500;
if (upstreamDown) {
const cached = await cache.match(cacheKey); const cached = await cache.match(cacheKey);
if (cached) { if (cached) return cached;
return cached;
}
// For recipe routes, redirect to the offline shell with the target URL // For recipe routes, redirect to the offline shell with the target URL
// The offline shell will then do client-side navigation to load from IndexedDB // The offline shell will then do client-side navigation to load from IndexedDB
@@ -262,10 +271,14 @@ sw.addEventListener('fetch', (event) => {
return Response.redirect(redirectUrl, 302); return Response.redirect(redirectUrl, 302);
} }
} }
}
// Last resort - return a styled offline response // Pass through non-5xx errors (404, 401, ...) untouched.
return new Response( if (response && !upstreamDown) return response;
`<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><title>Offline</title><style>
// Last resort - return a styled offline response
return new Response(
`<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><title>Offline</title><style>
*{box-sizing:border-box;margin:0;font-family:Helvetica,Arial,sans-serif} *{box-sizing:border-box;margin:0;font-family:Helvetica,Arial,sans-serif}
body{background:#f8f6f1;color:#2a2a2a;min-height:100svh;display:flex;flex-direction:column;padding-top:env(safe-area-inset-top,0px)} body{background:#f8f6f1;color:#2a2a2a;min-height:100svh;display:flex;flex-direction:column;padding-top:env(safe-area-inset-top,0px)}
nav{position:sticky;top:calc(12px + env(safe-area-inset-top,0px));z-index:100;display:flex;align-items:center;justify-content:center;height:3rem;padding:0 1.2rem;margin:12px auto 0;width:fit-content;max-width:calc(100% - 1.5rem);border-radius:100px;background:rgba(46,52,64,0.82);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);border:1px solid rgba(255,255,255,0.08);box-shadow:0 4px 24px rgba(0,0,0,0.25)} nav{position:sticky;top:calc(12px + env(safe-area-inset-top,0px));z-index:100;display:flex;align-items:center;justify-content:center;height:3rem;padding:0 1.2rem;margin:12px auto 0;width:fit-content;max-width:calc(100% - 1.5rem);border-radius:100px;background:rgba(46,52,64,0.82);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);border:1px solid rgba(255,255,255,0.08);box-shadow:0 4px 24px rgba(0,0,0,0.25)}
@@ -297,9 +310,8 @@ p{color:#aaa}
<p class="hint">Install the <a href="https://bocken.org/static/Bocken.apk">Android app</a> or add this site to your home screen to browse offline.</p> <p class="hint">Install the <a href="https://bocken.org/static/Bocken.apk">Android app</a> or add this site to your home screen to browse offline.</p>
</main> </main>
</body></html>`, </body></html>`,
{ headers: { 'Content-Type': 'text/html' } } { headers: { 'Content-Type': 'text/html' } }
); );
}
})() })()
); );
return; return;