feat(offline)!: deploy-proof PWA cache + universal recipe loads
CI / update (push) Successful in 1m10s
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:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.64.2",
|
||||
"version": "1.65.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
@@ -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()
|
||||
};
|
||||
};
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user