feat(map): cache and search reference stations

This commit is contained in:
Codex
2026-07-25 15:37:53 +03:00
parent fe274872e4
commit 951b884c4b
10 changed files with 1108 additions and 2 deletions
+77
View File
@@ -7,6 +7,11 @@ import { Readable, Transform } from "node:stream";
import { pipeline } from "node:stream/promises";
import { fileURLToPath } from "node:url";
import {
MAP_REFERENCE_SEARCH_SCHEMA,
TRANSPORT_STATION_PROFILE_ID,
createReferenceStationSource,
} from "./reference-station-source.mjs";
import { loadZoneSourceProfile } from "./zone-source-snapshot.mjs";
const canonicalCesiumAssetIds = ["1", "2", "96188"];
@@ -32,6 +37,14 @@ const zoneSourceRoot = resolve(String(
// 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 referenceStationSource = await createReferenceStationSource({
seedFile: config.referenceStationSeedFile,
cacheDir: config.cacheDir,
fetchEnabled: config.referenceStationFetchEnabled,
overpassApiBase: config.referenceStationOverpassUrl,
cellDegrees: config.referenceStationCellDegrees,
timeoutMs: config.upstreamTimeoutMs,
});
const liveCache = createCacheStore("live", config.cacheDir, true);
const offlineSnapshot = config.offlineSnapshotDir ? createCacheStore("offline-snapshot", config.offlineSnapshotDir, false) : null;
const inflightWrites = new Map();
@@ -70,6 +83,15 @@ const server = createServer(async (request, response) => {
}
if (!isAllowedRequest(request)) return writeJson(response, 401, { ok: false, error: "map_gateway_auth_required" });
if (requestUrl.pathname === `/api/map/reference-sources/v1/profiles/${TRANSPORT_STATION_PROFILE_ID}/current`
&& ["GET", "HEAD"].includes(request.method || "")) {
return await serveReferenceStationSnapshot(request, response, requestUrl);
}
if (requestUrl.pathname === `/api/map/reference-sources/v1/profiles/${TRANSPORT_STATION_PROFILE_ID}/search`
&& ["GET", "HEAD"].includes(request.method || "")) {
return await serveReferenceStationSearch(request, response, requestUrl);
}
if (requestUrl.pathname === "/healthz" && request.method === "GET") {
return writeJson(response, 200, {
ok: true,
@@ -89,6 +111,9 @@ const server = createServer(async (request, response) => {
sourceZoneCount: zoneSource.generation.metadata.sourceZoneCount,
publishedZoneCount: zoneSource.generation.metadata.publishedZoneCount,
},
referenceSources: {
transportStations: await referenceStationSource.status(),
},
});
}
@@ -126,6 +151,7 @@ server.listen(config.port, "0.0.0.0", () => {
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)`);
console.log(`Map reference source: ${TRANSPORT_STATION_PROFILE_ID}`);
});
process.on("SIGTERM", () => server.close());
@@ -146,6 +172,50 @@ function serveZoneSourceGeneration(request, response) {
return response.end(request.method === "HEAD" ? undefined : zoneSource.body);
}
async function serveReferenceStationSnapshot(request, response, requestUrl) {
const rawBbox = requestUrl.searchParams.get("bbox");
const bbox = rawBbox ? rawBbox.split(",").map(Number) : undefined;
const snapshot = await referenceStationSource.snapshot({ bbox });
const etag = `"sha256:${snapshot.contentDigest}"`;
if (request.headers["if-none-match"] === etag) {
response.writeHead(304, { ETag: etag, "Cache-Control": "private, max-age=300" });
return response.end();
}
const body = Buffer.from(`${JSON.stringify(snapshot)}\n`);
response.writeHead(200, {
"Content-Type": "application/json; charset=utf-8",
"Content-Length": body.byteLength,
"Cache-Control": "private, max-age=300",
ETag: etag,
"X-Content-Type-Options": "nosniff",
});
return response.end(request.method === "HEAD" ? undefined : body);
}
async function serveReferenceStationSearch(request, response, requestUrl) {
const search = await referenceStationSource.search({
query: requestUrl.searchParams.get("q"),
limit: requestUrl.searchParams.get("limit"),
});
if (search.schemaVersion !== MAP_REFERENCE_SEARCH_SCHEMA) {
throw new Error("reference_station_search_contract_invalid");
}
const etag = `"sha256:${search.contentDigest}"`;
if (request.headers["if-none-match"] === etag) {
response.writeHead(304, { ETag: etag, "Cache-Control": "private, max-age=300" });
return response.end();
}
const body = Buffer.from(`${JSON.stringify(search)}\n`);
response.writeHead(200, {
"Content-Type": "application/json; charset=utf-8",
"Content-Length": body.byteLength,
"Cache-Control": "private, max-age=300",
ETag: etag,
"X-Content-Type-Options": "nosniff",
});
return response.end(request.method === "HEAD" ? undefined : 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");
@@ -187,6 +257,13 @@ async function readConfig() {
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 || "")),
referenceStationSeedFile: String(
process.env.MAP_REFERENCE_STATION_SEED_FILE
|| resolve(dirname(fileURLToPath(import.meta.url)), "../reference-sources/transport-stations/moscow-v1.json"),
).trim(),
referenceStationFetchEnabled: parseBoolean(process.env.MAP_REFERENCE_STATION_FETCH_ENABLED, true),
referenceStationCellDegrees: Number(process.env.MAP_REFERENCE_STATION_CELL_DEGREES || 0.5),
referenceStationOverpassUrl: String(process.env.MAP_REFERENCE_STATION_OVERPASS_URL || "https://overpass-api.de/api/interpreter").trim(),
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(),