sw.js fixed
This commit is contained in:
146
.fixoffline
Normal file
146
.fixoffline
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
Hier ist die instruktive Anleitung für deine IDE (Cursor, Antigravity, etc.). Du kannst diesen Text direkt als Prompt oder Kontext-Datei (`OFFLINE_STRATEGY.md`) in deine IDE kopieren.
|
||||||
|
|
||||||
|
Es enthält die **Pre-Caching Strategie** (für den Start im Keller) und den **Navigation Fallback** (gegen den White Screen).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🛡️ PWA "Ironclad" Offline Strategy Implementation
|
||||||
|
|
||||||
|
**Context:** The application fails to load when started completely offline ("Keller-Szenario") or crashes during navigation when the network is flaky.
|
||||||
|
**Goal:** Implement aggressive Pre-Caching for core routes and a robust Navigation Fallback in `public/sw.js`.
|
||||||
|
**Framework:** Next.js (App Router) + Workbox.
|
||||||
|
|
||||||
|
## 🚨 Critical Requirements
|
||||||
|
|
||||||
|
1. **Preserve Existing Logic:** Do not remove the `skipWaiting()` or `clients.claim()` logic if already present.
|
||||||
|
2. **App Router Compatibility:** Ensure `_next/data` requests do not cause crashes.
|
||||||
|
3. **Dexie Integration:** Rely on the existing `useLiveQuery` hooks for data. The Service Worker only needs to deliver the **App Shell**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Implementation Instructions for `public/sw.js`
|
||||||
|
|
||||||
|
Please refactor or append the Service Worker logic to include the following **three mechanics**:
|
||||||
|
|
||||||
|
### 1. Define Core Pages (The "Keller" List)
|
||||||
|
|
||||||
|
Define a list of static routes that *must* work immediately upon installation, even without a network connection.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// CONFIG: Core pages to pre-cache immediately on install
|
||||||
|
const CORE_PAGES = [
|
||||||
|
'/', // Dashboard / Home
|
||||||
|
'/tasting/new', // Critical: Add Tasting Screen
|
||||||
|
'/scan', // Critical: Scan Screen
|
||||||
|
'/offline' // Optional: Dedicated offline fallback
|
||||||
|
];
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. The "Warm Cache" Install Event
|
||||||
|
|
||||||
|
Add an `install` event listener that aggressively fetches these pages into the cache *before* the user needs them.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open('pages').then((cache) => {
|
||||||
|
console.log('⚡ PWA: Pre-caching core pages for offline usage...');
|
||||||
|
// { cache: 'reload' } forces a network fetch, ignoring browser http cache
|
||||||
|
return cache.addAll(CORE_PAGES);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. The Navigation Safety Net (The "Anti-Crash" Logic)
|
||||||
|
|
||||||
|
Implement a `NavigationRoute` that attempts the network first (with a short timeout), falls back to the cache, and—**critically**—falls back to the Root (`/`) if the specific deep-link is missing from the cache.
|
||||||
|
|
||||||
|
*Rationale:* In Next.js (SPA), loading `/` from cache is sufficient to bootstrap the React app. The client-side router will then handle the URL, and Dexie will provide the data.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { registerRoute, NavigationRoute } from 'workbox-routing';
|
||||||
|
import { NetworkFirst } from 'workbox-strategies';
|
||||||
|
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
|
||||||
|
|
||||||
|
// Strategy: Network (3s timeout) -> Cache -> Fallback to Root
|
||||||
|
const navigationHandler = async (params) => {
|
||||||
|
try {
|
||||||
|
// 1. Try Network First (Fail fast if poor connection)
|
||||||
|
const strategy = new NetworkFirst({
|
||||||
|
cacheName: 'pages',
|
||||||
|
networkTimeoutSeconds: 3,
|
||||||
|
plugins: [
|
||||||
|
new CacheableResponsePlugin({ statuses: [0, 200] }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return await strategy.handle(params);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// 2. Network failed. Try getting the specific page from cache.
|
||||||
|
const cache = await caches.open('pages');
|
||||||
|
// params.request.url contains the full URL user is trying to reach
|
||||||
|
let response = await cache.match(params.request);
|
||||||
|
|
||||||
|
if (response) return response;
|
||||||
|
|
||||||
|
// 3. CRITICAL FALLBACK: Load the App Shell (Root)
|
||||||
|
// Even if the user requested /tasting/new, serving / allows Next.js to start.
|
||||||
|
// Once React hydrates, it sees the URL is /tasting/new and renders the correct page
|
||||||
|
// using data from Dexie.
|
||||||
|
const fallbackResponse = await cache.match('/');
|
||||||
|
if (fallbackResponse) {
|
||||||
|
console.log('⚠️ PWA: Serving Root App Shell for offline navigation.');
|
||||||
|
return fallbackResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Absolute last resort
|
||||||
|
return new Response('Offline mode unavailable', { status: 504 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply this handler to all Navigation requests
|
||||||
|
registerRoute(new NavigationRoute(navigationHandler));
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Handling Next.js Data (RSC)
|
||||||
|
|
||||||
|
Prevent crashes when `_next/data/...` fetches fail.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Swallow errors for RSC data to prevent "Application Error" screens
|
||||||
|
registerRoute(
|
||||||
|
({ url }) => url.pathname.startsWith('/_next/data/'),
|
||||||
|
new NetworkFirst({
|
||||||
|
cacheName: 'next-data',
|
||||||
|
networkTimeoutSeconds: 2,
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
// If offline and not in cache, return empty JSON to keep React alive
|
||||||
|
handlerDidError: async () => new Response(JSON.stringify({}), {
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Verification Checklist
|
||||||
|
|
||||||
|
After applying these changes:
|
||||||
|
|
||||||
|
1. **Clear Storage:** Unregister old Service Workers and clear Storage in DevTools.
|
||||||
|
2. **Install:** Load the app once online. Verify `pages` cache contains `/tasting/new`.
|
||||||
|
3. **Test:**
|
||||||
|
* Go Offline (Airplane Mode).
|
||||||
|
* Reload the page.
|
||||||
|
* Navigate to `/tasting/new`.
|
||||||
|
* **Expectation:** App loads, UI renders, data comes from Dexie. No "No Internet" dino.
|
||||||
115
public/sw.js
115
public/sw.js
@@ -1,9 +1,12 @@
|
|||||||
const CACHE_NAME = 'whisky-vault-v5'; // Increment version for refined strategy
|
const CACHE_NAME = 'whisky-vault-v6-bunker'; // Neue Version erzwingen!
|
||||||
|
|
||||||
// CONFIG: Core pages and assets to pre-cache immediately on install
|
// CONFIG: Core pages and assets to pre-cache immediately on install
|
||||||
|
// WICHTIG: Hier muss die URL für "Neues Tasting" rein, damit sie offline da ist!
|
||||||
const CORE_PAGES = [
|
const CORE_PAGES = [
|
||||||
'/', // Dashboard / Home (Includes Scan and Tasting features)
|
'/', // Dashboard / App Shell
|
||||||
'/sessions', // Tasting Sessions overview
|
'/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 = [
|
||||||
@@ -13,48 +16,41 @@ const STATIC_ASSETS = [
|
|||||||
'/favicon.ico',
|
'/favicon.ico',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Install: Lade alles Wichtige sofort in den Keller
|
||||||
self.addEventListener('install', (event) => {
|
self.addEventListener('install', (event) => {
|
||||||
|
self.skipWaiting(); // Sofortiger Wechsel zum neuen SW
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.open(CACHE_NAME).then((cache) => {
|
caches.open(CACHE_NAME).then((cache) => {
|
||||||
console.log('⚡ PWA: Pre-caching core pages and assets...');
|
console.log('⚡ PWA: Pre-caching core pages and assets...');
|
||||||
// Combine items to cache
|
|
||||||
return cache.addAll([...CORE_PAGES, ...STATIC_ASSETS]);
|
return cache.addAll([...CORE_PAGES, ...STATIC_ASSETS]);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
self.skipWaiting();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Activate: Alten Müll rauswerfen
|
||||||
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);
|
||||||
return caches.delete(cacheName);
|
return caches.delete(cacheName);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
self.clients.claim();
|
self.clients.claim(); // Sofort Kontrolle übernehmen
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper for Network-First with Timeout
|
// Helper: Network mit Timeout (für den Notfall)
|
||||||
function fetchWithTimeout(request, timeoutMs = 3000) {
|
function fetchWithTimeout(request, timeoutMs = 3000) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => reject(new Error('Network timeout')), timeoutMs);
|
||||||
reject(new Error('Network timeout'));
|
|
||||||
}, timeoutMs);
|
|
||||||
|
|
||||||
fetch(request).then(
|
fetch(request).then(
|
||||||
(response) => {
|
(res) => { clearTimeout(timeoutId); resolve(res); },
|
||||||
clearTimeout(timeoutId);
|
(err) => { clearTimeout(timeoutId); reject(err); }
|
||||||
resolve(response);
|
|
||||||
},
|
|
||||||
(err) => {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
reject(err);
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -62,35 +58,41 @@ function fetchWithTimeout(request, timeoutMs = 3000) {
|
|||||||
self.addEventListener('fetch', (event) => {
|
self.addEventListener('fetch', (event) => {
|
||||||
const url = new URL(event.request.url);
|
const url = new URL(event.request.url);
|
||||||
|
|
||||||
// CRITICAL: Always bypass cache for auth, api, and supabase requests
|
// 0. BYPASS: Auth & API immer durchlassen (werden von Dexie/App handled)
|
||||||
const isAuthRequest = url.pathname.includes('/auth/') ||
|
if (url.pathname.includes('/auth/') ||
|
||||||
url.pathname.includes('/v1/auth/') ||
|
url.pathname.includes('/api/') ||
|
||||||
url.pathname.includes('/v1/token');
|
url.hostname.includes('supabase.co')) {
|
||||||
const isApiRequest = url.pathname.includes('/api/');
|
|
||||||
const isSupabaseRequest = url.hostname.includes('supabase.co');
|
|
||||||
|
|
||||||
if (isAuthRequest || isApiRequest || isSupabaseRequest) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. NEXT.JS DATA (RSC): Stale-While-Revalidate with empty JSON fallback
|
// 1. NEXT.JS DATA (RSC): Swallow Errors
|
||||||
|
// Verhindert "Application Error" wenn JSON fehlt
|
||||||
if (url.pathname.startsWith('/_next/data/')) {
|
if (url.pathname.startsWith('/_next/data/')) {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
fetchWithTimeout(event.request, 2000)
|
caches.match(event.request).then((cached) => {
|
||||||
.catch(async () => {
|
// Wenn im Cache (selten bei RSC), nimm es
|
||||||
const cachedResponse = await caches.match(event.request);
|
if (cached) return cached;
|
||||||
if (cachedResponse) return cachedResponse;
|
|
||||||
|
|
||||||
// Fallback to empty JSON to prevent "Application Error" screens
|
// Sonst Netzwerk mit Timeout
|
||||||
return new Response(JSON.stringify({}), {
|
return fetchWithTimeout(event.request, 2000)
|
||||||
headers: { 'Content-Type': 'application/json' }
|
.then(res => {
|
||||||
|
// Optional: RSC cachen für später?
|
||||||
|
// Vorsicht: RSC Daten veralten schnell. Hier cachen wir NICHT.
|
||||||
|
return res;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Fallback: Leeres JSON, damit React nicht crasht
|
||||||
|
return new Response(JSON.stringify({}), {
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. ASSETS & APP SHELL: Stale-While-Revalidate
|
// 2. ASSETS (JS/CSS/Images): CACHE FIRST (Der "Bunker" Fix)
|
||||||
|
// Wenn wir die Datei haben, nutzen wir sie. Keine Diskussion mit dem Netzwerk.
|
||||||
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' ||
|
||||||
@@ -101,7 +103,13 @@ self.addEventListener('fetch', (event) => {
|
|||||||
if (isAsset) {
|
if (isAsset) {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
caches.match(event.request).then((cachedResponse) => {
|
caches.match(event.request).then((cachedResponse) => {
|
||||||
const fetchPromise = fetch(event.request).then((networkResponse) => {
|
if (cachedResponse) {
|
||||||
|
// TREFFER: Sofort zurückgeben. Kein Fetch!
|
||||||
|
// Das verhindert den "Network Changed" Crash.
|
||||||
|
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 responseClone = networkResponse.clone();
|
||||||
caches.open(CACHE_NAME).then((cache) => {
|
caches.open(CACHE_NAME).then((cache) => {
|
||||||
@@ -110,27 +118,36 @@ self.addEventListener('fetch', (event) => {
|
|||||||
}
|
}
|
||||||
return networkResponse;
|
return networkResponse;
|
||||||
});
|
});
|
||||||
return cachedResponse || fetchPromise;
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. NAVIGATION: Ironclad Navigation Fallback
|
// 3. NAVIGATION (HTML): App Shell / Cache First
|
||||||
|
// Wenn offline, gib die Startseite zurück und lass Next.js routen.
|
||||||
if (event.request.mode === 'navigate') {
|
if (event.request.mode === 'navigate') {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
fetchWithTimeout(event.request, 3000)
|
(async () => {
|
||||||
.catch(async () => {
|
try {
|
||||||
console.log('[SW] Navigation network failure, attempting cache fallback');
|
// A. Suche exakte Seite im Cache
|
||||||
const cachedResponse = await caches.match(event.request);
|
const cachedResponse = await caches.match(event.request);
|
||||||
if (cachedResponse) return cachedResponse;
|
if (cachedResponse) return cachedResponse;
|
||||||
|
|
||||||
// CRITICAL FALLBACK: Load the Root App Shell ('/')
|
// B. Suche Startseite (App Shell) im Cache
|
||||||
// This allows Next.js to bootstrap and handle the routing client-side
|
// Das ist der wichtigste Fallback für SPAs!
|
||||||
console.warn('⚠️ PWA: Route not in cache, fallback to Root App Shell');
|
const shellResponse = await caches.match('/');
|
||||||
return caches.match('/');
|
if (shellResponse) return shellResponse;
|
||||||
})
|
|
||||||
|
// 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' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})()
|
||||||
);
|
);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user