UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

245 lines (244 loc) • 8.43 kB
import { t as createDeferredCore } from "./deferred-D0La5CRk.mjs"; import { Socket } from "node:net"; import { channel } from "node:diagnostics_channel"; import { clearTimeout, setTimeout } from "node:timers"; //#region src/infra/http-request-lifecycle.ts const REJECTION_CLOSE_TIMEOUT_MS = 1e3; const connections = /* @__PURE__ */ new WeakMap(); function httpRejectionTransport(socket) { if (!process.versions.bun) return "node"; return socket.destroySoon === Socket.prototype.destroySoon ? "bun-native" : "bun-node"; } /** Abort disconnected work without treating normal request/response completion as cancellation. */ function createHttpRequestAbortSignal(req, res) { const controller = new AbortController(); const abortIfRequestIncomplete = () => { if (!req.complete) controller.abort(); }; const abortIfResponseStillOpen = () => { if (!res.writableEnded) controller.abort(); }; req.once("close", abortIfRequestIncomplete); res.once("close", abortIfResponseStillOpen); if (req.destroyed && !req.complete || res.destroyed && !res.writableEnded) controller.abort(); return { signal: controller.signal, cleanup: () => { req.off("close", abortIfRequestIncomplete); res.off("close", abortIfResponseStillOpen); } }; } 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 detachRequestError = () => req.off("error", rejection.destroy); req.once("close", detachRequestError); const onClose = () => { rejection.phase = "closed"; clearTimeout(timer); req.off("close", detachRequestError); req.off("readable", pauseCompletedRequest); socket.off("error", rejection.destroy); queueMicrotask(detachRequestError); 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 = () => { if (!res.writableFinished) 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(); } } }; const transport = httpRejectionTransport(socket); if (transport === "bun-native") 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(); if (transport === "bun-node") res.write("", onWritten); else res.socket.write("", onWritten); } catch { rejection.destroy(); } }; if (!res.socket) { if (transport === "bun-node") { const onSocket = () => queueMicrotask(writeHeaders); res.once("socket", onSocket); stopWaitingForSocket = () => res.off("socket", onSocket); } else { 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 export { selectHttpRequestRejection as a, runHttpConnectionRequest as i, createHttpRequestAbortSignal as n, sendHttpRequestRejection as o, isHttpConnectionClosing as r, waitForHttpRequestRejection as s, clearHttpResponseRepresentationHeaders as t };