Files
pikasTech-HWLAB/internal/dev-entrypoint/cloud-web-runtime.mjs
T
2026-05-30 11:09:56 +08:00

212 lines
6.5 KiB
JavaScript

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";
}