fix: enable SSL session caching and HTTP agent reuse for faster consecutive requests (#6987)

* fix: enable SSL session caching for faster consecutive requests (#6929)

* fix: enable SSL session caching for faster consecutive requests

Previously, Bruno created a new HTTPS agent for every request, which meant
SSL/TLS sessions couldn't be reused. This caused the full TLS handshake
(~450ms) to run on every request, even to the same endpoint.

Changes:
- Add agent caching based on TLS configuration (certs, proxy, SSL options)
- Reuse cached agents for requests with matching configuration
- SSL sessions are now cached and reused, significantly reducing
  response time for consecutive requests to the same host

The fix maintains backward compatibility:
- Timeline logging moved to setup phase (before agent creation)
- Proxy and SSL validation behavior unchanged
- Added clearAgentCache() for testing and configuration changes

Fixes #5574

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address review feedback for SSL session caching

- Add passphrase to cache key to prevent incorrect agent reuse
- Add MAX_AGENT_CACHE_SIZE (100) with LRU-style eviction
- Use consistent node: prefix for crypto import

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: lohit <lohit@usebruno.com>

* feat(bruno-requests): add timeline agent for TLS event logging

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(bruno-requests): add agent cache for SSL session reuse

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test(bruno-requests): add tests for agent cache

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(bruno-requests): integrate agent cache into http-https-agents

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(bruno-electron): use shared agent cache from bruno-requests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(bruno-cli): use agent cache for SSL session reuse

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(bruno-requests): add HTTP agent timeline support

Add createTimelineHttpAgentClass for logging HTTP connection events
including proxy usage, DNS lookups, and connection establishment.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(bruno-requests): extract shared agent caching logic

Add getOrCreateAgentInternal helper to reduce code duplication
between getOrCreateAgent and getOrCreateHttpAgent functions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(bruno-requests): use HTTP agent cache for connection reuse

Export getOrCreateHttpAgent and use it in http-https-agents for
HTTP requests to enable connection pooling.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(bruno-cli): improve HTTP agent handling and error logging

- Use { keepAlive: true } instead of tlsOptions for HTTP agents
- Add warning log for system proxy configuration errors
- Fix brace style consistency

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(bruno-electron): improve HTTP agent handling

- Use { keepAlive: true } instead of tlsOptions for HTTP agents
- Fix brace style consistency
- Add missing newline at EOF

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(bruno-requests): address code review findings for agent caching

- Fix Buffer hashing bug: properly handle Buffer values in hashValue()
- Add CA array support: new hashCaValue() handles string[] | Buffer[]
- Fix timeline race condition: capture timeline reference in closure
  at createConnection start to isolate concurrent requests
- Fix SSL verify message: check socket.authorized for accurate status
- Fix HTTP/HTTPS agent logic: only set httpsAgent for HTTPS requests
- Add tests for concurrent requests timeline isolation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(bruno-requests): log when reusing cached agent

- HTTPS agents: "Reusing cached agent (SSL session reuse enabled)"
- HTTP agents: "Reusing cached agent (connection reuse enabled)"

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(preferences): add cache.httpHttpsAgents.enabled preference

* feat(agent-cache): add disableCache option to getOrCreateAgent

* feat(proxy-util): respect httpHttpsAgents cache preference

* refactor(agent-cache): use named props for getOrCreateAgent and getOrCreateHttpAgent

* feat(ipc): add renderer:clear-http-https-agent-cache handler

* feat(redux): add cache.httpHttpsAgents preferences to initial state

* feat(ui): add Cache tab to Preferences

* feat(cli): add --disable-http-https-agents-cache flag

* refactor(cache): replace window.ipcRenderer calls with redux actions

Add getCacheStats, purgeCache, and clearHttpHttpsAgentCache thunks to
the app slice. Update the Cache preferences component to dispatch these
actions instead of calling window.ipcRenderer directly.

Also move handleSave and handleSaveRef above useFormik to fix declaration
order — onSubmit closes over handleSaveRef, so the ref must be initialized
before useFormik is called.

* fix: tests

* fix(cache): thread disableCache and hostname through all agent-creation paths

- Forward disableHttpHttpsAgentsCache through getHttpHttpsAgents → createAgents
  so OAuth2 token requests and bru.sendRequest honour the CLI flag
- Add hostname to agent cache keys (getAgentCacheKey, getHttpAgentCacheKey)
  for per-host TLS session reuse; extract hostname at every call site in
  run-single-request.js, proxy-util.js, and http-https-agents.ts
- Add extractHostname helper in http-https-agents.ts to safely parse hostnames
- Add test coverage for cert, key, pfx, passphrase, and hostname cache-key
  differentiation in agent-cache.spec.ts

* refactor(cache): rename getOrCreateAgent to getOrCreateHttpsAgent

* refactor: simplify UI labels, optimize agent timeline wrapping, silence proxy errors

* fix: tests

* fix(proxy): fix proxy agent construction and CA cert handling

Three fixes:

1. Proxy agents (HttpsProxyAgent, HttpProxyAgent, SocksProxyAgent) expect
   (proxyUri, options) constructor signature, but the agent cache was packing
   proxyUri into options as a single argument. Fixed the non-timeline code
   path in getOrCreateAgentInternal.

2. HTTP requests through an HTTPS proxy need TLS options (ca certs) to
   validate the proxy's certificate. All getOrCreateHttpAgent call sites
   now pass TLS options when the proxy protocol is HTTPS.

3. Setting the `ca` option on any Node.js TLS connection replaces the
   default OpenSSL trust store entirely. CAs only in the OpenSSL default
   trust store (e.g. /etc/ssl/cert.pem) but not in tls.rootCertificates
   were lost. Fixed by converting `ca` to a secureContext via addCACert(),
   which appends custom CAs on top of the OpenSSL defaults instead of
   replacing them.

Also simplified PatchedHttpsProxyAgent to selectively forward only the
relevant TLS options (cert, key, pfx, passphrase, rejectUnauthorized,
secureContext) to the target TLS upgrade instead of blindly merging all
constructor options.

* fix(tls): load client certs into secureContext to prevent silent drop

Add Cache tab to Preferences UI

* fix(proxy): align proxy auth check to use auth.disabled field consistently

* refactor(cache): rename CLI flag to --cache-ssl-session and disable caching by default

- Rename --disable-http-https-agents-cache to --cache-ssl-session (opt-in)
- Rename disableHttpHttpsAgentsCache to cacheSslSession across CLI and bruno-requests
- Default caching to disabled in both bruno-electron and bruno-cli
- Add cacheSslSession to buildCertsAndProxyConfig for bru.sendRequest
- Update Preferences UI labels to "Cache SSL Session"

* refactor(cache): rename httpHttpsAgents to sslSession across preferences and UI

* refactor(cache): remove unused getCacheStats and purgeCache IPC actions

---------

Co-authored-by: karthik <47263234+kxbnb@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
lohit
2026-03-05 13:16:20 +00:00
committed by GitHub
parent 39f8ce2a2f
commit f5e437adaf
16 changed files with 1523 additions and 355 deletions

View File

@@ -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<string, HttpAgent | HttpsAgent>();
/**
* 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<any, AgentClass>();
/**
* Cache for timeline-wrapped HTTP agent classes.
* Prevents creating new class definitions on every call.
*/
const timelineHttpClassCache = new WeakMap<any, HttpAgentClass>();
/**
* 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<any, number>();
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<string, tls.SecureContext>();
/**
* 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<T extends AgentOptions | HttpAgentOptions>(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<string, any> = {};
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<T> = (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<TOptions extends HttpAgentOptions>(
BaseAgentClass: any,
options: TOptions,
proxyUri: string | null,
timeline: TimelineEntry[] | null,
getCacheKey: CacheKeyFn<TOptions>,
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 };