1. /// <reference types="@sveltejs/kit" />
  2. /// <reference no-default-lib="true"/>
  3. /// <reference lib="esnext" />
  4. /// <reference lib="webworker" />
  5.  
  6. import { openDB } from "idb"
  7.  
  8. declare const self: ServiceWorkerGlobalScope
  9.  
  10. // ───────────────────────────────────────────────────────
  11. // CONFIG
  12. // ───────────────────────────────────────────────────────
  13.  
  14. const CACHE_NAME = "map-h3-v2"
  15. const DB_NAME = "matatu-gps-db"
  16. const STORE_OUTBOX = "gps-outbox"
  17.  
  18. const STATIC_ASSETS = [
  19. "/maps/bootstrap.parquet"
  20. ]
  21.  
  22. // ───────────────────────────────────────────────────────
  23. // INSTALL / ACTIVATE
  24. // ───────────────────────────────────────────────────────
  25.  
  26. self.addEventListener("install", (event) => {
  27. event.waitUntil(
  28. caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
  29. )
  30. self.skipWaiting()
  31. })
  32.  
  33. self.addEventListener("activate", (event) => {
  34. event.waitUntil(
  35. caches.keys().then((keys) =>
  36. Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
  37. )
  38. )
  39. self.clients.claim()
  40. })
  41.  
  42. // ───────────────────────────────────────────────────────
  43. // FETCH (H3-native)
  44. // ───────────────────────────────────────────────────────
  45.  
  46. self.addEventListener("fetch", (event) => {
  47. const req = event.request
  48. if (req.method !== "GET") return
  49.  
  50. const url = new URL(req.url)
  51.  
  52. // 1. MAP (H3-based)
  53. if (url.pathname.includes("/map") && hasHexParams(url)) {
  54. const normalized = normalizeHexRequest(req, "map")
  55. event.respondWith(cacheFirst(normalized))
  56. return
  57. }
  58.  
  59. // 2. BUILDINGS (H3-based)
  60. if (url.pathname.includes("/buildings") && hasHexParams(url)) {
  61. const normalized = normalizeHexRequest(req, "buildings")
  62. event.respondWith(cacheFirst(normalized))
  63. return
  64. }
  65.  
  66. // 3. Ignore streams
  67. if (url.pathname.includes("/api/gps/stream")) return
  68.  
  69. // 4. Let app handle everything else
  70. })
  71.  
  72. // ───────────────────────────────────────────────────────
  73. // MESSAGE HANDLERS
  74. // ───────────────────────────────────────────────────────
  75.  
  76. self.addEventListener("message", (event) => {
  77. const data = event.data
  78. if (!data) return
  79.  
  80. switch (data.type) {
  81. case "PREFETCH_HEXES":
  82. event.waitUntil(prefetchHexes(data.mapHexes || [], data.buildingHexes || []))
  83. break
  84.  
  85. case "CACHE_CITY":
  86. event.waitUntil(cacheCity(data.mapHexes || [], data.buildingHexes || []))
  87. break
  88. }
  89. })
  90.  
  91. // ───────────────────────────────────────────────────────
  92. // PREFETCH (UNIFIED H3)
  93. // ───────────────────────────────────────────────────────
  94.  
  95. async function prefetchHexes(
  96. mapHexes: string[],
  97. buildingHexes: string[]
  98. ): Promise<void> {
  99. const cache = await caches.open(CACHE_NAME)
  100.  
  101. const requests = [
  102. ...chunkHexes(mapHexes).map(h => `/map?hexes=${h.join(",")}`),
  103. ...chunkHexes(buildingHexes).map(h => `/buildings?hexes=${h.join(",")}`)
  104. ]
  105.  
  106. await Promise.all(
  107. requests.map(async (url) => {
  108. const req = normalizeHexRequest(new Request(url), url.includes("/map") ? "map" : "buildings")
  109.  
  110. if (!(await cache.match(req))) {
  111. try {
  112. const res = await fetch(req)
  113. if (res.ok) await cache.put(req, res.clone())
  114. } catch {}
  115. }
  116. })
  117. )
  118.  
  119. notifyClients({ type: "PREFETCH_COMPLETE" })
  120. }
  121.  
  122. // ───────────────────────────────────────────────────────
  123. // OFFLINE CACHE (H3-native)
  124. // ───────────────────────────────────────────────────────
  125.  
  126. async function cacheCity(
  127. mapHexes: string[],
  128. buildingHexes: string[]
  129. ): Promise<void> {
  130.  
  131. const total = mapHexes.length + buildingHexes.length
  132. let completed = 0
  133.  
  134. // map data
  135. for (const chunk of chunkHexes(mapHexes)) {
  136. await fetchAndCache(`/map?hexes=${chunk.join(",")}`)
  137. completed += chunk.length
  138.  
  139. notifyClients({
  140. type: "CACHE_PROGRESS",
  141. progress: completed / total,
  142. phase: "map"
  143. })
  144. }
  145.  
  146. // buildings
  147. for (const chunk of chunkHexes(buildingHexes)) {
  148. await fetchAndCache(`/buildings?hexes=${chunk.join(",")}`)
  149. completed += chunk.length
  150.  
  151. notifyClients({
  152. type: "CACHE_PROGRESS",
  153. progress: completed / total,
  154. phase: "buildings"
  155. })
  156. }
  157.  
  158. notifyClients({ type: "CITY_CACHED" })
  159. }
  160.  
  161. // ───────────────────────────────────────────────────────
  162. // BACKGROUND SYNC
  163. // ───────────────────────────────────────────────────────
  164.  
  165. self.addEventListener("sync", (event: SyncEvent) => {
  166. if (event.tag === "sync-gps-data") {
  167. event.waitUntil(flushGpsOutbox())
  168. }
  169. })
  170.  
  171. async function flushGpsOutbox(): Promise<void> {
  172. const db = await openDB(DB_NAME, 1)
  173. const items = await db.getAll(STORE_OUTBOX)
  174.  
  175. for (const item of items) {
  176. try {
  177. const res = await fetch("/api/map/gps-update", {
  178. method: "POST",
  179. headers: { "Content-Type": "application/json" },
  180. body: JSON.stringify(item),
  181. })
  182.  
  183. if (res.ok) {
  184. await db.delete(STORE_OUTBOX, item.id)
  185. } else {
  186. throw new Error()
  187. }
  188. } catch (err) {
  189. console.warn("[sw] Sync retry failed:", err)
  190. return
  191. }
  192. }
  193.  
  194. notifyClients({ type: "SYNC_COMPLETE" })
  195. }
  196.  
  197. // ───────────────────────────────────────────────────────
  198. // CACHE HELPERS
  199. // ───────────────────────────────────────────────────────
  200.  
  201. async function cacheFirst(req: Request): Promise<Response> {
  202. const cache = await caches.open(CACHE_NAME)
  203. const cached = await cache.match(req)
  204.  
  205. if (cached) return cached
  206.  
  207. const res = await fetch(req)
  208.  
  209. if (res.ok) {
  210. cache.put(req, res.clone())
  211. notifyClients({ type: "CACHE_UPDATED", url: req.url })
  212. }
  213.  
  214. return res
  215. }
  216.  
  217. async function fetchAndCache(url: string) {
  218. const cache = await caches.open(CACHE_NAME)
  219. const req = normalizeHexRequest(new Request(url), url.includes("/map") ? "map" : "buildings")
  220.  
  221. if (!(await cache.match(req))) {
  222. try {
  223. const res = await fetch(req)
  224. if (res.ok) await cache.put(req, res.clone())
  225. } catch {}
  226. }
  227. }
  228.  
  229. // ───────────────────────────────────────────────────────
  230. // NORMALIZATION (CRITICAL)
  231. // ───────────────────────────────────────────────────────
  232.  
  233. function normalizeHexRequest(req: Request, type: "map" | "buildings"): Request {
  234. const url = new URL(req.url)
  235.  
  236. let hexes: string[] = []
  237.  
  238. if (url.searchParams.has("hex")) {
  239. hexes = [url.searchParams.get("hex")!]
  240. }
  241.  
  242. if (url.searchParams.has("hexes")) {
  243. hexes = url.searchParams.get("hexes")!.split(",")
  244. }
  245.  
  246. // 🔥 canonical form
  247. hexes = hexes.map(h => h.trim()).filter(Boolean).sort()
  248.  
  249. const normalized = `/${type}?hexes=${hexes.join(",")}`
  250.  
  251. return new Request(normalized)
  252. }
  253.  
  254. function hasHexParams(url: URL): boolean {
  255. return url.searchParams.has("hex") || url.searchParams.has("hexes")
  256. }
  257.  
  258. // chunking prevents huge requests
  259. function chunkHexes(hexes: string[], size = 30): string[][] {
  260. const chunks: string[][] = []
  261. for (let i = 0; i < hexes.length; i += size) {
  262. chunks.push(hexes.slice(i, i + size))
  263. }
  264. return chunks
  265. }
  266.  
  267. // ───────────────────────────────────────────────────────
  268. // CLIENT MESSAGING
  269. // ───────────────────────────────────────────────────────
  270.  
  271. async function notifyClients(message: Record<string, unknown>) {
  272. const clients = await self.clients.matchAll({ includeUncontrolled: true })
  273. for (const client of clients) {
  274. client.postMessage(message)
  275. }
  276. }