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');
+ }
+ },
}));
// --------------------------------------------------------