map-gl-offline 0.7.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,396 +1,349 @@
1
1
  /**
2
- * Service Worker for offline map tile serving.
2
+ * Offline Service Worker for offline map tile serving.
3
3
  *
4
- * Intercepts /__offline__/{downloadId}/{type}/{...path} requests and serves
5
- * resources from IndexedDB. This is needed for Mapbox GL JS v3 which does NOT
6
- * have addProtocol, so tile requests from web workers cannot be intercepted
7
- * via window.fetch override alone.
8
- *
9
- * Self-contained: uses raw IndexedDB API (no imports).
10
- *
11
- * DB: 'offline-map-db' v3
12
- * Stores: tiles, styles, sprites, glyphs
13
- * Tile key format: {styleId}:{sourceId}:{z}:{x}:{y}.{ext}
4
+ * Built from src/sw/offline-sw.ts by scripts/build-sw.mjs.
5
+ * DO NOT EDIT THIS FILE BY HAND changes will be overwritten on next build.
6
+ * Edit src/sw/offline-sw.ts or src/sw/shared.ts instead.
14
7
  */
15
-
16
- const DB_NAME = 'offline-map-db';
17
- const OFFLINE_PREFIX = '/__offline__/';
18
-
19
- // In-memory cache: regionId -> { styleKey, timestamp }
20
- const regionToStyleCache = new Map();
21
- const CACHE_TTL_MS = 60000;
22
-
23
- // -----------------------------------------------------------
24
- // Install / Activate
25
- // -----------------------------------------------------------
26
-
27
- self.addEventListener('install', () => {
28
- self.skipWaiting();
29
- });
30
-
31
- self.addEventListener('activate', (event) => {
32
- event.waitUntil(self.clients.claim());
33
- });
34
-
35
- // -----------------------------------------------------------
36
- // IndexedDB helpers (raw API, no idb library)
37
- // -----------------------------------------------------------
38
-
39
- function openDatabase() {
40
- return new Promise((resolve, reject) => {
41
- // Open without specifying a version so we never trigger onupgradeneeded.
42
- // The main application is responsible for creating/upgrading the DB schema.
43
- // If the DB doesn't exist yet, this will create it at version 1 with no stores,
44
- // but the SW should only run after the main app has initialised the DB.
45
- const request = indexedDB.open(DB_NAME);
46
- request.onsuccess = () => resolve(request.result);
47
- request.onerror = () => reject(request.error);
48
- });
49
- }
50
-
51
- function idbGet(db, storeName, key) {
52
- return new Promise((resolve, reject) => {
53
- const tx = db.transaction(storeName, 'readonly');
54
- const store = tx.objectStore(storeName);
55
- const req = store.get(key);
56
- req.onsuccess = () => resolve(req.result || null);
57
- req.onerror = () => reject(req.error);
58
- });
59
- }
60
-
61
- function idbGetAll(db, storeName) {
62
- return new Promise((resolve, reject) => {
63
- const tx = db.transaction(storeName, 'readonly');
64
- const store = tx.objectStore(storeName);
65
- const req = store.getAll();
66
- req.onsuccess = () => resolve(req.result || []);
67
- req.onerror = () => reject(req.error);
68
- });
69
- }
70
-
71
- // -----------------------------------------------------------
72
- // findStyleByRegionId - search all styles for matching region
73
- // -----------------------------------------------------------
74
-
75
- async function findStyleByRegionId(db, regionId) {
76
- // Check cache
77
- const cached = regionToStyleCache.get(regionId);
78
- if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
79
- return cached.styleEntry;
8
+ "use strict";
9
+ (() => {
10
+ // src/sw/shared.ts
11
+ var OFFLINE_PREFIX = "/__offline__/";
12
+ var DB_NAME = "offline-map-db";
13
+ function makeTileKey(x, y, z, styleId, sourceId, ext) {
14
+ return `${styleId}:${sourceId}:${z}:${x}:${y}.${ext}`;
80
15
  }
81
-
82
- try {
83
- const allStyles = await idbGetAll(db, 'styles');
84
- for (const styleEntry of allStyles) {
85
- if (styleEntry.regions && Array.isArray(styleEntry.regions)) {
86
- const hasRegion = styleEntry.regions.some(
87
- (r) => r.regionId === regionId || r.id === regionId
88
- );
89
- if (hasRegion) {
90
- regionToStyleCache.set(regionId, { styleEntry, timestamp: Date.now() });
91
- return styleEntry;
16
+ var TILE_FALLBACK_EXTENSIONS = ["pbf", "mvt", "png", "jpg", "webp", "glb"];
17
+ function tileFallbackExtensions(requested) {
18
+ return TILE_FALLBACK_EXTENSIONS.filter((e) => e !== requested);
19
+ }
20
+ function parseTileYExt(yExt) {
21
+ const match = yExt.match(/^(\d+)\.([\w.]+?)(?:[?#]|$)/);
22
+ if (!match) return null;
23
+ const y = parseInt(match[1], 10);
24
+ if (Number.isNaN(y)) return null;
25
+ return { y, ext: match[2] };
26
+ }
27
+ function findStyleByRegionIdIn(styles, regionId) {
28
+ for (const entry of styles) {
29
+ const regions = entry.regions;
30
+ if (!Array.isArray(regions)) continue;
31
+ for (const r of regions) {
32
+ if (r?.regionId === regionId || r?.id === regionId) {
33
+ return entry;
92
34
  }
93
35
  }
94
36
  }
95
- // Cache negative result
96
- regionToStyleCache.set(regionId, { styleEntry: null, timestamp: Date.now() });
97
- return null;
98
- } catch (err) {
99
- console.warn('[SW] Error searching for style by region ID:', regionId, err);
100
37
  return null;
101
38
  }
102
- }
103
-
104
- // -----------------------------------------------------------
105
- // Response builders
106
- // -----------------------------------------------------------
107
-
108
- function createTileKey(x, y, z, styleId, sourceId, ext) {
109
- return `${styleId}:${sourceId}:${z}:${x}:${y}.${ext}`;
110
- }
111
-
112
- async function decompressGzip(data) {
113
- try {
114
- const ds = new DecompressionStream('gzip');
115
- const stream = new Response(data).body.pipeThrough(ds);
116
- return await new Response(stream).arrayBuffer();
117
- } catch (err) {
118
- console.warn('[SW] Gzip decompression failed:', err);
119
- return data;
39
+ function parseGlyphPath(decodedPath) {
40
+ const pathParts = decodedPath.split("/");
41
+ const fontstackPart = pathParts[0] ?? "";
42
+ const rangePart = pathParts[1] || "0-255.pbf";
43
+ const fontstacks = fontstackPart.split(",").map((f) => f.trim()).filter(Boolean);
44
+ return { fontstacks, rangePart };
120
45
  }
121
- }
122
-
123
- async function buildTileResponse(resource) {
124
- const headers = {
125
- 'Access-Control-Allow-Origin': '*',
126
- 'Cache-Control': 'public, max-age=31536000',
127
- };
128
-
129
- if (resource.contentType) {
130
- headers['Content-Type'] = resource.contentType;
131
- } else if (resource.type === 'vector') {
132
- headers['Content-Type'] = 'application/vnd.mapbox-vector-tile';
46
+ function glyphCandidateKeys(actualStyleId, downloadId, fontstack, rangePart) {
47
+ const glyphPath = `${fontstack}/${rangePart}`;
48
+ const normalizedPath = glyphPath.endsWith(".pbf") ? glyphPath : `${glyphPath}.pbf`;
49
+ return dedupe([
50
+ `${actualStyleId}::${normalizedPath}`,
51
+ `${actualStyleId}::${glyphPath}`,
52
+ `${downloadId}::${normalizedPath}`,
53
+ `${downloadId}::${glyphPath}`,
54
+ normalizedPath,
55
+ glyphPath
56
+ ]);
133
57
  }
134
-
135
- let finalData = resource.data;
136
-
137
- // Check for gzip and decompress
138
- const view = new Uint8Array(resource.data);
139
- const isGzipped = view.length >= 2 && view[0] === 0x1f && view[1] === 0x8b;
140
-
141
- if (isGzipped && resource.type === 'vector') {
142
- finalData = await decompressGzip(resource.data);
58
+ function spriteCandidateKeys(actualStyleId, downloadId, decodedPath) {
59
+ const stripExt = decodedPath.replace(/\.(json|png)$/i, "");
60
+ return dedupe([
61
+ `${actualStyleId}::${decodedPath}`,
62
+ `${actualStyleId}:${decodedPath}`,
63
+ `${actualStyleId}::${stripExt}`,
64
+ `${actualStyleId}:${stripExt}`,
65
+ `${downloadId}::${decodedPath}`,
66
+ `${downloadId}:${decodedPath}`,
67
+ `${downloadId}::${stripExt}`,
68
+ `${downloadId}:${stripExt}`,
69
+ decodedPath
70
+ ]);
143
71
  }
144
-
145
- if (resource.contentEncoding && resource.contentEncoding !== 'gzip') {
146
- headers['Content-Encoding'] = resource.contentEncoding;
72
+ function modelCandidateKeys(actualStyleId, downloadId, decodedPath) {
73
+ return dedupe([
74
+ `${actualStyleId}::model::${decodedPath}`,
75
+ `${downloadId}::model::${decodedPath}`
76
+ ]);
147
77
  }
148
-
149
- return new Response(finalData, { status: 200, statusText: 'OK', headers });
150
- }
151
-
152
- function deriveTileExtension(tiles) {
153
- if (Array.isArray(tiles) && tiles.length > 0 && typeof tiles[0] === 'string') {
154
- const match = tiles[0].match(/\.([\w]+)(?:\?|$)/i);
155
- if (match) return match[1];
78
+ function matchTileJsonSource(sources, decodedPath) {
79
+ const asConfig = (v) => v && typeof v === "object" ? v : null;
80
+ if (decodedPath in sources) {
81
+ const config = asConfig(sources[decodedPath]);
82
+ if (config) return { sourceId: decodedPath, config };
83
+ }
84
+ for (const [sourceId, raw] of Object.entries(sources)) {
85
+ const config = asConfig(raw);
86
+ if (!config) continue;
87
+ const url = typeof config.url === "string" ? config.url : void 0;
88
+ const original = typeof config.__originalTilesetUrl === "string" ? config.__originalTilesetUrl : void 0;
89
+ if (url === decodedPath || original === decodedPath) {
90
+ return { sourceId, config };
91
+ }
92
+ }
93
+ return null;
156
94
  }
157
- return 'pbf';
158
- }
159
-
160
- // -----------------------------------------------------------
161
- // Resource handlers
162
- // -----------------------------------------------------------
163
-
164
- async function handleTile(db, downloadId, rest) {
165
- const styleEntry = await findStyleByRegionId(db, downloadId);
166
- const actualStyleId = styleEntry ? styleEntry.key : downloadId;
167
-
168
- // rest = ['sourceKey', 'z', 'x', 'y.ext']
169
- if (rest.length !== 4) {
170
- return new Response('Invalid tile path', { status: 400 });
95
+ function buildOfflineTileJson(sourceConfig, downloadId, sourceId, extension, tileUrlScheme, origin) {
96
+ const base = tileUrlScheme === "idb" ? `idb://${downloadId}/tile/${sourceId}/{z}/{x}/{y}.${extension}` : `${origin ?? ""}${OFFLINE_PREFIX}${downloadId}/tile/${sourceId}/{z}/{x}/{y}.${extension}`;
97
+ const tileJson = {
98
+ tilejson: typeof sourceConfig.tilejson === "string" ? sourceConfig.tilejson : "2.2.0",
99
+ name: sourceConfig.name ?? sourceId,
100
+ tiles: [base],
101
+ minzoom: typeof sourceConfig.minzoom === "number" ? sourceConfig.minzoom : 0,
102
+ maxzoom: typeof sourceConfig.maxzoom === "number" ? sourceConfig.maxzoom : 22
103
+ };
104
+ const copyable = [
105
+ "bounds",
106
+ "center",
107
+ "vector_layers",
108
+ "scheme",
109
+ "attribution",
110
+ "encoding",
111
+ "format",
112
+ "grids",
113
+ "data",
114
+ "template",
115
+ "version"
116
+ ];
117
+ for (const field of copyable) {
118
+ if (field in sourceConfig && sourceConfig[field] !== void 0) {
119
+ tileJson[field] = sourceConfig[field];
120
+ }
121
+ }
122
+ return tileJson;
171
123
  }
172
-
173
- const sourceKey = rest[0];
174
- const z = Math.floor(parseFloat(rest[1]));
175
- const x = parseInt(rest[2], 10);
176
- const yExt = rest[3];
177
- const yMatch = yExt.match(/(\d+)\.(\w+)/);
178
-
179
- if (!yMatch) {
180
- return new Response('Invalid tile coordinates', { status: 400 });
124
+ function deriveTileExtensionFromTiles(tiles) {
125
+ if (Array.isArray(tiles) && tiles.length > 0 && typeof tiles[0] === "string") {
126
+ const match = tiles[0].match(/\.([\w]+)(?:[?#]|$)/i);
127
+ if (match) return match[1];
128
+ }
129
+ return "pbf";
181
130
  }
182
-
183
- const y = parseInt(yMatch[1], 10);
184
- const requestedExt = yMatch[2];
185
-
186
- // Try primary key
187
- const tileKey = createTileKey(x, y, z, actualStyleId, sourceKey, requestedExt);
188
- let resource = await idbGet(db, 'tiles', tileKey);
189
- if (resource && resource.data) {
190
- return buildTileResponse(resource);
131
+ function isGzipped(buffer) {
132
+ if (buffer.byteLength < 2) return false;
133
+ const view = new Uint8Array(buffer, 0, 2);
134
+ return view[0] === 31 && view[1] === 139;
191
135
  }
192
-
193
- // Fallback extensions
194
- const fallbacks = ['pbf', 'mvt', 'png', 'jpg', 'webp'].filter((e) => e !== requestedExt);
195
- for (const ext of fallbacks) {
196
- const key = createTileKey(x, y, z, actualStyleId, sourceKey, ext);
197
- resource = await idbGet(db, 'tiles', key);
198
- if (resource && resource.data) {
199
- return buildTileResponse(resource);
200
- }
136
+ function dedupe(values) {
137
+ return Array.from(new Set(values));
201
138
  }
202
139
 
203
- return new Response('Tile not found', { status: 404 });
204
- }
205
-
206
- async function handleGlyph(db, downloadId, rest) {
207
- const styleEntry = await findStyleByRegionId(db, downloadId);
208
- const actualStyleId = styleEntry ? styleEntry.key : downloadId;
209
-
210
- // rest might be ['FontA,FontB', '0-255.pbf'] or ['FontA,FontB,FontC', '0-255.pbf']
211
- const resourcePath = decodeURIComponent(rest.join('/'));
212
- const pathParts = resourcePath.split('/');
213
- const fontstackPart = pathParts[0] || '';
214
- const rangePart = pathParts[1] || '0-255.pbf';
215
-
216
- // Split comma-separated fonts for fallback
217
- const fontstacks = fontstackPart.split(',').map((f) => f.trim());
218
-
219
- for (const fontstack of fontstacks) {
220
- const glyphPath = `${fontstack}/${rangePart}`;
221
- const normalizedPath = glyphPath.endsWith('.pbf') ? glyphPath : `${glyphPath}.pbf`;
222
-
223
- const candidateKeys = [
224
- `${actualStyleId}::${normalizedPath}`,
225
- `${actualStyleId}::${glyphPath}`,
226
- `${downloadId}::${normalizedPath}`,
227
- `${downloadId}::${glyphPath}`,
228
- normalizedPath,
229
- glyphPath,
230
- ];
231
-
232
- for (const key of candidateKeys) {
233
- const resource = await idbGet(db, 'glyphs', key);
234
- if (resource && resource.data) {
235
- return new Response(resource.data, {
236
- status: 200,
237
- headers: { 'Content-Type': 'application/x-protobuf' },
238
- });
140
+ // src/sw/offline-sw.ts
141
+ self.addEventListener("install", () => {
142
+ self.skipWaiting();
143
+ });
144
+ self.addEventListener("activate", (event) => {
145
+ event.waitUntil(self.clients.claim());
146
+ });
147
+ function openDatabase() {
148
+ return new Promise((resolve, reject) => {
149
+ const req = indexedDB.open(DB_NAME);
150
+ req.onsuccess = () => resolve(req.result);
151
+ req.onerror = () => reject(req.error);
152
+ });
153
+ }
154
+ function idbGet(db, store, key) {
155
+ return new Promise((resolve, reject) => {
156
+ const tx = db.transaction(store, "readonly");
157
+ const req = tx.objectStore(store).get(key);
158
+ req.onsuccess = () => resolve(req.result ?? null);
159
+ req.onerror = () => reject(req.error);
160
+ });
161
+ }
162
+ function idbGetAll(db, store) {
163
+ return new Promise((resolve, reject) => {
164
+ const tx = db.transaction(store, "readonly");
165
+ const req = tx.objectStore(store).getAll();
166
+ req.onsuccess = () => resolve(req.result ?? []);
167
+ req.onerror = () => reject(req.error);
168
+ });
169
+ }
170
+ var CACHE_TTL_MS = 6e4;
171
+ var regionToStyleCache = /* @__PURE__ */ new Map();
172
+ async function findStyleByRegionId(db, regionId) {
173
+ const cached = regionToStyleCache.get(regionId);
174
+ if (cached && Date.now() - cached.ts < CACHE_TTL_MS) {
175
+ return cached.styleEntry;
176
+ }
177
+ try {
178
+ const all = await idbGetAll(db, "styles");
179
+ const hit = findStyleByRegionIdIn(all, regionId);
180
+ if (hit) {
181
+ regionToStyleCache.set(regionId, { styleEntry: hit, ts: Date.now() });
239
182
  }
183
+ return hit;
184
+ } catch (err) {
185
+ console.warn("[SW] findStyleByRegionId failed for", regionId, err);
186
+ return null;
240
187
  }
241
188
  }
242
-
243
- return new Response('Glyph not found', { status: 404 });
244
- }
245
-
246
- async function handleSprite(db, downloadId, rest) {
247
- const styleEntry = await findStyleByRegionId(db, downloadId);
248
- const actualStyleId = styleEntry ? styleEntry.key : downloadId;
249
-
250
- const decodedResourcePath = decodeURIComponent(rest.join('/'));
251
-
252
- const candidateKeys = [
253
- `${actualStyleId}::${decodedResourcePath}`,
254
- `${actualStyleId}:${decodedResourcePath}`,
255
- `${actualStyleId}::${decodedResourcePath.replace(/\.(json|png)$/i, '')}`,
256
- `${actualStyleId}:${decodedResourcePath.replace(/\.(json|png)$/i, '')}`,
257
- `${downloadId}::${decodedResourcePath}`,
258
- `${downloadId}:${decodedResourcePath}`,
259
- `${downloadId}::${decodedResourcePath.replace(/\.(json|png)$/i, '')}`,
260
- `${downloadId}:${decodedResourcePath.replace(/\.(json|png)$/i, '')}`,
261
- decodedResourcePath,
262
- `${downloadId}::${decodedResourcePath}`,
263
- ];
264
-
265
- // Deduplicate
266
- const uniqueKeys = [...new Set(candidateKeys)];
267
-
268
- for (const key of uniqueKeys) {
269
- const resource = await idbGet(db, 'sprites', key);
270
- if (resource && resource.data) {
271
- return new Response(resource.data, {
272
- status: 200,
273
- headers: resource.contentType ? { 'Content-Type': resource.contentType } : {},
274
- });
189
+ async function decompressGzip(data) {
190
+ try {
191
+ const stream = new Response(data).body?.pipeThrough(new DecompressionStream("gzip"));
192
+ if (!stream) return data;
193
+ return await new Response(stream).arrayBuffer();
194
+ } catch (err) {
195
+ console.warn("[SW] Gzip decompression failed:", err);
196
+ return data;
275
197
  }
276
198
  }
277
-
278
- return new Response('Sprite not found', { status: 404 });
279
- }
280
-
281
- async function handleTileJSON(db, downloadId, rest) {
282
- const decodedResourcePath = decodeURIComponent(rest.join('/'));
283
-
284
- // Try direct style lookup first
285
- let styleEntry = await idbGet(db, 'styles', downloadId);
286
-
287
- // Fallback: search by region ID
288
- if (!styleEntry || !styleEntry.style || !styleEntry.style.sources) {
289
- const foundStyle = await findStyleByRegionId(db, downloadId);
290
- if (foundStyle) {
291
- styleEntry = foundStyle;
199
+ async function buildTileResponse(resource) {
200
+ const headers = {
201
+ "Access-Control-Allow-Origin": "*",
202
+ "Cache-Control": "public, max-age=31536000"
203
+ };
204
+ if (resource.contentType) {
205
+ headers["Content-Type"] = resource.contentType;
206
+ } else if (resource.type === "vector") {
207
+ headers["Content-Type"] = "application/vnd.mapbox-vector-tile";
208
+ }
209
+ let finalData = resource.data;
210
+ if (isGzipped(resource.data) && resource.type === "vector") {
211
+ finalData = await decompressGzip(resource.data);
292
212
  }
213
+ if (resource.contentEncoding && resource.contentEncoding !== "gzip") {
214
+ headers["Content-Encoding"] = resource.contentEncoding;
215
+ }
216
+ return new Response(finalData, { status: 200, statusText: "OK", headers });
293
217
  }
294
-
295
- if (!styleEntry || !styleEntry.style || !styleEntry.style.sources) {
296
- return new Response('Style not found for TileJSON', { status: 404 });
218
+ async function handleTile(db, downloadId, rest) {
219
+ const styleEntry = await findStyleByRegionId(db, downloadId);
220
+ const styleId = styleEntry?.key ?? downloadId;
221
+ if (rest.length < 4) {
222
+ return new Response("Invalid tile path", { status: 400 });
223
+ }
224
+ const yExt = rest[rest.length - 1];
225
+ const x = parseInt(rest[rest.length - 2], 10);
226
+ const z = Math.floor(parseFloat(rest[rest.length - 3]));
227
+ const sourceKey = rest.slice(0, rest.length - 3).join("/");
228
+ const parsed = parseTileYExt(yExt);
229
+ if (!parsed || Number.isNaN(x) || Number.isNaN(z)) {
230
+ return new Response("Invalid tile coordinates", { status: 400 });
231
+ }
232
+ const primary = makeTileKey(x, parsed.y, z, styleId, sourceKey, parsed.ext);
233
+ let resource = await idbGet(db, "tiles", primary);
234
+ if (resource?.data) return buildTileResponse(resource);
235
+ for (const ext of tileFallbackExtensions(parsed.ext)) {
236
+ const key = makeTileKey(x, parsed.y, z, styleId, sourceKey, ext);
237
+ resource = await idbGet(db, "tiles", key);
238
+ if (resource?.data) return buildTileResponse(resource);
239
+ }
240
+ return new Response("Tile not found", { status: 404 });
297
241
  }
298
-
299
- const sources = styleEntry.style.sources;
300
- let matchedSourceId;
301
- let matchedSourceConfig;
302
-
303
- if (decodedResourcePath in sources) {
304
- matchedSourceId = decodedResourcePath;
305
- matchedSourceConfig = sources[decodedResourcePath];
306
- } else {
307
- for (const [sourceId, sourceValue] of Object.entries(sources)) {
308
- const sourceUrl = typeof sourceValue.url === 'string' ? sourceValue.url : undefined;
309
- const originalUrl =
310
- typeof sourceValue.__originalTilesetUrl === 'string'
311
- ? sourceValue.__originalTilesetUrl
312
- : undefined;
313
- if (sourceUrl === decodedResourcePath || originalUrl === decodedResourcePath) {
314
- matchedSourceId = sourceId;
315
- matchedSourceConfig = sourceValue;
316
- break;
242
+ async function handleGlyph(db, downloadId, rest) {
243
+ const styleEntry = await findStyleByRegionId(db, downloadId);
244
+ const styleId = styleEntry?.key ?? downloadId;
245
+ const { fontstacks, rangePart } = parseGlyphPath(decodeURIComponent(rest.join("/")));
246
+ for (const fontstack of fontstacks) {
247
+ for (const key of glyphCandidateKeys(styleId, downloadId, fontstack, rangePart)) {
248
+ const resource = await idbGet(db, "glyphs", key);
249
+ if (resource?.data) {
250
+ return new Response(resource.data, {
251
+ status: 200,
252
+ headers: { "Content-Type": "application/x-protobuf" }
253
+ });
254
+ }
317
255
  }
318
256
  }
257
+ return new Response("Glyph not found", { status: 404 });
319
258
  }
320
-
321
- if (!matchedSourceId || !matchedSourceConfig) {
322
- return new Response('Source not found for TileJSON', { status: 404 });
259
+ async function handleSprite(db, downloadId, rest) {
260
+ const styleEntry = await findStyleByRegionId(db, downloadId);
261
+ const styleId = styleEntry?.key ?? downloadId;
262
+ const path = decodeURIComponent(rest.join("/"));
263
+ for (const key of spriteCandidateKeys(styleId, downloadId, path)) {
264
+ const resource = await idbGet(db, "sprites", key);
265
+ if (resource?.data) {
266
+ const headers = {};
267
+ if (resource.contentType) headers["Content-Type"] = resource.contentType;
268
+ return new Response(resource.data, { status: 200, headers });
269
+ }
270
+ }
271
+ return new Response("Sprite not found", { status: 404 });
323
272
  }
324
-
325
- // Build offline TileJSON with /__offline__/ tile URLs
326
- const extension = deriveTileExtension(matchedSourceConfig.tiles);
327
- const offlineTiles = [
328
- `${self.location.origin}${OFFLINE_PREFIX}${downloadId}/tile/${matchedSourceId}/{z}/{x}/{y}.${extension}`,
329
- ];
330
-
331
- const tileJson = {
332
- tilejson: typeof matchedSourceConfig.tilejson === 'string' ? matchedSourceConfig.tilejson : '2.2.0',
333
- name: matchedSourceConfig.name || matchedSourceId,
334
- tiles: offlineTiles,
335
- minzoom: typeof matchedSourceConfig.minzoom === 'number' ? matchedSourceConfig.minzoom : 0,
336
- maxzoom: typeof matchedSourceConfig.maxzoom === 'number' ? matchedSourceConfig.maxzoom : 22,
337
- };
338
-
339
- // Copy additional fields
340
- const fieldsToCopy = [
341
- 'bounds', 'center', 'vector_layers', 'scheme', 'attribution',
342
- 'encoding', 'format', 'grids', 'data', 'template', 'version',
343
- ];
344
- for (const field of fieldsToCopy) {
345
- if (field in matchedSourceConfig && matchedSourceConfig[field] !== undefined) {
346
- tileJson[field] = matchedSourceConfig[field];
273
+ async function handleModel(db, downloadId, rest) {
274
+ const styleEntry = await findStyleByRegionId(db, downloadId);
275
+ const styleId = styleEntry?.key ?? downloadId;
276
+ const path = decodeURIComponent(rest.join("/"));
277
+ for (const key of modelCandidateKeys(styleId, downloadId, path)) {
278
+ const resource = await idbGet(db, "models", key);
279
+ if (resource?.data) {
280
+ return new Response(resource.data, {
281
+ status: 200,
282
+ headers: { "Content-Type": resource.contentType || "model/gltf-binary" }
283
+ });
284
+ }
347
285
  }
286
+ return new Response("Model not found", { status: 404 });
348
287
  }
349
-
350
- return new Response(JSON.stringify(tileJson), {
351
- status: 200,
352
- headers: { 'Content-Type': 'application/json' },
353
- });
354
- }
355
-
356
- // -----------------------------------------------------------
357
- // Fetch event listener
358
- // -----------------------------------------------------------
359
-
360
- self.addEventListener('fetch', (event) => {
361
- const url = event.request.url;
362
- const idx = url.indexOf(OFFLINE_PREFIX);
363
- if (idx === -1) return; // Not an offline request, let it pass through
364
-
365
- event.respondWith(handleOfflineRequest(url, idx));
366
- });
367
-
368
- async function handleOfflineRequest(url, prefixIndex) {
369
- try {
370
- const path = url.substring(prefixIndex + OFFLINE_PREFIX.length);
371
- const parts = path.split('/');
372
- const [downloadId, type, ...rest] = parts;
373
-
374
- if (!downloadId || !type) {
375
- return new Response('Invalid offline URL', { status: 400 });
288
+ async function handleTileJSON(db, downloadId, rest) {
289
+ const path = decodeURIComponent(rest.join("/"));
290
+ let styleEntry = await idbGet(db, "styles", downloadId);
291
+ if (!styleEntry?.style?.sources) {
292
+ styleEntry = await findStyleByRegionId(db, downloadId);
376
293
  }
377
-
378
- const db = await openDatabase();
379
-
380
- switch (type) {
381
- case 'tile':
382
- return await handleTile(db, downloadId, rest);
383
- case 'glyph':
384
- return await handleGlyph(db, downloadId, rest);
385
- case 'sprite':
386
- return await handleSprite(db, downloadId, rest);
387
- case 'tilesjson':
388
- return await handleTileJSON(db, downloadId, rest);
389
- default:
390
- return new Response(`Unknown resource type: ${type}`, { status: 400 });
294
+ if (!styleEntry?.style?.sources) {
295
+ return new Response("Style not found for TileJSON", { status: 404 });
296
+ }
297
+ const match = matchTileJsonSource(styleEntry.style.sources, path);
298
+ if (!match) {
299
+ return new Response("Source not found for TileJSON", { status: 404 });
300
+ }
301
+ const ext = deriveTileExtensionFromTiles(match.config.tiles);
302
+ const tileJson = buildOfflineTileJson(
303
+ match.config,
304
+ downloadId,
305
+ match.sourceId,
306
+ ext,
307
+ "offline",
308
+ self.location.origin
309
+ );
310
+ return new Response(JSON.stringify(tileJson), {
311
+ status: 200,
312
+ headers: { "Content-Type": "application/json" }
313
+ });
314
+ }
315
+ self.addEventListener("fetch", (event) => {
316
+ const url = event.request.url;
317
+ const idx = url.indexOf(OFFLINE_PREFIX);
318
+ if (idx === -1) return;
319
+ event.respondWith(handleOfflineRequest(url, idx));
320
+ });
321
+ async function handleOfflineRequest(url, prefixIndex) {
322
+ try {
323
+ const path = url.substring(prefixIndex + OFFLINE_PREFIX.length);
324
+ const parts = path.split("/");
325
+ const [downloadId, type, ...rest] = parts;
326
+ if (!downloadId || !type) {
327
+ return new Response("Invalid offline URL", { status: 400 });
328
+ }
329
+ const db = await openDatabase();
330
+ switch (type) {
331
+ case "tile":
332
+ return await handleTile(db, downloadId, rest);
333
+ case "glyph":
334
+ return await handleGlyph(db, downloadId, rest);
335
+ case "sprite":
336
+ return await handleSprite(db, downloadId, rest);
337
+ case "model":
338
+ return await handleModel(db, downloadId, rest);
339
+ case "tilesjson":
340
+ return await handleTileJSON(db, downloadId, rest);
341
+ default:
342
+ return new Response(`Unknown resource type: ${type}`, { status: 400 });
343
+ }
344
+ } catch (err) {
345
+ console.error("[SW] Error handling offline request:", err);
346
+ return new Response("Service Worker error", { status: 500 });
391
347
  }
392
- } catch (err) {
393
- console.error('[SW] Error handling offline request:', err);
394
- return new Response('Service Worker error', { status: 500 });
395
348
  }
396
- }
349
+ })();