1/* GeoView PWA. Public page snapshots only; no API, search, tile or third-party cache. */ 2'use strict'; 3const RELEASE = '20261001-1'; 4const CORE = 'geoview-core-' + RELEASE; 5const PAGES = 'geoview-pages-v1'; 6const MAX_PAGES = 12; 7const MAX_BYTES = 1024 * 1024; 8const MAX_AGE = 7 * 24 * 60 * 60 * 1000; 9// Icons stay out of the pre-cache: they are 200 of the 278 KB an install used to 10// cost, and the browser fetches them from the manifest only when an install prompt 11// or home-screen entry actually needs one. Clients that discard storage between 12// page views re-ran this install on ~1.4M page views a day, so the icons alone 13// accounted for roughly 5.7M requests and 286 GB of daily egress. 14const ASSETS = ['/offline.html', '/pwa.css', '/pwa.js', '/style.css', '/map-view.css', '/map-view.js']; 15let pageWrites = Promise.resolve(); 16 17self.addEventListener('install', event => { 18 // A failed pre-cache leaves the preceding worker in control. 19 event.waitUntil(caches.open(CORE).then(cache => cache.addAll(ASSETS.map(url => new Request(url, {cache: 'reload'}))))); 20}); 21self.addEventListener('activate', event => { 22 event.waitUntil((async () => { 23 const names = await caches.keys(); 24 await Promise.all(names.filter(name => name.startsWith('geoview-core-') && name !== CORE).map(name => caches.delete(name))); 25 await prunePages(); 26 await self.clients.claim(); 27 })()); 28}); 29 30function publicPage(url) { 31 return url.origin === self.location.origin && !url.search && !/\.php(?:\/|$)/i.test(url.pathname) 32 && !/^\/(?:admin|captcha|api|tiles|errors|vendor|scripts|data|alkis|\.well-known)(?:[/.]|$)/i.test(url.pathname) 33 && url.pathname !== '/search' && url.pathname !== '/offline.html'; 34} 35async function prunePages() { 36 const cache = await caches.open(PAGES); 37 const requests = await cache.keys(); 38 const live = []; 39 for (const request of requests) { 40 const response = await cache.match(request); 41 const saved = Number(response?.headers.get('X-GeoView-Saved-At')); 42 if (!saved || Date.now() - saved > MAX_AGE) await cache.delete(request); 43 else live.push(request); 44 } 45 while (live.length > MAX_PAGES) await cache.delete(live.shift()); 46} 47function queueWrite(job) { 48 pageWrites = pageWrites.then(job).catch(() => {}); // Storage denial/quota must never break browsing. 49 return pageWrites; 50} 51async function savePage(request, response) { 52 const url = new URL(request.url); 53 if (!publicPage(url) || response.status !== 200 || response.redirected 54 || !response.headers.get('Content-Type')?.includes('text/html') 55 || /no-store|private/i.test(response.headers.get('Cache-Control') || '') 56 || /noindex/i.test(response.headers.get('X-Robots-Tag') || '') 57 || Number(response.headers.get('Content-Length')) > MAX_BYTES) return; 58 const html = await response.text(); 59 if (new TextEncoder().encode(html).byteLength > MAX_BYTES 60 || !html.includes('<meta name="geoview-page" content="public">') 61 || /<meta\b[^>]*name=["']robots["'][^>]*content=["'][^"']*noindex/i.test(html)) return; 62 const saved = Date.now(); 63 const date = new Date(saved).toISOString().slice(0, 16).replace('T', ' ') + ' UTC'; 64 const banner = '<aside class="gv-pwa-snapshot" role="status"><span>Saved copy · ' + date 65 + ' · Maps and search need internet.</span><button type="button" data-gv-reconnect>Reload online</button></aside>'; 66 const headers = new Headers(response.headers); 67 headers.delete('Content-Length'); headers.delete('Content-Encoding'); 68 headers.set('X-GeoView-Saved-At', String(saved)); 69 headers.set('X-GeoView-Title', encodeURIComponent((html.match(/<title>([^<]{0,400})<\/title>/i) || [])[1] || url.pathname)); 70 const snapshot = new Response(html.replace(/<body\b[^>]*>/i, body => body + banner), {status: 200, headers}); 71 const cache = await caches.open(PAGES); 72 await cache.delete(url.href); 73 await cache.put(url.href, snapshot); 74 await prunePages(); 75} 76async function savedPage(request) { 77 const cache = await caches.open(PAGES); 78 const response = await cache.match(request); 79 if (!response) return null; 80 if (Date.now() - Number(response.headers.get('X-GeoView-Saved-At')) > MAX_AGE) { 81 await cache.delete(request); return null; 82 } 83 return response; 84} 85async function fetchWithTimeout(request, ms) { 86 const controller = new AbortController(); 87 const timer = setTimeout(() => controller.abort(), ms); 88 try { return await fetch(request, {signal: controller.signal}); } 89 finally { clearTimeout(timer); } 90} 91self.addEventListener('fetch', event => { 92 const request = event.request; 93 const url = new URL(request.url); 94 if (request.method !== 'GET' || url.origin !== self.location.origin) return; 95 if (request.mode === 'navigate') { 96 // Security challenges, errors and HTTP responses always retain their original status. 97 if (!publicPage(url) && url.pathname !== '/search') return; 98 const network = fetchWithTimeout(request, 7000); 99 event.waitUntil(network.then(response => { 100 const copy = response.clone(); 101 return queueWrite(() => savePage(request, copy)); 102 }).catch(() => {}));
103 event.respondWith(network.catch(async () => { 104 return (publicPage(url) && await savedPage(request)) 105 || await (await caches.open(CORE)).match('/offline.html') 106 || new Response('GeoView is offline. Please reconnect and reload.', {status: 503, headers: {'Content-Type': 'text/plain'}}); 107 })); 108 } else if (ASSETS.includes(url.pathname)) { 109 event.respondWith(fetchWithTimeout(request, 4000).catch(async () => { 110 return await (await caches.open(CORE)).match(url.pathname) || Response.error(); 111 })); 112 } 113}); 114self.addEventListener('message', event => { 115 if (!event.source || new URL(event.source.url).origin !== self.location.origin) return; 116 if (event.data?.type === 'ACTIVATE_UPDATE') { event.waitUntil(self.skipWaiting()); return; } 117 if (event.data?.type === 'SAVE_CURRENT_PAGE') { 118 const url = new URL(event.source.url); url.hash = ''; 119 if (!publicPage(url)) return; 120 event.waitUntil(queueWrite(async () => { 121 if (await savedPage(url.href)) return; 122 const request = new Request(url.href, {credentials: 'same-origin', cache: 'no-cache'}); 123 await savePage(request, await fetchWithTimeout(request, 7000)); 124 })); 125 return; 126 } 127 const port = event.ports[0]; 128 if (!port) return; 129 if (event.data?.type === 'LIST_PAGES') { 130 event.waitUntil((async () => { 131 await pageWrites; 132 await prunePages(); 133 const cache = await caches.open(PAGES); 134 const pages = []; 135 for (const request of await cache.keys()) { 136 const response = await cache.match(request); 137 pages.push({url: request.url, title: response.headers.get('X-GeoView-Title') || '', 138 saved: Number(response.headers.get('X-GeoView-Saved-At'))}); 139 } 140 port.postMessage({pages: pages.reverse()}); 141 })().catch(() => port.postMessage({error: 'Storage is unavailable.'}))); 142 } else if (event.data?.type === 'CLEAR_PAGES') { 143 event.waitUntil(queueWrite(() => caches.delete(PAGES)).then(() => port.postMessage({ok: true}))); 144 } 145});
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.