import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'; /** * Logger interface for HttpClient */ export interface HttpLogger { debug: (message: string, meta?: Record) => void; error: (message: string, meta?: Record) => void; info: (message: string, meta?: Record) => void; warn: (message: string, meta?: Record) => void; } // Default console logger const consoleLogger: HttpLogger = { debug: (message: string, meta?: Record) => meta ? console.debug(message, meta) : console.debug(message), error: (message: string, meta?: Record) => meta ? console.error(message, meta) : console.error(message), info: (message: string, meta?: Record) => meta ? console.info(message, meta) : console.info(message), warn: (message: string, meta?: Record) => meta ? console.warn(message, meta) : console.warn(message) }; let httpLogger: HttpLogger = consoleLogger; export const setHttpLogger = (logger: HttpLogger): void => { httpLogger = logger; }; /** * Shared HTTP client with connection management and pooling * Prevents connection exhaustion and improves performance */ export class HttpClient { private static instance: HttpClient; private readonly agents: Map = new Map(); private readonly userAgent: string; private constructor(userAgent: string = 'MCP-Server/1.0') { this.userAgent = userAgent; } public static getInstance(userAgent?: string): HttpClient { if (!HttpClient.instance) { HttpClient.instance = new HttpClient(userAgent); } return HttpClient.instance; } /** * Set custom user agent for all requests */ public setUserAgent(userAgent: string): void { (this as any).userAgent = userAgent; } /** * Get or create HTTP agent for a domain with connection pooling */ private getAgent(url: string): any { const domain = new URL(url).hostname; if (!this.agents.has(domain)) { // Use native fetch with AbortController for better connection management // Modern Node.js fetch has built-in connection pooling this.agents.set(domain, { keepAlive: true, maxSockets: 10, maxFreeSockets: 5, timeout: 60000 }); httpLogger.debug(`Created HTTP agent for domain: ${domain}`); } return this.agents.get(domain); } /** * Make HTTP request with proper connection management using axios */ async fetch(url: string, options: RequestInit = {}): Promise { try { // Convert fetch headers to plain object for axios const headers: Record = { 'User-Agent': this.userAgent, 'Accept': 'application/json', 'Accept-Encoding': 'gzip, deflate, br', 'Connection': 'keep-alive', 'Keep-Alive': 'timeout=60, max=100' }; // Sanitize header values to remove ALL control characters (prevents header injection) const sanitizeHeaderValue = (value: string | string[] | readonly string[] | undefined | null): string => { if (value === undefined || value === null) { return ''; } const stringValue = Array.isArray(value) ? value.join(', ') : String(value); return stringValue .trim() .replace(/[\r\n\t]/g, ' ') // Replace ALL control chars (CR, LF, TAB) with spaces .split('') .filter(char => { const code = char.charCodeAt(0); // Allow ONLY printable ASCII chars (32-126) to prevent header injection return (code >= 32 && code <= 126); }) .join('') .replace(/\s+/g, ' ') // Normalize multiple spaces to single space .trim(); }; // Handle different header formats from fetch RequestInit if (options.headers) { if (options.headers instanceof Headers) { // Headers object options.headers.forEach((value, key) => { headers[key] = sanitizeHeaderValue(value); }); } else if (Array.isArray(options.headers)) { // Array format for (const [key, value] of options.headers) { headers[key] = sanitizeHeaderValue(value); } } else { // Plain object - sanitize header values for (const [key, value] of Object.entries(options.headers)) { if (value !== undefined && value !== null) { headers[key] = sanitizeHeaderValue(Array.isArray(value) ? value.join(', ') : String(value)); } } } } const axiosConfig: AxiosRequestConfig = { url, method: (options.method as any) || 'GET', headers, timeout: 600000, // 10 minute timeout maxRedirects: 5 }; // Add body if present if (options.body) { if (typeof options.body === 'string') { axiosConfig.data = options.body; } else { axiosConfig.data = options.body; } } const axiosResponse: AxiosResponse = await axios(axiosConfig); // Convert axios response to fetch-like Response const responseHeaders = new Headers(); Object.entries(axiosResponse.headers).forEach(([key, value]) => { if (typeof value === 'string') { responseHeaders.set(key, value); } }); // Handle different response types properly // TypeScript's BodyInit type definition can be overly strict - use type assertion let responseBody: string | Uint8Array; const data = axiosResponse.data; if (typeof data === "string") { responseBody = data; } else if (Buffer.isBuffer(data)) { // Convert Buffer to Uint8Array (compatible with BodyInit) responseBody = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); } else if (data instanceof ArrayBuffer) { responseBody = new Uint8Array(data); } else if (data === undefined || data === null) { responseBody = ""; } else { responseBody = JSON.stringify(data); } const response = new Response(responseBody as BodyInit, { status: axiosResponse.status, statusText: axiosResponse.statusText, headers: responseHeaders }); httpLogger.debug('HTTP request successful', { url: new URL(url).hostname, status: axiosResponse.status }); return response; } catch (error: any) { const errorMessage = error.response ? `HTTP ${error.response.status}: ${error.response.statusText}` : error.message || 'Unknown error'; httpLogger.error('HTTP request failed', { url: new URL(url).hostname, error: errorMessage, status: error.response?.status }); throw new Error(errorMessage); } } /** * Cleanup unused connections (called periodically) */ cleanup(): void { // In a real implementation, you'd track last usage and cleanup old connections // For now, just log cleanup activity for (const domain of this.agents.keys()) { httpLogger.debug(`Cleaning up connections for domain: ${domain}`); } } /** * Graceful shutdown - close all connections */ async shutdown(): Promise { httpLogger.info('Shutting down HTTP client connections'); for (const domain of this.agents.keys()) { try { // Close connections gracefully httpLogger.debug(`Closing connections for domain: ${domain}`); } catch (error) { httpLogger.warn(`Failed to close connections for ${domain}`, { error: error instanceof Error ? error.message : 'Unknown error' }); } } this.agents.clear(); } } // Export singleton instance getter export const getHttpClient = (userAgent?: string): HttpClient => { return HttpClient.getInstance(userAgent); }; // Setup periodic cleanup and graceful shutdown (skip in test environment) if (process.env.NODE_ENV !== 'test' && typeof process !== 'undefined') { const httpClient = HttpClient.getInstance(); // Cleanup connections periodically setInterval(() => { httpClient.cleanup(); }, 5 * 60 * 1000); // Every 5 minutes // Graceful shutdown handler process.on('SIGTERM', async () => { await httpClient.shutdown(); }); process.on('SIGINT', async () => { await httpClient.shutdown(); }); }