import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { createServer } from "node:http"; import test from "node:test"; const nodeCommand = process.env.HWLAB_TEST_NODE_COMMAND || process.env.HWLAB_NODE_COMMAND || "node"; test("edge proxy reports local health and proxies live health to upstream", async () => { const upstream = await startUpstream(); const proxy = await startEdgeProxy(upstream.port); try { const health = await fetchJson(`http://127.0.0.1:${proxy.port}/health`); assert.equal(health.serviceId, "hwlab-edge-proxy"); assert.equal(health.status, "ok"); assert.equal(health.service.role, "public-dev-ingress"); assert.equal(health.details.upstream, `http://127.0.0.1:${upstream.port}`); assert.equal(health.details.idleTimeoutSeconds, 120); assert.equal(health.details.mode, "dev-edge-proxy"); const routes = await fetchJson(`http://127.0.0.1:${proxy.port}/routes`); assert.equal(routes.serviceId, "hwlab-edge-proxy"); assert.deepEqual(routes.routes, [{ pathPrefix: "/", upstream: `http://127.0.0.1:${upstream.port}`, mode: "http-proxy" }]); const live = await fetchJson(`http://127.0.0.1:${proxy.port}/health/live`); assert.equal(live.serviceId, "hwlab-cloud-api"); assert.equal(live.environment, "v02"); assert.equal(upstream.captured.method, "GET"); assert.equal(upstream.captured.url, "/health/live"); } finally { await proxy.stop(); await upstream.stop(); } }); test("edge proxy proxies upstream port 6667 without using fetch bad-port handling", async () => { const upstream = await startUpstream(6667); const proxy = await startEdgeProxy(upstream.port); try { const live = await fetchJson(`http://127.0.0.1:${proxy.port}/health/live`); assert.equal(live.serviceId, "hwlab-cloud-api"); assert.equal(upstream.captured.url, "/health/live"); } finally { await proxy.stop(); await upstream.stop(); } }); test("edge proxy tunnels websocket upgrade to upstream", async () => { const upstream = await startWebSocketUpstream(); const proxy = await startEdgeProxy(upstream.port); try { const observed = await openWebSocket(`ws://127.0.0.1:${proxy.port}/v1/hwpod-node/ws`, () => ({ proxyStderr: proxy.stderr, upstreamEvents: upstream.events })); assert.equal(observed.type, "welcome"); assert.equal(observed.path, "/v1/hwpod-node/ws"); } finally { await proxy.stop(); await upstream.stop(); } }); async function startUpstream(port = 0) { let captured = null; const server = createServer(async (request, response) => { const chunks = []; for await (const chunk of request) chunks.push(chunk); captured = { method: request.method, url: request.url, body: Buffer.concat(chunks).toString("utf8") }; response.writeHead(200, { "content-type": "application/json" }); response.end(JSON.stringify({ serviceId: "hwlab-cloud-api", environment: "v02", status: "live" })); }); await new Promise((resolve) => server.listen(port, "127.0.0.1", resolve)); return { port: server.address().port, get captured() { return captured; }, stop: () => new Promise((resolve) => server.close(resolve)) }; } async function startWebSocketUpstream() { const events = []; const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch(request, bunServer) { const url = new URL(request.url); events.push({ event: "upgrade", url: url.pathname }); const upgraded = bunServer.upgrade(request, { data: { path: url.pathname } }); return upgraded ? undefined : new Response("upgrade required", { status: 426 }); }, websocket: { open(socket) { setTimeout(() => { socket.send(JSON.stringify({ type: "welcome", path: socket.data.path })); }, 50); } } }); return { port: server.port, events, stop: () => server.stop(true) }; } async function openWebSocket(url, debug = () => ({})) { return await new Promise((resolve, reject) => { const socket = new WebSocket(url); let finished = false; const done = (error, value) => { if (finished) return; finished = true; clearTimeout(timer); try { socket.close(); } catch {} if (error) reject(error); else resolve(value); }; const timer = setTimeout(() => done(new Error(`websocket did not receive message: ${url}; debug=${JSON.stringify(debug())}`)), 2500); socket.addEventListener("message", (event) => { dataToText(event.data) .then((text) => done(null, JSON.parse(text))) .catch((error) => done(error)); }); socket.addEventListener("error", () => done(new Error(`websocket error: ${url}`))); socket.addEventListener("close", (event) => done(new Error(`websocket closed before message: code=${event.code} reason=${event.reason || ""}`))); }); } async function dataToText(data) { if (typeof data === "string") return data; if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8"); if (ArrayBuffer.isView(data)) return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8"); if (data && typeof data.text === "function") return await data.text(); return String(data); } async function startEdgeProxy(upstreamPort) { const port = await freePort(); let stderr = ""; const child = spawn(nodeCommand, ["cmd/hwlab-edge-proxy/main.mjs"], { cwd: process.cwd(), env: { ...process.env, HWLAB_EDGE_HOST: "127.0.0.1", HWLAB_EDGE_PORT: String(port), HWLAB_EDGE_UPSTREAM: `http://127.0.0.1:${upstreamPort}` }, stdio: ["ignore", "pipe", "pipe"] }); child.stderr.on("data", (chunk) => { stderr += chunk; }); await waitForListening(child, "edge proxy"); return { port, get stderr() { return stderr; }, stop: () => stopChild(child) }; } async function fetchJson(url, options = {}) { const response = await fetch(url, options); assert.equal(response.ok, true, `${url} returned ${response.status}`); return response.json(); } async function freePort() { const server = createServer(); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const port = server.address().port; await new Promise((resolve) => server.close(resolve)); return port; } async function waitForListening(child, label) { let stderr = ""; child.stderr.on("data", (chunk) => { stderr += chunk; }); await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error(`${label} did not start: ${stderr}`)), 5000); child.stdout.on("data", (chunk) => { if (chunk.toString().includes("listening")) { clearTimeout(timer); resolve(); } }); child.on("exit", (code) => { clearTimeout(timer); reject(new Error(`${label} exited early code=${code}: ${stderr}`)); }); }); } async function stopChild(child) { if (child.exitCode !== null) return; child.kill("SIGTERM"); await new Promise((resolve) => child.once("exit", resolve)); }