/* ============================================================ * ReceiptNext — Service Worker * Version: 2.0.0 * Cache name: receiptnext-v2 * Strategy: Cache-first for shell assets, network-only for API * ============================================================ */ const CACHE_NAME = 'receiptnext-v1'; // Shell assets to pre-cache on install const SHELL_ASSETS = [ '/', '/static/css/style.css', '/static/favicon.svg', 'https://unpkg.com/htmx.org@1.9.10' ]; // API path prefix pattern — requests matching these are never cached const API_PATTERNS = [ '/api/', '/auth/', '/login', '/logout', '/register' ]; /* ------------------------------------------------------------ * Utility: determine whether a request targets an API endpoint * ------------------------------------------------------------ */ function isApiRequest(url) { return API_PATTERNS.some(pattern => url.pathname.startsWith(pattern)); } /* ------------------------------------------------------------ * Utility: determine whether a request is a shell asset eligible * for cache-first strategy * ------------------------------------------------------------ */ function isShellAsset(url) { const path = url.pathname; const origin = url.origin; // Same-origin shell pages if (origin === self.location.origin && (path === '/' || path === '')) { return true; } // Same-origin CSS if (origin === self.location.origin && path.startsWith('/static/css/')) { return true; } // Same-origin JS if (origin === self.location.origin && path.startsWith('/static/js/')) { return true; } // HTMX CDN (cache-first for fast loading) if (url.href === 'https://unpkg.com/htmx.org@1.9.10') { return true; } return false; } /* ------------------------------------------------------------ * INSTALL — Pre-cache shell assets * ------------------------------------------------------------ */ self.addEventListener('install', event => { console.log('[SW] Install event — caching shell assets'); event.waitUntil( caches.open(CACHE_NAME) .then(cache => { // Use addAll for atomic caching — if one fails, the whole // install fails and the SW won't activate return cache.addAll(SHELL_ASSETS).catch(err => { console.error('[SW] Failed to cache some shell assets:', err); // Still attempt to activate even if caching is partially // unsuccessful — the fetch handler will fall back to network throw err; }); }) .then(() => { console.log('[SW] Shell assets cached successfully'); return self.skipWaiting(); }) .catch(err => { console.error('[SW] Install failed:', err); // Still try to activate so the SW takes over return self.skipWaiting(); }) ); }); /* ------------------------------------------------------------ * ACTIVATE — Clean up old caches * ------------------------------------------------------------ */ self.addEventListener('activate', event => { console.log('[SW] Activate event — cleaning old caches'); event.waitUntil( caches.keys().then(cacheNames => { return Promise.all( cacheNames .filter(name => name !== CACHE_NAME) .map(name => { console.log('[SW] Deleting old cache:', name); return caches.delete(name); }) ); }).then(() => { console.log('[SW] Activated — taking control of all clients'); return self.clients.claim(); }).catch(err => { console.error('[SW] Activation cleanup failed:', err); // Continue even if cleanup fails return self.clients.claim(); }) ); }); /* ------------------------------------------------------------ * FETCH — Hybrid strategy * - Cache-first with network fallback for shell assets * - Network-only for API / dynamic endpoints * - Stale-while-revalidate for other same-origin assets * ------------------------------------------------------------ */ self.addEventListener('fetch', event => { const request = event.request; const url = new URL(request.url); // Ignore non-GET requests (POST, PUT, DELETE, etc.) if (request.method !== 'GET') { return; } // Ignore browser-extension and non-http(s) requests if (!url.protocol.startsWith('http')) { return; } // ── Strategy 1: Network-only for API calls ── if (isApiRequest(url)) { event.respondWith( fetch(request).catch(err => { console.warn('[SW] API fetch failed (offline?):', url.pathname, err); // Return a lightweight JSON error so the app can handle it gracefully return new Response( JSON.stringify({ error: 'You are offline. Please check your connection.' }), { status: 503, statusText: 'Service Unavailable', headers: { 'Content-Type': 'application/json' } } ); }) ); return; } // ── Strategy 2: Cache-first with network fallback for shell assets ── if (isShellAsset(url)) { event.respondWith( caches.match(request) .then(cachedResponse => { if (cachedResponse) { // Cache hit — return immediately return cachedResponse; } // Cache miss — fetch from network, then cache for future return fetch(request) .then(networkResponse => { // Only cache valid responses if (!networkResponse || networkResponse.status !== 200) { return networkResponse; } // Clone the response so we can cache one and return the other const responseToCache = networkResponse.clone(); caches.open(CACHE_NAME) .then(cache => { cache.put(request, responseToCache).catch(err => { console.error('[SW] Failed to cache asset:', url.pathname, err); }); }) .catch(err => { console.error('[SW] Failed to open cache for storing:', err); }); return networkResponse; }) .catch(err => { console.warn('[SW] Network fetch failed for shell asset:', url.pathname, err); // Return a minimal offline fallback for navigations if (request.mode === 'navigate') { return new Response( '
ReceiptNext needs an internet connection to load. Please check your connection and try again.
', { status: 503, statusText: 'Service Unavailable', headers: { 'Content-Type': 'text/html; charset=utf-8' } } ); } return new Response( 'Offline — resource not available', { status: 503, statusText: 'Service Unavailable', headers: { 'Content-Type': 'text/plain; charset=utf-8' } } ); }); }) .catch(err => { console.error('[SW] Cache match error:', err); // Fall through to network return fetch(request).catch(() => { return new Response( 'An unexpected error occurred.', { status: 502, headers: { 'Content-Type': 'text/plain' } } ); }); }) ); return; } // ── Strategy 3: Network-first for all other (non-shell, non-API) assets ── // This covers images, fonts, etc. — try network first, fall back to cache event.respondWith( fetch(request) .then(networkResponse => { // Cache successful responses for offline fallback if (networkResponse && networkResponse.status === 200) { const responseToCache = networkResponse.clone(); caches.open(CACHE_NAME) .then(cache => { cache.put(request, responseToCache).catch(err => { console.error('[SW] Failed to cache dynamic asset:', url.pathname, err); }); }) .catch(err => { console.error('[SW] Failed to open cache for dynamic asset:', err); }); } return networkResponse; }) .catch(err => { console.warn('[SW] Network failed, trying cache for:', url.pathname, err); return caches.match(request).then(cachedResponse => { if (cachedResponse) { return cachedResponse; } // Nothing in cache either — return a basic error return new Response( 'Resource unavailable offline.', { status: 503, headers: { 'Content-Type': 'text/plain' } } ); }); }) ); });