diff --git a/packages/bruno-requests/src/network/agent-defaults.ts b/packages/bruno-requests/src/network/agent-defaults.ts new file mode 100644 index 000000000..5725349c2 --- /dev/null +++ b/packages/bruno-requests/src/network/agent-defaults.ts @@ -0,0 +1,24 @@ +import http from 'node:http'; +import { fastLookup } from './fast-lookup'; + +/** + * Shared agent configuration for HTTP/HTTPS agents across the application. + * + * - keepAlive: Reuse TCP connections to avoid repeated handshakes. + * - maxSockets: 100 concurrent sockets per host — high enough for parallel + * collection runs, low enough to avoid file-descriptor exhaustion. + * - maxFreeSockets: 10 idle sockets kept alive for reuse between bursts. + * - scheduling: 'fifo' distributes requests across connections evenly, + * which avoids head-of-line blocking that 'lifo' (Node's default) can + * cause when one connection stalls. + * - lookup: fastLookup uses async c-ares (dns.resolve4/6) to bypass the + * libuv thread pool bottleneck, falling back to dns.lookup for /etc/hosts + * and mDNS hostnames. + */ +export const defaultAgentOptions: http.AgentOptions = { + keepAlive: true, + maxSockets: 100, + maxFreeSockets: 10, + scheduling: 'fifo', + lookup: fastLookup as http.AgentOptions['lookup'] +}; diff --git a/packages/bruno-requests/src/network/axios-instance.ts b/packages/bruno-requests/src/network/axios-instance.ts index ba778d9e8..abb4339e3 100644 --- a/packages/bruno-requests/src/network/axios-instance.ts +++ b/packages/bruno-requests/src/network/axios-instance.ts @@ -1,6 +1,7 @@ import { default as axios, AxiosRequestConfig, AxiosRequestHeaders, AxiosResponse, InternalAxiosRequestConfig } from 'axios'; import http from 'node:http'; import https from 'node:https'; +import { defaultAgentOptions } from './agent-defaults'; /** * @@ -29,8 +30,8 @@ type ModifiedAxiosResponse = AxiosResponse & { const baseRequestConfig: Partial = { proxy: false, - httpAgent: new http.Agent({ keepAlive: true }), - httpsAgent: new https.Agent({ keepAlive: true }), + httpAgent: new http.Agent(defaultAgentOptions), + httpsAgent: new https.Agent(defaultAgentOptions), transformRequest: function transformRequest(data: any, headers: AxiosRequestHeaders) { const contentType = headers.getContentType() || ''; const hasJSONContentType = contentType.includes('json'); diff --git a/packages/bruno-requests/src/network/fast-lookup.spec.ts b/packages/bruno-requests/src/network/fast-lookup.spec.ts new file mode 100644 index 000000000..89205f3e2 --- /dev/null +++ b/packages/bruno-requests/src/network/fast-lookup.spec.ts @@ -0,0 +1,60 @@ +import dns from 'node:dns'; +import { fastLookup } from './fast-lookup'; + +type DnsMethod = 'resolve4' | 'resolve6'; + +function mockResolve(method: DnsMethod, result: string[], err: Error | null = null): void { + (jest.spyOn(dns, method) as any).mockImplementation((_hostname: string, cb: Function) => { + cb(err, result); + }); +} + +function mockLookup(address: string, family: number): void { + (jest.spyOn(dns, 'lookup') as any).mockImplementation((_hostname: string, _options: dns.LookupOptions, cb: Function) => { + cb(null, address, family); + }); +} + +describe('fastLookup', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should resolve a public hostname via dns.resolve4', (done) => { + mockResolve('resolve4', ['93.184.216.34']); + + fastLookup('example.com', {}, (err, address, family) => { + expect(err).toBeNull(); + expect(address).toBe('93.184.216.34'); + expect(family).toBe(4); + done(); + }); + }); + + it('should fall back to dns.lookup when both resolvers fail', (done) => { + mockResolve('resolve4', [], new Error('ENOTFOUND')); + mockResolve('resolve6', [], new Error('ENOTFOUND')); + mockLookup('127.0.0.1', 4); + + fastLookup('my-local-host', {}, (err, address, family) => { + expect(err).toBeNull(); + expect(address).toBe('127.0.0.1'); + expect(family).toBe(4); + done(); + }); + }); + + it('should return all addresses when options.all is true', (done) => { + mockResolve('resolve4', ['1.2.3.4', '5.6.7.8']); + + fastLookup('example.com', { all: true }, (err, addresses) => { + expect(err).toBeNull(); + expect(Array.isArray(addresses)).toBe(true); + expect(addresses).toEqual([ + { address: '1.2.3.4', family: 4 }, + { address: '5.6.7.8', family: 4 } + ]); + done(); + }); + }); +}); diff --git a/packages/bruno-requests/src/network/fast-lookup.ts b/packages/bruno-requests/src/network/fast-lookup.ts new file mode 100644 index 000000000..6f103bff8 --- /dev/null +++ b/packages/bruno-requests/src/network/fast-lookup.ts @@ -0,0 +1,32 @@ +import dns from 'node:dns'; + +/** + * Fast DNS lookup that bypasses the libuv thread pool. + * + * Tries dns.resolve4 then dns.resolve6 (async, c-ares based), + * falls back to dns.lookup for /etc/hosts and mDNS hostnames. + * + * NOTE: `options.family` is not currently respected — the function always + * tries IPv4 first regardless of the caller's preference. This is safe today + * because Bruno's HTTP agents use the default family (0), but should be + * addressed if any code path starts specifying a family. + */ +export function fastLookup( + hostname: string, + options: dns.LookupOptions | undefined, + callback: (err: Error | null, address: string | dns.LookupAddress[], family?: number) => void +): void { + dns.resolve4(hostname, (err4, addresses4) => { + if (!err4 && addresses4?.length) { + return options?.all + ? callback(null, addresses4.map((a) => ({ address: a, family: 4 }))) + : callback(null, addresses4[0], 4); + } + + // Forward to standard dns.lookup for /etc/hosts, mDNS, and other + // non-public hostnames that c-ares cannot resolve. + dns.lookup(hostname, options ?? {}, (err, address, family) => { + callback(err, address, family); + }); + }); +} diff --git a/packages/bruno-requests/src/utils/agent-cache.ts b/packages/bruno-requests/src/utils/agent-cache.ts index c7dc40105..b05445305 100644 --- a/packages/bruno-requests/src/utils/agent-cache.ts +++ b/packages/bruno-requests/src/utils/agent-cache.ts @@ -3,6 +3,7 @@ import tls from 'node:tls'; import type { Agent as HttpAgent } from 'node:http'; import type { Agent as HttpsAgent } from 'node:https'; import { createTimelineAgentClass, createTimelineHttpAgentClass, type TimelineEntry, type AgentOptions, type HttpAgentOptions, type AgentClass, type HttpAgentClass } from './timeline-agent'; +import { defaultAgentOptions } from '../network/agent-defaults'; /** * Agent cache for SSL session reuse. @@ -267,8 +268,16 @@ function getOrCreateAgentInternal( } const AgentClass = timeline ? getTimelineClass(BaseAgentClass) : BaseAgentClass; + + // Inject shared agent defaults (DNS lookup, socket pool settings), then + // layer on the caller's options so per-agent overrides still take effect. + const optimizedOptions = { + ...defaultAgentOptions, + ...options + }; + // Convert raw `ca` to a secureContext that adds CAs on top of OpenSSL defaults - const resolvedOptions = applySecureContext(options); + const resolvedOptions = applySecureContext(optimizedOptions); let agent: HttpAgent | HttpsAgent; if (timeline) {