feat(offline)!: deploy-proof PWA cache + universal recipe loads
CI / update (push) Successful in 1m10s

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.
This commit is contained in:
2026-05-04 21:25:39 +02:00
parent 1bceabe967
commit 065c435d8b
10 changed files with 177 additions and 154 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.64.2",
"version": "1.65.2",
"private": true,
"type": "module",
"scripts": {
+4 -3
View File
@@ -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}`);
}
}
+4
View File
@@ -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;
@@ -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
}
};
+22 -23
View File
@@ -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
};
};
@@ -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()
};
};
+45 -23
View File
@@ -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<T extends { _id: unknown }>(
recipes: T[],
favorites: string[]
): Array<T & { isFavorite: boolean }> {
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<typeof isRecipeInSeason>[0], today)
);
return {
...data,
all_brief: marked,
season,
heroIndex: Math.random(),
isOffline: false
};
};
@@ -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
};
};
@@ -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<T extends { _id: unknown }>(
recipes: T[],
favorites: string[]
): Array<T & { isFavorite: boolean }> {
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
};
};
+60 -23
View File
@@ -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);
batch.map(async (url) => {
try {
// Recipe image URLs embed a content hash
// (e.g. /static/rezepte/thumb/<slug>.<hash>.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++;
}
})
.catch(() => {
// Ignore failed image fetches
})
.finally(() => {
completed++;
})
)
);
// Report progress after each batch