/** * Tour Builder Platform - Service Worker * * Provides offline caching for PWA functionality. * Caches tour assets (images, videos, audio) for offline viewing. */ const CACHE_NAME = 'tour-builder-v1'; const STATIC_CACHE_NAME = 'tour-builder-static-v1'; const DYNAMIC_CACHE_NAME = 'tour-builder-dynamic-v1'; // Static assets to cache on install const STATIC_ASSETS = [ '/', '/runtime', '/offline.html', ]; // Asset types to cache const CACHEABLE_EXTENSIONS = [ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico', '.mp4', '.webm', '.mov', '.mp3', '.wav', '.ogg', '.m4a', '.woff', '.woff2', '.ttf', '.eot', '.css', '.js', ]; // Check if request should be cached const isCacheableRequest = (request) => { const url = new URL(request.url); // Don't cache API requests (except static assets from /file/download) if (url.pathname.startsWith('/api/') && !url.pathname.includes('/file/download')) { return false; } // Cache known asset extensions const hasExtension = CACHEABLE_EXTENSIONS.some((ext) => url.pathname.toLowerCase().endsWith(ext)); if (hasExtension) { return true; } // Cache file downloads (S3 presigned URLs, CDN assets) if (url.pathname.includes('/file/download') || url.hostname.includes('amazonaws.com') || url.hostname.includes('cloudfront.net')) { return true; } return false; }; // Install event - cache static assets self.addEventListener('install', (event) => { event.waitUntil( caches.open(STATIC_CACHE_NAME).then((cache) => { console.log('[SW] Caching static assets'); return cache.addAll(STATIC_ASSETS).catch((error) => { console.warn('[SW] Failed to cache some static assets:', error); }); }) ); self.skipWaiting(); }); // Activate event - clean up old caches self.addEventListener('activate', (event) => { event.waitUntil( caches.keys().then((cacheNames) => { return Promise.all( cacheNames .filter((name) => { return name.startsWith('tour-builder-') && name !== STATIC_CACHE_NAME && name !== DYNAMIC_CACHE_NAME; }) .map((name) => { console.log('[SW] Deleting old cache:', name); return caches.delete(name); }) ); }) ); self.clients.claim(); }); // Fetch event - serve from cache or network self.addEventListener('fetch', (event) => { const { request } = event; // Only handle GET requests if (request.method !== 'GET') { return; } // Skip non-http(s) requests if (!request.url.startsWith('http')) { return; } event.respondWith( caches.match(request).then((cachedResponse) => { // Return cached response if available if (cachedResponse) { // Optionally update cache in background (stale-while-revalidate) if (isCacheableRequest(request)) { event.waitUntil( fetch(request) .then((networkResponse) => { if (networkResponse && networkResponse.status === 200) { const responseToCache = networkResponse.clone(); caches.open(DYNAMIC_CACHE_NAME).then((cache) => { cache.put(request, responseToCache); }); } }) .catch(() => { // Network failed, but we have cache - that's fine }) ); } return cachedResponse; } // Fetch from network return fetch(request) .then((networkResponse) => { // Cache successful responses for cacheable requests if (networkResponse && networkResponse.status === 200 && isCacheableRequest(request)) { const responseToCache = networkResponse.clone(); caches.open(DYNAMIC_CACHE_NAME).then((cache) => { cache.put(request, responseToCache); }); } return networkResponse; }) .catch((error) => { console.warn('[SW] Fetch failed:', error); // Return offline page for navigation requests if (request.mode === 'navigate') { return caches.match('/offline.html'); } // Return empty response for assets return new Response('', { status: 503, statusText: 'Service Unavailable', }); }); }) ); }); // Message event - handle commands from main thread self.addEventListener('message', (event) => { const { type, payload } = event.data || {}; switch (type) { case 'CACHE_ASSETS': // Cache specific assets for a project/page if (Array.isArray(payload?.urls)) { event.waitUntil( caches.open(DYNAMIC_CACHE_NAME).then((cache) => { return Promise.all( payload.urls.map((url) => fetch(url) .then((response) => { if (response.status === 200) { return cache.put(url, response); } }) .catch((error) => { console.warn('[SW] Failed to cache asset:', url, error); }) ) ); }) ); } break; case 'CLEAR_CACHE': // Clear all dynamic caches event.waitUntil( caches.delete(DYNAMIC_CACHE_NAME).then(() => { console.log('[SW] Dynamic cache cleared'); }) ); break; case 'GET_CACHE_STATUS': // Return current cache status event.waitUntil( caches.open(DYNAMIC_CACHE_NAME).then((cache) => { return cache.keys().then((keys) => { event.source.postMessage({ type: 'CACHE_STATUS', payload: { cachedCount: keys.length, urls: keys.map((request) => request.url), }, }); }); }) ); break; default: break; } }); console.log('[SW] Service worker loaded');