/// /// /// /// import { openDB } from "idb" declare const self: ServiceWorkerGlobalScope // ─────────────────────────────────────────────────────── // CONFIG // ─────────────────────────────────────────────────────── const CACHE_NAME = "map-h3-v2" const DB_NAME = "matatu-gps-db" const STORE_OUTBOX = "gps-outbox" const STATIC_ASSETS = [ "/maps/bootstrap.parquet" ] // ─────────────────────────────────────────────────────── // INSTALL / ACTIVATE // ─────────────────────────────────────────────────────── self.addEventListener("install", (event) => { event.waitUntil( caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)) ) self.skipWaiting() }) self.addEventListener("activate", (event) => { event.waitUntil( caches.keys().then((keys) => Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))) ) ) self.clients.claim() }) // ─────────────────────────────────────────────────────── // FETCH (H3-native) // ─────────────────────────────────────────────────────── self.addEventListener("fetch", (event) => { const req = event.request if (req.method !== "GET") return const url = new URL(req.url) // 1. MAP (H3-based) if (url.pathname.includes("/map") && hasHexParams(url)) { const normalized = normalizeHexRequest(req, "map") event.respondWith(cacheFirst(normalized)) return } // 2. BUILDINGS (H3-based) if (url.pathname.includes("/buildings") && hasHexParams(url)) { const normalized = normalizeHexRequest(req, "buildings") event.respondWith(cacheFirst(normalized)) return } // 3. Ignore streams if (url.pathname.includes("/api/gps/stream")) return // 4. Let app handle everything else }) // ─────────────────────────────────────────────────────── // MESSAGE HANDLERS // ─────────────────────────────────────────────────────── self.addEventListener("message", (event) => { const data = event.data if (!data) return switch (data.type) { case "PREFETCH_HEXES": event.waitUntil(prefetchHexes(data.mapHexes || [], data.buildingHexes || [])) break case "CACHE_CITY": event.waitUntil(cacheCity(data.mapHexes || [], data.buildingHexes || [])) break } }) // ─────────────────────────────────────────────────────── // PREFETCH (UNIFIED H3) // ─────────────────────────────────────────────────────── async function prefetchHexes( mapHexes: string[], buildingHexes: string[] ): Promise { const cache = await caches.open(CACHE_NAME) const requests = [ ...chunkHexes(mapHexes).map(h => `/map?hexes=${h.join(",")}`), ...chunkHexes(buildingHexes).map(h => `/buildings?hexes=${h.join(",")}`) ] await Promise.all( requests.map(async (url) => { const req = normalizeHexRequest(new Request(url), url.includes("/map") ? "map" : "buildings") if (!(await cache.match(req))) { try { const res = await fetch(req) if (res.ok) await cache.put(req, res.clone()) } catch {} } }) ) notifyClients({ type: "PREFETCH_COMPLETE" }) } // ─────────────────────────────────────────────────────── // OFFLINE CACHE (H3-native) // ─────────────────────────────────────────────────────── async function cacheCity( mapHexes: string[], buildingHexes: string[] ): Promise { const total = mapHexes.length + buildingHexes.length let completed = 0 // map data for (const chunk of chunkHexes(mapHexes)) { await fetchAndCache(`/map?hexes=${chunk.join(",")}`) completed += chunk.length notifyClients({ type: "CACHE_PROGRESS", progress: completed / total, phase: "map" }) } // buildings for (const chunk of chunkHexes(buildingHexes)) { await fetchAndCache(`/buildings?hexes=${chunk.join(",")}`) completed += chunk.length notifyClients({ type: "CACHE_PROGRESS", progress: completed / total, phase: "buildings" }) } notifyClients({ type: "CITY_CACHED" }) } // ─────────────────────────────────────────────────────── // BACKGROUND SYNC // ─────────────────────────────────────────────────────── self.addEventListener("sync", (event: SyncEvent) => { if (event.tag === "sync-gps-data") { event.waitUntil(flushGpsOutbox()) } }) async function flushGpsOutbox(): Promise { const db = await openDB(DB_NAME, 1) const items = await db.getAll(STORE_OUTBOX) for (const item of items) { try { const res = await fetch("/api/map/gps-update", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(item), }) if (res.ok) { await db.delete(STORE_OUTBOX, item.id) } else { throw new Error() } } catch (err) { console.warn("[sw] Sync retry failed:", err) return } } notifyClients({ type: "SYNC_COMPLETE" }) } // ─────────────────────────────────────────────────────── // CACHE HELPERS // ─────────────────────────────────────────────────────── async function cacheFirst(req: Request): Promise { const cache = await caches.open(CACHE_NAME) const cached = await cache.match(req) if (cached) return cached const res = await fetch(req) if (res.ok) { cache.put(req, res.clone()) notifyClients({ type: "CACHE_UPDATED", url: req.url }) } return res } async function fetchAndCache(url: string) { const cache = await caches.open(CACHE_NAME) const req = normalizeHexRequest(new Request(url), url.includes("/map") ? "map" : "buildings") if (!(await cache.match(req))) { try { const res = await fetch(req) if (res.ok) await cache.put(req, res.clone()) } catch {} } } // ─────────────────────────────────────────────────────── // NORMALIZATION (CRITICAL) // ─────────────────────────────────────────────────────── function normalizeHexRequest(req: Request, type: "map" | "buildings"): Request { const url = new URL(req.url) let hexes: string[] = [] if (url.searchParams.has("hex")) { hexes = [url.searchParams.get("hex")!] } if (url.searchParams.has("hexes")) { hexes = url.searchParams.get("hexes")!.split(",") } // 🔥 canonical form hexes = hexes.map(h => h.trim()).filter(Boolean).sort() const normalized = `/${type}?hexes=${hexes.join(",")}` return new Request(normalized) } function hasHexParams(url: URL): boolean { return url.searchParams.has("hex") || url.searchParams.has("hexes") } // chunking prevents huge requests function chunkHexes(hexes: string[], size = 30): string[][] { const chunks: string[][] = [] for (let i = 0; i < hexes.length; i += size) { chunks.push(hexes.slice(i, i + size)) } return chunks } // ─────────────────────────────────────────────────────── // CLIENT MESSAGING // ─────────────────────────────────────────────────────── async function notifyClients(message: Record) { const clients = await self.clients.matchAll({ includeUncontrolled: true }) for (const client of clients) { client.postMessage(message) } }