feat(map): cache and search reference stations
This commit is contained in:
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user