UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

436 lines (435 loc) 14.4 kB
import { C as parseStrictNonNegativeInteger, F as resolveTimerTimeoutMs } from "./number-coercion-CLj0HTDM.js"; import { t as formatErrorMessage } from "./errors-Db3Ymjlb.js"; import { t as createDeferredCore } from "./deferred-D0La5CRk.js"; import "./http-response-body-CwT_cCNz.js"; import { clearTimeout, setTimeout } from "node:timers"; import { channel } from "node:diagnostics_channel"; //#region src/infra/http-request-lifecycle.ts const REJECTION_CLOSE_TIMEOUT_MS = 1e3; const connections = /* @__PURE__ */ new WeakMap(); function connectionFor(socket) { const existing = connections.get(socket); if (existing) return existing; const requests = /* @__PURE__ */ new Set(); const connection = { requests, detach: () => { socket.off("resume", pauseQueuedInput); socket.off("close", onClose); } }; connections.set(socket, connection); const pauseQueuedInput = () => { const phase = connection.rejection?.phase; if (requests.size > 1 || phase === "selected" || phase === "writing" || connection.rejection?.request.complete) socket.pause(); }; socket.on("resume", pauseQueuedInput); const onClose = () => { connection.detach(); for (const resume of requests) resume(); }; socket.once("close", onClose); return connection; } /** Closing is selected before any ordered response write can reach the socket. */ function isHttpConnectionClosing(socket) { return socket.destroyed || socket.writableEnded || Boolean(connections.get(socket)?.rejection); } /** * Serialize application admission, not Node's response writes. A later request * must not cross async routing/auth while an earlier body can still be rejected. * Already admitted work finishes; queued work is never dispatched after closure. */ async function runHttpConnectionRequest(req, run, response) { const socket = req.socket; if (isHttpConnectionClosing(socket)) { socket.pause(); return; } const connection = connectionFor(socket); const queued = connection.requests.size > 0; const ready = createDeferredCore(); connection.requests.add(ready.resolve); try { if (queued) { socket.pause(); await ready.promise; } if (!isHttpConnectionClosing(socket)) { if (response === "upgrade") { connection.requests.delete(ready.resolve); connection.detach(); connections.delete(socket); return await run(); } await run(); const res = response; if (res && !res.writableFinished && !res.destroyed && !socket.destroyed) { const responseDone = createDeferredCore(); res.once("finish", responseDone.resolve); res.once("close", responseDone.resolve); socket.once("close", responseDone.resolve); await responseDone.promise; res.off("finish", responseDone.resolve); res.off("close", responseDone.resolve); socket.off("close", responseDone.resolve); } } } finally { if (connections.get(socket) === connection) { connection.requests.delete(ready.resolve); if (connection.requests.size <= 1 && !isHttpConnectionClosing(socket)) socket.resume(); connection.requests.values().next().value?.(); } } } /** Keep security/CORS headers, but discard metadata for an abandoned representation. */ function clearHttpResponseRepresentationHeaders(res) { for (const header of [ "Content-Encoding", "Content-Disposition", "Content-Range", "Content-Language", "Content-Location", "Content-Type", "ETag", "Last-Modified", "Transfer-Encoding", "Trailer" ]) res.removeHeader(header); } /** Fence synchronously at the byte/time limit, before a reader rejects its promise. */ function selectHttpRequestRejection(req) { const socket = req.socket; const connection = connectionFor(socket); if (connection.rejection) return connection.rejection; const completion = createDeferredCore(); const rejection = { request: req, phase: "selected", closed: completion.promise, destroy: () => socket.destroy() }; connection.rejection = rejection; for (const resume of connection.requests) resume(); req.pause(); socket.pause(); const pauseCompletedRequest = () => { if (req.complete) socket.pause(); }; req.on("readable", pauseCompletedRequest); const timer = setTimeout(rejection.destroy, REJECTION_CLOSE_TIMEOUT_MS); timer.unref(); const onClose = () => { rejection.phase = "closed"; clearTimeout(timer); req.off("error", rejection.destroy); req.off("readable", pauseCompletedRequest); socket.off("error", rejection.destroy); completion.resolve(); }; req.on("error", rejection.destroy); socket.on("error", rejection.destroy); socket.once("close", onClose); if (socket.destroyed) { socket.off("close", onClose); onClose(); } return rejection; } /** Preserve the caller's error representation and security headers until half-close. */ async function sendHttpRequestRejection(req, res, statusCode, body, contentType) { const rejection = selectHttpRequestRejection(req); if (rejection.request !== req || rejection.phase !== "selected") return await rejection.closed; if (res.headersSent || res.destroyed || res.writableEnded) { rejection.destroy(); return await rejection.closed; } const socket = req.socket; const onResponseClose = () => { rejection.destroy(); }; res.on("error", rejection.destroy); res.once("close", onResponseClose); let stopWaitingForSocket; try { rejection.phase = "writing"; res.statusCode = statusCode; clearHttpResponseRepresentationHeaders(res); res.setHeader("Connection", "close"); res.setHeader("Content-Length", Buffer.byteLength(body)); if (contentType) res.setHeader("Content-Type", contentType); const onWritten = (error) => { if (error) rejection.destroy(); else if (rejection.phase === "writing") { rejection.phase = "written"; try { socket.end(); socket.resume(); } catch { rejection.destroy(); } } }; if (process.versions.bun) res.end(body, () => { if (rejection.phase === "writing") rejection.phase = "written"; }); else if (req.method === "HEAD") { const writeHeaders = () => { if (!res.socket || rejection.phase !== "writing") return; stopWaitingForSocket?.(); try { res.flushHeaders(); res.socket.write("", onWritten); } catch { rejection.destroy(); } }; if (!res.socket) { const finished = channel("http.server.response.finish"); const onFinish = (message) => { if (message.socket === socket) queueMicrotask(writeHeaders); }; finished.subscribe(onFinish); stopWaitingForSocket = () => finished.unsubscribe(onFinish); } writeHeaders(); } else res.write(body, onWritten); } catch { rejection.destroy(); } await rejection.closed; stopWaitingForSocket?.(); res.off("error", rejection.destroy); res.off("close", onResponseClose); } /** Release handler/limiter ownership only after the selected transport closes. */ function waitForHttpRequestRejection(req) { return connections.get(req.socket)?.rejection?.closed; } //#endregion //#region src/infra/http-body.ts const DEFAULT_WEBHOOK_MAX_BODY_BYTES = 1048576; const DEFAULT_WEBHOOK_BODY_TIMEOUT_MS = 3e4; const DEFAULT_ERROR_MESSAGE = { PAYLOAD_TOO_LARGE: "PayloadTooLarge", REQUEST_BODY_TIMEOUT: "RequestBodyTimeout", CONNECTION_CLOSED: "RequestBodyConnectionClosed" }; const DEFAULT_ERROR_STATUS_CODE = { PAYLOAD_TOO_LARGE: 413, REQUEST_BODY_TIMEOUT: 408, CONNECTION_CLOSED: 400 }; const DEFAULT_RESPONSE_MESSAGE = { PAYLOAD_TOO_LARGE: "Payload too large", REQUEST_BODY_TIMEOUT: "Request body timeout", CONNECTION_CLOSED: "Connection closed" }; var RequestBodyLimitError = class extends Error { constructor(init) { super(init.message ?? DEFAULT_ERROR_MESSAGE[init.code]); this.name = "RequestBodyLimitError"; this.code = init.code; this.statusCode = DEFAULT_ERROR_STATUS_CODE[init.code]; } }; function isRequestBodyLimitError(error, code) { if (!(error instanceof RequestBodyLimitError)) return false; if (!code) return true; return error.code === code; } function requestBodyErrorToText(code) { return DEFAULT_RESPONSE_MESSAGE[code]; } function parseContentLengthHeader(req) { const header = req.headers["content-length"]; const raw = Array.isArray(header) ? header[0] : header; if (typeof raw !== "string") return null; const parsed = parseStrictNonNegativeInteger(raw); if (parsed !== void 0) return parsed; return /^\d+$/.test(raw.trim()) ? Number.MAX_SAFE_INTEGER : null; } function resolveRequestBodyLimitValues(options) { return { maxBytes: Number.isFinite(options.maxBytes) ? Math.max(1, Math.floor(options.maxBytes)) : 1, timeoutMs: options.timeoutMs === void 0 ? DEFAULT_WEBHOOK_BODY_TIMEOUT_MS : resolveTimerTimeoutMs(options.timeoutMs, DEFAULT_WEBHOOK_BODY_TIMEOUT_MS) }; } const testApi = { resolveRequestBodyLimitValues }; function stopRequestBodyAfterLimit(req, destroyOnLimit) { if (req.destroyed) return; if (destroyOnLimit) { req.destroy(); return; } selectHttpRequestRejection(req); } async function readRequestBodyWithLimit(req, options) { const { maxBytes, timeoutMs } = resolveRequestBodyLimitValues(options); const encoding = options.encoding ?? "utf-8"; const destroyOnLimit = options.destroyOnLimit !== false; if (isHttpConnectionClosing(req.socket)) throw new RequestBodyLimitError({ code: "CONNECTION_CLOSED" }); const declaredLength = parseContentLengthHeader(req); if (declaredLength !== null && declaredLength > maxBytes) { const error = new RequestBodyLimitError({ code: "PAYLOAD_TOO_LARGE" }); stopRequestBodyAfterLimit(req, destroyOnLimit); throw error; } return await new Promise((resolve, reject) => { let done = false; let totalBytes = 0; const chunks = []; const cleanup = () => { req.removeListener("data", onData); req.removeListener("end", onEnd); req.removeListener("error", onError); req.removeListener("close", onClose); clearTimeout(timer); }; const finish = (cb) => { if (done) return; done = true; cleanup(); cb(); }; const fail = (error) => { finish(() => reject(error)); }; const timer = setTimeout(() => { const error = new RequestBodyLimitError({ code: "REQUEST_BODY_TIMEOUT" }); stopRequestBodyAfterLimit(req, destroyOnLimit); fail(error); }, timeoutMs); const onData = (chunk) => { if (done) return; const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); totalBytes += buffer.length; if (totalBytes > maxBytes) { const error = new RequestBodyLimitError({ code: "PAYLOAD_TOO_LARGE" }); stopRequestBodyAfterLimit(req, destroyOnLimit); fail(error); return; } chunks.push(buffer); }; const onEnd = () => { if (isHttpConnectionClosing(req.socket)) { fail(new RequestBodyLimitError({ code: "CONNECTION_CLOSED" })); return; } finish(() => resolve(chunks.length === 1 ? chunks[0].toString(encoding) : Buffer.concat(chunks).toString(encoding))); }; const onError = (error) => { if (done) return; fail(error); }; const onClose = () => { fail(new RequestBodyLimitError({ code: "CONNECTION_CLOSED" })); }; req.on("data", onData); req.on("end", onEnd); req.on("error", onError); req.on("close", onClose); if (req.destroyed && !req.readableEnded) onClose(); }); } async function readJsonBodyWithLimit(req, options) { try { const trimmed = (await readRequestBodyWithLimit(req, options)).trim(); if (!trimmed) { if (options.emptyObjectOnEmpty === false) return { ok: false, code: "INVALID_JSON", error: "empty payload" }; return { ok: true, value: {} }; } try { return { ok: true, value: JSON.parse(trimmed) }; } catch (error) { return { ok: false, code: "INVALID_JSON", error: formatErrorMessage(error) }; } } catch (error) { if (isRequestBodyLimitError(error)) return { ok: false, code: error.code, error: requestBodyErrorToText(error.code) }; return { ok: false, code: "CONNECTION_CLOSED", error: requestBodyErrorToText("CONNECTION_CLOSED") }; } } function installRequestBodyLimitGuard(req, res, options) { const { maxBytes, timeoutMs } = resolveRequestBodyLimitValues(options); const responseFormat = options.responseFormat ?? "json"; const customText = options.responseText ?? {}; let tripped = false; let reason = null; let done = false; let totalBytes = 0; const cleanup = () => { req.removeListener("data", onData); req.removeListener("end", finish); req.removeListener("close", finish); req.removeListener("error", finish); clearTimeout(timer); }; const finish = () => { if (done) return; done = true; cleanup(); }; const respond = (error) => { const text = customText[error.code] ?? requestBodyErrorToText(error.code); const body = responseFormat === "text" ? text : JSON.stringify({ error: text }); const contentType = responseFormat === "text" ? "text/plain" : "application/json"; sendHttpRequestRejection(req, res, error.statusCode, body, `${contentType}; charset=utf-8`); }; const trip = (error) => { if (tripped) return; tripped = true; reason = error.code; finish(); respond(error); }; const onData = (chunk) => { if (done) return; totalBytes += typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.length; if (totalBytes > maxBytes) trip(new RequestBodyLimitError({ code: "PAYLOAD_TOO_LARGE" })); }; const timer = setTimeout(() => { trip(new RequestBodyLimitError({ code: "REQUEST_BODY_TIMEOUT" })); }, timeoutMs); req.on("data", onData); req.on("end", finish); req.on("close", finish); req.on("error", finish); const declaredLength = parseContentLengthHeader(req); if (isHttpConnectionClosing(req.socket)) { tripped = true; reason = "CONNECTION_CLOSED"; finish(); } else if (req.destroyed && !req.readableEnded) finish(); else if (declaredLength !== null && declaredLength > maxBytes) trip(new RequestBodyLimitError({ code: "PAYLOAD_TOO_LARGE" })); return { dispose: finish, isTripped: () => tripped, code: () => reason }; } //#endregion export { isRequestBodyLimitError as a, requestBodyErrorToText as c, isHttpConnectionClosing as d, runHttpConnectionRequest as f, installRequestBodyLimitGuard as i, testApi as l, waitForHttpRequestRejection as m, DEFAULT_WEBHOOK_MAX_BODY_BYTES as n, readJsonBodyWithLimit as o, sendHttpRequestRejection as p, RequestBodyLimitError as r, readRequestBodyWithLimit as s, DEFAULT_WEBHOOK_BODY_TIMEOUT_MS as t, clearHttpResponseRepresentationHeaders as u };