Files
pikasTech-HWLAB/internal/dev-entrypoint/cloud-web-runtime.mjs
T
2026-06-06 13:34:34 +08:00

524 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createServer, request as httpRequest } from "node:http";
import { readFile, stat } from "node:fs/promises";
import path from "node:path";
import { gzip } from "node:zlib";
import { promisify } from "node:util";
import { isCloudWebSseRoute, proxyCloudApiRequest, upstreamRequestHeaders } 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"
]);
const gzipAsync = promisify(gzip);
const GZIP_MIN_BYTES = 1024;
const DEFAULT_AUTH_USERNAME = "admin";
const DEFAULT_AUTH_PASSWORD = "hwlab2026";
const DEFAULT_WORKBENCH_PROJECT_ID = "prj_hwpod_workbench";
const CLIENT_DISCONNECT_ERROR_CODES = new Set([
"ECONNRESET",
"EPIPE",
"ERR_STREAM_PREMATURE_CLOSE"
]);
const CLIENT_DISCONNECT_ERROR_PATTERN = /\b(?:socket hang up|client disconnected|premature close)\b/iu;
const CLOUD_WEB_ERROR_HANDLER_INSTALLED = Symbol.for("hwlab.cloud-web.error-handler-installed");
export async function serveCloudWeb(options) {
installCloudWebProcessErrorHandlers({ serviceId: options.serviceId });
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) => {
attachRequestErrorHandlers({ request, response, serviceId });
const url = new URL(request.url || "/", "http://hwlab-cloud-web.local");
try {
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" || routePath === "/skills" || routePath === "/access"
? "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);
const type = contentType(candidate);
const encoded = await encodeStaticBody(request, body, type);
response.writeHead(200, {
"content-type": type,
...encoded.headers,
"content-length": encoded.body.length
});
response.end(encoded.body);
return;
} catch (error) {
if (isClientDisconnectError(error)) {
logCloudWebRuntimeEvent("cloud-web-client-disconnect", { serviceId, request, error });
return;
}
// Try the next root for ordinary filesystem misses.
}
}
sendJson(response, 404, { error: "not_found", serviceId, path: url.pathname });
} catch (error) {
if (isClientDisconnectError(error)) {
logCloudWebRuntimeEvent("cloud-web-client-disconnect", { serviceId, request, error });
return;
}
if (!response.headersSent && !response.writableEnded) {
sendJson(response, 500, {
error: "internal_error",
serviceId,
message: error instanceof Error ? error.message : String(error)
});
return;
}
response.destroy(error);
}
});
}
export function installCloudWebProcessErrorHandlers({ serviceId } = {}) {
if (process[CLOUD_WEB_ERROR_HANDLER_INSTALLED]) return;
process[CLOUD_WEB_ERROR_HANDLER_INSTALLED] = true;
process.on("uncaughtException", (error) => {
if (isClientDisconnectError(error)) {
logCloudWebRuntimeEvent("cloud-web-client-disconnect", { serviceId, error, source: "uncaughtException" });
return;
}
logCloudWebRuntimeEvent("cloud-web-runtime-fatal", { serviceId, error, source: "uncaughtException" });
process.exit(1);
});
process.on("unhandledRejection", (reason) => {
const error = reason instanceof Error ? reason : new Error(String(reason));
if (isClientDisconnectError(error)) {
logCloudWebRuntimeEvent("cloud-web-client-disconnect", { serviceId, error, source: "unhandledRejection" });
return;
}
logCloudWebRuntimeEvent("cloud-web-runtime-fatal", { serviceId, error, source: "unhandledRejection" });
process.exit(1);
});
}
function attachRequestErrorHandlers({ request, response, serviceId }) {
const handle = (error) => {
if (isClientDisconnectError(error)) {
logCloudWebRuntimeEvent("cloud-web-client-disconnect", { serviceId, request, error });
return;
}
throw error;
};
request.on("error", handle);
response.on("error", handle);
}
export function isClientDisconnectError(error) {
const candidate = error instanceof Error ? error : new Error(String(error));
const code = typeof candidate.code === "string" ? candidate.code : "";
return CLIENT_DISCONNECT_ERROR_CODES.has(code) || CLIENT_DISCONNECT_ERROR_PATTERN.test(candidate.message || "");
}
function logCloudWebRuntimeEvent(event, { serviceId, request = null, error = null, source = null } = {}) {
const payload = {
event,
serviceId: serviceId || "hwlab-cloud-web",
source,
method: request?.method || null,
path: request?.url || null,
error: error
? {
name: error.name || "Error",
code: typeof error.code === "string" ? error.code : null,
message: error.message || String(error)
}
: null,
observedAt: new Date().toISOString()
};
process.stderr.write(JSON.stringify(payload) + "\n");
}
export async function handleCloudWebAuth(options) {
const { request, response, url } = options;
if (url.pathname === "/auth/workspace-bootstrap" && request.method === "GET") {
await handleWorkspaceBootstrap(options);
return true;
}
if (url.pathname === "/auth/bootstrap" && request.method === "GET") {
await handleAuthBootstrap(options);
return true;
}
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 handleAuthBootstrap(options) {
const { request, response, cloudApiBaseUrl, cloudApiProxyTimeoutMs, serviceId, sendJson } = options;
if (!cloudApiBaseUrl) {
sendJson(response, 503, { error: "cloud_api_base_url_missing", serviceId, path: "/auth/bootstrap" });
return;
}
const auth = await resolveAuthBootstrap({ request, cloudApiBaseUrl, cloudApiProxyTimeoutMs });
sendJsonWithForwardedCookies(response, auth.statusCode || 502, auth.body ?? { authenticated: false, error: "auth_bootstrap_failed" }, auth.headers);
}
async function handleWorkspaceBootstrap(options) {
const { request, response, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs, serviceId, sendJson } = options;
if (!cloudApiBaseUrl) {
sendJson(response, 503, { error: "cloud_api_base_url_missing", serviceId, path: "/auth/workspace-bootstrap" });
return;
}
const auth = await resolveAuthBootstrap({ request, cloudApiBaseUrl, cloudApiProxyTimeoutMs });
if (auth.body?.authenticated !== true) {
sendJsonWithForwardedCookies(response, auth.statusCode || 200, auth.body ?? { authenticated: false }, auth.headers);
return;
}
const projectId = authConfigValueFromSearch(url.searchParams.get("projectId"), DEFAULT_WORKBENCH_PROJECT_ID);
const workspace = await requestCloudApiJson({
request,
method: "GET",
pathname: `/v1/workbench/workspace?projectId=${encodeURIComponent(projectId)}`,
headers: auth.cookieHeader ? { cookie: auth.cookieHeader } : {},
cloudApiBaseUrl,
cloudApiProxyTimeoutMs
});
const body = {
...(auth.body ?? { authenticated: true }),
projectId,
workspace: workspace.ok ? workspace.body?.workspace ?? null : null,
workspaceStatus: workspace.statusCode || null,
workspaceError: workspace.ok ? null : workspace.body?.error ?? workspace.body?.message ?? null
};
sendJsonWithForwardedCookies(response, auth.statusCode || 200, body, auth.headers);
}
async function resolveAuthBootstrap({ request, cloudApiBaseUrl, cloudApiProxyTimeoutMs }) {
const config = webAuthConfig();
if (config.mode !== "auto" || hasCookieHeader(request.headers.cookie)) {
const session = await requestCloudApiJson({ request, method: "GET", pathname: "/auth/session", cloudApiBaseUrl, cloudApiProxyTimeoutMs });
if (session.ok && session.body?.authenticated === true) return withCookieHeader(request, session);
if (config.mode !== "auto") return withCookieHeader(request, session);
}
const login = await requestCloudApiJson({
request,
method: "POST",
pathname: "/auth/login",
body: JSON.stringify({ username: config.username, password: config.password }),
cloudApiBaseUrl,
cloudApiProxyTimeoutMs
});
return withCookieHeader(request, login);
}
function webAuthConfig() {
return {
mode: authConfigValue("HWLAB_CLOUD_WEB_AUTH_MODE", "auto"),
username: authConfigValue("HWLAB_CLOUD_WEB_AUTH_USERNAME", DEFAULT_AUTH_USERNAME),
password: authConfigValue("HWLAB_CLOUD_WEB_AUTH_PASSWORD", DEFAULT_AUTH_PASSWORD)
};
}
function authConfigValue(name, fallback) {
const value = process.env[name];
return typeof value === "string" && value.trim() ? value.trim() : fallback;
}
function authConfigValueFromSearch(value, fallback) {
return typeof value === "string" && value.trim() ? value.trim() : fallback;
}
function requestCloudApiJson({ request, method, pathname, body = "", headers = {}, cloudApiBaseUrl, cloudApiProxyTimeoutMs }) {
return new Promise((resolve) => {
let settled = false;
const target = new URL(pathname, cloudApiBaseUrl);
const proxyRequest = { method, headers: { ...upstreamRequestHeaders({ headers: request.headers }, body), ...headers } };
const upstream = httpRequest(target, { method, headers: proxyRequest.headers }, (upstreamResponse) => {
const chunks = [];
upstreamResponse.on("data", (chunk) => chunks.push(chunk));
upstreamResponse.on("end", () => {
if (settled) return;
settled = true;
const rawBody = Buffer.concat(chunks).toString("utf8");
let parsed = null;
try { parsed = rawBody ? JSON.parse(rawBody) : null; } catch { parsed = { error: "invalid_json", rawBody: rawBody.slice(0, 200) }; }
resolve({ ok: (upstreamResponse.statusCode || 0) >= 200 && (upstreamResponse.statusCode || 0) < 300, statusCode: upstreamResponse.statusCode || 502, body: parsed, headers: upstreamResponse.headers });
});
upstreamResponse.on("error", (error) => {
if (settled) return;
settled = true;
resolve({ ok: false, statusCode: 502, body: { authenticated: false, error: error.message }, headers: {} });
});
});
const timeout = setTimeout(() => {
if (settled) return;
settled = true;
upstream.destroy();
resolve({ ok: false, statusCode: 504, body: { authenticated: false, error: "auth_bootstrap_timeout" }, headers: {} });
}, cloudApiProxyTimeoutMs);
upstream.on("error", (error) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
resolve({ ok: false, statusCode: 502, body: { authenticated: false, error: error.message }, headers: {} });
});
upstream.on("close", () => clearTimeout(timeout));
if (body) upstream.write(body);
upstream.end();
});
}
function withCookieHeader(request, result) {
return {
...result,
cookieHeader: cookieHeaderForUpstream(request.headers.cookie, result.headers?.["set-cookie"])
};
}
function cookieHeaderForUpstream(existingCookie, setCookie) {
const pairs = new Map();
for (const pair of cookiePairsFromHeader(existingCookie)) pairs.set(pair.name, pair.value);
for (const pair of cookiePairsFromSetCookie(setCookie)) pairs.set(pair.name, pair.value);
return Array.from(pairs.entries()).map(([name, value]) => `${name}=${value}`).join("; ");
}
function cookiePairsFromHeader(header) {
if (!header) return [];
return String(header).split(";").map((part) => cookiePair(part)).filter(Boolean);
}
function hasCookieHeader(header) {
return cookiePairsFromHeader(header).length > 0;
}
function cookiePairsFromSetCookie(setCookie) {
const values = Array.isArray(setCookie) ? setCookie : setCookie ? [setCookie] : [];
return values.map((value) => cookiePair(String(value).split(";")[0])).filter(Boolean);
}
function cookiePair(value) {
const index = String(value).indexOf("=");
if (index <= 0) return null;
const name = String(value).slice(0, index).trim();
const cookieValue = String(value).slice(index + 1).trim();
if (!name || !cookieValue) return null;
return { name, value: cookieValue };
}
function sendJsonWithForwardedCookies(response, statusCode, body, headers = {}) {
const payload = JSON.stringify(body);
const setCookie = headers["set-cookie"];
const responseHeaders = {
"content-type": "application/json",
"content-length": Buffer.byteLength(payload)
};
if (setCookie) responseHeaders["set-cookie"] = setCookie;
response.writeHead(statusCode, responseHeaders);
response.end(payload);
}
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";
if (filePath.endsWith(".svg")) return "image/svg+xml; charset=utf-8";
if (filePath.endsWith(".md")) return "text/markdown; charset=utf-8";
if (filePath.endsWith(".json")) return "application/json; charset=utf-8";
return "application/octet-stream";
}
async function encodeStaticBody(request, body, type) {
if (!isGzipCandidate(type, body.length) || !acceptsEncoding(request.headers["accept-encoding"], "gzip")) {
return { body, headers: isCompressibleType(type) ? { "vary": "Accept-Encoding" } : {} };
}
const compressed = await gzipAsync(body, { level: 6 });
return {
body: compressed,
headers: {
"content-encoding": "gzip",
"vary": "Accept-Encoding"
}
};
}
function isGzipCandidate(type, sizeBytes) {
return sizeBytes >= GZIP_MIN_BYTES && isCompressibleType(type);
}
function isCompressibleType(type) {
return /^(?:text\/|application\/json\b)/u.test(String(type || ""));
}
function acceptsEncoding(header, encoding) {
const value = Array.isArray(header) ? header.join(",") : String(header || "");
for (const segment of value.split(",")) {
const [name, ...params] = segment.trim().split(";");
const token = name.trim().toLowerCase();
if (token !== encoding && token !== "*") continue;
const q = params.map((param) => param.trim().toLowerCase()).find((param) => param.startsWith("q="));
if (q && Number.parseFloat(q.slice(2)) <= 0) continue;
return true;
}
return false;
}