-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsw.js
More file actions
229 lines (199 loc) · 6.72 KB
/
sw.js
File metadata and controls
229 lines (199 loc) · 6.72 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
// Matrix — Service Worker for offline support
const CACHE_VERSION = 'matrix-v19';
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/exif.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)
/server\.arcgisonline\.com/,
/\.pbf(\?|$)/, // vector tile protobuf files
/sprites?\//, // map sprites
/glyphs?\//, // map font glyphs
];
// Max cached tiles (LRU eviction when exceeded)
const MAX_TILES = 10000;
// Local server port for disk-cached tile proxy
let serverPort = 8765;
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('message', (event) => {
if (event.data?.type === 'set-port') serverPort = event.data.port;
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) => {
return Promise.all(
// Keep current app + tile caches, and preserve tile caches from older
// versions (tiles are map data — still valid across app updates)
keys.filter((k) => k !== APP_CACHE && k !== TILE_CACHE && !k.endsWith('-tiles')).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)
const cached = await caches.match(request, { ignoreVary: true });
if (cached) return cached;
const proxyUrl = `http://localhost:${serverPort}/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);
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(`http://localhost:${serverPort}/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;
}
async function evictOldTiles(cache) {
const keys = await cache.keys();
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)));
}