Files
desfoto/site/gtin/sw.js
2026-09-25 20:02:16 +02:00

172 lines
5.2 KiB
JavaScript

/* Service Worker des GTIN-Generators.
*
* Nach dem ersten Laden liegen HTML, CSS, JavaScript, Manifest und Icons im
* Cache. Danach funktioniert die Anwendung ohne WLAN, ohne Mobilfunk und ohne
* erreichbaren Server.
*
* CACHE_VERSION wird beim Build aus dem Inhalt der Anwendungsdateien ersetzt.
* Dadurch erkennt der Browser ein neues Release und raeumt den alten Cache auf.
*/
const sw = /** @type {ServiceWorkerGlobalScope} */ (/** @type {unknown} */ (self));
const CACHE_VERSION = '403b5842d1cd';
const CACHE_PREFIX = 'desfoto-gtin-generator-';
const CACHE_NAME = `${CACHE_PREFIX}${CACHE_VERSION}`;
// PRECACHE
const PRECACHE = [
'./',
'./css/app.css',
'./js/app.js',
'./js/gtin.js',
'./js/barcode.js',
'./js/liste.js',
'./manifest.webmanifest',
'./icons/favicon.svg',
'./icons/icon-192.png',
'./icons/icon-512.png',
'./icons/icon-maskable-512.png',
];
// END PRECACHE
/** @param {string} pfad */
function scopeUrl(pfad) {
return new URL(pfad, sw.registration.scope).toString();
}
const INDEX_URL = scopeUrl('./');
/** Pfad des Geltungsbereichs, z. B. "/gtin/". */
const SCOPE_PFAD = new URL(sw.registration.scope).pathname;
/**
* Kennung der ausgelieferten Anwendungsquelle. Nur eine Antwort, die diese
* Kennung enthaelt, ist die echte index.html dieses Projekts.
*/
const SHELL_KENNUNG = 'id="artikelnummer"';
/**
* Entscheidet, ob eine Antwort die vorgehaltene Anwendungshuelle ersetzen darf.
*
* Nur eine erfolgreiche HTML-Antwort der eigenen Anwendung ist zulaessig. Eine
* Anmeldeseite der Steuerungsebene, eine Magic-Link-Seite oder eine Fehlerseite
* (z. B. "App deployment in progress") wuerde die Anwendung sonst offline
* unbrauchbar machen, weil sie die echte Seite aus dem Cache verdraengt.
*
* @param {Response} antwort
* @returns {Promise<boolean>}
*/
async function istAnwendungshuelle(antwort) {
if (!antwort.ok || antwort.type !== 'basic' || antwort.redirected) return false;
const inhaltstyp = antwort.headers.get('content-type') ?? '';
if (!inhaltstyp.includes('text/html')) return false;
const endgueltig = new URL(antwort.url);
if (endgueltig.origin !== sw.location.origin) return false;
if (!endgueltig.pathname.startsWith(SCOPE_PFAD)) return false;
try {
return (await antwort.text()).includes(SHELL_KENNUNG);
} catch {
return false;
}
}
sw.addEventListener('install', (ereignis) => {
ereignis.waitUntil(
caches
.open(CACHE_NAME)
.then((cache) =>
cache.addAll(
PRECACHE.map((pfad) => new Request(scopeUrl(pfad), { cache: 'reload' })),
),
)
.then(() => sw.skipWaiting()),
);
});
sw.addEventListener('activate', (ereignis) => {
ereignis.waitUntil(
caches
.keys()
.then((namen) =>
Promise.all(
namen
.filter((name) => name.startsWith(CACHE_PREFIX) && name !== CACHE_NAME)
.map((name) => caches.delete(name)),
),
)
.then(() => sw.clients.claim()),
);
});
/**
* Bedient einen Navigationsaufruf.
*
* Bewusst aus dem Cache: Nur so stammen HTML und Assets immer aus derselben
* Cache-Generation. Der neue Service Worker uebernimmt dank
* skipWaiting()/clients.claim() sofort; ein neues Release ist damit beim
* naechsten Laden konsistent aktiv (kein "neues HTML, altes JavaScript").
*
* @param {FetchEvent} ereignis
* @param {Request} anfrage
* @returns {Promise<Response>}
*/
function bedieneNavigation(ereignis, anfrage) {
return caches.match(INDEX_URL).then((treffer) => {
if (treffer) return treffer;
return fetch(anfrage)
.then((antwort) => {
const pruefung = antwort.clone();
const kopie = antwort.clone();
// waitUntil haelt die Cache-Schreiboperation am Leben. Ein blosses
// "fire and forget" innerhalb von respondWith kann abgebrochen werden.
ereignis.waitUntil(
istAnwendungshuelle(pruefung)
.then((istHuelle) =>
istHuelle
? caches.open(CACHE_NAME).then((cache) => cache.put(INDEX_URL, kopie))
: undefined,
)
.catch(() => undefined),
);
return antwort;
})
.catch(() => caches.match(INDEX_URL).then((treffer) => treffer ?? Response.error()));
});
}
sw.addEventListener('fetch', (ereignis) => {
const anfrage = ereignis.request;
if (anfrage.method !== 'GET') return;
const ziel = new URL(anfrage.url);
if (ziel.origin !== sw.location.origin) return;
if (!ziel.pathname.startsWith(SCOPE_PFAD)) return;
if (ziel.pathname.endsWith('/health')) return;
if (anfrage.mode === 'navigate') {
const appStart = new URL('./', sw.registration.scope).pathname;
if (ziel.pathname !== appStart) return;
ereignis.respondWith(bedieneNavigation(ereignis, anfrage));
return;
}
ereignis.respondWith(
caches.match(anfrage).then((treffer) => {
if (treffer) return treffer;
return fetch(anfrage).then((antwort) => {
if (antwort.ok && antwort.type === 'basic' && !antwort.redirected) {
const kopie = antwort.clone();
ereignis.waitUntil(
caches
.open(CACHE_NAME)
.then((cache) => cache.put(anfrage, kopie))
.catch(() => undefined),
);
}
return antwort;
});
}),
);
});