import { request as httpRequest } from "node:http"; const STREAMING_HEADER_DEFAULTS = Object.freeze({ "cache-control": "no-store", "x-accel-buffering": "no" }); const FORWARDED_REQUEST_HEADERS = Object.freeze([ "authorization", "cookie", "prefer", "x-hwlab-short-connection", "x-trace-id", "x-request-id", "x-actor-id", "x-source-service-id" ]); export function isCloudWebSseRoute(pathname) { return /^\/v1\/agent\/chat\/trace\/[^/]+\/stream$/u.test(String(pathname || "")); } export function isEventStreamResponse(headers = {}) { const contentType = headerValue(headers, "content-type"); return /^text\/event-stream(?:\s*;|$)/iu.test(contentType); } export function copyProxyResponseHeaders(headers = {}, { streaming = false } = {}) { const result = {}; for (const [key, value] of Object.entries(headers)) { const lowerKey = key.toLowerCase(); if (value === undefined) continue; if (lowerKey === "transfer-encoding") continue; if (lowerKey === "content-length") continue; result[key] = value; } if (streaming) { for (const [key, value] of Object.entries(STREAMING_HEADER_DEFAULTS)) { if (!hasHeader(result, key)) result[key] = value; } } return result; } export function upstreamRequestHeaders(request, body = "") { const headers = { accept: request.headers.accept || "application/json" }; const hasBody = body.length > 0; if (request.headers["content-type"] || hasBody) { headers["content-type"] = request.headers["content-type"] || "application/json"; } for (const name of FORWARDED_REQUEST_HEADERS) { if (request.headers[name] !== undefined) headers[name] = request.headers[name]; } if (hasBody) headers["content-length"] = Buffer.byteLength(body); return headers; } export function proxyCloudApiRequest({ target, request, response, body = "", timeoutMs, forceStream = false }) { return new Promise((resolve, reject) => { let timedOut = false; let settled = false; let timeout = null; const settle = (callback, value) => { if (settled) return; settled = true; if (timeout) clearTimeout(timeout); callback(value); }; const armTimeout = () => { if (timeout) clearTimeout(timeout); timeout = setTimeout(() => { timedOut = true; upstream.destroy(new Error("cloud api proxy timed out after " + timeoutMs + "ms")); }, timeoutMs); }; const upstream = httpRequest( target, { method: request.method, headers: upstreamRequestHeaders(request, body) }, (upstreamResponse) => { const streaming = forceStream || isEventStreamResponse(upstreamResponse.headers); if (streaming) { response.writeHead(upstreamResponse.statusCode || 502, copyProxyResponseHeaders(upstreamResponse.headers, { streaming: true })); response.flushHeaders?.(); upstreamResponse.on("data", (chunk) => { armTimeout(); response.write(chunk); }); upstreamResponse.on("end", () => { response.end(); settle(resolve); }); upstreamResponse.on("error", (error) => { if (response.headersSent) { response.destroy(error); settle(resolve); return; } settle(reject, error); }); upstreamResponse.on("close", () => { if (!response.writableEnded) response.end(); settle(resolve); }); response.on("close", () => upstream.destroy()); return; } const chunks = []; upstreamResponse.on("data", (chunk) => { armTimeout(); chunks.push(chunk); }); upstreamResponse.on("end", () => { const responseBody = Buffer.concat(chunks); response.writeHead(upstreamResponse.statusCode || 502, { ...copyProxyResponseHeaders(upstreamResponse.headers), "content-length": responseBody.length }); response.end(responseBody); settle(resolve); }); upstreamResponse.on("error", (error) => settle(reject, error)); } ); armTimeout(); upstream.on("error", (error) => { if (timedOut) error.timedOut = true; if (response.headersSent) { response.destroy(error); settle(resolve); return; } settle(reject, error); }); if (body) upstream.write(body); upstream.end(); }); } function headerValue(headers, name) { const lowerName = name.toLowerCase(); for (const [key, value] of Object.entries(headers)) { if (key.toLowerCase() !== lowerName) continue; if (Array.isArray(value)) return value[0] ?? ""; return String(value ?? ""); } return ""; } function hasHeader(headers, name) { const lowerName = name.toLowerCase(); return Object.keys(headers).some((key) => key.toLowerCase() === lowerName); }