const CACHE_NAME = "epicure-v1"; const SHELL_ASSETS = ["/", "/recipes", "/offline"]; self.addEventListener("install", event => { event.waitUntil(caches.open(CACHE_NAME).then(cache => cache.addAll(SHELL_ASSETS))); self.skipWaiting(); }); self.addEventListener("activate", event => { event.waitUntil( caches.keys().then(keys => Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))) ) ); self.clients.claim(); }); self.addEventListener("fetch", event => { const { request } = event; if (request.method !== "GET") return; const url = new URL(request.url); // Skip API routes if (url.pathname.startsWith("/api/")) return; // Cache-first for cook mode pages if (url.pathname.includes("/cook")) { event.respondWith( caches.match(request).then(cached => cached ?? fetch(request).then(res => { const clone = res.clone(); caches.open(CACHE_NAME).then(c => c.put(request, clone)); return res; }) ) ); return; } // Network-first for other pages, but keep a copy of every successful // response — this is what actually makes "recently visited recipes are // available offline" (see /offline) true, and what a "Save for offline" // action (save-offline-button.tsx) piggybacks on by re-fetching a page // it wants pinned. event.respondWith( fetch(request) .then(res => { if (res.ok) { const clone = res.clone(); caches.open(CACHE_NAME).then(c => c.put(request, clone)); } return res; }) .catch(() => caches.match(request).then(cached => cached ?? caches.match("/offline"))) ); }); // --- Background Sync: replay mutations queued while offline --- // Mirrors lib/offline-queue.ts's flushOfflineQueue, duplicated here because // this file is registered as a classic (non-module) script and can't import // app code. Both read/write the same IndexedDB database — keep the store // names and shapes in sync with lib/offline-db.ts if either changes. const DB_NAME = "epicure-offline"; const PENDING_ACTIONS_STORE = "pendingActions"; const SYNC_TAG = "epicure-sync"; function openOfflineDb() { return new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, 1); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } async function getPendingActions(db) { return new Promise((resolve, reject) => { const tx = db.transaction(PENDING_ACTIONS_STORE, "readonly"); const req = tx.objectStore(PENDING_ACTIONS_STORE).getAll(); req.onsuccess = () => resolve(req.result.sort((a, b) => a.createdAt.localeCompare(b.createdAt))); req.onerror = () => reject(req.error); }); } function deletePendingAction(db, id) { return new Promise((resolve, reject) => { const tx = db.transaction(PENDING_ACTIONS_STORE, "readwrite"); tx.objectStore(PENDING_ACTIONS_STORE).delete(id); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); } async function replayPendingActions() { const db = await openOfflineDb(); const pending = await getPendingActions(db); let synced = 0; for (const action of pending) { let ok = false; try { const res = await fetch(action.url, { method: action.method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(action.body), }); ok = res.ok; } catch { ok = false; } if (!ok) break; await deletePendingAction(db, action.id); synced += 1; } if (synced > 0) { const clients = await self.clients.matchAll(); for (const client of clients) client.postMessage({ type: "epicure:offline-synced", count: synced }); } } self.addEventListener("sync", event => { if (event.tag === SYNC_TAG) event.waitUntil(replayPendingActions()); }); // --- Push: display incoming notifications and handle taps --- // lib/push.ts sends {title, body, url} as the payload. Without a "push" // listener, a push message reaches the browser but never displays anything // — showNotification() must be called explicitly. self.addEventListener("push", event => { let data = {}; try { data = event.data ? event.data.json() : {}; } catch { data = {}; } const title = data.title || "Epicure"; event.waitUntil( self.registration.showNotification(title, { body: data.body || "", icon: "/icon-192.svg", badge: "/icon-192.svg", data: { url: data.url || "/" }, }) ); }); self.addEventListener("notificationclick", event => { event.notification.close(); const url = event.notification.data && event.notification.data.url ? event.notification.data.url : "/"; event.waitUntil( self.clients.matchAll({ type: "window", includeUncontrolled: true }).then(clientList => { const targetUrl = new URL(url, self.location.origin).href; const existing = clientList.find(c => c.url === targetUrl); if (existing) return existing.focus(); return self.clients.openWindow(url); }) ); });