1447 lines
63 KiB
JavaScript
1447 lines
63 KiB
JavaScript
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
||
import { createReadStream, createWriteStream } from "node:fs";
|
||
import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
||
import { createServer } from "node:http";
|
||
import { dirname, join, resolve } from "node:path";
|
||
import { Readable, Transform } from "node:stream";
|
||
import { pipeline } from "node:stream/promises";
|
||
import { fileURLToPath } from "node:url";
|
||
|
||
import { loadZoneSourceProfile } from "./zone-source-snapshot.mjs";
|
||
|
||
const canonicalCesiumAssetIds = ["1", "2", "96188"];
|
||
const cesiumVerificationTransportVersion = 4;
|
||
// The neighbouring VPN egress only serves official Cesium/Bing traffic for
|
||
// Map Gateway. Other upstreams retain their existing direct policy.
|
||
const canonicalCesiumEgressHosts = new Set([
|
||
"api.cesium.com",
|
||
"assets.ion.cesium.com",
|
||
"dev.virtualearth.net",
|
||
"ecn.t0.tiles.virtualearth.net",
|
||
"ecn.t1.tiles.virtualearth.net",
|
||
"ecn.t2.tiles.virtualearth.net",
|
||
"ecn.t3.tiles.virtualearth.net",
|
||
]);
|
||
const config = await readConfig();
|
||
const zoneSourceProfileId = String(process.env.ZONE_SOURCE_PROFILE_ID || "moscow-pmd-slow-zones").trim();
|
||
const zoneSourceRoot = resolve(String(
|
||
process.env.ZONE_SOURCE_SNAPSHOT_ROOT
|
||
|| resolve(dirname(fileURLToPath(import.meta.url)), "../zone-sources"),
|
||
).trim());
|
||
// The route contract is stable while the provider behind this profile is
|
||
// replaceable: today it is the audited MMap snapshot, later an API adapter can
|
||
// atomically materialise the same manifest + generation envelope.
|
||
const zoneSource = await loadZoneSourceProfile(zoneSourceRoot, zoneSourceProfileId);
|
||
const liveCache = createCacheStore("live", config.cacheDir, true);
|
||
const offlineSnapshot = config.offlineSnapshotDir ? createCacheStore("offline-snapshot", config.offlineSnapshotDir, false) : null;
|
||
const inflightWrites = new Map();
|
||
const gatewayDiagnostics = createGatewayDiagnostics(config.slowUpstreamMs);
|
||
// Provider endpoint credentials are kept only here or in the private volume.
|
||
// They are never part of an endpoint response or a browser resource URL.
|
||
const activeIonEndpoints = new Map();
|
||
const inflightIonEndpointRefreshes = new Map();
|
||
const credentialQueryKeys = new Set(["access_token", "accesstoken", "iontoken", "token", "key", "apikey", "api_key", "signature", "sig"]);
|
||
|
||
await initialiseCacheStore(liveCache);
|
||
if (offlineSnapshot) await initialiseCacheStore(offlineSnapshot);
|
||
// A Platform administrator may rotate the master token after deployment. The
|
||
// private NAS file wins over the legacy environment bootstrap on restart.
|
||
let activeCesiumIonToken = (await readStoredCesiumIonToken()) || config.bootstrapCesiumIonToken;
|
||
let ionTokenWriteQueue = Promise.resolve();
|
||
let ionTokenGeneration = 0;
|
||
|
||
const server = createServer(async (request, response) => {
|
||
applyCors(request, response);
|
||
if (request.method === "OPTIONS") return response.writeHead(204).end();
|
||
let requestPath = "unknown";
|
||
|
||
try {
|
||
const requestUrl = new URL(request.url || "/", `http://${request.headers.host || "127.0.0.1"}`);
|
||
requestPath = requestUrl.pathname;
|
||
if (requestUrl.pathname === "/api/map/admin/cesium-ion" && ["GET", "PUT"].includes(request.method || "")) {
|
||
return await serveCesiumIonAdmin(request, response, requestUrl.pathname);
|
||
}
|
||
if (requestUrl.pathname === `/internal/zone-sources/v1/profiles/${encodeURIComponent(zoneSourceProfileId)}/current`) {
|
||
if (!["GET", "HEAD"].includes(request.method || "")) {
|
||
response.setHeader("Allow", "GET, HEAD");
|
||
return writeJson(response, 405, { ok: false, error: "zone_source_method_not_allowed" });
|
||
}
|
||
return serveZoneSourceGeneration(request, response);
|
||
}
|
||
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,
|
||
diagnostics: gatewayDiagnostics.status(),
|
||
ionConfigured: Boolean(activeCesiumIonToken),
|
||
assetAllowlist: [...config.assetAllowlist].map(Number).sort((left, right) => left - right),
|
||
anonymousAccess: config.allowAnonymous,
|
||
zoneSource: {
|
||
profileId: zoneSourceProfileId,
|
||
authorityId: zoneSource.generation.authorityId,
|
||
datasetId: zoneSource.generation.datasetId,
|
||
sourceRevision: zoneSource.generation.sourceRevision,
|
||
contentDigest: zoneSource.generation.contentDigest,
|
||
sourceZoneCount: zoneSource.generation.metadata.sourceZoneCount,
|
||
publishedZoneCount: zoneSource.generation.metadata.publishedZoneCount,
|
||
},
|
||
});
|
||
}
|
||
|
||
const ionAssetMatch = requestUrl.pathname.match(/^\/api\/map\/ion\/assets\/(\d+)\/endpoint$/);
|
||
if (ionAssetMatch && request.method === "GET") {
|
||
return await serveIonEndpoint(request, 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);
|
||
const errorCode = safeDiagnosticCode(error?.message || "map_gateway_error");
|
||
gatewayDiagnostics.recordGatewayFailure(errorCode);
|
||
console.warn(JSON.stringify({ event: "map_gateway_request_failed", route: requestPath, status, error: errorCode }));
|
||
return writeJson(response, Number.isInteger(status) && status >= 400 && status < 600 ? status : 500, {
|
||
ok: false,
|
||
error: errorCode,
|
||
});
|
||
}
|
||
});
|
||
|
||
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)`);
|
||
console.log(`Zone source: ${zoneSourceProfileId}@${zoneSource.generation.sourceRevision} (${zoneSource.generation.metadata.publishedZoneCount} zones)`);
|
||
});
|
||
|
||
process.on("SIGTERM", () => server.close());
|
||
process.on("SIGINT", () => server.close());
|
||
|
||
function serveZoneSourceGeneration(request, response) {
|
||
if (request.headers["if-none-match"] === zoneSource.etag) {
|
||
response.writeHead(304, { ETag: zoneSource.etag, "Cache-Control": "private, max-age=60" });
|
||
return response.end();
|
||
}
|
||
response.writeHead(200, {
|
||
"Content-Type": "application/json; charset=utf-8",
|
||
"Content-Length": zoneSource.body.byteLength,
|
||
"Cache-Control": "private, max-age=60",
|
||
ETag: zoneSource.etag,
|
||
"X-Content-Type-Options": "nosniff",
|
||
});
|
||
return response.end(request.method === "HEAD" ? undefined : zoneSource.body);
|
||
}
|
||
|
||
async 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");
|
||
const mapEgress = await readMapEgressConfig();
|
||
const ionEndpointTtlMs = parsePositiveInt(process.env.CESIUM_ION_ENDPOINT_TTL_SECONDS, 300) * 1000;
|
||
const configuredRefreshAheadMs = parsePositiveInt(process.env.CESIUM_ION_ENDPOINT_REFRESH_AHEAD_SECONDS, 60) * 1000;
|
||
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,
|
||
slowUpstreamMs: parsePositiveInt(process.env.MAP_GATEWAY_SLOW_UPSTREAM_SECONDS, 2) * 1000,
|
||
ionEndpointTtlMs,
|
||
ionEndpointRefreshAheadMs: Math.min(configuredRefreshAheadMs, Math.max(0, ionEndpointTtlMs - 1000)),
|
||
// Test-only loopback escape hatch for the cache policy smoke test. It is
|
||
// impossible to enable in production and never broadens the real HTTPS
|
||
// allowlist.
|
||
testAllowHttpLoopback: process.env.NODE_ENV === "test" && parseBoolean(process.env.MAP_GATEWAY_TEST_ALLOW_HTTP_LOOPBACK, false),
|
||
// Transitional bootstrap only. A token written through the Foundry admin
|
||
// flow is held in the private cache volume and takes precedence.
|
||
bootstrapCesiumIonToken: String(process.env.CESIUM_ION_TOKEN || "").trim(),
|
||
cesiumIonApiBase: normalizeCesiumIonApiBase(process.env.CESIUM_ION_API_BASE_URL),
|
||
cesiumIonTokenFile: String(process.env.MAP_GATEWAY_CESIUM_ION_TOKEN_FILE || join(String(process.env.MAP_CACHE_DIR || "/var/lib/nodedc-map-cache").trim(), "secrets", "cesium-ion-token")).trim(),
|
||
// The production value is read from a runner-owned, read-only file. Local
|
||
// test/dev may still inject a process-only value without creating a file.
|
||
mapGatewayAdminSecret: await readMapGatewayAdminSecret(),
|
||
mapEgress,
|
||
cesiumEgressHosts: canonicalCesiumEgressHosts,
|
||
// The Foundry Map Page contract always needs terrain (1), imagery (2),
|
||
// and the canonical buildings asset. A malformed legacy environment value
|
||
// must never turn all three into 403 before the Gateway can reach Cesium.
|
||
// Root may add further approved assets, but cannot remove these core IDs.
|
||
assetAllowlist: new Set([...canonicalCesiumAssetIds, ...parseList(process.env.CESIUM_ION_ASSET_ALLOWLIST)]),
|
||
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(),
|
||
};
|
||
}
|
||
|
||
async function readMapEgressConfig() {
|
||
const rawUrl = String(process.env.MAP_GATEWAY_EGRESS_URL || "").trim();
|
||
if (!rawUrl) return { url: "", proxyToken: "" };
|
||
let url;
|
||
try {
|
||
url = new URL(rawUrl);
|
||
} catch {
|
||
throw new Error("map_gateway_egress_url_invalid");
|
||
}
|
||
if (!['http:', 'https:'].includes(url.protocol) || !url.hostname || url.username || url.password || url.pathname !== "/" || url.search || url.hash) {
|
||
throw new Error("map_gateway_egress_url_invalid");
|
||
}
|
||
const tokenFile = String(process.env.MAP_GATEWAY_EGRESS_PROXY_TOKEN_FILE || "").trim();
|
||
if (!tokenFile) throw new Error("map_gateway_egress_proxy_token_file_required");
|
||
let proxyToken;
|
||
try {
|
||
proxyToken = (await readFile(tokenFile, "utf8")).trim();
|
||
} catch {
|
||
throw new Error("map_gateway_egress_proxy_token_file_unreadable");
|
||
}
|
||
if (!proxyToken || proxyToken.length > 4096 || /[\u0000-\u001f\u007f\s]/.test(proxyToken)) {
|
||
throw new Error("map_gateway_egress_proxy_token_file_invalid");
|
||
}
|
||
return { url: url.toString(), proxyToken };
|
||
}
|
||
|
||
async function readMapGatewayAdminSecret() {
|
||
const secretFile = String(process.env.NODEDC_MAP_GATEWAY_ADMIN_SECRET_FILE || "").trim();
|
||
if (secretFile) {
|
||
let secret;
|
||
try {
|
||
secret = (await readFile(secretFile, "utf8")).trim();
|
||
} catch {
|
||
throw new Error("map_gateway_admin_secret_file_unreadable");
|
||
}
|
||
if (!/^[A-Za-z0-9_-]{48,256}$/.test(secret)) throw new Error("map_gateway_admin_secret_file_invalid");
|
||
return secret;
|
||
}
|
||
if (process.env.NODE_ENV === "production") throw new Error("map_gateway_admin_secret_file_required");
|
||
return String(process.env.NODEDC_MAP_GATEWAY_ADMIN_SECRET || "").trim();
|
||
}
|
||
|
||
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 normalizeCesiumIonApiBase(value) {
|
||
const configured = String(value || "https://api.cesium.com/").trim();
|
||
let base;
|
||
try { base = new URL(configured); } catch { throw new Error("invalid_cesium_ion_api_base"); }
|
||
const testHttp = process.env.NODE_ENV === "test" && base.protocol === "http:";
|
||
if ((!testHttp && base.protocol !== "https:") || base.username || base.password || base.search || base.hash) {
|
||
throw new Error("invalid_cesium_ion_api_base");
|
||
}
|
||
base.pathname = base.pathname.replace(/\/+$/, "") || "/";
|
||
return base.toString();
|
||
}
|
||
|
||
function normalizeIonReferer(value) {
|
||
const raw = String(value || "").trim();
|
||
if (!raw || raw.length > 2048) return "";
|
||
let referer;
|
||
try { referer = new URL(raw); } catch { return ""; }
|
||
if (!['http:', 'https:'].includes(referer.protocol) || referer.username || referer.password) return "";
|
||
referer.search = "";
|
||
referer.hash = "";
|
||
return referer.toString();
|
||
}
|
||
|
||
function requestIonReferer(request) {
|
||
// This is an internal Foundry-to-Gateway transport header. It is normalized
|
||
// before reaching Cesium and, for admin calls, included in the HMAC payload.
|
||
return normalizeIonReferer(request.headers['x-nodedc-ion-referer']);
|
||
}
|
||
|
||
function ionRefererHeaders(ionReferer) {
|
||
return ionReferer ? { referer: ionReferer } : {};
|
||
}
|
||
|
||
function cesiumIonAssetEndpointUrl(assetId) {
|
||
return new URL(`v1/assets/${encodeURIComponent(assetId)}/endpoint`, config.cesiumIonApiBase).toString();
|
||
}
|
||
|
||
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");
|
||
}
|
||
|
||
async function serveCesiumIonAdmin(request, response, pathname) {
|
||
const rawBody = request.method === "PUT" ? await readSmallBody(request) : "";
|
||
const { actorId, ionReferer } = verifyAdminRequest(request, pathname, rawBody);
|
||
if (request.method === "GET") {
|
||
const metadata = await readCesiumIonMetadata();
|
||
// A Bing asset endpoint request starts a billable provider session. The
|
||
// normal GET path must therefore report the persisted verification state,
|
||
// rather than re-validating every time an administrator opens the modal.
|
||
// Artifacts created before this check existed have no state: validate that
|
||
// one existing private value exactly once and then remember the safe result.
|
||
let verification = metadata?.verification || (activeCesiumIonToken ? null : "not-configured");
|
||
// Transport v4 adds production-shaped file endpoint scopes and safe
|
||
// cache-first refresh. Recheck a persisted failure once so a false v3
|
||
// result cannot survive the artifact upgrade forever.
|
||
const mustUpgradeLegacyFailure = metadata?.verification === "failed"
|
||
&& metadata.verificationTransportVersion !== cesiumVerificationTransportVersion;
|
||
if ((!verification || mustUpgradeLegacyFailure) && activeCesiumIonToken) {
|
||
verification = (await verifyCesiumIonToken(activeCesiumIonToken, ionReferer)).verification;
|
||
if (metadata) await persistCesiumIonVerification(metadata, verification);
|
||
}
|
||
return writeJson(response, 200, {
|
||
ok: true,
|
||
configured: Boolean(activeCesiumIonToken),
|
||
updatedAt: metadata?.updatedAt || null,
|
||
updatedBy: metadata?.updatedBy || null,
|
||
verification: verification || "failed",
|
||
});
|
||
}
|
||
|
||
let input;
|
||
try {
|
||
input = JSON.parse(rawBody);
|
||
} catch {
|
||
throw gatewayError("invalid_cesium_ion_token", 400);
|
||
}
|
||
const token = typeof input?.token === "string" ? input.token.trim() : "";
|
||
if (token.length < 16 || token.length > 4096 || /[\u0000-\u001f\u007f\s]/.test(token)) {
|
||
throw gatewayError("invalid_cesium_ion_token", 400);
|
||
}
|
||
const verification = await verifyCesiumIonToken(token, ionReferer);
|
||
if (verification.verification !== "verified") throw gatewayError("cesium_ion_token_verification_failed", 422);
|
||
const metadata = await persistCesiumIonToken(token, actorId, verification);
|
||
return writeJson(response, 200, {
|
||
ok: true,
|
||
configured: true,
|
||
updatedAt: metadata.updatedAt,
|
||
updatedBy: metadata.updatedBy,
|
||
verification: verification.verification,
|
||
});
|
||
}
|
||
|
||
async function readSmallBody(request) {
|
||
const chunks = [];
|
||
let size = 0;
|
||
for await (const chunk of request) {
|
||
size += chunk.length;
|
||
if (size > 8 * 1024) throw gatewayError("map_gateway_admin_payload_too_large", 413);
|
||
chunks.push(chunk);
|
||
}
|
||
return Buffer.concat(chunks).toString("utf8");
|
||
}
|
||
|
||
function verifyAdminRequest(request, pathname, body) {
|
||
if (!config.mapGatewayAdminSecret) throw gatewayError("map_gateway_admin_not_configured", 503);
|
||
const timestamp = String(request.headers["x-nodedc-admin-timestamp"] || "");
|
||
const actorId = String(request.headers["x-nodedc-admin-actor"] || "").trim();
|
||
const suppliedSignature = String(request.headers["x-nodedc-admin-signature"] || "").trim().toLowerCase();
|
||
const ionReferer = normalizeIonReferer(request.headers["x-nodedc-ion-referer"]);
|
||
const timestampMs = Number(timestamp);
|
||
if (!Number.isInteger(timestampMs) || Math.abs(Date.now() - timestampMs) > 60_000 || !/^[A-Za-z0-9._:@-]{1,256}$/.test(actorId) || !/^[a-f0-9]{64}$/.test(suppliedSignature)) {
|
||
throw gatewayError("map_gateway_admin_unauthorized", 401);
|
||
}
|
||
const bodyHash = createHash("sha256").update(body).digest("hex");
|
||
const message = `nodedc.map-gateway.admin.v2\n${request.method}\n${pathname}\n${timestamp}\n${actorId}\n${bodyHash}\n${ionReferer}`;
|
||
const expectedSignature = createHmac("sha256", config.mapGatewayAdminSecret).update(message).digest("hex");
|
||
const expected = Buffer.from(expectedSignature, "hex");
|
||
const supplied = Buffer.from(suppliedSignature, "hex");
|
||
if (expected.length !== supplied.length || !timingSafeEqual(expected, supplied)) throw gatewayError("map_gateway_admin_unauthorized", 401);
|
||
return { actorId, ionReferer };
|
||
}
|
||
|
||
function cesiumIonMetadataFile() {
|
||
return `${config.cesiumIonTokenFile}.metadata.json`;
|
||
}
|
||
|
||
async function readStoredCesiumIonToken() {
|
||
try {
|
||
return String(await readFile(config.cesiumIonTokenFile, "utf8")).trim();
|
||
} catch (error) {
|
||
if (error?.code !== "ENOENT") console.warn("Cesium Ion token file ignored: unreadable");
|
||
return "";
|
||
}
|
||
}
|
||
|
||
async function readCesiumIonMetadata() {
|
||
try {
|
||
const metadata = JSON.parse(await readFile(cesiumIonMetadataFile(), "utf8"));
|
||
const updatedAt = typeof metadata?.updatedAt === "string" ? metadata.updatedAt : null;
|
||
const updatedBy = typeof metadata?.updatedBy === "string" ? metadata.updatedBy : null;
|
||
const verification = metadata?.verification === "verified" || metadata?.verification === "failed" ? metadata.verification : null;
|
||
const verificationTransportVersion = Number.isInteger(metadata?.verificationTransportVersion)
|
||
? metadata.verificationTransportVersion
|
||
: 0;
|
||
return updatedAt && updatedBy ? { updatedAt, updatedBy, verification, verificationTransportVersion } : null;
|
||
} catch (error) {
|
||
if (error?.code !== "ENOENT") console.warn("Cesium Ion token metadata ignored: invalid file");
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function verifyCesiumIonToken(token, ionReferer = "") {
|
||
// Settings must validate the complete Foundry Map Page contract. Accepting
|
||
// a token that reads terrain and imagery but not canonical 3D Buildings
|
||
// would defer a predictable provider 403 to the browser.
|
||
const results = await Promise.all(canonicalCesiumAssetIds.map(async (assetId) => {
|
||
try {
|
||
await fetchCesiumIonEndpoint(assetId, token, ionReferer);
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}));
|
||
return { verification: results.every(Boolean) ? "verified" : "failed" };
|
||
}
|
||
|
||
async function fetchCesiumIonEndpoint(assetId, token, ionReferer = "") {
|
||
const endpoint = await fetchWithTimeout(cesiumIonAssetEndpointUrl(assetId), {
|
||
headers: { authorization: `Bearer ${token}`, ...ionRefererHeaders(ionReferer) },
|
||
}, { operation: "ion-endpoint", assetId });
|
||
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 (assetId === "1" && next.type !== "TERRAIN") throw gatewayError("cesium_ion_endpoint_invalid", 502);
|
||
if (assetId === "2" && (next.externalType !== "BING" || next.type !== "IMAGERY")) throw gatewayError("cesium_ion_endpoint_invalid", 502);
|
||
if (!(next.externalType === "BING" ? next.options?.key : next.accessToken)) throw gatewayError("cesium_ion_endpoint_invalid", 502);
|
||
const credentialExpiresAt = ionEndpointCredentialExpiry(next);
|
||
if (credentialExpiresAt !== null && credentialExpiresAt <= Date.now()) throw gatewayError("cesium_ion_endpoint_expired", 502);
|
||
return next;
|
||
}
|
||
|
||
function persistCesiumIonToken(token, actorId, verification) {
|
||
const task = ionTokenWriteQueue.then(async () => {
|
||
const secretDir = dirname(config.cesiumIonTokenFile);
|
||
await mkdir(secretDir, { recursive: true, mode: 0o700 });
|
||
await chmod(secretDir, 0o700);
|
||
const tokenTemp = `${config.cesiumIonTokenFile}.${process.pid}.${Date.now()}.tmp`;
|
||
const metadata = {
|
||
updatedAt: new Date().toISOString(),
|
||
updatedBy: actorId,
|
||
verification: verification?.verification === "verified" ? "verified" : "failed",
|
||
verificationTransportVersion: cesiumVerificationTransportVersion,
|
||
};
|
||
await writeFile(tokenTemp, `${token}\n`, { encoding: "utf8", mode: 0o600 });
|
||
await rename(tokenTemp, config.cesiumIonTokenFile);
|
||
await chmod(config.cesiumIonTokenFile, 0o600);
|
||
await writeCesiumIonMetadata(metadata);
|
||
// Asset-scoped endpoint credentials are derived from the master token.
|
||
// Discard the prior private endpoint cache on rotation so a fresh map load
|
||
// cannot keep using credentials minted under the old provider token.
|
||
ionTokenGeneration += 1;
|
||
activeCesiumIonToken = token;
|
||
activeIonEndpoints.clear();
|
||
inflightIonEndpointRefreshes.clear();
|
||
await Promise.all([...config.assetAllowlist].map((assetId) => rm(ionEndpointPath(assetId), { force: true })));
|
||
return metadata;
|
||
});
|
||
ionTokenWriteQueue = task.then(() => undefined, () => undefined);
|
||
return task;
|
||
}
|
||
|
||
function persistCesiumIonVerification(expectedMetadata, verification) {
|
||
const task = ionTokenWriteQueue.then(async () => {
|
||
const current = await readCesiumIonMetadata();
|
||
// Do not let an old GET validation race a newer token rotation.
|
||
if (!current || current.updatedAt !== expectedMetadata.updatedAt || current.updatedBy !== expectedMetadata.updatedBy) return;
|
||
await writeCesiumIonMetadata({ ...current, verification, verificationTransportVersion: cesiumVerificationTransportVersion });
|
||
});
|
||
ionTokenWriteQueue = task.then(() => undefined, () => undefined);
|
||
return task;
|
||
}
|
||
|
||
async function writeCesiumIonMetadata(metadata) {
|
||
const metadataPath = cesiumIonMetadataFile();
|
||
const metadataTemp = `${metadataPath}.${process.pid}.${Date.now()}.tmp`;
|
||
await writeFile(metadataTemp, `${JSON.stringify(metadata)}\n`, { encoding: "utf8", mode: 0o600 });
|
||
await rename(metadataTemp, metadataPath);
|
||
await chmod(metadataPath, 0o600);
|
||
}
|
||
|
||
function isAllowedRequest(request) {
|
||
return config.allowAnonymous || Boolean(request.headers[config.trustedSubjectHeader]);
|
||
}
|
||
|
||
async function serveIonEndpoint(request, response, assetId) {
|
||
if (!config.assetAllowlist.has(assetId)) return writeJson(response, 403, { ok: false, error: "cesium_asset_not_allowed" });
|
||
await ionTokenWriteQueue;
|
||
const cached = await readIonEndpointCache(assetId);
|
||
const cachedState = cached ? ionEndpointCacheState(cached) : null;
|
||
if (config.mode === "offline") {
|
||
if (!cached) return writeJson(response, 504, { ok: false, error: "cesium_ion_offline_endpoint_miss" });
|
||
if (!cachedState.usable) return writeJson(response, 504, { ok: false, error: "cesium_ion_offline_endpoint_expired" });
|
||
if (!isOfflineProviderAllowed(ionEndpointUrl(cached))) return writeJson(response, 409, { ok: false, error: "map_provider_offline_not_permitted" });
|
||
rememberIonEndpoint(cached);
|
||
return writeJson(response, 200, { ok: true, ...publicIonEndpoint(cached), cache: "offline-endpoint-hit" });
|
||
}
|
||
if (!activeCesiumIonToken) {
|
||
if (cached && cachedState.usable) {
|
||
rememberIonEndpoint(cached);
|
||
return writeJson(response, 200, { ok: true, ...publicIonEndpoint(cached), cache: "cached-endpoint-no-master-token" });
|
||
}
|
||
return writeJson(response, 503, { ok: false, error: "cesium_ion_not_configured" });
|
||
}
|
||
|
||
const ionReferer = requestIonReferer(request);
|
||
if (cached && cachedState.usable) {
|
||
rememberIonEndpoint(cached);
|
||
if (cachedState.needsRefresh) scheduleIonEndpointRefresh(assetId, ionReferer);
|
||
return writeJson(response, 200, {
|
||
ok: true,
|
||
...publicIonEndpoint(cached),
|
||
cache: cachedState.needsRefresh ? "ion-endpoint-refresh-ahead" : "ion-endpoint-hit",
|
||
});
|
||
}
|
||
|
||
const next = await refreshIonEndpoint(assetId, ionReferer);
|
||
return writeJson(response, 200, { ok: true, ...publicIonEndpoint(next), cache: "ion-endpoint-online" });
|
||
}
|
||
|
||
function scheduleIonEndpointRefresh(assetId, ionReferer = "") {
|
||
const active = activeIonEndpoints.get(String(assetId));
|
||
if (active) {
|
||
const state = ionEndpointCacheState(active);
|
||
if (state.usable && !state.needsRefresh) return;
|
||
}
|
||
void refreshIonEndpoint(assetId, ionReferer).catch((error) => {
|
||
const errorCode = safeDiagnosticCode(error?.message || "cesium_ion_endpoint_refresh_failed");
|
||
if (errorCode === "cesium_ion_token_rotated") return;
|
||
console.warn(JSON.stringify({ event: "map_ion_endpoint_refresh_failed", assetId, error: errorCode }));
|
||
});
|
||
}
|
||
|
||
function refreshIonEndpoint(assetId, ionReferer = "") {
|
||
const token = activeCesiumIonToken;
|
||
if (!token) return Promise.reject(gatewayError("cesium_ion_not_configured", 503));
|
||
const generation = ionTokenGeneration;
|
||
const existing = inflightIonEndpointRefreshes.get(assetId);
|
||
if (existing?.generation === generation) return existing.promise;
|
||
|
||
const promise = (async () => {
|
||
const next = await fetchCesiumIonEndpoint(assetId, token, ionReferer);
|
||
// An administrator may rotate the master token while this provider request
|
||
// is in flight. Never re-introduce an endpoint credential minted under the
|
||
// previous token generation.
|
||
if (generation !== ionTokenGeneration || token !== activeCesiumIonToken) {
|
||
throw gatewayError("cesium_ion_token_rotated", 409);
|
||
}
|
||
rememberIonEndpoint(next);
|
||
if (config.mode === "readwrite") await writeIonEndpointCache(next);
|
||
return next;
|
||
})();
|
||
const record = { generation, promise };
|
||
inflightIonEndpointRefreshes.set(assetId, record);
|
||
promise.finally(() => {
|
||
if (inflightIonEndpointRefreshes.get(assetId) === record) inflightIonEndpointRefreshes.delete(assetId);
|
||
}).catch(() => undefined);
|
||
return promise;
|
||
}
|
||
|
||
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" });
|
||
let target = validateUpstream(rawTarget);
|
||
// Browser-facing route revision only. It must not affect the upstream URL
|
||
// or create a duplicate TileCache object.
|
||
target.searchParams.delete("nodedc_client_revision");
|
||
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 ionReferer = requestIonReferer(request);
|
||
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") {
|
||
gatewayDiagnostics.recordCache("passthrough");
|
||
target = await injectGatewayCredentials(target, ionReferer);
|
||
return proxyUncachedRequest(request, response, target, "live-pass-through", ionReferer);
|
||
}
|
||
const cached = await getCachedEntry(store, cacheKey);
|
||
const fresh = cached && cached.expiresAt > Date.now();
|
||
|
||
// A refresh can have a previously published object at this key while its
|
||
// replacement is still streaming into the private temp file. Treat every
|
||
// request that arrives during that window as a follower: otherwise a normal
|
||
// cache-first request could race the atomic rename and receive the old
|
||
// object immediately after the refresh leader received the new one.
|
||
const activeFill = inflightWrites.get(`${store.name}:${cacheKey}`);
|
||
if (activeFill) {
|
||
try {
|
||
const entry = await activeFill.commit;
|
||
const state = cached ? "live-cache-hit" : (forceRefresh ? "live-refresh-record" : "live-record");
|
||
return serveCachedFile(request, response, store, entry, state);
|
||
} catch (error) {
|
||
if (cached) {
|
||
gatewayDiagnostics.recordCache("staleFallback");
|
||
return serveCachedFile(request, response, store, cached, "live-stale-upstream-error");
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// 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" || config.mode === "offline") {
|
||
if (cached) return serveCachedFile(request, response, store, cached, fresh ? `${store.name}-offline-hit` : `${store.name}-offline-stale`);
|
||
return writeJson(response, 504, { ok: false, error: cacheProfile === "offline" ? "map_offline_snapshot_miss" : "map_cache_offline_miss" });
|
||
}
|
||
if (isLegacyCacheHost) {
|
||
if (cached) return serveCachedFile(request, response, store, cached, fresh ? "legacy-cache-hit" : "legacy-cache-stale");
|
||
return writeJson(response, 504, { ok: false, error: "map_legacy_cache_miss" });
|
||
}
|
||
|
||
// The product setting "Не перезаписывать cache" is cache-first by
|
||
// definition: a collected tile is served from the shared persistent volume
|
||
// immediately, while only a miss travels through the official live
|
||
// provider. `nodedc_cache_refresh=1` is the explicit opt-in to replace a
|
||
// previously collected object.
|
||
if (cached && !forceRefresh) {
|
||
gatewayDiagnostics.recordCache(fresh ? "hit" : "staleHit");
|
||
return serveCachedFile(request, response, store, cached, fresh ? "live-cache-hit" : "live-cache-stale");
|
||
}
|
||
|
||
// Credentials are needed only for a real upstream request. Cache lookup uses
|
||
// the credential-free canonical URL, so a warm TileCache remains independent
|
||
// from Ion token refresh and from the AMD/VPN route.
|
||
target = await injectGatewayCredentials(target, ionReferer);
|
||
|
||
if (config.mode === "readonly") {
|
||
gatewayDiagnostics.recordCache("readonlyPassThrough");
|
||
return proxyUncachedRequest(request, response, target, "pass-through-readonly", ionReferer);
|
||
}
|
||
|
||
if (String(request.headers.range || "").trim()) {
|
||
gatewayDiagnostics.recordCache(cached ? "rangeRefresh" : "rangeMiss");
|
||
return proxyRangeRequest(request, response, target, cached, store, ionReferer);
|
||
}
|
||
|
||
try {
|
||
gatewayDiagnostics.recordCache(forceRefresh && cached ? "refresh" : "miss");
|
||
// Keep the await inside this try block: setup failures (notably an explicit
|
||
// refresh whose provider is temporarily unavailable) must reach the stale
|
||
// fallback below instead of bypassing it as an unobserved returned promise.
|
||
return await streamAndCacheMiss(
|
||
request,
|
||
response,
|
||
target,
|
||
cacheKey,
|
||
store,
|
||
forceRefresh ? "live-refresh-record" : "live-record",
|
||
ionReferer,
|
||
);
|
||
} catch (error) {
|
||
if (isCacheCapacityError(error)) return proxyUncachedRequest(request, response, target, "live-pass-through-cache-full", ionReferer);
|
||
if (cached) {
|
||
gatewayDiagnostics.recordCache("staleFallback");
|
||
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:" && /^(dev\.virtualearth\.net|ecn\.t[0-3]\.tiles\.virtualearth\.net|ecn\.tiles\.virtualearth\.net)$/.test(hostname)) {
|
||
target.protocol = "https:";
|
||
}
|
||
const testLoopbackHttp = config.testAllowHttpLoopback
|
||
&& target.protocol === "http:"
|
||
&& ["127.0.0.1", "localhost", "::1"].includes(hostname);
|
||
if (target.protocol !== "https:" && !testLoopbackHttp) 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 = stripCredentialQueryParameters(target);
|
||
// 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";
|
||
}
|
||
url.searchParams.sort();
|
||
return url.toString();
|
||
}
|
||
|
||
async function streamAndCacheMiss(request, response, target, cacheKey, store, cacheState, ionReferer = "") {
|
||
const inflightKey = `${store.name}:${cacheKey}`;
|
||
const existing = inflightWrites.get(inflightKey);
|
||
if (existing) {
|
||
const entry = await existing.commit;
|
||
return serveCachedFile(request, response, store, entry, cacheState);
|
||
}
|
||
|
||
const setup = prepareStreamingCacheFill(target, cacheKey, store, ionReferer);
|
||
const record = { setup, commit: null };
|
||
record.commit = setup.then(({ cacheCommit }) => cacheCommit);
|
||
inflightWrites.set(inflightKey, record);
|
||
// Cache fill is intentionally server-owned once started. A browser can stop
|
||
// consuming its tee branch without cancelling the upstream/disk branch used
|
||
// by concurrent viewers and future offline hits.
|
||
record.commit.catch((error) => {
|
||
const errorCode = safeDiagnosticCode(error?.message || "map_cache_fill_failed");
|
||
gatewayDiagnostics.recordGatewayFailure(errorCode);
|
||
console.warn(JSON.stringify({ event: "map_cache_fill_failed", error: errorCode }));
|
||
});
|
||
record.commit.finally(() => {
|
||
if (inflightWrites.get(inflightKey) === record) inflightWrites.delete(inflightKey);
|
||
}).catch(() => undefined);
|
||
|
||
const prepared = await setup;
|
||
if (request.method === "HEAD") {
|
||
await prepared.clientBody.cancel().catch(() => undefined);
|
||
const entry = await record.commit;
|
||
return serveCachedFile(request, response, store, entry, cacheState);
|
||
}
|
||
if (request.aborted || response.destroyed) {
|
||
await prepared.clientBody.cancel().catch(() => undefined);
|
||
return;
|
||
}
|
||
|
||
copyUpstreamHeaders(response, prepared.headers);
|
||
response.setHeader("x-nodedc-map-cache", cacheState);
|
||
response.writeHead(prepared.status);
|
||
try {
|
||
await pipeline(Readable.fromWeb(prepared.clientBody), response);
|
||
} catch (error) {
|
||
if (request.aborted || response.destroyed || response.writableEnded) return;
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async function prepareStreamingCacheFill(target, cacheKey, store, ionReferer = "") {
|
||
const upstream = await fetchWithTimeout(target, {
|
||
headers: { accept: "application/json, application/octet-stream, image/*, */*;q=0.5", ...ionRefererHeaders(ionReferer) },
|
||
}, { operation: "cache-fill" });
|
||
if (!upstream.ok || !upstream.body) {
|
||
await upstream.body?.cancel().catch(() => undefined);
|
||
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) {
|
||
await upstream.body.cancel().catch(() => undefined);
|
||
throw gatewayError("map_cache_object_too_large", 413);
|
||
}
|
||
|
||
const [clientBody, cacheBody] = upstream.body.tee();
|
||
const cacheCommit = commitCacheBody(cacheBody, upstream.headers, cacheKey, store);
|
||
return { status: upstream.status, headers: upstream.headers, clientBody, cacheCommit };
|
||
}
|
||
|
||
async function commitCacheBody(cacheBody, upstreamHeaders, cacheKey, store) {
|
||
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);
|
||
},
|
||
});
|
||
let reservationHeld = false;
|
||
try {
|
||
await pipeline(Readable.fromWeb(cacheBody), limit, createWriteStream(tempPath, { flags: "wx" }));
|
||
const replacedBytes = Number(store.index.entries[cacheKey]?.bytes || 0);
|
||
if (!(await reserveCacheCapacity(store, bytes, replacedBytes))) throw cacheCapacityError();
|
||
reservationHeld = true;
|
||
await rename(tempPath, filePath);
|
||
const now = Date.now();
|
||
const entry = {
|
||
key: cacheKey,
|
||
file: relativePath,
|
||
bytes,
|
||
contentType: upstreamHeaders.get("content-type") || "application/octet-stream",
|
||
etag: upstreamHeaders.get("etag") || null,
|
||
savedAt: now,
|
||
lastAccessAt: now,
|
||
expiresAt: now + responseTtl(upstreamHeaders.get("cache-control")),
|
||
};
|
||
store.index.entries[cacheKey] = entry;
|
||
await releaseCacheCapacity(store, bytes);
|
||
reservationHeld = false;
|
||
await writeCacheIndex(store);
|
||
return entry;
|
||
} catch (error) {
|
||
if (reservationHeld) await releaseCacheCapacity(store, bytes);
|
||
await rm(tempPath, { force: true });
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
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, ionReferer = "") {
|
||
const upstream = await fetchWithTimeout(target, {
|
||
headers: { range: String(request.headers.range), accept: "*/*", ...ionRefererHeaders(ionReferer) },
|
||
}, { operation: "range" });
|
||
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, ionReferer = "") {
|
||
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) } : {}),
|
||
...ionRefererHeaders(ionReferer),
|
||
},
|
||
}, { operation: "passthrough" });
|
||
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);
|
||
const etag = cacheEntityTag(entry, info.size);
|
||
response.setHeader("content-type", entry.contentType);
|
||
response.setHeader("accept-ranges", "bytes");
|
||
response.setHeader("cache-control", "public, max-age=60");
|
||
response.setHeader("etag", etag);
|
||
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))));
|
||
// This store has append-only/no-eviction policy. A hit therefore has no
|
||
// persistent metadata to update: rewriting the complete JSON index for every
|
||
// 20 KB tile made warm views slower than the provider itself.
|
||
if (ifNoneMatchMatches(request.headers["if-none-match"], etag)) {
|
||
response.writeHead(304);
|
||
return response.end();
|
||
}
|
||
|
||
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 cacheEntityTag(entry, size) {
|
||
const providerTag = String(entry.etag || "").trim();
|
||
if (providerTag.length <= 1024 && /^(?:W\/)?"[^"\r\n]*"$/.test(providerTag)) return providerTag;
|
||
const key = /^[a-f0-9]{64}$/i.test(String(entry.key || "")) ? String(entry.key).slice(0, 32).toLowerCase() : "object";
|
||
const revision = Number.isFinite(Number(entry.savedAt)) ? Math.max(0, Number(entry.savedAt)).toString(36) : "0";
|
||
return `"nodedc-${key}-${Number(size).toString(16)}-${revision}"`;
|
||
}
|
||
|
||
function ifNoneMatchMatches(rawHeader, etag) {
|
||
const raw = String(rawHeader || "");
|
||
if (!raw || raw.length > 8192) return false;
|
||
const expected = etag.replace(/^W\//, "");
|
||
return raw.split(",").some((part) => {
|
||
const candidate = part.trim();
|
||
return candidate === "*" || candidate.replace(/^W\//, "") === expected;
|
||
});
|
||
}
|
||
|
||
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: {} },
|
||
indexRevision: 0,
|
||
persistedIndexRevision: 0,
|
||
indexWriteScheduled: false,
|
||
indexWriteRunning: false,
|
||
indexWriteWaiters: [],
|
||
reservedBytes: 0,
|
||
capacityQueue: 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 rememberIonEndpoint(endpoint) {
|
||
if (endpoint?.assetId === undefined) return;
|
||
const assetId = String(endpoint.assetId);
|
||
const current = activeIonEndpoints.get(assetId);
|
||
if (!current || Number(endpoint.savedAt || 0) >= Number(current.savedAt || 0)) activeIonEndpoints.set(assetId, endpoint);
|
||
}
|
||
|
||
function ionEndpointCacheState(endpoint, now = Date.now()) {
|
||
const savedAt = Number(endpoint?.savedAt || 0);
|
||
const credentialExpiresAt = ionEndpointCredentialExpiry(endpoint);
|
||
const usable = credentialExpiresAt === null || now < credentialExpiresAt;
|
||
const ttlRefreshAt = savedAt > 0
|
||
? savedAt + Math.max(0, config.ionEndpointTtlMs - config.ionEndpointRefreshAheadMs)
|
||
: 0;
|
||
const credentialRefreshAt = credentialExpiresAt === null
|
||
? Number.POSITIVE_INFINITY
|
||
: Math.max(0, credentialExpiresAt - config.ionEndpointRefreshAheadMs);
|
||
return {
|
||
usable,
|
||
needsRefresh: !savedAt || now >= Math.min(ttlRefreshAt, credentialRefreshAt),
|
||
credentialExpiresAt,
|
||
};
|
||
}
|
||
|
||
function ionEndpointCredentialExpiry(endpoint) {
|
||
if (endpoint?.externalType === "BING") return null;
|
||
const token = String(endpoint?.accessToken || "");
|
||
const parts = token.split(".");
|
||
if (parts.length !== 3 || !parts[1]) return null;
|
||
try {
|
||
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
|
||
const seconds = Number(payload?.exp);
|
||
return Number.isFinite(seconds) && seconds > 0 ? Math.floor(seconds * 1000) : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function ionEndpointUrl(endpoint) {
|
||
return endpoint?.externalType === "BING" ? endpoint.options?.url : endpoint?.url;
|
||
}
|
||
|
||
function publicIonEndpoint(endpoint) {
|
||
const shared = {
|
||
assetId: endpoint.assetId,
|
||
type: endpoint.type,
|
||
attributions: Array.isArray(endpoint.attributions) ? endpoint.attributions : [],
|
||
credentialMode: "gateway",
|
||
};
|
||
if (endpoint.externalType === "BING") {
|
||
return {
|
||
...shared,
|
||
externalType: "BING",
|
||
options: {
|
||
url: publicProviderUrl(endpoint.options?.url),
|
||
mapStyle: String(endpoint.options?.mapStyle || "Aerial"),
|
||
},
|
||
};
|
||
}
|
||
return { ...shared, url: publicProviderUrl(endpoint.url) };
|
||
}
|
||
|
||
function publicProviderUrl(rawUrl) {
|
||
return stripCredentialQueryParameters(validateUpstream(String(rawUrl || ""))).toString();
|
||
}
|
||
|
||
function stripCredentialQueryParameters(target) {
|
||
const sanitized = new URL(target.toString());
|
||
for (const key of [...sanitized.searchParams.keys()]) {
|
||
if (credentialQueryKeys.has(key.toLowerCase())) sanitized.searchParams.delete(key);
|
||
}
|
||
return sanitized;
|
||
}
|
||
|
||
async function injectGatewayCredentials(target, ionReferer = "") {
|
||
await ionTokenWriteQueue;
|
||
const upstream = stripCredentialQueryParameters(target);
|
||
const endpoints = await knownIonEndpoints();
|
||
let matchedIon = endpoints
|
||
.filter((endpoint) => endpoint.externalType !== "BING" && endpoint.accessToken)
|
||
.map((endpoint) => ({ endpoint, scopeLength: endpointResourceScopeLength(upstream, endpoint.url) }))
|
||
.filter(({ scopeLength }) => scopeLength >= 0)
|
||
.sort((left, right) => right.scopeLength - left.scopeLength)[0]?.endpoint;
|
||
if (matchedIon) {
|
||
const state = ionEndpointCacheState(matchedIon);
|
||
if (!state.usable) {
|
||
const refreshed = await refreshIonEndpoint(String(matchedIon.assetId), ionReferer);
|
||
if (endpointResourceScopeLength(upstream, refreshed.url) < 0) throw gatewayError("cesium_ion_endpoint_scope_changed", 409);
|
||
matchedIon = refreshed;
|
||
} else if (state.needsRefresh) {
|
||
scheduleIonEndpointRefresh(String(matchedIon.assetId), ionReferer);
|
||
}
|
||
}
|
||
if (matchedIon) upstream.searchParams.set("access_token", matchedIon.accessToken);
|
||
|
||
const bing = endpoints.find((endpoint) => endpoint.externalType === "BING" && endpoint.options?.key);
|
||
if (bing && isBingResource(upstream)) upstream.searchParams.set("key", bing.options.key);
|
||
return upstream;
|
||
}
|
||
|
||
async function knownIonEndpoints() {
|
||
for (const assetId of config.assetAllowlist) {
|
||
if (activeIonEndpoints.has(assetId)) continue;
|
||
const cached = await readIonEndpointCache(assetId);
|
||
if (cached) rememberIonEndpoint(cached);
|
||
}
|
||
return [...activeIonEndpoints.values()];
|
||
}
|
||
|
||
function endpointResourceScopeLength(target, endpointUrl) {
|
||
if (!endpointUrl) return -1;
|
||
try {
|
||
const endpoint = new URL(endpointUrl);
|
||
if (target.origin !== endpoint.origin) return -1;
|
||
if (target.pathname === endpoint.pathname) return 1_000_000 + endpoint.pathname.length;
|
||
// Ion may return a root file such as `tileset.json`; its child URIs resolve
|
||
// beside that file, not below `tileset.json/`. The directory is trusted only
|
||
// because this endpoint and credential came from the private Ion response.
|
||
// Never broaden a root-level file into an origin-wide credential scope.
|
||
const directoryPath = new URL(".", endpoint).pathname;
|
||
if (directoryPath === "/" || !target.pathname.startsWith(directoryPath)) return -1;
|
||
return directoryPath.length;
|
||
} catch {
|
||
return -1;
|
||
}
|
||
}
|
||
|
||
function isBingResource(target) {
|
||
return /(^|\.)virtualearth\.net$/i.test(target.hostname);
|
||
}
|
||
|
||
function writeCacheIndex(store) {
|
||
if (!store.mutable) return Promise.resolve();
|
||
const revision = ++store.indexRevision;
|
||
const durable = new Promise((resolve, reject) => store.indexWriteWaiters.push({ revision, resolve, reject }));
|
||
scheduleCacheIndexWrite(store);
|
||
return durable;
|
||
}
|
||
|
||
function scheduleCacheIndexWrite(store) {
|
||
if (store.indexWriteScheduled || store.indexWriteRunning) return;
|
||
store.indexWriteScheduled = true;
|
||
// Batch a short burst of neighbouring I/O completions. A Cesium viewport can
|
||
// finish dozens of distinct tiles together; serialising the full index once
|
||
// per tile is unnecessary because one later snapshot contains every entry.
|
||
setTimeout(() => {
|
||
store.indexWriteScheduled = false;
|
||
void flushCacheIndex(store);
|
||
}, 10);
|
||
}
|
||
|
||
async function flushCacheIndex(store) {
|
||
if (store.indexWriteRunning) return;
|
||
store.indexWriteRunning = true;
|
||
try {
|
||
while (store.persistedIndexRevision < store.indexRevision) {
|
||
const snapshotRevision = store.indexRevision;
|
||
const snapshot = `${JSON.stringify(store.index)}\n`;
|
||
const tempPath = `${store.indexPath}.${process.pid}.${snapshotRevision}.tmp`;
|
||
await writeFile(tempPath, snapshot, "utf8");
|
||
await rename(tempPath, store.indexPath);
|
||
store.persistedIndexRevision = snapshotRevision;
|
||
gatewayDiagnostics.recordIndexSnapshot();
|
||
settleIndexWaiters(store);
|
||
}
|
||
} catch (error) {
|
||
console.warn("Map cache index write failed", error instanceof Error ? error.message : "unknown");
|
||
const waiters = store.indexWriteWaiters.splice(0);
|
||
for (const waiter of waiters) waiter.reject(error);
|
||
} finally {
|
||
store.indexWriteRunning = false;
|
||
if (store.indexWriteWaiters.length && store.persistedIndexRevision < store.indexRevision) scheduleCacheIndexWrite(store);
|
||
}
|
||
}
|
||
|
||
function settleIndexWaiters(store) {
|
||
const pending = [];
|
||
for (const waiter of store.indexWriteWaiters) {
|
||
if (waiter.revision <= store.persistedIndexRevision) waiter.resolve();
|
||
else pending.push(waiter);
|
||
}
|
||
store.indexWriteWaiters = pending;
|
||
}
|
||
|
||
function cacheBytes(store) {
|
||
return Object.values(store.index.entries).reduce((sum, entry) => sum + Number(entry.bytes || 0), 0);
|
||
}
|
||
|
||
function reserveCacheCapacity(store, bytes, replacedBytes = 0) {
|
||
const action = store.capacityQueue.then(() => {
|
||
const used = Math.max(0, cacheBytes(store) - replacedBytes) + store.reservedBytes;
|
||
if (used + bytes > config.maxCacheBytes) return false;
|
||
store.reservedBytes += bytes;
|
||
return true;
|
||
});
|
||
store.capacityQueue = action.then(() => undefined, () => undefined);
|
||
return action;
|
||
}
|
||
|
||
function releaseCacheCapacity(store, bytes) {
|
||
const action = store.capacityQueue.then(() => {
|
||
store.reservedBytes = Math.max(0, store.reservedBytes - bytes);
|
||
});
|
||
store.capacityQueue = action.then(() => undefined, () => undefined);
|
||
return action;
|
||
}
|
||
|
||
function cacheCapacityError() {
|
||
return gatewayError("map_cache_capacity_reached", 507);
|
||
}
|
||
|
||
function isCacheCapacityError(error) {
|
||
return error?.message === "map_cache_capacity_reached";
|
||
}
|
||
|
||
async function cacheStats(store) {
|
||
const entries = Object.values(store.index.entries);
|
||
const bytes = cacheBytes(store);
|
||
return {
|
||
name: store.name,
|
||
mode: store.mutable ? config.mode : "readonly",
|
||
writePolicy: store.mutable ? "append-only-no-eviction" : "readonly",
|
||
entries: entries.length,
|
||
bytes,
|
||
maxBytes: store.mutable ? config.maxCacheBytes : null,
|
||
atCapacity: store.mutable ? bytes >= config.maxCacheBytes : false,
|
||
persistent: true,
|
||
};
|
||
}
|
||
|
||
function shouldUseCesiumEgress(target) {
|
||
if (!config.mapEgress.url) return false;
|
||
try {
|
||
return config.cesiumEgressHosts.has(new URL(String(target)).hostname.toLowerCase());
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function throughCesiumEgress(target, options = {}) {
|
||
const upstreamUrl = new URL(String(target));
|
||
const proxyUrl = new URL("proxy/cesium/fetch", config.mapEgress.url);
|
||
proxyUrl.searchParams.set("url", upstreamUrl.toString());
|
||
const headers = new Headers(options.headers || {});
|
||
const authorization = headers.get("authorization");
|
||
const referer = headers.get("referer");
|
||
headers.delete("authorization");
|
||
headers.delete("referer");
|
||
headers.set("x-proxy-token", config.mapEgress.proxyToken);
|
||
if (authorization) headers.set("x-nodedc-cesium-authorization", authorization);
|
||
if (referer) headers.set("x-nodedc-map-referer", referer);
|
||
return { target: proxyUrl, options: { ...options, headers } };
|
||
}
|
||
|
||
async function fetchWithTimeout(target, options = {}, diagnostic = {}) {
|
||
const controller = new AbortController();
|
||
const timeout = setTimeout(() => controller.abort(), config.upstreamTimeoutMs);
|
||
const viaEgress = shouldUseCesiumEgress(target);
|
||
const provider = safeProviderName(target);
|
||
const operation = safeProviderOperation(diagnostic.operation);
|
||
const assetId = /^\d{1,20}$/.test(String(diagnostic.assetId || "")) ? String(diagnostic.assetId) : undefined;
|
||
const request = viaEgress
|
||
? throughCesiumEgress(target, options)
|
||
: { target, options };
|
||
const startedAt = Date.now();
|
||
try {
|
||
const response = await fetch(request.target, { ...request.options, signal: controller.signal, redirect: "follow" });
|
||
const egressError = safeEgressErrorCode(response.headers.get("x-nodedc-egress-error"));
|
||
// The restricted egress emits a short code instead of a target URL or a
|
||
// secret. Preserve it through Map Gateway so the operator can distinguish
|
||
// an AMD/VPN timeout from a real Cesium provider response.
|
||
if (egressError) {
|
||
await response.body?.cancel().catch(() => undefined);
|
||
throw gatewayError(`map_egress_${egressError}`, response.status || 502);
|
||
}
|
||
const durationMs = Date.now() - startedAt;
|
||
gatewayDiagnostics.recordUpstream({
|
||
viaEgress,
|
||
status: response.status,
|
||
durationMs,
|
||
error: "",
|
||
});
|
||
if (response.status >= 400) {
|
||
console.warn(JSON.stringify({
|
||
event: "map_provider_http_failure",
|
||
provider,
|
||
operation,
|
||
...(assetId ? { assetId } : {}),
|
||
status: response.status,
|
||
viaEgress,
|
||
durationMs,
|
||
}));
|
||
}
|
||
return response;
|
||
} catch (error) {
|
||
const errorCode = error?.message === "map_egress_amd_connector_timeout" || error?.message === "map_egress_amd_upstream_tls_timeout"
|
||
? error.message
|
||
: error?.name === "AbortError"
|
||
? "map_upstream_timeout"
|
||
: error?.message?.startsWith?.("map_egress_")
|
||
? error.message
|
||
: "map_upstream_fetch_failed";
|
||
const durationMs = Date.now() - startedAt;
|
||
gatewayDiagnostics.recordUpstream({
|
||
viaEgress,
|
||
status: Number(error?.statusCode || 502),
|
||
durationMs,
|
||
error: errorCode,
|
||
});
|
||
console.warn(JSON.stringify({
|
||
event: "map_provider_transport_failure",
|
||
provider,
|
||
operation,
|
||
...(assetId ? { assetId } : {}),
|
||
error: safeDiagnosticCode(errorCode),
|
||
viaEgress,
|
||
durationMs,
|
||
}));
|
||
throw gatewayError(errorCode, Number(error?.statusCode || 502));
|
||
}
|
||
finally { clearTimeout(timeout); }
|
||
}
|
||
|
||
function copyUpstreamHeaders(response, headers) {
|
||
// Node's fetch transparently decodes a compressed upstream body, but retains
|
||
// the upstream Content-Length header. Forwarding that encoded byte length
|
||
// alongside the decoded stream truncates JSON (notably Cesium layer.json)
|
||
// in the browser. Cached responses calculate their own exact length; live
|
||
// pass-through responses intentionally use HTTP chunking instead.
|
||
for (const name of ["content-type", "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;
|
||
}
|
||
|
||
function createGatewayDiagnostics(slowUpstreamMs) {
|
||
const metrics = {
|
||
cacheHits: 0,
|
||
cacheStaleHits: 0,
|
||
cacheMisses: 0,
|
||
cacheRefreshes: 0,
|
||
cachePassthroughs: 0,
|
||
cacheStaleFallbacks: 0,
|
||
upstreamRequests: 0,
|
||
egressRequests: 0,
|
||
upstreamFailures: 0,
|
||
slowUpstreamRequests: 0,
|
||
indexSnapshots: 0,
|
||
lastFailure: null,
|
||
lastFailureAt: null,
|
||
};
|
||
const recordFailure = (error) => {
|
||
if (!error) return;
|
||
metrics.lastFailure = safeDiagnosticCode(error);
|
||
metrics.lastFailureAt = new Date().toISOString();
|
||
};
|
||
return {
|
||
recordCache(state) {
|
||
if (state === "hit") metrics.cacheHits += 1;
|
||
else if (state === "staleHit") metrics.cacheStaleHits += 1;
|
||
else if (state === "refresh") metrics.cacheRefreshes += 1;
|
||
else if (state === "passthrough" || state === "readonlyPassThrough" || state === "rangeMiss" || state === "rangeRefresh") metrics.cachePassthroughs += 1;
|
||
else if (state === "staleFallback") metrics.cacheStaleFallbacks += 1;
|
||
else metrics.cacheMisses += 1;
|
||
},
|
||
recordUpstream({ viaEgress, status, durationMs, error }) {
|
||
metrics.upstreamRequests += 1;
|
||
if (viaEgress) metrics.egressRequests += 1;
|
||
if (durationMs >= slowUpstreamMs) metrics.slowUpstreamRequests += 1;
|
||
if (Number(status) >= 400 || error) {
|
||
metrics.upstreamFailures += 1;
|
||
recordFailure(error || `upstream_http_${status}`);
|
||
}
|
||
},
|
||
recordGatewayFailure(error) {
|
||
recordFailure(error);
|
||
},
|
||
recordIndexSnapshot() {
|
||
metrics.indexSnapshots += 1;
|
||
},
|
||
status() {
|
||
return { ...metrics };
|
||
},
|
||
};
|
||
}
|
||
|
||
function safeEgressErrorCode(value) {
|
||
const code = String(value || "").trim();
|
||
return /^amd_[a-z0-9_.:-]{1,100}$/.test(code) ? code : "";
|
||
}
|
||
|
||
function safeDiagnosticCode(value) {
|
||
return String(value || "map_gateway_error").replace(/[^A-Za-z0-9_.:-]/g, "_").slice(0, 120) || "map_gateway_error";
|
||
}
|
||
|
||
function safeProviderName(target) {
|
||
try {
|
||
const host = new URL(String(target)).hostname.toLowerCase();
|
||
if (host === "api.cesium.com") return "cesium-ion-api";
|
||
if (host === "assets.ion.cesium.com") return "cesium-ion-assets";
|
||
if (/(^|\.)virtualearth\.net$/.test(host)) return "bing-imagery";
|
||
return "allowlisted-provider";
|
||
} catch {
|
||
return "unknown-provider";
|
||
}
|
||
}
|
||
|
||
function safeProviderOperation(value) {
|
||
const operation = String(value || "resource").trim().toLowerCase();
|
||
return /^(?:resource|ion-endpoint|cache-fill|range|passthrough)$/.test(operation) ? operation : "resource";
|
||
}
|