PageSourceSearch

https://www.aev-panther.de/sw.js

js aev-panther.de collected 2026-10-02 18:41:23 UTC 11,725 bytes, 327 lines download raw bytes

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.