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); } }