From f9bf6231b55ff7283d29609c111683eb1a113bce Mon Sep 17 00:00:00 2001 From: Lyon <88232613+pikasTech@users.noreply.github.com> Date: Sun, 21 Jun 2026 19:06:03 +0800 Subject: [PATCH] fix: run edge proxy on node runtime (#1830) --- cmd/hwlab-edge-proxy/main.mjs | 285 ++++++++++++++++++++++++ cmd/hwlab-edge-proxy/main.test.ts | 4 +- cmd/hwlab-edge-proxy/main.ts | 244 -------------------- deploy/artifact-catalog.dev.json | 2 +- deploy/deploy.yaml | 12 +- deploy/runtime/boot/hwlab-edge-proxy.sh | 22 +- scripts/check-runner.test.mjs | 2 +- scripts/ci-plan.test.mjs | 12 +- scripts/gateway-outbound-demo-smoke.mjs | 5 +- 9 files changed, 314 insertions(+), 274 deletions(-) create mode 100644 cmd/hwlab-edge-proxy/main.mjs delete mode 100644 cmd/hwlab-edge-proxy/main.ts diff --git a/cmd/hwlab-edge-proxy/main.mjs b/cmd/hwlab-edge-proxy/main.mjs new file mode 100644 index 00000000..0b790488 --- /dev/null +++ b/cmd/hwlab-edge-proxy/main.mjs @@ -0,0 +1,285 @@ +#!/usr/bin/env node +import http, { request as httpRequest } from "node:http"; +import https, { request as httpsRequest } from "node:https"; +import { randomBytes } from "node:crypto"; + +import { + healthPayload, + resolveHostPort, +} from "../../internal/dev-entrypoint/http.mjs"; + +const serviceId = "hwlab-edge-proxy"; +const upstream = process.env.HWLAB_EDGE_UPSTREAM || "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667"; +const proxyTimeoutMs = parseTimeout(process.env.HWLAB_EDGE_PROXY_TIMEOUT_MS, 1260000, { + min: 1000, + max: 2400000 +}); +const idleTimeoutSeconds = parseTimeout(process.env.HWLAB_EDGE_IDLE_TIMEOUT_SECONDS, 120, { + min: 1, + max: 255 +}); +const { host, port } = resolveHostPort({ + listenEnv: "HWLAB_EDGE_LISTEN", + hostEnv: "HWLAB_EDGE_HOST", + portEnv: "HWLAB_EDGE_PORT", + fallbackPort: 6667 +}); + +const server = http.createServer(handleHttpRequest); +server.on("upgrade", handleUpgrade); +server.keepAliveTimeout = idleTimeoutSeconds * 1000; +server.headersTimeout = Math.max(60000, server.keepAliveTimeout + 5000); +server.requestTimeout = 0; + +server.listen(port, host, () => { + process.stdout.write(`${JSON.stringify({ serviceId, status: "listening", host, port })}\n`); +}); + +for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, () => { + server.close(() => process.exit(0)); + setTimeout(() => process.exit(0), 5000).unref(); + }); +} + +function handleHttpRequest(request, response) { + const url = incomingUrl(request); + + if (request.method === "GET" && url.pathname === "/health/live") { + proxyHttpRequest(request, response, upstream, proxyTimeoutMs); + return; + } + + if (request.method === "GET" && (url.pathname === "/health" || url.pathname === "/edge/health")) { + writeJson(response, healthPayload({ serviceId, role: "public-dev-ingress", details: proxyDetails() })); + return; + } + + if (request.method === "GET" && (url.pathname === "/status" || url.pathname === "/routes")) { + writeJson(response, { + serviceId, + environment: "dev", + status: "ok", + upstream, + routes: [{ pathPrefix: "/", upstream, mode: "http-proxy" }] + }); + return; + } + + if (request.method === "GET" && url.pathname === "/help") { + writeJson(response, { serviceId, commands: ["GET /health", "GET /edge/health", "GET /health/live", "GET /status", "GET /routes"] }); + return; + } + + if (isInternalRoute(url.pathname)) { + writeJson(response, { error: { code: "not_found", message: "Internal HWLAB routes are not exposed through the public edge proxy." } }, 404); + return; + } + + proxyHttpRequest(request, response, upstream, proxyTimeoutMs); +} + +function handleUpgrade(request, socket, head) { + const url = incomingUrl(request); + if (isInternalRoute(url.pathname)) { + socket.end("HTTP/1.1 404 Not Found\r\ncontent-type: text/plain; charset=utf-8\r\nconnection: close\r\n\r\nnot found"); + return; + } + const target = new URL(url.pathname + url.search, upstream); + const transport = target.protocol === "https:" ? httpsRequest : httpRequest; + const edgeTraceId = newTraceId(); + let timedOut = false; + const upstreamRequest = transport(target, { + method: request.method || "GET", + headers: websocketRequestHeaders(request.headers, target) + }); + const timeout = setTimeout(() => { + timedOut = true; + upstreamRequest.destroy(new Error(`upstream websocket timed out after ${proxyTimeoutMs}ms`)); + }, proxyTimeoutMs); + const finish = () => clearTimeout(timeout); + upstreamRequest.on("upgrade", (upstreamResponse, upstreamSocket, upstreamHead) => { + finish(); + logProxyEvent("ws.upstream_open", { edgeTraceId, path: url.pathname, upstreamUrl: redactUrlToken(target.toString()), statusCode: upstreamResponse.statusCode ?? 101 }); + socket.write(formatUpgradeResponse(upstreamResponse)); + if (upstreamHead?.length) socket.write(upstreamHead); + if (head?.length) upstreamSocket.write(head); + upstreamSocket.on("error", (error) => logProxyEvent("ws.upstream_socket_error", { edgeTraceId, path: url.pathname, message: error.message })); + socket.on("error", (error) => logProxyEvent("ws.client_socket_error", { edgeTraceId, path: url.pathname, message: error.message })); + upstreamSocket.pipe(socket); + socket.pipe(upstreamSocket); + }); + upstreamRequest.on("response", (upstreamResponse) => { + finish(); + socket.write(formatHttpResponse(upstreamResponse)); + upstreamResponse.pipe(socket); + }); + upstreamRequest.on("error", (error) => { + finish(); + logProxyEvent("ws.upstream_error", { edgeTraceId, path: url.pathname, timedOut, message: error.message }); + socket.end("HTTP/1.1 502 Bad Gateway\r\ncontent-type: application/json; charset=utf-8\r\nconnection: close\r\n\r\n" + JSON.stringify(proxyFailurePayload({ timedOut, timeoutMs: proxyTimeoutMs, upstreamUrl: upstream, edgeTraceId })) + "\n"); + }); + socket.on("error", () => upstreamRequest.destroy()); + upstreamRequest.end(); +} + +function proxyHttpRequest(request, response, upstreamUrl, timeoutMs) { + const target = new URL(incomingUrl(request).pathname + incomingUrl(request).search, upstreamUrl); + const transport = target.protocol === "https:" ? httpsRequest : httpRequest; + const edgeTraceId = newTraceId(); + let timedOut = false; + let upstreamResponded = false; + const upstreamRequest = transport(target, { + method: request.method || "GET", + headers: nodeRequestHeaders(request.headers, target) + }, (upstreamResponse) => { + upstreamResponded = true; + clearTimeout(timeout); + response.writeHead(upstreamResponse.statusCode ?? 502, responseHeadersFromNode(upstreamResponse.headers)); + upstreamResponse.on("error", (error) => response.destroy(error)); + upstreamResponse.pipe(response); + }); + const timeout = setTimeout(() => { + timedOut = true; + upstreamRequest.destroy(new Error(`upstream timed out after ${timeoutMs}ms`)); + }, timeoutMs); + upstreamRequest.on("error", (error) => { + clearTimeout(timeout); + logProxyEvent("http.upstream_error", { edgeTraceId, path: incomingUrl(request).pathname, timedOut, upstreamResponded, message: error.message }); + if (response.headersSent) { + response.destroy(error); + return; + } + writeJson(response, proxyFailurePayload({ timedOut, timeoutMs, upstreamUrl, edgeTraceId }), 502); + }); + request.on("aborted", () => upstreamRequest.destroy(new Error("client request aborted"))); + request.on("error", (error) => upstreamRequest.destroy(error)); + request.pipe(upstreamRequest); +} + +function proxyDetails() { + return { + upstream, + timeoutMs: proxyTimeoutMs, + idleTimeoutSeconds, + mode: "dev-edge-proxy" + }; +} + +function parseTimeout(value, fallback, { min, max }) { + const parsed = Number.parseInt(value || "", 10); + if (!Number.isInteger(parsed)) return fallback; + return Math.min(Math.max(parsed, min), max); +} + +function incomingUrl(request) { + const authority = request.headers.host || `${host}:${port}`; + return new URL(request.url || "/", `http://${authority}`); +} + +function nodeRequestHeaders(headers, target) { + const next = {}; + for (const [key, value] of Object.entries(headers)) { + if (value === undefined || isHopByHopHeader(key)) continue; + next[key] = value; + } + next.host = target.host; + return next; +} + +function websocketRequestHeaders(headers, target) { + const next = {}; + for (const [key, value] of Object.entries(headers)) { + if (value === undefined || key.toLowerCase() === "host") continue; + next[key] = value; + } + next.host = target.host; + return next; +} + +function responseHeadersFromNode(headers) { + const next = {}; + for (const [key, value] of Object.entries(headers)) { + if (value === undefined || isHopByHopHeader(key)) continue; + next[key] = value; + } + next["cache-control"] ??= "no-store"; + return next; +} + +function formatUpgradeResponse(response) { + return formatStatusAndHeaders(response.statusCode ?? 101, response.statusMessage || "Switching Protocols", response.rawHeaders); +} + +function formatHttpResponse(response) { + return formatStatusAndHeaders(response.statusCode ?? 502, response.statusMessage || http.STATUS_CODES[response.statusCode ?? 502] || "Bad Gateway", response.rawHeaders); +} + +function formatStatusAndHeaders(statusCode, statusMessage, rawHeaders) { + const lines = [`HTTP/1.1 ${statusCode} ${statusMessage}`]; + for (let index = 0; index < rawHeaders.length; index += 2) { + lines.push(`${rawHeaders[index]}: ${rawHeaders[index + 1]}`); + } + lines.push("", ""); + return lines.join("\r\n"); +} + +function isHopByHopHeader(key) { + return ["connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade"].includes(key.toLowerCase()); +} + +function isInternalRoute(pathname) { + return pathname === "/v1/internal" || pathname.startsWith("/v1/internal/"); +} + +function proxyFailurePayload({ timedOut, timeoutMs, upstreamUrl, edgeTraceId }) { + const code = timedOut ? "proxy_timeout" : "upstream_unavailable"; + const userMessage = timedOut + ? `Code Agent 代理等待上游超过 ${timeoutMs}ms;输入已保留,可稍后重试。` + : "Code Agent 代理暂时无法连接上游;输入已保留,可稍后重试。"; + return { + status: "failed", + error: { + code, + layer: "edge-proxy", + category: timedOut ? "timeout" : "proxy", + retryable: true, + userMessage, + message: userMessage, + diagnostic: { + contractVersion: "hwlab-error-diagnostic-v1", + traceId: edgeTraceId, + serviceId, + layer: "edge-proxy", + category: timedOut ? "timeout" : "proxy", + code, + httpStatus: 502, + source: "edge-proxy", + observedAt: new Date().toISOString(), + valuesPrinted: false + }, + blocker: { code, layer: "edge-proxy", retryable: true, summary: userMessage } + }, + upstream: upstreamUrl, + message: userMessage + }; +} + +function writeJson(response, body, status = 200) { + response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); + response.end(`${JSON.stringify(body, null, 2)}\n`); +} + +function newTraceId() { + return randomBytes(16).toString("hex"); +} + +function logProxyEvent(event, fields) { + process.stderr.write(`${JSON.stringify({ event: `hwlab-edge-proxy.${event}`, observedAt: new Date().toISOString(), valuesPrinted: false, ...fields })}\n`); +} + +function redactUrlToken(urlValue) { + const parsed = new URL(urlValue); + if (parsed.searchParams.has("token")) parsed.searchParams.set("token", ""); + return parsed.toString(); +} diff --git a/cmd/hwlab-edge-proxy/main.test.ts b/cmd/hwlab-edge-proxy/main.test.ts index b7aa9d85..00fc2661 100644 --- a/cmd/hwlab-edge-proxy/main.test.ts +++ b/cmd/hwlab-edge-proxy/main.test.ts @@ -3,7 +3,7 @@ import { spawn } from "node:child_process"; import { createServer } from "node:http"; import test from "node:test"; -const bunCommand = process.env.HWLAB_TEST_BUN_COMMAND || process.env.HWLAB_BUN_COMMAND || (process.versions.bun ? process.execPath : "bun"); +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(); @@ -150,7 +150,7 @@ async function dataToText(data) { async function startEdgeProxy(upstreamPort) { const port = await freePort(); let stderr = ""; - const child = spawn(bunCommand, ["run", "cmd/hwlab-edge-proxy/main.ts"], { + const child = spawn(nodeCommand, ["cmd/hwlab-edge-proxy/main.mjs"], { cwd: process.cwd(), env: { ...process.env, diff --git a/cmd/hwlab-edge-proxy/main.ts b/cmd/hwlab-edge-proxy/main.ts deleted file mode 100644 index 95c0ee19..00000000 --- a/cmd/hwlab-edge-proxy/main.ts +++ /dev/null @@ -1,244 +0,0 @@ -#!/usr/bin/env bun -import { request as httpRequest } from "node:http"; -import { request as httpsRequest } from "node:https"; -import { Readable } from "node:stream"; - -import { - healthPayload, - resolveHostPort, -} from "../../internal/dev-entrypoint/http.mjs"; - -const serviceId = "hwlab-edge-proxy"; -const upstream = process.env.HWLAB_EDGE_UPSTREAM || "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667"; -const proxyTimeoutMs = parseTimeout(process.env.HWLAB_EDGE_PROXY_TIMEOUT_MS, 1260000, { - min: 1000, - max: 2400000 -}); -const idleTimeoutSeconds = parseTimeout(process.env.HWLAB_EDGE_IDLE_TIMEOUT_SECONDS, 120, { - min: 1, - max: 255 -}); -const { host, port } = resolveHostPort({ - listenEnv: "HWLAB_EDGE_LISTEN", - hostEnv: "HWLAB_EDGE_HOST", - portEnv: "HWLAB_EDGE_PORT", - fallbackPort: 6667 -}); - -function proxyDetails() { - return { - upstream, - timeoutMs: proxyTimeoutMs, - idleTimeoutSeconds, - mode: "dev-edge-proxy" - }; -} - -function parseTimeout(value, fallback, { min, max }) { - const parsed = Number.parseInt(value || "", 10); - if (!Number.isInteger(parsed)) return fallback; - return Math.min(Math.max(parsed, min), max); -} - -const server = Bun.serve({ - hostname: host, - port, - idleTimeout: idleTimeoutSeconds, - async fetch(request, bunServer) { - const url = new URL(request.url); - - if (isWebSocketUpgrade(request)) { - if (url.pathname === "/v1/internal" || url.pathname.startsWith("/v1/internal/")) return text("not found", 404); - const upgraded = bunServer.upgrade(request, { data: { upstreamUrl: upstreamWebSocketUrl(request, upstream), upstreamSocket: null, upstreamOpen: false, pendingMessages: [] } }); - return upgraded ? undefined : json({ ok: false, status: "upgrade_required" }, 426); - } - - if (request.method === "GET" && url.pathname === "/health/live") return proxyFetch(request, upstream, proxyTimeoutMs); - - if (request.method === "GET" && (url.pathname === "/health" || url.pathname === "/edge/health")) { - return json(healthPayload({ serviceId, role: "public-dev-ingress", details: proxyDetails() })); - } - - if (request.method === "GET" && (url.pathname === "/status" || url.pathname === "/routes")) { - return json({ - serviceId, - environment: "dev", - status: "ok", - upstream, - routes: [{ pathPrefix: "/", upstream, mode: "http-proxy" }] - }); - } - - if (request.method === "GET" && url.pathname === "/help") { - return json({ serviceId, commands: ["GET /health", "GET /edge/health", "GET /health/live", "GET /status", "GET /routes"] }); - } - - if (url.pathname === "/v1/internal" || url.pathname.startsWith("/v1/internal/")) { - return json({ error: { code: "not_found", message: "Internal HWLAB routes are not exposed through the public edge proxy." } }, 404); - } - - return proxyFetch(request, upstream, proxyTimeoutMs); - }, - websocket: { - open(socket: any) { - const upstreamSocket = new WebSocket(socket.data.upstreamUrl); - socket.data.upstreamSocket = upstreamSocket; - upstreamSocket.addEventListener("open", () => { - logWsBridge("upstream_open", socket.data.upstreamUrl); - socket.data.upstreamOpen = true; - for (const message of socket.data.pendingMessages.splice(0)) upstreamSocket.send(message); - }); - upstreamSocket.addEventListener("message", (event) => { - logWsBridge("upstream_message", socket.data.upstreamUrl); - try { - socket.send(event.data); - } catch { - logWsBridge("client_send_failed", socket.data.upstreamUrl); - upstreamSocket.close(); - } - }); - upstreamSocket.addEventListener("close", (event) => { - logWsBridge("upstream_close", socket.data.upstreamUrl, { code: event.code, reason: event.reason || null }); - try { - socket.close(event.code || 1000, event.reason || undefined); - } catch {} - }); - upstreamSocket.addEventListener("error", () => { - logWsBridge("upstream_error", socket.data.upstreamUrl); - try { - socket.close(1011, "upstream websocket error"); - } catch {} - }); - }, - message(socket: any, message: string | Buffer) { - const upstreamSocket = socket.data.upstreamSocket; - if (socket.data.upstreamOpen && upstreamSocket?.readyState === WebSocket.OPEN) upstreamSocket.send(message); - else socket.data.pendingMessages.push(message); - }, - close(socket: any) { - const upstreamSocket = socket.data.upstreamSocket; - if (upstreamSocket?.readyState === WebSocket.OPEN || upstreamSocket?.readyState === WebSocket.CONNECTING) upstreamSocket.close(); - } - } -}); - -process.stdout.write(`${JSON.stringify({ serviceId, status: "listening", host, port: server.port })}\n`); - -for (const signal of ["SIGINT", "SIGTERM"]) { - process.on(signal, () => { - server.stop(true); - process.exit(0); - }); -} - -async function proxyFetch(request: Request, upstreamUrl: string, timeoutMs: number) { - const target = new URL(upstreamHttpUrl(request, upstreamUrl)); - return await proxyNodeRequest(request, target, upstreamUrl, timeoutMs); -} - -function proxyNodeRequest(request: Request, target: URL, upstreamUrl: string, timeoutMs: number): Promise { - const method = request.method || "GET"; - let timedOut = false; - return new Promise((resolve) => { - let settled = false; - let timeout: ReturnType | null = null; - const fail = () => { - if (settled) return; - settled = true; - if (timeout) clearTimeout(timeout); - resolve(proxyFailureResponse({ timedOut, timeoutMs, upstreamUrl })); - }; - const transport = target.protocol === "https:" ? httpsRequest : httpRequest; - const upstreamRequest = transport(target, { method, headers: nodeRequestHeaders(request.headers, target) }, (upstreamResponse) => { - if (settled) { - upstreamResponse.resume(); - return; - } - settled = true; - if (timeout) clearTimeout(timeout); - const status = upstreamResponse.statusCode ?? 502; - const body = method === "HEAD" || status === 204 || status === 304 ? null : Readable.toWeb(upstreamResponse) as unknown as ReadableStream; - resolve(new Response(body, { status, headers: responseHeadersFromNode(upstreamResponse.headers) })); - }); - timeout = setTimeout(() => { - timedOut = true; - upstreamRequest.destroy(new Error(`upstream timed out after ${timeoutMs}ms`)); - }, timeoutMs); - upstreamRequest.on("error", fail); - if (method === "GET" || method === "HEAD" || !request.body) { - upstreamRequest.end(); - return; - } - const bodyStream = Readable.fromWeb(request.body as unknown as ReadableStream); - bodyStream.on("error", (error) => upstreamRequest.destroy(error)); - bodyStream.pipe(upstreamRequest); - }); -} - -function nodeRequestHeaders(headers: Headers, target: URL): Record { - const next: Record = {}; - for (const [key, value] of headers.entries()) { - if (isHopByHopHeader(key)) continue; - next[key] = value; - } - next.host = target.host; - return next; -} - -function responseHeadersFromNode(headers: Record): Headers { - const next = new Headers(); - for (const [key, value] of Object.entries(headers)) { - if (value === undefined || isHopByHopHeader(key)) continue; - if (Array.isArray(value)) { - for (const item of value) next.append(key, item); - continue; - } - next.set(key, String(value)); - } - return next; -} - -function isHopByHopHeader(key: string): boolean { - return ["connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade"].includes(key.toLowerCase()); -} - -function proxyFailureResponse({ timedOut, timeoutMs, upstreamUrl }: { timedOut: boolean; timeoutMs: number; upstreamUrl: string }) { - const code = timedOut ? "proxy_timeout" : "upstream_unavailable"; - const userMessage = timedOut - ? `Code Agent 代理等待上游超过 ${timeoutMs}ms;输入已保留,可稍后重试。` - : "Code Agent 代理暂时无法连接上游;输入已保留,可稍后重试。"; - return json({ status: "failed", error: { code, layer: "proxy", category: timedOut ? "timeout" : "proxy", retryable: true, userMessage, message: userMessage, blocker: { code, layer: "proxy", retryable: true, summary: userMessage } }, upstream: upstreamUrl, message: userMessage }, 502); -} - -function upstreamHttpUrl(request: Request, upstreamUrl: string) { - const source = new URL(request.url); - return new URL(source.pathname + source.search, upstreamUrl).toString(); -} - -function upstreamWebSocketUrl(request: Request, upstreamUrl: string) { - const target = new URL(upstreamHttpUrl(request, upstreamUrl)); - target.protocol = target.protocol === "https:" ? "wss:" : "ws:"; - return target.toString(); -} - -function isWebSocketUpgrade(request: Request) { - return request.headers.get("upgrade")?.toLowerCase() === "websocket"; -} - -function json(body: any, status = 200) { - return new Response(`${JSON.stringify(body, null, 2)}\n`, { status, headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" } }); -} - -function text(body: string, status = 200) { - return new Response(body, { status, headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" } }); -} - -function logWsBridge(event: string, upstreamUrl: string, extra: Record = {}) { - process.stderr.write(`${JSON.stringify({ event: `hwlab-edge-proxy.ws.${event}`, upstreamUrl: redactUrlToken(upstreamUrl), observedAt: new Date().toISOString(), ...extra })}\n`); -} - -function redactUrlToken(urlValue: string) { - const parsed = new URL(urlValue); - if (parsed.searchParams.has("token")) parsed.searchParams.set("token", ""); - return parsed.toString(); -} diff --git a/deploy/artifact-catalog.dev.json b/deploy/artifact-catalog.dev.json index f43c30e4..71ecefe3 100644 --- a/deploy/artifact-catalog.dev.json +++ b/deploy/artifact-catalog.dev.json @@ -297,7 +297,7 @@ "runtimeKind": "node-command", "implementationState": "repo-entrypoint", "sourceState": "source-present", - "entrypoint": "cmd/hwlab-edge-proxy/main.ts", + "entrypoint": "cmd/hwlab-edge-proxy/main.mjs", "disabledReason": null }, { diff --git a/deploy/deploy.yaml b/deploy/deploy.yaml index fdd7a7e1..b0bb9275 100644 --- a/deploy/deploy.yaml +++ b/deploy/deploy.yaml @@ -160,9 +160,9 @@ lanes: env: {} observable: false hwlab-edge-proxy: - runtimeKind: bun-command - entrypoint: cmd/hwlab-edge-proxy/main.ts - artifactKind: bun-command + runtimeKind: node-command + entrypoint: cmd/hwlab-edge-proxy/main.mjs + artifactKind: node-command healthPath: /health healthPort: 6667 componentPaths: @@ -412,9 +412,9 @@ lanes: TZ: Asia/Shanghai observable: false hwlab-edge-proxy: - runtimeKind: bun-command - entrypoint: cmd/hwlab-edge-proxy/main.ts - artifactKind: bun-command + runtimeKind: node-command + entrypoint: cmd/hwlab-edge-proxy/main.mjs + artifactKind: node-command healthPath: /health healthPort: 6667 healthProbe: diff --git a/deploy/runtime/boot/hwlab-edge-proxy.sh b/deploy/runtime/boot/hwlab-edge-proxy.sh index 4f21e41e..dd315aa0 100644 --- a/deploy/runtime/boot/hwlab-edge-proxy.sh +++ b/deploy/runtime/boot/hwlab-edge-proxy.sh @@ -2,8 +2,8 @@ set -eu export HWLAB_SERVICE_ID="hwlab-edge-proxy" -export HWLAB_ARTIFACT_KIND="bun-command" -export HWLAB_SERVICE_ENTRYPOINT="cmd/hwlab-edge-proxy/main.ts" +export HWLAB_ARTIFACT_KIND="node-command" +export HWLAB_SERVICE_ENTRYPOINT="cmd/hwlab-edge-proxy/main.mjs" export HWLAB_RUNTIME_MODE="${HWLAB_RUNTIME_MODE:-env-reuse-git-mirror-checkout}" export HWLAB_COMMIT_ID="${HWLAB_BOOT_COMMIT:?HWLAB_BOOT_COMMIT is required}" export HWLAB_REVISION="${HWLAB_BOOT_COMMIT}" @@ -15,18 +15,16 @@ export HWLAB_PORT="$PORT" export HWLAB_EDGE_PORT="$edge_port" export HWLAB_EDGE_LISTEN="${HWLAB_EDGE_LISTEN:-0.0.0.0:$edge_port}" -bun_bin="${HWLAB_BUN_COMMAND:-}" -if [ -z "$bun_bin" ]; then - if command -v bun >/dev/null 2>&1; then - bun_bin="$(command -v bun)" - elif [ -x /usr/local/bin/bun ]; then - bun_bin="/usr/local/bin/bun" - elif [ -x ./node_modules/.bin/bun ]; then - bun_bin="./node_modules/.bin/bun" +node_bin="${HWLAB_NODE_COMMAND:-}" +if [ -z "$node_bin" ]; then + if command -v node >/dev/null 2>&1; then + node_bin="$(command -v node)" + elif [ -x /usr/local/bin/node ]; then + node_bin="/usr/local/bin/node" else - echo "hwlab-edge-proxy boot failed: bun not found" >&2 + echo "hwlab-edge-proxy boot failed: node not found" >&2 exit 127 fi fi -exec "$bun_bin" run cmd/hwlab-edge-proxy/main.ts +exec "$node_bin" cmd/hwlab-edge-proxy/main.mjs diff --git a/scripts/check-runner.test.mjs b/scripts/check-runner.test.mjs index 647b96ba..4ff15d7e 100644 --- a/scripts/check-runner.test.mjs +++ b/scripts/check-runner.test.mjs @@ -25,7 +25,7 @@ test("check plan runs migrated runtime service TypeScript tests", () => { const commands = checkProfiles.check.map((task) => task.command.join(" ")); assert.ok(commands.some((command) => command.includes("cmd/hwlab-edge-proxy/main.test.ts"))); assert.ok(commands.some((command) => command.includes("cmd/hwlab-deepseek-responses-bridge/main.test.ts"))); - assert.equal(commands.some((command) => command.includes("cmd/hwlab-edge-proxy/main.mjs")), false); + assert.equal(commands.some((command) => command.includes("cmd/hwlab-edge-proxy/main.ts")), false); assert.equal(commands.some((command) => command.includes("cmd/hwlab-deepseek-responses-bridge/main.test.mjs")), false); }); diff --git a/scripts/ci-plan.test.mjs b/scripts/ci-plan.test.mjs index 083ec912..d73a3ddf 100644 --- a/scripts/ci-plan.test.mjs +++ b/scripts/ci-plan.test.mjs @@ -564,7 +564,7 @@ test("v02 planner enables env reuse for all retained runtime services", async () await writeFile(path.join(repo, "cmd/hwlab-cloud-api/main.ts"), "console.log('api v2');\n"); await writeFile(path.join(repo, "web/hwlab-cloud-web/index.html"), "v2\n"); await writeFile(path.join(repo, "cmd/hwlab-gateway/main.ts"), "console.log('gateway v2');\n"); - await writeFile(path.join(repo, "cmd/hwlab-edge-proxy/main.ts"), "console.log('edge v2');\n"); + await writeFile(path.join(repo, "cmd/hwlab-edge-proxy/main.mjs"), "console.log('edge v2');\n"); await writeFile(path.join(repo, "skills/hwlab-agent-runtime/SKILL.md"), "agent runtime skill v2\n"); await git(repo, ["add", "."]); await git(repo, ["commit", "-m", "change all v02 service code"]); @@ -761,7 +761,7 @@ async function createFixtureRepo(options = {}) { await writeFile(path.join(repo, "cmd/hwlab-deepseek-responses-bridge/main.ts"), "console.log('bridge');\n"); await writeFile(path.join(repo, "cmd/hwlab-deepseek-responses-bridge/main.test.ts"), "console.log('test');\n"); await writeFile(path.join(repo, "cmd/hwlab-gateway/main.ts"), "console.log('gateway');\n"); - await writeFile(path.join(repo, "cmd/hwlab-edge-proxy/main.ts"), "console.log('edge');\n"); + await writeFile(path.join(repo, "cmd/hwlab-edge-proxy/main.mjs"), "console.log('edge');\n"); await writeFile(path.join(repo, "web/hwlab-cloud-web/index.html"), "\n"); await writeFile(path.join(repo, "internal/dev-entrypoint/artifact-runtime.mjs"), "export const artifactRuntime = 1;\n"); await writeFile(path.join(repo, "internal/dev-entrypoint/cloud-web-runtime.mjs"), "export const runtime = 1;\n"); @@ -785,7 +785,7 @@ async function createFixtureRepo(options = {}) { await writeFile(path.join(repo, "deploy/runtime/boot/hwlab-codex-api-responses-forwarder.sh"), "#!/bin/sh\nexec bun run cmd/hwlab-codex-api-responses-forwarder/main.ts\n"); await writeFile(path.join(repo, "deploy/runtime/boot/hwlab-deepseek-responses-bridge.sh"), "#!/bin/sh\nexec bun run cmd/hwlab-deepseek-responses-bridge/main.ts\n"); await writeFile(path.join(repo, "deploy/runtime/boot/hwlab-gateway.sh"), "#!/bin/sh\nexec bun run cmd/hwlab-gateway/main.ts\n"); - await writeFile(path.join(repo, "deploy/runtime/boot/hwlab-edge-proxy.sh"), "#!/bin/sh\nexec bun run cmd/hwlab-edge-proxy/main.ts\n"); + await writeFile(path.join(repo, "deploy/runtime/boot/hwlab-edge-proxy.sh"), "#!/bin/sh\nexec node cmd/hwlab-edge-proxy/main.mjs\n"); await writeFile(path.join(repo, "deploy/runtime/boot/hwlab-agent-skills.sh"), "#!/bin/sh\nexec bun .hwlab-agent-skills-runtime.mjs\n"); await writeFile(path.join(repo, "deploy/runtime/launcher/hwlab-env-reuse-launcher.ts"), "console.log('launcher');\n"); await writeFile(path.join(repo, "skills/hwlab-agent-runtime/SKILL.md"), "agent runtime skill\n"); @@ -903,9 +903,9 @@ function createDeployFixture({ serviceIds = V02_RUNTIME_SERVICE_IDS } = {}) { observable: false }, "hwlab-edge-proxy": { - runtimeKind: "bun-command", - entrypoint: "cmd/hwlab-edge-proxy/main.ts", - artifactKind: "bun-command", + runtimeKind: "node-command", + entrypoint: "cmd/hwlab-edge-proxy/main.mjs", + artifactKind: "node-command", healthPath: "/health", healthPort: 6667, componentPaths: ["cmd/hwlab-edge-proxy/", "internal/dev-entrypoint/http.mjs"], diff --git a/scripts/gateway-outbound-demo-smoke.mjs b/scripts/gateway-outbound-demo-smoke.mjs index ffcecf28..4d46c6fd 100644 --- a/scripts/gateway-outbound-demo-smoke.mjs +++ b/scripts/gateway-outbound-demo-smoke.mjs @@ -27,6 +27,7 @@ const commonEnv = { }; const children = []; const bunCommand = resolveBunCommand(); +const nodeCommand = process.env.HWLAB_NODE_COMMAND || "node"; try { const cloud = startNode("cmd/hwlab-cloud-api/main.ts", { @@ -39,7 +40,7 @@ try { await waitForJson(`http://127.0.0.1:${cloudPort}/health/live`); if (useEdgeProxy) { - const edge = startNode("cmd/hwlab-edge-proxy/main.ts", { + const edge = startNode("cmd/hwlab-edge-proxy/main.mjs", { ...commonEnv, HWLAB_EDGE_HOST: "127.0.0.1", HWLAB_EDGE_PORT: String(edgePort), @@ -137,7 +138,7 @@ try { } function startNode(entrypoint, env) { - const command = entrypoint.endsWith(".ts") ? bunCommand : process.execPath; + const command = entrypoint.endsWith(".ts") ? bunCommand : nodeCommand; const child = spawn(command, [entrypoint], { cwd, env,