// Shivao Service Worker — offline real // Estratégia: shell precachado, tiles cache-first, windy network-first, /api passa direto. // Versão usada nos cache names — bumpa essa string pra invalidar caches antigos em deploys. const VERSION = 'shivao-v1'; const SHELL_CACHE = `shivao-shell-${VERSION}`; const TILES_CACHE = 'shivao-tiles-v1'; // separado pra não invalidar tiles em update do shell const WINDY_CACHE = `shivao-windy-${VERSION}`; // Recursos do "shell" (UI essencial). Pré-cacheados no install. const SHELL_URLS = [ '/', '/manifest.json', '/icon.svg', // Leaflet (cdnjs) 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css', 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js', ]; self.addEventListener('install', (event) => { event.waitUntil( caches.open(SHELL_CACHE).then(async (cache) => { // Tenta cachear cada um individualmente — falha de 1 não derruba install await Promise.all(SHELL_URLS.map(url => cache.add(url).catch(e => console.warn('[SW] precache miss:', url, e.message)) )); }).then(() => self.skipWaiting()) ); }); self.addEventListener('activate', (event) => { event.waitUntil( caches.keys().then(keys => Promise.all( // Remove caches antigos do shell e windy (não tiles — que tem própria versão) keys.filter(k => k.startsWith('shivao-shell-') && k !== SHELL_CACHE) .concat(keys.filter(k => k.startsWith('shivao-windy-') && k !== WINDY_CACHE)) .map(k => caches.delete(k)) )).then(() => self.clients.claim()) ); }); self.addEventListener('fetch', (event) => { const url = new URL(event.request.url); if (event.request.method !== 'GET') return; // POST/PUT/DELETE passam direto // /api do Shivão: passa direto, não interferir em sync/heartbeat/auth if (url.pathname.startsWith('/api/')) return; // Map tiles (OSM): cache-first, com network fallback if (url.host === 'tile.openstreetmap.org' || url.pathname.match(/\.(png|jpg|jpeg)$/) && url.pathname.match(/\/\d+\/\d+\/\d+\./)) { event.respondWith(cacheFirst(event.request, TILES_CACHE)); return; } // Windy API: network-first, fallback cache (mostra última previsão quando offline) if (url.host === 'api.windy.com') { event.respondWith(networkFirst(event.request, WINDY_CACHE, 3600 * 1000)); // TTL 1h return; } // Open-Meteo (fallback meteo): mesmo padrão if (url.host === 'api.open-meteo.com' || url.host === 'marine-api.open-meteo.com') { event.respondWith(networkFirst(event.request, WINDY_CACHE, 3600 * 1000)); return; } // Fonts Google (fontes carregam no startup): cache-first if (url.host === 'fonts.googleapis.com' || url.host === 'fonts.gstatic.com') { event.respondWith(cacheFirst(event.request, SHELL_CACHE)); return; } // CDN Leaflet e similares if (url.host === 'cdnjs.cloudflare.com') { event.respondWith(cacheFirst(event.request, SHELL_CACHE)); return; } // Mesma origem (HTML/JS/CSS do app): stale-while-revalidate if (url.origin === self.location.origin) { event.respondWith(staleWhileRevalidate(event.request, SHELL_CACHE)); return; } // Resto: deixa rede tratar }); async function cacheFirst(request, cacheName) { const cache = await caches.open(cacheName); const cached = await cache.match(request); if (cached) return cached; try { const fresh = await fetch(request); if (fresh.ok) cache.put(request, fresh.clone()).catch(() => {}); return fresh; } catch (e) { return new Response('offline-no-cache', { status: 503, statusText: 'Offline' }); } } async function networkFirst(request, cacheName, ttlMs) { const cache = await caches.open(cacheName); try { const fresh = await fetch(request); if (fresh.ok) cache.put(request, fresh.clone()).catch(() => {}); return fresh; } catch (e) { const cached = await cache.match(request); if (cached) return cached; throw e; } } async function staleWhileRevalidate(request, cacheName) { const cache = await caches.open(cacheName); const cached = await cache.match(request); const fetchPromise = fetch(request).then(res => { if (res.ok) cache.put(request, res.clone()).catch(() => {}); return res; }).catch(() => cached || new Response('offline', { status: 503 })); return cached || fetchPromise; } // Mensagens da página: pré-cachear tiles de uma área self.addEventListener('message', (event) => { if (!event.data || !event.data.type) return; if (event.data.type === 'PRECACHE_TILES') { const { bounds, minZoom, maxZoom } = event.data; precacheTiles(bounds, minZoom, maxZoom).then(stats => { event.source && event.source.postMessage({ type: 'PRECACHE_DONE', ...stats }); }).catch(err => { event.source && event.source.postMessage({ type: 'PRECACHE_ERROR', error: err.message }); }); } if (event.data.type === 'CACHE_SIZE') { cacheSizes().then(sizes => { event.source && event.source.postMessage({ type: 'CACHE_SIZE_REPORT', sizes }); }); } if (event.data.type === 'CLEAR_TILES') { caches.delete(TILES_CACHE).then(ok => { event.source && event.source.postMessage({ type: 'TILES_CLEARED', ok }); }); } }); // Calcula tiles necessários e baixa em paralelo (max 6 simultâneos) async function precacheTiles(bounds, minZoom, maxZoom) { const cache = await caches.open(TILES_CACHE); const tiles = []; for (let z = minZoom; z <= maxZoom; z++) { const min = lngLatToTile(bounds.west, bounds.north, z); const max = lngLatToTile(bounds.east, bounds.south, z); for (let x = min.x; x <= max.x; x++) { for (let y = min.y; y <= max.y; y++) { tiles.push({ z, x, y }); } } } let done = 0, failed = 0; const total = tiles.length; // Limita a 6 conexões paralelas pra respeitar OSM tile policy const queue = tiles.slice(); await Promise.all([...Array(6)].map(async () => { while (queue.length) { const t = queue.shift(); const url = `https://tile.openstreetmap.org/${t.z}/${t.x}/${t.y}.png`; try { const res = await fetch(url, { headers: { 'User-Agent': 'Shivao-PWA' } }); if (res.ok) await cache.put(url, res); else failed++; } catch { failed++; } done++; } })); return { total, done, failed }; } function lngLatToTile(lng, lat, z) { const x = Math.floor((lng + 180) / 360 * Math.pow(2, z)); const y = Math.floor((1 - Math.log(Math.tan(lat * Math.PI / 180) + 1 / Math.cos(lat * Math.PI / 180)) / Math.PI) / 2 * Math.pow(2, z)); return { x, y }; } async function cacheSizes() { const result = {}; for (const name of [SHELL_CACHE, TILES_CACHE, WINDY_CACHE]) { const cache = await caches.open(name); const keys = await cache.keys(); result[name] = keys.length; } return result; }