From 065c435d8bdbb6af855409a36404665d7f8314e3 Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Mon, 4 May 2026 21:25:39 +0200 Subject: [PATCH] feat(offline)!: deploy-proof PWA cache + universal recipe loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Service worker: - Strip version suffix from CACHE_PAGES and CACHE_IMAGES so cached pages and recipe thumbs survive SW updates. Each deploy used to wipe both, forcing a re-sync before the user could open the app offline. Build and static caches stay version-suffixed (entries are hash-fingerprinted). - Install precaches the offline shells: /, /rezepte, /recipes, both offline-shell pages, /glaube, /faith, /fitness — best-effort with Promise.allSettled so a single 5xx can't fail SW install. - Bulk thumbnail precache now skips URLs already in CACHE_IMAGES. Recipe thumb URLs embed a content hash, so a cache hit guarantees identical bytes; subsequent syncs after small recipe edits no longer redownload every image. - Activate cleanup deletes only stale versioned build/static entries and obsolete versioned pages/images caches. Universal load migration: - [recipeLang]/+layout.server.ts removed; logic in universal +layout.ts. Session fetched from /auth/session, nulled when offline. - [recipeLang]/+page.server.ts and season/[month]/+page.server.ts removed; merged into universal +page.ts. Drops the __data.json round-trip entirely for these routes — IndexedDB fallback now runs even when the SW page cache is empty (fresh install, hash mismatch, etc.) instead of getting blocked by a 503 from the data handler. Other: - /static/rezepte/thumb URLs in sync.ts and the SW thumb fallback now use the absolute https://bocken.org origin. Dev/preview servers don't host /static/rezepte and were 404ing on themselves; production keys resolve to the same string so existing caches stay valid. - Root +layout.svelte invalidateAll() now bails when !navigator.onLine. Resume-while-offline used to refetch every load() and surface the error page instead of the still-viewable cached content. Bump 1.64.2 -> 1.65.2. --- package.json | 2 +- src/lib/offline/sync.ts | 7 +- src/routes/+layout.svelte | 4 + .../[recipeLang=recipeLang]/+layout.server.ts | 17 ---- src/routes/[recipeLang=recipeLang]/+layout.ts | 45 +++++----- .../[recipeLang=recipeLang]/+page.server.ts | 24 ----- src/routes/[recipeLang=recipeLang]/+page.ts | 68 ++++++++++----- .../season/[month]/+page.server.ts | 18 ---- .../season/[month]/+page.ts | 59 ++++++++----- src/service-worker.ts | 87 +++++++++++++------ 10 files changed, 177 insertions(+), 154 deletions(-) delete mode 100644 src/routes/[recipeLang=recipeLang]/+layout.server.ts delete mode 100644 src/routes/[recipeLang=recipeLang]/+page.server.ts delete mode 100644 src/routes/[recipeLang=recipeLang]/season/[month]/+page.server.ts diff --git a/package.json b/package.json index 597aa4e7..13114021 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.64.2", + "version": "1.65.2", "private": true, "type": "module", "scripts": { diff --git a/src/lib/offline/sync.ts b/src/lib/offline/sync.ts index 2d935bb2..8bb9ea11 100644 --- a/src/lib/offline/sync.ts +++ b/src/lib/offline/sync.ts @@ -278,13 +278,14 @@ async function precacheThumbnails( const registration = await navigator.serviceWorker.ready; if (!registration.active) return; - // Collect all thumbnail URLs using mediapath (includes hash for cache busting) + // Collect all thumbnail URLs using mediapath (includes hash for cache busting). + // Absolute origin so dev/preview servers — which don't host /static/rezepte — + // fetch directly from production instead of 404ing on themselves. const thumbnailUrls: string[] = []; for (const recipe of recipes) { if (recipe.images && recipe.images.length > 0) { const mediapath = recipe.images[0].mediapath; - // Thumbnail path format: /static/rezepte/thumb/{mediapath} - thumbnailUrls.push(`/static/rezepte/thumb/${mediapath}`); + thumbnailUrls.push(`https://bocken.org/static/rezepte/thumb/${mediapath}`); } } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index f425a089..8df96324 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -40,6 +40,10 @@ onMount(() => { const refresh = () => { if (document.hidden) return; + // Skip when offline — invalidateAll() forces every load() to refetch, + // and a failed __data.json on a still-cached route renders the error + // page instead of the perfectly viewable cached content. + if (typeof navigator !== 'undefined' && !navigator.onLine) return; const now = Date.now(); if (now - lastRefreshAt < REFRESH_MIN_GAP_MS) return; lastRefreshAt = now; diff --git a/src/routes/[recipeLang=recipeLang]/+layout.server.ts b/src/routes/[recipeLang=recipeLang]/+layout.server.ts deleted file mode 100644 index b5082d46..00000000 --- a/src/routes/[recipeLang=recipeLang]/+layout.server.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { LayoutServerLoad } from "./$types" -import { errorWithVerse } from "$lib/server/errorQuote" - -export const load : LayoutServerLoad = async ({locals, params, fetch, url}) => { - // Validate recipeLang parameter - if (params.recipeLang !== 'rezepte' && params.recipeLang !== 'recipes') { - await errorWithVerse(fetch, url.pathname, 404, 'Not found'); - } - - const lang: 'en' | 'de' = params.recipeLang === 'recipes' ? 'en' : 'de'; - - return { - session: locals.session ?? await locals.auth(), - lang, - recipeLang: params.recipeLang - } -}; diff --git a/src/routes/[recipeLang=recipeLang]/+layout.ts b/src/routes/[recipeLang=recipeLang]/+layout.ts index e02f2ea1..6f8e5a84 100644 --- a/src/routes/[recipeLang=recipeLang]/+layout.ts +++ b/src/routes/[recipeLang=recipeLang]/+layout.ts @@ -1,35 +1,34 @@ import { browser } from '$app/environment'; -import { error } from '@sveltejs/kit'; import type { LayoutLoad } from './$types'; -export const load: LayoutLoad = async ({ params, data }) => { - // Validate recipeLang parameter - if (params.recipeLang !== 'rezepte' && params.recipeLang !== 'recipes') { - throw error(404, 'Not found'); - } - +/** Universal load. Lives outside `+layout.server.ts` so the entire `[recipeLang]` + * group can render without a `__data.json` round-trip — critical for offline: + * if SvelteKit can't reach the network for the layout's server data, it errors + * before the page-level offline fallback ever runs. Session is fetched from + * the Auth.js `/auth/session` endpoint and gracefully nulled when offline. + */ +export const load: LayoutLoad = async ({ params, fetch }) => { + // recipeLang param matcher already restricts to 'rezepte'/'recipes' const lang: 'en' | 'de' = params.recipeLang === 'recipes' ? 'en' : 'de'; + const isClientOffline = browser && !navigator.onLine; - // Check if we're offline: - // 1. Browser reports offline (navigator.onLine === false) - // 2. Service worker returned offline flag (data.isOffline === true) - const isClientOffline = browser && (!navigator.onLine || (data as any)?.isOffline); - - if (isClientOffline) { - // Return minimal data for offline mode - return { - session: null, - lang, - recipeLang: params.recipeLang, - isOffline: true - }; + let session: { user?: unknown; expires?: string } | null = null; + if (!isClientOffline) { + try { + const res = await fetch('/auth/session'); + if (res.ok) { + const body = await res.json(); + session = body && (body.user || body.expires) ? body : null; + } + } catch { + // Auth endpoint unreachable — proceed as logged out + } } - // Use server data when available (online mode) return { - ...data, + session, lang, recipeLang: params.recipeLang, - isOffline: false + isOffline: isClientOffline }; }; diff --git a/src/routes/[recipeLang=recipeLang]/+page.server.ts b/src/routes/[recipeLang=recipeLang]/+page.server.ts deleted file mode 100644 index 48c42434..00000000 --- a/src/routes/[recipeLang=recipeLang]/+page.server.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { PageServerLoad } from "./$types"; -import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favorites"; -import { isRecipeInSeason } from "$lib/js/seasonRange"; - -export const load: PageServerLoad = async ({ fetch, locals, params }) => { - const apiBase = `/api/${params.recipeLang}`; - const session = locals.session ?? await locals.auth(); - - const [res_all_brief, userFavorites] = await Promise.all([ - fetch(`${apiBase}/items/all_brief`).then(r => r.json()), - getUserFavorites(fetch, locals, params.recipeLang) - ]); - - const all_brief = addFavoriteStatusToRecipes(res_all_brief, userFavorites); - const today = new Date(); - const season = all_brief.filter((r: any) => r.icon !== '🍽️' && isRecipeInSeason(r, today)); - - return { - season, - all_brief, - session, - heroIndex: Math.random() - }; -}; diff --git a/src/routes/[recipeLang=recipeLang]/+page.ts b/src/routes/[recipeLang=recipeLang]/+page.ts index 59c37c30..579171ef 100644 --- a/src/routes/[recipeLang=recipeLang]/+page.ts +++ b/src/routes/[recipeLang=recipeLang]/+page.ts @@ -2,49 +2,71 @@ import { browser } from '$app/environment'; import { isOffline, canUseOfflineData } from '$lib/offline/helpers'; import { getAllBriefRecipes, getBriefRecipesInSeasonOn, isOfflineDataAvailable } from '$lib/offline/db'; import { rand_array } from '$lib/js/randomize'; +import { isRecipeInSeason } from '$lib/js/seasonRange'; import type { PageLoad } from './$types'; -export const load: PageLoad = async ({ data }) => { - // On the server, just pass through the server data unchanged - if (!browser) { - return { - ...data, - isOffline: false - }; - } +function addFavoriteStatus( + recipes: T[], + favorites: string[] +): Array { + if (!Array.isArray(recipes)) return []; + return recipes.map((r) => ({ + ...r, + isFavorite: favorites.some((id) => id.toString() === (r._id as { toString(): string }).toString()) + })); +} - // On the client, check if we need to load from IndexedDB - // This happens when: - // 1. We're offline (navigator.onLine is false) - // 2. Service worker returned offline flag - // 3. Server data is missing (e.g., client-side navigation while offline) - const shouldUseOfflineData = (isOffline() || (data as any)?.isOffline || !data?.all_brief?.length) && canUseOfflineData(); +export const load: PageLoad = async ({ params, fetch, parent }) => { + const parentData = await parent(); + const apiBase = `/api/${params.recipeLang}`; + const useOfflineData = + browser && (isOffline() || parentData.isOffline) && canUseOfflineData(); - if (shouldUseOfflineData) { + if (useOfflineData) { try { - const hasOfflineData = await isOfflineDataAvailable(); - if (hasOfflineData) { + if (await isOfflineDataAvailable()) { const [allBrief, seasonRecipes] = await Promise.all([ getAllBriefRecipes(), getBriefRecipesInSeasonOn(new Date()) ]); - return { - ...data, all_brief: rand_array(allBrief), season: rand_array(seasonRecipes), heroIndex: Math.random(), isOffline: true }; } - } catch (error) { - console.error('Failed to load offline data:', error); + } catch (e) { + console.error('Failed to load offline data:', e); } } - // Return server data as-is + let all_brief: Array<{ _id: unknown; icon?: string }> = []; + let favorites: string[] = []; + try { + const [briefRes, favRes] = await Promise.all([ + fetch(`${apiBase}/items/all_brief`), + fetch(`${apiBase}/favorites`).catch(() => null) + ]); + if (briefRes.ok) all_brief = await briefRes.json(); + if (favRes && favRes.ok) { + const body = await favRes.json(); + favorites = body.favorites ?? []; + } + } catch { + // Network unreachable — empty data; +page.svelte renders fallback layout. + } + + const marked = addFavoriteStatus(all_brief, favorites); + const today = new Date(); + const season = marked.filter( + (r) => r.icon !== '🍽️' && isRecipeInSeason(r as Parameters[0], today) + ); + return { - ...data, + all_brief: marked, + season, + heroIndex: Math.random(), isOffline: false }; }; diff --git a/src/routes/[recipeLang=recipeLang]/season/[month]/+page.server.ts b/src/routes/[recipeLang=recipeLang]/season/[month]/+page.server.ts deleted file mode 100644 index 25300a33..00000000 --- a/src/routes/[recipeLang=recipeLang]/season/[month]/+page.server.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { PageServerLoad } from "./$types"; -import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favorites"; - -export const load: PageServerLoad = async ({ fetch, locals, params }) => { - const apiBase = `/api/${params.recipeLang}`; - - const res_season = await fetch(`${apiBase}/items/in_season/` + params.month); - const item_season = await res_season.json(); - - const session = locals.session ?? await locals.auth(); - const userFavorites = await getUserFavorites(fetch, locals, params.recipeLang); - - return { - month: params.month, - season: addFavoriteStatusToRecipes(item_season, userFavorites), - session - }; -}; \ No newline at end of file diff --git a/src/routes/[recipeLang=recipeLang]/season/[month]/+page.ts b/src/routes/[recipeLang=recipeLang]/season/[month]/+page.ts index 087b9cef..8705cb0e 100644 --- a/src/routes/[recipeLang=recipeLang]/season/[month]/+page.ts +++ b/src/routes/[recipeLang=recipeLang]/season/[month]/+page.ts @@ -4,39 +4,58 @@ import { getBriefRecipesOverlappingMonth, isOfflineDataAvailable } from '$lib/of import { rand_array } from '$lib/js/randomize'; import type { PageLoad } from './$types'; -export const load: PageLoad = async ({ data, params }) => { - // On the server, just pass through the server data unchanged - if (!browser) { - return { - ...data, - isOffline: false - }; - } +function addFavoriteStatus( + recipes: T[], + favorites: string[] +): Array { + if (!Array.isArray(recipes)) return []; + return recipes.map((r) => ({ + ...r, + isFavorite: favorites.some((id) => id.toString() === (r._id as { toString(): string }).toString()) + })); +} - // On the client, check if we need to load from IndexedDB - const shouldUseOfflineData = (isOffline() || (data as any)?.isOffline || !data?.season?.length) && canUseOfflineData(); +export const load: PageLoad = async ({ params, fetch, parent }) => { + const parentData = await parent(); + const month = parseInt(params.month, 10); + const apiBase = `/api/${params.recipeLang}`; + const useOfflineData = + browser && (isOffline() || parentData.isOffline) && canUseOfflineData(); - if (shouldUseOfflineData) { + if (useOfflineData) { try { - const hasOfflineData = await isOfflineDataAvailable(); - if (hasOfflineData) { - const month = parseInt(params.month); + if (await isOfflineDataAvailable()) { const recipes = await getBriefRecipesOverlappingMonth(month); - return { - ...data, + month, season: rand_array(recipes), isOffline: true }; } - } catch (error) { - console.error('Failed to load offline data:', error); + } catch (e) { + console.error('Failed to load offline season data:', e); } } - // Return server data as-is + let item_season: Array<{ _id: unknown }> = []; + let favorites: string[] = []; + try { + const [seasonRes, favRes] = await Promise.all([ + fetch(`${apiBase}/items/in_season/${month}`), + fetch(`${apiBase}/favorites`).catch(() => null) + ]); + if (seasonRes.ok) item_season = await seasonRes.json(); + if (favRes && favRes.ok) { + const body = await favRes.json(); + favorites = body.favorites ?? []; + } + } catch { + // Empty arrays — page will render with no recipes + } + return { - ...data, + month, + season: addFavoriteStatus(item_season, favorites), isOffline: false }; }; diff --git a/src/service-worker.ts b/src/service-worker.ts index b5206e36..5f9ddb97 100644 --- a/src/service-worker.ts +++ b/src/service-worker.ts @@ -7,11 +7,29 @@ import { build, files, version } from '$service-worker'; const sw = self as unknown as ServiceWorkerGlobalScope; -// Unique cache names +// Build/static caches are version-suffixed because their entries are +// hash-fingerprinted — old entries become dead weight after a deploy. +// Pages/images caches use stable names so user-visible content survives +// SW updates; otherwise every deploy wipes the offline shell and forces a +// re-sync before the user can open the app offline again. const CACHE_BUILD = `cache-build-${version}`; const CACHE_STATIC = `cache-static-${version}`; -const CACHE_IMAGES = `cache-images-${version}`; -const CACHE_PAGES = `cache-pages-${version}`; +const CACHE_IMAGES = `cache-images-v1`; +const CACHE_PAGES = `cache-pages-v1`; + +// Shells precached on install so a fresh user (or one whose pages cache was +// just wiped pre-stabilization) can open the app offline at start_url and +// reach the recipe shell without a prior online visit. +const PRECACHE_SHELLS = [ + '/', + '/rezepte', + '/recipes', + '/rezepte/offline-shell', + '/recipes/offline-shell', + '/glaube', + '/faith', + '/fitness' +]; // Assets to precache const buildAssets = new Set(build); @@ -30,6 +48,21 @@ sw.addEventListener('install', (event) => { file === '/manifest.json' ); return cache.addAll(smallStaticFiles); + }), + // Best-effort precache of shell pages — individual failures are + // non-fatal so a single 5xx can't break SW install. + caches.open(CACHE_PAGES).then(async (cache) => { + await Promise.allSettled( + PRECACHE_SHELLS.map(async (path) => { + try { + const res = await fetch(path, { credentials: 'same-origin' }); + if (res.ok) await cache.put(path, res); + } catch { + // Network unavailable during install — shell will be cached + // on first online visit instead. + } + }) + ); }) ]).then(() => { // Skip waiting to activate immediately @@ -44,13 +77,13 @@ sw.addEventListener('activate', (event) => { return Promise.all( keys .filter((key) => { - // Delete old caches - return ( - key !== CACHE_BUILD && - key !== CACHE_STATIC && - key !== CACHE_IMAGES && - key !== CACHE_PAGES - ); + // Delete only stale build/static caches from prior versions. + // Stable pages/images caches are kept so offline data persists. + if (key === CACHE_BUILD || key === CACHE_STATIC) return false; + if (key === CACHE_IMAGES || key === CACHE_PAGES) return false; + return key.startsWith('cache-build-') || key.startsWith('cache-static-') || + // Old per-version pages/images caches from before stabilization + key.startsWith('cache-pages-') || key.startsWith('cache-images-'); }) .map((key) => caches.delete(key)) ); @@ -142,7 +175,9 @@ sw.addEventListener('fetch', (event) => { // Extract filename and try to find cached thumbnail const filename = url.pathname.split('/').pop(); if (filename) { - const thumbUrl = `/static/rezepte/thumb/${filename}`; + // Match the absolute origin used by sync.ts — keys are stored + // as the full URL so cross-origin requests in dev resolve too. + const thumbUrl = `https://bocken.org/static/rezepte/thumb/${filename}`; const thumbCached = await cache.match(thumbUrl); if (thumbCached) { return thumbCached; @@ -323,20 +358,22 @@ sw.addEventListener('message', (event) => { const batch = urls.slice(i, i + batchSize); await Promise.all( - batch.map((url) => - fetch(url) - .then((response) => { - if (response.ok) { - return cache.put(url, response); - } - }) - .catch(() => { - // Ignore failed image fetches - }) - .finally(() => { - completed++; - }) - ) + batch.map(async (url) => { + try { + // Recipe image URLs embed a content hash + // (e.g. /static/rezepte/thumb/..webp), so a cache + // hit on the exact URL guarantees the bytes haven't changed. + // Skip the network round-trip when already cached. + const cached = await cache.match(url); + if (cached) return; + const response = await fetch(url); + if (response.ok) await cache.put(url, response); + } catch { + // Ignore failed image fetches + } finally { + completed++; + } + }) ); // Report progress after each batch