fix: resolve pre-cache 404s and implement Bunker v7 with SWR
This commit is contained in:
@@ -62,13 +62,15 @@ A critical feature is the ability to link tasting notes to a bottle that hasn't
|
|||||||
|
|
||||||
## 📱 PWA Features
|
## 📱 PWA Features
|
||||||
|
|
||||||
### Service Worker (`public/sw.js`) - "Paranoid Mode" Strategy
|
### Service Worker (`public/sw.js`) - "Bunker v7 + SWR"
|
||||||
The Service Worker implements an extremely resilient "Cache-First, Network-Background" strategy for navigation:
|
The Service Worker implements a robust "Cache-First, Network-Background" strategy:
|
||||||
|
|
||||||
- **Pre-Caching**: The landing page (`/`), core static assets, and the sessions overview (`/sessions`) are cached during installation.
|
- **Pre-Caching**: The landing page (`/`) and core static assets are cached individually during installation to prevent total failure on single-file 404s.
|
||||||
- **SWR Navigation**: Navigation requests are handled with Stale-While-Revalidate. The SW serves the cached version immediately (instant load) and refreshes the cache in the background.
|
- **Manifest Path**: Corrected to `/manifest.webmanifest` to match Next.js defaults.
|
||||||
- **Universal Root Fallback**: If a URL is not found in the cache while offline, the SW serves the Root (`/`). This allows Next.js to bootstrap and handle the routing client-side (including Scan and Note features) using local Dexie data.
|
- **SWR Navigation & Assets**: Both load instantly from cache. Updates happen in the background via `fetchWithTimeout` and `AbortController`.
|
||||||
- **RSC Data Resiliency**: Requests to `/_next/data/` return an empty JSON object if they fail, preventing "Application Error" screens.
|
- **Universal Root Fallback**: Deep links (like `/bottles/[id]`) fallback to `/` if not cached, allowing Next.js to take over.
|
||||||
|
- **Network Stability**: Added a 2-second stabilization delay in `UploadQueue.tsx` before background sync starts after a network change.
|
||||||
|
- **RSC Data Resiliency**: Requests to `/_next/data/` return an empty JSON object if they fail.
|
||||||
- **Stale-While-Revalidate**: Applied to static assets to ensure immediate UI response.
|
- **Stale-While-Revalidate**: Applied to static assets to ensure immediate UI response.
|
||||||
|
|
||||||
### Manifest (`src/app/manifest.ts`)
|
### Manifest (`src/app/manifest.ts`)
|
||||||
|
|||||||
144
public/sw.js
144
public/sw.js
@@ -1,87 +1,89 @@
|
|||||||
const CACHE_NAME = 'whisky-vault-v6-bunker'; // Neue Version erzwingen!
|
const CACHE_NAME = 'whisky-vault-v7-bunker'; // Neue Version für stabilen Bunker + SWR
|
||||||
|
|
||||||
// CONFIG: Core pages and assets to pre-cache immediately on install
|
// CONFIG: Kern-Seiten und Assets für den Bunker (sofortiges Pre-Caching)
|
||||||
// WICHTIG: Hier muss die URL für "Neues Tasting" rein, damit sie offline da ist!
|
|
||||||
const CORE_PAGES = [
|
const CORE_PAGES = [
|
||||||
'/', // Dashboard / App Shell
|
'/', // Dashboard / App Shell (Enthält Scan & Tasting)
|
||||||
'/sessions', // Tasting Sessions
|
|
||||||
//'/scan', // Scan Page (falls URL existiert)
|
|
||||||
// '/tasting/new' // <--- FÜGE HIER DEINE TASTING URL HINZU, falls sie existiert!
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const STATIC_ASSETS = [
|
const STATIC_ASSETS = [
|
||||||
'/manifest.json',
|
'/manifest.webmanifest', // Korrigierter Pfad für Next.js
|
||||||
'/icon-192.png',
|
'/icon-192.png',
|
||||||
'/icon-512.png',
|
'/icon-512.png',
|
||||||
'/favicon.ico',
|
'/favicon.ico',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Install: Lade alles Wichtige sofort in den Keller
|
// Helper: Fetch mit Timeout und AbortController (sauberer Abbruch)
|
||||||
|
async function fetchWithTimeout(request, timeoutMs = 3000) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const id = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
const response = await fetch(request, { signal: controller.signal });
|
||||||
|
clearTimeout(id);
|
||||||
|
return response;
|
||||||
|
} catch (e) {
|
||||||
|
clearTimeout(id);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install: Lade alles Wichtige einzeln in den Bunker
|
||||||
self.addEventListener('install', (event) => {
|
self.addEventListener('install', (event) => {
|
||||||
self.skipWaiting(); // Sofortiger Wechsel zum neuen SW
|
self.skipWaiting();
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.open(CACHE_NAME).then((cache) => {
|
caches.open(CACHE_NAME).then(async (cache) => {
|
||||||
console.log('⚡ PWA: Pre-caching core pages and assets...');
|
console.log('🏗️ PWA: Building bunker v7...');
|
||||||
return cache.addAll([...CORE_PAGES, ...STATIC_ASSETS]);
|
const promises = [...CORE_PAGES, ...STATIC_ASSETS].map(async (url) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`Status ${res.status}`);
|
||||||
|
return cache.put(url, res);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`⚠️ PWA: Pre-cache failed for ${url}:`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(promises);
|
||||||
|
console.log('✅ PWA: Bunker build finished');
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Activate: Alten Müll rauswerfen
|
// Activate: Alte Bunker räumen
|
||||||
self.addEventListener('activate', (event) => {
|
self.addEventListener('activate', (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.keys().then((cacheNames) => {
|
caches.keys().then((cacheNames) => {
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
cacheNames.map((cacheName) => {
|
cacheNames.map((cacheName) => {
|
||||||
if (cacheName !== CACHE_NAME) {
|
if (cacheName !== CACHE_NAME) {
|
||||||
console.log('🧹 PWA: Clearing old cache', cacheName);
|
console.log('🧹 PWA: Clearing old bunker', cacheName);
|
||||||
return caches.delete(cacheName);
|
return caches.delete(cacheName);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
self.clients.claim(); // Sofort Kontrolle übernehmen
|
self.clients.claim();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper: Network mit Timeout (für den Notfall)
|
|
||||||
function fetchWithTimeout(request, timeoutMs = 3000) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const timeoutId = setTimeout(() => reject(new Error('Network timeout')), timeoutMs);
|
|
||||||
fetch(request).then(
|
|
||||||
(res) => { clearTimeout(timeoutId); resolve(res); },
|
|
||||||
(err) => { clearTimeout(timeoutId); reject(err); }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
self.addEventListener('fetch', (event) => {
|
self.addEventListener('fetch', (event) => {
|
||||||
|
// Nur GET-Requests cachen
|
||||||
|
if (event.request.method !== 'GET') return;
|
||||||
|
|
||||||
const url = new URL(event.request.url);
|
const url = new URL(event.request.url);
|
||||||
|
|
||||||
// 0. BYPASS: Auth & API immer durchlassen (werden von Dexie/App handled)
|
// 0. BYPASS: Auth & API (wird von App/Dexie direkt gehandelt)
|
||||||
if (url.pathname.includes('/auth/') ||
|
if (url.pathname.includes('/auth/') ||
|
||||||
url.pathname.includes('/api/') ||
|
url.pathname.includes('/api/') ||
|
||||||
url.hostname.includes('supabase.co')) {
|
url.hostname.includes('supabase.co')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. NEXT.JS DATA (RSC): Swallow Errors
|
// 1. NEXT.JS DATA (RSC): Bunker & Fallback
|
||||||
// Verhindert "Application Error" wenn JSON fehlt
|
|
||||||
if (url.pathname.startsWith('/_next/data/')) {
|
if (url.pathname.startsWith('/_next/data/')) {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
caches.match(event.request).then((cached) => {
|
caches.match(event.request).then((cached) => {
|
||||||
// Wenn im Cache (selten bei RSC), nimm es
|
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
// Sonst Netzwerk mit Timeout
|
|
||||||
return fetchWithTimeout(event.request, 2000)
|
return fetchWithTimeout(event.request, 2000)
|
||||||
.then(res => {
|
|
||||||
// Optional: RSC cachen für später?
|
|
||||||
// Vorsicht: RSC Daten veralten schnell. Hier cachen wir NICHT.
|
|
||||||
return res;
|
|
||||||
})
|
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Fallback: Leeres JSON, damit React nicht crasht
|
|
||||||
return new Response(JSON.stringify({}), {
|
return new Response(JSON.stringify({}), {
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { 'Content-Type': 'application/json' }
|
||||||
});
|
});
|
||||||
@@ -91,8 +93,9 @@ self.addEventListener('fetch', (event) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. ASSETS (JS/CSS/Images): CACHE FIRST (Der "Bunker" Fix)
|
// 2. ASSETS & NAVIGATION: Stale-While-Revalidate (Der "Echte" Bunker Mode)
|
||||||
// Wenn wir die Datei haben, nutzen wir sie. Keine Diskussion mit dem Netzwerk.
|
// Wir liefern SOFORT aus dem Cache, fragen aber im Hintergrund das Netzwerk.
|
||||||
|
const isNavigation = event.request.mode === 'navigate';
|
||||||
const isAsset = event.request.destination === 'style' ||
|
const isAsset = event.request.destination === 'style' ||
|
||||||
event.request.destination === 'script' ||
|
event.request.destination === 'script' ||
|
||||||
event.request.destination === 'worker' ||
|
event.request.destination === 'worker' ||
|
||||||
@@ -100,54 +103,35 @@ self.addEventListener('fetch', (event) => {
|
|||||||
event.request.destination === 'image' ||
|
event.request.destination === 'image' ||
|
||||||
url.pathname.startsWith('/_next/static');
|
url.pathname.startsWith('/_next/static');
|
||||||
|
|
||||||
if (isAsset) {
|
if (isNavigation || isAsset) {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
caches.match(event.request).then((cachedResponse) => {
|
caches.match(event.request).then(async (cachedResponse) => {
|
||||||
if (cachedResponse) {
|
// Hintergrund-Update vorbereiten
|
||||||
// TREFFER: Sofort zurückgeben. Kein Fetch!
|
const fetchPromise = fetchWithTimeout(event.request, 5000)
|
||||||
// Das verhindert den "Network Changed" Crash.
|
.then(async (networkResponse) => {
|
||||||
return cachedResponse;
|
|
||||||
}
|
|
||||||
// MISS: Nur dann zum Netzwerk gehen
|
|
||||||
return fetch(event.request).then((networkResponse) => {
|
|
||||||
if (networkResponse && networkResponse.status === 200) {
|
if (networkResponse && networkResponse.status === 200) {
|
||||||
const responseClone = networkResponse.clone();
|
const cache = await caches.open(CACHE_NAME);
|
||||||
caches.open(CACHE_NAME).then((cache) => {
|
cache.put(event.request, networkResponse.clone());
|
||||||
cache.put(event.request, responseClone);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return networkResponse;
|
return networkResponse;
|
||||||
});
|
|
||||||
})
|
})
|
||||||
);
|
.catch(() => { /* Fail silently in background */ });
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. NAVIGATION (HTML): App Shell / Cache First
|
// Navigation Fallback Logik
|
||||||
// Wenn offline, gib die Startseite zurück und lass Next.js routen.
|
if (isNavigation) {
|
||||||
if (event.request.mode === 'navigate') {
|
|
||||||
event.respondWith(
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
// A. Suche exakte Seite im Cache
|
|
||||||
const cachedResponse = await caches.match(event.request);
|
|
||||||
if (cachedResponse) return cachedResponse;
|
if (cachedResponse) return cachedResponse;
|
||||||
|
|
||||||
// B. Suche Startseite (App Shell) im Cache
|
// Root Fallback für Deep Links (App Shell)
|
||||||
// Das ist der wichtigste Fallback für SPAs!
|
const shell = await caches.match('/');
|
||||||
const shellResponse = await caches.match('/');
|
if (shell) {
|
||||||
if (shellResponse) return shellResponse;
|
console.log('[SW] Route not cached, using Root App Shell fallback');
|
||||||
|
return shell;
|
||||||
// C. Wenn nichts im Cache ist (ganz neuer User): Netzwerk
|
|
||||||
return await fetchWithTimeout(event.request, 3000);
|
|
||||||
} catch (error) {
|
|
||||||
console.log('[SW] Offline fallback failed:', error);
|
|
||||||
// D. Letzter Ausweg (Text anzeigen)
|
|
||||||
return new Response('<h1>Offline</h1><p>Bitte App neu laden.</p>', {
|
|
||||||
headers: { 'Content-Type': 'text/html' }
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
})()
|
}
|
||||||
|
|
||||||
|
// Assets: Cache oder Netzwerk
|
||||||
|
return cachedResponse || fetchPromise || fetch(event.request);
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -19,7 +19,7 @@ export const metadata: Metadata = {
|
|||||||
template: "%s | Whisky Vault"
|
template: "%s | Whisky Vault"
|
||||||
},
|
},
|
||||||
description: "Dein persönlicher Whisky-Begleiter zum Scannen und Verkosten.",
|
description: "Dein persönlicher Whisky-Begleiter zum Scannen und Verkosten.",
|
||||||
manifest: "/manifest.json",
|
manifest: "/manifest.webmanifest",
|
||||||
appleWebApp: {
|
appleWebApp: {
|
||||||
capable: true,
|
capable: true,
|
||||||
statusBarStyle: "default",
|
statusBarStyle: "default",
|
||||||
|
|||||||
@@ -127,8 +127,10 @@ export default function UploadQueue() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleOnline = () => {
|
const handleOnline = () => {
|
||||||
console.log('Online! Triggering background sync...');
|
console.log('Online! Waiting 2s for network stability...');
|
||||||
|
setTimeout(() => {
|
||||||
syncQueue();
|
syncQueue();
|
||||||
|
}, 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('online', handleOnline);
|
window.addEventListener('online', handleOnline);
|
||||||
|
|||||||
Reference in New Issue
Block a user