534 lines
24 KiB
JavaScript
534 lines
24 KiB
JavaScript
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;
|
|
}
|