Files
pikasTech-HWLAB/internal/dev-entrypoint/cloud-web-runtime.mjs
T

450 lines
17 KiB
JavaScript

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 gzipAsync = promisify(gzip);
const GZIP_MIN_BYTES = 1024;
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");
const CLIENT_ROUTE_FALLBACK_PATHS = new Set(["/workbench", "/workspace", "/gate", "/diagnostics/gate", "/help", "/skills", "/access"]);
const STATIC_ASSET_EXTENSION_PATTERN = /\.[A-Za-z0-9][A-Za-z0-9_-]*$/u;
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 = normalizeCloudWebRoutePath(url.pathname);
const relativePath = routePath === "/" || CLIENT_ROUTE_FALLBACK_PATHS.has(routePath) || isCloudWebWorkbenchSessionRoute(routePath) || isCloudWebClientRouteRequest(request, routePath)
? "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);
}
});
}
function isCloudWebWorkbenchSessionRoute(routePath) {
return /^\/(?:workbench|workspace)\/sessions\/cnv_[A-Za-z0-9_.:-]+$/u.test(routePath);
}
function normalizeCloudWebRoutePath(pathname) {
return String(pathname || "/").replace(/\/+$/u, "") || "/";
}
function isCloudWebClientRouteRequest(request, routePath) {
if (request.method !== "GET" && request.method !== "HEAD") return false;
if (!routePath || routePath === "/") return true;
if (routePath.startsWith("/auth/") || routePath === "/auth") return false;
if (routePath.startsWith("/v1/") || routePath === "/v1") return false;
if (STATIC_ASSET_EXTENSION_PATTERN.test(path.basename(routePath))) return false;
const accept = String(request.headers.accept || "").toLowerCase();
return !accept || accept.includes("text/html") || accept.includes("*/*");
}
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/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/register" && 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 resolveAuthBootstrap({ request, cloudApiBaseUrl, cloudApiProxyTimeoutMs }) {
const session = await requestCloudApiJson({ request, method: "GET", pathname: "/auth/session", cloudApiBaseUrl, cloudApiProxyTimeoutMs });
return withCookieHeader(request, session);
}
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);
}
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;
}