#!/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 ? `Workbench 后端请求超过 ${timeoutMs}ms 未返回;请求已保留,可稍后重试。` : "Workbench 后端暂时不可用;请求已保留,可稍后重试。"; 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(); }