diff --git a/packages/bruno-app/src/components/Preferences/Cache/StyledWrapper.js b/packages/bruno-app/src/components/Preferences/Cache/StyledWrapper.js
new file mode 100644
index 000000000..f19772f57
--- /dev/null
+++ b/packages/bruno-app/src/components/Preferences/Cache/StyledWrapper.js
@@ -0,0 +1,13 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ color: ${(props) => props.theme.text};
+
+ form.bruno-form {
+ label {
+ font-size: 0.8125rem;
+ }
+ }
+`;
+
+export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/Preferences/Cache/index.js b/packages/bruno-app/src/components/Preferences/Cache/index.js
new file mode 100644
index 000000000..28ef91483
--- /dev/null
+++ b/packages/bruno-app/src/components/Preferences/Cache/index.js
@@ -0,0 +1,122 @@
+import React, { useEffect, useCallback, useRef } from 'react';
+import { useFormik } from 'formik';
+import { useSelector, useDispatch } from 'react-redux';
+import {
+ savePreferences,
+ clearHttpHttpsAgentCache
+} from 'providers/ReduxStore/slices/app';
+import toast from 'react-hot-toast';
+import StyledWrapper from './StyledWrapper';
+import * as Yup from 'yup';
+import debounce from 'lodash/debounce';
+import get from 'lodash/get';
+
+const cacheSchema = Yup.object().shape({
+ sslSession: Yup.object({
+ enabled: Yup.boolean()
+ })
+});
+
+const Cache = () => {
+ const preferences = useSelector((state) => state.app.preferences);
+ const dispatch = useDispatch();
+
+ const handleSave = useCallback(
+ (newCachePreferences) => {
+ dispatch(
+ savePreferences({
+ ...preferences,
+ cache: newCachePreferences
+ })
+ ).catch(() => toast.error('Failed to update cache preferences'));
+ },
+ [dispatch, preferences]
+ );
+
+ const handleSaveRef = useRef(handleSave);
+ handleSaveRef.current = handleSave;
+
+ const formik = useFormik({
+ initialValues: {
+ sslSession: {
+ enabled: get(preferences, 'cache.sslSession.enabled', false)
+ }
+ },
+ validationSchema: cacheSchema,
+ onSubmit: async (values) => {
+ try {
+ const newPreferences = await cacheSchema.validate(values, { abortEarly: true });
+ handleSave(newPreferences);
+ } catch (error) {
+ console.error('Cache preferences validation error:', error.message);
+ }
+ }
+ });
+
+ const debouncedSave = useCallback(
+ debounce((values) => {
+ cacheSchema
+ .validate(values, { abortEarly: true })
+ .then((validatedValues) => handleSaveRef.current(validatedValues))
+ .catch(() => {});
+ }, 500),
+ []
+ );
+
+ useEffect(() => {
+ if (formik.dirty && formik.isValid) {
+ debouncedSave(formik.values);
+ }
+ return () => {
+ debouncedSave.cancel();
+ };
+ }, [formik.values, formik.dirty, formik.isValid, debouncedSave]);
+
+ const handleAgentCachingChange = (e) => {
+ formik.handleChange(e);
+ // Immediately evict all cached agents when caching is disabled
+ if (!e.target.checked) {
+ dispatch(clearHttpHttpsAgentCache()).catch(() => {});
+ }
+ };
+
+ const handleResetCache = () => {
+ dispatch(clearHttpHttpsAgentCache())
+ .then(() => toast.success('ssl session cache cleared'))
+ .catch(() => toast.error('Failed to clear ssl session cache'));
+ };
+
+ return (
+
+
+
+ );
+};
+
+export default Cache;
diff --git a/packages/bruno-app/src/components/Preferences/index.js b/packages/bruno-app/src/components/Preferences/index.js
index 547ffd09a..88a17be43 100644
--- a/packages/bruno-app/src/components/Preferences/index.js
+++ b/packages/bruno-app/src/components/Preferences/index.js
@@ -9,7 +9,8 @@ import {
IconUserCircle,
IconKeyboard,
IconZoomQuestion,
- IconSquareLetterB
+ IconSquareLetterB,
+ IconDatabase
} from '@tabler/icons';
import Support from './Support';
@@ -21,6 +22,7 @@ import Keybindings from './Keybindings';
import Beta from './Beta';
import StyledWrapper from './StyledWrapper';
+import Cache from './Cache/index';
const Preferences = () => {
const dispatch = useDispatch();
@@ -65,6 +67,10 @@ const Preferences = () => {
case 'support': {
return ;
}
+
+ case 'cache': {
+ return ;
+ }
}
};
@@ -92,6 +98,10 @@ const Preferences = () => {
Keybindings
+
setTab('support')}>
Support
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/app.js b/packages/bruno-app/src/providers/ReduxStore/slices/app.js
index 9d91b166f..3d3c552f2 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/app.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/app.js
@@ -43,6 +43,11 @@ const initialState = {
autoSave: {
enabled: false,
interval: 1000
+ },
+ cache: {
+ sslSession: {
+ enabled: false
+ }
}
},
generateCode: {
@@ -301,4 +306,11 @@ export const refreshSystemProxy = () => (dispatch, getState) => {
});
};
+export const clearHttpHttpsAgentCache = () => () => {
+ return new Promise((resolve, reject) => {
+ const { ipcRenderer } = window;
+ ipcRenderer.invoke('renderer:clear-http-https-agent-cache').then(resolve).catch(reject);
+ });
+};
+
export default appSlice.reducer;
diff --git a/packages/bruno-cli/src/commands/run.js b/packages/bruno-cli/src/commands/run.js
index bf5cc766d..2c20f7921 100644
--- a/packages/bruno-cli/src/commands/run.js
+++ b/packages/bruno-cli/src/commands/run.js
@@ -225,6 +225,11 @@ const builder = async (yargs) => {
description: 'Disable all proxy settings (both collection-defined and system proxies)',
default: false
})
+ .option('cache-ssl-session', {
+ type: 'boolean',
+ description: 'Enable SSL session caching — reuses TLS sessions across requests for faster handshakes',
+ default: false
+ })
.option('delay', {
type: 'number',
description: 'Delay between each requests (in miliseconds)'
@@ -330,6 +335,7 @@ const handler = async function (argv) {
reporterSkipBody,
clientCertConfig,
noproxy,
+ cacheSslSession,
delay,
tags: includeTags,
excludeTags,
@@ -531,6 +537,9 @@ const handler = async function (argv) {
if (noproxy) {
options['noproxy'] = true;
}
+ if (cacheSslSession) {
+ options['cacheSslSession'] = true;
+ }
if (verbose) {
options['verbose'] = true;
}
diff --git a/packages/bruno-cli/src/runner/run-single-request.js b/packages/bruno-cli/src/runner/run-single-request.js
index f0bd4f182..259a6f0fb 100644
--- a/packages/bruno-cli/src/runner/run-single-request.js
+++ b/packages/bruno-cli/src/runner/run-single-request.js
@@ -9,7 +9,8 @@ const { interpolateString, interpolateObject } = require('./interpolate-string')
const { ScriptRuntime, TestRuntime, VarsRuntime, AssertRuntime, formatErrorWithContext, SCRIPT_TYPES } = require('@usebruno/js');
const { stripExtension } = require('../utils/filesystem');
const { getOptions } = require('../utils/bru');
-const https = require('https');
+const https = require('node:https');
+const http = require('node:http');
const { HttpProxyAgent } = require('http-proxy-agent');
const { SocksProxyAgent } = require('socks-proxy-agent');
const { makeAxiosInstance } = require('../utils/axios-instance');
@@ -22,7 +23,7 @@ const { createFormData } = require('../utils/form-data');
const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/;
const { NtlmClient } = require('axios-ntlm');
const { addDigestInterceptor, getHttpHttpsAgents, makeAxiosInstance: makeAxiosInstanceForOauth2 } = require('@usebruno/requests');
-const { getCACertificates, transformProxyConfig } = require('@usebruno/requests');
+const { getCACertificates, transformProxyConfig, getOrCreateHttpsAgent, getOrCreateHttpAgent } = require('@usebruno/requests');
const { getOAuth2Token, getFormattedOauth2Credentials } = require('../utils/oauth2');
const tokenStore = require('../store/tokenStore');
const { encodeUrl, buildFormUrlEncodedPayload, extractPromptVariables, isFormData } = require('@usebruno/common').utils;
@@ -203,7 +204,8 @@ const runSingleRequest = async function (
shouldVerifyTls: !get(options, 'insecure', false),
shouldUseCustomCaCertificate: !!options['cacert'],
customCaCertificateFilePath: options['cacert'],
- shouldKeepDefaultCaCertificates: !options['ignoreTruststore']
+ shouldKeepDefaultCaCertificates: !options['ignoreTruststore'],
+ cacheSslSession: get(options, 'cacheSslSession', false)
},
clientCertificates: rawClientCertificates ? interpolateObject(rawClientCertificates, sendRequestInterpolationOptions) : undefined,
collectionLevelProxy: transformProxyConfig(interpolateObject(rawProxyConfig, sendRequestInterpolationOptions)),
@@ -347,6 +349,7 @@ const runSingleRequest = async function (
const insecure = get(options, 'insecure', false);
const noproxy = get(options, 'noproxy', false);
const cachedSystemProxy = get(options, 'cachedSystemProxy', null);
+ const disableCache = !get(options, 'cacheSslSession', false);
const httpsAgentRequestFields = {};
if (insecure) {
@@ -426,6 +429,18 @@ const runSingleRequest = async function (
}
// else: collection proxy is disabled, proxyMode stays 'off'
+ // Prepare TLS options for agent caching
+ const tlsOptions = {
+ ...httpsAgentRequestFields
+ };
+
+ // HTTP agent options — separate from tlsOptions to avoid leaking TLS fields
+ const httpAgentOptions = { keepAlive: true };
+
+ const parsedRequestUrl = new URL(request.url);
+ const isHttpsRequest = parsedRequestUrl.protocol === 'https:';
+ const hostname = parsedRequestUrl.hostname || null;
+
if (proxyMode === 'on') {
const shouldProxy = shouldUseProxy(request.url, get(proxyConfig, 'bypassProxy', ''));
if (shouldProxy) {
@@ -444,35 +459,37 @@ const runSingleRequest = async function (
} else {
proxyUri = `${proxyProtocol}://${proxyHostname}${uriPort}`;
}
+ // When the proxy itself uses HTTPS, the agent connecting to it needs TLS options
+ // (e.g., ca certs) even for plain HTTP requests
+ const isHttpsProxy = proxyProtocol === 'https';
+ const httpProxyAgentOptions = isHttpsProxy ? { ...httpAgentOptions, ...tlsOptions } : httpAgentOptions;
+
+ // Only set the agent needed for the request protocol
if (socksEnabled) {
- request.httpsAgent = new SocksProxyAgent(
- proxyUri,
- Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined
- );
- request.httpAgent = new SocksProxyAgent(proxyUri);
+ if (isHttpsRequest) {
+ request.httpsAgent = getOrCreateHttpsAgent({ AgentClass: SocksProxyAgent, options: tlsOptions, proxyUri, disableCache, hostname });
+ } else {
+ request.httpAgent = getOrCreateHttpAgent({ AgentClass: SocksProxyAgent, options: httpProxyAgentOptions, proxyUri, disableCache, hostname });
+ }
} else {
- request.httpsAgent = new PatchedHttpsProxyAgent(
- proxyUri,
- Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined
- );
- request.httpAgent = new HttpProxyAgent(proxyUri);
+ if (isHttpsRequest) {
+ request.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri, disableCache, hostname });
+ } else {
+ request.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: httpProxyAgentOptions, proxyUri, disableCache, hostname });
+ }
}
- } else {
- request.httpsAgent = new https.Agent({
- ...httpsAgentRequestFields
- });
}
} else if (proxyMode === 'system') {
try {
const { http_proxy, https_proxy, no_proxy } = cachedSystemProxy || {};
const shouldUseSystemProxy = shouldUseProxy(request.url, no_proxy || '');
- const parsedUrl = new URL(request.url);
- const isHttpsRequest = parsedUrl.protocol === 'https:';
if (shouldUseSystemProxy) {
try {
if (http_proxy?.length && !isHttpsRequest) {
- new URL(http_proxy);
- request.httpAgent = new HttpProxyAgent(http_proxy);
+ const parsedHttpProxy = new URL(http_proxy);
+ const isHttpsSystemProxy = parsedHttpProxy.protocol === 'https:';
+ const systemHttpProxyAgentOptions = isHttpsSystemProxy ? { ...httpAgentOptions, ...tlsOptions } : httpAgentOptions;
+ request.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: systemHttpProxyAgentOptions, proxyUri: http_proxy, disableCache, hostname });
}
} catch (error) {
throw new Error('Invalid system http_proxy');
@@ -480,30 +497,21 @@ const runSingleRequest = async function (
try {
if (https_proxy?.length && isHttpsRequest) {
new URL(https_proxy);
- request.httpsAgent = new PatchedHttpsProxyAgent(https_proxy,
- Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined);
- } else {
- request.httpsAgent = new https.Agent({
- ...httpsAgentRequestFields
- });
+ request.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri: https_proxy, disableCache, hostname });
}
} catch (error) {
throw new Error('Invalid system https_proxy');
}
- } else {
- request.httpsAgent = new https.Agent({
- ...httpsAgentRequestFields
- });
}
- } catch (error) {
- request.httpsAgent = new https.Agent({
- ...httpsAgentRequestFields
- });
+ } catch (error) {}
+ }
+
+ if (!request.httpAgent && !request.httpsAgent) {
+ if (isHttpsRequest) {
+ request.httpsAgent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: tlsOptions, disableCache, hostname });
+ } else {
+ request.httpAgent = getOrCreateHttpAgent({ AgentClass: http.Agent, options: httpAgentOptions, disableCache, hostname });
}
- } else if (Object.keys(httpsAgentRequestFields).length > 0) {
- request.httpsAgent = new https.Agent({
- ...httpsAgentRequestFields
- });
}
// set cookies if enabled
@@ -610,12 +618,13 @@ const runSingleRequest = async function (
let token;
if (oauth2RequestUrl) {
- const tlsOptions = {
+ const oauth2ConfigOptions = {
noproxy: options.noproxy,
shouldVerifyTls: !insecure,
shouldUseCustomCaCertificate: !!options['cacert'],
customCaCertificateFilePath: options['cacert'],
- shouldKeepDefaultCaCertificates: !options['ignoreTruststore']
+ shouldKeepDefaultCaCertificates: !options['ignoreTruststore'],
+ cacheSslSession: !disableCache
};
const clientCertificates = get(brunoConfig, 'clientCertificates');
@@ -627,7 +636,7 @@ const runSingleRequest = async function (
const { httpAgent: oauth2HttpAgent, httpsAgent: oauth2HttpsAgent } = await getHttpHttpsAgents({
requestUrl: oauth2RequestUrl,
collectionPath,
- options: tlsOptions,
+ options: oauth2ConfigOptions,
clientCertificates: interpolatedClientCertificates,
collectionLevelProxy: interpolatedProxyConfig,
systemProxyConfig
diff --git a/packages/bruno-cli/src/utils/proxy-util.js b/packages/bruno-cli/src/utils/proxy-util.js
index 729e03356..70d1b4057 100644
--- a/packages/bruno-cli/src/utils/proxy-util.js
+++ b/packages/bruno-cli/src/utils/proxy-util.js
@@ -63,9 +63,17 @@ const shouldUseProxy = (url, proxyBypass) => {
};
/**
- * Patched version of HttpsProxyAgent to get around a bug that ignores
- * options like ca and rejectUnauthorized when upgrading the socket to TLS:
- * https://github.com/TooTallNate/proxy-agents/issues/194
+ * Options that should be forwarded from the constructor to the target TLS upgrade.
+ */
+const TARGET_TLS_OPTIONS = ['cert', 'key', 'pfx', 'passphrase', 'rejectUnauthorized', 'secureContext'];
+
+/**
+ * Patched version of HttpsProxyAgent that correctly handles TLS options for
+ * both the proxy connection and the target server connection.
+ *
+ * The upstream HttpsProxyAgent (https://github.com/TooTallNate/proxy-agents/issues/194)
+ * ignores constructor options when upgrading the tunneled socket to TLS for the
+ * target server. This patch forwards the relevant TLS options to the target upgrade.
*/
class PatchedHttpsProxyAgent extends HttpsProxyAgent {
constructor(proxy, opts) {
@@ -74,8 +82,17 @@ class PatchedHttpsProxyAgent extends HttpsProxyAgent {
}
async connect(req, opts) {
- const combinedOpts = { ...this.constructorOpts, ...opts };
- return super.connect(req, combinedOpts);
+ const targetOpts = { ...opts };
+
+ if (this.constructorOpts) {
+ for (const key of TARGET_TLS_OPTIONS) {
+ if (key in this.constructorOpts) {
+ targetOpts[key] = this.constructorOpts[key];
+ }
+ }
+ }
+
+ return super.connect(req, targetOpts);
}
}
diff --git a/packages/bruno-electron/src/ipc/network/cert-utils.js b/packages/bruno-electron/src/ipc/network/cert-utils.js
index ba71d894a..3fd45742c 100644
--- a/packages/bruno-electron/src/ipc/network/cert-utils.js
+++ b/packages/bruno-electron/src/ipc/network/cert-utils.js
@@ -194,7 +194,8 @@ const buildCertsAndProxyConfig = async ({
shouldVerifyTls: preferencesUtil.shouldVerifyTls(),
shouldUseCustomCaCertificate: preferencesUtil.shouldUseCustomCaCertificate(),
customCaCertificateFilePath: preferencesUtil.getCustomCaCertificateFilePath(),
- shouldKeepDefaultCaCertificates: preferencesUtil.shouldKeepDefaultCaCertificates()
+ shouldKeepDefaultCaCertificates: preferencesUtil.shouldKeepDefaultCaCertificates(),
+ cacheSslSession: preferencesUtil.isSslSessionCachingEnabled()
};
// Get client certificates from bruno config and interpolate
diff --git a/packages/bruno-electron/src/ipc/preferences.js b/packages/bruno-electron/src/ipc/preferences.js
index 305e87895..8ec2f0fd2 100644
--- a/packages/bruno-electron/src/ipc/preferences.js
+++ b/packages/bruno-electron/src/ipc/preferences.js
@@ -6,6 +6,7 @@ const { getCachedSystemProxy, fetchSystemProxy } = require('../store/system-prox
const { resolveDefaultLocation } = require('../utils/default-location');
const onboardUser = require('../app/onboarding');
const LastOpenedCollections = require('../store/last-opened-collections');
+const { clearAgentCache } = require('@usebruno/requests');
const registerPreferencesIpc = (mainWindow) => {
const lastOpenedCollections = new LastOpenedCollections();
@@ -56,6 +57,14 @@ const registerPreferencesIpc = (mainWindow) => {
}
});
+ ipcMain.handle('renderer:clear-http-https-agent-cache', async () => {
+ try {
+ clearAgentCache();
+ } catch (error) {
+ return Promise.reject(error);
+ }
+ });
+
ipcMain.on('renderer:theme-change', (event, theme) => {
nativeTheme.themeSource = theme;
});
diff --git a/packages/bruno-electron/src/store/preferences.js b/packages/bruno-electron/src/store/preferences.js
index a37d363a7..48ed9db2a 100644
--- a/packages/bruno-electron/src/store/preferences.js
+++ b/packages/bruno-electron/src/store/preferences.js
@@ -106,6 +106,11 @@ const defaultPreferences = {
},
display: {
zoomPercentage: 100
+ },
+ cache: {
+ sslSession: {
+ enabled: false
+ }
}
};
@@ -164,7 +169,12 @@ const preferencesSchema = Yup.object().shape({
}),
display: Yup.object({
zoomPercentage: Yup.number().min(50).max(150)
- })
+ }),
+ cache: Yup.object({
+ sslSession: Yup.object({
+ enabled: Yup.boolean()
+ })
+ }).optional()
});
class PreferencesStore {
@@ -351,6 +361,9 @@ const preferencesUtil = {
getZoomPercentage: () => {
return get(getPreferences(), 'display.zoomPercentage', 100);
},
+ isSslSessionCachingEnabled: () => {
+ return get(getPreferences(), 'cache.sslSession.enabled', false);
+ },
hasLaunchedBefore: () => {
return get(getPreferences(), 'onboarding.hasLaunchedBefore', false);
},
diff --git a/packages/bruno-electron/src/utils/proxy-util.js b/packages/bruno-electron/src/utils/proxy-util.js
index 5730968ac..bf922f80a 100644
--- a/packages/bruno-electron/src/utils/proxy-util.js
+++ b/packages/bruno-electron/src/utils/proxy-util.js
@@ -1,10 +1,13 @@
const parseUrl = require('url').parse;
const https = require('node:https');
+const http = require('node:http');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { interpolateString } = require('../ipc/network/interpolate-string');
const { SocksProxyAgent } = require('socks-proxy-agent');
const { HttpProxyAgent } = require('http-proxy-agent');
const { isEmpty, get, isUndefined, isNull } = require('lodash');
+const { getOrCreateHttpsAgent, getOrCreateHttpAgent } = require('@usebruno/requests');
+const { preferencesUtil } = require('../store/preferences');
const DEFAULT_PORTS = {
ftp: 21,
@@ -67,9 +70,17 @@ const shouldUseProxy = (url, proxyBypass) => {
};
/**
- * Patched version of HttpsProxyAgent to get around a bug that ignores options
- * such as ca and rejectUnauthorized when upgrading the proxied socket to TLS:
- * https://github.com/TooTallNate/proxy-agents/issues/194
+ * Options that should be forwarded from the constructor to the target TLS upgrade.
+ */
+const TARGET_TLS_OPTIONS = ['cert', 'key', 'pfx', 'passphrase', 'rejectUnauthorized', 'secureContext'];
+
+/**
+ * Patched version of HttpsProxyAgent that correctly handles TLS options for
+ * both the proxy connection and the target server connection.
+ *
+ * The upstream HttpsProxyAgent (https://github.com/TooTallNate/proxy-agents/issues/194)
+ * ignores constructor options when upgrading the tunneled socket to TLS for the
+ * target server. This patch forwards the relevant TLS options to the target upgrade.
*/
class PatchedHttpsProxyAgent extends HttpsProxyAgent {
constructor(proxy, opts) {
@@ -78,244 +89,20 @@ class PatchedHttpsProxyAgent extends HttpsProxyAgent {
}
async connect(req, opts) {
- const combinedOpts = { ...this.constructorOpts, ...opts };
- return super.connect(req, combinedOpts);
+ const targetOpts = { ...opts };
+
+ if (this.constructorOpts) {
+ for (const key of TARGET_TLS_OPTIONS) {
+ if (key in this.constructorOpts) {
+ targetOpts[key] = this.constructorOpts[key];
+ }
+ }
+ }
+
+ return super.connect(req, targetOpts);
}
}
-function createTimelineHttpAgentClass(BaseAgentClass) {
- return class extends BaseAgentClass {
- constructor(options, timeline) {
- // For proxy agents, the first argument is the proxy URI and the second is options
- const { proxy: proxyUri, httpProxyAgentOptions } = options || {};
-
- if (!proxyUri) {
- throw new Error('TimelineHttpProxyAgent requires options.proxy to be set');
- }
-
- super(proxyUri, httpProxyAgentOptions);
-
- this.timeline = Array.isArray(timeline) ? timeline : [];
- // Log the proxy details
- this.timeline.push({
- timestamp: new Date(),
- type: 'info',
- message: `Using proxy: ${proxyUri}`
- });
- }
- };
-}
-
-function createTimelineAgentClass(BaseAgentClass) {
- return class extends BaseAgentClass {
- constructor(options, timeline) {
- let caCertificatesCount = options.caCertificatesCount || {};
- delete options.caCertificatesCount;
-
- // For proxy agents, the first argument is the proxy URI and the second is options
- if (options?.proxy) {
- const { proxy: proxyUri, ...agentOptions } = options;
- // Ensure TLS options are properly set
- const tlsOptions = {
- ...agentOptions,
- rejectUnauthorized: agentOptions.rejectUnauthorized ?? true
- };
- super(proxyUri, tlsOptions);
- this.timeline = Array.isArray(timeline) ? timeline : [];
- this.alpnProtocols = tlsOptions.ALPNProtocols || ['h2', 'http/1.1'];
- this.caProvided = !!tlsOptions.ca;
-
- // Log TLS verification status
- this.timeline.push({
- timestamp: new Date(),
- type: 'info',
- message: `SSL validation: ${tlsOptions.rejectUnauthorized ? 'enabled' : 'disabled'}`
- });
-
- // Log the proxy details
- this.timeline.push({
- timestamp: new Date(),
- type: 'info',
- message: `Using proxy: ${proxyUri}`
- });
- } else {
- // This is a regular HTTPS agent case
- const tlsOptions = {
- ...options,
- rejectUnauthorized: options.rejectUnauthorized ?? true
- };
- super(tlsOptions);
- this.timeline = Array.isArray(timeline) ? timeline : [];
- this.alpnProtocols = options.ALPNProtocols || ['h2', 'http/1.1'];
- this.caProvided = !!options.ca;
-
- // Log TLS verification status
- this.timeline.push({
- timestamp: new Date(),
- type: 'info',
- message: `SSL validation: ${tlsOptions.rejectUnauthorized ? 'enabled' : 'disabled'}`
- });
- }
-
- this.caCertificatesCount = caCertificatesCount;
- }
-
- createConnection(options, callback) {
- const { host, port } = options;
-
- // Log ALPN protocols offered
- if (this.alpnProtocols && this.alpnProtocols.length > 0) {
- this.timeline.push({
- timestamp: new Date(),
- type: 'tls',
- message: `ALPN: offers ${this.alpnProtocols.join(', ')}`
- });
- }
-
- const rootCerts = this.caCertificatesCount.root || 0;
- const systemCerts = this.caCertificatesCount.system || 0;
- const extraCerts = this.caCertificatesCount.extra || 0;
- const customCerts = this.caCertificatesCount.custom || 0;
-
- this.timeline.push({
- timestamp: new Date(),
- type: 'tls',
- message: `CA Certificates: ${rootCerts} root, ${systemCerts} system, ${extraCerts} extra, ${customCerts} custom`
- });
-
- // Log "Trying host:port..."
- this.timeline.push({
- timestamp: new Date(),
- type: 'info',
- message: `Trying ${host}:${port}...`
- });
-
- let socket;
- try {
- socket = super.createConnection(options, callback);
- } catch (error) {
- this.timeline.push({
- timestamp: new Date(),
- type: 'error',
- message: `Error creating connection: ${error.message}`
- });
- error.timeline = this.timeline;
- throw error;
- }
-
- // Attach event listeners to the socket
- socket?.on('lookup', (err, address, family, host) => {
- if (err) {
- this.timeline.push({
- timestamp: new Date(),
- type: 'error',
- message: `DNS lookup error for ${host}: ${err.message}`
- });
- } else {
- this.timeline.push({
- timestamp: new Date(),
- type: 'info',
- message: `DNS lookup: ${host} -> ${address}`
- });
- }
- });
-
- socket?.on('connect', () => {
- const address = socket.remoteAddress || host;
- const remotePort = socket.remotePort || port;
-
- this.timeline.push({
- timestamp: new Date(),
- type: 'info',
- message: `Connected to ${host} (${address}) port ${remotePort}`
- });
- });
-
- socket?.on('secureConnect', () => {
- const protocol = socket.getProtocol() || 'SSL/TLS';
- const cipher = socket.getCipher();
- const cipherSuite = cipher ? `${cipher.name} (${cipher.version})` : 'Unknown cipher';
-
- this.timeline.push({
- timestamp: new Date(),
- type: 'tls',
- message: `SSL connection using ${protocol} / ${cipherSuite}`
- });
-
- // ALPN protocol
- const alpnProtocol = socket.alpnProtocol || 'None';
- this.timeline.push({
- timestamp: new Date(),
- type: 'tls',
- message: `ALPN: server accepted ${alpnProtocol}`
- });
-
- // Server certificate
- const cert = socket.getPeerCertificate(true);
- if (cert) {
- this.timeline.push({
- timestamp: new Date(),
- type: 'tls',
- message: `Server certificate:`
- });
- if (cert.subject) {
- this.timeline.push({
- timestamp: new Date(),
- type: 'tls',
- message: ` subject: ${Object.entries(cert.subject).map(([k, v]) => `${k}=${v}`).join(', ')}`
- });
- }
- if (cert.valid_from) {
- this.timeline.push({
- timestamp: new Date(),
- type: 'tls',
- message: ` start date: ${cert.valid_from}`
- });
- }
- if (cert.valid_to) {
- this.timeline.push({
- timestamp: new Date(),
- type: 'tls',
- message: ` expire date: ${cert.valid_to}`
- });
- }
- if (cert.subjectaltname) {
- this.timeline.push({
- timestamp: new Date(),
- type: 'tls',
- message: ` subjectAltName: ${cert.subjectaltname}`
- });
- }
- if (cert.issuer) {
- this.timeline.push({
- timestamp: new Date(),
- type: 'tls',
- message: ` issuer: ${Object.entries(cert.issuer).map(([k, v]) => `${k}=${v}`).join(', ')}`
- });
- }
-
- // SSL certificate verify ok
- this.timeline.push({
- timestamp: new Date(),
- type: 'tls',
- message: `SSL certificate verify ok.`
- });
- }
- });
-
- socket?.on('error', (err) => {
- this.timeline.push({
- timestamp: new Date(),
- type: 'error',
- message: `Socket error: ${err.message}`
- });
- });
-
- return socket;
- }
- };
-}
-
function setupProxyAgents({
requestConfig,
proxyMode = 'off',
@@ -324,6 +111,8 @@ function setupProxyAgents({
interpolationOptions,
timeline
}) {
+ const disableCache = !preferencesUtil.isSslSessionCachingEnabled();
+
// Ensure TLS options are properly set
const tlsOptions = {
...httpsAgentRequestFields,
@@ -331,21 +120,22 @@ function setupProxyAgents({
secureProtocol: undefined,
// Allow Node.js to choose the protocol
minVersion: 'TLSv1',
- rejectUnauthorized: httpsAgentRequestFields.rejectUnauthorized !== undefined ? httpsAgentRequestFields.rejectUnauthorized : true
- };
-
- const httpProxyAgentOptions = {
+ rejectUnauthorized: httpsAgentRequestFields.rejectUnauthorized !== undefined ? httpsAgentRequestFields.rejectUnauthorized : true,
+ // Enable keepAlive for connection reuse
keepAlive: true
};
+ const parsedUrl = parseUrl(requestConfig.url);
+ const isHttpsRequest = parsedUrl.protocol === 'https:';
+ const hostname = parsedUrl.hostname || null;
+
if (proxyMode === 'on') {
const shouldProxy = shouldUseProxy(requestConfig.url, get(proxyConfig, 'bypassProxy', ''));
if (shouldProxy) {
const proxyProtocol = interpolateString(get(proxyConfig, 'protocol'), interpolationOptions);
const proxyHostname = interpolateString(get(proxyConfig, 'hostname'), interpolationOptions);
const proxyPort = interpolateString(get(proxyConfig, 'port'), interpolationOptions);
- const proxyAuthDisabled = get(proxyConfig, 'auth.disabled', false);
- const proxyAuthEnabled = !proxyAuthDisabled;
+ const proxyAuthEnabled = !get(proxyConfig, 'auth.disabled', false);
const socksEnabled = proxyProtocol.includes('socks');
let uriPort = isUndefined(proxyPort) || isNull(proxyPort) ? '' : `:${proxyPort}`;
@@ -358,35 +148,51 @@ function setupProxyAgents({
proxyUri = `${proxyProtocol}://${proxyHostname}${uriPort}`;
}
- if (socksEnabled) {
- const TimelineSocksProxyAgent = createTimelineAgentClass(SocksProxyAgent);
- requestConfig.httpAgent = new TimelineSocksProxyAgent({ proxy: proxyUri }, timeline);
- requestConfig.httpsAgent = new TimelineSocksProxyAgent({ proxy: proxyUri, ...tlsOptions }, timeline);
- } else {
- const TimelineHttpProxyAgent = createTimelineHttpAgentClass(HttpProxyAgent);
- const TimelineHttpsProxyAgent = createTimelineAgentClass(PatchedHttpsProxyAgent);
- requestConfig.httpAgent = new TimelineHttpProxyAgent({ proxy: proxyUri, httpProxyAgentOptions }, timeline);
- requestConfig.httpsAgent = new TimelineHttpsProxyAgent(
- { proxy: proxyUri, ...tlsOptions },
- timeline
- );
+ if (timeline) {
+ timeline.push({
+ timestamp: new Date(),
+ type: 'info',
+ message: `Using proxy: ${proxyProtocol}://${proxyHostname}${uriPort}`
+ });
+ }
+
+ // When the proxy itself uses HTTPS, the agent connecting to it needs TLS options
+ // (e.g., ca certs) even for plain HTTP requests
+ const isHttpsProxy = proxyProtocol === 'https';
+ const httpProxyAgentOptions = isHttpsProxy ? { keepAlive: true, ...tlsOptions } : { keepAlive: true };
+
+ // Only set the agent needed for the request protocol
+ if (socksEnabled) {
+ if (isHttpsRequest) {
+ requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: SocksProxyAgent, options: tlsOptions, proxyUri, timeline, disableCache, hostname });
+ } else {
+ requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: SocksProxyAgent, options: httpProxyAgentOptions, proxyUri, timeline, disableCache, hostname });
+ }
+ } else {
+ if (isHttpsRequest) {
+ requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri, timeline, disableCache, hostname });
+ } else {
+ requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: httpProxyAgentOptions, proxyUri, timeline, disableCache, hostname });
+ }
}
- } else {
- // If proxy should not be used, set default HTTPS agent
- const TimelineHttpsAgent = createTimelineAgentClass(https.Agent);
- requestConfig.httpsAgent = new TimelineHttpsAgent(tlsOptions, timeline);
}
} else if (proxyMode === 'system') {
const { http_proxy, https_proxy, no_proxy } = proxyConfig || {};
const shouldUseSystemProxy = shouldUseProxy(requestConfig.url, no_proxy || '');
- const parsedUrl = parseUrl(requestConfig.url);
- const isHttpsRequest = parsedUrl.protocol === 'https:';
if (shouldUseSystemProxy) {
try {
if (http_proxy?.length && !isHttpsRequest) {
- new URL(http_proxy);
- const TimelineHttpProxyAgent = createTimelineHttpAgentClass(HttpProxyAgent);
- requestConfig.httpAgent = new TimelineHttpProxyAgent({ proxy: http_proxy, httpProxyAgentOptions }, timeline);
+ const parsedHttpProxy = new URL(http_proxy);
+ const isHttpsSystemProxy = parsedHttpProxy.protocol === 'https:';
+ const systemHttpProxyAgentOptions = isHttpsSystemProxy ? { keepAlive: true, ...tlsOptions } : { keepAlive: true };
+ if (timeline) {
+ timeline.push({
+ timestamp: new Date(),
+ type: 'info',
+ message: `Using system proxy: ${http_proxy}`
+ });
+ }
+ requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: systemHttpProxyAgentOptions, proxyUri: http_proxy, timeline, disableCache, hostname });
}
} catch (error) {
throw new Error(`Invalid system http_proxy "${http_proxy}": ${error.message}`);
@@ -394,25 +200,27 @@ function setupProxyAgents({
try {
if (https_proxy?.length && isHttpsRequest) {
new URL(https_proxy);
- const TimelineHttpsProxyAgent = createTimelineAgentClass(PatchedHttpsProxyAgent);
- requestConfig.httpsAgent = new TimelineHttpsProxyAgent(
- { proxy: https_proxy, ...tlsOptions },
- timeline
- );
- } else {
- const TimelineHttpsAgent = createTimelineAgentClass(https.Agent);
- requestConfig.httpsAgent = new TimelineHttpsAgent(tlsOptions, timeline);
+ if (timeline) {
+ timeline.push({
+ timestamp: new Date(),
+ type: 'info',
+ message: `Using system proxy: ${https_proxy}`
+ });
+ }
+ requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri: https_proxy, timeline, disableCache, hostname });
}
} catch (error) {
throw new Error(`Invalid system https_proxy "${https_proxy}": ${error.message}`);
}
- } else {
- const TimelineHttpsAgent = createTimelineAgentClass(https.Agent);
- requestConfig.httpsAgent = new TimelineHttpsAgent(tlsOptions, timeline);
}
- } else {
- const TimelineHttpsAgent = createTimelineAgentClass(https.Agent);
- requestConfig.httpsAgent = new TimelineHttpsAgent(tlsOptions, timeline);
+ }
+
+ if (!requestConfig.httpAgent && !requestConfig.httpsAgent) {
+ if (isHttpsRequest) {
+ requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: tlsOptions, proxyUri: null, timeline, disableCache, hostname });
+ } else {
+ requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: http.Agent, options: { keepAlive: true }, proxyUri: null, timeline, disableCache, hostname });
+ }
}
}
diff --git a/packages/bruno-requests/src/index.ts b/packages/bruno-requests/src/index.ts
index dd728899c..03f2c6fed 100644
--- a/packages/bruno-requests/src/index.ts
+++ b/packages/bruno-requests/src/index.ts
@@ -9,6 +9,7 @@ export { default as createVaultClient, VaultError } from './utils/node-vault';
export type { VaultClient, VaultConfig, VaultRequestOptions } from './utils/node-vault';
export { getHttpHttpsAgents } from './utils/http-https-agents';
export { initializeShellEnv } from './utils/shell-env';
+export { getOrCreateHttpsAgent, getOrCreateHttpAgent, clearAgentCache, getAgentCacheSize } from './utils/agent-cache';
export * as scripting from './scripting';
diff --git a/packages/bruno-requests/src/utils/agent-cache.spec.ts b/packages/bruno-requests/src/utils/agent-cache.spec.ts
new file mode 100644
index 000000000..ba19c60e6
--- /dev/null
+++ b/packages/bruno-requests/src/utils/agent-cache.spec.ts
@@ -0,0 +1,374 @@
+import https from 'node:https';
+import http from 'node:http';
+import { EventEmitter } from 'node:events';
+import { getOrCreateHttpsAgent, getOrCreateHttpAgent, clearAgentCache, getAgentCacheSize } from './agent-cache';
+
+describe('Agent Cache', () => {
+ beforeEach(() => {
+ clearAgentCache();
+ });
+
+ describe('getOrCreateHttpsAgent', () => {
+ it('creates a new agent when cache is empty', () => {
+ const agent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { rejectUnauthorized: true } });
+
+ expect(agent).toBeInstanceOf(https.Agent);
+ expect(getAgentCacheSize()).toBe(1);
+ });
+
+ it('returns cached agent for identical options', () => {
+ const options = { rejectUnauthorized: true, keepAlive: true };
+
+ const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options });
+ const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options });
+
+ expect(agent1).toBe(agent2);
+ expect(getAgentCacheSize()).toBe(1);
+ });
+
+ it('creates separate agents for different rejectUnauthorized values', () => {
+ const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { rejectUnauthorized: true } });
+ const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { rejectUnauthorized: false } });
+
+ expect(agent1).not.toBe(agent2);
+ expect(getAgentCacheSize()).toBe(2);
+ });
+
+ it('creates separate agents for different CA certificates', () => {
+ const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { ca: 'cert-a' } });
+ const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { ca: 'cert-b' } });
+
+ expect(agent1).not.toBe(agent2);
+ expect(getAgentCacheSize()).toBe(2);
+ });
+
+ it('creates separate agents for different cert values', () => {
+ const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { cert: Buffer.from('cert-a') } });
+ const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { cert: Buffer.from('cert-b') } });
+
+ expect(agent1).not.toBe(agent2);
+ expect(getAgentCacheSize()).toBe(2);
+ });
+
+ it('creates separate agents for different key values', () => {
+ const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { key: Buffer.from('key-a') } });
+ const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { key: Buffer.from('key-b') } });
+
+ expect(agent1).not.toBe(agent2);
+ expect(getAgentCacheSize()).toBe(2);
+ });
+
+ it('creates separate agents for different pfx values', () => {
+ const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { pfx: Buffer.from('pfx-a') } });
+ const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { pfx: Buffer.from('pfx-b') } });
+
+ expect(agent1).not.toBe(agent2);
+ expect(getAgentCacheSize()).toBe(2);
+ });
+
+ it('creates separate agents for different passphrase values', () => {
+ const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { passphrase: 'pass-a' } });
+ const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { passphrase: 'pass-b' } });
+
+ expect(agent1).not.toBe(agent2);
+ expect(getAgentCacheSize()).toBe(2);
+ });
+
+ it('creates separate agents for different proxy URIs', () => {
+ const options = { rejectUnauthorized: true };
+
+ const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options, proxyUri: 'http://proxy1:8080' });
+ const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options, proxyUri: 'http://proxy2:8080' });
+
+ expect(agent1).not.toBe(agent2);
+ expect(getAgentCacheSize()).toBe(2);
+ });
+
+ it('creates separate agents for different agent classes', () => {
+ const options = { keepAlive: true };
+
+ const httpsAgent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options });
+ const httpAgent = getOrCreateHttpsAgent({ AgentClass: http.Agent, options });
+
+ expect(httpsAgent).not.toBe(httpAgent);
+ expect(getAgentCacheSize()).toBe(2);
+ });
+
+ it('creates separate agents for different keepAlive values', () => {
+ const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { keepAlive: true } });
+ const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { keepAlive: false } });
+
+ expect(agent1).not.toBe(agent2);
+ expect(getAgentCacheSize()).toBe(2);
+ });
+
+ it('creates separate agents for different hostnames', () => {
+ const options = { rejectUnauthorized: true };
+
+ const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options, hostname: 'api.example.com' });
+ const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options, hostname: 'auth.example.com' });
+
+ expect(agent1).not.toBe(agent2);
+ expect(getAgentCacheSize()).toBe(2);
+ });
+
+ it('returns cached agent for the same hostname', () => {
+ const options = { rejectUnauthorized: true };
+
+ const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options, hostname: 'api.example.com' });
+ const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options, hostname: 'api.example.com' });
+
+ expect(agent1).toBe(agent2);
+ expect(getAgentCacheSize()).toBe(1);
+ });
+
+ it('creates separate agents for null hostname vs explicit hostname', () => {
+ const options = { rejectUnauthorized: true };
+
+ const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options, hostname: null });
+ const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options, hostname: 'api.example.com' });
+
+ expect(agent1).not.toBe(agent2);
+ expect(getAgentCacheSize()).toBe(2);
+ });
+ });
+
+ describe('timeline support', () => {
+ it('does not add timeline when none is provided', () => {
+ const agent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {} }) as any;
+
+ expect(agent.timeline).toBeUndefined();
+ });
+
+ it('uses provided timeline array', () => {
+ const timeline: any[] = [];
+ const agent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {}, timeline }) as any;
+
+ expect(agent.timeline).toBe(timeline);
+ });
+
+ it('updates timeline reference on cached agents', () => {
+ const timeline1: any[] = [];
+ const timeline2: any[] = [];
+
+ const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {}, timeline: timeline1 }) as any;
+ expect(agent1.timeline).toBe(timeline1);
+
+ const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {}, timeline: timeline2 }) as any;
+ expect(agent1).toBe(agent2);
+ expect(agent2.timeline).toBe(timeline2);
+ });
+
+ it('logs when reusing a cached HTTPS agent', () => {
+ const timeline1: any[] = [];
+ const timeline2: any[] = [];
+
+ // First call creates new agent - no reuse message
+ getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {}, timeline: timeline1 });
+ expect(timeline1.some((e) => e.message.includes('Reusing cached https agent'))).toBe(false);
+
+ // Second call reuses cached agent - should log reuse message with SSL session reuse
+ getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {}, timeline: timeline2 });
+ expect(timeline2.some((e) => e.message.includes('Reusing cached https agent'))).toBe(true);
+ });
+
+ it('logs when reusing a cached HTTP agent', () => {
+ const timeline1: any[] = [];
+ const timeline2: any[] = [];
+
+ // First call creates new agent - no reuse message
+ getOrCreateHttpAgent({ AgentClass: http.Agent, options: { keepAlive: true }, timeline: timeline1 });
+ expect(timeline1.some((e) => e.message.includes('Reusing cached http agent'))).toBe(false);
+
+ // Second call reuses cached agent - should log reuse message with connection reuse
+ getOrCreateHttpAgent({ AgentClass: http.Agent, options: { keepAlive: true }, timeline: timeline2 });
+ expect(timeline2.some((e) => e.message.includes('Reusing cached http agent'))).toBe(true);
+ });
+
+ it('logs SSL validation status on agent creation', () => {
+ const timeline: any[] = [];
+ getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { rejectUnauthorized: true }, timeline });
+
+ const sslEntry = timeline.find((e) => e.message.includes('SSL validation'));
+ expect(sslEntry).toBeDefined();
+ expect(sslEntry.message).toContain('enabled');
+ });
+
+ it('logs SSL validation disabled when rejectUnauthorized is false', () => {
+ const timeline: any[] = [];
+ getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { rejectUnauthorized: false }, timeline });
+
+ const sslEntry = timeline.find((e) => e.message.includes('SSL validation'));
+ expect(sslEntry).toBeDefined();
+ expect(sslEntry.message).toContain('disabled');
+ });
+ });
+
+ describe('clearAgentCache', () => {
+ it('removes all cached agents', () => {
+ getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { rejectUnauthorized: true } });
+ getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { rejectUnauthorized: false } });
+ expect(getAgentCacheSize()).toBe(2);
+
+ clearAgentCache();
+ expect(getAgentCacheSize()).toBe(0);
+ });
+
+ it('destroys all agents when clearing cache', () => {
+ const destroyMocks: jest.Mock[] = [];
+
+ // Create several agents and attach mock destroy functions
+ for (let i = 0; i < 5; i++) {
+ const agent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { ca: `cert-${i}` } }) as any;
+ const mock = jest.fn();
+ agent.destroy = mock;
+ destroyMocks.push(mock);
+ }
+
+ expect(getAgentCacheSize()).toBe(5);
+
+ clearAgentCache();
+
+ expect(getAgentCacheSize()).toBe(0);
+ // All agents should have been destroyed
+ destroyMocks.forEach((mock) => {
+ expect(mock).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('LRU eviction', () => {
+ it('maintains cache size under limit', () => {
+ // Create many agents with different options
+ for (let i = 0; i < 150; i++) {
+ getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { ca: `cert-${i}` } });
+ }
+
+ // Cache should be capped at MAX_AGENT_CACHE_SIZE (100)
+ expect(getAgentCacheSize()).toBeLessThanOrEqual(100);
+ });
+
+ it('destroys evicted agents to prevent memory leaks', () => {
+ // Create first agent and attach a mock destroy function
+ const firstAgent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { ca: 'cert-to-evict' } }) as any;
+ const destroyMock = jest.fn();
+ firstAgent.destroy = destroyMock;
+
+ // Fill cache to trigger eviction (100 more agents will evict the first one)
+ for (let i = 0; i < 100; i++) {
+ getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { ca: `cert-${i}` } });
+ }
+
+ // First agent should have been evicted and destroyed
+ expect(destroyMock).toHaveBeenCalled();
+ });
+ });
+
+ describe('concurrent requests timeline isolation', () => {
+ it('isolates timeline events for concurrent requests using the same cached agent', () => {
+ const timeline1: any[] = [];
+ const timeline2: any[] = [];
+
+ // Get the same agent twice with different timelines (simulating concurrent requests)
+ const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {}, timeline: timeline1 }) as any;
+ const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {}, timeline: timeline2 }) as any;
+
+ // Both should return the same cached agent
+ expect(agent1).toBe(agent2);
+
+ // Create mock sockets to simulate concurrent connections
+ const mockSocket1 = new EventEmitter() as any;
+ mockSocket1.remoteAddress = '1.2.3.4';
+ mockSocket1.remotePort = 443;
+ mockSocket1.getProtocol = () => 'TLSv1.3';
+ mockSocket1.getCipher = () => ({ name: 'AES-256-GCM', version: 'TLSv1.3' });
+ mockSocket1.alpnProtocol = 'h2';
+ mockSocket1.getPeerCertificate = () => ({
+ subject: { CN: 'example.com' },
+ valid_from: 'Jan 1 00:00:00 2024 GMT',
+ valid_to: 'Jan 1 00:00:00 2025 GMT'
+ });
+ mockSocket1.authorized = true;
+
+ const mockSocket2 = new EventEmitter() as any;
+ mockSocket2.remoteAddress = '5.6.7.8';
+ mockSocket2.remotePort = 443;
+ mockSocket2.getProtocol = () => 'TLSv1.3';
+ mockSocket2.getCipher = () => ({ name: 'AES-256-GCM', version: 'TLSv1.3' });
+ mockSocket2.alpnProtocol = 'http/1.1';
+ mockSocket2.getPeerCertificate = () => ({
+ subject: { CN: 'other.com' },
+ valid_from: 'Jan 1 00:00:00 2024 GMT',
+ valid_to: 'Jan 1 00:00:00 2025 GMT'
+ });
+ mockSocket2.authorized = true;
+
+ // Mock createConnection to return our mock sockets
+ const originalCreateConnection = Object.getPrototypeOf(Object.getPrototypeOf(agent1)).createConnection;
+ let callCount = 0;
+ jest.spyOn(Object.getPrototypeOf(Object.getPrototypeOf(agent1)), 'createConnection').mockImplementation(function (this: any, options: any, callback: any) {
+ callCount++;
+ return callCount === 1 ? mockSocket1 : mockSocket2;
+ });
+
+ // Simulate request 1 starting - this captures timeline1 in the closure
+ agent1.timeline = timeline1;
+ const socket1 = agent1.createConnection({ host: 'example.com', port: 443 }, () => {});
+
+ // Before request 1's events fire, request 2 starts and updates agent.timeline
+ // This simulates the race condition
+ agent1.timeline = timeline2;
+ const socket2 = agent1.createConnection({ host: 'other.com', port: 443 }, () => {});
+
+ // Now fire events for both sockets - they should go to their respective timelines
+ mockSocket1.emit('connect');
+ mockSocket1.emit('secureConnect');
+
+ mockSocket2.emit('connect');
+ mockSocket2.emit('secureConnect');
+
+ // Verify timeline1 only contains events for request 1 (example.com)
+ const timeline1Messages = timeline1.map((e) => e.message);
+ expect(timeline1Messages.some((m) => m.includes('example.com'))).toBe(true);
+ expect(timeline1Messages.some((m) => m.includes('other.com'))).toBe(false);
+
+ // Verify timeline2 only contains events for request 2 (other.com)
+ const timeline2Messages = timeline2.map((e) => e.message);
+ expect(timeline2Messages.some((m) => m.includes('other.com'))).toBe(true);
+ expect(timeline2Messages.some((m) => m.includes('example.com'))).toBe(false);
+
+ // Restore the original implementation
+ jest.restoreAllMocks();
+ });
+
+ it('logs events to captured timeline even after agent.timeline is reassigned', () => {
+ const timeline1: any[] = [];
+ const timeline2: any[] = [];
+
+ const agent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {}, timeline: timeline1 }) as any;
+
+ // Create a mock socket
+ const mockSocket = new EventEmitter() as any;
+ mockSocket.remoteAddress = '1.2.3.4';
+ mockSocket.remotePort = 443;
+
+ // Mock createConnection
+ jest.spyOn(Object.getPrototypeOf(Object.getPrototypeOf(agent)), 'createConnection').mockImplementation(() => mockSocket);
+
+ // Start creating connection - this captures timeline1
+ const socket = agent.createConnection({ host: 'test.com', port: 443 }, () => {});
+
+ // Reassign agent.timeline (simulating another request coming in)
+ agent.timeline = timeline2;
+
+ // Fire the connect event - this should still go to timeline1 (captured reference)
+ mockSocket.emit('connect');
+
+ // Verify event went to timeline1, not timeline2
+ expect(timeline1.some((e) => e.message.includes('Connected to test.com'))).toBe(true);
+ expect(timeline2.some((e) => e.message.includes('Connected to test.com'))).toBe(false);
+
+ jest.restoreAllMocks();
+ });
+ });
+});
diff --git a/packages/bruno-requests/src/utils/agent-cache.ts b/packages/bruno-requests/src/utils/agent-cache.ts
new file mode 100644
index 000000000..c7dc40105
--- /dev/null
+++ b/packages/bruno-requests/src/utils/agent-cache.ts
@@ -0,0 +1,393 @@
+import crypto from 'node:crypto';
+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';
+
+/**
+ * Agent cache for SSL session reuse.
+ * Agents are cached by their configuration to enable TLS session resumption,
+ * which significantly reduces SSL handshake time for repeated requests.
+ */
+const agentCache = new Map
();
+
+/**
+ * Maximum number of agents to cache.
+ * 100 provides a good balance between memory usage and SSL session reuse.
+ * Each agent maintains persistent connections, so higher values increase memory.
+ * Lower values may reduce SSL session hits for users with many different TLS configs.
+ */
+const MAX_AGENT_CACHE_SIZE = 100;
+
+/**
+ * Cache for timeline-wrapped HTTPS agent classes.
+ * Prevents creating new class definitions on every call.
+ */
+const timelineClassCache = new WeakMap();
+
+/**
+ * Cache for timeline-wrapped HTTP agent classes.
+ * Prevents creating new class definitions on every call.
+ */
+const timelineHttpClassCache = new WeakMap();
+
+/**
+ * Map to assign unique IDs to agent classes.
+ * Used for cache key generation since different classes may have the same name.
+ */
+const agentClassIdMap = new WeakMap();
+let agentClassIdCounter = 0;
+
+function getAgentClassId(AgentClass: any): number {
+ if (agentClassIdMap.has(AgentClass)) {
+ return agentClassIdMap.get(AgentClass)!;
+ }
+ const id = ++agentClassIdCounter;
+ agentClassIdMap.set(AgentClass, id);
+ return id;
+}
+
+/**
+ * Hash a value using SHA-256 and return a truncated hex string.
+ * Truncated to 16 chars for compact cache keys while maintaining uniqueness.
+ */
+function hashValue(value: string | Buffer | undefined): string | null {
+ if (!value) return null;
+ const data = Buffer.isBuffer(value) ? value : String(value);
+ return crypto.createHash('sha256').update(data).digest('hex').slice(0, 16);
+}
+
+/**
+ * Cache for secure contexts created from CA options.
+ * Keyed by the hash of the CA value to avoid creating duplicate contexts.
+ */
+const secureContextCache = new Map();
+
+/**
+ * Build a TLS secure context that adds custom CAs on top of the OpenSSL defaults.
+ *
+ * When Node.js receives an explicit `ca` option in tls.connect() or https.Agent,
+ * it replaces the default CA store entirely. This means CAs that are only in the
+ * OpenSSL default trust store (e.g. /etc/ssl/cert.pem) but not in
+ * tls.rootCertificates or tls.getCACertificates('system') are lost.
+ *
+ * This function creates a secureContext starting from the OpenSSL defaults
+ * and adds custom CAs on top via addCACert(), which appends rather than replaces.
+ */
+function buildSecureContext(ca: string | Buffer | (string | Buffer)[]): tls.SecureContext {
+ const caHash = hashCaValue(ca);
+ if (caHash && secureContextCache.has(caHash)) {
+ return secureContextCache.get(caHash)!;
+ }
+
+ const ctx = tls.createSecureContext();
+ const caList = Array.isArray(ca) ? ca : [ca];
+ for (const cert of caList) {
+ if (cert) {
+ ctx.context.addCACert(cert);
+ }
+ }
+
+ if (caHash) {
+ secureContextCache.set(caHash, ctx);
+ }
+ return ctx;
+}
+
+/**
+ * Convert agent options to use a secureContext instead of raw `ca`.
+ * This ensures custom CAs are added on top of the OpenSSL defaults
+ * rather than replacing the default CA store.
+ *
+ * When client certificates (pfx/cert/key) are also present, they are loaded
+ * into the secure context so they aren't silently ignored by Node.js
+ * (Node.js skips pfx/cert/key/ca when a secureContext is provided).
+ */
+function applySecureContext(options: T): T {
+ if ('ca' in options && (options as AgentOptions).ca) {
+ const { ca, ...rest } = options as AgentOptions;
+
+ // When client certs are present alongside CA, build a combined context
+ // that includes both. This context can't be CA-cached since it's unique
+ // per client cert + CA combination.
+ const hasClientCert = rest.pfx || rest.cert || rest.key;
+ if (hasClientCert) {
+ const ctxOptions: Record = {};
+ if (rest.pfx) ctxOptions.pfx = rest.pfx;
+ if (rest.cert) ctxOptions.cert = rest.cert;
+ if (rest.key) ctxOptions.key = rest.key;
+ if (rest.passphrase) ctxOptions.passphrase = rest.passphrase;
+
+ const ctx = tls.createSecureContext(ctxOptions);
+ const caList = Array.isArray(ca) ? ca : [ca!];
+ for (const caCert of caList) {
+ if (caCert) ctx.context.addCACert(caCert);
+ }
+
+ const { pfx: _pfx, cert: _cert, key: _key, passphrase: _pass, ...cleanRest } = rest;
+ return { ...cleanRest, secureContext: ctx } as unknown as T;
+ }
+
+ // CA-only case: use cached secure context
+ return { ...rest, secureContext: buildSecureContext(ca!) } as unknown as T;
+ }
+ return options;
+}
+
+/**
+ * Hash a CA value which can be a single value or an array of certificates.
+ * Node.js TLS options allow ca to be string | Buffer | (string | Buffer)[].
+ */
+function hashCaValue(value: string | Buffer | (string | Buffer)[] | undefined): string | null {
+ if (!value) return null;
+ if (Array.isArray(value)) {
+ // Concatenate all values with separator and hash together
+ const combined = value.map((v) => (Buffer.isBuffer(v) ? v.toString('base64') : String(v))).join('|');
+ return crypto.createHash('sha256').update(combined).digest('hex').slice(0, 16);
+ }
+ const data = Buffer.isBuffer(value) ? value : String(value);
+ return crypto.createHash('sha256').update(data).digest('hex').slice(0, 16);
+}
+
+/**
+ * Generate a cache key from HTTPS agent options.
+ * Uses a hash of the serialized options to create a compact key.
+ */
+function getAgentCacheKey(agentClassId: number, options: AgentOptions, proxyUri: string | null = null, hostname: string | null = null): string {
+ // Extract the TLS-relevant options for the cache key
+ const keyData = {
+ agentClassId,
+ hostname: proxyUri?.length ? null : hostname,
+ proxyUri,
+ keepAlive: options.keepAlive,
+ rejectUnauthorized: options.rejectUnauthorized,
+ // Hash certificates and passphrase instead of including full content
+ ca: hashCaValue(options.ca),
+ cert: hashValue(options.cert),
+ key: hashValue(options.key),
+ pfx: hashValue(options.pfx),
+ passphrase: hashValue(options.passphrase),
+ minVersion: options.minVersion,
+ secureProtocol: options.secureProtocol
+ };
+ return JSON.stringify(keyData);
+}
+
+/**
+ * Generate a cache key from HTTP agent options.
+ * Simpler than HTTPS since no TLS options are involved.
+ */
+function getHttpAgentCacheKey(agentClassId: number, options: HttpAgentOptions, proxyUri: string | null = null, hostname: string | null = null): string {
+ const keyData = {
+ agentClassId,
+ hostname: proxyUri?.length ? null : hostname,
+ proxyUri,
+ keepAlive: options.keepAlive
+ };
+ return JSON.stringify(keyData);
+}
+
+/**
+ * Get a cached timeline-wrapped HTTPS agent class.
+ * Creates the wrapped class once and caches it for reuse.
+ */
+function getTimelineAgentClass(BaseAgentClass: any): AgentClass {
+ if (timelineClassCache.has(BaseAgentClass)) {
+ return timelineClassCache.get(BaseAgentClass)!;
+ }
+ const wrappedClass = createTimelineAgentClass(BaseAgentClass);
+ timelineClassCache.set(BaseAgentClass, wrappedClass);
+ return wrappedClass;
+}
+
+/**
+ * Get a cached timeline-wrapped HTTP agent class.
+ * Creates the wrapped class once and caches it for reuse.
+ */
+function getTimelineHttpAgentClass(BaseAgentClass: any): HttpAgentClass {
+ if (timelineHttpClassCache.has(BaseAgentClass)) {
+ return timelineHttpClassCache.get(BaseAgentClass)!;
+ }
+ const wrappedClass = createTimelineHttpAgentClass(BaseAgentClass);
+ timelineHttpClassCache.set(BaseAgentClass, wrappedClass);
+ return wrappedClass;
+}
+
+/**
+ * Type for cache key generation functions.
+ */
+type CacheKeyFn = (classId: number, options: T, proxyUri: string | null, hostname: string | null) => string;
+
+/**
+ * Type for timeline class wrapper functions.
+ */
+type TimelineClassFn = (base: any) => AgentClass | HttpAgentClass;
+
+/**
+ * Internal helper for agent caching with LRU eviction.
+ * Shared logic for both HTTP and HTTPS agents.
+ */
+function getOrCreateAgentInternal(
+ BaseAgentClass: any,
+ options: TOptions,
+ proxyUri: string | null,
+ timeline: TimelineEntry[] | null,
+ getCacheKey: CacheKeyFn,
+ getTimelineClass: TimelineClassFn,
+ cacheHitMessage: string,
+ disableCache: boolean = false,
+ hostname: string | null = null
+): HttpAgent | HttpsAgent {
+ const agentClassId = getAgentClassId(BaseAgentClass);
+ const cacheKey = getCacheKey(agentClassId, options, proxyUri, hostname);
+
+ if (!disableCache && agentCache.has(cacheKey)) {
+ // Move to end for LRU (delete and re-add)
+ const agent = agentCache.get(cacheKey)!;
+ agentCache.delete(cacheKey);
+ agentCache.set(cacheKey, agent);
+
+ // Update timeline reference for new request
+ // The cached agent was created with a previous timeline,
+ // but we need events to go to the current request's timeline
+ if (timeline && 'timeline' in agent) {
+ (agent as any).timeline = timeline;
+ }
+
+ // Log that we're reusing a cached agent
+ if (timeline) {
+ timeline.push({
+ timestamp: new Date(),
+ type: 'info',
+ message: cacheHitMessage
+ });
+ }
+
+ return agent;
+ }
+
+ const AgentClass = timeline ? getTimelineClass(BaseAgentClass) : BaseAgentClass;
+ // Convert raw `ca` to a secureContext that adds CAs on top of OpenSSL defaults
+ const resolvedOptions = applySecureContext(options);
+
+ let agent: HttpAgent | HttpsAgent;
+ if (timeline) {
+ // Timeline-wrapped classes handle proxy internally via options.proxy
+ const agentOptions = proxyUri ? { ...resolvedOptions, proxy: proxyUri } : resolvedOptions;
+ agent = new AgentClass(agentOptions, timeline);
+ } else if (proxyUri) {
+ // Proxy agent classes expect (proxyUri, options) constructor signature
+ agent = new BaseAgentClass(proxyUri, resolvedOptions);
+ } else {
+ agent = new BaseAgentClass(resolvedOptions);
+ }
+
+ if (!disableCache) {
+ // Evict oldest entry if cache is full (LRU eviction)
+ if (agentCache.size >= MAX_AGENT_CACHE_SIZE) {
+ const firstKey = agentCache.keys().next().value;
+ if (firstKey !== undefined) {
+ const evictedAgent = agentCache.get(firstKey);
+ agentCache.delete(firstKey);
+ // Destroy the agent to release its sockets and prevent memory leaks
+ if (evictedAgent && typeof (evictedAgent as any).destroy === 'function') {
+ (evictedAgent as any).destroy();
+ }
+ }
+ }
+
+ agentCache.set(cacheKey, agent);
+ }
+
+ return agent;
+}
+
+/**
+ * Get or create a cached HTTPS agent.
+ * Reuses existing agents to enable SSL session caching.
+ * Uses LRU-style eviction when cache exceeds MAX_AGENT_CACHE_SIZE.
+ * Automatically wraps the agent class with timeline logging support.
+ */
+function getOrCreateHttpsAgent({
+ AgentClass,
+ options,
+ proxyUri = null,
+ timeline = null,
+ disableCache = false,
+ hostname = null
+}: {
+ AgentClass: any;
+ options: AgentOptions;
+ proxyUri?: string | null;
+ timeline?: TimelineEntry[] | null;
+ disableCache?: boolean;
+ hostname?: string | null;
+}): HttpAgent | HttpsAgent {
+ return getOrCreateAgentInternal(
+ AgentClass,
+ options,
+ proxyUri,
+ timeline,
+ getAgentCacheKey,
+ getTimelineAgentClass,
+ 'Reusing cached https agent',
+ disableCache,
+ hostname
+ );
+}
+
+/**
+ * Get or create a cached HTTP agent.
+ * Reuses existing agents to enable connection reuse.
+ * Uses LRU-style eviction when cache exceeds MAX_AGENT_CACHE_SIZE.
+ * Automatically wraps the agent class with timeline logging support.
+ */
+function getOrCreateHttpAgent({
+ AgentClass,
+ options,
+ proxyUri = null,
+ timeline = null,
+ disableCache = false,
+ hostname = null
+}: {
+ AgentClass: any;
+ options: HttpAgentOptions;
+ proxyUri?: string | null;
+ timeline?: TimelineEntry[] | null;
+ disableCache?: boolean;
+ hostname?: string | null;
+}): HttpAgent {
+ return getOrCreateAgentInternal(
+ AgentClass,
+ options,
+ proxyUri,
+ timeline,
+ getHttpAgentCacheKey,
+ getTimelineHttpAgentClass,
+ 'Reusing cached http agent',
+ disableCache,
+ hostname
+ ) as HttpAgent;
+}
+
+/**
+ * Clear the agent cache. Useful for testing or when SSL configuration changes.
+ * Destroys all cached agents to properly release their sockets.
+ */
+function clearAgentCache(): void {
+ for (const agent of agentCache.values()) {
+ if (agent && typeof (agent as any).destroy === 'function') {
+ (agent as any).destroy();
+ }
+ }
+ agentCache.clear();
+}
+
+/**
+ * Get the current size of the agent cache.
+ */
+function getAgentCacheSize(): number {
+ return agentCache.size;
+}
+
+export { getOrCreateHttpsAgent, getOrCreateHttpAgent, clearAgentCache, getAgentCacheSize };
diff --git a/packages/bruno-requests/src/utils/http-https-agents.ts b/packages/bruno-requests/src/utils/http-https-agents.ts
index 8671779a2..3ea8969db 100644
--- a/packages/bruno-requests/src/utils/http-https-agents.ts
+++ b/packages/bruno-requests/src/utils/http-https-agents.ts
@@ -1,5 +1,6 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
+import http from 'node:http';
import https from 'node:https';
import type { Agent as HttpAgent } from 'node:http';
import type { Agent as HttpsAgent } from 'node:https';
@@ -10,6 +11,8 @@ import { HttpProxyAgent } from 'http-proxy-agent';
import { isEmpty, get, isUndefined, isNull } from 'lodash';
import { getCACertificates } from './ca-cert';
import { transformProxyConfig } from './proxy-util';
+import { getOrCreateHttpsAgent, getOrCreateHttpAgent } from './agent-cache';
+import type { TimelineEntry } from './timeline-agent';
const DEFAULT_PORTS: Record = {
ftp: 21,
@@ -93,6 +96,7 @@ type ConfigOptions = {
shouldUseCustomCaCertificate: boolean;
customCaCertificateFilePath?: string;
shouldKeepDefaultCaCertificates: boolean;
+ cacheSslSession?: boolean;
};
type GetCertsAndProxyConfigParams = {
@@ -120,6 +124,8 @@ type CreateAgentsParams = {
certsConfig: CertsConfig;
httpsAgentRequestFields: HttpsAgentRequestFields;
systemProxyConfig?: SystemProxyConfig;
+ timeline?: TimelineEntry[];
+ disableCache?: boolean;
};
type GetHttpHttpsAgentsParams = {
@@ -132,6 +138,7 @@ type GetHttpHttpsAgentsParams = {
collectionLevelProxy?: ProxyConfig;
appLevelProxyConfig?: Record;
systemProxyConfig?: SystemProxyConfig;
+ timeline?: TimelineEntry[];
};
/**
@@ -188,9 +195,21 @@ const shouldUseProxy = (url: string | undefined, proxyBypass: string | undefined
};
/**
- * Patched version of HttpsProxyAgent to get around a bug that ignores options
- * such as ca and rejectUnauthorized when upgrading the proxied socket to TLS:
- * https://github.com/TooTallNate/proxy-agents/issues/194
+ * Options that should be forwarded from the constructor to the target TLS upgrade.
+ * The upstream HttpsProxyAgent (https://github.com/TooTallNate/proxy-agents/issues/194)
+ * ignores constructor options when upgrading the tunneled socket to TLS for the
+ * target server. This list covers client certificates, verification, and secure context.
+ */
+const TARGET_TLS_OPTIONS = ['cert', 'key', 'pfx', 'passphrase', 'rejectUnauthorized', 'secureContext'] as const;
+
+/**
+ * Patched version of HttpsProxyAgent that correctly handles TLS options for
+ * both the proxy connection and the target server connection.
+ *
+ * This patch forwards client certificate options, rejectUnauthorized, and
+ * secureContext to the target TLS upgrade. The agent-cache layer converts raw
+ * `ca` to a secureContext (via addCACert) before construction, so custom CAs
+ * are added on top of the OpenSSL defaults rather than replacing them.
*/
class PatchedHttpsProxyAgent extends HttpsProxyAgent {
private constructorOpts: any;
@@ -201,8 +220,18 @@ class PatchedHttpsProxyAgent extends HttpsProxyAgent {
}
async connect(req: any, opts: any) {
- const combinedOpts = { ...this.constructorOpts, ...opts };
- return super.connect(req, combinedOpts);
+ const targetOpts = { ...opts };
+
+ // Forward TLS options to the target TLS upgrade
+ if (this.constructorOpts) {
+ for (const key of TARGET_TLS_OPTIONS) {
+ if (key in this.constructorOpts) {
+ targetOpts[key] = this.constructorOpts[key];
+ }
+ }
+ }
+
+ return super.connect(req, targetOpts);
}
}
@@ -336,13 +365,24 @@ const getCertsAndProxyConfig = ({
return { proxyMode, proxyConfig, certsConfig };
};
+function extractHostname(url: string | undefined): string | null {
+ if (!url) return null;
+ try {
+ return new URL(url).hostname || null;
+ } catch {
+ return null;
+ }
+}
+
function createAgents({
requestUrl,
proxyMode,
proxyConfig,
systemProxyConfig,
certsConfig,
- httpsAgentRequestFields
+ httpsAgentRequestFields,
+ timeline,
+ disableCache = true
}: CreateAgentsParams): AgentResult {
// Ensure TLS options are properly set
const tlsOptions: TlsOptions = {
@@ -358,13 +398,19 @@ function createAgents({
let httpAgent: HttpAgent | undefined;
let httpsAgent: HttpsAgent | HttpsProxyAgent | SocksProxyAgent | undefined;
+ // Determine if this is an HTTPS request
+ const isHttpsRequest = requestUrl ? requestUrl.startsWith('https:') : true;
+
+ // Extract hostname for per-host agent caching (enables TLS session reuse per host)
+ const hostname = extractHostname(requestUrl);
+
if (proxyMode === 'on') {
const shouldProxy = shouldUseProxy(requestUrl, get(proxyConfig, 'bypassProxy', ''));
if (shouldProxy) {
const proxyProtocol = get(proxyConfig, 'protocol');
const proxyHostname = get(proxyConfig, 'hostname');
const proxyPort = get(proxyConfig, 'port');
- const proxyAuthEnabled = get(proxyConfig, 'auth.enabled', false);
+ const proxyAuthEnabled = !get(proxyConfig, 'auth.disabled', false);
const socksEnabled = proxyProtocol && proxyProtocol.includes('socks');
if (!proxyProtocol || !proxyHostname) {
@@ -381,16 +427,31 @@ function createAgents({
proxyUri = `${proxyProtocol}://${proxyHostname}${uriPort}`;
}
+ // When the proxy itself uses HTTPS, the agent connecting to it needs TLS options
+ // (e.g., ca certs) even for plain HTTP requests
+ const isHttpsProxy = proxyProtocol === 'https';
+ const httpProxyAgentOptions = isHttpsProxy ? { keepAlive: true, ...tlsOptions } : { keepAlive: true };
+
+ // Only set the agent needed for the request protocol
if (socksEnabled) {
- httpAgent = new SocksProxyAgent(proxyUri);
- httpsAgent = new SocksProxyAgent(proxyUri, tlsOptions as any);
+ if (isHttpsRequest) {
+ httpsAgent = getOrCreateHttpsAgent({ AgentClass: SocksProxyAgent, options: tlsOptions as any, proxyUri, timeline: timeline || null, disableCache, hostname }) as HttpsAgent;
+ } else {
+ httpAgent = getOrCreateHttpAgent({ AgentClass: SocksProxyAgent, options: httpProxyAgentOptions as any, proxyUri, timeline: timeline || null, disableCache, hostname });
+ }
} else {
- httpAgent = new HttpProxyAgent(proxyUri);
- httpsAgent = new PatchedHttpsProxyAgent(proxyUri, tlsOptions);
+ if (isHttpsRequest) {
+ httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions as any, proxyUri, timeline: timeline || null, disableCache, hostname }) as HttpsAgent;
+ } else {
+ httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: httpProxyAgentOptions as any, proxyUri, timeline: timeline || null, disableCache, hostname });
+ }
}
} else {
- // If proxy should not be used, set default HTTPS agent
- httpsAgent = new https.Agent(tlsOptions as any);
+ // If proxy should not be used, only set HTTPS agent for HTTPS requests
+ if (isHttpsRequest) {
+ httpsAgent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: tlsOptions as any, timeline: timeline || null, disableCache, hostname }) as HttpsAgent;
+ }
+ // HTTP requests without proxy don't need a custom agent
}
} else if (proxyMode === 'system') {
const http_proxy = get(systemProxyConfig, 'http_proxy');
@@ -399,28 +460,32 @@ function createAgents({
const shouldUseSystemProxy = shouldUseProxy(requestUrl, no_proxy || '');
if (shouldUseSystemProxy) {
try {
- if (http_proxy?.length) {
- new URL(http_proxy);
- httpAgent = new HttpProxyAgent(http_proxy);
+ if (http_proxy?.length && !isHttpsRequest) {
+ const parsedHttpProxy = new URL(http_proxy);
+ const isHttpsSystemProxy = parsedHttpProxy.protocol === 'https:';
+ const systemHttpProxyAgentOptions = isHttpsSystemProxy ? { keepAlive: true, ...tlsOptions } : { keepAlive: true };
+ httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: systemHttpProxyAgentOptions as any, proxyUri: http_proxy, timeline: timeline || null, disableCache, hostname });
}
} catch (error) {
throw new Error('Invalid system http_proxy');
}
try {
- if (https_proxy?.length) {
+ if (https_proxy?.length && isHttpsRequest) {
new URL(https_proxy);
- httpsAgent = new PatchedHttpsProxyAgent(https_proxy, tlsOptions as any);
- } else {
- httpsAgent = new https.Agent(tlsOptions as any);
+ httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions as any, proxyUri: https_proxy, timeline: timeline || null, disableCache, hostname }) as HttpsAgent;
}
} catch (error) {
throw new Error('Invalid system https_proxy');
}
- } else {
- httpsAgent = new https.Agent(tlsOptions as any);
}
- } else {
- httpsAgent = new https.Agent(tlsOptions as any);
+ }
+
+ if (!httpAgent && !httpsAgent) {
+ if (isHttpsRequest) {
+ httpsAgent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: tlsOptions as any, timeline: timeline || null, disableCache, hostname }) as HttpsAgent;
+ } else {
+ httpAgent = getOrCreateHttpAgent({ AgentClass: http.Agent, options: { keepAlive: true }, timeline: timeline || null, disableCache, hostname });
+ }
}
return { httpAgent, httpsAgent };
@@ -433,7 +498,8 @@ const getHttpHttpsAgents = async ({
collectionLevelProxy,
appLevelProxyConfig,
systemProxyConfig,
- options
+ options,
+ timeline
}: GetHttpHttpsAgentsParams): Promise => {
const { proxyMode, proxyConfig, certsConfig } = getCertsAndProxyConfig({
requestUrl,
@@ -460,7 +526,9 @@ const getHttpHttpsAgents = async ({
proxyConfig,
systemProxyConfig,
certsConfig,
- httpsAgentRequestFields
+ httpsAgentRequestFields,
+ timeline,
+ disableCache: !options.cacheSslSession
});
return { httpAgent, httpsAgent };
diff --git a/packages/bruno-requests/src/utils/timeline-agent.ts b/packages/bruno-requests/src/utils/timeline-agent.ts
new file mode 100644
index 000000000..e1d9e1a84
--- /dev/null
+++ b/packages/bruno-requests/src/utils/timeline-agent.ts
@@ -0,0 +1,309 @@
+import http from 'node:http';
+import https from 'node:https';
+
+type TimelineEntry = {
+ timestamp: Date;
+ type: 'info' | 'tls' | 'error';
+ message: string;
+};
+
+type CaCertificatesCount = {
+ root?: number;
+ system?: number;
+ extra?: number;
+ custom?: number;
+};
+
+type AgentOptions = {
+ rejectUnauthorized?: boolean;
+ ca?: string | string[] | Buffer | Buffer[];
+ cert?: string | Buffer;
+ key?: string | Buffer;
+ pfx?: string | Buffer;
+ passphrase?: string;
+ minVersion?: string;
+ secureProtocol?: string;
+ keepAlive?: boolean;
+ ALPNProtocols?: string[];
+ caCertificatesCount?: CaCertificatesCount;
+ proxy?: string;
+ secureContext?: any;
+};
+
+type AgentClass = new (options: AgentOptions, timeline?: TimelineEntry[]) => https.Agent;
+type ProxyAgentClass = new (proxyUri: string, options?: AgentOptions) => https.Agent;
+
+type HttpAgentOptions = {
+ keepAlive?: boolean;
+ proxy?: string;
+};
+
+type HttpAgentClass = new (options: HttpAgentOptions, timeline?: TimelineEntry[]) => http.Agent;
+type HttpProxyAgentClass = new (proxyUri: string, options?: HttpAgentOptions) => http.Agent;
+
+/**
+ * Creates a timeline-aware agent class that logs TLS connection events.
+ * The returned class wraps the base agent and adds timeline logging for:
+ * - SSL validation status
+ * - Proxy usage
+ * - ALPN protocol negotiation
+ * - CA certificates info
+ * - DNS lookups
+ * - Connection establishment
+ * - TLS handshake details
+ * - Server certificate info
+ */
+function createTimelineAgentClass(BaseAgentClass: T): AgentClass {
+ return class TimelineAgent extends (BaseAgentClass as any) {
+ timeline: TimelineEntry[];
+ alpnProtocols: string[];
+ caProvided: boolean;
+ caCertificatesCount: CaCertificatesCount;
+
+ /**
+ * Helper method to log entries to the timeline.
+ */
+ private log(type: 'info' | 'tls' | 'error', message: string): void {
+ this.timeline.push({
+ timestamp: new Date(),
+ type,
+ message
+ });
+ }
+
+ constructor(options: AgentOptions, timeline?: TimelineEntry[]) {
+ const caCertificatesCount = options.caCertificatesCount || {};
+ const optionsCopy = { ...options };
+ delete optionsCopy.caCertificatesCount;
+
+ // For proxy agents, the first argument is the proxy URI and the second is options
+ if (optionsCopy?.proxy) {
+ const { proxy: proxyUri, ...agentOptions } = optionsCopy;
+ // Ensure TLS options are properly set
+ const tlsOptions = {
+ ...agentOptions,
+ rejectUnauthorized: agentOptions.rejectUnauthorized ?? true
+ };
+ super(proxyUri, tlsOptions);
+ this.timeline = Array.isArray(timeline) ? timeline : [];
+ this.alpnProtocols = tlsOptions.ALPNProtocols || ['h2', 'http/1.1'];
+ this.caProvided = !!(tlsOptions.ca || tlsOptions.secureContext);
+
+ // Log TLS verification status and proxy details
+ this.log('info', `SSL validation: ${tlsOptions.rejectUnauthorized ? 'enabled' : 'disabled'}`);
+ this.log('info', `Using proxy: ${proxyUri}`);
+ } else {
+ // This is a regular HTTPS agent case
+ const tlsOptions = {
+ ...optionsCopy,
+ rejectUnauthorized: optionsCopy.rejectUnauthorized ?? true
+ };
+ super(tlsOptions);
+ this.timeline = Array.isArray(timeline) ? timeline : [];
+ this.alpnProtocols = optionsCopy.ALPNProtocols || ['h2', 'http/1.1'];
+ this.caProvided = !!(optionsCopy.ca || optionsCopy.secureContext);
+
+ // Log TLS verification status
+ this.log('info', `SSL validation: ${tlsOptions.rejectUnauthorized ? 'enabled' : 'disabled'}`);
+ }
+
+ this.caCertificatesCount = caCertificatesCount;
+ }
+
+ createConnection(options: any, callback: any) {
+ const { host, port } = options;
+
+ // Capture the current timeline reference to avoid race conditions
+ // when multiple concurrent requests reuse the same cached agent
+ const timeline = this.timeline;
+ const log = (type: 'info' | 'tls' | 'error', message: string): void => {
+ timeline.push({
+ timestamp: new Date(),
+ type,
+ message
+ });
+ };
+
+ // Log ALPN protocols offered
+ if (this.alpnProtocols && this.alpnProtocols.length > 0) {
+ log('tls', `ALPN: offers ${this.alpnProtocols.join(', ')}`);
+ }
+
+ const rootCerts = this.caCertificatesCount.root || 0;
+ const systemCerts = this.caCertificatesCount.system || 0;
+ const extraCerts = this.caCertificatesCount.extra || 0;
+ const customCerts = this.caCertificatesCount.custom || 0;
+
+ log('tls', `CA Certificates: ${rootCerts} root, ${systemCerts} system, ${extraCerts} extra, ${customCerts} custom`);
+
+ // Log "Trying host:port..."
+ log('info', `Trying ${host}:${port}...`);
+
+ let socket: any;
+ try {
+ socket = super.createConnection(options, callback);
+ } catch (error: any) {
+ log('error', `Error creating connection: ${error.message}`);
+ error.timeline = timeline;
+ throw error;
+ }
+
+ // Attach event listeners to the socket
+ socket?.on('lookup', (err: Error | null, address: string, family: number, host: string) => {
+ if (err) {
+ log('error', `DNS lookup error for ${host}: ${err.message}`);
+ } else {
+ log('info', `DNS lookup: ${host} -> ${address}`);
+ }
+ });
+
+ socket?.on('connect', () => {
+ const address = socket.remoteAddress || host;
+ const remotePort = socket.remotePort || port;
+
+ log('info', `Connected to ${host} (${address}) port ${remotePort}`);
+ });
+
+ socket?.on('secureConnect', () => {
+ const protocol = socket.getProtocol?.() || 'SSL/TLS';
+ const cipher = socket.getCipher?.();
+ const cipherSuite = cipher ? `${cipher.name} (${cipher.version})` : 'Unknown cipher';
+
+ log('tls', `SSL connection using ${protocol} / ${cipherSuite}`);
+
+ // ALPN protocol
+ const alpnProtocol = socket.alpnProtocol || 'None';
+ log('tls', `ALPN: server accepted ${alpnProtocol}`);
+
+ // Server certificate
+ const cert = socket.getPeerCertificate?.(true);
+ if (cert) {
+ log('tls', `Server certificate:`);
+ if (cert.subject) {
+ log('tls', ` subject: ${Object.entries(cert.subject).map(([k, v]) => `${k}=${v}`).join(', ')}`);
+ }
+ if (cert.valid_from) {
+ log('tls', ` start date: ${cert.valid_from}`);
+ }
+ if (cert.valid_to) {
+ log('tls', ` expire date: ${cert.valid_to}`);
+ }
+ if (cert.subjectaltname) {
+ log('tls', ` subjectAltName: ${cert.subjectaltname}`);
+ }
+ if (cert.issuer) {
+ log('tls', ` issuer: ${Object.entries(cert.issuer).map(([k, v]) => `${k}=${v}`).join(', ')}`);
+ }
+
+ // SSL certificate verification status
+ if (socket.authorized !== false) {
+ log('tls', `SSL certificate verify ok.`);
+ } else {
+ log('tls', `SSL certificate verification skipped (rejectUnauthorized: false).`);
+ }
+ }
+ });
+
+ socket?.on('error', (err: Error) => {
+ log('error', `Socket error: ${err.message}`);
+ });
+
+ return socket;
+ }
+ } as unknown as AgentClass;
+}
+
+/**
+ * Creates a timeline-aware HTTP agent class that logs connection events.
+ * The returned class wraps the base HTTP agent and adds timeline logging for:
+ * - Proxy usage (when applicable)
+ * - DNS lookups
+ * - Connection establishment
+ * - Errors
+ *
+ * This is a simplified version of createTimelineAgentClass for HTTP (non-TLS) connections.
+ */
+function createTimelineHttpAgentClass(BaseAgentClass: T): HttpAgentClass {
+ return class TimelineHttpAgent extends (BaseAgentClass as any) {
+ timeline: TimelineEntry[];
+
+ /**
+ * Helper method to log entries to the timeline.
+ */
+ private log(type: 'info' | 'tls' | 'error', message: string): void {
+ this.timeline.push({
+ timestamp: new Date(),
+ type,
+ message
+ });
+ }
+
+ constructor(options: HttpAgentOptions, timeline?: TimelineEntry[]) {
+ const optionsCopy = { ...options };
+
+ // For proxy agents, the first argument is the proxy URI and the second is options
+ if (optionsCopy?.proxy) {
+ const { proxy: proxyUri, ...agentOptions } = optionsCopy;
+ super(proxyUri, agentOptions);
+ this.timeline = Array.isArray(timeline) ? timeline : [];
+
+ // Log proxy details
+ this.log('info', `Using proxy: ${proxyUri}`);
+ } else {
+ super(optionsCopy);
+ this.timeline = Array.isArray(timeline) ? timeline : [];
+ }
+ }
+
+ createConnection(options: any, callback: any) {
+ const { host, port } = options;
+
+ // Capture the current timeline reference to avoid race conditions
+ // when multiple concurrent requests reuse the same cached agent
+ const timeline = this.timeline;
+ const log = (type: 'info' | 'tls' | 'error', message: string): void => {
+ timeline.push({
+ timestamp: new Date(),
+ type,
+ message
+ });
+ };
+
+ // Log "Trying host:port..."
+ log('info', `Trying ${host}:${port}...`);
+
+ let socket: any;
+ try {
+ socket = super.createConnection(options, callback);
+ } catch (error: any) {
+ log('error', `Error creating connection: ${error.message}`);
+ error.timeline = timeline;
+ throw error;
+ }
+
+ // Attach event listeners to the socket
+ socket?.on('lookup', (err: Error | null, address: string, family: number, host: string) => {
+ if (err) {
+ log('error', `DNS lookup error for ${host}: ${err.message}`);
+ } else {
+ log('info', `DNS lookup: ${host} -> ${address}`);
+ }
+ });
+
+ socket?.on('connect', () => {
+ const address = socket.remoteAddress || host;
+ const remotePort = socket.remotePort || port;
+
+ log('info', `Connected to ${host} (${address}) port ${remotePort}`);
+ });
+
+ socket?.on('error', (err: Error) => {
+ log('error', `Socket error: ${err.message}`);
+ });
+
+ return socket;
+ }
+ } as unknown as HttpAgentClass;
+}
+
+export { createTimelineAgentClass, createTimelineHttpAgentClass, TimelineEntry, AgentOptions, HttpAgentOptions, CaCertificatesCount, AgentClass, HttpAgentClass, ProxyAgentClass, HttpProxyAgentClass };