import { createServer } from "node:http"; import { readFile, stat } from "node:fs/promises"; import path from "node:path"; import { isCloudWebSseRoute, proxyCloudApiRequest } from "./cloud-web-proxy.mjs"; import { cloudWebProxyRoutePolicy } from "./cloud-web-routes.mjs"; const readOnlyRpcMethods = new Set([ "system.health", "cloud.adapter.describe", "audit.event.query", "evidence.record.query" ]); export async function serveCloudWeb(options) { const server = createCloudWebServer(options); server.listen(options.port, "0.0.0.0", () => { process.stdout.write(JSON.stringify({ serviceId: options.serviceId, environment: process.env.HWLAB_ENVIRONMENT || "dev", status: "listening", port: options.port }) + "\n"); }); } export function createCloudWebServer({ serviceId, healthPayload, sendJson, roots = ["/app/web/hwlab-cloud-web/dist", "/app/web/hwlab-cloud-web"], cloudApiBaseUrl = process.env.HWLAB_API_BASE_URL || "", cloudApiProxyTimeoutMs = parseTimeout(process.env.HWLAB_CLOUD_WEB_PROXY_TIMEOUT_MS, 1260000, { min: 1000, max: 2400000 }) }) { return createServer(async (request, response) => { const url = new URL(request.url || "/", "http://hwlab-cloud-web.local"); if (url.pathname === "/health/live" || url.pathname === "/health") { sendJson(response, 200, healthPayload()); return; } if (await handleCloudWebAuth({ request, response, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs, serviceId, sendJson })) { return; } const proxyPolicy = cloudWebProxyRoutePolicy(request.method, url.pathname); if (proxyPolicy.proxy) { await proxyCloudApi({ request, response, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs, serviceId, sendJson }); return; } const routePath = url.pathname.replace(/\/+$/u, "") || "/"; const relativePath = routePath === "/" || routePath === "/gate" || routePath === "/diagnostics/gate" || routePath === "/help" ? "index.html" : url.pathname.slice(1); for (const root of roots) { const candidate = path.resolve(root, relativePath); if (!candidate.startsWith(root)) continue; try { const info = await stat(candidate); if (!info.isFile()) continue; const body = await readFile(candidate); response.writeHead(200, { "content-type": contentType(candidate), "content-length": body.length }); response.end(body); return; } catch { // Try the next root. } } sendJson(response, 404, { error: "not_found", serviceId, path: url.pathname }); }); } export async function handleCloudWebAuth(options) { const { request, response, url } = options; if (url.pathname === "/auth/session" && request.method === "GET") { await proxyCloudApi(options); return true; } if (url.pathname === "/auth/login" && request.method === "POST") { await proxyCloudApi(options); return true; } if (url.pathname === "/auth/logout" && request.method === "POST") { await proxyCloudApi(options); return true; } if (!url.pathname.startsWith("/auth/")) return false; await proxyCloudApi(options); return true; } async function readRequestBody(request) { let body = ""; for await (const chunk of request) { body += chunk; } return body; } 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 requestUpstream({ target, request, response, body, url, cloudApiProxyTimeoutMs }) { return proxyCloudApiRequest({ target, request, response, body, timeoutMs: cloudApiProxyTimeoutMs, forceStream: isCloudWebSseRoute(url.pathname) }); } export async function proxyCloudApi({ request, response, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs, serviceId, sendJson }) { if (!cloudApiBaseUrl) { sendJson(response, 503, { error: "cloud_api_base_url_missing", serviceId, path: url.pathname }); return; } let body = ""; if (request.method !== "GET") { body = await readRequestBody(request); } if (url.pathname === "/json-rpc") { try { const envelope = body ? JSON.parse(body) : {}; if (!readOnlyRpcMethods.has(envelope.method)) { sendJson(response, 403, { error: "readonly_rpc_required", serviceId, method: envelope.method || null }); return; } } catch (error) { sendJson(response, 400, { error: "invalid_json", serviceId, reason: error.message }); return; } } const target = new URL(url.pathname + url.search, cloudApiBaseUrl); try { await requestUpstream({ target, request, response, body: request.method === "GET" ? "" : body, url, cloudApiProxyTimeoutMs }); } catch (error) { if (response.headersSent || response.writableEnded) { if (!response.writableEnded) response.destroy(error); return; } const timedOut = error.timedOut === true || /timed out after \d+ms/iu.test(error.message); const code = timedOut ? "cloud_api_proxy_timeout" : "cloud_api_proxy_failed"; const category = timedOut ? "timeout" : "proxy"; const traceId = request.headers["x-trace-id"] || null; const userMessage = timedOut ? "Code Agent 代理等待 cloud-api 超过 " + cloudApiProxyTimeoutMs + "ms;输入已保留,可稍后重试。" : "Code Agent 代理暂时无法连接 cloud-api;输入已保留,可稍后重试。"; sendJson(response, 502, { status: "failed", error: { code, layer: "proxy", category, retryable: true, userMessage, message: userMessage, timeoutMs: timedOut ? cloudApiProxyTimeoutMs : null, traceId, route: url.pathname, blocker: { code, layer: "proxy", category, retryable: true, summary: userMessage, userMessage, traceId, route: url.pathname } }, serviceId, target: target.href.replace(/\/\/[^/@]*@/u, "//***@"), traceId, reason: userMessage }); } } function contentType(filePath) { if (filePath.endsWith(".html")) return "text/html; charset=utf-8"; if (filePath.endsWith(".css")) return "text/css; charset=utf-8"; if (filePath.endsWith(".mjs") || filePath.endsWith(".js")) return "text/javascript; charset=utf-8"; return "application/octet-stream"; }