js: update node:https Agent

This commit is contained in:
Meghan Denny
2025-11-05 18:31:32 -08:00
parent 86a0ff442a
commit 5f87c5c19a
14 changed files with 464 additions and 19 deletions

View File

@@ -2382,6 +2382,17 @@ JSC_DEFINE_HOST_FUNCTION(Bun::jsFunctionMakeErrorWithCode, (JSC::JSGlobalObject
return JSC::JSValue::encode(err);
}
case Bun::ErrorCode::ERR_HTTP_CONTENT_LENGTH_MISMATCH: {
auto arg0 = callFrame->argument(1);
auto str0 = arg0.toWTFString(globalObject);
RETURN_IF_EXCEPTION(scope, {});
auto arg1 = callFrame->argument(2);
auto str1 = arg1.toWTFString(globalObject);
RETURN_IF_EXCEPTION(scope, {});
auto message = makeString("Response body's content-length of "_s, str0, " byte(s) does not match the content-length of "_s, str1, " byte(s) set in header"_s);
return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_HTTP_CONTENT_LENGTH_MISMATCH, message));
}
case ErrorCode::ERR_IPC_DISCONNECTED:
return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_IPC_DISCONNECTED, "IPC channel is already disconnected"_s));
case ErrorCode::ERR_SERVER_NOT_RUNNING:
@@ -2518,6 +2529,8 @@ JSC_DEFINE_HOST_FUNCTION(Bun::jsFunctionMakeErrorWithCode, (JSC::JSGlobalObject
return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_HTTP2_TOO_MANY_INVALID_FRAMES, "Too many invalid HTTP/2 frames"_s));
case ErrorCode::ERR_HTTP2_PING_CANCEL:
return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_HTTP2_PING_CANCEL, "HTTP2 ping cancelled"_s));
case ErrorCode::ERR_HTTP_TRAILER_INVALID:
return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_HTTP_TRAILER_INVALID, "Trailers are invalid with this transfer encoding"_s));
default: {
break;

View File

@@ -209,6 +209,7 @@ const errors: ErrorCodeMapping = [
["ERR_POSTGRES_UNSUPPORTED_INTEGER_SIZE", TypeError, "PostgresError"],
["ERR_POSTGRES_UNSUPPORTED_NUMERIC_FORMAT", TypeError, "PostgresError"],
["ERR_PROXY_INVALID_CONFIG", Error],
["ERR_PROXY_TUNNEL", Error],
["ERR_MYSQL_CONNECTION_CLOSED", Error, "MySQLError"],
["ERR_MYSQL_CONNECTION_TIMEOUT", Error, "MySQLError"],
["ERR_MYSQL_IDLE_TIMEOUT", Error, "MySQLError"],

View File

@@ -511,6 +511,7 @@ declare module "module" {
dts += ` (id: "bun"): typeof import("bun");\n`;
dts += ` (id: "bun:test"): typeof import("bun:test");\n`;
dts += ` (id: "bun:jsc"): typeof import("bun:jsc");\n`;
dts += ` (id: "node:util/types"): typeof import("node:util/types");\n`;
for (let i = 0; i < nativeStartIndex; i++) {
const id = moduleList[i];

View File

@@ -766,6 +766,8 @@ declare function $ERR_ASYNC_CALLBACK(name): TypeError;
declare function $ERR_AMBIGUOUS_ARGUMENT(arg, message): TypeError;
declare function $ERR_INVALID_FD_TYPE(type): TypeError;
declare function $ERR_IP_BLOCKED(ip): Error;
declare function $ERR_HTTP_CONTENT_LENGTH_MISMATCH(bodylen: number, headerlen: number): Error;
declare function $ERR_PROXY_TUNNEL(msg: string): Error;
declare function $ERR_IPC_DISCONNECTED(): Error;
declare function $ERR_SERVER_NOT_RUNNING(): Error;
@@ -837,6 +839,7 @@ declare function $ERR_HTTP2_CONNECT_SCHEME(): Error;
declare function $ERR_HTTP2_CONNECT_PATH(): Error;
declare function $ERR_HTTP2_TOO_MANY_INVALID_FRAMES(): Error;
declare function $ERR_HTTP2_PING_CANCEL(): Error;
declare function $ERR_HTTP_TRAILER_INVALID(): Error;
/**
* Convert a function to a class-like object.

View File

@@ -19,6 +19,11 @@ function urlToHttpOptions(url) {
return options;
}
function isURL(self) {
return Boolean(self?.href && self.protocol && self.auth === undefined && self.path === undefined);
}
export default {
urlToHttpOptions,
isURL,
};

View File

@@ -1,3 +1,5 @@
// Hardcoded module "node:_http_agent"
const EventEmitter = require("node:events");
const { parseProxyConfigFromEnv, kProxyConfig, checkShouldUseProxy, kWaitForProxyTunnel } = require("internal/http");
const { getLazy, kEmptyObject, once } = require("internal/shared");
@@ -146,7 +148,7 @@ Agent.prototype.createConnection = function createConnection(...args) {
const shouldUseProxy = checkShouldUseProxy(this[kProxyConfig], options);
$debug(`http createConnection should use proxy for ${options.host}:${options.port}:`, shouldUseProxy);
if (!shouldUseProxy) {
// @ts-ignore
// @ts-expect-error
return net().createConnection(...args);
}
@@ -155,10 +157,10 @@ Agent.prototype.createConnection = function createConnection(...args) {
};
const proxyProtocol = this[kProxyConfig].protocol;
if (proxyProtocol === "http:") {
// @ts-ignore
// @ts-expect-error
return net().connect(connectOptions, cb);
} else if (proxyProtocol === "https:") {
// @ts-ignore
// @ts-expect-error
return tls().connect(connectOptions, cb);
}
// This should be unreachable because proxy config should be null for other protocols.

View File

@@ -1,3 +1,5 @@
// Hardcoded module "node:_http_client"
const { isIP, isIPv6 } = require("internal/net/isIP");
const { checkIsHttpToken, validateFunction, validateInteger, validateBoolean } = require("internal/validators");
@@ -67,7 +69,7 @@ function emitErrorEventNT(self, err) {
}
}
function ClientRequest(input, options, cb) {
function ClientRequest(input, options, cb): void {
if (!(this instanceof ClientRequest)) {
return new (ClientRequest as any)(input, options, cb);
}
@@ -910,11 +912,11 @@ function ClientRequest(input, options, cb) {
this[kEmitState] = 0;
this.setSocketKeepAlive = (_enable = true, _initialDelay = 0) => {
$debug(`${NODE_HTTP_WARNING}\n`, "WARN: ClientRequest.setSocketKeepAlive is a no-op");
$debug("WARN: ClientRequest.setSocketKeepAlive is a no-op");
};
this.setNoDelay = (_noDelay = true) => {
$debug(`${NODE_HTTP_WARNING}\n`, "WARN: ClientRequest.setNoDelay is a no-op");
$debug("WARN: ClientRequest.setNoDelay is a no-op");
};
this[kClearTimeout] = () => {

View File

@@ -1,3 +1,5 @@
// Hardcoded module "node:_http_incoming"
const Readable = require("internal/streams/readable");
const {

View File

@@ -1,3 +1,5 @@
// Hardcoded module "node:_http_outgoing"
const { Stream } = require("internal/stream");
const { isUint8Array, validateString } = require("internal/validators");
const { deprecate } = require("internal/util/deprecate");

View File

@@ -1,13 +1,18 @@
// Hardcoded module "node:https"
const http = require("node:http");
const { urlToHttpOptions } = require("internal/url");
const { kEmptyObject, once } = require("internal/shared");
const { kProxyConfig, checkShouldUseProxy, kWaitForProxyTunnel } = require("internal/http");
const tls = require("node:tls");
const net = require("node:net");
const ArrayPrototypeShift = Array.prototype.shift;
const ObjectAssign = Object.assign;
const ArrayPrototypeUnshift = Array.prototype.unshift;
function request(...args) {
let options = {};
let options: any = {};
if (typeof args[0] === "string") {
const urlStr = ArrayPrototypeShift.$call(args);
@@ -20,7 +25,7 @@ function request(...args) {
ObjectAssign.$call(null, options, ArrayPrototypeShift.$call(args));
}
options._defaultAgent = https.globalAgent;
options._defaultAgent = globalAgent;
ArrayPrototypeUnshift.$call(args, options);
return new http.ClientRequest(...args);
@@ -32,24 +37,380 @@ function get(input, options, cb) {
return req;
}
function Agent(options) {
// When proxying a HTTPS request, the following needs to be done:
// https://datatracker.ietf.org/doc/html/rfc9110#CONNECT
// 1. Send a CONNECT request to the proxy server.
// 2. Wait for 200 connection established response to establish the tunnel.
// 3. Perform TLS handshake with the endpoint over the socket.
// 4. Tunnel the request using the established connection.
//
// This function computes the tunnel configuration for HTTPS requests.
// The handling of the tunnel connection is done in createConnection.
function getTunnelConfigForProxiedHttps(agent, reqOptions) {
if (!agent[kProxyConfig]) return null;
if ((reqOptions.protocol || agent.protocol) !== "https:") return null;
const shouldUseProxy = checkShouldUseProxy(agent[kProxyConfig], reqOptions);
$debug(`getTunnelConfigForProxiedHttps should use proxy for ${reqOptions.host}:${reqOptions.port}:`, shouldUseProxy);
if (!shouldUseProxy) return null;
const { auth, href } = agent[kProxyConfig];
// The request is a HTTPS request, assemble the payload for establishing the tunnel.
const ipType = net.isIP(reqOptions.host);
// The request target must put IPv6 address in square brackets.
// Here reqOptions is already processed by urlToHttpOptions so we'll add them back if necessary.
// See https://www.rfc-editor.org/rfc/rfc3986#section-3.2.2
const requestHost = ipType === 6 ? `[${reqOptions.host}]` : reqOptions.host;
const requestPort = reqOptions.port || agent.defaultPort;
const endpoint = `${requestHost}:${requestPort}`;
// The ClientRequest constructor should already have validated the host and the port.
// When the request options come from a string invalid characters would be stripped away,
// when it's an object ERR_INVALID_CHAR would be thrown. Here we just assert in case
// agent.createConnection() is called with invalid options.
$assert(!endpoint.includes("\r"));
$assert(!endpoint.includes("\n"));
let payload = `CONNECT ${endpoint} HTTP/1.1\r\n`;
if (auth) payload += `proxy-authorization: ${auth}\r\n`;
if (agent.keepAlive || agent.maxSockets !== Infinity) payload += "proxy-connection: keep-alive\r\n";
payload += `host: ${endpoint}`;
payload += "\r\n\r\n";
const result = {
__proto__: null,
proxyTunnelPayload: payload,
requestOptions: {
__proto__: null,
servername: reqOptions.servername || ipType ? undefined : reqOptions.host,
...reqOptions,
},
};
$debug(`updated request for HTTPS proxy ${href} with`, result);
return result;
}
function establishTunnel(agent, socket, options, tunnelConfig, afterSocket) {
const { proxyTunnelPayload } = tunnelConfig;
// By default, the socket is in paused mode. Read to look for the 200 connection established response.
function read() {
let chunk;
while ((chunk = socket.read()) !== null) {
if (onProxyData(chunk) !== -1) {
break;
}
}
socket.on("readable", read);
}
function cleanup() {
socket.removeListener("end", onProxyEnd);
socket.removeListener("error", onProxyError);
socket.removeListener("readable", read);
socket.setTimeout(0); // Clear the timeout for the tunnel establishment.
}
function onProxyError(err) {
$debug("onProxyError", err);
cleanup();
afterSocket(err, socket);
}
// Read the headers from the chunks and check for the status code. If it fails we
// clean up the socket and return an error. Otherwise we establish the tunnel.
let buffer = "";
function onProxyData(chunk) {
const str = chunk.toString();
$debug("onProxyData", str);
buffer += str;
const headerEndIndex = buffer.indexOf("\r\n\r\n");
if (headerEndIndex === -1) return headerEndIndex;
const statusLine = buffer.substring(0, buffer.indexOf("\r\n"));
const statusCode = statusLine.split(" ")[1];
if (statusCode !== "200") {
$debug(`onProxyData receives ${statusCode}, cleaning up`);
cleanup();
const targetHost = proxyTunnelPayload.split("\r")[0].split(" ")[1];
const message = `Failed to establish tunnel to ${targetHost} via ${agent[kProxyConfig].href}: ${statusLine}`;
const err = $ERR_PROXY_TUNNEL(message);
// @ts-expect-error
err.statusCode = Number.parseInt(statusCode);
afterSocket(err, socket);
} else {
// https://datatracker.ietf.org/doc/html/rfc9110#CONNECT
// RFC 9110 says that it can be 2xx but in the real world, proxy clients generally only accepts 200.
// Proxy servers are not supposed to send anything after the headers - the payload must be
// be empty. So after this point we will proceed with the tunnel e.g. starting TLS handshake.
$debug("onProxyData receives 200, establishing tunnel");
cleanup();
// Reuse the tunneled socket to perform the TLS handshake with the endpoint, then send the request.
const { requestOptions } = tunnelConfig;
tunnelConfig.requestOptions = null;
requestOptions.socket = socket;
let tunneldSocket;
const onTLSHandshakeError = err => {
$debug("Propagate error event from tunneled socket to tunnel socket");
afterSocket(err, tunneldSocket);
};
tunneldSocket = tls.connect(requestOptions, () => {
$debug("TLS handshake over tunnel succeeded");
tunneldSocket.removeListener("error", onTLSHandshakeError);
afterSocket(null, tunneldSocket);
});
tunneldSocket.on("free", () => {
$debug("Propagate free event from tunneled socket to tunnel socket");
socket.emit("free");
});
tunneldSocket.on("error", onTLSHandshakeError);
}
return headerEndIndex;
}
function onProxyEnd() {
cleanup();
const err = $ERR_PROXY_TUNNEL("Connection to establish proxy tunnel ended unexpectedly");
afterSocket(err, socket);
}
const proxyTunnelTimeout = tunnelConfig.requestOptions.timeout;
$debug("proxyTunnelTimeout", proxyTunnelTimeout, options.timeout);
// It may be worth a separate timeout error/event.
// But it also makes sense to treat the tunnel establishment timeout as a normal timeout for the request.
function onProxyTimeout() {
$debug("onProxyTimeout", proxyTunnelTimeout);
cleanup();
const err = $ERR_PROXY_TUNNEL(`Connection to establish proxy tunnel timed out after ${proxyTunnelTimeout}ms`);
// @ts-expect-error
err.proxyTunnelTimeout = proxyTunnelTimeout;
afterSocket(err, socket);
}
if (proxyTunnelTimeout && proxyTunnelTimeout > 0) {
$debug("proxy tunnel setTimeout", proxyTunnelTimeout);
socket.setTimeout(proxyTunnelTimeout, onProxyTimeout);
}
socket.on("error", onProxyError);
socket.on("end", onProxyEnd);
socket.write(proxyTunnelPayload);
read();
}
// HTTPS agents.
// See ProxyConfig in src/js/internal/http.ts for how the connection should be handled when the agent is configured to use a proxy server.
function createConnection(...args) {
// XXX: This signature (port, host, options) is different from all the other createConnection() methods.
let options, cb;
if (args[0] !== null && typeof args[0] === "object") {
options = args[0];
} else if (args[1] !== null && typeof args[1] === "object") {
options = { ...args[1] };
} else if (args[2] === null || typeof args[2] !== "object") {
options = {};
} else {
options = { ...args[2] };
}
if (typeof args[0] === "number") {
options.port = args[0];
}
if (typeof args[1] === "string") {
options.host = args[1];
}
if (typeof args[args.length - 1] === "function") {
cb = args[args.length - 1];
}
$debug("createConnection", options);
if (options._agentKey) {
const session = this._getSession(options._agentKey);
if (session) {
$debug("reuse session for %j", options._agentKey);
options = {
session,
...options,
};
}
}
let socket;
const tunnelConfig = getTunnelConfigForProxiedHttps(this, options);
$debug(`https createConnection should use proxy for ${options.host}:${options.port}:`, tunnelConfig);
if (!tunnelConfig) {
socket = tls.connect(options);
} else {
const connectOptions = {
...this[kProxyConfig].proxyConnectionOptions,
};
$debug("Create proxy socket", connectOptions);
const onError = err => {
cleanupAndPropagate(err, socket);
};
const proxyTunnelTimeout = tunnelConfig.requestOptions.timeout;
const onTimeout = () => {
const err = $ERR_PROXY_TUNNEL(`Connection to establish proxy tunnel timed out after ${proxyTunnelTimeout}ms`);
// @ts-expect-error
err.proxyTunnelTimeout = proxyTunnelTimeout;
cleanupAndPropagate(err, socket);
};
const cleanupAndPropagate = once((err, currentSocket) => {
$debug("cleanupAndPropagate", err);
socket.removeListener("error", onError);
socket.removeListener("timeout", onTimeout);
// An error occurred during tunnel establishment, in that case just destroy the socket and propagate the error to the callback.
// When the error comes from unexpected status code, the stream is still in good shape,
// in that case let req.onSocket handle the destruction instead.
if (err && err.code === "ERR_PROXY_TUNNEL" && !err.statusCode) {
socket.destroy();
}
// This error should go to:
// -> oncreate in Agent.prototype.createSocket
// -> closure in Agent.prototype.addRequest or Agent.prototype.removeSocket
if (cb) {
cb(err, currentSocket);
}
});
const onProxyConnection = () => {
socket.removeListener("error", onError);
establishTunnel(this, socket, options, tunnelConfig, cleanupAndPropagate);
};
if (this[kProxyConfig].protocol === "http:") {
socket = net.connect(connectOptions, onProxyConnection);
} else {
socket = tls.connect(connectOptions, onProxyConnection);
}
socket.on("error", onError);
if (proxyTunnelTimeout) {
socket.setTimeout(proxyTunnelTimeout, onTimeout);
}
socket[kWaitForProxyTunnel] = true;
}
if (options._agentKey) {
socket.on("session", session => {
this._cacheSession(options._agentKey, session);
});
socket.once("close", err => {
if (err) this._evictSession(options._agentKey);
});
}
return socket;
}
type Agent = import("node:https").Agent;
function Agent(options): void {
if (!(this instanceof Agent)) return new Agent(options);
http.Agent.$apply(this, [options]);
this.defaultPort = 443;
this.protocol = "https:";
options = { __proto__: null, ...options };
options.defaultPort ??= 443;
options.protocol ??= "https:";
http.Agent.$call(this, options);
this.maxCachedSessions = this.options.maxCachedSessions;
if (this.maxCachedSessions === undefined) this.maxCachedSessions = 100;
this._sessionCache = {
map: {},
list: [],
};
}
$toClass(Agent, "Agent", http.Agent);
Agent.prototype.createConnection = http.createConnection;
var https = {
Agent.prototype.createConnection = createConnection;
Agent.prototype.getName = function getName(options = kEmptyObject) {
let name = http.Agent.prototype.getName.$call(this, options);
name += ":";
if (options.ca) name += options.ca;
name += ":";
if (options.cert) name += options.cert;
name += ":";
if (options.clientCertEngine) name += options.clientCertEngine;
name += ":";
if (options.ciphers) name += options.ciphers;
name += ":";
if (options.key) name += options.key;
name += ":";
if (options.pfx) name += options.pfx;
name += ":";
if (options.rejectUnauthorized !== undefined) name += options.rejectUnauthorized;
name += ":";
if (options.servername && options.servername !== options.host) name += options.servername;
name += ":";
if (options.minVersion) name += options.minVersion;
name += ":";
if (options.maxVersion) name += options.maxVersion;
name += ":";
if (options.secureProtocol) name += options.secureProtocol;
name += ":";
if (options.crl) name += options.crl;
name += ":";
if (options.honorCipherOrder !== undefined) name += options.honorCipherOrder;
name += ":";
if (options.ecdhCurve) name += options.ecdhCurve;
name += ":";
if (options.dhparam) name += options.dhparam;
name += ":";
if (options.secureOptions !== undefined) name += options.secureOptions;
name += ":";
if (options.sessionIdContext) name += options.sessionIdContext;
name += ":";
if (options.sigalgs) name += JSON.stringify(options.sigalgs);
name += ":";
if (options.privateKeyIdentifier) name += options.privateKeyIdentifier;
name += ":";
if (options.privateKeyEngine) name += options.privateKeyEngine;
return name;
};
Agent.prototype._getSession = function _getSession(key) {
return this._sessionCache.map[key];
};
Agent.prototype._cacheSession = function _cacheSession(key, session) {
// Cache is disabled
if (this.maxCachedSessions === 0) {
return;
}
// Fast case - update existing entry
if (this._sessionCache.map[key]) {
this._sessionCache.map[key] = session;
return;
}
// Put new entry
if (this._sessionCache.list.length >= this.maxCachedSessions) {
const oldKey = this._sessionCache.list.shift();
$debug("evicting %j", oldKey);
delete this._sessionCache.map[oldKey];
}
this._sessionCache.list.push(key);
this._sessionCache.map[key] = session;
};
Agent.prototype._evictSession = function _evictSession(key) {
const index = this._sessionCache.list.indexOf(key);
if (index === -1) return;
this._sessionCache.list.splice(index, 1);
delete this._sessionCache.map[key];
};
const globalAgent = new Agent({
keepAlive: true,
scheduling: "lifo",
timeout: 5000,
// This normalized from both --use-env-proxy and NODE_USE_ENV_PROXY settings.
// proxyEnv: getOptionValue("--use-env-proxy") ? filterEnvForProxies(process.env) : undefined,
proxyEnv: undefined, // TODO:
});
export default {
Agent,
globalAgent: new Agent({ keepAlive: true, scheduling: "lifo", timeout: 5000 }),
globalAgent,
Server: http.Server,
createServer: http.createServer,
get,
request,
};
export default https;

View File

@@ -0,0 +1,54 @@
'use strict';
const common = require('../common');
if (!common.hasCrypto)
common.skip('missing crypto');
const assert = require('assert');
const https = require('https');
const agent = new https.Agent();
// empty argument
assert.strictEqual(
agent.getName(),
'localhost::::::::::::::::::::::'
);
// empty options
assert.strictEqual(
agent.getName({}),
'localhost::::::::::::::::::::::'
);
// Pass all options arguments
const options = {
host: '0.0.0.0',
port: 443,
localAddress: '192.168.1.1',
ca: 'ca',
cert: 'cert',
clientCertEngine: 'dynamic',
ciphers: 'ciphers',
crl: [Buffer.from('c'), Buffer.from('r'), Buffer.from('l')],
dhparam: 'dhparam',
ecdhCurve: 'ecdhCurve',
honorCipherOrder: false,
key: 'key',
pfx: 'pfx',
rejectUnauthorized: false,
secureOptions: 0,
secureProtocol: 'secureProtocol',
servername: 'localhost',
sessionIdContext: 'sessionIdContext',
sigalgs: 'sigalgs',
privateKeyIdentifier: 'privateKeyIdentifier',
privateKeyEngine: 'privateKeyEngine',
};
assert.strictEqual(
agent.getName(options),
'0.0.0.0:443:192.168.1.1:ca:cert:dynamic:ciphers:key:pfx:false:localhost:' +
'::secureProtocol:c,r,l:false:ecdhCurve:dhparam:0:sessionIdContext:' +
'"sigalgs":privateKeyIdentifier:privateKeyEngine'
);

View File

@@ -67,7 +67,7 @@ server.listen(0, function() {
'-cert', fixtures.path('keys/rsa_cert_foafssl_b.crt'),
'-key', fixtures.path('keys/rsa_private_b.pem')];
const client = spawn(common.opensslCli, args);
const client = spawn(opensslCli, args);
client.stdout.on('data', function(data) {
console.log('response received');

View File

@@ -151,4 +151,4 @@ const caArrDataView = toDataView(caCert);
' of Buffer, TypedArray, DataView, or BunFile.' +
common.invalidArgTypeHelper(val)
});
});
});

View File

@@ -1,6 +1,5 @@
'use strict';
const common = require('../common');
if (common.isWindows) return; // TODO: BUN
if (!common.hasCrypto)
common.skip('missing crypto');