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"; import { UPSTREAM_UNAVAILABLE_ERROR_CODE, UPSTREAM_UNAVAILABLE_MESSAGE } from "./http.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 type = contentType(candidate); const body = await staticResponseBody({ candidate, relativePath, type }); 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); } }); } async function staticResponseBody({ candidate, relativePath, type }) { const body = await readFile(candidate); if (relativePath !== "index.html" || !String(type).startsWith("text/html")) return body; return injectCloudWebRuntimeConfig(body); } function injectCloudWebRuntimeConfig(body) { const config = runtimeConfigFromEnv(); if (Object.keys(config).length === 0) return body; const html = body.toString("utf8"); const script = ``; if (html.includes("")) return Buffer.from(html.replace("", `${script}`), "utf8"); if (html.includes("")) return Buffer.from(html.replace("", `${script}`), "utf8"); return Buffer.from(`${html}${script}`, "utf8"); } function runtimeConfigFromEnv() { const config = { displayTime: displayTimeConfigFromEnv() }; const workbench = workbenchRuntimeConfigFromEnv(); if (workbench) config.workbench = workbench; return config; } function displayTimeConfigFromEnv() { const timeZone = requiredRuntimeConfigEnv("HWLAB_CLOUD_WEB_DISPLAY_TIME_ZONE"); const locale = requiredRuntimeConfigEnv("HWLAB_CLOUD_WEB_DISPLAY_TIME_LOCALE"); const label = requiredRuntimeConfigEnv("HWLAB_CLOUD_WEB_DISPLAY_TIME_LABEL"); validateDisplayTimeZone(timeZone); validateDisplayLocale(locale); return { timeZone, locale, label }; } function requiredRuntimeConfigEnv(name) { const value = process.env[name]; if (typeof value !== "string" || value.trim().length === 0) { throw new Error(`missing required Cloud Web runtime config env: ${name}`); } return value.trim(); } function validateDisplayTimeZone(timeZone) { try { new Intl.DateTimeFormat("en-US", { timeZone }).format(new Date(0)); } catch { throw new Error(`invalid HWLAB_CLOUD_WEB_DISPLAY_TIME_ZONE: ${timeZone}`); } } function validateDisplayLocale(locale) { try { new Intl.DateTimeFormat(locale).format(new Date(0)); } catch { throw new Error(`invalid HWLAB_CLOUD_WEB_DISPLAY_TIME_LOCALE: ${locale}`); } } function workbenchRuntimeConfigFromEnv() { const traceTimeline = {}; const autoExpandRunning = parseEnvBoolean(process.env.HWLAB_WORKBENCH_TRACE_AUTO_EXPAND_RUNNING); const autoCollapseTerminal = parseEnvBoolean(process.env.HWLAB_WORKBENCH_TRACE_AUTO_COLLAPSE_TERMINAL); if (typeof autoExpandRunning === "boolean") traceTimeline.autoExpandRunning = autoExpandRunning; if (typeof autoCollapseTerminal === "boolean") traceTimeline.autoCollapseTerminal = autoCollapseTerminal; return Object.keys(traceTimeline).length > 0 ? { traceTimeline } : null; } function parseEnvBoolean(value) { if (value === undefined || value === null || value === "") return null; const normalized = String(value).trim().toLowerCase(); if (["1", "true", "yes", "on"].includes(normalized)) return true; if (["0", "false", "no", "off"].includes(normalized)) return false; return null; } function safeInlineJson(value) { return JSON.stringify(value).replace(/ { 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 = UPSTREAM_UNAVAILABLE_ERROR_CODE; const category = timedOut ? "timeout" : "proxy"; const traceId = request.headers["x-trace-id"] || null; const userMessage = UPSTREAM_UNAVAILABLE_MESSAGE; 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; }