Feature: Offline-Stufen 1+2+3 — Timeout, API-Cache, Write-Queue (IndexedDB + BackgroundSync) — SW by-v509, APP_VER 486
This commit is contained in:
parent
ad3b73d687
commit
2411151b17
4 changed files with 268 additions and 31 deletions
|
|
@ -3,9 +3,10 @@
|
|||
Offline-Cache + Push Notifications + Tile-Cache
|
||||
============================================================ */
|
||||
|
||||
const CACHE_VERSION = 'by-v508';
|
||||
const CACHE_VERSION = 'by-v509';
|
||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
|
||||
|
||||
// index.html wird NICHT pre-gecacht (immer Network-First)
|
||||
const STATIC_ASSETS = [
|
||||
|
|
@ -23,6 +24,112 @@ const STATIC_ASSETS = [
|
|||
'/icons/icon-192.png',
|
||||
];
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// HILFSFUNKTIONEN
|
||||
// ----------------------------------------------------------
|
||||
|
||||
// Fetch mit Timeout (verhindert Hängen bei schlechtem Netz)
|
||||
function _fetchTimeout(request, ms = 8000) {
|
||||
const ctrl = new AbortController();
|
||||
const tid = setTimeout(() => ctrl.abort(), ms);
|
||||
return fetch(request, { signal: ctrl.signal }).finally(() => clearTimeout(tid));
|
||||
}
|
||||
|
||||
// IndexedDB Write-Queue (in SW-Kontext verfügbar)
|
||||
function _queueDb() {
|
||||
return new Promise((res, rej) => {
|
||||
const req = indexedDB.open('by-offline-queue', 1);
|
||||
req.onupgradeneeded = e => {
|
||||
const db = e.target.result;
|
||||
if (!db.objectStoreNames.contains('q'))
|
||||
db.createObjectStore('q', { keyPath: 'id', autoIncrement: true });
|
||||
};
|
||||
req.onsuccess = e => res(e.target.result);
|
||||
req.onerror = e => rej(e.target.error);
|
||||
});
|
||||
}
|
||||
async function _queueAdd(entry) {
|
||||
const db = await _queueDb();
|
||||
return new Promise((res, rej) => {
|
||||
const tx = db.transaction('q', 'readwrite');
|
||||
const r = tx.objectStore('q').add(entry);
|
||||
r.onsuccess = () => res(r.result);
|
||||
r.onerror = () => rej(r.error);
|
||||
});
|
||||
}
|
||||
async function _queueAll() {
|
||||
const db = await _queueDb();
|
||||
return new Promise((res, rej) => {
|
||||
const tx = db.transaction('q', 'readonly');
|
||||
const r = tx.objectStore('q').getAll();
|
||||
r.onsuccess = () => res(r.result);
|
||||
r.onerror = () => rej(r.error);
|
||||
});
|
||||
}
|
||||
async function _queueDel(id) {
|
||||
const db = await _queueDb();
|
||||
return new Promise((res, rej) => {
|
||||
const tx = db.transaction('q', 'readwrite');
|
||||
const r = tx.objectStore('q').delete(id);
|
||||
r.onsuccess = () => res();
|
||||
r.onerror = () => rej(r.error);
|
||||
});
|
||||
}
|
||||
|
||||
// Queue abarbeiten (bei Background-Sync oder manuellem Trigger)
|
||||
async function _processQueue() {
|
||||
const items = await _queueAll();
|
||||
let synced = 0, failed = 0;
|
||||
for (const entry of items) {
|
||||
try {
|
||||
const resp = await fetch(entry.url, {
|
||||
method: entry.method,
|
||||
headers: entry.headers,
|
||||
body: entry.body || undefined,
|
||||
});
|
||||
// 2xx und 4xx aus Queue entfernen (4xx = Userfehler, nicht Netzwerk)
|
||||
if (resp.status < 500) { await _queueDel(entry.id); synced++; }
|
||||
else { failed++; }
|
||||
} catch { failed++; }
|
||||
}
|
||||
// Clients benachrichtigen
|
||||
const clients = await self.clients.matchAll({ includeUncontrolled: true });
|
||||
clients.forEach(c => c.postMessage({ type: 'QUEUE_PROCESSED', synced, failed, total: items.length }));
|
||||
return { synced, failed };
|
||||
}
|
||||
|
||||
// Kann dieser Endpunkt offline in die Queue?
|
||||
const _QUEUEABLE = [
|
||||
{ re: /^\/api\/dogs\/\d+\/diary$/, methods: ['POST'] },
|
||||
{ re: /^\/api\/dogs\/\d+\/diary\/\d+$/, methods: ['PATCH'] },
|
||||
{ re: /^\/api\/dogs\/\d+\/health$/, methods: ['POST'] },
|
||||
{ re: /^\/api\/dogs\/\d+\/diary\/\d+\/media$/, methods: ['POST'], skipBody: true },
|
||||
{ re: /^\/api\/training\/sessions$/, methods: ['POST'] },
|
||||
{ re: /^\/api\/training\/progress$/, methods: ['POST'] },
|
||||
{ re: /^\/api\/poison$/, methods: ['POST'] },
|
||||
];
|
||||
function _isQueueable(pathname, method) {
|
||||
return _QUEUEABLE.some(q => q.methods.includes(method) && q.re.test(pathname));
|
||||
}
|
||||
function _isMediaUpload(request) {
|
||||
return (request.headers.get('Content-Type') || '').includes('multipart');
|
||||
}
|
||||
|
||||
// Welche GET-API-Endpoints sollen gecacht werden?
|
||||
const _CACHEABLE_GET = [
|
||||
/^\/api\/dogs(\/\d+)?$/,
|
||||
/^\/api\/dogs\/\d+\/welcome-dashboard/,
|
||||
/^\/api\/dogs\/\d+\/diary/,
|
||||
/^\/api\/dogs\/\d+\/health/,
|
||||
/^\/api\/training\/exercises/,
|
||||
/^\/api\/training\/progress/,
|
||||
/^\/api\/wiki\/rassen/,
|
||||
/^\/api\/dogs\/\d+\/diary\/stats/,
|
||||
];
|
||||
function _isCacheableGet(pathname) {
|
||||
return _CACHEABLE_GET.some(re => re.test(pathname));
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// INSTALL — App Shell cachen
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -30,19 +137,22 @@ self.addEventListener('install', event => {
|
|||
event.waitUntil(
|
||||
caches.open(CACHE_STATIC)
|
||||
.then(cache => cache.addAll(STATIC_ASSETS))
|
||||
.then(() => caches.open(CACHE_API).then(c =>
|
||||
fetch('/api/training/exercises').then(r => { if (r.ok) c.put('/api/training/exercises', r); }).catch(() => {})
|
||||
))
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// ACTIVATE — alte Caches aufräumen (CACHE_TILES behalten)
|
||||
// ACTIVATE — alte Caches aufräumen (CACHE_TILES + CACHE_API behalten)
|
||||
// ----------------------------------------------------------
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(
|
||||
caches.keys()
|
||||
.then(keys => Promise.all(
|
||||
keys
|
||||
.filter(k => k !== CACHE_STATIC && k !== CACHE_TILES)
|
||||
.filter(k => k !== CACHE_STATIC && k !== CACHE_TILES && k !== CACHE_API)
|
||||
.map(k => caches.delete(k))
|
||||
))
|
||||
.then(() => self.clients.claim())
|
||||
|
|
@ -50,15 +160,71 @@ self.addEventListener('activate', event => {
|
|||
});
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// FETCH — Network-First für HTML, Cache-First für Assets, Network für API
|
||||
// FETCH — Network-First für HTML, Cache-First für Assets, API mit Offline-Logik
|
||||
// ----------------------------------------------------------
|
||||
self.addEventListener('fetch', event => {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// API-Calls: immer Network, kein Cache
|
||||
// API-Calls mit Timeout, Caching und Write-Queue
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
const method = event.request.method;
|
||||
|
||||
// GET: Network-First mit Timeout, Cache-Fallback, im Hintergrund aktualisieren
|
||||
if (method === 'GET' && _isCacheableGet(url.pathname)) {
|
||||
event.respondWith((async () => {
|
||||
const cached = await caches.match(event.request);
|
||||
const networkPromise = _fetchTimeout(event.request.clone(), 8000)
|
||||
.then(resp => {
|
||||
if (resp.ok) caches.open(CACHE_API).then(c => c.put(event.request, resp.clone()));
|
||||
return resp;
|
||||
})
|
||||
.catch(() => null);
|
||||
// Stale-While-Revalidate: sofort aus Cache, im Hintergrund holen
|
||||
if (cached) {
|
||||
networkPromise.catch(() => {}); // fire and forget
|
||||
return cached;
|
||||
}
|
||||
const fresh = await networkPromise;
|
||||
if (fresh) return fresh;
|
||||
return new Response(JSON.stringify({ detail: 'Offline — keine Daten im Cache.' }),
|
||||
{ status: 503, headers: { 'Content-Type': 'application/json' } });
|
||||
})());
|
||||
return;
|
||||
}
|
||||
|
||||
// Mutationen (POST/PATCH/PUT/DELETE): mit Timeout, bei Offline → Queue
|
||||
if (['POST', 'PATCH', 'PUT', 'DELETE'].includes(method)) {
|
||||
event.respondWith((async () => {
|
||||
try {
|
||||
return await _fetchTimeout(event.request.clone(), 10000);
|
||||
} catch {
|
||||
if (!_isQueueable(url.pathname, method))
|
||||
return new Response(JSON.stringify({ detail: 'Offline — keine Verbindung.' }),
|
||||
{ status: 503, headers: { 'Content-Type': 'application/json' } });
|
||||
if (_isMediaUpload(event.request))
|
||||
return new Response(JSON.stringify({ detail: 'Medien-Upload offline nicht möglich.' }),
|
||||
{ status: 503, headers: { 'Content-Type': 'application/json' } });
|
||||
// In Queue speichern
|
||||
let body = '';
|
||||
try { body = await event.request.text(); } catch {}
|
||||
await _queueAdd({
|
||||
url: url.pathname + url.search,
|
||||
method,
|
||||
headers: { 'Content-Type': event.request.headers.get('Content-Type') || 'application/json' },
|
||||
body,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
try { await self.registration.sync.register('by-offline-mutations'); } catch {}
|
||||
return new Response(JSON.stringify({ _queued: true, detail: 'Offline gespeichert — wird synchronisiert wenn Verbindung besteht.' }),
|
||||
{ status: 202, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
})());
|
||||
return;
|
||||
}
|
||||
|
||||
// Alle anderen API-Calls (GET ohne Caching): mit Timeout
|
||||
event.respondWith(
|
||||
fetch(event.request).catch(() =>
|
||||
_fetchTimeout(event.request, 8000).catch(() =>
|
||||
new Response(JSON.stringify({ detail: 'Offline — keine Verbindung.' }),
|
||||
{ status: 503, headers: { 'Content-Type': 'application/json' } })
|
||||
)
|
||||
|
|
@ -134,9 +300,29 @@ self.addEventListener('fetch', event => {
|
|||
});
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// MESSAGE — Tile-Vorausladung (Offline-Speicherung)
|
||||
// BACKGROUND SYNC — Queue abarbeiten wenn Verbindung wiederhergestellt
|
||||
// ----------------------------------------------------------
|
||||
self.addEventListener('sync', event => {
|
||||
if (event.tag === 'by-offline-mutations') {
|
||||
event.waitUntil(_processQueue());
|
||||
}
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// MESSAGE — Tile-Vorausladung (Offline-Speicherung) + Queue-Steuerung
|
||||
// ----------------------------------------------------------
|
||||
self.addEventListener('message', event => {
|
||||
if (event.data?.type === 'PROCESS_QUEUE') {
|
||||
event.waitUntil(_processQueue());
|
||||
return;
|
||||
}
|
||||
if (event.data?.type === 'QUEUE_COUNT') {
|
||||
_queueAll().then(items =>
|
||||
event.source?.postMessage({ type: 'QUEUE_COUNT', count: items.length })
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data?.type !== 'CACHE_TILES') return;
|
||||
|
||||
const urls = event.data.urls || [];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue