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

View File

@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url)); const scriptDir = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(scriptDir, "../.."); const platformRoot = resolve(scriptDir, "../..");
const artifactDir = resolve(scriptDir, "../deploy-artifacts"); const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
const [patchId = "platform-map-gateway-20260714-001", ...extra] = process.argv.slice(2); const [patchId = "platform-map-gateway-20260714-001", ...extra] = process.argv.slice(2);
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) throw new Error("usage: build-map-gateway-artifact.mjs [patch-id]"); if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) throw new Error("usage: build-map-gateway-artifact.mjs [patch-id]");
@ -27,13 +27,32 @@ try {
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=platform\ntype=app-overlay\n`, "utf8"); await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=platform\ntype=app-overlay\n`, "utf8");
await writeFile(join(stage, "files.txt"), `${files.map(([, destination]) => destination).join("\n")}\n`, "utf8"); await writeFile(join(stage, "files.txt"), `${files.map(([, destination]) => destination).join("\n")}\n`, "utf8");
await mkdir(artifactDir, { recursive: true }); await mkdir(artifactDir, { recursive: true });
const tar = spawnSync("python3", ["-c", "import sys,tarfile\nwith tarfile.open(sys.argv[1],'w:gz',format=tarfile.PAX_FORMAT) as a:\n [a.add(n,arcname=n,recursive=True) for n in ('manifest.env','files.txt','payload')]", target], { cwd: stage, encoding: "utf8" }); const tar = spawnSync("python3", ["-c", canonicalTarScript(), target, stage], {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
});
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`); if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
console.log(JSON.stringify({ ok: true, patchId, artifact: target, sha256: createHash("sha256").update(await readFile(target)).digest("hex") }, null, 2)); console.log(JSON.stringify({ ok: true, patchId, artifact: target, sha256: createHash("sha256").update(await readFile(target)).digest("hex") }, null, 2));
} finally { } finally {
await rm(stage, { recursive: true, force: true }); await rm(stage, { recursive: true, force: true });
} }
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
async function copySafe(source, destination) { async function copySafe(source, destination) {
const info = await lstat(source); const info = await lstat(source);
if (info.isSymbolicLink()) throw new Error(`source_symlink_rejected:${source}`); if (info.isSymbolicLink()) throw new Error(`source_symlink_rejected:${source}`);

View File

@ -279,6 +279,9 @@ services:
MAP_GATEWAY_UPSTREAM_ALLOWLIST: ${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} MAP_GATEWAY_UPSTREAM_ALLOWLIST: ${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}
MAP_GATEWAY_LEGACY_CACHE_HOSTS: ${MAP_GATEWAY_LEGACY_CACHE_HOSTS:-} MAP_GATEWAY_LEGACY_CACHE_HOSTS: ${MAP_GATEWAY_LEGACY_CACHE_HOSTS:-}
MAP_GATEWAY_OFFLINE_PROVIDER_ALLOWLIST: ${MAP_GATEWAY_OFFLINE_PROVIDER_ALLOWLIST:-} MAP_GATEWAY_OFFLINE_PROVIDER_ALLOWLIST: ${MAP_GATEWAY_OFFLINE_PROVIDER_ALLOWLIST:-}
MAP_REFERENCE_STATION_FETCH_ENABLED: ${MAP_REFERENCE_STATION_FETCH_ENABLED:-true}
MAP_REFERENCE_STATION_CELL_DEGREES: ${MAP_REFERENCE_STATION_CELL_DEGREES:-0.5}
MAP_REFERENCE_STATION_OVERPASS_URL: ${MAP_REFERENCE_STATION_OVERPASS_URL:-https://overpass-api.de/api/interpreter}
volumes: volumes:
- type: bind - type: bind
source: ${MAP_LIVE_CACHE_HOST_DIR:-/volume1/docker/nodedc-platform/map-gateway/live-tile-cache} source: ${MAP_LIVE_CACHE_HOST_DIR:-/volume1/docker/nodedc-platform/map-gateway/live-tile-cache}

View File

@ -11,6 +11,7 @@ RUN apk add --no-cache su-exec
COPY package.json ./ COPY package.json ./
COPY src ./src COPY src ./src
COPY zone-sources ./zone-sources COPY zone-sources ./zone-sources
COPY reference-sources ./reference-sources
COPY runtime-entrypoint.mjs /app/runtime-entrypoint.mjs COPY runtime-entrypoint.mjs /app/runtime-entrypoint.mjs
EXPOSE 18103 EXPOSE 18103

View File

@ -13,6 +13,10 @@
- защищает proxy allowlist-ом upstream hosts, HTTPS-only правилом, лимитами размера и token-free cache key; - защищает proxy allowlist-ом upstream hosts, HTTPS-only правилом, лимитами размера и token-free cache key;
- ведёт лёгкий persistent cache index и health/stats без автоматического удаления уже записанных tiles. - ведёт лёгкий persistent cache index и health/stats без автоматического удаления уже записанных tiles.
- отдаёт Engine отдельный provider-neutral generation геозон из проверенного профиля: текущий backend — версионированный snapshot Дептранса, будущий API-адаптер обязан материализовать тот же envelope и не меняет L2-потребителя. - отдаёт Engine отдельный provider-neutral generation геозон из проверенного профиля: текущий backend — версионированный snapshot Дептранса, будущий API-адаптер обязан материализовать тот же envelope и не меняет L2-потребителя.
- отдаёт Foundry provider-neutral справочный snapshot транспортных станций:
seed Москвы из проверенного legacy MMAP donor плюс bounded spatial
cache OSM для новых viewport. L1/L2 и пользовательские Data Product в этом
контуре не участвуют.
## API ## API
@ -20,8 +24,44 @@
GET /healthz GET /healthz
GET /api/map/ion/assets/:assetId/endpoint GET /api/map/ion/assets/:assetId/endpoint
GET|HEAD /api/map/cache?url=<encoded-upstream-url> GET|HEAD /api/map/cache?url=<encoded-upstream-url>
GET /api/map/reference-sources/v1/profiles/transport-stations.v1/current
GET /api/map/reference-sources/v1/profiles/transport-stations.v1/current?bbox=west,south,east,north
GET /api/map/reference-sources/v1/profiles/transport-stations.v1/search?q=<exact-name>&limit=12
``` ```
Reference-station route возвращает `nodedc.map-reference.snapshot/v1` и только
два онтологических типа: `map.station` и `map.terminal`. Категории v1 —
`metro`, `railway_station`, `railway_terminal`. Проекция allowlist-ит имена,
operator/network, `uic_ref`, wheelchair и геометрию; raw OSM tags, upstream
endpoint и provider credentials в snapshot отсутствуют.
Seed проверяется на старте и покрывает Москву без сетевого запроса. Для bbox за
пределами покрытия Gateway делит область на фиксированные spatial cells,
выполняет не более двух OSM-запросов одновременно, разносит старты запросов
минимум на 750 мс, дедуплицирует одинаковые
inflight cells и атомарно пишет нормализованный cell snapshot в
`${MAP_CACHE_DIR}/reference-features`. Warm viewport читается из этого
persistent cache. `name:ru` имеет приоритет перед общим `name`. Неудачный
upstream не повреждает уже сохранённые cells; `complete=false` сообщает
вызывающей стороне о частичном результате. `/healthz` возвращает только
безопасный код последней ошибки, её время, counters и глубину очереди — без
upstream endpoint или payload.
Поиск вне уже загруженного viewport вызывается только явным Enter пользователя.
Gateway сначала ищет в seed и persistent search index, затем при miss выполняет
точный OSM name lookup. Upstream selector использует глобально индексированные
`name`/`name:ru`, а найденные элементы повторно проходят fail-closed проверку
`railway=station|halt`. Это не autocomplete и не глобальный regex scan.
Нормализованный результат атомарно пополняет persistent search index; после
перелёта обычный bbox-контур загружает полную spatial cell. Одинаковые запросы
дедуплицируются и кэшируются, а `/healthz` отдельно показывает безопасные
search counters.
Граница seed округляется наружу до границ этих spatial cells. Поэтому viewport
внутри московского покрытия всегда отвечает синхронно из packaged snapshot и
не ожидает OSM только из-за того, что содержащая его ячейка выходит за крайнюю
координату одной из seed-точек.
Внутренний route `GET|HEAD /internal/zone-sources/v1/profiles/moscow-pmd-slow-zones/current` доступен сервисам в private `engine` network и намеренно не публикуется через Caddy. На старте Gateway проверяет manifest, SHA-256 обоих исходных JSON, геометрию, замкнутость колец, уникальность identity, расписание и его связи с зонами; повреждённый или неполный generation останавливает запуск. Сейчас profile упакован в image как immutable MMap snapshot. Для будущего Дептранс API меняется producer профиля (`depttrans-api-snapshot-v1`), но endpoint, stable source IDs и `nodedc.zone-source-generation/v2` остаются прежними. Внутренний route `GET|HEAD /internal/zone-sources/v1/profiles/moscow-pmd-slow-zones/current` доступен сервисам в private `engine` network и намеренно не публикуется через Caddy. На старте Gateway проверяет manifest, SHA-256 обоих исходных JSON, геометрию, замкнутость колец, уникальность identity, расписание и его связи с зонами; повреждённый или неполный generation останавливает запуск. Сейчас profile упакован в image как immutable MMap snapshot. Для будущего Дептранс API меняется producer профиля (`depttrans-api-snapshot-v1`), но endpoint, stable source IDs и `nodedc.zone-source-generation/v2` остаются прежними.
Внутренний admin route `GET|PUT /api/map/admin/cesium-ion` не является Browser API: он доступен только из Foundry по HMAC. Ключ подписи создаётся и хранится root-owned deploy runner в `/volume1/docker/nodedc-platform/secrets/map-gateway-admin-secret`, а оба сервиса получают только read-only file mount. Он не является `.env`-значением, настройкой Foundry или Cesium credential. Перед записью `PUT` проверяет candidate token через Cesium asset endpoints `1` (terrain), `2` (imagery) и `96188` (3D Buildings). Не прошедшее проверку значение не заменяет рабочее. Успешный token записывается атомарно в `${MAP_CACHE_DIR}/secrets/cesium-ion-token` с mode `0600`; ответ содержит только факт конфигурации, safe verification state и audit metadata — никогда само значение. Внутренний admin route `GET|PUT /api/map/admin/cesium-ion` не является Browser API: он доступен только из Foundry по HMAC. Ключ подписи создаётся и хранится root-owned deploy runner в `/volume1/docker/nodedc-platform/secrets/map-gateway-admin-secret`, а оба сервиса получают только read-only file mount. Он не является `.env`-значением, настройкой Foundry или Cesium credential. Перед записью `PUT` проверяет candidate token через Cesium asset endpoints `1` (terrain), `2` (imagery) и `96188` (3D Buildings). Не прошедшее проверку значение не заменяет рабочее. Успешный token записывается атомарно в `${MAP_CACHE_DIR}/secrets/cesium-ion-token` с mode `0600`; ответ содержит только факт конфигурации, safe verification state и audit metadata — никогда само значение.
@ -90,6 +130,8 @@ MAP_GATEWAY_LEGACY_CACHE_HOSTS=ecn.t0.tiles.virtualearth.net,ecn.t1.tiles.virtua
MAP_GATEWAY_OFFLINE_PROVIDER_ALLOWLIST= MAP_GATEWAY_OFFLINE_PROVIDER_ALLOWLIST=
MAP_CACHE_DIR=/var/lib/nodedc-map-cache MAP_CACHE_DIR=/var/lib/nodedc-map-cache
MAP_CACHE_MODE=readwrite MAP_CACHE_MODE=readwrite
MAP_REFERENCE_STATION_FETCH_ENABLED=true
MAP_REFERENCE_STATION_CELL_DEGREES=0.5
``` ```
# Cache topology # Cache topology

View File

@ -12,6 +12,7 @@
"test:streaming-cache-fill": "node scripts/smoke-streaming-cache-fill.mjs", "test:streaming-cache-fill": "node scripts/smoke-streaming-cache-fill.mjs",
"test:zone-source": "node --test test/zone-source-snapshot.test.mjs", "test:zone-source": "node --test test/zone-source-snapshot.test.mjs",
"test:zone-source-route": "node scripts/smoke-zone-source.mjs", "test:zone-source-route": "node scripts/smoke-zone-source.mjs",
"test:reference-stations": "node --test test/reference-station-source.test.mjs",
"audit:engine-cache": "node scripts/audit-engine-cache.mjs", "audit:engine-cache": "node scripts/audit-engine-cache.mjs",
"import:engine-cache": "node scripts/import-engine-cache.mjs" "import:engine-cache": "node scripts/import-engine-cache.mjs"
}, },

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,110 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
const [inputPath, outputPath] = process.argv.slice(2);
if (!inputPath || !outputPath) {
console.error("usage: generate-reference-station-seed.mjs <input.geojson> <output.json>");
process.exit(2);
}
const source = JSON.parse(await readFile(resolve(inputPath), "utf8"));
if (source?.type !== "FeatureCollection" || !Array.isArray(source.features)) {
throw new Error("station_seed_feature_collection_required");
}
const generatedAt = isIso(source.timestamp) ? new Date(source.timestamp).toISOString() : new Date(0).toISOString();
const facts = source.features.map((feature, index) => normalizeFeature(feature, generatedAt, index));
facts.sort((left, right) => left.sourceId.localeCompare(right.sourceId));
const contentDigest = createHash("sha256")
.update(JSON.stringify(facts))
.digest("hex");
const counts = Object.fromEntries(["metro", "railway_station", "railway_terminal"].map((category) => [
category,
facts.filter((fact) => fact.attributes.category === category).length,
]));
const document = {
schemaVersion: "nodedc.map-reference-seed/v1",
profileId: "transport-stations.v1",
sourceRevision: `seed-${contentDigest.slice(0, 16)}`,
generatedAt,
contentDigest,
complete: true,
facts,
metadata: {
factCount: facts.length,
categoryCounts: counts,
},
};
await mkdir(dirname(resolve(outputPath)), { recursive: true });
await writeFile(resolve(outputPath), `${JSON.stringify(document)}\n`, "utf8");
console.log(JSON.stringify({ output: resolve(outputPath), ...document.metadata, contentDigest }));
function normalizeFeature(feature, generatedAt, index) {
if (feature?.type !== "Feature" || feature?.geometry?.type !== "Point") {
throw new Error(`station_seed_feature_${index}_invalid`);
}
const coordinates = feature.geometry.coordinates;
if (!Array.isArray(coordinates) || coordinates.length < 2) {
throw new Error(`station_seed_feature_${index}_coordinates_invalid`);
}
const longitude = Number(coordinates[0]);
const latitude = Number(coordinates[1]);
if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180
|| !Number.isFinite(latitude) || latitude < -90 || latitude > 90) {
throw new Error(`station_seed_feature_${index}_coordinates_invalid`);
}
const properties = feature.properties && typeof feature.properties === "object" ? feature.properties : {};
const category = normalizeCategory(properties.sort);
const nativeId = String(feature.id || properties["@id"] || "").trim();
const identity = nativeId.match(/^(node|way|relation)\/([1-9]\d*)$/);
if (!identity) throw new Error(`station_seed_feature_${index}_identity_invalid`);
const attributes = compact({
name: firstString(properties.name, properties["name:ru"], properties.official_name, properties.loc_name),
category,
network: optionalString(properties.network),
operator: optionalString(properties.operator),
official_name: optionalString(properties.official_name),
local_name: optionalString(properties.loc_name),
uic_ref: optionalString(properties.uic_ref),
wheelchair: optionalString(properties.wheelchair),
});
if (!attributes.name) throw new Error(`station_seed_feature_${index}_name_required`);
return {
sourceId: `osm.${identity[1]}.${identity[2]}`,
semanticType: category === "railway_terminal" ? "map.terminal" : "map.station",
observedAt: generatedAt,
receivedAt: generatedAt,
attributes,
geometry: { type: "Point", coordinates: [longitude, latitude] },
presentationStatus: "active",
};
}
function normalizeCategory(value) {
if (value === "metro") return "metro";
if (value === "terminal") return "railway_terminal";
if (value === "rzd_station") return "railway_station";
throw new Error("station_seed_category_invalid");
}
function firstString(...values) {
return values.map(optionalString).find(Boolean);
}
function optionalString(value) {
if (typeof value !== "string") return undefined;
const normalized = value.trim();
return normalized && normalized.length <= 256 ? normalized : undefined;
}
function compact(value) {
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
}
function isIso(value) {
return typeof value === "string" && !Number.isNaN(Date.parse(value));
}

View File

@ -0,0 +1,648 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
import { join, resolve, sep } from "node:path";
export const MAP_REFERENCE_SEED_SCHEMA = "nodedc.map-reference-seed/v1";
export const MAP_REFERENCE_SNAPSHOT_SCHEMA = "nodedc.map-reference.snapshot/v1";
export const MAP_REFERENCE_SEARCH_SCHEMA = "nodedc.map-reference.search/v1";
export const TRANSPORT_STATION_PROFILE_ID = "transport-stations.v1";
const FACT_ID = /^osm\.(node|way|relation)\.([1-9]\d*)$/;
const CATEGORIES = new Set(["metro", "railway_station", "railway_terminal"]);
const SEMANTIC_TYPES = new Set(["map.station", "map.terminal"]);
const MAX_FACTS = 100_000;
const MAX_CELLS_PER_REQUEST = 64;
const MAX_CELL_BYTES = 8 * 1024 * 1024;
const MAX_CONCURRENT_UPSTREAM_FETCHES = 2;
const MIN_UPSTREAM_START_INTERVAL_MS = 750;
const MAX_SEARCH_RESULTS = 32;
const SEARCH_CACHE_TTL_MS = 5 * 60 * 1000;
export async function createReferenceStationSource({
seedFile,
cacheDir,
fetchEnabled = true,
overpassApiBase = "https://overpass-api.de/api/interpreter",
cellDegrees = 0.25,
timeoutMs = 30_000,
fetchImpl = fetch,
}) {
const seed = validateSeed(JSON.parse(await readFile(resolve(seedFile), "utf8")));
const cellRoot = resolve(cacheDir, "reference-features", TRANSPORT_STATION_PROFILE_ID);
const searchIndexFile = resolve(cacheDir, "reference-features", `${TRANSPORT_STATION_PROFILE_ID}.search.json`);
await mkdir(cellRoot, { recursive: true, mode: 0o750 });
const endpoint = normalizeOverpassEndpoint(overpassApiBase);
const normalizedCellDegrees = finite(cellDegrees, 0.05, 5, "reference_station_cell_degrees_invalid");
const seedCoverage = factCoverage(seed.facts, normalizedCellDegrees);
const normalizedTimeoutMs = finite(timeoutMs, 1_000, 120_000, "reference_station_timeout_invalid");
const inflightCells = new Map();
const inflightSearches = new Map();
const recentSearches = new Map();
const indexedFacts = new Map(seed.facts.map((fact) => [fact.sourceId, fact]));
for (const fact of await readSearchIndex(searchIndexFile)) indexedFacts.set(fact.sourceId, fact);
const pendingFetches = [];
let activeFetches = 0;
let upstreamRequests = 0;
let upstreamFailures = 0;
let lastRefreshAt = null;
let lastFailure = null;
let lastFailureAt = null;
let searchRequests = 0;
let searchFailures = 0;
let upstreamStartQueue = Promise.resolve();
let nextUpstreamStartAt = 0;
async function snapshot({ bbox } = {}) {
const normalizedBbox = bbox ? normalizeBbox(bbox) : null;
let cachedFacts = [];
let requestedCells = [];
let complete = true;
if (normalizedBbox) {
requestedCells = cellsForBbox(normalizedBbox, normalizedCellDegrees);
if (requestedCells.length > MAX_CELLS_PER_REQUEST) {
requestedCells = [];
complete = false;
} else {
const results = await Promise.all(requestedCells.map(async (cell) => {
if (cellInsideCoverage(cell, seedCoverage)) return { facts: [] };
const cached = await readCell(cellRoot, cell.key);
if (cached) {
indexFacts(cached.facts);
return cached;
}
if (!fetchEnabled) return null;
return enqueueFetch(cell);
}));
cachedFacts = results.flatMap((result) => result?.facts ?? []);
if (results.some((result) => !result)) complete = false;
}
}
const seedFacts = normalizedBbox ? seed.facts.filter((fact) => factInside(fact, normalizedBbox)) : seed.facts;
const facts = deduplicateFacts([...seedFacts, ...cachedFacts])
.filter((fact) => !normalizedBbox || factInside(fact, normalizedBbox));
const generatedAt = new Date().toISOString();
const contentDigest = sha256(JSON.stringify(facts));
const categoryCounts = Object.fromEntries([...CATEGORIES].map((category) => [
category,
facts.filter((fact) => fact.attributes.category === category).length,
]));
return {
schemaVersion: MAP_REFERENCE_SNAPSHOT_SCHEMA,
profileId: TRANSPORT_STATION_PROFILE_ID,
sourceRevision: contentDigest,
generatedAt,
complete,
contentDigest,
facts,
metadata: {
factCount: facts.length,
categoryCounts,
requestedCellCount: requestedCells.length,
seedRevision: seed.sourceRevision,
},
};
}
async function search({ query, limit = 12 } = {}) {
const displayQuery = normalizeDisplaySearchQuery(query);
const normalizedQuery = normalizeSearchQuery(displayQuery);
const normalizedLimit = Math.max(1, Math.min(MAX_SEARCH_RESULTS, Number(limit) || 12));
const localFacts = searchIndexedFacts(indexedFacts.values(), normalizedQuery, normalizedLimit);
let upstreamFacts = [];
let complete = !fetchEnabled;
if (fetchEnabled && normalizedQuery.length >= 3) {
const cachedSearch = recentSearches.get(normalizedQuery);
if (cachedSearch && Date.now() - cachedSearch.storedAt < SEARCH_CACHE_TTL_MS) {
upstreamFacts = cachedSearch.facts;
complete = true;
} else {
const inflight = inflightSearches.get(normalizedQuery);
const task = inflight ?? scheduleFetch(async () => {
searchRequests += 1;
upstreamRequests += 1;
try {
const facts = await fetchSearch(endpoint, displayQuery, normalizedLimit, normalizedTimeoutMs, fetchImpl);
indexFacts(facts);
await writeSearchIndex(searchIndexFile, [...indexedFacts.values()]);
recentSearches.set(normalizedQuery, { storedAt: Date.now(), facts });
lastRefreshAt = new Date().toISOString();
return { facts, complete: true };
} catch (error) {
searchFailures += 1;
upstreamFailures += 1;
lastFailure = safeUpstreamFailure(error);
lastFailureAt = new Date().toISOString();
return { facts: [], complete: false };
}
});
if (!inflight) {
inflightSearches.set(normalizedQuery, task);
void task.finally(() => inflightSearches.delete(normalizedQuery));
}
const result = await task;
upstreamFacts = result.facts;
complete = result.complete;
}
}
const facts = searchIndexedFacts(
deduplicateFacts([...localFacts, ...upstreamFacts]),
normalizedQuery,
normalizedLimit,
);
const generatedAt = new Date().toISOString();
const contentDigest = sha256(JSON.stringify(facts));
return {
schemaVersion: MAP_REFERENCE_SEARCH_SCHEMA,
profileId: TRANSPORT_STATION_PROFILE_ID,
sourceRevision: contentDigest,
generatedAt,
complete,
query: displayQuery,
contentDigest,
facts,
metadata: {
factCount: facts.length,
localMatchCount: localFacts.length,
},
};
}
function indexFacts(facts) {
for (const fact of facts) indexedFacts.set(fact.sourceId, fact);
}
function enqueueFetch(cell) {
const inflight = inflightCells.get(cell.key);
if (inflight) return inflight;
const task = scheduleFetch(async () => {
upstreamRequests += 1;
try {
const document = await fetchCell(endpoint, cell, normalizedTimeoutMs, fetchImpl);
await writeCell(cellRoot, cell.key, document);
lastRefreshAt = document.generatedAt;
return document;
} catch (error) {
upstreamFailures += 1;
lastFailure = safeUpstreamFailure(error);
lastFailureAt = new Date().toISOString();
return null;
}
});
inflightCells.set(cell.key, task);
void task.finally(() => inflightCells.delete(cell.key));
return task;
}
function scheduleFetch(run) {
return new Promise((resolveTask) => {
pendingFetches.push({ run, resolveTask });
pumpFetchQueue();
});
}
function pumpFetchQueue() {
while (activeFetches < MAX_CONCURRENT_UPSTREAM_FETCHES && pendingFetches.length) {
const next = pendingFetches.shift();
activeFetches += 1;
void Promise.resolve()
.then(waitForUpstreamStart)
.then(next.run)
.then(next.resolveTask)
.finally(() => {
activeFetches -= 1;
pumpFetchQueue();
});
}
}
function waitForUpstreamStart() {
const turn = upstreamStartQueue.then(async () => {
const waitMs = Math.max(0, nextUpstreamStartAt - Date.now());
if (waitMs) await new Promise((resolveDelay) => setTimeout(resolveDelay, waitMs));
nextUpstreamStartAt = Date.now() + MIN_UPSTREAM_START_INTERVAL_MS;
});
upstreamStartQueue = turn.catch(() => undefined);
return turn;
}
async function status() {
let cachedCellCount = 0;
try {
cachedCellCount = (await readdir(cellRoot)).filter((name) => name.endsWith(".json")).length;
} catch {
cachedCellCount = 0;
}
return {
profileId: TRANSPORT_STATION_PROFILE_ID,
seedRevision: seed.sourceRevision,
seedFactCount: seed.facts.length,
fetchEnabled,
cellDegrees: normalizedCellDegrees,
cachedCellCount,
upstreamRequests,
upstreamFailures,
searchRequests,
searchFailures,
upstreamState: upstreamFailures > 0 && (!lastRefreshAt || Date.parse(lastFailureAt) > Date.parse(lastRefreshAt))
? "degraded"
: (lastRefreshAt ? "ready" : "idle"),
activeFetches,
queuedFetches: pendingFetches.length,
lastRefreshAt,
lastFailure,
lastFailureAt,
};
}
return Object.freeze({ search, snapshot, status });
}
function validateSeed(value) {
if (!isObject(value) || value.schemaVersion !== MAP_REFERENCE_SEED_SCHEMA
|| value.profileId !== TRANSPORT_STATION_PROFILE_ID || !Array.isArray(value.facts)
|| !value.facts.length || value.facts.length > MAX_FACTS) {
throw sourceError("reference_station_seed_invalid");
}
const facts = value.facts.map(validateFact);
if (sha256(JSON.stringify(facts)) !== value.contentDigest) {
throw sourceError("reference_station_seed_digest_mismatch");
}
return Object.freeze({ ...value, facts: Object.freeze(facts) });
}
function validateFact(value) {
if (!isObject(value) || !FACT_ID.test(String(value.sourceId || ""))
|| !SEMANTIC_TYPES.has(value.semanticType) || !isIso(value.observedAt)
|| !isIso(value.receivedAt) || value.presentationStatus !== "active"
|| !isObject(value.attributes) || !CATEGORIES.has(value.attributes.category)) {
throw sourceError("reference_station_fact_invalid");
}
const coordinates = value.geometry?.type === "Point" ? value.geometry.coordinates : null;
if (!Array.isArray(coordinates) || coordinates.length !== 2
|| !Number.isFinite(coordinates[0]) || coordinates[0] < -180 || coordinates[0] > 180
|| !Number.isFinite(coordinates[1]) || coordinates[1] < -90 || coordinates[1] > 90) {
throw sourceError("reference_station_geometry_invalid");
}
const allowedAttributes = new Set(["name", "category", "network", "operator", "official_name", "local_name", "uic_ref", "wheelchair"]);
if (Object.keys(value.attributes).some((key) => !allowedAttributes.has(key))) {
throw sourceError("reference_station_attribute_not_allowed");
}
if (value.semanticType === "map.terminal" !== (value.attributes.category === "railway_terminal")) {
throw sourceError("reference_station_semantic_type_mismatch");
}
return Object.freeze(structuredClone(value));
}
async function fetchCell(endpoint, cell, timeoutMs, fetchImpl) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const query = `[out:json][timeout:25];(nwr["railway"="station"](${cell.south},${cell.west},${cell.north},${cell.east});nwr["railway"="halt"](${cell.south},${cell.west},${cell.north},${cell.east}););out center tags;`;
const response = await fetchImpl(endpoint, {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded;charset=UTF-8",
"user-agent": "NODE.DC-Map-Gateway/1.0 (+https://node-dc.ru)",
},
body: new URLSearchParams({ data: query }).toString(),
signal: controller.signal,
redirect: "error",
});
if (!response.ok) throw sourceError(`reference_station_upstream_http_${response.status}`);
const raw = await response.text();
if (Buffer.byteLength(raw) > MAX_CELL_BYTES) throw sourceError("reference_station_upstream_bytes_exceeded");
const payload = JSON.parse(raw);
if (!Array.isArray(payload?.elements)) throw sourceError("reference_station_upstream_invalid");
const observedAt = new Date().toISOString();
const facts = payload.elements.flatMap((element) => {
try {
return [normalizeOverpassElement(element, observedAt)];
} catch {
return [];
}
});
return {
schemaVersion: "nodedc.map-reference-cell/v1",
key: cell.key,
generatedAt: observedAt,
facts: deduplicateFacts(facts),
};
} finally {
clearTimeout(timeout);
}
}
async function fetchSearch(endpoint, query, limit, timeoutMs, fetchImpl) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const expression = escapeOverpassString(query);
const resultLimit = Math.max(limit, Math.min(96, limit * 3));
// Exact OSM name keys are globally indexed by Overpass. Filtering the
// whole planet by railway first is several orders of magnitude slower and
// routinely times out; station semantics are therefore verified locally
// by the same fail-closed normalizer used by viewport cells.
const overpassQuery = `[out:json][timeout:25];(nwr["name"="${expression}"];nwr["name:ru"="${expression}"];);out center tags ${resultLimit};`;
const response = await fetchImpl(endpoint, {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded;charset=UTF-8",
"user-agent": "NODE.DC-Map-Gateway/1.0 (+https://node-dc.ru)",
},
body: new URLSearchParams({ data: overpassQuery }).toString(),
signal: controller.signal,
redirect: "error",
});
if (!response.ok) throw sourceError(`reference_station_upstream_http_${response.status}`);
const raw = await response.text();
if (Buffer.byteLength(raw) > MAX_CELL_BYTES) throw sourceError("reference_station_upstream_bytes_exceeded");
const payload = JSON.parse(raw);
if (!Array.isArray(payload?.elements)) throw sourceError("reference_station_upstream_invalid");
const observedAt = new Date().toISOString();
return deduplicateFacts(payload.elements.flatMap((element) => {
try {
return [normalizeOverpassElement(element, observedAt)];
} catch {
return [];
}
}));
} finally {
clearTimeout(timeout);
}
}
function normalizeOverpassElement(value, observedAt) {
if (!isObject(value) || !new Set(["node", "way", "relation"]).has(value.type)
|| !Number.isSafeInteger(value.id) || value.id <= 0 || !isObject(value.tags)) {
throw sourceError("reference_station_upstream_element_invalid");
}
if (!new Set(["station", "halt"]).has(value.tags.railway)) {
throw sourceError("reference_station_upstream_not_station");
}
const longitude = Number(value.lon ?? value.center?.lon);
const latitude = Number(value.lat ?? value.center?.lat);
if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180
|| !Number.isFinite(latitude) || latitude < -90 || latitude > 90) {
throw sourceError("reference_station_upstream_position_invalid");
}
const name = firstString(value.tags["name:ru"], value.tags.name, value.tags.official_name, value.tags.loc_name);
if (!name) throw sourceError("reference_station_upstream_name_required");
const category = classifyStation(value.tags, name);
return validateFact({
sourceId: `osm.${value.type}.${value.id}`,
semanticType: category === "railway_terminal" ? "map.terminal" : "map.station",
observedAt,
receivedAt: observedAt,
attributes: compact({
name,
category,
network: optionalString(value.tags.network),
operator: optionalString(value.tags.operator),
official_name: optionalString(value.tags.official_name),
local_name: optionalString(value.tags.loc_name),
uic_ref: optionalString(value.tags.uic_ref),
wheelchair: optionalString(value.tags.wheelchair),
}),
geometry: { type: "Point", coordinates: [longitude, latitude] },
presentationStatus: "active",
});
}
function classifyStation(tags, name) {
if (tags.station === "subway" || tags.subway === "yes") return "metro";
const terminalText = `${name} ${tags.loc_name || ""} ${tags.official_name || ""}`.toLocaleLowerCase("ru");
if (/(?:^|\s)(вокзал|hauptbahnhof|central station|railway station)(?:\s|$)/u.test(terminalText)) {
return "railway_terminal";
}
return "railway_station";
}
function cellsForBbox(bbox, cellDegrees) {
const cells = [];
const minX = Math.floor(bbox.west / cellDegrees);
const maxX = Math.ceil(bbox.east / cellDegrees) - 1;
const minY = Math.floor(bbox.south / cellDegrees);
const maxY = Math.ceil(bbox.north / cellDegrees) - 1;
for (let x = minX; x <= maxX; x += 1) {
for (let y = minY; y <= maxY; y += 1) {
const west = x * cellDegrees;
const south = y * cellDegrees;
cells.push({
key: `${x}_${y}`,
west,
south,
east: Math.min(180, west + cellDegrees),
north: Math.min(90, south + cellDegrees),
});
}
}
return cells;
}
function factCoverage(facts, cellDegrees) {
const longitudes = facts.map((fact) => fact.geometry.coordinates[0]);
const latitudes = facts.map((fact) => fact.geometry.coordinates[1]);
return {
west: Math.floor(Math.min(...longitudes) / cellDegrees) * cellDegrees,
south: Math.floor(Math.min(...latitudes) / cellDegrees) * cellDegrees,
east: Math.ceil(Math.max(...longitudes) / cellDegrees) * cellDegrees,
north: Math.ceil(Math.max(...latitudes) / cellDegrees) * cellDegrees,
};
}
function cellInsideCoverage(cell, coverage) {
return cell.west >= coverage.west && cell.east <= coverage.east
&& cell.south >= coverage.south && cell.north <= coverage.north;
}
function normalizeBbox(value) {
const [west, south, east, north] = Array.isArray(value) ? value.map(Number) : [];
if (![west, south, east, north].every(Number.isFinite)
|| west < -180 || west >= east || east > 180
|| south < -90 || south >= north || north > 90) {
throw sourceError("reference_station_bbox_invalid");
}
return { west, south, east, north };
}
function factInside(fact, bbox) {
const [longitude, latitude] = fact.geometry.coordinates;
return longitude >= bbox.west && longitude <= bbox.east
&& latitude >= bbox.south && latitude <= bbox.north;
}
function deduplicateFacts(values) {
return [...new Map(values.map((fact) => [fact.sourceId, validateFact(fact)])).values()]
.sort((left, right) => left.sourceId.localeCompare(right.sourceId));
}
function searchIndexedFacts(values, query, limit) {
return [...values]
.flatMap((fact) => {
const rank = bestSearchRank([
fact.sourceId,
fact.attributes.name,
fact.attributes.official_name,
fact.attributes.local_name,
fact.attributes.uic_ref,
], query);
return rank === null ? [] : [{ fact, rank }];
})
.sort((left, right) => (
left.rank - right.rank
|| left.fact.attributes.name.localeCompare(right.fact.attributes.name, "ru")
|| left.fact.sourceId.localeCompare(right.fact.sourceId)
))
.slice(0, limit)
.map(({ fact }) => fact);
}
function bestSearchRank(values, query) {
let best = null;
for (const value of values) {
const normalized = normalizeSearchValue(value);
if (!normalized) continue;
let rank = null;
if (normalized === query) rank = 0;
else if (normalized.startsWith(query)) rank = 1;
else if (normalized.split(/\s+/u).some((token) => token.startsWith(query))) rank = 2;
else if (normalized.includes(query)) rank = 3;
if (rank !== null && (best === null || rank < best)) best = rank;
}
return best;
}
function normalizeSearchQuery(value) {
const normalized = normalizeSearchValue(value);
if (normalized.length < 2 || normalized.length > 96 || /[\u0000-\u001f\u007f]/u.test(normalized)) {
throw sourceError("reference_station_search_query_invalid");
}
return normalized;
}
function normalizeDisplaySearchQuery(value) {
const normalized = String(value ?? "").normalize("NFKC").trim().replace(/\s+/gu, " ");
if (normalized.length < 2 || normalized.length > 96 || /[\u0000-\u001f\u007f]/u.test(normalized)) {
throw sourceError("reference_station_search_query_invalid");
}
return normalized;
}
function normalizeSearchValue(value) {
return String(value ?? "")
.normalize("NFKC")
.trim()
.toLocaleLowerCase("ru")
.replace(/\s+/gu, " ");
}
function escapeOverpassString(value) {
return String(value).replace(/[\\"]/gu, "\\$&");
}
async function readCell(root, key) {
try {
const value = JSON.parse(await readFile(withinRoot(root, `${key}.json`), "utf8"));
if (value?.schemaVersion !== "nodedc.map-reference-cell/v1" || value.key !== key
|| !isIso(value.generatedAt) || !Array.isArray(value.facts) || value.facts.length > MAX_FACTS) return null;
return { ...value, facts: value.facts.map(validateFact) };
} catch {
return null;
}
}
async function readSearchIndex(file) {
try {
const value = JSON.parse(await readFile(file, "utf8"));
if (value?.schemaVersion !== "nodedc.map-reference-search-index/v1"
|| !Array.isArray(value.facts) || value.facts.length > MAX_FACTS) return [];
return value.facts.map(validateFact);
} catch {
return [];
}
}
async function writeSearchIndex(file, facts) {
const normalizedFacts = deduplicateFacts(facts).slice(-MAX_FACTS);
const document = {
schemaVersion: "nodedc.map-reference-search-index/v1",
generatedAt: new Date().toISOString(),
facts: normalizedFacts,
};
const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
await writeFile(temp, `${JSON.stringify(document)}\n`, { encoding: "utf8", mode: 0o640 });
await rename(temp, file);
}
async function writeCell(root, key, document) {
const target = withinRoot(root, `${key}.json`);
const temp = withinRoot(root, `${key}.${process.pid}.${Date.now()}.tmp`);
await writeFile(temp, `${JSON.stringify(document)}\n`, { encoding: "utf8", mode: 0o640 });
await rename(temp, target);
}
function normalizeOverpassEndpoint(value) {
let endpoint;
try {
endpoint = new URL(String(value || ""));
} catch {
throw sourceError("reference_station_upstream_url_invalid");
}
if (endpoint.protocol !== "https:" || endpoint.username || endpoint.password
|| endpoint.search || endpoint.hash || !endpoint.hostname) {
throw sourceError("reference_station_upstream_url_invalid");
}
return endpoint.toString();
}
function withinRoot(root, child) {
const base = resolve(root);
const target = resolve(base, child);
if (target !== base && !target.startsWith(`${base}${sep}`)) throw sourceError("reference_station_path_escape");
return target;
}
function finite(value, min, max, code) {
const number = Number(value);
if (!Number.isFinite(number) || number < min || number > max) throw sourceError(code);
return number;
}
function firstString(...values) {
return values.map(optionalString).find(Boolean);
}
function optionalString(value) {
if (typeof value !== "string") return undefined;
const normalized = value.trim();
return normalized && normalized.length <= 256 ? normalized : undefined;
}
function compact(value) {
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
}
function isObject(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function isIso(value) {
return typeof value === "string" && !Number.isNaN(Date.parse(value));
}
function sha256(value) {
return createHash("sha256").update(value).digest("hex");
}
function sourceError(code) {
const clientError = code === "reference_station_bbox_invalid"
|| code === "reference_station_search_query_invalid";
return Object.assign(new Error(code), { code, statusCode: clientError ? 400 : 500 });
}
function safeUpstreamFailure(error) {
const code = String(error?.message || "");
if (/^reference_station_upstream_[a-z0-9_]+$/u.test(code)) return code.slice(0, 96);
if (error?.name === "AbortError") return "reference_station_upstream_timeout";
return "reference_station_upstream_unavailable";
}

View File

@ -7,6 +7,11 @@ import { Readable, Transform } from "node:stream";
import { pipeline } from "node:stream/promises"; import { pipeline } from "node:stream/promises";
import { fileURLToPath } from "node:url"; 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"; import { loadZoneSourceProfile } from "./zone-source-snapshot.mjs";
const canonicalCesiumAssetIds = ["1", "2", "96188"]; 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 // replaceable: today it is the audited MMap snapshot, later an API adapter can
// atomically materialise the same manifest + generation envelope. // atomically materialise the same manifest + generation envelope.
const zoneSource = await loadZoneSourceProfile(zoneSourceRoot, zoneSourceProfileId); 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 liveCache = createCacheStore("live", config.cacheDir, true);
const offlineSnapshot = config.offlineSnapshotDir ? createCacheStore("offline-snapshot", config.offlineSnapshotDir, false) : null; const offlineSnapshot = config.offlineSnapshotDir ? createCacheStore("offline-snapshot", config.offlineSnapshotDir, false) : null;
const inflightWrites = new Map(); 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 (!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") { if (requestUrl.pathname === "/healthz" && request.method === "GET") {
return writeJson(response, 200, { return writeJson(response, 200, {
ok: true, ok: true,
@ -89,6 +111,9 @@ const server = createServer(async (request, response) => {
sourceZoneCount: zoneSource.generation.metadata.sourceZoneCount, sourceZoneCount: zoneSource.generation.metadata.sourceZoneCount,
publishedZoneCount: zoneSource.generation.metadata.publishedZoneCount, 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)`); 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)`); 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(`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()); process.on("SIGTERM", () => server.close());
@ -146,6 +172,50 @@ function serveZoneSourceGeneration(request, response) {
return response.end(request.method === "HEAD" ? undefined : zoneSource.body); 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() { async function readConfig() {
const mode = String(process.env.MAP_CACHE_MODE || "readwrite").trim().toLowerCase(); const mode = String(process.env.MAP_CACHE_MODE || "readwrite").trim().toLowerCase();
if (!["readwrite", "readonly", "offline"].includes(mode)) throw new Error("invalid_map_cache_mode"); if (!["readwrite", "readonly", "offline"].includes(mode)) throw new Error("invalid_map_cache_mode");
@ -187,6 +257,13 @@ async function readConfig() {
upstreamAllowlist: new Set(allowlist), upstreamAllowlist: new Set(allowlist),
legacyCacheHosts: new Set(parseList(process.env.MAP_GATEWAY_LEGACY_CACHE_HOSTS || "")), legacyCacheHosts: new Set(parseList(process.env.MAP_GATEWAY_LEGACY_CACHE_HOSTS || "")),
offlineProviderAllowlist: new Set(parseList(process.env.MAP_GATEWAY_OFFLINE_PROVIDER_ALLOWLIST || "")), 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")), 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"), 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(), trustedSubjectHeader: String(process.env.MAP_GATEWAY_TRUSTED_SUBJECT_HEADER || "x-nodedc-user-id").toLowerCase(),

View File

@ -0,0 +1,204 @@
import assert from "node:assert/strict";
import { mkdtemp, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import {
MAP_REFERENCE_SEARCH_SCHEMA,
MAP_REFERENCE_SNAPSHOT_SCHEMA,
TRANSPORT_STATION_PROFILE_ID,
createReferenceStationSource,
} from "../src/reference-station-source.mjs";
const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
const seedFile = join(root, "reference-sources", "transport-stations", "moscow-v1.json");
test("packaged transport station profile is normalized and provider-neutral", async () => {
const cacheDir = await mkdtemp(join(tmpdir(), "nodedc-reference-stations-"));
const source = await createReferenceStationSource({ seedFile, cacheDir, fetchEnabled: false });
const snapshot = await source.snapshot();
assert.equal(snapshot.schemaVersion, MAP_REFERENCE_SNAPSHOT_SCHEMA);
assert.equal(snapshot.profileId, TRANSPORT_STATION_PROFILE_ID);
assert.equal(snapshot.complete, true);
assert.equal(snapshot.metadata.factCount, 638);
assert.deepEqual(snapshot.metadata.categoryCounts, {
metro: 273,
railway_station: 357,
railway_terminal: 8,
});
assert.ok(snapshot.facts.every((fact) => !JSON.stringify(fact).match(/https?:|token|credential|endpoint/i)));
assert.ok(snapshot.facts.every((fact) => ["map.station", "map.terminal"].includes(fact.semanticType)));
});
test("bbox inside the seed spatial-cell coverage never waits for upstream", async () => {
const cacheDir = await mkdtemp(join(tmpdir(), "nodedc-reference-stations-"));
let calls = 0;
const source = await createReferenceStationSource({
seedFile,
cacheDir,
cellDegrees: 0.5,
fetchImpl: async () => {
calls += 1;
throw new Error("seed coverage must not call upstream");
},
});
const snapshot = await source.snapshot({ bbox: [36.9, 55.4, 38.2, 56.1] });
assert.equal(calls, 0);
assert.equal(snapshot.complete, true);
assert.equal(snapshot.metadata.requestedCellCount, 12);
assert.ok(snapshot.facts.length > 0);
});
test("bbox fetch uses one cached spatial cell and never exposes upstream payload", async () => {
const cacheDir = await mkdtemp(join(tmpdir(), "nodedc-reference-stations-"));
let calls = 0;
const source = await createReferenceStationSource({
seedFile,
cacheDir,
cellDegrees: 1,
fetchImpl: async () => {
calls += 1;
return new Response(JSON.stringify({
elements: [{
type: "node",
id: 987654321,
lat: 59.93,
lon: 30.31,
tags: { railway: "station", station: "subway", name: "Тестовая", website: "https://not-projected.invalid" },
}],
}), { status: 200, headers: { "content-type": "application/json" } });
},
});
const first = await source.snapshot({ bbox: [30, 59, 31, 60] });
const second = await source.snapshot({ bbox: [30, 59, 31, 60] });
assert.equal(calls, 1);
assert.ok(first.facts.some((fact) => fact.sourceId === "osm.node.987654321"));
assert.deepEqual(first.facts, second.facts);
assert.ok(!JSON.stringify(first).includes("not-projected"));
const cached = JSON.parse(await readFile(join(cacheDir, "reference-features", TRANSPORT_STATION_PROFILE_ID, "30_59.json"), "utf8"));
assert.equal(cached.schemaVersion, "nodedc.map-reference-cell/v1");
});
test("viewport cells use bounded concurrency and deduplicate inflight fetches", async () => {
const cacheDir = await mkdtemp(join(tmpdir(), "nodedc-reference-stations-"));
let active = 0;
let maximumActive = 0;
let calls = 0;
const starts = [];
const source = await createReferenceStationSource({
seedFile,
cacheDir,
cellDegrees: 0.5,
fetchImpl: async () => {
calls += 1;
starts.push(Date.now());
active += 1;
maximumActive = Math.max(maximumActive, active);
await new Promise((resolveDelay) => setTimeout(resolveDelay, 15));
active -= 1;
return new Response(JSON.stringify({ elements: [] }), {
status: 200,
headers: { "content-type": "application/json" },
});
},
});
const bbox = [29.9, 59.7, 30.9, 60.2];
await Promise.all([source.snapshot({ bbox }), source.snapshot({ bbox })]);
assert.equal(calls, 6);
assert.ok(maximumActive <= 2);
assert.ok(starts.slice(1).every((startedAt, index) => startedAt - starts[index] >= 650));
const status = await source.status();
assert.equal(status.activeFetches, 0);
assert.equal(status.queuedFetches, 0);
assert.equal(status.upstreamState, "ready");
});
test("Russian name wins and upstream failures are safe diagnostics", async () => {
const cacheDir = await mkdtemp(join(tmpdir(), "nodedc-reference-stations-"));
let fail = false;
const source = await createReferenceStationSource({
seedFile,
cacheDir,
cellDegrees: 1,
fetchImpl: async () => {
if (fail) throw new TypeError("getaddrinfo EAI_AGAIN overpass.internal.example");
return new Response(JSON.stringify({
elements: [{
type: "node",
id: 987654322,
lat: 59.93,
lon: 30.31,
tags: {
railway: "station",
station: "subway",
name: "Petrogradskaya",
"name:ru": "Петроградская",
},
}],
}), { status: 200, headers: { "content-type": "application/json" } });
},
});
const snapshot = await source.snapshot({ bbox: [30, 59, 31, 60] });
assert.equal(snapshot.facts[0].attributes.name, "Петроградская");
fail = true;
await source.snapshot({ bbox: [31, 59, 32, 60] });
const status = await source.status();
assert.equal(status.upstreamState, "degraded");
assert.equal(status.lastFailure, "reference_station_upstream_unavailable");
assert.ok(status.lastFailureAt);
assert.ok(!JSON.stringify(status).includes("overpass.internal.example"));
});
test("global station search is normalized, deduplicated and persisted", async () => {
const cacheDir = await mkdtemp(join(tmpdir(), "nodedc-reference-stations-"));
let calls = 0;
const source = await createReferenceStationSource({
seedFile,
cacheDir,
fetchImpl: async (_url, options) => {
calls += 1;
const body = new URLSearchParams(options.body);
assert.match(body.get("data"), /Петроградская/u);
return new Response(JSON.stringify({
elements: [{
type: "node",
id: 987654323,
lat: 59.96639,
lon: 30.3113,
tags: {
railway: "station",
station: "subway",
name: "Petrogradskaya",
"name:ru": "Петроградская",
website: "https://not-projected.invalid",
},
}],
}), { status: 200, headers: { "content-type": "application/json" } });
},
});
const first = await source.search({ query: "Петроградская", limit: 8 });
const second = await source.search({ query: "Петроградская", limit: 8 });
assert.equal(first.schemaVersion, MAP_REFERENCE_SEARCH_SCHEMA);
assert.equal(first.complete, true);
assert.equal(calls, 1);
assert.deepEqual(first.facts, second.facts);
assert.equal(first.facts[0].attributes.name, "Петроградская");
assert.ok(!JSON.stringify(first).includes("not-projected"));
const persisted = JSON.parse(await readFile(
join(cacheDir, "reference-features", `${TRANSPORT_STATION_PROFILE_ID}.search.json`),
"utf8",
));
assert.equal(persisted.schemaVersion, "nodedc.map-reference-search-index/v1");
const restarted = await createReferenceStationSource({
seedFile,
cacheDir,
fetchEnabled: false,
});
const cached = await restarted.search({ query: "Петроградская", limit: 8 });
assert.equal(cached.complete, true);
assert.equal(cached.facts[0].sourceId, "osm.node.987654323");
});