- Service worker reports image caching progress back to main thread - Sync progress shows current phase (recipes, pages, data, images) - Display progress bar for image downloads in sync tooltip - Use mediapath for thumbnail URLs (with hash for cache busting) - Serve cached thumbnails as fallback for full/placeholder when offline
361 lines
9.8 KiB
TypeScript
361 lines
9.8 KiB
TypeScript
/// <reference types="@sveltejs/kit" />
|
|
/// <reference no-default-lib="true"/>
|
|
/// <reference lib="esnext" />
|
|
/// <reference lib="webworker" />
|
|
|
|
import { build, files, version } from '$service-worker';
|
|
|
|
const sw = self as unknown as ServiceWorkerGlobalScope;
|
|
|
|
// Unique cache names
|
|
const CACHE_BUILD = `cache-build-${version}`;
|
|
const CACHE_STATIC = `cache-static-${version}`;
|
|
const CACHE_IMAGES = `cache-images-${version}`;
|
|
const CACHE_PAGES = `cache-pages-${version}`;
|
|
|
|
// Assets to precache
|
|
const buildAssets = new Set(build);
|
|
const staticAssets = new Set(files);
|
|
|
|
sw.addEventListener('install', (event) => {
|
|
event.waitUntil(
|
|
Promise.all([
|
|
// Cache build assets (JS, CSS)
|
|
caches.open(CACHE_BUILD).then((cache) => cache.addAll(build)),
|
|
// Cache static assets (fonts, etc.) - filter out large files
|
|
caches.open(CACHE_STATIC).then((cache) => {
|
|
const smallStaticFiles = files.filter(
|
|
(file) =>
|
|
!file.endsWith('.json') ||
|
|
file === '/manifest.json'
|
|
);
|
|
return cache.addAll(smallStaticFiles);
|
|
})
|
|
]).then(() => {
|
|
// Skip waiting to activate immediately
|
|
sw.skipWaiting();
|
|
})
|
|
);
|
|
});
|
|
|
|
sw.addEventListener('activate', (event) => {
|
|
event.waitUntil(
|
|
caches.keys().then((keys) => {
|
|
return Promise.all(
|
|
keys
|
|
.filter((key) => {
|
|
// Delete old caches
|
|
return (
|
|
key !== CACHE_BUILD &&
|
|
key !== CACHE_STATIC &&
|
|
key !== CACHE_IMAGES &&
|
|
key !== CACHE_PAGES
|
|
);
|
|
})
|
|
.map((key) => caches.delete(key))
|
|
);
|
|
}).then(() => {
|
|
// Take control of all clients immediately
|
|
sw.clients.claim();
|
|
})
|
|
);
|
|
});
|
|
|
|
sw.addEventListener('fetch', (event) => {
|
|
const url = new URL(event.request.url);
|
|
|
|
// Only handle same-origin requests
|
|
if (url.origin !== location.origin) return;
|
|
|
|
// Skip API requests - let them go to network (IndexedDB handles offline)
|
|
if (url.pathname.startsWith('/api/')) return;
|
|
|
|
// Handle SvelteKit __data.json requests for cacheable routes (recipes, glaube, root)
|
|
// Cache successful responses, serve from cache when offline
|
|
const isCacheableDataRoute = url.pathname.includes('__data.json') &&
|
|
(url.pathname.match(/^\/(rezepte|recipes|glaube)(\/|$)/) || url.pathname === '/__data.json');
|
|
|
|
if (isCacheableDataRoute) {
|
|
event.respondWith(
|
|
(async () => {
|
|
const cache = await caches.open(CACHE_PAGES);
|
|
|
|
// Create a cache key without query parameters
|
|
// SvelteKit adds ?x-sveltekit-invalidated=... which we need to ignore
|
|
const cacheKey = url.pathname;
|
|
|
|
try {
|
|
// Try network first
|
|
const response = await fetch(event.request);
|
|
|
|
// Cache successful responses for offline use (using pathname as key)
|
|
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
|
|
return new Response(JSON.stringify({ error: 'offline' }), {
|
|
status: 503,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
}
|
|
})()
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Handle recipe images (thumbnails, full images, and placeholders)
|
|
if (
|
|
url.pathname.startsWith('/static/rezepte/') &&
|
|
(url.pathname.includes('/thumb/') || url.pathname.includes('/full/') || url.pathname.includes('/placeholder/'))
|
|
) {
|
|
event.respondWith(
|
|
(async () => {
|
|
const cache = await caches.open(CACHE_IMAGES);
|
|
|
|
// Try exact match first
|
|
const cached = await cache.match(event.request);
|
|
if (cached) return cached;
|
|
|
|
// Try to fetch from network
|
|
try {
|
|
const response = await fetch(event.request);
|
|
if (response.ok) {
|
|
// Cache thumbnails for offline use
|
|
if (url.pathname.includes('/thumb/')) {
|
|
cache.put(event.request, response.clone());
|
|
}
|
|
}
|
|
return response;
|
|
} catch {
|
|
// Network failed - try to serve thumbnail as fallback for full/placeholder
|
|
if (url.pathname.includes('/full/') || url.pathname.includes('/placeholder/')) {
|
|
// Extract filename and try to find cached thumbnail
|
|
const filename = url.pathname.split('/').pop();
|
|
if (filename) {
|
|
const thumbUrl = `/static/rezepte/thumb/${filename}`;
|
|
const thumbCached = await cache.match(thumbUrl);
|
|
if (thumbCached) {
|
|
return thumbCached;
|
|
}
|
|
}
|
|
}
|
|
|
|
// No fallback available
|
|
return new Response('', { status: 404 });
|
|
}
|
|
})()
|
|
);
|
|
return;
|
|
}
|
|
|
|
// For build assets - cache first
|
|
if (buildAssets.has(url.pathname)) {
|
|
event.respondWith(
|
|
caches.match(event.request).then((cached) => {
|
|
return cached || fetch(event.request);
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
// For static assets - cache first
|
|
if (staticAssets.has(url.pathname)) {
|
|
event.respondWith(
|
|
caches.match(event.request).then((cached) => {
|
|
return cached || fetch(event.request);
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
// For navigation requests (HTML pages) - network first, cache response, fallback to cache
|
|
if (event.request.mode === 'navigate') {
|
|
event.respondWith(
|
|
(async () => {
|
|
const cache = await caches.open(CACHE_PAGES);
|
|
|
|
// Use pathname only for cache key (ignore query params)
|
|
const cacheKey = url.pathname;
|
|
|
|
try {
|
|
// Try network first
|
|
const response = await fetch(event.request);
|
|
|
|
// Cache successful HTML responses for cacheable pages (using pathname as key)
|
|
const isCacheablePage = response.ok && (
|
|
url.pathname.match(/^\/(rezepte|recipes|glaube)(\/|$)/) ||
|
|
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;
|
|
}
|
|
|
|
// 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
|
|
const isRecipeRoute = url.pathname.match(/^\/(rezepte|recipes)(\/|$)/);
|
|
const isOfflineShell = url.pathname.includes('/offline-shell');
|
|
const isOfflineNavigation = url.searchParams.has('_offline');
|
|
|
|
if (isRecipeRoute && !isOfflineShell && !isOfflineNavigation) {
|
|
const isEnglish = url.pathname.startsWith('/recipes');
|
|
const shellPath = isEnglish ? '/recipes/offline-shell' : '/rezepte/offline-shell';
|
|
|
|
// Check if we have the offline shell cached
|
|
const shellCached = await cache.match(shellPath);
|
|
if (shellCached) {
|
|
// Redirect to the offline shell with the original URL as a query param
|
|
const redirectUrl = `${shellPath}?redirect=${encodeURIComponent(url.pathname + url.search)}`;
|
|
return Response.redirect(redirectUrl, 302);
|
|
}
|
|
}
|
|
|
|
// Last resort - return a basic offline response
|
|
return new Response(
|
|
'<!DOCTYPE html><html><head><meta charset="utf-8"><title>Offline</title></head><body><h1>Offline</h1><p>Please connect to the internet and try again.</p></body></html>',
|
|
{ headers: { 'Content-Type': 'text/html' } }
|
|
);
|
|
}
|
|
})()
|
|
);
|
|
return;
|
|
}
|
|
});
|
|
|
|
// Handle messages from the app
|
|
sw.addEventListener('message', (event) => {
|
|
if (event.data?.type === 'CACHE_PAGES') {
|
|
const urls: string[] = event.data.urls;
|
|
caches.open(CACHE_PAGES).then((cache) => {
|
|
Promise.all(
|
|
urls.map((url) =>
|
|
fetch(url, { credentials: 'same-origin' })
|
|
.then((response) => {
|
|
if (response.ok) {
|
|
return cache.put(url, response);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
// Ignore failed page fetches
|
|
})
|
|
)
|
|
);
|
|
});
|
|
}
|
|
|
|
if (event.data?.type === 'CACHE_IMAGES') {
|
|
const urls: string[] = event.data.urls;
|
|
const requestId = event.data.requestId;
|
|
|
|
caches.open(CACHE_IMAGES).then(async (cache) => {
|
|
// Cache images in batches to avoid overwhelming the network
|
|
const batchSize = 10;
|
|
const total = urls.length;
|
|
let completed = 0;
|
|
|
|
// Report progress to all clients
|
|
async function reportProgress(done: boolean = false) {
|
|
const clients = await sw.clients.matchAll();
|
|
for (const client of clients) {
|
|
client.postMessage({
|
|
type: 'CACHE_IMAGES_PROGRESS',
|
|
requestId,
|
|
completed,
|
|
total,
|
|
done
|
|
});
|
|
}
|
|
}
|
|
|
|
// Report initial state
|
|
await reportProgress();
|
|
|
|
for (let i = 0; i < urls.length; i += batchSize) {
|
|
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++;
|
|
})
|
|
)
|
|
);
|
|
|
|
// Report progress after each batch
|
|
await reportProgress();
|
|
|
|
// Small delay between batches
|
|
if (i + batchSize < urls.length) {
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
}
|
|
}
|
|
|
|
// Report completion
|
|
await reportProgress(true);
|
|
});
|
|
}
|
|
|
|
if (event.data?.type === 'CACHE_DATA') {
|
|
const urls: string[] = event.data.urls;
|
|
caches.open(CACHE_PAGES).then((cache) => {
|
|
// Cache __data.json files in batches to avoid overwhelming the network
|
|
const batchSize = 20;
|
|
let index = 0;
|
|
|
|
function cacheBatch() {
|
|
const batch = urls.slice(index, index + batchSize);
|
|
if (batch.length === 0) return;
|
|
|
|
Promise.all(
|
|
batch.map((url) =>
|
|
fetch(url)
|
|
.then((response) => {
|
|
if (response.ok) {
|
|
return cache.put(url, response);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
// Ignore failed fetches
|
|
})
|
|
)
|
|
).then(() => {
|
|
index += batchSize;
|
|
if (index < urls.length) {
|
|
// Small delay between batches
|
|
setTimeout(cacheBatch, 50);
|
|
}
|
|
});
|
|
}
|
|
|
|
cacheBatch();
|
|
});
|
|
}
|
|
});
|