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",
|
"name": "homepage",
|
||||||
"version": "1.64.2",
|
"version": "1.65.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -278,13 +278,14 @@ async function precacheThumbnails(
|
|||||||
const registration = await navigator.serviceWorker.ready;
|
const registration = await navigator.serviceWorker.ready;
|
||||||
if (!registration.active) return;
|
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[] = [];
|
const thumbnailUrls: string[] = [];
|
||||||
for (const recipe of recipes) {
|
for (const recipe of recipes) {
|
||||||
if (recipe.images && recipe.images.length > 0) {
|
if (recipe.images && recipe.images.length > 0) {
|
||||||
const mediapath = recipe.images[0].mediapath;
|
const mediapath = recipe.images[0].mediapath;
|
||||||
// Thumbnail path format: /static/rezepte/thumb/{mediapath}
|
thumbnailUrls.push(`https://bocken.org/static/rezepte/thumb/${mediapath}`);
|
||||||
thumbnailUrls.push(`/static/rezepte/thumb/${mediapath}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,10 @@
|
|||||||
onMount(() => {
|
onMount(() => {
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
if (document.hidden) return;
|
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();
|
const now = Date.now();
|
||||||
if (now - lastRefreshAt < REFRESH_MIN_GAP_MS) return;
|
if (now - lastRefreshAt < REFRESH_MIN_GAP_MS) return;
|
||||||
lastRefreshAt = now;
|
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 { browser } from '$app/environment';
|
||||||
import { error } from '@sveltejs/kit';
|
|
||||||
import type { LayoutLoad } from './$types';
|
import type { LayoutLoad } from './$types';
|
||||||
|
|
||||||
export const load: LayoutLoad = async ({ params, data }) => {
|
/** Universal load. Lives outside `+layout.server.ts` so the entire `[recipeLang]`
|
||||||
// Validate recipeLang parameter
|
* group can render without a `__data.json` round-trip — critical for offline:
|
||||||
if (params.recipeLang !== 'rezepte' && params.recipeLang !== 'recipes') {
|
* if SvelteKit can't reach the network for the layout's server data, it errors
|
||||||
throw error(404, 'Not found');
|
* 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 lang: 'en' | 'de' = params.recipeLang === 'recipes' ? 'en' : 'de';
|
||||||
|
const isClientOffline = browser && !navigator.onLine;
|
||||||
|
|
||||||
// Check if we're offline:
|
let session: { user?: unknown; expires?: string } | null = null;
|
||||||
// 1. Browser reports offline (navigator.onLine === false)
|
if (!isClientOffline) {
|
||||||
// 2. Service worker returned offline flag (data.isOffline === true)
|
try {
|
||||||
const isClientOffline = browser && (!navigator.onLine || (data as any)?.isOffline);
|
const res = await fetch('/auth/session');
|
||||||
|
if (res.ok) {
|
||||||
if (isClientOffline) {
|
const body = await res.json();
|
||||||
// Return minimal data for offline mode
|
session = body && (body.user || body.expires) ? body : null;
|
||||||
return {
|
}
|
||||||
session: null,
|
} catch {
|
||||||
lang,
|
// Auth endpoint unreachable — proceed as logged out
|
||||||
recipeLang: params.recipeLang,
|
}
|
||||||
isOffline: true
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use server data when available (online mode)
|
|
||||||
return {
|
return {
|
||||||
...data,
|
session,
|
||||||
lang,
|
lang,
|
||||||
recipeLang: params.recipeLang,
|
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 { isOffline, canUseOfflineData } from '$lib/offline/helpers';
|
||||||
import { getAllBriefRecipes, getBriefRecipesInSeasonOn, isOfflineDataAvailable } from '$lib/offline/db';
|
import { getAllBriefRecipes, getBriefRecipesInSeasonOn, isOfflineDataAvailable } from '$lib/offline/db';
|
||||||
import { rand_array } from '$lib/js/randomize';
|
import { rand_array } from '$lib/js/randomize';
|
||||||
|
import { isRecipeInSeason } from '$lib/js/seasonRange';
|
||||||
import type { PageLoad } from './$types';
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageLoad = async ({ data }) => {
|
function addFavoriteStatus<T extends { _id: unknown }>(
|
||||||
// On the server, just pass through the server data unchanged
|
recipes: T[],
|
||||||
if (!browser) {
|
favorites: string[]
|
||||||
return {
|
): Array<T & { isFavorite: boolean }> {
|
||||||
...data,
|
if (!Array.isArray(recipes)) return [];
|
||||||
isOffline: false
|
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
|
export const load: PageLoad = async ({ params, fetch, parent }) => {
|
||||||
// This happens when:
|
const parentData = await parent();
|
||||||
// 1. We're offline (navigator.onLine is false)
|
const apiBase = `/api/${params.recipeLang}`;
|
||||||
// 2. Service worker returned offline flag
|
const useOfflineData =
|
||||||
// 3. Server data is missing (e.g., client-side navigation while offline)
|
browser && (isOffline() || parentData.isOffline) && canUseOfflineData();
|
||||||
const shouldUseOfflineData = (isOffline() || (data as any)?.isOffline || !data?.all_brief?.length) && canUseOfflineData();
|
|
||||||
|
|
||||||
if (shouldUseOfflineData) {
|
if (useOfflineData) {
|
||||||
try {
|
try {
|
||||||
const hasOfflineData = await isOfflineDataAvailable();
|
if (await isOfflineDataAvailable()) {
|
||||||
if (hasOfflineData) {
|
|
||||||
const [allBrief, seasonRecipes] = await Promise.all([
|
const [allBrief, seasonRecipes] = await Promise.all([
|
||||||
getAllBriefRecipes(),
|
getAllBriefRecipes(),
|
||||||
getBriefRecipesInSeasonOn(new Date())
|
getBriefRecipesInSeasonOn(new Date())
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...data,
|
|
||||||
all_brief: rand_array(allBrief),
|
all_brief: rand_array(allBrief),
|
||||||
season: rand_array(seasonRecipes),
|
season: rand_array(seasonRecipes),
|
||||||
heroIndex: Math.random(),
|
heroIndex: Math.random(),
|
||||||
isOffline: true
|
isOffline: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (e) {
|
||||||
console.error('Failed to load offline data:', error);
|
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 {
|
return {
|
||||||
...data,
|
all_brief: marked,
|
||||||
|
season,
|
||||||
|
heroIndex: Math.random(),
|
||||||
isOffline: false
|
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 { rand_array } from '$lib/js/randomize';
|
||||||
import type { PageLoad } from './$types';
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageLoad = async ({ data, params }) => {
|
function addFavoriteStatus<T extends { _id: unknown }>(
|
||||||
// On the server, just pass through the server data unchanged
|
recipes: T[],
|
||||||
if (!browser) {
|
favorites: string[]
|
||||||
return {
|
): Array<T & { isFavorite: boolean }> {
|
||||||
...data,
|
if (!Array.isArray(recipes)) return [];
|
||||||
isOffline: false
|
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
|
export const load: PageLoad = async ({ params, fetch, parent }) => {
|
||||||
const shouldUseOfflineData = (isOffline() || (data as any)?.isOffline || !data?.season?.length) && canUseOfflineData();
|
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 {
|
try {
|
||||||
const hasOfflineData = await isOfflineDataAvailable();
|
if (await isOfflineDataAvailable()) {
|
||||||
if (hasOfflineData) {
|
|
||||||
const month = parseInt(params.month);
|
|
||||||
const recipes = await getBriefRecipesOverlappingMonth(month);
|
const recipes = await getBriefRecipesOverlappingMonth(month);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...data,
|
month,
|
||||||
season: rand_array(recipes),
|
season: rand_array(recipes),
|
||||||
isOffline: true
|
isOffline: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (e) {
|
||||||
console.error('Failed to load offline data:', error);
|
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 {
|
return {
|
||||||
...data,
|
month,
|
||||||
|
season: addFavoriteStatus(item_season, favorites),
|
||||||
isOffline: false
|
isOffline: false
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
+60
-23
@@ -7,11 +7,29 @@ import { build, files, version } from '$service-worker';
|
|||||||
|
|
||||||
const sw = self as unknown as ServiceWorkerGlobalScope;
|
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_BUILD = `cache-build-${version}`;
|
||||||
const CACHE_STATIC = `cache-static-${version}`;
|
const CACHE_STATIC = `cache-static-${version}`;
|
||||||
const CACHE_IMAGES = `cache-images-${version}`;
|
const CACHE_IMAGES = `cache-images-v1`;
|
||||||
const CACHE_PAGES = `cache-pages-${version}`;
|
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
|
// Assets to precache
|
||||||
const buildAssets = new Set(build);
|
const buildAssets = new Set(build);
|
||||||
@@ -30,6 +48,21 @@ sw.addEventListener('install', (event) => {
|
|||||||
file === '/manifest.json'
|
file === '/manifest.json'
|
||||||
);
|
);
|
||||||
return cache.addAll(smallStaticFiles);
|
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(() => {
|
]).then(() => {
|
||||||
// Skip waiting to activate immediately
|
// Skip waiting to activate immediately
|
||||||
@@ -44,13 +77,13 @@ sw.addEventListener('activate', (event) => {
|
|||||||
return Promise.all(
|
return Promise.all(
|
||||||
keys
|
keys
|
||||||
.filter((key) => {
|
.filter((key) => {
|
||||||
// Delete old caches
|
// Delete only stale build/static caches from prior versions.
|
||||||
return (
|
// Stable pages/images caches are kept so offline data persists.
|
||||||
key !== CACHE_BUILD &&
|
if (key === CACHE_BUILD || key === CACHE_STATIC) return false;
|
||||||
key !== CACHE_STATIC &&
|
if (key === CACHE_IMAGES || key === CACHE_PAGES) return false;
|
||||||
key !== CACHE_IMAGES &&
|
return key.startsWith('cache-build-') || key.startsWith('cache-static-') ||
|
||||||
key !== CACHE_PAGES
|
// Old per-version pages/images caches from before stabilization
|
||||||
);
|
key.startsWith('cache-pages-') || key.startsWith('cache-images-');
|
||||||
})
|
})
|
||||||
.map((key) => caches.delete(key))
|
.map((key) => caches.delete(key))
|
||||||
);
|
);
|
||||||
@@ -142,7 +175,9 @@ sw.addEventListener('fetch', (event) => {
|
|||||||
// Extract filename and try to find cached thumbnail
|
// Extract filename and try to find cached thumbnail
|
||||||
const filename = url.pathname.split('/').pop();
|
const filename = url.pathname.split('/').pop();
|
||||||
if (filename) {
|
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);
|
const thumbCached = await cache.match(thumbUrl);
|
||||||
if (thumbCached) {
|
if (thumbCached) {
|
||||||
return thumbCached;
|
return thumbCached;
|
||||||
@@ -323,20 +358,22 @@ sw.addEventListener('message', (event) => {
|
|||||||
const batch = urls.slice(i, i + batchSize);
|
const batch = urls.slice(i, i + batchSize);
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
batch.map((url) =>
|
batch.map(async (url) => {
|
||||||
fetch(url)
|
try {
|
||||||
.then((response) => {
|
// Recipe image URLs embed a content hash
|
||||||
if (response.ok) {
|
// (e.g. /static/rezepte/thumb/<slug>.<hash>.webp), so a cache
|
||||||
return cache.put(url, response);
|
// 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
|
// Report progress after each batch
|
||||||
|
|||||||
Reference in New Issue
Block a user