feat(map): add cache-first Cesium gateway and AMD egress
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash, createHmac } from "node:crypto";
|
||||
import { once } from "node:events";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { createServer as createHttpServer } from "node:http";
|
||||
import { createServer } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), "nodedc-map-gateway-admin-smoke-"));
|
||||
const cacheDir = join(root, "cache");
|
||||
const port = await freePort();
|
||||
const egressPort = await freePort();
|
||||
const adminSecret = "gateway-admin-smoke-secret";
|
||||
const providerToken = "cesium-private-token-should-never-be-returned";
|
||||
const rotatedProviderToken = "rotated-cesium-private-token-should-never-be-returned";
|
||||
const egressProxyToken = "map-egress-private-token-should-never-be-returned";
|
||||
const ionReferer = "https://foundry.example.test/page-library/";
|
||||
const providerState = createProviderState();
|
||||
let child;
|
||||
let egress;
|
||||
let childOutput = "";
|
||||
|
||||
try {
|
||||
const egressTokenFile = join(root, "map-egress-proxy-token");
|
||||
await writeFile(egressTokenFile, `${egressProxyToken}\n`, { mode: 0o600 });
|
||||
egress = createCesiumEgressMock(providerState, egressProxyToken);
|
||||
egress.listen(egressPort, "127.0.0.1");
|
||||
await once(egress, "listening");
|
||||
child = spawn(process.execPath, ["src/server.mjs"], {
|
||||
cwd: new URL("..", import.meta.url),
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(port),
|
||||
MAP_CACHE_DIR: cacheDir,
|
||||
MAP_GATEWAY_ALLOW_ANONYMOUS: "true",
|
||||
MAP_CACHE_MODE: "readwrite",
|
||||
NODE_ENV: "test",
|
||||
CESIUM_ION_TOKEN: "",
|
||||
// A malformed legacy override may add an approved asset, but must never
|
||||
// remove the three canonical Foundry map assets.
|
||||
CESIUM_ION_ASSET_ALLOWLIST: "999",
|
||||
CESIUM_ION_API_BASE_URL: "https://api.cesium.com/",
|
||||
MAP_GATEWAY_UPSTREAM_ALLOWLIST: "api.cesium.com,assets.ion.cesium.com,dev.virtualearth.net",
|
||||
MAP_GATEWAY_TEST_ALLOW_HTTP_LOOPBACK: "true",
|
||||
MAP_GATEWAY_EGRESS_URL: `http://127.0.0.1:${egressPort}/`,
|
||||
MAP_GATEWAY_EGRESS_PROXY_TOKEN_FILE: egressTokenFile,
|
||||
NODEDC_MAP_GATEWAY_ADMIN_SECRET: adminSecret,
|
||||
CESIUM_ION_ENDPOINT_TTL_SECONDS: "2",
|
||||
CESIUM_ION_ENDPOINT_REFRESH_AHEAD_SECONDS: "1",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
child.stdout.on("data", (chunk) => { childOutput += String(chunk); });
|
||||
child.stderr.on("data", (chunk) => { childOutput += String(chunk); });
|
||||
|
||||
await waitForGateway(port, child);
|
||||
const path = "/api/map/admin/cesium-ion";
|
||||
const unsigned = await fetch(`http://127.0.0.1:${port}${path}`);
|
||||
assert.equal(unsigned.status, 401);
|
||||
|
||||
const initial = await requestAdmin(port, adminSecret, "GET", path, "user_root", "", ionReferer);
|
||||
assert.equal(initial.response.status, 200);
|
||||
assert.equal(initial.body.configured, false);
|
||||
|
||||
const saved = await requestAdmin(port, adminSecret, "PUT", path, "user_root", JSON.stringify({ token: providerToken }), ionReferer);
|
||||
assert.equal(saved.response.status, 200);
|
||||
assert.equal(saved.body.configured, true);
|
||||
assert.equal(saved.body.verification, "verified");
|
||||
assert.equal(saved.raw.includes(providerToken), false);
|
||||
|
||||
const terrainEndpoint = await fetch(`http://127.0.0.1:${port}/api/map/ion/assets/1/endpoint`, { headers: { "x-nodedc-ion-referer": ionReferer } });
|
||||
assert.equal(terrainEndpoint.status, 200);
|
||||
assert.equal((await terrainEndpoint.json()).credentialMode, "gateway");
|
||||
const buildingsEndpoint = await fetch(`http://127.0.0.1:${port}/api/map/ion/assets/96188/endpoint`, { headers: { "x-nodedc-ion-referer": ionReferer } });
|
||||
assert.equal(buildingsEndpoint.status, 200);
|
||||
const buildingsEndpointRaw = await buildingsEndpoint.text();
|
||||
const buildingsEndpointBody = JSON.parse(buildingsEndpointRaw);
|
||||
assert.equal(buildingsEndpointBody.credentialMode, "gateway");
|
||||
assert.equal(buildingsEndpointRaw.includes(providerState.credentials.v1.buildings), false);
|
||||
assert.equal(buildingsEndpointBody.url.endsWith("/tileset.json?v=production-shaped"), true);
|
||||
|
||||
const endpointCallsBeforeCacheHit = providerState.endpointRequests.get("v1:96188");
|
||||
const cachedBuildingsEndpoint = await fetch(`http://127.0.0.1:${port}/api/map/ion/assets/96188/endpoint`, { headers: { "x-nodedc-ion-referer": ionReferer } });
|
||||
assert.equal(cachedBuildingsEndpoint.status, 200);
|
||||
assert.equal(providerState.endpointRequests.get("v1:96188"), endpointCallsBeforeCacheHit);
|
||||
|
||||
const terrainTile = encodeURIComponent("https://assets.ion.cesium.com/us-east-1/asset_depot/1/CesiumWorldTerrain/v1.2/0/0/0.terrain");
|
||||
const proxiedTerrain = await fetch(`http://127.0.0.1:${port}/api/map/cache?url=${terrainTile}`, { headers: { "x-nodedc-ion-referer": ionReferer } });
|
||||
assert.equal(proxiedTerrain.status, 200);
|
||||
assert.equal(await proxiedTerrain.text(), "terrain-bytes");
|
||||
|
||||
const buildingsRoot = await fetch(`http://127.0.0.1:${port}/api/map/cache?url=${encodeURIComponent(buildingsEndpointBody.url)}`, { headers: { "x-nodedc-ion-referer": ionReferer } });
|
||||
assert.equal(buildingsRoot.status, 200);
|
||||
assert.equal((await buildingsRoot.json()).root.content.uri, "root.b3dm");
|
||||
const buildingsChildUrl = new URL("14/31780/12975.b3dm", buildingsEndpointBody.url).toString();
|
||||
const buildingsChild = await fetch(`http://127.0.0.1:${port}/api/map/cache?url=${encodeURIComponent(buildingsChildUrl)}`, { headers: { "x-nodedc-ion-referer": ionReferer } });
|
||||
assert.equal(buildingsChild.status, 200);
|
||||
assert.equal(await buildingsChild.text(), "building-bytes-v1");
|
||||
|
||||
const nestedEndpoint = await fetch(`http://127.0.0.1:${port}/api/map/ion/assets/999/endpoint`, { headers: { "x-nodedc-ion-referer": ionReferer } });
|
||||
const nestedEndpointRaw = await nestedEndpoint.text();
|
||||
const nestedEndpointBody = JSON.parse(nestedEndpointRaw);
|
||||
assert.equal(nestedEndpoint.status, 200);
|
||||
assert.equal(nestedEndpointRaw.includes(providerState.credentials.v1.nested), false);
|
||||
const nestedChildUrl = new URL("nested-child.b3dm", nestedEndpointBody.url).toString();
|
||||
const nestedChild = await fetch(`http://127.0.0.1:${port}/api/map/cache?url=${encodeURIComponent(nestedChildUrl)}`, { headers: { "x-nodedc-ion-referer": ionReferer } });
|
||||
assert.equal(nestedChild.status, 200);
|
||||
assert.equal(await nestedChild.text(), "nested-building-bytes-v1");
|
||||
|
||||
const attackerToken = "browser-supplied-token-must-be-removed";
|
||||
const siblingUrl = `https://assets.ion.cesium.com/us-east-1/asset_depot/999/OtherAsset/root.b3dm?access_token=${attackerToken}`;
|
||||
const sibling = await fetch(`http://127.0.0.1:${port}/api/map/cache?url=${encodeURIComponent(siblingUrl)}`, { headers: { "x-nodedc-ion-referer": ionReferer } });
|
||||
assert.equal(sibling.status, 401);
|
||||
assert.equal(providerState.siblingCredentialObserved, "");
|
||||
|
||||
const rejected = await requestAdmin(port, adminSecret, "PUT", path, "user_root", JSON.stringify({ token: "invalid-private-token-should-not-replace-valid-value" }), ionReferer);
|
||||
assert.equal(rejected.response.status, 422);
|
||||
assert.equal(rejected.body.error, "cesium_ion_token_verification_failed");
|
||||
|
||||
// Simulate metadata persisted by an older Gateway transport. A false failure
|
||||
// must be rechecked exactly once after the v4 scope/cache transport upgrade.
|
||||
await writeFile(join(cacheDir, "secrets", "cesium-ion-token.metadata.json"), JSON.stringify({
|
||||
updatedAt: "2026-07-15T00:00:00.000Z",
|
||||
updatedBy: "user_root",
|
||||
verification: "failed",
|
||||
}));
|
||||
const status = await requestAdmin(port, adminSecret, "GET", path, "user_root", "", ionReferer);
|
||||
assert.equal(status.response.status, 200);
|
||||
assert.equal(status.body.configured, true);
|
||||
assert.equal(status.body.verification, "verified");
|
||||
assert.equal(status.raw.includes(providerToken), false);
|
||||
const metadata = JSON.parse(await readFile(join(cacheDir, "secrets", "cesium-ion-token.metadata.json"), "utf8"));
|
||||
assert.equal(metadata.verificationTransportVersion, 4);
|
||||
|
||||
// Refresh-ahead is cache-first and single-flight: concurrent callers receive
|
||||
// the still-valid cached endpoint while exactly one Ion request refreshes it.
|
||||
await delay(1_100);
|
||||
const callsBeforeRefreshAhead = providerState.endpointRequests.get("v1:96188");
|
||||
const refreshAheadResponses = await Promise.all(Array.from({ length: 8 }, () => fetch(
|
||||
`http://127.0.0.1:${port}/api/map/ion/assets/96188/endpoint`,
|
||||
{ headers: { "x-nodedc-ion-referer": ionReferer } },
|
||||
)));
|
||||
assert.equal(refreshAheadResponses.every((response) => response.status === 200), true);
|
||||
await waitFor(() => providerState.endpointRequests.get("v1:96188") >= callsBeforeRefreshAhead + 1);
|
||||
await delay(100);
|
||||
assert.equal(providerState.endpointRequests.get("v1:96188"), callsBeforeRefreshAhead + 1);
|
||||
|
||||
const rotated = await requestAdmin(port, adminSecret, "PUT", path, "user_root", JSON.stringify({ token: rotatedProviderToken }), ionReferer);
|
||||
assert.equal(rotated.response.status, 200);
|
||||
assert.equal(rotated.raw.includes(rotatedProviderToken), false);
|
||||
const rotatedEndpoint = await fetch(`http://127.0.0.1:${port}/api/map/ion/assets/96188/endpoint`, { headers: { "x-nodedc-ion-referer": ionReferer } });
|
||||
const rotatedEndpointRaw = await rotatedEndpoint.text();
|
||||
const rotatedEndpointBody = JSON.parse(rotatedEndpointRaw);
|
||||
assert.equal(rotatedEndpoint.status, 200);
|
||||
assert.equal(rotatedEndpointRaw.includes(providerState.credentials.v1.buildings), false);
|
||||
assert.equal(rotatedEndpointRaw.includes(providerState.credentials.v2.buildings), false);
|
||||
const rotatedChildUrl = new URL("rotated.b3dm", rotatedEndpointBody.url).toString();
|
||||
const rotatedChild = await fetch(`http://127.0.0.1:${port}/api/map/cache?url=${encodeURIComponent(rotatedChildUrl)}`, { headers: { "x-nodedc-ion-referer": ionReferer } });
|
||||
assert.equal(rotatedChild.status, 200);
|
||||
assert.equal(await rotatedChild.text(), "building-bytes-v2");
|
||||
|
||||
const indexRaw = await readFile(join(cacheDir, "index.json"), "utf8");
|
||||
for (const secret of [providerToken, rotatedProviderToken, ...Object.values(providerState.credentials.v1), ...Object.values(providerState.credentials.v2), attackerToken]) {
|
||||
assert.equal(indexRaw.includes(secret), false);
|
||||
assert.equal(childOutput.includes(secret), false);
|
||||
}
|
||||
|
||||
const health = await fetch(`http://127.0.0.1:${port}/healthz`);
|
||||
const healthRaw = await health.text();
|
||||
assert.equal(health.ok, true);
|
||||
assert.equal(healthRaw.includes(providerToken), false);
|
||||
assert.equal(JSON.parse(healthRaw).ionConfigured, true);
|
||||
|
||||
const stored = await readFile(join(cacheDir, "secrets", "cesium-ion-token"), "utf8");
|
||||
assert.equal(stored.trim(), rotatedProviderToken);
|
||||
console.log("ok: Ion scopes, refresh single-flight, token rotation, and secret boundaries hold");
|
||||
} finally {
|
||||
if (child && !child.killed) {
|
||||
child.kill("SIGTERM");
|
||||
await once(child, "exit").catch(() => undefined);
|
||||
}
|
||||
if (egress?.listening) {
|
||||
egress.close();
|
||||
await once(egress, "close");
|
||||
}
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function createCesiumEgressMock(state, expectedProxyToken) {
|
||||
return createHttpServer((request, response) => {
|
||||
const requestUrl = new URL(request.url || "/", "http://127.0.0.1");
|
||||
if (requestUrl.pathname !== "/proxy/cesium/fetch") {
|
||||
response.writeHead(404, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ error: "not_found" }));
|
||||
return;
|
||||
}
|
||||
if (request.headers["x-proxy-token"] !== expectedProxyToken || request.headers.authorization) {
|
||||
response.writeHead(401, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ error: "proxy_authorization_required" }));
|
||||
return;
|
||||
}
|
||||
const target = new URL(requestUrl.searchParams.get("url") || "https://invalid.example/");
|
||||
if (request.headers["x-nodedc-map-referer"] !== ionReferer) {
|
||||
response.writeHead(401, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ error: "referer_required" }));
|
||||
return;
|
||||
}
|
||||
if (target.hostname === "assets.ion.cesium.com" && target.pathname.startsWith("/us-east-1/asset_depot/1/CesiumWorldTerrain/v1.2/")) {
|
||||
if (![state.credentials.v1.terrain, state.credentials.v2.terrain].includes(target.searchParams.get("access_token"))) {
|
||||
response.writeHead(401, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ error: "terrain_authorization_required" }));
|
||||
return;
|
||||
}
|
||||
response.writeHead(200, { "content-type": "application/octet-stream" });
|
||||
response.end("terrain-bytes");
|
||||
return;
|
||||
}
|
||||
if (target.hostname === "assets.ion.cesium.com" && target.pathname.startsWith("/us-east-1/asset_depot/96188/OpenStreetMap/CWT/2025-04-01/nested/")) {
|
||||
const generation = target.searchParams.get("access_token") === state.credentials.v2.nested ? "v2"
|
||||
: target.searchParams.get("access_token") === state.credentials.v1.nested ? "v1" : "";
|
||||
if (!generation) {
|
||||
response.writeHead(401, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ error: "nested_authorization_required" }));
|
||||
return;
|
||||
}
|
||||
response.writeHead(200, { "content-type": "application/octet-stream" });
|
||||
response.end(`nested-building-bytes-${generation}`);
|
||||
return;
|
||||
}
|
||||
if (target.hostname === "assets.ion.cesium.com" && target.pathname.startsWith("/us-east-1/asset_depot/96188/OpenStreetMap/CWT/2025-04-01/")) {
|
||||
const generation = target.searchParams.get("access_token") === state.credentials.v2.buildings ? "v2"
|
||||
: target.searchParams.get("access_token") === state.credentials.v1.buildings ? "v1" : "";
|
||||
if (!generation) {
|
||||
response.writeHead(401, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ error: "buildings_authorization_required" }));
|
||||
return;
|
||||
}
|
||||
if (target.pathname.endsWith("/tileset.json")) {
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ asset: { version: "1.1" }, geometricError: 1, root: { geometricError: 0, content: { uri: "root.b3dm" } } }));
|
||||
return;
|
||||
}
|
||||
response.writeHead(200, { "content-type": "application/octet-stream" });
|
||||
response.end(`building-bytes-${generation}`);
|
||||
return;
|
||||
}
|
||||
if (target.hostname === "assets.ion.cesium.com" && target.pathname.startsWith("/us-east-1/asset_depot/999/")) {
|
||||
state.siblingCredentialObserved = target.searchParams.get("access_token") || "";
|
||||
response.writeHead(401, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ error: "scope_isolation" }));
|
||||
return;
|
||||
}
|
||||
const authorization = String(request.headers["x-nodedc-cesium-authorization"] || "");
|
||||
const generation = authorization === `Bearer ${providerToken}` ? "v1" : authorization === `Bearer ${rotatedProviderToken}` ? "v2" : "";
|
||||
if (target.hostname !== "api.cesium.com" || !generation) {
|
||||
response.writeHead(401, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ error: "unauthorized" }));
|
||||
return;
|
||||
}
|
||||
const assetId = target.pathname.match(/^\/v1\/assets\/(\d+)\/endpoint$/)?.[1];
|
||||
state.endpointRequests.set(`${generation}:${assetId}`, (state.endpointRequests.get(`${generation}:${assetId}`) || 0) + 1);
|
||||
const body = assetId === "1"
|
||||
? { type: "TERRAIN", url: "https://assets.ion.cesium.com/us-east-1/asset_depot/1/CesiumWorldTerrain/v1.2/", accessToken: state.credentials[generation].terrain }
|
||||
: assetId === "2"
|
||||
? { type: "IMAGERY", externalType: "BING", options: { url: "https://dev.virtualearth.net/REST/v1/Imagery/Metadata/Aerial", key: state.credentials[generation].imagery, mapStyle: "Aerial" } }
|
||||
: assetId === "96188"
|
||||
? { type: "3DTILES", url: "https://assets.ion.cesium.com/us-east-1/asset_depot/96188/OpenStreetMap/CWT/2025-04-01/tileset.json?v=production-shaped", accessToken: state.credentials[generation].buildings }
|
||||
: assetId === "999"
|
||||
? { type: "3DTILES", url: "https://assets.ion.cesium.com/us-east-1/asset_depot/96188/OpenStreetMap/CWT/2025-04-01/nested/tileset.json?v=nested", accessToken: state.credentials[generation].nested }
|
||||
: null;
|
||||
if (!body) {
|
||||
response.writeHead(404, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ error: "not_found" }));
|
||||
return;
|
||||
}
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify(body));
|
||||
});
|
||||
}
|
||||
|
||||
function createProviderState() {
|
||||
const expiresAt = Math.floor(Date.now() / 1000) + 3600;
|
||||
return {
|
||||
credentials: {
|
||||
v1: {
|
||||
terrain: fakeJwt("terrain-v1", expiresAt),
|
||||
imagery: "bing-private-endpoint-key-v1",
|
||||
buildings: fakeJwt("buildings-v1", expiresAt),
|
||||
nested: fakeJwt("nested-v1", expiresAt),
|
||||
},
|
||||
v2: {
|
||||
terrain: fakeJwt("terrain-v2", expiresAt),
|
||||
imagery: "bing-private-endpoint-key-v2",
|
||||
buildings: fakeJwt("buildings-v2", expiresAt),
|
||||
nested: fakeJwt("nested-v2", expiresAt),
|
||||
},
|
||||
},
|
||||
endpointRequests: new Map(),
|
||||
siblingCredentialObserved: null,
|
||||
};
|
||||
}
|
||||
|
||||
function fakeJwt(subject, exp) {
|
||||
const encode = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
|
||||
return `${encode({ alg: "HS256", typ: "JWT" })}.${encode({ sub: subject, exp })}.test-signature`;
|
||||
}
|
||||
|
||||
async function requestAdmin(port, secret, method, pathname, actorId, body = "", referer = "") {
|
||||
const timestamp = String(Date.now());
|
||||
const bodyHash = createHash("sha256").update(body).digest("hex");
|
||||
const message = `nodedc.map-gateway.admin.v2\n${method}\n${pathname}\n${timestamp}\n${actorId}\n${bodyHash}\n${referer}`;
|
||||
const signature = createHmac("sha256", secret).update(message).digest("hex");
|
||||
const response = await fetch(`http://127.0.0.1:${port}${pathname}`, {
|
||||
method,
|
||||
headers: {
|
||||
...(body ? { "content-type": "application/json" } : {}),
|
||||
"x-nodedc-admin-timestamp": timestamp,
|
||||
"x-nodedc-admin-actor": actorId,
|
||||
"x-nodedc-admin-signature": signature,
|
||||
...(referer ? { "x-nodedc-ion-referer": referer } : {}),
|
||||
},
|
||||
body: body || undefined,
|
||||
});
|
||||
const raw = await response.text();
|
||||
return { response, raw, body: JSON.parse(raw) };
|
||||
}
|
||||
|
||||
async function freePort() {
|
||||
const server = createServer();
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
assert(address && typeof address === "object");
|
||||
const port = address.port;
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
return port;
|
||||
}
|
||||
|
||||
async function waitForGateway(port, child) {
|
||||
let output = "";
|
||||
child.stderr.on("data", (chunk) => { output += String(chunk); });
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
if (child.exitCode !== null) throw new Error(`gateway_exited:${child.exitCode}:${output}`);
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/healthz`);
|
||||
if (response.ok) return;
|
||||
} catch { /* service is still starting */ }
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
throw new Error(`gateway_start_timeout:${output}`);
|
||||
}
|
||||
|
||||
async function waitFor(predicate) {
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
if (predicate()) return;
|
||||
await delay(20);
|
||||
}
|
||||
throw new Error("condition_timeout");
|
||||
}
|
||||
|
||||
function delay(milliseconds) {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { createServer as createNetServer } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { gzipSync } from "node:zlib";
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), "nodedc-map-gateway-live-cache-smoke-"));
|
||||
const upstreamPort = await freePort();
|
||||
const gatewayPort = await freePort();
|
||||
let upstream;
|
||||
let gateway;
|
||||
let upstreamState = "live-1";
|
||||
let gzipRevision = 1;
|
||||
let upstreamRequests = 0;
|
||||
|
||||
try {
|
||||
upstream = createServer((request, response) => {
|
||||
upstreamRequests += 1;
|
||||
if (request.url?.startsWith("/gzip/")) {
|
||||
const compressed = gzipSync(JSON.stringify({ revision: gzipRevision, payload: "x".repeat(8_192) }));
|
||||
response.writeHead(200, {
|
||||
"content-type": "application/json",
|
||||
"content-encoding": "gzip",
|
||||
"content-length": String(compressed.byteLength),
|
||||
"cache-control": "max-age=3600",
|
||||
});
|
||||
response.end(compressed);
|
||||
return;
|
||||
}
|
||||
if (upstreamState === "unavailable") {
|
||||
response.writeHead(503, { "content-type": "text/plain" });
|
||||
response.end("unavailable");
|
||||
return;
|
||||
}
|
||||
response.writeHead(200, { "content-type": "text/plain", "cache-control": "max-age=3600" });
|
||||
response.end(upstreamState);
|
||||
});
|
||||
upstream.listen(upstreamPort, "127.0.0.1");
|
||||
await once(upstream, "listening");
|
||||
|
||||
gateway = spawn(process.execPath, ["src/server.mjs"], {
|
||||
cwd: new URL("..", import.meta.url),
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: "test",
|
||||
PORT: String(gatewayPort),
|
||||
MAP_CACHE_DIR: join(root, "cache"),
|
||||
MAP_CACHE_MODE: "readwrite",
|
||||
MAP_GATEWAY_ALLOW_ANONYMOUS: "true",
|
||||
MAP_GATEWAY_UPSTREAM_ALLOWLIST: "127.0.0.1",
|
||||
MAP_GATEWAY_TEST_ALLOW_HTTP_LOOPBACK: "true",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
await waitForGateway(gatewayPort, gateway);
|
||||
|
||||
const source = `http://127.0.0.1:${upstreamPort}/terrain/layer.json`;
|
||||
const first = await requestGateway(gatewayPort, source);
|
||||
assert.equal(first.body, "live-1");
|
||||
assert.equal(first.response.headers.get("x-nodedc-map-cache"), "live-record");
|
||||
|
||||
upstreamState = "live-2";
|
||||
const second = await requestGateway(gatewayPort, source);
|
||||
assert.equal(second.body, "live-1");
|
||||
assert.equal(["live-record", "live-cache-hit"].includes(second.response.headers.get("x-nodedc-map-cache")), true);
|
||||
assert.equal(upstreamRequests, 1);
|
||||
const cachedEtag = second.response.headers.get("etag");
|
||||
assert.match(cachedEtag || "", /^(?:W\/)?"[^"]+"$/);
|
||||
const indexPath = join(root, "cache", "index.json");
|
||||
const indexBeforeHits = await stat(indexPath);
|
||||
const indexRawBeforeHits = await readFile(indexPath, "utf8");
|
||||
|
||||
const revalidated = await requestGateway(gatewayPort, source, { headers: { "if-none-match": cachedEtag } });
|
||||
assert.equal(revalidated.response.status, 304);
|
||||
assert.equal(revalidated.body, "");
|
||||
assert.equal(revalidated.response.headers.get("etag"), cachedEtag);
|
||||
assert.equal(upstreamRequests, 1);
|
||||
|
||||
// Browser route revisions bypass only the browser HTTP cache. They must not
|
||||
// create a duplicate upstream URL or TileCache entry.
|
||||
const versioned = await requestGateway(gatewayPort, `${source}?nodedc_client_revision=2`);
|
||||
assert.equal(versioned.body, "live-1");
|
||||
assert.equal(versioned.response.headers.get("x-nodedc-map-cache"), "live-cache-hit");
|
||||
assert.equal(upstreamRequests, 1);
|
||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||
const repeated = await requestGateway(gatewayPort, source);
|
||||
assert.equal(repeated.response.headers.get("x-nodedc-map-cache"), "live-cache-hit");
|
||||
}
|
||||
const indexAfterHits = await stat(indexPath);
|
||||
const indexRawAfterHits = await readFile(indexPath, "utf8");
|
||||
assert.equal(indexAfterHits.ino, indexBeforeHits.ino);
|
||||
assert.equal(indexAfterHits.mtimeMs, indexBeforeHits.mtimeMs);
|
||||
assert.equal(indexRawAfterHits, indexRawBeforeHits);
|
||||
|
||||
// `nodedc_cache_refresh=1` is the explicit opposite of "Не
|
||||
// перезаписывать cache": it goes live and replaces the stored object.
|
||||
const refreshed = await requestGateway(gatewayPort, `${source}?nodedc_cache_refresh=1`);
|
||||
assert.equal(refreshed.body, "live-2");
|
||||
assert.equal(refreshed.response.headers.get("x-nodedc-map-cache"), "live-refresh-record");
|
||||
assert.equal(upstreamRequests, 2);
|
||||
|
||||
upstreamState = "unavailable";
|
||||
const retained = await requestGateway(gatewayPort, source);
|
||||
assert.equal(retained.body, "live-2");
|
||||
assert.equal(retained.response.headers.get("x-nodedc-map-cache"), "live-cache-hit");
|
||||
assert.equal(upstreamRequests, 2);
|
||||
|
||||
// The second response takes the live-network-first path with a cache entry
|
||||
// already present. The Gateway must not forward the compressed upstream
|
||||
// Content-Length after Node has decoded the body.
|
||||
const gzipSource = `http://127.0.0.1:${upstreamPort}/gzip/layer.json`;
|
||||
const gzipFirst = await requestGateway(gatewayPort, gzipSource);
|
||||
assert.equal(JSON.parse(gzipFirst.body).revision, 1);
|
||||
gzipRevision = 2;
|
||||
const gzipSecond = await requestGateway(gatewayPort, gzipSource);
|
||||
assert.equal(JSON.parse(gzipSecond.body).revision, 1);
|
||||
assert.equal(
|
||||
["live-record", "live-cache-hit"].includes(gzipSecond.response.headers.get("x-nodedc-map-cache")),
|
||||
true,
|
||||
);
|
||||
const gzipRefresh = await requestGateway(gatewayPort, `${gzipSource}?nodedc_cache_refresh=1`);
|
||||
assert.equal(JSON.parse(gzipRefresh.body).revision, 2);
|
||||
// Node fetch decodes gzip while retaining the encoded upstream length. A
|
||||
// streaming response cannot know the decoded length before completion, so
|
||||
// it must use chunking instead of forwarding the unsafe encoded value.
|
||||
assert.equal(gzipRefresh.response.headers.get("content-length"), null);
|
||||
console.log("ok: TileCache hits do zero index writes, support ETag/304, and only explicit refresh replaces an object");
|
||||
} finally {
|
||||
await stop(gateway);
|
||||
if (upstream?.listening) {
|
||||
upstream.close();
|
||||
await once(upstream, "close");
|
||||
}
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function requestGateway(port, source, options = {}) {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/map/cache?url=${encodeURIComponent(source)}`, options);
|
||||
return { response, body: await response.text() };
|
||||
}
|
||||
|
||||
async function freePort() {
|
||||
const server = createNetServer();
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
assert(address && typeof address === "object");
|
||||
const port = address.port;
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
return port;
|
||||
}
|
||||
|
||||
async function waitForGateway(port, child) {
|
||||
let output = "";
|
||||
child.stderr.on("data", (chunk) => { output += String(chunk); });
|
||||
for (let attempt = 0; attempt < 80; attempt += 1) {
|
||||
if (child.exitCode !== null) throw new Error(`gateway_exited:${child.exitCode}:${output}`);
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/healthz`);
|
||||
if (response.ok) return;
|
||||
} catch { /* service is still starting */ }
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
throw new Error(`gateway_start_timeout:${output}`);
|
||||
}
|
||||
|
||||
async function stop(child) {
|
||||
if (child && !child.killed) {
|
||||
child.kill("SIGTERM");
|
||||
await once(child, "exit").catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { once } from "node:events";
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { createServer } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), "nodedc-map-gateway-credential-smoke-"));
|
||||
const cacheDir = join(root, "cache");
|
||||
const port = await freePort();
|
||||
let child;
|
||||
|
||||
try {
|
||||
await mkdir(join(cacheDir, "ion-endpoints"), { recursive: true });
|
||||
await writeFile(join(cacheDir, "index.json"), `${JSON.stringify({ version: 1, entries: {} })}\n`);
|
||||
await writeFile(join(cacheDir, "ion-endpoints", "1.json"), `${JSON.stringify({
|
||||
assetId: "1",
|
||||
type: "TERRAIN",
|
||||
url: "https://assets.ion.cesium.com/asset/1/",
|
||||
accessToken: "terrain-private-token",
|
||||
attributions: [],
|
||||
savedAt: Date.now(),
|
||||
})}\n`);
|
||||
await writeFile(join(cacheDir, "ion-endpoints", "2.json"), `${JSON.stringify({
|
||||
assetId: "2",
|
||||
type: "IMAGERY",
|
||||
externalType: "BING",
|
||||
options: {
|
||||
url: "https://dev.virtualearth.net/",
|
||||
key: "bing-private-key",
|
||||
mapStyle: "Aerial",
|
||||
},
|
||||
attributions: [],
|
||||
savedAt: Date.now(),
|
||||
})}\n`);
|
||||
const expiredBuildingsToken = fakeJwt("expired-buildings", Math.floor(Date.now() / 1000) - 60);
|
||||
await writeFile(join(cacheDir, "ion-endpoints", "96188.json"), `${JSON.stringify({
|
||||
assetId: "96188",
|
||||
type: "3DTILES",
|
||||
url: "https://assets.ion.cesium.com/us-east-1/asset_depot/96188/OpenStreetMap/CWT/2025-04-01/tileset.json?v=expired",
|
||||
accessToken: expiredBuildingsToken,
|
||||
attributions: [],
|
||||
savedAt: Date.now(),
|
||||
})}\n`);
|
||||
|
||||
child = spawn(process.execPath, ["src/server.mjs"], {
|
||||
cwd: new URL("..", import.meta.url),
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(port),
|
||||
MAP_CACHE_DIR: cacheDir,
|
||||
MAP_GATEWAY_ALLOW_ANONYMOUS: "true",
|
||||
MAP_CACHE_MODE: "readwrite",
|
||||
CESIUM_ION_TOKEN: "",
|
||||
CESIUM_ION_ASSET_ALLOWLIST: "1,2,96188",
|
||||
MAP_GATEWAY_UPSTREAM_ALLOWLIST: "assets.ion.cesium.com,dev.virtualearth.net",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
await waitForGateway(port, child);
|
||||
for (const assetId of ["1", "2"]) {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/map/ion/assets/${assetId}/endpoint`);
|
||||
assert.equal(response.status, 200);
|
||||
const body = await response.text();
|
||||
assert.equal(body.includes("terrain-private-token"), false);
|
||||
assert.equal(body.includes("bing-private-key"), false);
|
||||
assert.equal(body.includes("accessToken"), false);
|
||||
assert.equal(body.includes('"key"'), false);
|
||||
const endpoint = JSON.parse(body);
|
||||
assert.equal(endpoint.credentialMode, "gateway");
|
||||
}
|
||||
const expired = await fetch(`http://127.0.0.1:${port}/api/map/ion/assets/96188/endpoint`);
|
||||
const expiredRaw = await expired.text();
|
||||
assert.equal(expired.status, 503);
|
||||
assert.equal(expiredRaw.includes(expiredBuildingsToken), false);
|
||||
assert.equal(expiredRaw.includes("accessToken"), false);
|
||||
console.log("ok: endpoint responses contain no credentials and a known-expired Ion JWT is never served");
|
||||
} finally {
|
||||
if (child && !child.killed) {
|
||||
child.kill("SIGTERM");
|
||||
await once(child, "exit").catch(() => undefined);
|
||||
}
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function freePort() {
|
||||
const server = createServer();
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
assert(address && typeof address === "object");
|
||||
const port = address.port;
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
return port;
|
||||
}
|
||||
|
||||
async function waitForGateway(port, child) {
|
||||
let output = "";
|
||||
child.stderr.on("data", (chunk) => { output += String(chunk); });
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
if (child.exitCode !== null) throw new Error(`gateway_exited:${child.exitCode}:${output}`);
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/healthz`);
|
||||
if (response.ok) return;
|
||||
} catch { /* service is still starting */ }
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
throw new Error(`gateway_start_timeout:${output}`);
|
||||
}
|
||||
|
||||
function fakeJwt(subject, exp) {
|
||||
const encode = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
|
||||
return `${encode({ alg: "HS256", typ: "JWT" })}.${encode({ sub: subject, exp })}.test-signature`;
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
|
||||
import { createServer, get as httpGet } from "node:http";
|
||||
import { createServer as createNetServer } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), "nodedc-map-gateway-streaming-smoke-"));
|
||||
const cacheDir = join(root, "cache");
|
||||
const upstreamPort = await freePort();
|
||||
const gatewayPort = await freePort();
|
||||
const distinctCount = 12;
|
||||
const state = {
|
||||
sharedRequests: 0,
|
||||
sharedEnded: false,
|
||||
abortRequests: 0,
|
||||
abortEnded: false,
|
||||
brokenRequests: 0,
|
||||
distinctRequests: 0,
|
||||
distinctResponses: [],
|
||||
};
|
||||
let upstream;
|
||||
let gateway;
|
||||
|
||||
try {
|
||||
upstream = createServer((request, response) => {
|
||||
const requestUrl = new URL(request.url || "/", `http://127.0.0.1:${upstreamPort}`);
|
||||
if (requestUrl.pathname === "/slow/shared") {
|
||||
state.sharedRequests += 1;
|
||||
response.writeHead(200, { "content-type": "application/octet-stream", "cache-control": "max-age=3600" });
|
||||
response.write("first-");
|
||||
setTimeout(() => {
|
||||
state.sharedEnded = true;
|
||||
response.end("second");
|
||||
}, 250);
|
||||
return;
|
||||
}
|
||||
if (requestUrl.pathname === "/slow/abort") {
|
||||
state.abortRequests += 1;
|
||||
response.writeHead(200, { "content-type": "application/octet-stream", "cache-control": "max-age=3600" });
|
||||
response.write("abort-first-");
|
||||
setTimeout(() => {
|
||||
state.abortEnded = true;
|
||||
response.end("abort-second");
|
||||
}, 250);
|
||||
return;
|
||||
}
|
||||
if (requestUrl.pathname === "/broken") {
|
||||
state.brokenRequests += 1;
|
||||
response.writeHead(200, { "content-type": "application/octet-stream", "cache-control": "max-age=3600" });
|
||||
response.write("partial-must-not-commit");
|
||||
setTimeout(() => response.destroy(new Error("intentional_upstream_truncation")), 50);
|
||||
return;
|
||||
}
|
||||
if (requestUrl.pathname.startsWith("/distinct/")) {
|
||||
state.distinctRequests += 1;
|
||||
state.distinctResponses.push({ id: requestUrl.pathname.split("/").pop(), response });
|
||||
if (state.distinctResponses.length === distinctCount) {
|
||||
for (const item of state.distinctResponses) {
|
||||
item.response.writeHead(200, { "content-type": "text/plain", "cache-control": "max-age=3600" });
|
||||
item.response.end(`distinct-${item.id}`);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
response.writeHead(404).end();
|
||||
});
|
||||
upstream.listen(upstreamPort, "127.0.0.1");
|
||||
await once(upstream, "listening");
|
||||
|
||||
gateway = spawn(process.execPath, ["src/server.mjs"], {
|
||||
cwd: new URL("..", import.meta.url),
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: "test",
|
||||
PORT: String(gatewayPort),
|
||||
MAP_CACHE_DIR: cacheDir,
|
||||
MAP_CACHE_MODE: "readwrite",
|
||||
MAP_GATEWAY_ALLOW_ANONYMOUS: "true",
|
||||
MAP_GATEWAY_UPSTREAM_ALLOWLIST: "127.0.0.1",
|
||||
MAP_GATEWAY_TEST_ALLOW_HTTP_LOOPBACK: "true",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
await waitForGateway(gatewayPort, gateway);
|
||||
|
||||
const sharedSource = `http://127.0.0.1:${upstreamPort}/slow/shared`;
|
||||
const sharedUrl = gatewayUrl(gatewayPort, sharedSource);
|
||||
const leader = await fetch(sharedUrl);
|
||||
assert.equal(leader.status, 200);
|
||||
const reader = leader.body.getReader();
|
||||
const first = await reader.read();
|
||||
assert.equal(Buffer.from(first.value).toString("utf8"), "first-");
|
||||
assert.equal(state.sharedEnded, false, "first byte must reach the leader before upstream end");
|
||||
assert.equal(await indexEntryCount(cacheDir), 0, "partial body must not be published in index");
|
||||
|
||||
const followerPromise = fetch(sharedUrl).then(async (response) => ({ status: response.status, body: await response.text() }));
|
||||
const leaderBody = `first-${await readRemaining(reader)}`;
|
||||
const follower = await followerPromise;
|
||||
assert.equal(leaderBody, "first-second");
|
||||
assert.deepEqual(follower, { status: 200, body: "first-second" });
|
||||
assert.equal(state.sharedRequests, 1, "same-key followers must share one upstream");
|
||||
assert.equal(await indexEntryCount(cacheDir), 1, "complete body must be committed");
|
||||
|
||||
const abortSource = `http://127.0.0.1:${upstreamPort}/slow/abort`;
|
||||
const abortUrl = gatewayUrl(gatewayPort, abortSource);
|
||||
assert.equal(await abortAfterFirstChunk(abortUrl), "abort-first-");
|
||||
const afterAbort = await fetch(abortUrl);
|
||||
assert.equal(await afterAbort.text(), "abort-first-abort-second");
|
||||
assert.equal(state.abortEnded, true);
|
||||
assert.equal(state.abortRequests, 1, "client abort must not cancel the server-owned cache fill");
|
||||
assert.equal(await indexEntryCount(cacheDir), 2);
|
||||
|
||||
const beforeBroken = await indexEntryCount(cacheDir);
|
||||
const brokenSource = `http://127.0.0.1:${upstreamPort}/broken`;
|
||||
let brokenRejected = false;
|
||||
try {
|
||||
const broken = await fetch(gatewayUrl(gatewayPort, brokenSource));
|
||||
await broken.arrayBuffer();
|
||||
} catch {
|
||||
brokenRejected = true;
|
||||
}
|
||||
assert.equal(brokenRejected, true);
|
||||
await waitFor(async () => (await tempFiles(cacheDir)).length === 0);
|
||||
assert.equal(await indexEntryCount(cacheDir), beforeBroken, "truncated upstream must not publish an index entry");
|
||||
assert.equal((await tempFiles(cacheDir)).length, 0, "truncated upstream must remove temp files");
|
||||
assert.equal((await fetch(`http://127.0.0.1:${gatewayPort}/healthz`)).status, 200, "stream failure must not stop Gateway");
|
||||
|
||||
const diagnosticsBefore = (await (await fetch(`http://127.0.0.1:${gatewayPort}/healthz`)).json()).diagnostics;
|
||||
const distinctBodies = await Promise.all(Array.from({ length: distinctCount }, async (_, index) => {
|
||||
const response = await fetch(gatewayUrl(gatewayPort, `http://127.0.0.1:${upstreamPort}/distinct/${index}`));
|
||||
return response.text();
|
||||
}));
|
||||
assert.deepEqual(distinctBodies.sort(), Array.from({ length: distinctCount }, (_, index) => `distinct-${index}`).sort());
|
||||
await waitFor(async () => await indexEntryCount(cacheDir) === beforeBroken + distinctCount);
|
||||
const diagnosticsAfter = (await (await fetch(`http://127.0.0.1:${gatewayPort}/healthz`)).json()).diagnostics;
|
||||
const snapshotDelta = diagnosticsAfter.indexSnapshots - diagnosticsBefore.indexSnapshots;
|
||||
assert.equal(state.distinctRequests, distinctCount);
|
||||
assert.equal(snapshotDelta >= 1 && snapshotDelta <= 2, true, `expected <=2 coalesced snapshots, got ${snapshotDelta}`);
|
||||
console.log("ok: cold miss streams immediately, singleflight survives abort, partial bodies stay private, and index snapshots coalesce");
|
||||
} finally {
|
||||
await stop(gateway);
|
||||
if (upstream?.listening) {
|
||||
upstream.close();
|
||||
await once(upstream, "close");
|
||||
}
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function gatewayUrl(port, source) {
|
||||
return `http://127.0.0.1:${port}/api/map/cache?url=${encodeURIComponent(source)}`;
|
||||
}
|
||||
|
||||
async function readRemaining(reader) {
|
||||
const chunks = [];
|
||||
while (true) {
|
||||
const next = await reader.read();
|
||||
if (next.done) break;
|
||||
chunks.push(Buffer.from(next.value));
|
||||
}
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
|
||||
function abortAfterFirstChunk(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let resolved = false;
|
||||
const request = httpGet(url, (response) => {
|
||||
response.once("data", (chunk) => {
|
||||
resolved = true;
|
||||
resolve(String(chunk));
|
||||
response.destroy();
|
||||
});
|
||||
response.once("error", (error) => { if (!resolved) reject(error); });
|
||||
});
|
||||
request.once("error", (error) => { if (!resolved) reject(error); });
|
||||
});
|
||||
}
|
||||
|
||||
async function indexEntryCount(dir) {
|
||||
try {
|
||||
const index = JSON.parse(await readFile(join(dir, "index.json"), "utf8"));
|
||||
return Object.keys(index.entries || {}).length;
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") return 0;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function tempFiles(dir) {
|
||||
const found = [];
|
||||
async function walk(current) {
|
||||
let entries;
|
||||
try { entries = await readdir(current, { withFileTypes: true }); }
|
||||
catch (error) { if (error?.code === "ENOENT") return; throw error; }
|
||||
for (const entry of entries) {
|
||||
const path = join(current, entry.name);
|
||||
if (entry.isDirectory()) await walk(path);
|
||||
else if (entry.name.includes(".tmp")) found.push(path);
|
||||
}
|
||||
}
|
||||
await walk(dir);
|
||||
return found;
|
||||
}
|
||||
|
||||
async function waitFor(predicate) {
|
||||
for (let attempt = 0; attempt < 200; attempt += 1) {
|
||||
if (await predicate()) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
throw new Error("condition_timeout");
|
||||
}
|
||||
|
||||
async function freePort() {
|
||||
const server = createNetServer();
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
assert(address && typeof address === "object");
|
||||
const port = address.port;
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
return port;
|
||||
}
|
||||
|
||||
async function waitForGateway(port, child) {
|
||||
let output = "";
|
||||
child.stderr.on("data", (chunk) => { output += String(chunk); });
|
||||
for (let attempt = 0; attempt < 80; attempt += 1) {
|
||||
if (child.exitCode !== null) throw new Error(`gateway_exited:${child.exitCode}:${output}`);
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/healthz`);
|
||||
if (response.ok) return;
|
||||
} catch { /* service is still starting */ }
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
throw new Error(`gateway_start_timeout:${output}`);
|
||||
}
|
||||
|
||||
async function stop(child) {
|
||||
if (child && !child.killed) {
|
||||
child.kill("SIGTERM");
|
||||
await once(child, "exit").catch(() => undefined);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user