fix: keep Cloud Web alive on client disconnect
This commit is contained in:
@@ -18,8 +18,16 @@ 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({
|
||||
@@ -43,50 +51,130 @@ export function createCloudWebServer({
|
||||
})
|
||||
}) {
|
||||
return createServer(async (request, response) => {
|
||||
attachRequestErrorHandlers({ request, response, serviceId });
|
||||
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" || 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);
|
||||
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;
|
||||
} catch {
|
||||
// Try the next root.
|
||||
}
|
||||
}
|
||||
|
||||
sendJson(response, 404, { error: "not_found", serviceId, path: url.pathname });
|
||||
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") {
|
||||
|
||||
Reference in New Issue
Block a user