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 };