/** @typedef {import('./types').MapOptions} MapOptions */ /** @typedef {import('./types').MsgMapReady} MsgMapReady */ /** @typedef {import('./types').MsgReqMap} MsgReqMap */ const MSG_REQ_MAP = "request-map"; const MSG_MAP_READY = "map-ready"; const MSG_REGISTER = "register-map-renderer"; const CACHE_NAME = "map-images"; const DEFAULT_STYLE = "mapbox://styles/mapbox/light-v9"; const basePathRe = /^\/static-maps/; // Generated by `./lib/generate-regexp.js' const pathRegExp = /^(?:\/(-?\d+(?:\.\d+)?))(?:,(-?\d+(?:\.\d+)?))(?:,(\d+(?:\.\d+)?))(?:,(-?\d+(?:\.\d+)?))?(?:,(-?\d+(?:\.\d+)?))?(?:\/(\d+))(?:x(\d+))(?:@(\d+)x)?[\/#\?]?$/i; // @ts-ignore // This is to work around TS not correctly typing self for Service Workers init(self); /** @param {ServiceWorkerGlobalScope} self */ function init(self) { let id = 0; /** @type {Map void>} */ const pendingRequests = new Map(); /** @type {Set} Set of registered client ids */ const registeredClients = new Set(); self.addEventListener("install", function (event) { // The promise that skipWaiting() returns can be safely ignored. self.skipWaiting(); }); self.addEventListener("activate", (event) => { event.waitUntil(self.clients.claim()); }); self.addEventListener("message", ({ data: message }) => { if (!message || message.type !== MSG_MAP_READY) return; const { requestId } = /** @type {MsgMapReady} */ (message); // Static map should be ready in the cache by now const resolve = pendingRequests.get(requestId); pendingRequests.delete(requestId); if (resolve) resolve(); }); // Any clients that can process map requests register with this message self.addEventListener("message", async (event) => { // Only handle messages of type `MSG_REGISTER` if (!event.data || event.data.type !== MSG_REGISTER) return; // Only register clients of type "window" if (!(event.source instanceof Client) || event.source.type !== "window") return; console.log("Registering client", event.source.id); registeredClients.add(event.source.id); // Clean up registered clients which are no longer available const clients = await self.clients.matchAll({ type: "window" }); for (const clientId of registeredClients) { if (!clients.find((client) => client.id === clientId)) { registeredClients.delete(clientId); } } console.log("Num clients regsitered:", registeredClients.size); }); self.addEventListener("fetch", (event) => { const url = new URL(event.request.url); if (location.origin !== url.origin || !basePathRe.test(url.pathname)) return; console.log("fetch", event.request.url); const path = url.pathname.replace(basePathRe, ""); try { var mapOptions = parseMapOptions(path, url.searchParams); } catch (e) { console.log("failed to parse mapOptions"); return event.respondWith( new Response(null, { status: 400, statusText: e.message }) ); } const cacheKey = path + "?" + url.searchParams.toString(); event.respondWith(mapResponse({ mapOptions, cacheKey })); }); /** * Return an active client that is able to process the map request, or void if * no clients are available * @returns {Promise} */ async function getRegisteredClient() { const clients = await self.clients.matchAll({ type: "window" }); // Get the first connected client that has registered const client = clients.find((client) => registeredClients.has(client.id)); return client; } const responseInitOk = { status: 200, headers: { "Content-Type": "image/png", }, }; /** * @param {{mapOptions: MapOptions, cacheKey: string}} options * @returns {Promise} */ async function mapResponse({ mapOptions, cacheKey }) { const cache = await self.caches.open(CACHE_NAME); const cachedResponse = await cache.match(cacheKey); if (cachedResponse) { return new Response(cachedResponse.body, responseInitOk); } const client = await getRegisteredClient(); if (!client) { return new Response(null, { status: 500, statusText: "No client available", }); } console.log(client); const requestId = id++; /** @type {MsgReqMap} */ const message = { requestId, cacheKey, mapOptions, type: MSG_REQ_MAP, }; client.postMessage(message); return new Promise((resolve) => { pendingRequests.set(requestId, async () => { console.log("checking for cache", cacheKey); const cachedResponse = await cache.match(cacheKey); if (cachedResponse) { resolve(new Response(cachedResponse.body, responseInitOk)); } else { resolve(new Response(null, { status: 404 })); } }); }); } } /** * Parse the pathname and search params from the request URL and return a map * configuration object * @param {string} pathname * @param {URLSearchParams} searchParams * @returns {MapOptions} */ function parseMapOptions(pathname, searchParams) { const match = pathRegExp.exec(pathname); if (!match) throw new Error("Invalid Request"); const [ , lon, lat, zoom, bearing, // optional pitch, // optional width, height, pixelRatio, // optional ] = match.map(Number); const { isNaN } = Number; if (lon < -180 || lon > 180) { throw new Error("Longitude must be between -180 and 180"); } if (lat < -90 || lat > 90) { throw new Error("Latitude must be between -90 and 90"); } if (width > 2000 || height > 2000) { throw new Error("Max width and height is 2000"); } return { center: [lon, lat], zoom, bearing: isNaN(bearing) ? 0 : bearing, pitch: isNaN(pitch) ? 0 : pitch, pixelRatio: pixelRatio !== undefined && !isNaN(pixelRatio) ? pixelRatio : 1, style: searchParams.get("style") || DEFAULT_STYLE, accessToken: searchParams.get("accessToken") || undefined, width, height, }; }