1// db695a1babf4ba35a152193cb48c2d4c353c47c2 is replaced with the deployed commit SHA at image build time (see 2// Dockerfile), so every deployment starts on a fresh cache generation and old 3// entries are pruned on activate. In local development the placeholder stays 4// literal, which simply means a stable cache name. 5const STATIC_CACHE = 'aev-static-db695a1babf4ba35a152193cb48c2d4c353c47c2'; 6 7// Proxied images (/img/...) live in their own cache: their URLs carry no content hash, 8// so tying them to the release generation bounds how long a replaced logo can linger to 9// "until the next deployment" instead of forever. 10const IMAGE_CACHE = 'aev-img-db695a1babf4ba35a152193cb48c2d4c353c47c2'; 11 12const OFFLINE_URL = '/offline.html'; 13 14// Assets the offline page itself needs, so it renders without a network. 15const OFFLINE_ASSETS = [OFFLINE_URL, '/icons/icon-192x192.png']; 16 17// Graphics that are worth having before they are first needed â brand images and the 18// team logos of the running season. Built server-side because Asset Mapper URLs are 19// content-hashed and logo URLs depend on database state. 20const PRECACHE_MANIFEST_URL = '/pwa/precache.json'; 21 22// Caches owned by this SW â anything else gets pruned on activate. Bumping 23// STATIC_CACHE therefore drops every entry of the previous generation. 24const OWNED_CACHES = [STATIC_CACHE, IMAGE_CACHE]; 25 26// What a response must look like per request type before it may be cached or served. 27// A 200 HTML error page (proxy/tunnel interstitial, login wall) stored under a .js URL 28// would otherwise stay there forever â hashed assets are served cache-first and never 29// revalidated â and every module importing it fails with a MIME type error. 30const EXPECTED_CONTENT_TYPE = { 31 script: 'javascript', 32 style: 'css', 33 image: 'image/', 34 font: 'font', 35}; 36 37self.addEventListener('install', (event) => { 38 event.waitUntil( 39 caches.open(STATIC_CACHE).then(cache => cache.addAll(OFFLINE_ASSETS)) 40 ); 41 self.skipWaiting(); 42}); 43 44self.addEventListener('activate', (event) => { 45 event.waitUntil( 46 caches.keys().then(keys => 47 Promise.all(keys.filter(k => !OWNED_CACHES.includes(k)).map(k => caches.delete(k))) 48 ).then(() => self.clients.claim()) 49 ); 50}); 51 52function hasExpectedContentType(request, response) { 53 const expected = EXPECTED_CONTENT_TYPE[request.destination]; 54 if (!expected) return true; 55 56 return (response.headers.get('Content-Type') ?? '').includes(expected); 57} 58 59// Serving is deliberately more permissive than storing: only status and content type 60// are checked, so a legitimately cached entry never gets discarded over a response 61// type that the Cache API may report differently across browsers. 62function isServable(request, response) { 63 return response.status === 200 && hasExpectedContentType(request, response); 64} 65 66function isStorable(request, response) { 67 return response.status === 200 68 && response.type === 'basic' 69 && hasExpectedContentType(request, response); 70} 71 72/* 73 * Whether a response may be kept for the whole cache generation. 74 * 75 * Proxied image URLs carry no content hash, so the response has to say for itself 76 * whether it is final. ImageProxyController marks stored images "immutable" but gives 77 * DEL year-fallbacks a one-hour max-age precisely so a newly published portrait isn't 78 * masked â honouring that here keeps the service worker from re-introducing the very 79 * staleness the proxy avoids. 80 */ 81function isImmutable(response) { 82 return (response.headers.get('Cache-Control') ?? '').includes('immutable'); 83} 84 85/* 86 * Cache-first for immutable, content-hashed URLs, with three safety nets: 87 * - a cached entry that no longer looks right is dropped and re-fetched (self-healing) 88 * - a failing Cache Storage (quota, eviction) degrades to a plain network request 89 * - a failing network falls back to the cached copy instead of failing the request 90 */ 91async function cacheFirst(request, cacheName, requireImmutable = false) { 92 let cache; 93 94 try { 95 cache = await caches.open(cacheName); 96 const cached = await cache.match(request); 97 98 if (cached) { 99 if (isServable(request, cached)) return cached; 100 101 await cache.delete(request); 102 } 103 } catch { 104 return fetch(request); 105 } 106 107 try { 108 const response = await fetch(request); 109 110 if (isStorable(request, response) && (!requireImmutable || isImmutable(response))) { 111 cache.put(request, response.clone()).catch(() => {}); 112 } 113 114 return response; 115 } catch (error) { 116 const cached = await cache.match(request).catch(() => null); 117 if (cached) return cached; 118 119 throw error; 120 } 121} 122 123// Which cache a precachable URL belongs to; null for anything this SW does not serve. 124function cacheNameFor(pathname) { 125 if (pathname.startsWith('/assets/') || pathname.startsWith('/icons/')) return STATIC_CACHE; 126 if (pathname.startsWith('/img/')) return IMAGE_CACHE; 127 128 return null; 129} 130 131/* 132 * Warms the caches from the server-built manifest. Runs once per service worker 133 * lifetime, deliberately outside install/activate: blocking activation on ~1.5 MB of 134 * images would hold back fetch handling on exactly the slow connections this is meant 135 * to help. Each entry is fetched independently so one failure costs one image. 136 */ 137let corePrecachePromise; 138let warmPrecachePromise; 139let precacheManifestPromise; 140 141function isPrecacheable(cacheName, response) { 142 if (response.status !== 200 || response.type !== 'basic') return false;
143 144 const contentType = response.headers.get('Content-Type') ?? ''; 145 if (cacheName === IMAGE_CACHE) { 146 return contentType.startsWith('image/') && isImmutable(response); 147 } 148 149 return contentType.startsWith('image/') 150 || contentType.includes('javascript') 151 || contentType.includes('text/css') 152 || contentType.startsWith('font/'); 153} 154 155async function precacheUrls(urls) { 156 if (!Array.isArray(urls)) return; 157 158 await Promise.all(urls.map(async (relativeUrl) => { 159 const url = new URL(relativeUrl, self.location.origin).href; 160 const cacheName = cacheNameFor(new URL(url).pathname); 161 if (!cacheName) return; 162 163 try { 164 const cache = await caches.open(cacheName); 165 if (await cache.match(url)) return; 166 167 const response = await fetch(url); 168 if (isPrecacheable(cacheName, response)) { 169 await cache.put(url, response); 170 } 171 } catch { 172 // A single missing image must not abort the rest of the warm-up. 173 } 174 })); 175} 176 177async function fetchPrecacheManifest() { 178 if (!precacheManifestPromise) { 179 precacheManifestPromise = fetch(PRECACHE_MANIFEST_URL) 180 .then(response => { 181 if (!response.ok) throw new Error('Unable to fetch PWA precache manifest.'); 182 183 return response.json(); 184 }) 185 .catch(error => { 186 precacheManifestPromise = undefined; 187 throw error; 188 }); 189 } 190 191 return precacheManifestPromise; 192} 193 194function warmCorePrecache() { 195 if (!corePrecachePromise) { 196 corePrecachePromise = fetchPrecacheManifest() 197 .then(({ coreUrls }) => precacheUrls(coreUrls)) 198 .catch(error => { 199 corePrecachePromise = undefined; 200 throw error; 201 }); 202 } 203 204 return corePrecachePromise; 205} 206 207function startPrecache(event) { 208 if (warmPrecachePromise) return; 209 210 warmPrecachePromise = warmCorePrecache() 211 .catch(() => {}) 212 .then(async () => { 213 const { warmUrls } = await fetchPrecacheManifest(); 214 await precacheUrls(warmUrls); 215 }) 216 .catch(error => { 217 warmPrecachePromise = undefined; 218 throw error; 219 }); 220 event.waitUntil(warmPrecachePromise); 221} 222 223self.addEventListener('fetch', (event) => { 224 const request = event.request; 225 226 // Only handle same-origin GET requests; everything else goes to the network. 227 if (request.method !== 'GET') return; 228 229 const url = new URL(request.url); 230 if (url.origin !== self.location.origin) return; 231 232 // Never cache live data or the admin area â always hit the network. 233 if (url.pathname.startsWith('/_components') || url.pathname.startsWith('/admin')) { 234 return; 235 } 236 237 // Content-hashed Asset Mapper files are immutable, icons are precached for the 238 // offline page and rarely change -> both cache-first. 239 if (url.pathname.startsWith('/assets/') || url.pathname.startsWith('/icons/')) { 240 event.respondWith(cacheFirst(request, STATIC_CACHE)); 241 return; 242 } 243 244 // Proxied images (team logos, portraits, article images). Their URLs change whenever 245 // the underlying file changes â uploads get a random prefix, imports a content-derived 246 // one â so they can be served cache-first just like hashed assets. 247 if (url.pathname.startsWith('/img/')) { 248 event.respondWith(cacheFirst(request, IMAGE_CACHE, true)); 249 return; 250 } 251 252 // Page navigations -> network-first, fall back to the offline page. 253 if (request.mode === 'navigate') { 254 // First navigation of this SW's life doubles as the trigger to warm the caches. 255 // waitUntil keeps the worker alive without delaying the response. 256 startPrecache(event); 257 258 event.respondWith( 259 fetch(request).catch(() => 260 caches.open(STATIC_CACHE).then(cache => cache.match(OFFLINE_URL)) 261 ) 262 ); 263 } 264}); 265 266// The PWA launch screen requests this as soon as a worker is active. It lets a 267// first installation start warming without waiting for a later navigation. 268self.addEventListener('message', (event) => { 269 if (event.data?.type === 'PREPARE_PWA_CORE') { 270 event.waitUntil( 271 warmCorePrecache() 272 .then(() => event.ports[0]?.postMessage({ type: 'PWA_CORE_READY' })) 273 .catch(() => event.ports[0]?.postMessage({ type: 'PWA_CORE_FAILED' })) 274 ); 275 startPrecache(event); 276 return; 277 } 278 279 if (event.data?.type === 'WARM_PRECACHE') { 280 startPrecache(event); 281 } 282}); 283
284// Push notification handler 285self.addEventListener('push', (event) => { 286 if (!event.data) return; 287 288 let payload; 289 try { 290 payload = event.data.json(); 291 } catch { 292 payload = { title: 'Augsburger Panther', body: event.data.text() }; 293 } 294 295 const title = payload.title ?? 'Augsburger Panther'; 296 const options = { 297 body: payload.body ?? '', 298 icon: payload.icon ?? '/icons/icon-192x192.png', 299 badge: '/icons/icon-192x192.png', 300 data: { url: payload.url ?? '/', reportUrl: payload.reportUrl ?? null }, 301 tag: payload.tag ?? 'aev-push', 302 renotify: true, 303 }; 304 305 event.waitUntil(self.registration.showNotification(title, options)); 306}); 307 308// Click on notification â report open + open URL 309self.addEventListener('notificationclick', (event) => { 310 event.notification.close(); 311 const target = event.notification.data?.url ?? '/'; 312 const reportUrl = event.notification.data?.reportUrl; 313 314 event.waitUntil( 315 (reportUrl 316 ? fetch(reportUrl, { method: 'GET', keepalive: true }).catch(() => {}) 317 : Promise.resolve() 318 ).then(() => 319 clients.matchAll({ type: 'window', includeUncontrolled: true }).then(list => { 320 for (const client of list) { 321 if (client.url === target && 'focus' in client) return client.focus(); 322 } 323 if (clients.openWindow) return clients.openWindow(target); 324 }) 325 ) 326 ); 327});
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.