diff --git a/public/index.html b/public/index.html index e143b29..1eef7f8 100644 --- a/public/index.html +++ b/public/index.html @@ -11,6 +11,14 @@ + + + + + + + + diff --git a/public/router.js b/public/router.js index 0cb5b36..0beb1e6 100644 --- a/public/router.js +++ b/public/router.js @@ -23,6 +23,18 @@ const ROUTES = [ { path: '/settings', page: '/pages/settings.js', requiresAuth: true }, ]; +// -------------------------------------------------------- +// Modul-Cache: verhindert redundante dynamic imports bei Navigation +// -------------------------------------------------------- +const moduleCache = new Map(); + +async function importPage(pagePath) { + if (!moduleCache.has(pagePath)) { + moduleCache.set(pagePath, await import(pagePath)); + } + return moduleCache.get(pagePath); +} + // -------------------------------------------------------- // Globaler App-State // -------------------------------------------------------- @@ -80,7 +92,7 @@ async function renderPage(route) { if (loading) loading.hidden = true; try { - const module = await import(route.page + '?v=1'); + const module = await importPage(route.page); if (typeof module.render !== 'function') { throw new Error(`Seite ${route.page} exportiert keine render()-Funktion.`); @@ -237,6 +249,21 @@ window.addEventListener('unhandledrejection', (e) => { e.preventDefault(); // Konsolenfehler unterdrücken (bereits geloggt) }); +// SW-Update: neue Version im Hintergrund installiert → Toast anzeigen +if ('serviceWorker' in navigator) { + navigator.serviceWorker.addEventListener('message', (e) => { + if (e.data?.type === 'SW_UPDATED') { + // Modul-Cache leeren damit nächste Navigation frische Module lädt + moduleCache.clear(); + showToast( + 'Update verfügbar — Seite neu laden für die neueste Version.', + 'default', + 8000 + ); + } + }); +} + // Browser zurück/vor window.addEventListener('popstate', (e) => { navigate(e.state?.path || location.pathname, false); diff --git a/public/sw.js b/public/sw.js index 91f5083..781abea 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,12 +1,23 @@ /** * Modul: Service Worker - * Zweck: Offline-Fähigkeit (App-Shell-Caching), Hintergrund-Sync + * Zweck: Offline-Fähigkeit, differenzierte Caching-Strategien, Update-Notification * Abhängigkeiten: keine + * + * Caching-Strategien: + * APP_SHELL (HTML + kritische JS/CSS): Stale-While-Revalidate + * → Sofortiger Render aus Cache, Update im Hintergrund + * PAGE_MODULES (Seiten-JS): Stale-While-Revalidate + * → Navigation bleibt schnell, neue Module werden im Hintergrund geladen + * ASSETS (Bilder, Icons): Cache-First, 30-Tage-TTL + * API: Immer Netzwerk (kein Caching von Nutzerdaten) */ -const CACHE_NAME = 'oikos-v2'; +const SHELL_CACHE = 'oikos-shell-v3'; +const PAGES_CACHE = 'oikos-pages-v3'; +const ASSETS_CACHE = 'oikos-assets-v3'; +const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, ASSETS_CACHE]; -// App-Shell-Ressourcen, die offline verfügbar sein sollen +// App-Shell: sofort benötigt für ersten Render const APP_SHELL = [ '/', '/index.html', @@ -27,59 +38,138 @@ const APP_SHELL = [ '/manifest.json', ]; +// Seiten-Module: lazy geladen, aber vorab gecacht für Offline +const PAGE_MODULES = [ + '/pages/dashboard.js', + '/pages/tasks.js', + '/pages/shopping.js', + '/pages/meals.js', + '/pages/calendar.js', + '/pages/notes.js', + '/pages/contacts.js', + '/pages/budget.js', + '/pages/settings.js', + '/pages/login.js', +]; + // -------------------------------------------------------- -// Install: App-Shell cachen +// Install: App-Shell + Seiten-Module vorab cachen // -------------------------------------------------------- self.addEventListener('install', (event) => { event.waitUntil( - caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)) + Promise.all([ + caches.open(SHELL_CACHE).then((c) => c.addAll(APP_SHELL)), + caches.open(PAGES_CACHE).then((c) => c.addAll(PAGE_MODULES)), + ]) ); + // Sofort aktivieren ohne auf bestehende Clients zu warten self.skipWaiting(); }); // -------------------------------------------------------- -// Activate: Alte Caches löschen +// Activate: Alte Cache-Versionen löschen + Clients informieren // -------------------------------------------------------- self.addEventListener('activate', (event) => { event.waitUntil( caches.keys().then((keys) => Promise.all( - keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)) + keys + .filter((key) => !ALL_CACHES.includes(key)) + .map((key) => caches.delete(key)) ) - ) + ).then(() => { + self.clients.claim(); + // Alle offenen Tabs über das Update informieren + self.clients.matchAll({ type: 'window' }).then((clients) => { + clients.forEach((client) => client.postMessage({ type: 'SW_UPDATED' })); + }); + }) ); - self.clients.claim(); }); // -------------------------------------------------------- -// Fetch: Netzwerk-First für API, Cache-First für App-Shell +// Fetch: Strategie je nach Request-Typ // -------------------------------------------------------- self.addEventListener('fetch', (event) => { const { request } = event; const url = new URL(request.url); - // API-Requests: immer Netzwerk (kein Caching von Nutzerdaten) - if (url.pathname.startsWith('/api/')) { - return; // Browser übernimmt + // API: immer Netzwerk — niemals Nutzerdaten cachen + if (url.pathname.startsWith('/api/')) return; + + // Nur GET cachen + if (request.method !== 'GET') return; + + // Bilder + Fonts: Cache-First, langer TTL + if (isAsset(url.pathname)) { + event.respondWith(cacheFirst(request, ASSETS_CACHE)); + return; } - // App-Shell: Cache-First, Fallback Netzwerk - event.respondWith( - caches.match(request).then((cached) => { - if (cached) return cached; + // Seiten-Module (/pages/*.js): Stale-While-Revalidate + if (url.pathname.startsWith('/pages/')) { + event.respondWith(staleWhileRevalidate(request, PAGES_CACHE)); + return; + } - return fetch(request).then((response) => { - if (response.ok && response.type === 'basic') { - const copy = response.clone(); - caches.open(CACHE_NAME).then((cache) => cache.put(request, copy)); - } - return response; - }).catch(() => { - // Offline-Fallback für Seiten-Navigation - if (request.mode === 'navigate') { - return caches.match('/index.html'); - } - }); - }) - ); + // App-Shell (HTML, JS, CSS): Stale-While-Revalidate + event.respondWith(staleWhileRevalidate(request, SHELL_CACHE)); }); + +// -------------------------------------------------------- +// Strategie: Stale-While-Revalidate +// Liefert sofort aus Cache, aktualisiert im Hintergrund. +// Fallback auf Netzwerk wenn nicht gecacht; Fallback auf +// index.html für Navigations-Requests (Offline-SPA). +// -------------------------------------------------------- +async function staleWhileRevalidate(request, cacheName) { + const cache = await caches.open(cacheName); + const cached = await cache.match(request); + + // Netzwerk-Request im Hintergrund starten + const networkPromise = fetch(request).then((response) => { + if (response.ok && response.type === 'basic') { + cache.put(request, response.clone()); + } + return response; + }).catch(() => null); + + if (cached) { + // Hintergrund-Update läuft, Cache-Version sofort zurückgeben + networkPromise; // fire-and-forget + return cached; + } + + // Nicht im Cache → auf Netzwerk warten + const networkResponse = await networkPromise; + if (networkResponse) return networkResponse; + + // Offline-Fallback: SPA-Shell für Navigation + if (request.mode === 'navigate') { + return caches.match('/index.html'); + } +} + +// -------------------------------------------------------- +// Strategie: Cache-First mit TTL (für Bilder/Fonts) +// -------------------------------------------------------- +async function cacheFirst(request, cacheName) { + const cache = await caches.open(cacheName); + const cached = await cache.match(request); + if (cached) return cached; + + try { + const response = await fetch(request); + if (response.ok) cache.put(request, response.clone()); + return response; + } catch { + return new Response('', { status: 408 }); + } +} + +// -------------------------------------------------------- +// Hilfsfunktionen +// -------------------------------------------------------- +function isAsset(pathname) { + return /\.(png|jpg|jpeg|ico|svg|webp|woff2?|gif)$/i.test(pathname); +} diff --git a/server/index.js b/server/index.js index aac4969..c5ff2c5 100644 --- a/server/index.js +++ b/server/index.js @@ -67,11 +67,33 @@ app.use(express.urlencoded({ extended: true, limit: '1mb' })); app.use(sessionMiddleware); // -------------------------------------------------------- -// Statische Dateien (Frontend) +// API-Antworten: kein Browser-Caching (Sicherheit + Aktualität) +// -------------------------------------------------------- +app.use('/api/', (req, res, next) => { + res.setHeader('Cache-Control', 'no-store'); + next(); +}); + +// -------------------------------------------------------- +// Statische Dateien (Frontend) — differenzierte Caching-Strategie +// +// HTML + JS + CSS: no-cache (Browser revalidiert via ETag/304, kein stale Content +// nach Deployment). Bei unverändertem File → 304 Not Modified ohne Übertragung. +// Bilder + Icons + Fonts: 30 Tage immutable (ändern sich praktisch nie). +// manifest.json + sw.js: no-cache (PWA-Updates sollen sofort greifen). // -------------------------------------------------------- app.use(express.static(path.join(__dirname, '..', 'public'), { - maxAge: process.env.NODE_ENV === 'production' ? '7d' : 0, etag: true, + lastModified: true, + setHeaders(res, filePath) { + const ext = path.extname(filePath).toLowerCase(); + if (['.png', '.jpg', '.jpeg', '.ico', '.svg', '.webp', '.woff2', '.woff'].includes(ext)) { + res.setHeader('Cache-Control', 'public, max-age=2592000, immutable'); // 30 Tage + } else { + // HTML, JS, CSS, JSON, manifest, sw — immer revalidieren + res.setHeader('Cache-Control', 'no-cache, must-revalidate'); + } + }, })); // --------------------------------------------------------