// Bambuddy Service Worker const CACHE_NAME = 'bambuddy-v27'; const STATIC_CACHE = 'bambuddy-static-v26'; // Static assets to cache on install const STATIC_ASSETS = [ '/', '/manifest.json', '/img/favicon.png', '/img/favicon-16x16.png', '/img/favicon-32x32.png', '/img/android-chrome-192x192.png', '/img/android-chrome-512x512.png', '/img/apple-touch-icon.png', '/img/bambuddy_logo_dark.png', // Self-hosted Inter font (#1460) - cached so the UI renders offline. '/fonts/inter-latin.woff2', '/fonts/inter-latin-ext.woff2', ]; // 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); }) ); // Activate immediately 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 .filter((name) => name !== CACHE_NAME && name !== STATIC_CACHE) .map((name) => { console.log('[SW] Deleting old cache:', name); return caches.delete(name); }) ); }) ); // Take control immediately self.clients.claim(); }); // Fetch event - network-first for API, cache-first for static self.addEventListener('fetch', (event) => { const { request } = event; const url = new URL(request.url); // Skip non-GET requests if (request.method !== 'GET') { return; } // Skip cross-origin requests - let the browser handle them directly. // Without this the catch-all HTML branch below would answer a failed // cross-origin request with our cached index.html, so e.g. a blocked // Google Fonts request came back as text/html (#1460). if (url.origin !== self.location.origin) { return; } // Skip WebSocket connections if (url.protocol === 'ws:' || url.protocol === 'wss:') { return; } // Skip camera stream/snapshot requests - Safari has issues with streaming through SW if (url.pathname.includes('/camera/stream') || url.pathname.includes('/camera/snapshot')) { return; } // API requests - network first, no cache (real-time data is critical) if (url.pathname.startsWith('/api/')) { event.respondWith( fetch(request).catch(() => { // Return offline response for API failures return new Response( JSON.stringify({ error: 'offline', message: 'You are currently offline' }), { status: 503, headers: { 'Content-Type': 'application/json' }, } ); }) ); return; } // Static assets - cache first, then network if ( url.pathname.startsWith('/img/') || url.pathname.startsWith('/icons/') || url.pathname.startsWith('/fonts/') || url.pathname.endsWith('.png') || url.pathname.endsWith('.jpg') || url.pathname.endsWith('.svg') || url.pathname.endsWith('.ico') || url.pathname.endsWith('.woff2') ) { event.respondWith( caches.match(request).then((cached) => { if (cached) { return cached; } return fetch(request).then((response) => { // Cache successful responses if (response.ok) { const clone = response.clone(); caches.open(STATIC_CACHE).then((cache) => { cache.put(request, clone); }); } return response; }); }) ); return; } // JS/CSS assets - network first (Vite content-hashes filenames, so // cache-busting is built in; network-first ensures new builds load immediately) if ( url.pathname.startsWith('/assets/') || url.pathname.endsWith('.js') || url.pathname.endsWith('.css') ) { event.respondWith( fetch(request) .then((response) => { if (response.ok) { const clone = response.clone(); caches.open(CACHE_NAME).then((cache) => { cache.put(request, clone); }); } return response; }) .catch(() => { return caches.match(request); }) ); return; } // HTML pages - network first, fall back to cache event.respondWith( fetch(request) .then((response) => { if (response.ok) { const clone = response.clone(); caches.open(CACHE_NAME).then((cache) => { cache.put(request, clone); }); } return response; }) .catch(() => { return caches.match(request).then((cached) => { if (cached) { return cached; } // Return cached index for SPA navigation return caches.match('/'); }); }) ); }); // Handle push notifications (for future use) self.addEventListener('push', (event) => { if (!event.data) return; const data = event.data.json(); const options = { body: data.body || 'New notification from Bambuddy', icon: '/img/android-chrome-192x192.png', badge: '/img/favicon-32x32.png', vibrate: [100, 50, 100], data: { url: data.url || '/', }, }; event.waitUntil( self.registration.showNotification(data.title || 'Bambuddy', options) ); }); // Handle notification clicks self.addEventListener('notificationclick', (event) => { event.notification.close(); const url = event.notification.data?.url || '/'; event.waitUntil( clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => { // Check if there's already a window open for (const client of windowClients) { if (client.url.includes(self.location.origin) && 'focus' in client) { client.navigate(url); return client.focus(); } } // Open a new window if none exists if (clients.openWindow) { return clients.openWindow(url); } }) ); });