openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
1,072 lines (985 loc) • 38 kB
text/typescript
import http from "node:http";
import https from "node:https";
import net from "node:net";
import tls from "node:tls";
import { domainToASCII } from "node:url";
import {
readProxyEnv,
resolveAmbientProxyForUrl,
type ProxyEnvSnapshot,
} from "./env.js";
import { formatConnectAuthority, resolveProxyConnectTimeoutMs } from "./connect.js";
import { connectToProxy, type ProxyConnectOptions } from "./proxy-socket.js";
import { ProxylineError, decodeProxyUserinfoComponent, type ProxylineTlsOptions } from "./shared.js";
import type { ProxylineSurface, ProxyResolver } from "./types.js";
export type NodeHttpRequestOptions = http.RequestOptions & https.RequestOptions & {
agent?: http.Agent | false;
};
type NodeHttpMethod = typeof http.request;
type NodeAgentFactory = (options: NodeHttpRequestOptions) => http.Agent;
type NodeAgentOptions = http.AgentOptions & https.AgentOptions;
type ResolveProxyConnectOptions = (proxyUrl: string) => ProxyConnectOptions;
const pendingRequestSymbol = Symbol("proxyline.pendingRequest");
type NodeAgentRequestOptions = http.RequestOptions & https.RequestOptions & {
secureEndpoint?: boolean;
[pendingRequestSymbol]?: http.ClientRequest;
};
type NodeAddRequestAgent = http.Agent & {
addRequest(req: http.ClientRequest, options: NodeAgentRequestOptions): void;
};
type NodeCreateSocketAgent = http.Agent & {
createSocket(
req: http.ClientRequest,
options: NodeAgentRequestOptions,
callback: (error: Error | null, socket?: net.Socket) => void,
): void;
};
type RequestSetTimeout = (
this: http.ClientRequest,
timeout: number,
callback?: () => void,
) => http.ClientRequest;
type RequestDestroy = (this: http.ClientRequest, error?: Error) => http.ClientRequest;
type NodeAgentWithOptions = http.Agent & {
options?: NodeAgentOptions;
};
type NodeProxyAgentOptions = NodeAgentOptions & {
defaultProtocol?: "http" | "https";
getProxyForUrl: (
url: string,
surface?: ProxylineSurface,
request?: http.ClientRequest,
) => string;
proxyTls?: ProxylineTlsOptions;
resolveProxyConnectOptions?: ResolveProxyConnectOptions;
};
const MAX_CONNECT_RESPONSE_HEADER_BYTES = 16 * 1024;
const INVALID_PROXY_TARGET_HOST_DELIMITER_PATTERN = /[/:?#@\\]/;
const INVALID_PROXY_TARGET_HOST_CONTROL_PATTERN = /[\u0000-\u0020\u007f]/;
const nodeAgentDefaultPorts = new WeakMap<object, number>();
export const CALLER_AGENT_TLS_OPTION_KEYS = [
"ca",
"cert",
"ciphers",
"clientCertEngine",
"crl",
"dhparam",
"ecdhCurve",
"honorCipherOrder",
"key",
"maxVersion",
"minVersion",
"passphrase",
"pfx",
"rejectUnauthorized",
"secureOptions",
"secureProtocol",
"sessionIdContext",
] as const;
export type NodeHttpStackSnapshot = {
httpRequest: typeof http.request;
httpGet: typeof http.get;
httpGlobalAgent: typeof http.globalAgent;
httpsRequest: typeof https.request;
httpsGet: typeof https.get;
httpsGlobalAgent: typeof https.globalAgent;
};
function copyNodeHttpOptions(value: unknown): NodeHttpRequestOptions {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return {};
}
return { ...(value as NodeHttpRequestOptions) };
}
function readAgentOptions(agent: http.Agent | false | undefined): NodeAgentOptions | undefined {
if (agent === undefined || agent === false) {
return undefined;
}
return (agent as NodeAgentWithOptions).options;
}
function preserveCallerAgentOptions(options: NodeHttpRequestOptions): void {
const agentOptions = readAgentOptions(options.agent);
if (agentOptions === undefined) {
return;
}
for (const key of CALLER_AGENT_TLS_OPTION_KEYS) {
const value = agentOptions[key];
if (value !== undefined && options[key as keyof NodeHttpRequestOptions] === undefined) {
options[key as keyof NodeHttpRequestOptions] = value as never;
}
}
}
function unbracketHostname(hostname: string): string {
return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
}
function inferDestinationHostname(
url: string | URL | undefined,
options: NodeHttpRequestOptions,
): string | undefined {
if (typeof options.hostname === "string") {
return unbracketHostname(options.hostname);
}
if (url !== undefined) {
return unbracketHostname(url instanceof URL ? url.hostname : new URL(url).hostname);
}
if (typeof options.host === "string") {
return unbracketHostname(splitHostPort(options.host).host);
}
return undefined;
}
function preserveDestinationTlsIdentity(
url: string | URL | undefined,
options: NodeHttpRequestOptions,
): void {
if (options.servername !== undefined) {
return;
}
const hostname = inferDestinationHostname(url, options);
if (!hostname) {
return;
}
if (net.isIP(hostname) === 0) {
options.servername = hostname;
}
}
export function bindNodeHttpMethod<TMethod extends NodeHttpMethod>(
originalMethod: TMethod,
createAgent: NodeAgentFactory,
): TMethod {
return ((...args: unknown[]) => {
let url: string | URL | undefined;
let options: NodeHttpRequestOptions;
let callback: unknown;
const firstArg = args[0];
if (typeof firstArg === "string" || firstArg instanceof URL) {
url = firstArg;
if (typeof args[1] === "function") {
options = {};
callback = args[1];
} else {
options = copyNodeHttpOptions(args[1]);
callback = args[2];
}
} else {
options = copyNodeHttpOptions(firstArg);
callback = args[1];
}
preserveCallerAgentOptions(options);
preserveDestinationTlsIdentity(url, options);
const agent = createAgent(options);
options.agent = agent;
delete options.createConnection;
if (url !== undefined) {
const request = originalMethod(url, options, callback as (res: http.IncomingMessage) => void);
request.once("close", () => {
agent.destroy();
});
return request;
}
const request = originalMethod(options, callback as (res: http.IncomingMessage) => void);
request.once("close", () => {
agent.destroy();
});
return request;
}) as TMethod;
}
function proxyAuthorization(proxy: URL): string | undefined {
if (!proxy.username && !proxy.password) {
return undefined;
}
const username = decodeProxyUserinfoComponent(proxy.username);
const password = decodeProxyUserinfoComponent(proxy.password);
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
}
function assertSupportedNodeProxyProtocol(proxy: URL): void {
if (proxy.protocol !== "http:" && proxy.protocol !== "https:") {
throw new ProxylineError(
"UNSUPPORTED_PROXY_PROTOCOL",
`Node HTTP agents support http:// and https:// proxy endpoints: ${proxy.protocol}`,
);
}
}
function requestProtocol(
req: http.ClientRequest,
options: NodeAgentRequestOptions,
stackProtocol: "http" | "https" | undefined,
): string {
const isWebSocket = isWebSocketRequest(req);
if (isSecureEndpoint(options, stackProtocol)) {
return isWebSocket ? "wss:" : "https:";
}
return isWebSocket ? "ws:" : "http:";
}
function normalizedPort(value: unknown): number | undefined {
if (typeof value !== "number" && typeof value !== "string") {
return undefined;
}
const port = Number(value);
return Number.isInteger(port) && port >= 1 && port <= 65_535 ? port : undefined;
}
function normalizedPositiveInteger(value: unknown): number | undefined {
if (typeof value !== "number" && typeof value !== "string") {
return undefined;
}
const integer = Number(value);
return Number.isInteger(integer) && integer > 0 ? integer : undefined;
}
function requestAuthority(options: NodeAgentRequestOptions): string {
const rawHost = options.hostname ?? options.host ?? "localhost";
const parsed = splitHostPort(String(rawHost));
const host = normalizeProxyTargetHost(parsed.host || "localhost");
const port = parsed.port ?? normalizedPort(options.port);
const authorityHost = net.isIPv6(host) ? `[${host}]` : host;
return port === undefined ? authorityHost : `${authorityHost}:${port}`;
}
function requestDestinationUrl(
req: http.ClientRequest,
options: NodeAgentRequestOptions,
stackProtocol: "http" | "https" | undefined,
): string {
const path = req.path.startsWith("/") ? req.path : `/${req.path}`;
return `${requestProtocol(req, options, stackProtocol)}//${requestAuthority(options)}${path}`;
}
function proxyForwardRequestPath(req: http.ClientRequest, options: NodeAgentRequestOptions): string {
if (/^(?:https?|wss?):\/\//i.test(req.path)) {
return new URL(req.path).href;
}
return requestDestinationUrl(req, options, undefined);
}
function setProxyRequestHeaders(req: http.ClientRequest, proxy: URL, keepAlive: boolean): void {
const authorization = proxyAuthorization(proxy);
if (authorization !== undefined) {
req.setHeader("Proxy-Authorization", authorization);
}
if (!req.hasHeader("Proxy-Connection")) {
req.setHeader("Proxy-Connection", keepAlive ? "Keep-Alive" : "close");
}
}
function setForwardProxyRequestPath(
req: http.ClientRequest & { _header?: string | null },
options: NodeAgentRequestOptions,
): void {
req._header = null;
req.path = proxyForwardRequestPath(req, options);
}
function isWebSocketRequest(req: http.ClientRequest): boolean {
return String(req.getHeader("upgrade") ?? "").toLowerCase() === "websocket";
}
function isSecureEndpoint(
options: NodeAgentRequestOptions,
stackProtocol?: "http" | "https",
): boolean {
return (
stackProtocol === "https" ||
options.secureEndpoint === true ||
options.protocol === "https:" ||
options.protocol === "wss:" ||
options.defaultPort === 443
);
}
function shouldTunnelRequest(
req: http.ClientRequest,
options: NodeAgentRequestOptions,
stackProtocol: "http" | "https" | undefined,
): boolean {
return isSecureEndpoint(options, stackProtocol) || isWebSocketRequest(req);
}
function requestSurface(
req: http.ClientRequest,
options: NodeAgentRequestOptions,
stackProtocol: "http" | "https" | undefined,
): ProxylineSurface {
if (isWebSocketRequest(req)) {
return "websocket";
}
return isSecureEndpoint(options, stackProtocol) ? "node-https" : "node-http";
}
function splitHostPort(value: string): { host: string; port?: number } {
const bracketed = value.match(/^\[([^\]]+)\](?::(\d+))?$/);
if (bracketed) {
return {
host: bracketed[1] ?? "",
...(bracketed[2] !== undefined ? { port: Number(bracketed[2]) } : {}),
};
}
const lastColon = value.lastIndexOf(":");
const hasSingleColon = lastColon !== -1 && value.indexOf(":") === lastColon;
if (hasSingleColon) {
const possiblePort = value.slice(lastColon + 1);
if (/^\d+$/.test(possiblePort)) {
return { host: value.slice(0, lastColon), port: Number(possiblePort) };
}
}
return { host: value };
}
function normalizeProxyTargetHost(host: string): string {
const unbracketedHost =
host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
if (net.isIP(unbracketedHost) !== 0) {
return unbracketedHost;
}
if (INVALID_PROXY_TARGET_HOST_DELIMITER_PATTERN.test(host)) {
throw new ProxylineError("INVALID_CONNECT_TARGET", "CONNECT target host contains unsafe delimiters.");
}
if (INVALID_PROXY_TARGET_HOST_CONTROL_PATTERN.test(host)) {
throw new ProxylineError("INVALID_CONNECT_TARGET", "CONNECT target host contains unsafe characters.");
}
const asciiHost = domainToASCII(host);
if (!asciiHost) {
throw new ProxylineError("INVALID_CONNECT_TARGET", "CONNECT target host is not a valid host name.");
}
return asciiHost;
}
function connectTarget(options: NodeAgentRequestOptions): { host: string; port: number } {
const rawHost = options.hostname ?? options.host;
if (typeof rawHost !== "string") {
throw new ProxylineError("INVALID_CONNECT_TARGET", "CONNECT target is missing host.");
}
const parsed = splitHostPort(rawHost);
const port = parsed.port ?? Number(options.port);
if (!parsed.host || !Number.isInteger(port)) {
throw new ProxylineError("INVALID_CONNECT_TARGET", "CONNECT target is missing host or port.");
}
const host = normalizeProxyTargetHost(parsed.host);
formatConnectAuthority(host, port);
return { host, port };
}
function destinationTlsConnectOptions(
options: NodeAgentRequestOptions,
socket: net.Socket,
): tls.ConnectionOptions {
const target = connectTarget(options);
const tlsOptions = { ...options, socket } as tls.ConnectionOptions & Record<string, unknown>;
tlsOptions.host = target.host;
delete tlsOptions.path;
delete tlsOptions.port;
delete tlsOptions.secureEndpoint;
delete tlsOptions.agent;
return tlsOptions;
}
abstract class ProxylineRequestAgent extends http.Agent {
public addRequest(req: http.ClientRequest, options: NodeAgentRequestOptions): void {
(http.Agent.prototype as unknown as NodeAddRequestAgent).addRequest.call(this, req, options);
}
public createSocket(
req: http.ClientRequest,
options: NodeAgentRequestOptions,
callback: (error: Error | null, socket?: net.Socket) => void,
): void {
// Node chooses queued requests by origin, not global FIFO, and clones options.
(http.Agent.prototype as unknown as NodeCreateSocketAgent).createSocket.call(
this, req, { ...options, [pendingRequestSymbol]: req }, callback,
);
}
protected takePendingRequest(options: NodeAgentRequestOptions): http.ClientRequest | undefined {
const request = options[pendingRequestSymbol];
// Node retains these options in pooled socket listeners after handoff.
delete options[pendingRequestSymbol];
return request;
}
}
class ProxylineHttpForwardAgent extends ProxylineRequestAgent {
public readonly options: NodeAgentOptions;
readonly #keepAlive: boolean;
readonly #pendingConnectSockets = new Set<net.Socket>();
readonly #proxy: URL;
readonly #proxyTls: ProxylineTlsOptions | undefined;
readonly #proxyConnect: ProxyConnectOptions;
public constructor(proxy: URL, options: NodeAgentOptions, proxyTls: ProxylineTlsOptions | undefined, proxyConnect: ProxyConnectOptions) {
super(options);
this.options = options;
this.#keepAlive = options.keepAlive === true;
this.#proxy = proxy;
this.#proxyTls = proxyTls;
this.#proxyConnect = proxyConnect;
}
public override addRequest(req: http.ClientRequest, options: NodeAgentRequestOptions): void {
setForwardProxyRequestPath(req as http.ClientRequest & { _header?: string | null }, options);
setProxyRequestHeaders(req, this.#proxy, this.#keepAlive);
super.addRequest(req, options);
}
public override createConnection(
options: NodeAgentRequestOptions,
callback?: (error: Error | null, socket: net.Socket) => void,
): net.Socket {
const request = this.takePendingRequest(options);
const socket = connectToProxy(this.#proxy, this.#proxyTls, this.#proxyConnect);
// When Node supplies an async callback, deliver the socket only through that
// path. Returning the same socket as well double-invokes Agent setup and can
// hand an unready TLS proxy socket to plain HTTP forward traffic.
if (callback !== undefined) {
this.#pendingConnectSockets.add(socket);
let pendingTimeout: ReturnType<typeof setTimeout> | undefined;
let settled = false;
let originalRequestSetTimeout: RequestSetTimeout | undefined;
let hookedRequestSetTimeout: RequestSetTimeout | undefined;
let originalRequestDestroy: RequestDestroy | undefined;
let hookedRequestDestroy: RequestDestroy | undefined;
const restoreRequestDestroyHook = (): void => {
if (
request !== undefined &&
originalRequestDestroy !== undefined &&
request.destroy === hookedRequestDestroy
) {
request.destroy = originalRequestDestroy;
}
originalRequestDestroy = undefined;
hookedRequestDestroy = undefined;
};
const startPendingTimeout = (timeoutMs: number): void => {
if (pendingTimeout !== undefined) {
clearTimeout(pendingTimeout);
}
pendingTimeout = setTimeout(() => {
request?.emit("timeout");
if (!settled) {
fail(new ProxylineError("CONNECT_FAILED", "proxy connection timed out"));
}
}, timeoutMs);
pendingTimeout.unref?.();
};
const clearPendingTimeout = (): void => {
if (pendingTimeout !== undefined) {
clearTimeout(pendingTimeout);
pendingTimeout = undefined;
}
};
const restoreRequestTimeoutHook = (): void => {
if (
request !== undefined &&
originalRequestSetTimeout !== undefined &&
request.setTimeout === hookedRequestSetTimeout
) {
request.setTimeout = originalRequestSetTimeout;
}
originalRequestSetTimeout = undefined;
hookedRequestSetTimeout = undefined;
};
const cleanup = (): void => {
clearPendingTimeout();
this.#pendingConnectSockets.delete(socket);
restoreRequestTimeoutHook();
restoreRequestDestroyHook();
socket.off(this.#proxy.protocol === "https:" ? "secureConnect" : "connect", onConnected);
socket.off("error", onError);
socket.off("close", onClosed);
request?.off("abort", onRequestClosed);
request?.off("close", onRequestClosed);
request?.off("error", onRequestClosed);
request?.off("timeout", onRequestTimedOut);
};
const finish = (error: Error | null): void => {
if (settled) {
return;
}
settled = true;
cleanup();
callback(error, socket);
};
const fail = (error: Error): void => {
socket.destroy();
finish(error);
};
const onError = (error: Error): void => {
finish(error);
};
const onClosed = (): void => {
finish(new ProxylineError("CONNECT_FAILED", "proxy socket closed before connection completed"));
};
const onConnected = (): void => {
finish(null);
};
const onRequestTimedOut = (): void => {
if (!settled) {
fail(new ProxylineError("CONNECT_FAILED", "proxy connection timed out"));
}
};
const onRequestClosed = (error?: Error): void => {
if (!settled) {
fail(error ?? new ProxylineError("CONNECT_FAILED", "request closed before proxy connection completed"));
}
};
if (request !== undefined) {
originalRequestSetTimeout = request.setTimeout;
hookedRequestSetTimeout = function hookedSetTimeout(timeout, callback) {
const result = originalRequestSetTimeout?.call(this, timeout, callback) ?? this;
const timeoutMs = normalizedPositiveInteger(timeout);
if (timeoutMs !== undefined) {
startPendingTimeout(timeoutMs);
} else {
clearPendingTimeout();
}
return result;
};
request.setTimeout = hookedRequestSetTimeout;
}
// Prefer explicit agent/request timeout. Default 30s only when both are omitted.
// Explicit values go through normalizedPositiveInteger first so fractional/invalid
// timeouts keep the historical no-pending-timer behavior (not Math.trunc to 1ms).
const requestTimeout = (request as { timeout?: unknown } | undefined)?.timeout;
const rawTimeout = options.timeout !== undefined ? options.timeout : requestTimeout;
if (rawTimeout === undefined) {
const connectTimeoutMs = resolveProxyConnectTimeoutMs(undefined);
if (connectTimeoutMs !== undefined) {
startPendingTimeout(connectTimeoutMs);
}
} else {
const timeoutMs = normalizedPositiveInteger(rawTimeout);
if (timeoutMs !== undefined) {
startPendingTimeout(timeoutMs);
}
}
request?.once("timeout", onRequestTimedOut);
socket.once(this.#proxy.protocol === "https:" ? "secureConnect" : "connect", onConnected);
socket.once("error", onError);
socket.once("close", onClosed);
// Node defers ClientRequest.destroy() until the async socket callback.
if (request !== undefined) {
originalRequestDestroy = request.destroy;
hookedRequestDestroy = function hookedDestroy(this: http.ClientRequest, error?: Error) {
const result = originalRequestDestroy?.call(this, error) ?? this;
onRequestClosed(error);
return result;
};
request.destroy = hookedRequestDestroy;
}
request?.once("abort", onRequestClosed);
request?.once("close", onRequestClosed);
request?.once("error", onRequestClosed);
if (request?.destroyed) {
onRequestClosed();
}
return undefined as unknown as net.Socket;
}
return socket;
}
public override destroy(): void {
for (const socket of this.#pendingConnectSockets) {
socket.destroy(new ProxylineError("CONNECT_FAILED", "proxy connection failed because agent was destroyed"));
}
this.#pendingConnectSockets.clear();
super.destroy();
}
}
class ProxylineConnectAgent extends ProxylineRequestAgent {
public readonly options: NodeAgentOptions;
readonly #keepAlive: boolean;
readonly #pendingConnectSockets = new Set<net.Socket>();
readonly #proxy: URL;
readonly #proxyTls: ProxylineTlsOptions | undefined;
readonly #proxyConnect: ProxyConnectOptions;
public constructor(proxy: URL, options: NodeAgentOptions, proxyTls: ProxylineTlsOptions | undefined, proxyConnect: ProxyConnectOptions) {
super(options);
this.options = options;
this.#keepAlive = options.keepAlive === true;
this.#proxy = proxy;
this.#proxyTls = proxyTls;
this.#proxyConnect = proxyConnect;
}
public override createConnection(
options: NodeAgentRequestOptions,
callback?: (error: Error | null, socket: net.Socket) => void,
): net.Socket {
const request = this.takePendingRequest(options);
if (callback === undefined) {
throw new ProxylineError("INVALID_CONNECT_CALLBACK", "CONNECT agents require an async socket callback.");
}
const proxySocket = connectToProxy(this.#proxy, this.#proxyTls, this.#proxyConnect);
this.#pendingConnectSockets.add(proxySocket);
let pendingTimeout: ReturnType<typeof setTimeout> | undefined;
let settled = false;
let responseBuffer = Buffer.alloc(0);
let originalRequestSetTimeout: RequestSetTimeout | undefined;
let hookedRequestSetTimeout: RequestSetTimeout | undefined;
let originalRequestDestroy: RequestDestroy | undefined;
let hookedRequestDestroy: RequestDestroy | undefined;
let tlsSocket: tls.TLSSocket | undefined;
const startPendingTimeout = (timeoutMs: number): void => {
if (pendingTimeout !== undefined) {
clearTimeout(pendingTimeout);
}
pendingTimeout = setTimeout(() => {
request?.emit("timeout");
if (!settled) {
fail(new ProxylineError("CONNECT_FAILED", "proxy CONNECT timed out"));
}
}, timeoutMs);
pendingTimeout.unref?.();
};
const clearPendingTimeout = (): void => {
if (pendingTimeout !== undefined) {
clearTimeout(pendingTimeout);
pendingTimeout = undefined;
}
};
const restoreRequestTimeoutHook = (): void => {
if (
request !== undefined &&
originalRequestSetTimeout !== undefined &&
request.setTimeout === hookedRequestSetTimeout
) {
request.setTimeout = originalRequestSetTimeout;
}
originalRequestSetTimeout = undefined;
hookedRequestSetTimeout = undefined;
};
const restoreRequestDestroyHook = (): void => {
if (
request !== undefined &&
originalRequestDestroy !== undefined &&
request.destroy === hookedRequestDestroy
) {
request.destroy = originalRequestDestroy;
}
originalRequestDestroy = undefined;
hookedRequestDestroy = undefined;
};
const cleanupProxyHandshakeListeners = (): void => {
proxySocket.off("data", onData);
proxySocket.off("error", onError);
proxySocket.off("end", onClosed);
proxySocket.off("close", onClosed);
proxySocket.off("connect", onConnected);
proxySocket.off("secureConnect", onConnected);
};
const cleanup = (): void => {
clearPendingTimeout();
this.#pendingConnectSockets.delete(proxySocket);
if (tlsSocket !== undefined) {
this.#pendingConnectSockets.delete(tlsSocket);
}
restoreRequestTimeoutHook();
restoreRequestDestroyHook();
cleanupProxyHandshakeListeners();
request?.off("abort", onRequestClosed);
request?.off("close", onRequestClosed);
request?.off("error", onRequestClosed);
request?.off("timeout", onRequestTimedOut);
};
const finish = (error: Error | null, socket: net.Socket): void => {
if (settled) {
if (error === null) {
socket.destroy();
}
return;
}
settled = true;
cleanup();
callback(error, socket);
};
const fail = (error: Error): void => {
proxySocket.destroy();
finish(error, proxySocket);
};
const onConnected = (): void => {
try {
const { host, port } = connectTarget(options);
const authority = formatConnectAuthority(host, port);
const headers = [
`CONNECT ${authority} HTTP/1.1`,
`Host: ${authority}`,
`Proxy-Connection: ${this.#keepAlive ? "Keep-Alive" : "close"}`,
];
const authorization = proxyAuthorization(this.#proxy);
if (authorization !== undefined) {
headers.push(`Proxy-Authorization: ${authorization}`);
}
proxySocket.write([...headers, "", ""].join("\r\n"));
} catch (error) {
fail(error instanceof Error ? error : new Error(String(error)));
}
};
const onData = (chunk: Buffer): void => {
responseBuffer = Buffer.concat([responseBuffer, chunk]);
const headerEnd = responseBuffer.indexOf("\r\n\r\n");
if (headerEnd === -1) {
if (responseBuffer.length > MAX_CONNECT_RESPONSE_HEADER_BYTES) {
fail(new ProxylineError("CONNECT_FAILED", "proxy CONNECT response headers were too large"));
}
return;
}
const bodyOffset = headerEnd + 4;
if (bodyOffset > MAX_CONNECT_RESPONSE_HEADER_BYTES) {
fail(new ProxylineError("CONNECT_FAILED", "proxy CONNECT response headers were too large"));
return;
}
const statusLine = responseBuffer.subarray(0, bodyOffset).toString("latin1").split("\r\n", 1)[0] ?? "";
if (!/^HTTP\/1\.[01] 2\d\d\b/.test(statusLine)) {
fail(new ProxylineError("CONNECT_FAILED", statusLine || "proxy returned an invalid CONNECT response"));
return;
}
const tunneledBytes = responseBuffer.subarray(bodyOffset);
cleanupProxyHandshakeListeners();
if (tunneledBytes.length > 0) {
proxySocket.unshift(tunneledBytes);
}
if (!isSecureEndpoint(options)) {
finish(null, proxySocket);
return;
}
const currentTlsSocket = tls.connect(destinationTlsConnectOptions(options, proxySocket));
tlsSocket = currentTlsSocket;
this.#pendingConnectSockets.add(currentTlsSocket);
const onTlsError = (error: Error): void => {
currentTlsSocket.off("close", onTlsClosed);
finish(error, currentTlsSocket);
};
const onTlsSecureConnect = (): void => {
currentTlsSocket.off("error", onTlsError);
currentTlsSocket.off("close", onTlsClosed);
finish(null, currentTlsSocket);
};
const onTlsClosed = (): void => {
finish(
new ProxylineError("CONNECT_FAILED", "destination TLS socket closed before secureConnect"),
currentTlsSocket,
);
};
currentTlsSocket.once("secureConnect", onTlsSecureConnect);
currentTlsSocket.once("error", onTlsError);
currentTlsSocket.once("close", onTlsClosed);
};
const onError = (error: Error): void => {
fail(error);
};
const onClosed = (): void => {
fail(new ProxylineError("CONNECT_FAILED", "proxy socket closed before CONNECT completed"));
};
const onRequestClosed = (error?: Error): void => {
if (!settled) {
fail(error ?? new ProxylineError("CONNECT_FAILED", "request closed before proxy CONNECT completed"));
}
};
const onRequestTimedOut = (): void => {
if (!settled) {
fail(new ProxylineError("CONNECT_FAILED", "proxy CONNECT timed out"));
}
};
if (request !== undefined) {
originalRequestSetTimeout = request.setTimeout;
hookedRequestSetTimeout = function hookedSetTimeout(timeout, callback) {
const result = originalRequestSetTimeout?.call(this, timeout, callback) ?? this;
const timeoutMs = normalizedPositiveInteger(timeout);
if (timeoutMs !== undefined) {
startPendingTimeout(timeoutMs);
} else {
clearPendingTimeout();
}
return result;
};
request.setTimeout = hookedRequestSetTimeout;
}
// Prefer explicit agent/request timeout. Default 30s only when both are omitted.
// Explicit values go through normalizedPositiveInteger first so fractional/invalid
// timeouts keep the historical no-pending-timer behavior (not Math.trunc to 1ms).
const requestTimeout = (request as { timeout?: unknown } | undefined)?.timeout;
const rawTimeout = options.timeout !== undefined ? options.timeout : requestTimeout;
if (rawTimeout === undefined) {
const connectTimeoutMs = resolveProxyConnectTimeoutMs(undefined);
if (connectTimeoutMs !== undefined) {
startPendingTimeout(connectTimeoutMs);
}
} else {
const timeoutMs = normalizedPositiveInteger(rawTimeout);
if (timeoutMs !== undefined) {
startPendingTimeout(timeoutMs);
}
}
request?.once("timeout", onRequestTimedOut);
// Node defers ClientRequest.destroy() until the async socket callback.
if (request !== undefined) {
originalRequestDestroy = request.destroy;
hookedRequestDestroy = function hookedDestroy(this: http.ClientRequest, error?: Error) {
const result = originalRequestDestroy?.call(this, error) ?? this;
onRequestClosed(error);
return result;
};
request.destroy = hookedRequestDestroy;
}
request?.once("abort", onRequestClosed);
request?.once("close", onRequestClosed);
request?.once("error", onRequestClosed);
if (request?.destroyed) {
onRequestClosed();
}
proxySocket.once(this.#proxy.protocol === "https:" ? "secureConnect" : "connect", onConnected);
proxySocket.on("data", onData);
proxySocket.once("error", onError);
proxySocket.once("end", onClosed);
proxySocket.once("close", onClosed);
return undefined as unknown as net.Socket;
}
public override destroy(): void {
for (const socket of this.#pendingConnectSockets) {
socket.destroy();
}
this.#pendingConnectSockets.clear();
super.destroy();
}
}
export class ProxylineNodeProxyAgent extends http.Agent {
public readonly options: NodeAgentOptions;
readonly #agents = new Map<string, NodeAddRequestAgent>();
readonly #defaultProtocol: "http" | "https";
readonly #getProxyForUrl: (
url: string,
surface?: ProxylineSurface,
request?: http.ClientRequest,
) => string;
readonly #httpAgent: NodeAddRequestAgent;
readonly #httpsAgent: NodeAddRequestAgent;
readonly #proxyTls: ProxylineTlsOptions | undefined;
readonly #resolveProxyConnectOptions: ResolveProxyConnectOptions | undefined;
public constructor(options: NodeProxyAgentOptions) {
const { defaultProtocol = "http", getProxyForUrl, proxyTls, resolveProxyConnectOptions, ...agentOptions } = options;
super(agentOptions);
if (nodeAgentDefaultPorts.get(this) === 80) {
nodeAgentDefaultPorts.delete(this);
}
this.options = agentOptions;
this.#defaultProtocol = defaultProtocol;
this.#getProxyForUrl = getProxyForUrl;
this.#proxyTls = proxyTls;
this.#resolveProxyConnectOptions = resolveProxyConnectOptions;
this.#httpAgent = new http.Agent(agentOptions) as NodeAddRequestAgent;
this.#httpsAgent = new https.Agent(agentOptions) as unknown as NodeAddRequestAgent;
}
public get defaultPort(): number {
const stackProtocol = this.#callStackProtocol();
return nodeAgentDefaultPorts.get(this) ??
((stackProtocol ?? this.#defaultProtocol) === "https" ? 443 : 80);
}
public set defaultPort(value: number) {
nodeAgentDefaultPorts.set(this, value);
}
public get protocol(): string {
return `${this.#callStackProtocol() ?? this.#defaultProtocol}:`;
}
public set protocol(_value: string) {
// Node's http.Agent constructor assigns this, but this wrapper is dual-use.
}
public getProxyForUrl(url: string, request?: http.ClientRequest): string {
return this.#getProxyForUrl(url, undefined, request);
}
#callStackProtocol(): "http" | "https" | undefined {
const originalStackTraceLimit = Error.stackTraceLimit;
const errorConstructor = Error as unknown as { prepareStackTrace?: unknown };
const originalPrepareStackTrace = errorConstructor.prepareStackTrace;
if (typeof originalStackTraceLimit !== "number" || originalStackTraceLimit < 20) {
// Node reads agent.protocol/defaultPort before addRequest, so this is the only caller signal.
Error.stackTraceLimit = 20;
}
let stack: string | undefined;
try {
delete errorConstructor.prepareStackTrace;
stack = new Error().stack;
} finally {
if (originalPrepareStackTrace === undefined) {
delete errorConstructor.prepareStackTrace;
} else {
errorConstructor.prepareStackTrace = originalPrepareStackTrace;
}
Error.stackTraceLimit = originalStackTraceLimit;
}
if (typeof stack !== "string") {
return undefined;
}
for (const line of stack.split("\n")) {
if (line.includes("node:https:")) {
return "https";
}
if (line.includes("node:http:")) {
return "http";
}
}
return undefined;
}
public addRequest(req: http.ClientRequest, options: NodeAgentRequestOptions): void {
const stackProtocol = this.#callStackProtocol();
const agentOptions =
stackProtocol === "https" && options.secureEndpoint !== true
? { ...options, secureEndpoint: true }
: options;
const url = requestDestinationUrl(req, agentOptions, stackProtocol);
const surface = requestSurface(req, agentOptions, stackProtocol);
const proxy = this.#getProxyForUrl(url, surface, req);
if (!proxy) {
(isSecureEndpoint(agentOptions, stackProtocol) ? this.#httpsAgent : this.#httpAgent)
.addRequest(req, agentOptions);
return;
}
const proxyUrl = new URL(proxy);
assertSupportedNodeProxyProtocol(proxyUrl);
const tunnel = shouldTunnelRequest(req, agentOptions, stackProtocol);
const key = `${tunnel ? "connect" : "forward"}:${proxyUrl.href}`;
let agent = this.#agents.get(key);
if (agent === undefined) {
// Each cached child owns a snapshot; later mutation of the caller's
// options cannot change an already-selected proxy connection policy.
const proxyConnect = { ...this.#resolveProxyConnectOptions?.(proxyUrl.href) };
const newAgent = tunnel
? new ProxylineConnectAgent(proxyUrl, this.options, this.#proxyTls, proxyConnect)
: new ProxylineHttpForwardAgent(proxyUrl, this.options, this.#proxyTls, proxyConnect);
agent = newAgent;
this.#agents.set(key, agent);
}
agent.addRequest(req, agentOptions);
}
public override destroy(): void {
for (const agent of this.#agents.values()) {
agent.destroy();
}
this.#agents.clear();
this.#httpAgent.destroy();
this.#httpsAgent.destroy();
super.destroy();
}
}
export function createNodeProxyAgent(
resolver: ProxyResolver,
proxyCa: string | undefined,
defaultProtocol: "http" | "https" = "http",
): ProxylineNodeProxyAgent {
return new ProxylineNodeProxyAgent({
defaultProtocol,
getProxyForUrl: resolver.getProxyForUrl,
...(proxyCa !== undefined ? { proxyTls: { ca: proxyCa } } : {}),
});
}
export function createDirectNodeAgent(): ProxylineNodeProxyAgent {
return new ProxylineNodeProxyAgent({
getProxyForUrl: () => "",
});
}
export type AmbientNodeProxyAgentOptions = {
env?: ProxyEnvSnapshot;
protocol?: "http" | "https";
proxyTls?: ProxylineTlsOptions;
resolveProxyConnectOptions?: ResolveProxyConnectOptions;
};
function ambientProbeUrl(protocol: "http" | "https"): string {
return `${protocol}://proxyline.invalid/`;
}
export function hasAmbientNodeProxyConfigured(
options: AmbientNodeProxyAgentOptions = {},
): boolean {
const env = options.env ?? readProxyEnv();
const protocol = options.protocol ?? "https";
return resolveAmbientProxyForUrl(ambientProbeUrl(protocol), env) !== undefined;
}
export function createAmbientNodeProxyAgent(
options: AmbientNodeProxyAgentOptions = {},
): ProxylineNodeProxyAgent | undefined {
const env = options.env ?? readProxyEnv();
const protocol = options.protocol ?? "https";
if (resolveAmbientProxyForUrl(ambientProbeUrl(protocol), env) === undefined) {
return undefined;
}
return new ProxylineNodeProxyAgent({
defaultProtocol: protocol,
getProxyForUrl: (url) => resolveAmbientProxyForUrl(url, env) ?? "",
...(options.proxyTls !== undefined ? { proxyTls: options.proxyTls } : {}),
...(options.resolveProxyConnectOptions !== undefined
? { resolveProxyConnectOptions: options.resolveProxyConnectOptions }
: {}),
});
}