From d7ff08f3f26b474038d7fccd63625dcb8beffe4d Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 29 May 2026 15:01:56 +0800 Subject: [PATCH] refactor: run hwlab gateway through bun ts --- cmd/hwlab-gateway/main.test.ts | 223 +++++++++++++++++++++++ cmd/hwlab-gateway/{main.mjs => main.ts} | 2 +- deploy/artifact-catalog.dev.json | 4 +- docs/reference/gateway-outbound-demo.md | 2 +- docs/reference/spec-v02-hwlab-gateway.md | 2 +- scripts/gateway-outbound-demo-smoke.mjs | 2 +- scripts/src/check-plan.mjs | 4 +- scripts/src/g14-ci-plan-lib.mjs | 1 + 8 files changed, 232 insertions(+), 8 deletions(-) create mode 100644 cmd/hwlab-gateway/main.test.ts rename cmd/hwlab-gateway/{main.mjs => main.ts} (99%) diff --git a/cmd/hwlab-gateway/main.test.ts b/cmd/hwlab-gateway/main.test.ts new file mode 100644 index 00000000..c3aba337 --- /dev/null +++ b/cmd/hwlab-gateway/main.test.ts @@ -0,0 +1,223 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { createServer } from "node:http"; +import test from "node:test"; +import { setTimeout as delay } from "node:timers/promises"; + +const bunCommand = process.env.HWLAB_TEST_BUN_COMMAND || process.env.HWLAB_BUN_COMMAND || (process.versions.bun ? process.execPath : "bun"); + +test("gateway outbound transport registers session and keeps shell execution disabled by default", async () => { + const cloud = await startFakeCloud(); + const gateway = await startGateway(cloud.port, { + HWLAB_GATEWAY_SESSION_ID: "gws_gateway_disabled_test", + HWLAB_GATEWAY_CMD_EXEC_ENABLED: "" + }); + try { + const registration = await cloud.waitForRegistration((item) => item.gatewaySessionId === "gws_gateway_disabled_test"); + assert.equal(registration.serviceId, "hwlab-gateway"); + assert.equal(registration.outbound.commandExecutionEnabled, false); + + const health = await fetchJson(`http://127.0.0.1:${gateway.port}/health/live`); + assert.equal(health.serviceId, "hwlab-gateway"); + assert.equal(health.gatewaySessionId, "gws_gateway_disabled_test"); + + const status = await fetchJson(`http://127.0.0.1:${gateway.port}/status`); + assert.equal(status.outbound.commandExecutionEnabled, false); + + const capabilities = await fetchJson(`http://127.0.0.1:${gateway.port}/capabilities`); + assert.equal(capabilities.capabilities[0].constraints.demoOpen, false); + + cloud.enqueue(shellRequest({ id: "req_disabled_shell", traceId: "trc_disabled_shell", command: "echo should-not-run" })); + const result = await cloud.waitForResult("req_disabled_shell"); + assert.equal(result.response.error.data.reason, "cmd_exec_disabled"); + assert.equal(result.gatewaySessionId, "gws_gateway_disabled_test"); + } finally { + await gateway.stop(); + await cloud.stop(); + } +}); + +test("gateway outbound transport executes cloud requests without blocking poll loop", async () => { + const cloud = await startFakeCloud(); + const gateway = await startGateway(cloud.port, { + HWLAB_GATEWAY_SESSION_ID: "gws_gateway_nonblocking_test", + HWLAB_GATEWAY_CMD_EXEC_ENABLED: "1", + HWLAB_GATEWAY_MAX_INFLIGHT: "2" + }); + try { + await cloud.waitForRegistration((item) => item.gatewaySessionId === "gws_gateway_nonblocking_test"); + cloud.enqueue(shellRequest({ + id: "req_slow_shell", + traceId: "trc_slow_shell", + command: "node -e \"setTimeout(()=>console.log('slow-done'),800)\"" + })); + await cloud.waitForRegistration((item) => item.outbound.inflightCount >= 1); + + const quickStartedAt = Date.now(); + cloud.enqueue(shellRequest({ id: "req_quick_shell", traceId: "trc_quick_shell", command: "echo quick-done" })); + const quick = await cloud.waitForResult("req_quick_shell"); + const quickDurationMs = Date.now() - quickStartedAt; + const slow = await cloud.waitForResult("req_slow_shell"); + + assert.equal(quick.response.result.shellExecuted, true, JSON.stringify(quick.response, null, 2)); + assert.equal(quick.response.result.exitCode, 0, JSON.stringify(quick.response, null, 2)); + assert.match(quick.response.result.stdout, /quick-done/u); + assert.equal(slow.response.result.shellExecuted, true, JSON.stringify(slow.response, null, 2)); + assert.equal(slow.response.result.exitCode, 0, JSON.stringify(slow.response, null, 2)); + assert.match(slow.response.result.stdout, /slow-done/u); + assert.ok(quick.receivedAt < slow.receivedAt, "quick request should complete before the earlier slow request"); + assert.ok(quickDurationMs < 700, `quick request was blocked for ${quickDurationMs}ms`); + assert.ok(cloud.registrations.some((item) => item.outbound.inflightCount >= 1)); + } finally { + await gateway.stop(); + await cloud.stop(); + } +}); + +async function startGateway(cloudPort: number, env: Record) { + const port = await freePort(); + const child = spawn(bunCommand, ["run", "cmd/hwlab-gateway/main.ts"], { + cwd: process.cwd(), + env: { + ...process.env, + ...env, + PORT: String(port), + HWLAB_GATEWAY_ID: env.HWLAB_GATEWAY_ID || "gtw_gateway_test", + HWLAB_GATEWAY_CLOUD_URL: `http://127.0.0.1:${cloudPort}`, + HWLAB_GATEWAY_POLL_INTERVAL_MS: "20", + HWLAB_GATEWAY_CMD_TIMEOUT_MS: "3000", + HWLAB_GATEWAY_CMD_OUTPUT_LIMIT_BYTES: "4096", + NO_PROXY: mergeNoProxy(process.env.NO_PROXY, "127.0.0.1,localhost,::1"), + no_proxy: mergeNoProxy(process.env.no_proxy, "127.0.0.1,localhost,::1") + }, + stdio: ["ignore", "pipe", "pipe"] + }); + await waitForListening(child, "hwlab-gateway"); + return { + port, + stop: () => stopChild(child) + }; +} + +async function startFakeCloud() { + const requests: any[] = []; + const registrations: any[] = []; + const results: any[] = []; + const server = createServer(async (request, response) => { + try { + const body = await readJson(request); + if (request.method === "POST" && request.url === "/v1/gateway/poll") { + registrations.push({ ...body, receivedAt: Date.now() }); + sendJson(response, 200, { ok: true, request: requests.shift() ?? null }); + return; + } + if (request.method === "POST" && request.url === "/v1/gateway/result") { + results.push({ ...body, receivedAt: Date.now() }); + sendJson(response, 200, { ok: true }); + return; + } + sendJson(response, 404, { error: "not_found" }); + } catch (error) { + sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) }); + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + return { + port: (server.address() as any).port as number, + registrations, + enqueue: (rpcRequest: any) => requests.push(rpcRequest), + waitForRegistration: (predicate: (item: any) => boolean) => waitFor(() => registrations.find(predicate), "gateway registration"), + waitForResult: (id: string) => waitFor(() => results.find((item) => item.response?.id === id), `gateway result ${id}`), + stop: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())) + }; +} + +function shellRequest({ id, traceId, command }: { id: string; traceId: string; command: string }) { + return { + jsonrpc: "2.0", + id, + method: "hardware.invoke.shell", + params: { + projectId: "prj_v02_device_pod", + gatewaySessionId: "gws_gateway_test", + resourceId: "res_d601_windows_host", + capabilityId: "cap_d601_windows_cmd_exec", + operationId: `op_${id}`, + input: { command, cwd: process.cwd(), timeoutMs: 3000 } + }, + meta: { traceId, actorId: "svc_hwlab-device-pod", serviceId: "hwlab-cloud-api", environment: "v02" } + }; +} + +async function waitFor(read: () => T | undefined, label: string, timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const value = read(); + if (value) return value; + await delay(20); + } + throw new Error(`timed out waiting for ${label}`); +} + +async function fetchJson(url: string) { + const response = await fetch(url, { headers: { accept: "application/json" } }); + assert.equal(response.ok, true, `${url} returned ${response.status}`); + return response.json(); +} + +function readJson(request: any) { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + request.on("data", (chunk: Buffer) => chunks.push(Buffer.from(chunk))); + request.on("end", () => { + const text = Buffer.concat(chunks).toString("utf8").trim(); + resolve(text ? JSON.parse(text) : {}); + }); + request.on("error", reject); + }); +} + +function sendJson(response: any, statusCode: number, body: any) { + const payload = JSON.stringify(body); + response.writeHead(statusCode, { "content-type": "application/json", "content-length": Buffer.byteLength(payload) }); + response.end(payload); +} + +async function freePort() { + const server = createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const port = (server.address() as any).port as number; + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + return port; +} + +async function waitForListening(child: any, label: string) { + let stderr = ""; + child.stderr.on("data", (chunk: Buffer) => { stderr += chunk.toString("utf8"); }); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`${label} did not start: ${stderr}`)), 5000); + child.stdout.on("data", (chunk: Buffer) => { + if (chunk.toString("utf8").includes("listening")) { + clearTimeout(timer); + resolve(); + } + }); + child.on("exit", (code: number) => { + clearTimeout(timer); + reject(new Error(`${label} exited early code=${code}: ${stderr}`)); + }); + }); +} + +async function stopChild(child: any) { + if (child.exitCode !== null || child.signalCode !== null) return; + child.kill("SIGTERM"); + await Promise.race([ + new Promise((resolve) => child.on("exit", () => resolve())), + delay(1000).then(() => child.kill("SIGKILL")) + ]); +} + +function mergeNoProxy(...values: Array) { + return [...new Set(values.flatMap((value) => String(value ?? "").split(",").map((item) => item.trim()).filter(Boolean)))].join(","); +} diff --git a/cmd/hwlab-gateway/main.mjs b/cmd/hwlab-gateway/main.ts similarity index 99% rename from cmd/hwlab-gateway/main.mjs rename to cmd/hwlab-gateway/main.ts index aa408252..8decccfb 100644 --- a/cmd/hwlab-gateway/main.mjs +++ b/cmd/hwlab-gateway/main.ts @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import os from "node:os"; import { spawn } from "node:child_process"; diff --git a/deploy/artifact-catalog.dev.json b/deploy/artifact-catalog.dev.json index 6abcf71f..8e41528c 100644 --- a/deploy/artifact-catalog.dev.json +++ b/deploy/artifact-catalog.dev.json @@ -455,10 +455,10 @@ "publishEnabled": true, "artifactRequired": true, "artifactScope": "required", - "runtimeKind": "node-command", + "runtimeKind": "bun-command", "implementationState": "repo-entrypoint", "sourceState": "source-present", - "entrypoint": "cmd/hwlab-gateway/main.mjs", + "entrypoint": "cmd/hwlab-gateway/main.ts", "disabledReason": null }, { diff --git a/docs/reference/gateway-outbound-demo.md b/docs/reference/gateway-outbound-demo.md index 2a764da1..97fbe39c 100644 --- a/docs/reference/gateway-outbound-demo.md +++ b/docs/reference/gateway-outbound-demo.md @@ -72,7 +72,7 @@ $env:HWLAB_GATEWAY_ID="gtw_windows_1" $env:HWLAB_GATEWAY_SESSION_ID="gws_gtw_windows_1" $env:HWLAB_GATEWAY_CMD_EXEC_ENABLED="1" $env:HWLAB_GATEWAY_DEMO_OPEN="1" -node .\cmd\hwlab-gateway\main.mjs +bun run .\cmd\hwlab-gateway\main.ts ``` 验证时从 cloud-api 侧调用 `hardware.invoke.shell`,不要尝试从 cloud 入站访问 Windows gateway。若 `GET /v1/gateway/sessions` 能看到对应 `gatewaySessionId` 且 JSON-RPC 返回 `dispatch.shellExecuted=true`,说明主动出站 demo 链路成立。 diff --git a/docs/reference/spec-v02-hwlab-gateway.md b/docs/reference/spec-v02-hwlab-gateway.md index e5ed71f8..2ddb08a4 100644 --- a/docs/reference/spec-v02-hwlab-gateway.md +++ b/docs/reference/spec-v02-hwlab-gateway.md @@ -10,7 +10,7 @@ ## 内部架构 -- `cmd/hwlab-gateway/main.mjs` 维护 gateway state、outbound poll loop、inflight request map 和 command execution。 +- `cmd/hwlab-gateway/main.ts` 维护 gateway state、outbound poll loop、inflight request map 和 command execution,运行入口必须使用 Bun。 - `internal/cloud/gateway-demo-registry.ts` 在 cloud-api 内保存 gateway session、队列和 pending result。 - command execution 默认关闭;只有显式 `HWLAB_GATEWAY_CMD_EXEC_ENABLED=1` 或 demo open 时才执行 shell。 diff --git a/scripts/gateway-outbound-demo-smoke.mjs b/scripts/gateway-outbound-demo-smoke.mjs index 7bd6d825..ffcecf28 100644 --- a/scripts/gateway-outbound-demo-smoke.mjs +++ b/scripts/gateway-outbound-demo-smoke.mjs @@ -50,7 +50,7 @@ try { } const apiBaseUrl = useEdgeProxy ? `http://127.0.0.1:${edgePort}` : `http://127.0.0.1:${cloudPort}`; - const gateway = startNode("cmd/hwlab-gateway/main.mjs", { + const gateway = startNode("cmd/hwlab-gateway/main.ts", { ...commonEnv, PORT: String(gatewayPort), HWLAB_GATEWAY_CLOUD_URL: apiBaseUrl, diff --git a/scripts/src/check-plan.mjs b/scripts/src/check-plan.mjs index 8f89af26..8fe1f5f8 100644 --- a/scripts/src/check-plan.mjs +++ b/scripts/src/check-plan.mjs @@ -70,8 +70,8 @@ export const checkProfiles = Object.freeze({ { id: "check-045-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","build","cmd/hwlab-cloud-api/main.ts","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] }, { id: "check-046-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","build","cmd/hwlab-cloud-api/provision.ts","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] }, { id: "check-047-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","build","cmd/hwlab-cloud-api/migrate.ts","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] }, - { id: "check-048-runtime-services-run-bun", group: "runtime-services", command: ["node","scripts/run-bun.mjs","test","cmd/hwlab-edge-proxy/main.test.ts","cmd/hwlab-device-pod/main.test.ts","cmd/hwlab-agent-mgr/main.test.ts","cmd/hwlab-agent-worker/main.test.ts","cmd/hwlab-codex-api-responses-forwarder/main.test.ts","cmd/hwlab-deepseek-responses-bridge/main.test.ts"] }, - { id: "check-056-repo-main", group: "repo", command: ["node","--check","cmd/hwlab-gateway/main.mjs"] }, + { id: "check-048-runtime-services-run-bun", group: "runtime-services", command: ["node","scripts/run-bun.mjs","test","cmd/hwlab-edge-proxy/main.test.ts","cmd/hwlab-device-pod/main.test.ts","cmd/hwlab-gateway/main.test.ts","cmd/hwlab-agent-mgr/main.test.ts","cmd/hwlab-agent-worker/main.test.ts","cmd/hwlab-codex-api-responses-forwarder/main.test.ts","cmd/hwlab-deepseek-responses-bridge/main.test.ts"] }, + { id: "check-056-repo-main", group: "repo", command: ["node","scripts/run-bun.mjs","build","cmd/hwlab-gateway/main.ts","cmd/hwlab-gateway/main.test.ts","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] }, { id: "check-058-tools-hwlab-gateway-shell", group: "tools", command: ["node","--check","tools/hwlab-gateway-shell.mjs"] }, { id: "check-059-tools-hwlab-gateway-tran", group: "tools", command: ["node","--check","tools/hwlab-gateway-tran.mjs"] }, { id: "check-060-tools-tran", group: "tools", command: ["node","--check","tools/tran.mjs"] }, diff --git a/scripts/src/g14-ci-plan-lib.mjs b/scripts/src/g14-ci-plan-lib.mjs index 62512ac0..4724123f 100644 --- a/scripts/src/g14-ci-plan-lib.mjs +++ b/scripts/src/g14-ci-plan-lib.mjs @@ -66,6 +66,7 @@ const bunCommandServices = new Set([ "hwlab-codex-api-responses-forwarder", "hwlab-deepseek-responses-bridge", "hwlab-device-pod", + "hwlab-gateway", "hwlab-edge-proxy" ]);