-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsw.js
More file actions
315 lines (292 loc) · 11.2 KB
/
Copy pathsw.js
File metadata and controls
315 lines (292 loc) · 11.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
/* ============================================================
sw.js – MotoRoute Service Worker
Handles: offline caching + Web Push Notifications
============================================================ */
const CACHE_NAME = 'motoroute-v11';
const API_CACHE_NAME = 'motoroute-api-v1';
const TILE_CACHE_NAME = 'motoroute-tiles-v1';
const TILE_CACHE_MAX = 500; // ~500 Tiles × ø20 KB = max ~10 MB
// App-Shell: alles was offline verfügbar sein soll
const PRECACHE = [
'/',
'/index.html',
'/manifest.json',
'/css/styles.css',
'/css/theme-premium.css',
'/vendor/leaflet/leaflet.min.css',
'/vendor/leaflet/leaflet.min.js',
'/vendor/supabase/supabase-js-2.umd.min.js',
'/vendor/leaflet/images/marker-icon.png',
'/vendor/leaflet/images/marker-icon-2x.png',
'/vendor/leaflet/images/marker-shadow.png',
'/js/config.js',
'/js/state.js',
'/js/utils.js',
'/js/gpx-cache.js',
'/js/api.js',
'/js/auth.js',
'/js/map.js',
'/js/render.js',
'/js/events.js',
'/js/app.js',
'/img/logo.webp',
'/img/icon-192x192.png',
'/img/icon-512x512.png',
'/img/apple-touch-icon.png'
];
/* ----------------------------------------------------------
Install – App-Shell cachen
---------------------------------------------------------- */
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(PRECACHE))
.then(() => self.skipWaiting()) // become "waiting" then immediately active
);
});
/* ----------------------------------------------------------
Activate – alte Caches aufräumen + sofort übernehmen
---------------------------------------------------------- */
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(
keys
.filter(k => k !== CACHE_NAME && k !== API_CACHE_NAME && k !== TILE_CACHE_NAME)
.map(k => caches.delete(k))
)
).then(() => self.clients.claim()) // take control of open pages without reload-required
);
});
function isAppShellRequest(event, url) {
if (url.origin !== self.location.origin || event.request.method !== 'GET') return false;
if (event.request.mode === 'navigate') return true;
return /\.(?:html|css|js|json|webmanifest)$/i.test(url.pathname);
}
function networkFirstWithCache(event, fallbackUrl) {
event.respondWith(
caches.open(CACHE_NAME).then(async cache => {
try {
const response = await fetch(event.request);
if (response.ok) cache.put(event.request, response.clone());
return response;
} catch (e) {
const cached = await cache.match(event.request);
if (cached) return cached;
if (fallbackUrl) return cache.match(fallbackUrl);
return undefined;
}
})
);
}
/* ----------------------------------------------------------
Fetch – Network-first für API/App-Shell, Cache-first für Tiles/Images
---------------------------------------------------------- */
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
// Externe CDNs & nicht-cacheable Supabase-Endpunkte immer live abrufen
if (
url.hostname.includes('cloudinary.com') ||
url.hostname.includes('googleapis.com') ||
url.hostname.includes('cdnjs.cloudflare.com') ||
url.hostname.includes('jsdelivr.net') ||
url.hostname.includes('open-meteo.com')
) {
return; // Browser-Standard
}
// Supabase REST GET → Network-first mit Cache-Fallback.
// Die App rendert bereits vorher den persistierten State. Wenn sie online
// frisch nachlädt, muss der awaited Fetch deshalb echte Live-Daten liefern;
// stale-first würde Änderungen oft erst beim zweiten/dritten Reload zeigen.
if (
url.hostname.includes('supabase.co') &&
url.pathname.startsWith('/rest/v1/') &&
event.request.method === 'GET'
) {
event.respondWith(
caches.open(API_CACHE_NAME).then(async cache => {
try {
const response = await fetch(event.request);
// Bei abgelaufenem JWT (401/403) liefert Supabase einen Fehler zurück,
// der im App-Layer wie "leeres Ergebnis" behandelt würde und so cached
// State überschreiben kann. Lieber den vorhandenen Cache zurückgeben —
// der Auth-Layer kümmert sich parallel um Token-Refresh, und beim
// nächsten Refresh-Tick sehen wir wieder frische Daten.
if (response.status === 401 || response.status === 403) {
const cached = await cache.match(event.request);
if (cached) return cached;
}
if (response.ok) cache.put(event.request, response.clone());
return response;
} catch (e) {
const cached = await cache.match(event.request);
if (cached) return cached;
return new Response(JSON.stringify({ error: 'offline', message: 'Offline – kein Cache verfügbar' }), {
status: 503,
headers: { 'Content-Type': 'application/json' },
});
}
})
);
return;
}
// Supabase REST Schreiboperationen → nach Erfolg betroffene Tabelle aus Cache entfernen
// Verhindert, dass nach z.B. Beitritt die Mitgliederliste noch alt ist.
if (
url.hostname.includes('supabase.co') &&
url.pathname.startsWith('/rest/v1/') &&
['POST', 'PATCH', 'PUT', 'DELETE'].includes(event.request.method)
) {
event.respondWith((async () => {
const response = await fetch(event.request);
try {
if (response.ok) {
// Tabellenname aus dem Pfad extrahieren: /rest/v1/<table>...
const tableMatch = url.pathname.match(/^\/rest\/v1\/([^/?]+)/);
const table = tableMatch ? tableMatch[1] : null;
if (table) {
const cache = await caches.open(API_CACHE_NAME);
const keys = await cache.keys();
await Promise.all(keys.map(req => {
const reqUrl = new URL(req.url);
if (reqUrl.pathname.startsWith(`/rest/v1/${table}`)) {
return cache.delete(req);
}
}));
}
}
} catch (e) {
// Cache-Invalidierung darf die Antwort nicht blockieren
}
return response;
})());
return;
}
// Alle anderen Supabase-Endpunkte (auth, functions, storage) → immer live
if (url.hostname.includes('supabase.co')) {
return; // Browser-Standard
}
// App-Shell (HTML/CSS/JS/Manifest): online immer frisch laden.
// Wichtig für installierte PWAs: cache-first kann sonst nach Deploys alte
// JS-Dateien mit neuem Serverstand mischen.
if (isAppShellRequest(event, url)) {
networkFirstWithCache(event, event.request.mode === 'navigate' ? '/index.html' : null);
return;
}
// Karten-Tiles (OpenStreetMap) — Cache-first, Größenlimit
if (url.hostname.includes('tile.openstreetmap.org')) {
event.respondWith(
caches.open(TILE_CACHE_NAME).then(async cache => {
const cached = await cache.match(event.request);
if (cached) return cached;
// Tile noch nicht gecacht → laden und speichern
const response = await fetch(event.request);
if (response.ok) {
await cache.put(event.request, response.clone());
// Älteste Tiles entfernen wenn Limit überschritten
const keys = await cache.keys();
if (keys.length > TILE_CACHE_MAX) {
const toDelete = keys.slice(0, keys.length - TILE_CACHE_MAX);
toDelete.forEach(k => cache.delete(k));
}
}
return response;
}).catch(() => {
// Offline + nicht gecacht → leere aber gültige PNG-Antwort
// (Leaflet zeigt graue Tile statt kaputtem Bild-Icon).
// WICHTIG: atob() liefert einen Binary-String — als String an Response
// übergeben würde der als UTF-8 enkodiert und das PNG zerstört. Deshalb
// explizit in Uint8Array umwandeln, damit echte Bytes ausgehen.
const b64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return new Response(bytes, {
status: 200,
headers: { 'Content-Type': 'image/png' },
});
})
);
return;
}
// Statische Medien: Cache-first, Fallback auf Network
event.respondWith(
caches.match(event.request).then(cached => {
if (cached) return cached;
return fetch(event.request).then(response => {
// Nur GET-Antworten cachen
if (event.request.method !== 'GET' || !response.ok) return response;
const clone = response.clone();
caches.open(CACHE_NAME).then(c => c.put(event.request, clone));
return response;
}).catch(() => {
// Offline-Fallback für Navigation
if (event.request.mode === 'navigate') {
return caches.match('/index.html');
}
});
})
);
});
/* ----------------------------------------------------------
Message – SKIP_WAITING für sofortiges Update
---------------------------------------------------------- */
self.addEventListener('message', event => {
if (event.data?.type === 'SKIP_WAITING') {
self.skipWaiting();
}
// Cache-Invalidierung für eine bestimmte Tabelle — wird von der App vor
// einem frischen Tour-Fetch aufgerufen, damit stale Daten nicht gezeigt werden.
if (event.data?.type === 'INVALIDATE_TABLE') {
const table = event.data.table;
const port = event.ports?.[0];
caches.open(API_CACHE_NAME).then(async cache => {
const keys = await cache.keys();
await Promise.all(keys.map(req => {
const reqUrl = new URL(req.url);
if (reqUrl.pathname.startsWith(`/rest/v1/${table}`)) return cache.delete(req);
}));
port?.postMessage('done');
});
}
});
/* ----------------------------------------------------------
Push – Eingehende Benachrichtigung anzeigen
---------------------------------------------------------- */
self.addEventListener('push', event => {
let data = { title: 'MotoRoute', body: 'Neue Benachrichtigung', icon: '/img/icon-192x192.png' };
if (event.data) {
try {
data = { ...data, ...event.data.json() };
} catch {
data.body = event.data.text();
}
}
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: data.icon || '/img/icon-192x192.png',
badge: '/img/icon-72x72.png',
tag: data.tag || 'motoroute',
data: { url: data.url || '/' },
vibrate: [100, 50, 100]
})
);
});
/* ----------------------------------------------------------
Notification Click – App öffnen / in den Vordergrund bringen
---------------------------------------------------------- */
self.addEventListener('notificationclick', event => {
event.notification.close();
const target = event.notification.data?.url || '/';
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clients => {
for (const client of clients) {
if (client.url.includes(self.location.origin) && 'focus' in client) {
return client.focus();
}
}
return self.clients.openWindow(target);
})
);
});