// rMaps Service Worker for Push Notifications, Background Location & Offline Support const CACHE_VERSION = 'v2'; const STATIC_CACHE = `rmaps-static-${CACHE_VERSION}`; const TILE_CACHE = `rmaps-tiles-${CACHE_VERSION}`; const DATA_CACHE = `rmaps-data-${CACHE_VERSION}`; const SYNC_TAG = 'rmaps-location-sync'; // Assets to precache for app shell const PRECACHE_ASSETS = [ '/', '/icon-192.png', '/icon-512.png', '/manifest.json', ]; // Tile URL patterns to cache const TILE_PATTERNS = [ /basemaps\.cartocdn\.com/, /tile\.openstreetmap\.org/, ]; // Max tiles to cache (prevent storage bloat) const MAX_TILE_CACHE_SIZE = 500; // ==================== INSTALL ==================== self.addEventListener('install', (event) => { console.log('[SW] Installing service worker...'); event.waitUntil( caches.open(STATIC_CACHE) .then((cache) => { console.log('[SW] Precaching app shell'); return cache.addAll(PRECACHE_ASSETS); }) .then(() => self.skipWaiting()) .catch((err) => { console.error('[SW] Precache failed:', err); // Don't fail installation if precache fails return self.skipWaiting(); }) ); }); // ==================== ACTIVATE ==================== self.addEventListener('activate', (event) => { console.log('[SW] Activating service worker...'); event.waitUntil( caches.keys() .then((cacheNames) => { return Promise.all( cacheNames .filter((name) => { // Delete old versioned caches return name.startsWith('rmaps-') && name !== STATIC_CACHE && name !== TILE_CACHE && name !== DATA_CACHE; }) .map((name) => { console.log('[SW] Deleting old cache:', name); return caches.delete(name); }) ); }) .then(() => self.clients.claim()) ); }); // ==================== FETCH ==================== self.addEventListener('fetch', (event) => { const url = new URL(event.request.url); // Skip non-GET requests if (event.request.method !== 'GET') return; // Skip chrome-extension and other non-http(s) requests if (!url.protocol.startsWith('http')) return; // Map tiles - cache-first strategy if (isTileRequest(url)) { event.respondWith(handleTileRequest(event.request)); return; } // API requests - network-first strategy if (url.pathname.startsWith('/api/')) { event.respondWith(handleApiRequest(event.request)); return; } // Static assets and pages - stale-while-revalidate event.respondWith(handleStaticRequest(event.request)); }); // Check if URL is a map tile function isTileRequest(url) { return TILE_PATTERNS.some(pattern => pattern.test(url.href)); } // Handle map tile requests - cache-first for fast loading async function handleTileRequest(request) { const cache = await caches.open(TILE_CACHE); const cached = await cache.match(request); if (cached) { // Return cached tile immediately, refresh in background refreshTileCache(request, cache); return cached; } try { const response = await fetch(request); if (response.ok) { // Clone and cache the response cache.put(request, response.clone()); // Trim cache if too large trimCache(cache, MAX_TILE_CACHE_SIZE); } return response; } catch (error) { console.log('[SW] Tile fetch failed, no cache:', request.url); // Return a placeholder or transparent tile return new Response('', { status: 404 }); } } // Refresh tile in background async function refreshTileCache(request, cache) { try { const response = await fetch(request); if (response.ok) { cache.put(request, response); } } catch (e) { // Ignore background refresh failures } } // Handle API requests - network-first with cache fallback async function handleApiRequest(request) { const cache = await caches.open(DATA_CACHE); try { const response = await fetch(request); if (response.ok) { cache.put(request, response.clone()); } return response; } catch (error) { console.log('[SW] API fetch failed, trying cache:', request.url); const cached = await cache.match(request); if (cached) { return cached; } return new Response(JSON.stringify({ error: 'Offline' }), { status: 503, headers: { 'Content-Type': 'application/json' } }); } } // Handle static assets - stale-while-revalidate async function handleStaticRequest(request) { const cache = await caches.open(STATIC_CACHE); const cached = await cache.match(request); const fetchPromise = fetch(request) .then((response) => { if (response.ok) { cache.put(request, response.clone()); } return response; }) .catch(() => null); // Return cached version immediately if available if (cached) { // Refresh in background fetchPromise; return cached; } // Wait for network if no cache const networkResponse = await fetchPromise; if (networkResponse) { return networkResponse; } // Fallback to root for navigation requests (SPA) if (request.mode === 'navigate') { const rootCached = await cache.match('/'); if (rootCached) { return rootCached; } } return new Response('Offline', { status: 503 }); } // Trim cache to max size (LRU-ish - just delete oldest) async function trimCache(cache, maxSize) { const keys = await cache.keys(); if (keys.length > maxSize) { const deleteCount = keys.length - maxSize; console.log(`[SW] Trimming ${deleteCount} tiles from cache`); for (let i = 0; i < deleteCount; i++) { await cache.delete(keys[i]); } } } // ==================== INDEXEDDB FOR ROOM STATE ==================== const DB_NAME = 'rmaps-offline'; const DB_VERSION = 1; const ROOM_STORE = 'room-state'; function openDB() { return new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onerror = () => reject(request.error); request.onsuccess = () => resolve(request.result); request.onupgradeneeded = (event) => { const db = event.target.result; if (!db.objectStoreNames.contains(ROOM_STORE)) { db.createObjectStore(ROOM_STORE, { keyPath: 'slug' }); } }; }); } async function saveRoomState(slug, state) { try { const db = await openDB(); const tx = db.transaction(ROOM_STORE, 'readwrite'); const store = tx.objectStore(ROOM_STORE); await store.put({ slug, state, timestamp: Date.now() }); console.log('[SW] Room state saved:', slug); } catch (error) { console.error('[SW] Failed to save room state:', error); } } async function getRoomState(slug) { try { const db = await openDB(); const tx = db.transaction(ROOM_STORE, 'readonly'); const store = tx.objectStore(ROOM_STORE); const request = store.get(slug); return new Promise((resolve) => { request.onsuccess = () => resolve(request.result?.state || null); request.onerror = () => resolve(null); }); } catch (error) { console.error('[SW] Failed to get room state:', error); return null; } } // ==================== BACKGROUND SYNC ==================== self.addEventListener('sync', (event) => { console.log('[SW] Sync event:', event.tag); if (event.tag === SYNC_TAG) { event.waitUntil(syncLocation()); } }); async function syncLocation() { try { const clients = await self.clients.matchAll({ type: 'window' }); for (const client of clients) { client.postMessage({ type: 'REQUEST_LOCATION_SYNC' }); } console.log('[SW] Location sync requested from clients'); } catch (error) { console.error('[SW] Location sync failed:', error); } } async function requestLocationFromClient() { const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); if (clients.length > 0) { clients[0].postMessage({ type: 'REQUEST_LOCATION_UPDATE' }); return true; } return false; } // ==================== PUSH NOTIFICATIONS ==================== self.addEventListener('push', (event) => { console.log('[SW] Push received:', event); let data = { title: 'rMaps', body: 'You have a new notification', icon: '/icon-192.png', badge: '/icon-192.png', tag: 'rmaps-notification', data: {}, silent: false, }; if (event.data) { try { const payload = event.data.json(); data = { ...data, ...payload }; } catch (e) { data.body = event.data.text(); } } // Handle silent push - request location update without showing notification if (data.silent || data.data?.type === 'location_request') { event.waitUntil( requestLocationFromClient().then((sent) => { console.log('[SW] Silent push - location request sent:', sent); }) ); return; } const options = { body: data.body, icon: data.icon || '/icon-192.png', badge: data.badge || '/icon-192.png', tag: data.tag || 'rmaps-notification', data: data.data || {}, vibrate: [200, 100, 200, 100, 400], actions: data.actions || [], requireInteraction: data.requireInteraction || false, silent: false, renotify: true, }; event.waitUntil( self.registration.showNotification(data.title, options) ); }); // ==================== NOTIFICATION EVENTS ==================== self.addEventListener('notificationclick', (event) => { console.log('[SW] Notification clicked:', event); event.notification.close(); const data = event.notification.data || {}; let targetUrl = '/'; if (data.roomSlug) { targetUrl = `/${data.roomSlug}`; } else if (data.url) { targetUrl = data.url; } if (event.action === 'view') { targetUrl = data.url || targetUrl; } else if (event.action === 'dismiss') { return; } event.waitUntil( clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => { for (const client of clientList) { if (client.url.includes(targetUrl) && 'focus' in client) { return client.focus(); } } if (clients.openWindow) { return clients.openWindow(targetUrl); } }) ); }); self.addEventListener('notificationclose', (event) => { console.log('[SW] Notification closed:', event); }); // ==================== MESSAGE HANDLER ==================== self.addEventListener('message', async (event) => { console.log('[SW] Message received:', event.data); if (!event.data) return; switch (event.data.type) { case 'SKIP_WAITING': self.skipWaiting(); break; case 'SAVE_ROOM_STATE': if (event.data.slug && event.data.state) { await saveRoomState(event.data.slug, event.data.state); } break; case 'GET_ROOM_STATE': if (event.data.slug) { const state = await getRoomState(event.data.slug); event.source.postMessage({ type: 'ROOM_STATE', slug: event.data.slug, state }); } break; case 'CLEAR_CACHES': await caches.delete(STATIC_CACHE); await caches.delete(TILE_CACHE); await caches.delete(DATA_CACHE); console.log('[SW] All caches cleared'); break; } }); console.log('[SW] Service worker loaded');