fix: run edge proxy on node runtime (#1830)
This commit is contained in:
@@ -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", "<redacted>");
|
||||
return parsed.toString();
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Response> {
|
||||
const method = request.method || "GET";
|
||||
let timedOut = false;
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let timeout: ReturnType<typeof setTimeout> | 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<Uint8Array>;
|
||||
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<Uint8Array>);
|
||||
bodyStream.on("error", (error) => upstreamRequest.destroy(error));
|
||||
bodyStream.pipe(upstreamRequest);
|
||||
});
|
||||
}
|
||||
|
||||
function nodeRequestHeaders(headers: Headers, target: URL): Record<string, string> {
|
||||
const next: Record<string, string> = {};
|
||||
for (const [key, value] of headers.entries()) {
|
||||
if (isHopByHopHeader(key)) continue;
|
||||
next[key] = value;
|
||||
}
|
||||
next.host = target.host;
|
||||
return next;
|
||||
}
|
||||
|
||||
function responseHeadersFromNode(headers: Record<string, string | string[] | number | undefined>): 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<string, unknown> = {}) {
|
||||
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", "<redacted>");
|
||||
return parsed.toString();
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
{
|
||||
|
||||
+6
-6
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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"), "<html>v2</html>\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"), "<html></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"],
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user