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",
"version": "1.67.3",
"version": "1.67.4",
"private": true,
"type": "module",
"scripts": {
+37 -25
View File
@@ -117,30 +117,34 @@ sw.addEventListener('fetch', (event) => {
// SvelteKit adds ?x-sveltekit-invalidated=... which we need to ignore
const cacheKey = url.pathname;
let response: Response | undefined;
try {
// Try network first
const response = await fetch(event.request);
response = await fetch(event.request);
} catch {
// Network unreachable — fall through to cache fallback below.
}
// Cache successful responses for offline use (using pathname as key)
if (response.ok) {
if (response?.ok) {
cache.put(cacheKey, response.clone());
}
return response;
} catch {
// Network failed - try to serve from cache (ignoring query params)
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
// 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;
@@ -222,27 +226,32 @@ sw.addEventListener('fetch', (event) => {
// Use pathname only for cache key (ignore query params)
const cacheKey = url.pathname;
let response: Response | undefined;
try {
// Try network first
const response = await fetch(event.request);
response = await fetch(event.request);
} catch {
// Network unreachable — fall through to fallback below.
}
// 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 === '/'
);
url.pathname === '/';
if (isCacheablePage) {
cache.put(cacheKey, response.clone());
}
return response;
} catch {
// Network failed - try to serve from cache (ignoring query params)
const cached = await cache.match(cacheKey);
if (cached) {
return cached;
}
// 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);
if (cached) return cached;
// 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
// Skip if this is already the offline-shell or an offline navigation to prevent loops
@@ -262,6 +271,10 @@ sw.addEventListener('fetch', (event) => {
return Response.redirect(redirectUrl, 302);
}
}
}
// Pass through non-5xx errors (404, 401, ...) untouched.
if (response && !upstreamDown) return response;
// Last resort - return a styled offline response
return new Response(
@@ -299,7 +312,6 @@ p{color:#aaa}
</body></html>`,
{ headers: { 'Content-Type': 'text/html' } }
);
}
})()
);
return;