-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsw.js
More file actions
257 lines (225 loc) · 7.77 KB
/
Copy pathsw.js
File metadata and controls
257 lines (225 loc) · 7.77 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
// Matrix — Service Worker for offline support
const CACHE_VERSION = 'matrix-v21';
const APP_CACHE = `${CACHE_VERSION}-app`;
const TILE_CACHE = `${CACHE_VERSION}-tiles`;
// App shell files to pre-cache on install
const APP_SHELL = [
'/',
'/index.html',
'/css/styles.css',
'/js/utils.js',
'/js/state.js',
'/js/map.js',
'/js/pins.js',
'/js/photos.js',
'/js/albums.js',
'/js/modals.js',
'/js/search.js',
'/js/media.js',
'/js/photo-worker.js',
'/js/data.js',
'/js/demo.js',
'/vendor/maplibre-gl.js',
'/vendor/maplibre-gl.css',
'/vendor/supercluster.min.js',
'/vendor/fonts.css',
];
// Tile URL patterns to cache (raster + vector tiles, sprites, glyphs)
const TILE_PATTERNS = [
/tiles\.openfreemap\.org\/planet\//, // vector tiles only (not style JSON)
/tiles\.maps\.eox\.at/,
/\.pbf(\?|$)/, // vector tile protobuf files
/sprites?\//, // map sprites
/glyphs?\//, // map font glyphs
];
// Max cached tiles (LRU eviction when exceeded). Kept well below Chrome's
// cache.keys() limit (~10k entries) which throws "Operation too large".
const MAX_TILES = 5000;
console.log(`SW: ${CACHE_VERSION} loaded`);
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(APP_CACHE).then((cache) => {
return cache.addAll(APP_SHELL).catch((err) => {
// Don't fail install if some files aren't available yet
console.warn('SW: Some app shell files not cached:', err);
});
})
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(async (keys) => {
const current = new Set([APP_CACHE, TILE_CACHE]);
const oldTileCaches = keys.filter((k) => !current.has(k) && k.endsWith('-tiles'));
const oldAppCaches = keys.filter((k) => !current.has(k) && !k.endsWith('-tiles'));
// Old tile caches are preserved (tiles are map data — still valid across
// app updates). But if a cache grew too large for Chrome to enumerate
// (cache.keys() throws "Operation too large"), it can never be evicted
// selectively, so reset it entirely.
await Promise.all(oldTileCaches.map(async (name) => {
try {
const cache = await caches.open(name);
await cache.keys();
} catch {
console.warn(`SW: cache ${name} too large to enumerate, resetting`);
await caches.delete(name);
}
}));
await Promise.all(oldAppCaches.map((k) => caches.delete(k)));
})
);
self.clients.claim();
});
function isTileRequest(url) {
return TILE_PATTERNS.some((p) => p.test(url));
}
function isLocalApi(url) {
return new URL(url).pathname.startsWith('/api/');
}
function isNominatim(url) {
return url.includes('nominatim.openstreetmap.org');
}
function isFontFile(url) {
return url.includes('/vendor/fonts/') || url.includes('fonts.gstatic.com');
}
// Network-first for app shell, cache-first for tiles
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = request.url;
// Only handle http(s) requests — ignore chrome-extension://, etc.
if (!url.startsWith('http')) return;
// Don't intercept local API calls or POST/DELETE requests
if (isLocalApi(url) || request.method !== 'GET') {
return;
}
// Don't cache Nominatim — it's transient geocoding data
if (isNominatim(url)) {
return;
}
// Map tiles: cache-first (tiles don't change often)
if (isTileRequest(url)) {
event.respondWith(tileStrategy(request));
return;
}
// Font files: cache-first
if (isFontFile(url)) {
event.respondWith(cacheFirst(request, APP_CACHE));
return;
}
// Everything else (app shell): network-first with cache fallback
event.respondWith(networkFirst(request, APP_CACHE));
});
async function networkFirst(request, cacheName) {
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(cacheName);
cache.put(request, response.clone());
}
return response;
} catch (err) {
const cached = await caches.match(request);
if (cached) return cached;
throw err;
}
}
async function cacheFirst(request, cacheName) {
const cached = await caches.match(request);
if (cached) return cached;
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(cacheName);
cache.put(request, response.clone());
}
return response;
} catch (err) {
throw err;
}
}
const TRANSPARENT_PNG = Uint8Array.from(atob('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpeqz8AAAAASUVORK5CYII='), c => c.charCodeAt(0));
async function tileStrategy(request) {
// L1: browser Cache API (instant)
let cached;
try {
cached = await caches.match(request, { ignoreVary: true });
} catch (err) {
// Cache may be corrupted or too large to query — fall through to L2/L3
}
if (cached) return cached;
const proxyUrl = `${self.location.origin}/api/tiles/proxy?url=${encodeURIComponent(request.url)}`;
const cacheAndReturn = async (body, ct) => {
try {
const cacheResp = new Response(body, { status: 200, headers: { 'Content-Type': ct } });
const cache = await caches.open(TILE_CACHE);
cache.put(request, cacheResp.clone());
evictOldTiles(cache).catch(() => {});
return cacheResp;
} catch {
return new Response(body, { status: 200, headers: { 'Content-Type': ct } });
}
};
// Race L2 (disk) and L3 (origin) — whichever responds first wins
const diskCheck = fetch(proxyUrl).then(r => {
if (!r.ok) throw new Error('miss');
return r.arrayBuffer().then(body => ({ body, ct: r.headers.get('Content-Type') || 'application/octet-stream' }));
});
const originFetch = fetch(request).then(r => {
if (!r.ok) throw new Error('origin error');
return r.arrayBuffer().then(body => {
const ct = r.headers.get('Content-Type') || 'application/octet-stream';
// Save to disk in background
fetch(`${self.location.origin}/api/tiles/cache?url=${encodeURIComponent(request.url)}`, {
method: 'POST', body: body.slice(0)
}).catch(() => {});
return { body, ct };
});
});
try {
const { body, ct } = await Promise.any([diskCheck, originFetch]);
return cacheAndReturn(body, ct);
} catch {
return new Response(TRANSPARENT_PNG, {
status: 200,
headers: { 'Content-Type': 'image/png', 'Cache-Control': 'no-store' }
});
}
}
function zoomFromUrl(url) {
// Tile URLs contain /{z}/{x}/{y} — extract z from the path
const m = url.match(/\/(\d+)\/\d+\/\d+(?:\.pbf)?(?:\?|$)/);
return m ? parseInt(m[1], 10) : 99;
}
let lastEvictCheck = 0;
async function evictOldTiles(cache) {
// Don't enumerate the whole cache on every tile put — Chrome throws
// "Operation too large" when a cache has too many entries to list.
const now = Date.now();
if (now - lastEvictCheck < 5000) return;
lastEvictCheck = now;
let keys;
try {
keys = await cache.keys();
} catch (err) {
// Cache too large to enumerate — can't evict selectively, so reset it.
console.warn('SW: tile cache too large, resetting');
await caches.delete(TILE_CACHE);
return;
}
if (keys.length <= MAX_TILES) return;
// Protect low-zoom tiles (z ≤ 8) — they cover the most area
const protectedKeys = [];
const evictableKeys = [];
keys.forEach((k) => {
if (zoomFromUrl(k.url) <= 8) protectedKeys.push(k);
else evictableKeys.push(k);
});
let excess = keys.length - MAX_TILES;
// Evict high-zoom tiles first (oldest first)
const toDelete = evictableKeys.slice(0, Math.min(excess, evictableKeys.length));
excess -= toDelete.length;
// If still over limit, evict protected tiles too
if (excess > 0) toDelete.push(...protectedKeys.slice(0, excess));
await Promise.all(toDelete.map((k) => cache.delete(k)));
}