// NextSnap Service Worker // Provides offline-first caching for the app shell const CACHE_VERSION = 'nextsnap-v25'; const APP_SHELL_CACHE = 'nextsnap-shell-v21'; const RUNTIME_CACHE = 'nextsnap-runtime-v21'; // Offline fallback page with bottom nav bar so user can navigate to cached pages const OFFLINE_PAGE = ` Offline - NextSnap

NextSnap

Offline Mode

You're Offline

This page requires an internet connection.
Capture and Queue are available offline.

`; // Assets to cache on install const APP_SHELL_ASSETS = [ '/static/css/style.css', '/static/js/app.js', '/static/js/auth.js', '/static/js/camera.js', '/static/js/storage.js', '/static/js/sync.js', '/static/js/filebrowser.js', '/static/lib/dexie.min.js', '/static/manifest.json', '/static/icons/icon-192.png', '/static/icons/icon-512.png' ]; // Install event - precache app shell self.addEventListener('install', (event) => { console.log('[SW] Installing service worker...'); event.waitUntil( caches.open(APP_SHELL_CACHE) .then((cache) => { console.log('[SW] Caching app shell'); return cache.addAll(APP_SHELL_ASSETS); }) .then(() => { console.log('[SW] App shell cached successfully'); return self.skipWaiting(); // Activate immediately }) .catch((error) => { console.error('[SW] Failed to cache app shell:', error); }) ); }); // Activate event - clean up old caches self.addEventListener('activate', (event) => { console.log('[SW] Activating service worker...'); event.waitUntil( caches.keys() .then((cacheNames) => { return Promise.all( cacheNames.map((cacheName) => { if (cacheName !== APP_SHELL_CACHE && cacheName !== RUNTIME_CACHE) { console.log('[SW] Deleting old cache:', cacheName); return caches.delete(cacheName); } }) ); }) .then(() => { // Purge cached online-only pages so they show offline fallback return caches.open(RUNTIME_CACHE).then((cache) => { return Promise.all( ['/browser', '/admin', '/reviewer'].map(path => cache.delete(new Request(path)).catch(() => {}) ) ); }); }) .then(() => { console.log('[SW] Service worker activated'); return self.clients.claim(); // Take control immediately }) ); }); // Fetch event - route requests to appropriate caching strategy self.addEventListener('fetch', (event) => { const { request } = event; const url = new URL(request.url); // Skip non-GET requests (uploads, form posts, etc.) if (request.method !== 'GET') { return; } // Static assets (/static/) - cache-first (versioned via SW cache bump) if (url.pathname.startsWith('/static/')) { event.respondWith( caches.match(request) .then((cachedResponse) => { if (cachedResponse) { return cachedResponse; } return fetch(request) .then((response) => { if (!response || response.status !== 200) { return response; } const responseClone = response.clone(); caches.open(APP_SHELL_CACHE).then((cache) => { cache.put(request, responseClone); }); return response; }) .catch(() => new Response('Offline', { status: 503 })); }) ); return; } // Pages that require connectivity - never cache, always show offline page const ONLINE_ONLY = ['/browser', '/admin', '/reviewer']; const isOnlineOnly = ONLINE_ONLY.includes(url.pathname); // Everything else (pages, API) - network-first with cache fallback event.respondWith( fetch(request) .then((response) => { // Cache successful GET responses for offline fallback // Skip online-only pages - they need live data if (response.status === 200 && !isOnlineOnly) { const responseClone = response.clone(); caches.open(RUNTIME_CACHE).then((cache) => { // Don't cache file uploads or large binary responses if (!url.pathname.includes('/upload') && !url.pathname.includes('/thumbnail')) { cache.put(request, responseClone); } }); } return response; }) .catch(() => { // Online-only pages go straight to offline fallback if (isOnlineOnly) { return new Response(OFFLINE_PAGE, { status: 200, headers: { 'Content-Type': 'text/html' } }); } // Network failed - try cache return caches.match(request) .then((cachedResponse) => { if (cachedResponse) { return cachedResponse; } // No cache - return offline response if (url.pathname.startsWith('/api/')) { return new Response( JSON.stringify({ error: 'offline', message: 'You are offline.' }), { status: 503, headers: { 'Content-Type': 'application/json' } } ); } return new Response(OFFLINE_PAGE, { status: 200, headers: { 'Content-Type': 'text/html' } }); }); }) ); }); // Listen for messages from clients self.addEventListener('message', (event) => { if (event.data && event.data.type === 'SKIP_WAITING') { self.skipWaiting(); } if (event.data && event.data.type === 'CLEAR_CACHE') { event.waitUntil( caches.keys().then((cacheNames) => { return Promise.all( cacheNames.map((cacheName) => caches.delete(cacheName)) ); }) ); } });