const CACHE_NAME = 'trips-v17'; const STATIC_CACHE = 'trips-static-v17'; const DATA_CACHE = 'trips-data-v17'; const IMAGE_CACHE = 'trips-images-v17'; // Static assets to cache on install const STATIC_ASSETS = [ '/' ]; // Install event - cache static assets self.addEventListener('install', (event) => { console.log('[SW] Installing service worker...'); event.waitUntil( caches.open(STATIC_CACHE) .then((cache) => { console.log('[SW] Caching static assets'); return cache.addAll(STATIC_ASSETS); }) .then(() => self.skipWaiting()) ); }); // 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) => { // Delete old version caches if (cacheName.startsWith('trips-') && cacheName !== STATIC_CACHE && cacheName !== DATA_CACHE && cacheName !== IMAGE_CACHE) { console.log('[SW] Deleting old cache:', cacheName); return caches.delete(cacheName); } }) ); }).then(() => self.clients.claim()) ); }); // Check if response is valid (not error page, not Cloudflare error) function isValidResponse(response) { if (!response) return false; if (!response.ok) return false; if (response.redirected) return false; if (response.status !== 200) return false; // Check content-type to detect error pages const contentType = response.headers.get('content-type') || ''; // If we expect HTML but got something else, it might be an error return true; } // Check if response is a Cloudflare/proxy error page async function isErrorPage(response) { if (!response) return true; if (response.status >= 500) return true; if (response.status === 0) return true; // Clone to read body without consuming const clone = response.clone(); try { const text = await clone.text(); // Detect common error page signatures if (text.includes('cloudflare') && text.includes('Error')) return true; if (text.includes('502 Bad Gateway')) return true; if (text.includes('503 Service')) return true; if (text.includes('504 Gateway')) return true; } catch (e) { // If we can't read it, assume it's fine } return false; } // Fetch event - serve from cache, fallback to network self.addEventListener('fetch', (event) => { const url = new URL(event.request.url); // Skip non-GET requests if (event.request.method !== 'GET') { return; } // Never cache auth pages - always go to network if (url.pathname === '/login' || url.pathname === '/logout' || url.pathname.startsWith('/auth')) { return; } // Handle CDN requests (Quill, etc.) - cache first if (url.origin !== location.origin) { if (url.hostname.includes('cdn.jsdelivr.net')) { event.respondWith( caches.match(event.request).then(cached => { return cached || fetch(event.request); }) ); } return; } // Handle API requests if (url.pathname.startsWith('/api/')) { event.respondWith(handleApiRequest(event.request)); return; } // Handle image requests if (url.pathname.startsWith('/images/')) { event.respondWith(handleImageRequest(event.request)); return; } // Handle document requests if (url.pathname.startsWith('/documents/')) { event.respondWith(handleDocumentRequest(event.request)); return; } // Handle trip pages - CACHE FIRST for offline support if (url.pathname.startsWith('/trip/')) { event.respondWith(handleTripPageRequest(event.request)); return; } // Handle main page - cache first, then pre-cache all trips if (url.pathname === '/') { event.respondWith(handleMainPageRequest(event.request)); return; } // Default: cache first, network fallback event.respondWith(handleStaticRequest(event.request)); }); // Handle main page - NETWORK FIRST, only cache when offline async function handleMainPageRequest(request) { // Try network first try { const response = await fetch(request); // If redirected (to login), clear cache and return the redirect if (response.redirected) { console.log('[SW] Main page redirected (likely to login), clearing cache'); const cache = await caches.open(STATIC_CACHE); cache.delete(request); return response; } if (response.ok) { // Clone to read HTML and cache main page images const htmlText = await response.clone().text(); const cache = await caches.open(STATIC_CACHE); cache.put(request, response.clone()); // Cache main page images in background cacheMainPageImages(htmlText); return response; } // Non-OK response, just return it return response; } catch (error) { // Network failed - we're offline, try cache console.log('[SW] Main page network failed, trying cache'); const cachedResponse = await caches.match(request); if (cachedResponse) return cachedResponse; return createOfflineResponse('Main page not available offline'); } } // Cache images from main page async function cacheMainPageImages(html) { const imageCache = await caches.open(IMAGE_CACHE); const imageUrls = extractUrls(html, /\/images\/[^"'\s<>]+/g); for (const imgUrl of imageUrls) { try { const existing = await caches.match(imgUrl); if (!existing) { const imgResponse = await fetch(imgUrl); if (imgResponse.ok) { await imageCache.put(imgUrl, imgResponse); console.log('[SW] Cached main page image:', imgUrl); } } } catch (e) { /* skip */ } } } // Handle trip pages - NETWORK FIRST, fall back to cache only when offline async function handleTripPageRequest(request) { // Always try network first try { console.log('[SW] Fetching trip from network:', request.url); const response = await fetch(request); // Check if response is valid if (response.ok && response.status === 200) { // Check if we got redirected to login/auth page (auth failure) const finalUrl = response.url || ''; if (finalUrl.includes('/login') || finalUrl.includes('pocket.') || finalUrl.includes('/authorize') || finalUrl.includes('/oauth')) { console.log('[SW] Redirected to auth, not caching:', finalUrl); // Clear any cached version of this page since user is logged out const cache = await caches.open(DATA_CACHE); cache.delete(request); return response; } // Cache and return the valid response const cache = await caches.open(DATA_CACHE); cache.put(request, response.clone()); console.log('[SW] Cached trip page:', request.url); return response; } // Non-200 response, just return it (might be login redirect) console.log('[SW] Non-200 response:', response.status, request.url); return response; } catch (error) { // Network failed - we're offline, try cache console.log('[SW] Network failed, trying cache for trip:', request.url); const cachedResponse = await caches.match(request); if (cachedResponse) { console.log('[SW] Serving trip from cache (offline):', request.url); // Add header to indicate offline mode const headers = new Headers(cachedResponse.headers); headers.set('X-From-Cache', 'true'); return new Response(cachedResponse.body, { status: cachedResponse.status, statusText: cachedResponse.statusText, headers: headers }); } return createOfflineResponse('Trip not available offline. Download this trip while online first.'); } } // Update trip cache in background async function updateTripCache(request) { try { const response = await fetch(request); if (response.ok && response.status === 200) { // Skip if redirected to login/auth const finalUrl = response.url || ''; if (finalUrl.includes('/login') || finalUrl.includes('pocket.') || finalUrl.includes('/authorize') || finalUrl.includes('/oauth')) return; const cache = await caches.open(DATA_CACHE); cache.put(request, response.clone()); console.log('[SW] Updated cache for:', request.url); } } catch (error) { // Silent fail - we already served from cache } } // Send progress message to all clients async function sendProgress(message, progress, total) { const clients = await self.clients.matchAll(); clients.forEach(client => { client.postMessage({ type: 'CACHE_PROGRESS', message, progress, total }); }); } // Pre-cache all trips when main page loads let preCacheInProgress = false; async function preCacheAllTrips() { // Prevent duplicate runs if (preCacheInProgress) return; preCacheInProgress = true; console.log('[SW] Pre-caching all trips (full offline mode)...'); // Small delay to ensure page JS has loaded await new Promise(r => setTimeout(r, 500)); try { const response = await fetch('/api/trips', { credentials: 'include' }); if (!response.ok) { console.log('[SW] Failed to fetch trips list:', response.status); await sendProgress('Offline sync failed - not logged in?', 0, 0); preCacheInProgress = false; return; } const trips = await response.json(); const dataCache = await caches.open(DATA_CACHE); const imageCache = await caches.open(IMAGE_CACHE); const totalTrips = trips.length; let currentTrip = 0; let totalAssets = 0; let cachedAssets = 0; await sendProgress('Starting offline sync...', 0, totalTrips); // Cache the trips list API response const tripsResponse = await fetch('/api/trips', { credentials: 'include' }); if (tripsResponse.ok) { dataCache.put('/api/trips', tripsResponse); } // Cache each trip page and its assets for (const trip of trips) { currentTrip++; const tripUrl = `/trip/${trip.id}`; await sendProgress(`Caching: ${trip.name}`, currentTrip, totalTrips); try { const tripResponse = await fetch(tripUrl, { credentials: 'include' }); if (tripResponse.ok && tripResponse.status === 200) { // Skip if redirected to login/auth const finalUrl = tripResponse.url || ''; if (finalUrl.includes('/login') || finalUrl.includes('pocket.') || finalUrl.includes('/authorize') || finalUrl.includes('/oauth')) { console.log('[SW] Auth required for trip:', trip.name); continue; } // Clone response to read HTML and still cache it const htmlText = await tripResponse.clone().text(); await dataCache.put(tripUrl, tripResponse); console.log('[SW] Pre-cached trip:', trip.name); // Extract and cache all images const imageUrls = extractUrls(htmlText, /\/images\/[^"'\s<>]+/g); totalAssets += imageUrls.length; for (const imgUrl of imageUrls) { try { const existing = await caches.match(imgUrl); if (!existing) { const imgResponse = await fetch(imgUrl); if (imgResponse.ok) { await imageCache.put(imgUrl, imgResponse); cachedAssets++; console.log('[SW] Cached image:', imgUrl); } } else { cachedAssets++; } } catch (e) { /* skip failed images */ } } // Extract and cache all documents const docUrls = extractUrls(htmlText, /\/documents\/[^"'\s<>]+/g); totalAssets += docUrls.length; for (const docUrl of docUrls) { try { const existing = await caches.match(docUrl); if (!existing) { const docResponse = await fetch(docUrl); if (docResponse.ok) { await dataCache.put(docUrl, docResponse); cachedAssets++; console.log('[SW] Cached document:', docUrl); } } else { cachedAssets++; } } catch (e) { /* skip failed docs */ } } } } catch (e) { console.log('[SW] Failed to pre-cache trip:', trip.name, e); } } await sendProgress('complete', totalTrips, totalTrips); console.log('[SW] Full pre-caching complete!', totalTrips, 'trips,', cachedAssets, 'files'); preCacheInProgress = false; } catch (error) { console.error('[SW] Pre-caching failed:', error); await sendProgress('Offline sync failed', 0, 0); preCacheInProgress = false; } } // Helper to extract URLs from HTML using regex function extractUrls(html, pattern) { const matches = html.match(pattern) || []; // Dedupe and clean return [...new Set(matches.map(url => { // Decode HTML entities and remove trailing quotes return url.replace(/&/g, '&').replace(/["'<>]/g, ''); }))]; } // Handle static assets - cache first async function handleStaticRequest(request) { const cachedResponse = await caches.match(request); if (cachedResponse) { // Return cached, but also update cache in background fetchAndCache(request, STATIC_CACHE); return cachedResponse; } return fetchAndCache(request, STATIC_CACHE); } // Handle API requests - cache first for trip data, network for others async function handleApiRequest(request) { const url = new URL(request.url); // These endpoints can be cached for offline use const cacheableEndpoints = [ '/api/trips', '/api/trip/' ]; const shouldCache = cacheableEndpoints.some(endpoint => url.pathname.startsWith(endpoint)); // NETWORK FIRST - always try network, only use cache when offline try { const response = await fetch(request); if (response.ok && shouldCache) { const cache = await caches.open(DATA_CACHE); cache.put(request, response.clone()); } return response; } catch (error) { // Network failed - we're offline, try cache console.log('[SW] API request failed (offline), trying cache:', request.url); const cachedResponse = await caches.match(request); if (cachedResponse) { const headers = new Headers(cachedResponse.headers); headers.set('X-From-Cache', 'true'); return new Response(cachedResponse.body, { status: cachedResponse.status, statusText: cachedResponse.statusText, headers: headers }); } return createOfflineJsonResponse({ error: 'Offline - data not cached' }); } } // Fetch and cache API in background async function fetchAndCacheApi(request) { try { const response = await fetch(request); if (response.ok) { const cache = await caches.open(DATA_CACHE); cache.put(request, response.clone()); } } catch (e) { // Silent fail } } // Handle image requests - cache first (images don't change) async function handleImageRequest(request) { const cachedResponse = await caches.match(request); if (cachedResponse) { return cachedResponse; } try { const response = await fetch(request); if (response.ok) { const cache = await caches.open(IMAGE_CACHE); cache.put(request, response.clone()); } return response; } catch (error) { console.log('[SW] Image not available offline:', request.url); return createPlaceholderImage(); } } // Handle document requests - cache first async function handleDocumentRequest(request) { const cachedResponse = await caches.match(request); if (cachedResponse) { return cachedResponse; } try { const response = await fetch(request); if (response.ok) { const cache = await caches.open(DATA_CACHE); cache.put(request, response.clone()); } return response; } catch (error) { console.log('[SW] Document not available offline:', request.url); return createOfflineResponse('Document not available offline'); } } // Helper: fetch and cache async function fetchAndCache(request, cacheName) { try { const response = await fetch(request); if (response.ok) { const cache = await caches.open(cacheName); cache.put(request, response.clone()); } return response; } catch (error) { const cachedResponse = await caches.match(request); if (cachedResponse) { return cachedResponse; } throw error; } } // Helper: create offline HTML response function createOfflineResponse(message) { return new Response( `
${message}