baustelle-pwa/sw.js
Eduard Wisch a108664854 App ohne Netz lesbar statt "Failed to fetch"
Gemeldet: die App zeigte beim Oeffnen nur "Failed to fetch", waehrend das
Teilen aus WhatsApp weiter ging. Deploy und API waren in Ordnung — die
Meldung ist die rohe Browserausgabe eines fetch(), das gar nicht bis zum
Server kam, und stand 1:1 im UI, weil die Routen im catch e.message
anzeigten. Das Teilen fiel nicht auf, weil share.html sich die letzten
Auftraege seit eaad796 lokal merkt; die Hauptapp hatte fuer die Listen
keinen Offline-Fallback.

- api.js uebersetzt Netzfehler in einen eigenen Fehler mit offline=true
  und deutschem Text; 4xx/5xx bleiben Serverfehler
- isTransient() in offline.js erkannte Netzfehler an "instanceof TypeError".
  Ohne Anpassung waere ein Foto aus dem Funkloch als dauerhaft
  fehlgeschlagen gewertet und nach sechs Versuchen in Quarantaene gelandet
- Datenspiegel in IndexedDB: Auftrags-, Kunden- und Berichtsliste sowie
  geoeffnete Details (letzte 30). Ohne Netz wird der Stand angezeigt,
  mit Band "Kein Netz — Stand von HH:MM Uhr", und die Suche filtert darin
- beim Abmelden wird der Spiegel geloescht (geteiltes Geraet)
- sw.js: der Same-Origin-Zweig endete auf caches.match(); fand der Cache
  nichts, wurde respondWith(undefined) aufgerufen und die Anfrage
  scheiterte erneut mit genau der Meldung, die verhindert werden sollte.
  Jetzt immer eine echte Response, nur vollstaendige Antworten im Cache
- Precache nach Deploy: die Seite meldet dem Worker ihre echten Asset-URLs,
  sonst ist der Cache direkt nach einem Deploy leer (?v=<mtime>)

Muster aus KB #1037 und #1041. Verifiziert auf der lokalen Testinstanz
inklusive echtem Funkloch (setOffline + reload): App startet aus dem
Cache, zeigt Band und Auftragsliste, keine JS-Fehler.
2026-08-31 19:07:09 +02:00

385 lines
16 KiB
JavaScript

