@@ -356,6 +356,19 @@ export interface SecureFetchOptions {
356356 proxyUrl ?: string
357357 /** Hide credential-derived URL details from validation logs. */
358358 logUrlValidationDetails ?: boolean
359+ /**
360+ * Ask for a gzip or brotli body. The body is decoded before it is returned, and
361+ * `maxResponseBytes` bounds the decoded bytes, so a compression bomb still stops at the cap.
362+ */
363+ acceptCompressed ?: boolean
364+ /**
365+ * Reuses keep-alive connections to the same pinned address across requests. A connection is
366+ * only ever reused for the IP it was opened to, so every request keeps its DNS pinning. The
367+ * owner must call {@link PinnedConnectionPool.destroy} once its requests have finished. Bun
368+ * keeps sockets in its own per-address pool, so there reuse spans pools and `destroy` releases
369+ * only the agents.
370+ */
371+ connectionPool ?: PinnedConnectionPool
359372 /**
360373 * Where this request's URL came from. Carried on the options so the same
361374 * policy is re-applied to every redirect hop rather than re-derived — a hop
@@ -447,6 +460,43 @@ function resolveRedirectUrl(baseUrl: string, location: string): string {
447460 }
448461}
449462
463+ /** Keep-alive agents keyed by protocol, host, port, and the pinned address they connect to. */
464+ export interface PinnedConnectionPool {
465+ /** Undefined once destroyed, so a late request falls back to a single-use pinned agent. */
466+ agent ( isHttps : boolean , host : string , port : number , resolvedIP : string ) : http . Agent | undefined
467+ destroy ( ) : void
468+ }
469+
470+ /**
471+ * Creates a request-scoped pool of pinned keep-alive agents. Reusing a connection skips the TCP
472+ * and TLS handshakes that otherwise dominate short provider API calls.
473+ */
474+ export function createPinnedConnectionPool ( ) : PinnedConnectionPool {
475+ const agents = new Map < string , http . Agent > ( )
476+ let destroyed = false
477+ return {
478+ agent ( isHttps , host , port , resolvedIP ) {
479+ if ( destroyed ) return undefined
480+ const key = JSON . stringify ( [ isHttps , host , port , resolvedIP ] )
481+ let agent = agents . get ( key )
482+ if ( ! agent ) {
483+ const options : http . AgentOptions = {
484+ keepAlive : true ,
485+ lookup : createPinnedLookup ( resolvedIP ) ,
486+ }
487+ agent = isHttps ? new https . Agent ( options ) : new http . Agent ( options )
488+ agents . set ( key , agent )
489+ }
490+ return agent
491+ } ,
492+ destroy ( ) {
493+ destroyed = true
494+ for ( const agent of agents . values ( ) ) agent . destroy ( )
495+ agents . clear ( )
496+ } ,
497+ }
498+ }
499+
450500/**
451501 * Creates a DNS lookup function that always returns a pre-resolved IP address.
452502 * Use this to prevent DNS rebinding (TOCTOU) attacks when connecting to
@@ -1088,6 +1138,11 @@ export async function secureFetchWithPinnedIP(
10881138 const port = parsed . port ? Number . parseInt ( parsed . port , 10 ) : defaultPort
10891139
10901140 let agent : http . Agent | undefined
1141+ /**
1142+ * Bun ignores a `lookup` set on an Agent and honors one on the request, while Node honors
1143+ * both. A pinned direct connection sets it in both places so pinning holds in either runtime.
1144+ */
1145+ let pinnedLookup : LookupFunction | undefined
10911146 if ( outboundDispatcher ) {
10921147 agent = undefined
10931148 } else if ( options . proxyUrl ) {
@@ -1096,12 +1151,17 @@ export async function secureFetchWithPinnedIP(
10961151 // targets tunnel via CONNECT, http targets use absolute-URI forwarding.
10971152 agent = isHttps ? new HttpsProxyAgent ( options . proxyUrl ) : new HttpProxyAgent ( options . proxyUrl )
10981153 } else {
1099- const lookup = createPinnedLookup ( resolvedIP )
1100- const agentOptions : http . AgentOptions = { lookup }
1101- agent = isHttps ? new https . Agent ( agentOptions ) : new http . Agent ( agentOptions )
1154+ pinnedLookup = createPinnedLookup ( resolvedIP )
1155+ agent =
1156+ options . connectionPool ?. agent ( isHttps , parsed . hostname , port , resolvedIP ) ??
1157+ ( isHttps
1158+ ? new https . Agent ( { lookup : pinnedLookup } )
1159+ : new http . Agent ( { lookup : pinnedLookup } ) )
11021160 }
11031161
11041162 const { 'accept-encoding' : _ , ...sanitizedHeaders } = options . headers ?? { }
1163+ /** Raw deflate streams are ambiguous to decode, so only gzip and brotli are requested. */
1164+ if ( options . acceptCompressed ) sanitizedHeaders [ 'accept-encoding' ] = 'gzip, br'
11051165 if ( ! Object . keys ( sanitizedHeaders ) . some ( ( name ) => name . toLowerCase ( ) === 'user-agent' ) ) {
11061166 sanitizedHeaders [ 'user-agent' ] = DEFAULT_USER_AGENT
11071167 }
@@ -1123,6 +1183,7 @@ export async function secureFetchWithPinnedIP(
11231183 method : options . method || 'GET' ,
11241184 headers : sanitizedHeaders ,
11251185 agent,
1186+ ...( pinnedLookup ? { lookup : pinnedLookup } : { } ) ,
11261187 timeout : options . timeout || 300000 ,
11271188 }
11281189
@@ -1265,7 +1326,13 @@ export async function secureFetchWithPinnedIP(
12651326 statusCode === 204 ||
12661327 statusCode === 205 ||
12671328 statusCode === 304
1268- const contentLength = headersRecord [ 'content-length' ]
1329+ /**
1330+ * An encoded body's Content-Length is its wire size, not the decoded size the cap bounds,
1331+ * so only an identity body is rejected up front; the decoded stream is capped as it reads.
1332+ */
1333+ const contentLength = headersRecord [ 'content-encoding' ]
1334+ ? undefined
1335+ : headersRecord [ 'content-length' ]
12691336 if ( contentLength && ! isBodylessResponse ) {
12701337 const parsedLength = Number . parseInt ( contentLength , 10 )
12711338 if ( Number . isFinite ( parsedLength ) && parsedLength > maxResponseBytes ) {
0 commit comments