rmaps-online/public/sw.js

427 lines
11 KiB
JavaScript

// 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');