/* Baustelle PWA Service Worker
* Pattern nach claude-db #201 (filemtime-basiert):
* - Cache-Version kommt aus URL-Query ?v=<mtime>, kein manuelles Hochzählen
* - Network-First für eigene Assets (immer aktuell, Fallback Cache)
* - skipWaiting() + clients.claim() damit Updates sofort greifen
* - Web Share Target via POST → share.html
*/
// Cache-Version dynamisch aus ?v=<mtime> — wird von index.php gesetzt
const SW_VERSION = (new URL(self.location.href)).searchParams.get('v') || 'static';
const CACHE = 'baustelle-' + SW_VERSION;
/* Bild-Cache für die Thumbnails aus photo.php — bewusst NICHT versioniert, damit ein
* Deploy die schon geladenen Fotos nicht wegräumt. Ohne das ist auf der Baustelle ohne
* Netz kein einziges Foto sichtbar, auch keins, das man vorher schon angesehen hat. */
const MEDIA_CACHE = 'baustelle-media';
const MEDIA_MAX = 300;
async function trimMediaCache() {
const c = await caches.open(MEDIA_CACHE);
const keys = await c.keys();
// keys() liefert in Einfüge-Reihenfolge — die ältesten stehen vorn
for (let i = 0; i < keys.length - MEDIA_MAX; i++) await c.delete(keys[i]);
}
/* Thumbnails: erst Cache, dann Netz (und im Hintergrund auffrischen).
* Die URL enthält relpath + Größe, und der Server schickt ein ETag aus der mtime —
* ein ersetztes Original bekommt damit ohnehin eine neue Antwort. */
async function mediaCacheFirst(request) {
const c = await caches.open(MEDIA_CACHE);
const hit = await c.match(request);
if (hit) {
// Auffrischen ohne den Nutzer warten zu lassen
fetch(request).then(r => {
if (cacheable(r)) c.put(request, r.clone()).then(trimMediaCache);
}).catch(() => null);
return hit;
}
try {
const r = await fetch(request);
if (cacheable(r)) { await c.put(request, r.clone()); trimMediaCache(); }
return r;
} catch (e) {
// Offline und nichts im Cache — der Aufrufer zeigt seinen Platzhalter
return new Response('', { status: 504, statusText: 'Offline' });
}
}
const SHELL = [
'./',
'./index.php',
'./share.html',
'./icons/icon-192.png',
'./icons/icon-512.png',
'./icons/icon.svg',
// CSS/JS werden mit ?v=<mtime> geladen und dynamisch gecached
// beim ersten fetch — nicht in SHELL damit Pre-Cache nicht mit alten v's läuft
];
self.addEventListener('install', (e) => {
e.waitUntil(
caches.open(CACHE)
.then(c => c.addAll(SHELL).catch(() => null))
.then(() => self.skipWaiting())
);
});
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys()
.then(keys => Promise.all(
// MEDIA_CACHE ausnehmen — der überlebt Deploys bewusst
keys.filter(k => k.startsWith('baustelle-') && k !== CACHE && k !== MEDIA_CACHE)
.map(k => caches.delete(k))
))
.then(() => self.clients.claim())
);
});
/* ==========================================================================
* Web Share Target — was aus WhatsApp & Co. geteilt wird, hier annehmen
*
* Der POST-Body gibt seine Daten genau EINMAL her. Deshalb wird alles sofort in
* IndexedDB gesichert, bevor irgendetwas angezeigt wird; share.html liest es von dort.
*
* Bis 30.08.2026 wurden nur Dateien gesichert. Teilt jemand aus WhatsApp einen
* Beschreibungstext (mit oder ohne Bild), landete der Text nirgends — die Seite
* meldete dann "Keine geteilten Fotos gefunden". Titel, Text und Link gehoeren
* genauso ins Paket wie die Bilder.
* ========================================================================== */
const SHARE_KEY = 'shared_payload';
function shareDb() {
return new Promise((resolve, reject) => {
const req = indexedDB.open('baustelle-pwa-v1', 1);
req.onupgradeneeded = () => {
const d = req.result;
if (!d.objectStoreNames.contains('kv')) d.createObjectStore('kv');
if (!d.objectStoreNames.contains('queue')) d.createObjectStore('queue', { keyPath: 'id', autoIncrement: true });
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
function isSharedFile(v) {
return v && typeof v === 'object' && typeof v.arrayBuffer === 'function' && v.size > 0;
}
async function handleShareTarget(request) {
try {
const fd = await request.formData();
let files = [];
for (const key of ['photos', 'file', 'files', 'image', 'images']) {
for (const v of fd.getAll(key)) if (isSharedFile(v)) files.push(v);
}
// Fallback: manche Apps benennen das Feld anders — alles nehmen, was eine Datei ist
if (!files.length) {
for (const [, v] of fd.entries()) if (isSharedFile(v)) files.push(v);
}
const str = (k) => { const v = fd.get(k); return typeof v === 'string' ? v.trim() : ''; };
const payload = {
title: str('title'),
text: str('text'),
url: str('url'),
files: files.map(f => ({ name: f.name || 'foto.jpg', type: f.type || 'image/jpeg', data: f })),
ts: Date.now(),
};
const db = await shareDb();
const tx = db.transaction('kv', 'readwrite');
tx.objectStore('kv').put(payload, SHARE_KEY);
tx.objectStore('kv').delete('shared_files'); // altes Format, sonst taucht eine alte Ladung wieder auf
await new Promise((resolve, reject) => {
tx.oncomplete = resolve;
tx.onerror = () => reject(tx.error);
});
} catch (e) {
// Nicht am Sichern haengenbleiben — share.html meldet dann, dass nichts ankam
}
// Absolute Adresse: eine relative wuerde Response.redirect je nach Browser ablehnen
return Response.redirect(new URL('share.html', self.location.href).href, 303);
}
self.addEventListener('fetch', (e) => {
const url = new URL(e.request.url);
// Web Share Target POST → share.html
if (e.request.method === 'POST' && url.pathname.endsWith('/share.html')) {
e.respondWith(handleShareTarget(e.request));
return;
}
// Thumbnails aus photo.php: cache-first, damit die Fotoliste offline steht
if (e.request.method === 'GET'
&& url.pathname.endsWith('/custom/bericht/api/photo.php')
&& url.searchParams.get('size') === 'thumb') {
e.respondWith(mediaCacheFirst(e.request));
return;
}
// Übrige API-Requests: nicht cachen, durchreichen
if (url.pathname.includes('/custom/bericht/api/')) {
return;
}
// Nicht-GETs: durchreichen
if (e.request.method !== 'GET') {
return;
}
// Eigene Assets: Network-First mit Cache-Fallback
if (url.origin === location.origin) {
e.respondWith(networkFirst(e.request));
return;
}
});
/* Nur vollstaendige Antworten cachen. Eine 206 (Teilinhalt, z.B. Audio-Seek) oder eine
* opaque Antwort liefert der Worker spaeter als kaputte Datei aus. */
function cacheable(response) {
return !!response && response.ok && response.status === 200 && response.type !== 'opaque';
}
/* Network-First fuer eigene Dateien.
*
* ACHTUNG, hier lag ein echter Fehler: frueher endete der Zweig auf
* `.catch(() => caches.match(e.request))`. Findet der Cache nichts, ist das Ergebnis
* `undefined` — und `respondWith(undefined)` laesst die Anfrage erneut scheitern, mit
* exakt derselben Meldung "Failed to fetch", die der Fallback verhindern sollte.
* Deshalb gibt diese Fassung IMMER eine echte Response zurueck. */
async function networkFirst(request) {
try {
const response = await fetch(request);
if (cacheable(response)) {
const clone = response.clone();
caches.open(CACHE).then(c => c.put(request, clone)).catch(() => null);
}
return response;
} catch (_) {
const hit = await caches.match(request);
if (hit) return hit;
return offlineFallback(request);
}
}
/* Kein Netz und nichts im Cache. Fuer eine Seitennavigation die App-Huelle nehmen
* (die Startseite liegt im Precache) — die App startet dann und zeigt ihren eigenen
* Offline-Stand. Nur wenn selbst die fehlt, eine erklaerende Seite statt der
* Browser-Fehlerseite. */
async function offlineFallback(request) {
const accept = request.headers.get('accept') || '';
const isPage = request.mode === 'navigate' || accept.includes('text/html');
if (isPage) {
const shell = (await caches.match('./index.php')) || (await caches.match('./'));
if (shell) return shell;
return new Response(
'<!doctype html><html lang="de"><head><meta charset="utf-8">'
+ '<meta name="viewport" content="width=device-width,initial-scale=1">'
+ '<title>Baustelle — offline</title></head>'
+ '<body style="margin:0;background:#1a1a1f;color:#f0f0f0;'
+ 'font-family:-apple-system,Roboto,sans-serif;display:flex;align-items:center;'
+ 'justify-content:center;height:100vh;text-align:center">'
+ '<div style="padding:24px"><div style="font-size:44px">\u{1F4F4}</div>'
+ '<h1 style="font-size:18px">Keine Verbindung</h1>'
+ '<p style="opacity:.7;line-height:1.5">Die App konnte nicht geladen werden.<br>'
+ 'Sobald wieder Netz da ist, erneut \u00f6ffnen.</p></div></body></html>',
{ status: 503, headers: { 'Content-Type': 'text/html; charset=utf-8' } }
);
}
return new Response(JSON.stringify({ error: 'offline', offline: true }), {
status: 503,
headers: { 'Content-Type': 'application/json; charset=utf-8' },
});
}
/* Message-Handler: Client kann "SKIP_WAITING" schicken um ein wartenden SW
* sofort zu aktivieren (für In-App-Update-Button). */
self.addEventListener('message', (e) => {
if (e.data && e.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
// Beim Abmelden dürfen keine Kundenfotos auf dem Gerät zurückbleiben
if (e.data && e.data.type === 'CLEAR_MEDIA') {
e.waitUntil(caches.delete(MEDIA_CACHE));
}
/* Nach jedem Deploy traegt der Cache einen neuen Namen und ist erst einmal LEER —
* inklusive app.js und app.css. Wer die neue Fassung morgens laedt und dann ins
* Funkloch faehrt, haette die Browser-Fehlerseite (KB #1041).
*
* Eine feste Dateiliste im install-Schritt hilft nicht: die Asset-URLs tragen
* ?v=<mtime> und aendern sich mit jedem Deploy. Deshalb meldet die Seite nach dem
* Laden, was sie tatsaechlich geholt hat, und das wird nachgecached — einzeln, denn
* ein addAll() wuerde bei einer einzigen 404 alles verwerfen. */
if (e.data && e.data.type === 'PRECACHE' && Array.isArray(e.data.urls)) {
e.waitUntil(caches.open(CACHE).then(c => Promise.all(
e.data.urls.map(u => c.match(u).then(hit => hit ? null : c.add(u).catch(() => null)))
)));
}
});
/* ==========================================================================
* Background Sync — Fotos nachliefern, auch wenn die App gar nicht offen ist
*
* Bis hierher lief das Nachliefern ausschliesslich in der Seite selbst: über
* das online-Ereignis, „App wieder sichtbar" und einen 15-Sekunden-Takt. Ist
* das Display aus oder die App weggewischt, friert der Browser diese Timer ein
* — es passierte schlicht nichts, bis jemand die App wieder aufmachte.
*
* Der Service Worker läuft unabhängig davon. Er greift auf dieselbe
* IndexedDB-Queue zu (baustelle-pwa-v1 / Store "queue") und lädt selbst hoch;
* die Anmeldung steckt im HttpOnly-Cookie awl_sso und geht bei same-origin
* automatisch mit — deshalb braucht es hier kein Token-Handling.
*
* Gelöscht wird ein Eintrag nur bei BESTÄTIGTEM Upload: HTTP 2xx, echtes JSON
* und ein relpath in der Antwort. Alles andere bleibt liegen; wir lehnen das
* Sync-Versprechen ab, dann plant der Browser den nächsten Versuch selbst.
* ========================================================================== */
const QUEUE_DB = 'baustelle-pwa-v1';
const QUEUE_STORE = 'queue';
const UPLOAD_BASE = '/custom/bericht/api/orders.php';
function queueDb() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(QUEUE_DB); // KEINE Version angeben:
req.onsuccess = () => resolve(req.result); // das Schema legt die Seite an
req.onerror = () => reject(req.error);
});
}
function queueRead(db) {
return new Promise((resolve, reject) => {
const tx = db.transaction(QUEUE_STORE, 'readonly');
const req = tx.objectStore(QUEUE_STORE).getAll();
req.onsuccess = () => resolve(req.result || []);
req.onerror = () => reject(req.error);
});
}
function queueRemove(db, id) {
return new Promise((resolve, reject) => {
const tx = db.transaction(QUEUE_STORE, 'readwrite');
tx.objectStore(QUEUE_STORE).delete(id);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
async function tellClients(msg) {
const list = await self.clients.matchAll({ includeUncontrolled: true, type: 'window' });
list.forEach(c => { try { c.postMessage(msg); } catch (_) {} });
}
/* Ein Foto hochladen. Rückgabe: die Serverantwort bei bestätigtem Upload,
* sonst wird geworfen — der Aufrufer lässt den Eintrag dann in der Queue. */
async function uploadQueued(item) {
const blob = new Blob([item.data], { type: item.mime || 'image/jpeg' });
const fd = new FormData();
fd.append('file', blob, item.filename || 'foto.jpg');
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 60000); // haengende Verbindung nicht ewig halten
let r;
try {
r = await fetch(UPLOAD_BASE + '?id=' + encodeURIComponent(item.order_id) + '&action=upload_photo', {
method: 'POST',
body: fd,
credentials: 'same-origin',
signal: ctrl.signal,
});
} finally {
clearTimeout(timer);
}
if (!r.ok) throw new Error('HTTP ' + r.status);
// 2xx mit HTML-Body heisst: Proxy oder abgelaufene Anmeldung, NICHT gespeichert.
const ct = (r.headers.get('content-type') || '').toLowerCase();
if (ct.indexOf('application/json') === -1) throw new Error('Antwort ist kein JSON');
const data = await r.json();
if (!data || typeof data.relpath !== 'string' || data.relpath === '') {
throw new Error('Upload nicht bestaetigt (kein relpath)');
}
return data;
}
async function drainQueue() {
const db = await queueDb();
const items = (await queueRead(db)).filter(i => i.type === 'photo' && !i.failed);
if (!items.length) return;
let left = 0;
for (const it of items) {
try {
const res = await uploadQueued(it);
await queueRemove(db, it.id);
await tellClients({
type: 'photo-uploaded',
queueId: it.id,
relpath: res.relpath,
orderId: it.order_id,
filename: it.filename,
duplicate: !!res.duplicate,
});
} catch (_) {
left++; // bleibt in der Queue, naechster Sync versucht es erneut
}
}
// Noch etwas offen? Ablehnen, damit der Browser erneut zustellt.
if (left) throw new Error(left + ' Foto(s) weiterhin offen');
}
self.addEventListener('sync', (e) => {
if (e.tag === 'photo-queue') e.waitUntil(drainQueue());
});
/* Periodic Background Sync, falls der Browser ihn gewährt (installierte PWA,
* Chrome/Android). Fängt den Fall ab, dass gar kein sync-Ereignis mehr kommt. */
self.addEventListener('periodicsync', (e) => {
if (e.tag === 'photo-queue') e.waitUntil(drainQueue().catch(() => {}));
});