import { createHash } from "node:crypto"; import { createReadStream, createWriteStream } from "node:fs"; import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; import { createServer } from "node:http"; import { dirname, join } from "node:path"; import { Readable, Transform } from "node:stream"; import { pipeline } from "node:stream/promises"; const config = readConfig(); const liveCache = createCacheStore("live", config.cacheDir, true); const offlineSnapshot = config.offlineSnapshotDir ? createCacheStore("offline-snapshot", config.offlineSnapshotDir, false) : null; const inflightWrites = new Map(); await initialiseCacheStore(liveCache); if (offlineSnapshot) await initialiseCacheStore(offlineSnapshot); const server = createServer(async (request, response) => { applyCors(request, response); if (request.method === "OPTIONS") return response.writeHead(204).end(); try { const requestUrl = new URL(request.url || "/", `http://${request.headers.host || "127.0.0.1"}`); if (!isAllowedRequest(request)) return writeJson(response, 401, { ok: false, error: "map_gateway_auth_required" }); if (requestUrl.pathname === "/healthz" && request.method === "GET") { return writeJson(response, 200, { ok: true, service: "nodedc-map-gateway", cache: await cacheStats(liveCache), offlineSnapshot: offlineSnapshot ? await cacheStats(offlineSnapshot) : null, ionConfigured: Boolean(config.cesiumIonToken), assetAllowlist: [...config.assetAllowlist].map(Number).sort((left, right) => left - right), anonymousAccess: config.allowAnonymous, }); } const ionAssetMatch = requestUrl.pathname.match(/^\/api\/map\/ion\/assets\/(\d+)\/endpoint$/); if (ionAssetMatch && request.method === "GET") { return await serveIonEndpoint(response, ionAssetMatch[1]); } if (requestUrl.pathname === "/api/map/cache" && ["GET", "HEAD"].includes(request.method || "")) { return await serveCachedUpstream(request, response, requestUrl); } return writeJson(response, 404, { ok: false, error: "map_gateway_route_not_found" }); } catch (error) { // A client can cancel a streamed cache response after its headers have // already been sent. Do not try to turn that into JSON: Node would throw // ERR_HTTP_HEADERS_SENT and take the entire gateway process down. if (response.headersSent || response.writableEnded) { if (!response.writableEnded) response.destroy(); return; } const status = Number(error?.statusCode || 500); return writeJson(response, Number.isInteger(status) && status >= 400 && status < 600 ? status : 500, { ok: false, error: error instanceof Error ? error.message : "map_gateway_error", }); } }); server.listen(config.port, "0.0.0.0", () => { console.log(`NODE.DC Map Gateway listening on http://0.0.0.0:${config.port}`); console.log(`Live map cache: ${config.cacheDir} (${config.mode}, max ${Math.round(config.maxCacheBytes / 1024 / 1024)} MB)`); if (offlineSnapshot) console.log(`Offline map snapshot: ${config.offlineSnapshotDir} (read-only)`); }); process.on("SIGTERM", () => server.close()); process.on("SIGINT", () => server.close()); function readConfig() { const mode = String(process.env.MAP_CACHE_MODE || "readwrite").trim().toLowerCase(); if (!["readwrite", "readonly", "offline"].includes(mode)) throw new Error("invalid_map_cache_mode"); const allowlist = parseList(process.env.MAP_GATEWAY_UPSTREAM_ALLOWLIST || "api.cesium.com,assets.ion.cesium.com,tile.openstreetmap.org,dev.virtualearth.net,ecn.t0.tiles.virtualearth.net,ecn.t1.tiles.virtualearth.net,ecn.t2.tiles.virtualearth.net,ecn.t3.tiles.virtualearth.net"); return { port: parsePositiveInt(process.env.PORT, 18103), cacheDir: String(process.env.MAP_CACHE_DIR || "/var/lib/nodedc-map-cache").trim(), offlineSnapshotDir: String(process.env.MAP_OFFLINE_SNAPSHOT_DIR || "").trim(), mode, maxCacheBytes: parsePositiveInt(process.env.MAP_CACHE_MAX_MB, 20480) * 1024 * 1024, maxObjectBytes: parsePositiveInt(process.env.MAP_CACHE_MAX_OBJECT_MB, 128) * 1024 * 1024, defaultTtlMs: parsePositiveInt(process.env.MAP_CACHE_DEFAULT_TTL_SECONDS, 604800) * 1000, upstreamTimeoutMs: parsePositiveInt(process.env.MAP_GATEWAY_UPSTREAM_TIMEOUT_SECONDS, 30) * 1000, cesiumIonToken: String(process.env.CESIUM_ION_TOKEN || "").trim(), assetAllowlist: new Set(parseList(process.env.CESIUM_ION_ASSET_ALLOWLIST || "1,2,96188")), upstreamAllowlist: new Set(allowlist), legacyCacheHosts: new Set(parseList(process.env.MAP_GATEWAY_LEGACY_CACHE_HOSTS || "")), offlineProviderAllowlist: new Set(parseList(process.env.MAP_GATEWAY_OFFLINE_PROVIDER_ALLOWLIST || "")), corsOrigins: new Set(parseList(process.env.MAP_GATEWAY_CORS_ORIGIN || "http://127.0.0.1:3333,http://localhost:3333")), allowAnonymous: parseBoolean(process.env.MAP_GATEWAY_ALLOW_ANONYMOUS, process.env.NODE_ENV !== "production"), trustedSubjectHeader: String(process.env.MAP_GATEWAY_TRUSTED_SUBJECT_HEADER || "x-nodedc-user-id").toLowerCase(), }; } function parseList(value) { return String(value || "").split(",").map((part) => part.trim().toLowerCase()).filter(Boolean); } function parsePositiveInt(value, fallback) { const parsed = Number.parseInt(String(value || ""), 10); return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; } function parseBoolean(value, fallback) { if (value === undefined || value === "") return fallback; return ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase()); } function applyCors(request, response) { const origin = String(request.headers.origin || ""); if (!origin || (!config.corsOrigins.has("*") && !config.corsOrigins.has(origin))) return; response.setHeader("access-control-allow-origin", config.corsOrigins.has("*") ? "*" : origin); response.setHeader("access-control-allow-methods", "GET, HEAD, OPTIONS"); response.setHeader("access-control-allow-headers", "Range, Content-Type"); response.setHeader("vary", "Origin"); } function isAllowedRequest(request) { return config.allowAnonymous || Boolean(request.headers[config.trustedSubjectHeader]); } async function serveIonEndpoint(response, assetId) { if (!config.assetAllowlist.has(assetId)) return writeJson(response, 403, { ok: false, error: "cesium_asset_not_allowed" }); const cached = await readIonEndpointCache(assetId); if (config.mode === "offline") { if (!cached) return writeJson(response, 504, { ok: false, error: "cesium_ion_offline_endpoint_miss" }); if (!isOfflineProviderAllowed(cached.url)) return writeJson(response, 409, { ok: false, error: "map_provider_offline_not_permitted" }); return writeJson(response, 200, { ok: true, ...cached, cache: "offline-endpoint-hit" }); } if (!config.cesiumIonToken) { if (cached) return writeJson(response, 200, { ok: true, ...cached, cache: "cached-endpoint-no-master-token" }); return writeJson(response, 503, { ok: false, error: "cesium_ion_not_configured" }); } try { const endpoint = await fetchWithTimeout(`https://api.cesium.com/v1/assets/${assetId}/endpoint`, { headers: { authorization: `Bearer ${config.cesiumIonToken}` }, }); if (!endpoint.ok) throw gatewayError("cesium_ion_endpoint_unavailable", endpoint.status || 502); const body = await endpoint.json(); const next = body.type === "IMAGERY" && body.externalType === "BING" ? { assetId, type: body.type, externalType: "BING", options: { url: validateUpstream(String(body.options?.url || "")).toString(), key: String(body.options?.key || ""), mapStyle: String(body.options?.mapStyle || "Aerial"), }, attributions: Array.isArray(body.attributions) ? body.attributions : [], savedAt: Date.now(), } : { assetId, type: body.type, url: validateUpstream(String(body.url || "")).toString(), accessToken: String(body.accessToken || ""), attributions: Array.isArray(body.attributions) ? body.attributions : [], savedAt: Date.now(), }; if (!(next.externalType === "BING" ? next.options?.key : next.accessToken)) throw gatewayError("cesium_ion_endpoint_invalid", 502); if (config.mode === "readwrite") await writeIonEndpointCache(next); return writeJson(response, 200, { ok: true, ...next, cache: "ion-endpoint-online" }); } catch (error) { if (cached) return writeJson(response, 200, { ok: true, ...cached, cache: "stale-ion-endpoint" }); throw error; } } async function serveCachedUpstream(request, response, requestUrl) { const rawTarget = requestUrl.searchParams.get("url"); if (!rawTarget) return writeJson(response, 400, { ok: false, error: "map_cache_url_required" }); const target = validateUpstream(rawTarget); const cacheProfile = String(target.searchParams.get("nodedc_cache_profile") || "live").toLowerCase(); target.searchParams.delete("nodedc_cache_profile"); if (!new Set(["live", "offline"]).has(cacheProfile)) return writeJson(response, 400, { ok: false, error: "map_cache_profile_invalid" }); const store = cacheProfile === "offline" ? offlineSnapshot : liveCache; if (!store) return writeJson(response, 409, { ok: false, error: "map_offline_snapshot_not_configured" }); const cacheMode = String(target.searchParams.get("nodedc_cache_mode") || "hybrid").toLowerCase(); target.searchParams.delete("nodedc_cache_mode"); if (!new Set(["hybrid", "passthrough"]).has(cacheMode)) return writeJson(response, 400, { ok: false, error: "map_cache_mode_invalid" }); const isLegacyCacheHost = config.legacyCacheHosts.has(target.hostname.toLowerCase()); const forceRefresh = target.searchParams.get("nodedc_cache_refresh") === "1"; target.searchParams.delete("nodedc_cache_refresh"); const cacheKey = createHash("sha256").update(canonicalCacheUrl(target)).digest("hex"); // "Live" without cache is still routed through Gateway — it is never a // browser-side direct request — but it neither reads from nor writes to the // persistent store. The immutable offline profile intentionally has no such // bypass mode. if (cacheMode === "passthrough" && cacheProfile === "live") { return proxyUncachedRequest(request, response, target, "live-pass-through"); } const cached = await getCachedEntry(store, cacheKey); const fresh = cached && cached.expiresAt > Date.now(); if (cached && (!forceRefresh || !store.mutable) && (fresh || !store.mutable || config.mode !== "readwrite")) { return serveCachedFile(request, response, store, cached, fresh ? `${store.name}-hit` : `${store.name}-stale`); } // A migrated Engine cache is a local sandbox snapshot, not an instruction to // fetch arbitrary historic providers when an object is missing. The profile // is selected by the visual adapter; the browser can never turn it into a // new upstream fetch by changing a setting. if (cacheProfile === "offline") return writeJson(response, 504, { ok: false, error: "map_offline_snapshot_miss" }); if (isLegacyCacheHost) return writeJson(response, 504, { ok: false, error: "map_legacy_cache_miss" }); if (config.mode === "offline") { if (!isOfflineProviderAllowed(target.toString())) return writeJson(response, 409, { ok: false, error: "map_provider_offline_not_permitted" }); return writeJson(response, 504, { ok: false, error: "map_cache_offline_miss" }); } if (config.mode === "readonly") { return proxyUncachedRequest(request, response, target, "pass-through-readonly"); } if (String(request.headers.range || "").trim()) { return proxyRangeRequest(request, response, target, cached, store); } try { const entry = await fetchAndCache(target, cacheKey, store); return serveCachedFile(request, response, store, entry, forceRefresh ? "live-refresh-record" : "live-record"); } catch (error) { if (cached) return serveCachedFile(request, response, store, cached, "live-stale-upstream-error"); throw error; } } function isOfflineProviderAllowed(rawTarget) { if (!config.offlineProviderAllowlist.size) return false; try { const target = new URL(rawTarget); return config.offlineProviderAllowlist.has(target.hostname.toLowerCase()); } catch { return false; } } function validateUpstream(rawTarget) { if (rawTarget.length > 8192) throw gatewayError("map_cache_url_too_long", 400); let target; try { target = new URL(rawTarget); } catch { throw gatewayError("invalid_map_cache_url", 400); } const hostname = target.hostname.toLowerCase(); // Bing's metadata endpoint still returns HTTP tile templates. They are not // allowed through as HTTP: upgrade only the known Bing tile hosts before // the standard HTTPS and allowlist checks. This keeps the browser and the // Gateway on encrypted transport while making the provider contract work. if (target.protocol === "http:" && /^(ecn\.t[0-3]\.tiles\.virtualearth\.net|ecn\.tiles\.virtualearth\.net)$/.test(hostname)) { target.protocol = "https:"; } if (target.protocol !== "https:") throw gatewayError("map_cache_https_required", 400); if (target.username || target.password || (!config.upstreamAllowlist.has(hostname) && !config.legacyCacheHosts.has(hostname))) { throw gatewayError("map_cache_upstream_not_allowed", 403); } return target; } function canonicalCacheUrl(target) { const url = new URL(target.toString()); // The old Engine snapshot distributes identical Bing tile semantics across // t0…t3. A viewer can choose a different subdomain for the same quadkey, so // use one logical host for the cache key while preserving the original URL // only as provenance in the imported index entry. if (/^ecn\.t[0-3]\.tiles\.virtualearth\.net$/i.test(url.hostname)) { url.hostname = "ecn.tiles.virtualearth.net"; } for (const key of [...url.searchParams.keys()]) { if (["access_token", "accesstoken", "iontoken", "token", "key", "apikey", "api_key", "signature", "sig"].includes(key.toLowerCase())) { url.searchParams.delete(key); } } url.searchParams.sort(); return url.toString(); } async function fetchAndCache(target, cacheKey, store) { const inflightKey = `${store.name}:${cacheKey}`; const existing = inflightWrites.get(inflightKey); if (existing) return existing; const task = downloadAndCache(target, cacheKey, store); inflightWrites.set(inflightKey, task); // Several Cesium requests can share one in-flight download. Keep a rejection // observer on the shared promise, then rethrow to each HTTP caller below. // A 4xx/5xx tile response must never become an unhandled rejection that stops // the gateway process. task.catch(() => undefined); try { return await task; } finally { if (inflightWrites.get(inflightKey) === task) inflightWrites.delete(inflightKey); } } async function downloadAndCache(target, cacheKey, store) { const upstream = await fetchWithTimeout(target, { headers: { accept: "application/json, application/octet-stream, image/*, */*;q=0.5" } }); if (!upstream.ok || !upstream.body) throw gatewayError("map_upstream_unavailable", upstream.status || 502); const expectedBytes = Number.parseInt(upstream.headers.get("content-length") || "", 10); if (Number.isFinite(expectedBytes) && expectedBytes > config.maxObjectBytes) throw gatewayError("map_cache_object_too_large", 413); const relativePath = join("objects", cacheKey.slice(0, 2), `${cacheKey}.bin`); const filePath = join(store.dir, relativePath); const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; await mkdir(dirname(filePath), { recursive: true }); let bytes = 0; const limit = new Transform({ transform(chunk, _encoding, callback) { bytes += chunk.length; if (bytes > config.maxObjectBytes) return callback(gatewayError("map_cache_object_too_large", 413)); callback(null, chunk); }, }); try { await pipeline(Readable.fromWeb(upstream.body), limit, createWriteStream(tempPath, { flags: "wx" })); await rename(tempPath, filePath); } catch (error) { await rm(tempPath, { force: true }); throw error; } const now = Date.now(); const entry = { key: cacheKey, file: relativePath, bytes, contentType: upstream.headers.get("content-type") || "application/octet-stream", etag: upstream.headers.get("etag") || null, savedAt: now, lastAccessAt: now, expiresAt: now + responseTtl(upstream.headers.get("cache-control")), }; store.index.entries[cacheKey] = entry; await evictCache(store); await writeCacheIndex(store); return entry; } function responseTtl(cacheControl) { const match = String(cacheControl || "").match(/max-age=(\d+)/i); if (!match) return config.defaultTtlMs; return Math.min(Number.parseInt(match[1], 10) * 1000, config.defaultTtlMs); } async function proxyRangeRequest(request, response, target, cached, store) { const upstream = await fetchWithTimeout(target, { headers: { range: String(request.headers.range), accept: "*/*" } }); if (!upstream.ok || !upstream.body) { if (cached) return serveCachedFile(request, response, store, cached, "stale-range-error"); throw gatewayError("map_upstream_range_unavailable", upstream.status || 502); } copyUpstreamHeaders(response, upstream.headers); response.setHeader("x-nodedc-map-cache", "range-pass-through"); response.writeHead(upstream.status); await pipeline(Readable.fromWeb(upstream.body), response); } async function proxyUncachedRequest(request, response, target, cacheState) { const upstream = await fetchWithTimeout(target, { headers: { accept: String(request.headers.accept || "application/json, application/octet-stream, image/*, */*;q=0.5"), ...(request.headers.range ? { range: String(request.headers.range) } : {}), }, }); if (!upstream.ok || !upstream.body) throw gatewayError("map_upstream_unavailable", upstream.status || 502); copyUpstreamHeaders(response, upstream.headers); response.setHeader("x-nodedc-map-cache", cacheState); response.writeHead(upstream.status); if (request.method === "HEAD") return response.end(); await pipeline(Readable.fromWeb(upstream.body), response); } async function serveCachedFile(request, response, store, entry, state) { const filePath = join(store.dir, entry.file); const info = await stat(filePath); const range = parseRange(request.headers.range, info.size); response.setHeader("content-type", entry.contentType); response.setHeader("accept-ranges", "bytes"); response.setHeader("cache-control", "public, max-age=60"); response.setHeader("x-nodedc-map-cache", state); response.setHeader("x-nodedc-map-cache-age", String(Math.max(0, Math.round((Date.now() - entry.savedAt) / 1000)))); if (store.mutable) { entry.lastAccessAt = Date.now(); store.index.entries[entry.key] = entry; void writeCacheIndex(store); } if (!range) { response.setHeader("content-length", info.size); response.writeHead(200); if (request.method === "HEAD") return response.end(); return pipeline(createReadStream(filePath), response); } response.setHeader("content-length", range.end - range.start + 1); response.setHeader("content-range", `bytes ${range.start}-${range.end}/${info.size}`); response.writeHead(206); if (request.method === "HEAD") return response.end(); return pipeline(createReadStream(filePath, range), response); } function parseRange(header, size) { if (!header) return null; const match = String(header).match(/^bytes=(\d*)-(\d*)$/); if (!match) return null; const start = match[1] ? Number.parseInt(match[1], 10) : 0; const end = match[2] ? Number.parseInt(match[2], 10) : size - 1; if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end >= size) return null; return { start, end }; } async function getCachedEntry(store, cacheKey) { const entry = store.index.entries[cacheKey]; if (!entry) return null; try { const info = await stat(join(store.dir, entry.file)); if (!info.isFile()) throw new Error("not_a_file"); return entry; } catch { if (store.mutable) { delete store.index.entries[cacheKey]; await writeCacheIndex(store); } return null; } } function createCacheStore(name, dir, mutable) { return { name, dir, mutable, objectsDir: join(dir, "objects"), ionEndpointsDir: join(dir, "ion-endpoints"), indexPath: join(dir, "index.json"), index: { version: 1, entries: {} }, pendingIndexWrite: Promise.resolve(), }; } async function initialiseCacheStore(store) { if (store.mutable) { await mkdir(store.objectsDir, { recursive: true }); await mkdir(store.ionEndpointsDir, { recursive: true }); } store.index = await readCacheIndex(store); } async function readCacheIndex(store) { try { const parsed = JSON.parse(await readFile(store.indexPath, "utf8")); if (parsed?.version === 1 && parsed.entries && typeof parsed.entries === "object") return parsed; } catch (error) { if (error?.code !== "ENOENT") console.warn("Map cache index ignored: invalid file"); } return { version: 1, entries: {} }; } function ionEndpointPath(assetId) { return join(liveCache.ionEndpointsDir, `${assetId}.json`); } async function readIonEndpointCache(assetId) { try { const value = JSON.parse(await readFile(ionEndpointPath(assetId), "utf8")); if (value?.assetId === assetId && ((value?.url && value?.accessToken) || (value?.externalType === "BING" && value?.options?.url && value?.options?.key))) return value; } catch (error) { if (error?.code !== "ENOENT") console.warn("Ion endpoint cache ignored: invalid file"); } return null; } async function writeIonEndpointCache(endpoint) { const target = ionEndpointPath(endpoint.assetId); const temp = `${target}.${process.pid}.tmp`; await writeFile(temp, `${JSON.stringify(endpoint)}\n`, { encoding: "utf8", mode: 0o600 }); await rename(temp, target); } function writeCacheIndex(store) { if (!store.mutable) return Promise.resolve(); store.pendingIndexWrite = store.pendingIndexWrite.then(async () => { const tempPath = `${store.indexPath}.${process.pid}.tmp`; await writeFile(tempPath, `${JSON.stringify(store.index)}\n`, "utf8"); await rename(tempPath, store.indexPath); }).catch((error) => console.warn("Map cache index write failed", error instanceof Error ? error.message : "unknown")); return store.pendingIndexWrite; } async function evictCache(store) { let entries = Object.values(store.index.entries); let size = entries.reduce((sum, entry) => sum + Number(entry.bytes || 0), 0); if (size <= config.maxCacheBytes) return; entries = entries.sort((left, right) => Number(left.lastAccessAt || 0) - Number(right.lastAccessAt || 0)); for (const entry of entries) { if (size <= config.maxCacheBytes) break; await rm(join(store.dir, entry.file), { force: true }); delete store.index.entries[entry.key]; size -= Number(entry.bytes || 0); } } async function cacheStats(store) { const entries = Object.values(store.index.entries); const bytes = entries.reduce((sum, entry) => sum + Number(entry.bytes || 0), 0); return { name: store.name, mode: store.mutable ? config.mode : "readonly", entries: entries.length, bytes, maxBytes: store.mutable ? config.maxCacheBytes : null, persistent: true }; } async function fetchWithTimeout(target, options = {}) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), config.upstreamTimeoutMs); try { return await fetch(target, { ...options, signal: controller.signal, redirect: "follow" }); } catch (error) { throw gatewayError(error?.name === "AbortError" ? "map_upstream_timeout" : "map_upstream_fetch_failed", 502); } finally { clearTimeout(timeout); } } function copyUpstreamHeaders(response, headers) { for (const name of ["content-type", "content-length", "content-range", "accept-ranges", "etag", "last-modified"]) { const value = headers.get(name); if (value) response.setHeader(name, value); } } function writeJson(response, status, value) { response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); response.end(JSON.stringify(value)); } function gatewayError(message, statusCode) { const error = new Error(message); error.statusCode = statusCode; return error; }