UNPKG

turbium

Version:

A fast and efficient HTTP/2 client library for Node.js with built-in connection pooling, DNS caching, circuit breaker, and request/response compression.

341 lines (292 loc) 12.3 kB
import http2 from 'http2-wrapper'; import { Transform } from 'stream'; import zlib from 'zlib'; import dns from 'dns'; import { LRUCache } from 'lru-cache'; const JSONDOC = { request: { url: "URL to make request to (string)", method: "HTTP method (GET, POST, PUT, etc.) (string) (default: GET)", headers: "Request headers (object)", body: "Request body (object or string)", data: "Request body - alias for body (object or string)", followRedirects: "Whether to follow redirects (boolean) (default: true)", maxRedirects: "Maximum number of redirects to follow (number) (default: 5)", options: "Additional HTTP/2 request options (object)", timeout: "Request timeout in ms (number) (default: 30000)", stream: "Whether to return a stream instead of buffered response (boolean) (default: false)", enableCompression: "Enable response compression handling (boolean) (default: true)", enableCaching: "Enable response caching (boolean) (default: false)", enableDNSCache: "Enable DNS caching (boolean) (default: true)", enableCircuitBreaker: "Enable circuit breaker (boolean) (default: false)", maxConcurrentRequests: "Maximum concurrent requests allowed (number, default: Infinity)", enableRequestCompression: "Enable request body compression (boolean) (default: false)", requestEncoding: "Encoding to use for request compression (gzip, deflate, br) (string) (default: gzip)" }, response: { data: "Server response (object or string)", status: "HTTP status code (number)", statusText: "HTTP status message (string)", headers: "Response headers (object)", config: "Request configuration that was used (object)", request: "Request instance (object)" } }; const CONNECTION_POOL = new Map(); const MAX_POOLED_CONNECTIONS = 100; const DEFAULT_TIMEOUT = 30000; const DNS_CACHE_TIMEOUT = 300000; const CONNECTION_TIMEOUT = 30000 const dnsCache = new Map(); const circuitBreakerStates = new Map(); const requestQueue = []; let currentConcurrentRequests = 0; const CACHE = new LRUCache({ max: 100 }); function decompressResponse(responseStream, headers) { const encoding = headers['content-encoding']; if (encoding === 'gzip') { return responseStream.pipe(zlib.createGunzip()); } else if (encoding === 'deflate') { return responseStream.pipe(zlib.createInflate()); } else if (encoding === 'br') { return responseStream.pipe(zlib.createBrotliDecompress()); } return responseStream; } function compressRequest(body, encoding) { if (encoding === 'gzip') { return zlib.gzipSync(body, { level: zlib.constants.Z_BEST_COMPRESSION }); } else if (encoding === 'deflate') { return zlib.deflateSync(body, { level: zlib.constants.Z_BEST_COMPRESSION }); } else if (encoding === 'br') { return zlib.brotliCompressSync(body, { level: zlib.constants.Z_BEST_COMPRESSION }); } return body; } function getPoolKey(url) { const urlObj = new URL(url); return `${urlObj.protocol}//${urlObj.host}`; } function getConnection(url) { const key = getPoolKey(url); if (!CONNECTION_POOL.has(key)) { if (CONNECTION_POOL.size >= MAX_POOLED_CONNECTIONS) { const now = Date.now(); for (const [k, client] of CONNECTION_POOL.entries()) { if (now - client.lastUsed > CONNECTION_TIMEOUT) { client.close(); CONNECTION_POOL.delete(k); } } } const client = http2.connect(key, { settings: { enablePush: false, initialWindowSize: 1024 * 1024, maxConcurrentStreams: 1000 } }); client.lastUsed = Date.now(); CONNECTION_POOL.set(key, client); client.on('error', () => CONNECTION_POOL.delete(key)); client.on('goaway', () => CONNECTION_POOL.delete(key)); } const client = CONNECTION_POOL.get(key); client.lastUsed = Date.now(); return client; } function resolveDNS(hostname) { const now = Date.now(); const cached = dnsCache.get(hostname); if (cached && now - cached.timestamp < DNS_CACHE_TIMEOUT) { return Promise.resolve(cached.address); } return new Promise((resolve, reject) => { dns.lookup(hostname, { family: 4 }, (err, address) => { if (err) return reject(err); dnsCache.set(hostname, { address, timestamp: now }); resolve(address); }); }); } function checkCircuitBreaker(url) { const state = circuitBreakerStates.get(url) || { failures: 0, lastFailureTime: 0 }; const currentTime = Date.now(); if (state.failures >= 5 && currentTime - state.lastFailureTime < 60000) { throw new Error(`Circuit breaker is active for ${url}`); } } function recordFailure(url) { const state = circuitBreakerStates.get(url) || { failures: 0, lastFailureTime: 0 }; state.failures++; state.lastFailureTime = Date.now(); circuitBreakerStates.set(url, state); } function handleRequestQueue() { if (requestQueue.length > 0 && currentConcurrentRequests < http2Client.config.maxConcurrentRequests) { const { config, resolve, reject } = requestQueue.shift(); currentConcurrentRequests++; http2Client.request(config).then(response => { currentConcurrentRequests--; resolve(response); handleRequestQueue(); }).catch(error => { currentConcurrentRequests--; reject(error); handleRequestQueue(); }); } } const http2Client = function (urlOrConfig, config = {}) { if (typeof urlOrConfig === 'string') { return http2Client.request({ url: urlOrConfig, ...config }); } return http2Client.request(urlOrConfig); }; http2Client.config = { enableCompression: true, enableCaching: false, enableDNSCache: true, enableCircuitBreaker: false, maxConcurrentRequests: Infinity, enableRequestCompression: false, requestEncoding: 'gzip' }; http2Client.request = (config) => { return new Promise((resolve, reject) => { const defaults = { method: 'GET', followRedirects: true, maxRedirects: 5, headers: {}, options: {}, timeout: DEFAULT_TIMEOUT, stream: false }; config = { ...defaults, ...http2Client.config, ...config }; if (config.enableDNSCache) { const hostname = new URL(config.url).hostname; resolveDNS(hostname).then(() => { executeRequest(config, resolve, reject); }).catch(reject); } else { executeRequest(config, resolve, reject); } }); }; function executeRequest(config, resolve, reject) { try { if (config.enableCircuitBreaker) checkCircuitBreaker(config.url); if (config.enableCaching && CACHE.has(config.url)) { return resolve(CACHE.get(config.url)); } const url = new URL(config.url); const client = getConnection(config.url); const options = { ':method': config.method.toUpperCase(), ':path': url.pathname + url.search, ...config.headers }; if (config.enableCompression) { options['accept-encoding'] = 'gzip, deflate, br'; } if (config.data && !options['content-type'] && typeof config.data === 'object') { options['content-type'] = 'application/json'; } if (config.data && config.enableRequestCompression) { options['content-encoding'] = config.requestEncoding; } const req = client.request(options); let timeoutId = setTimeout(() => { req.destroy(new Error(`Request timeout after ${config.timeout}ms`)); }, config.timeout); req.on('response', headers => { if (config.stream) { clearTimeout(timeoutId); const responseStream = decompressResponse(req, headers); return resolve(responseStream); } let stream = req; if (config.enableCompression) { stream = decompressResponse(req, headers); } let responseData = ''; // Initialize as an empty string let isBinary = false; // Flag to indicate binary data stream.on('data', chunk => { if (Buffer.isBuffer(chunk)) { // If it's a buffer, and we haven't determined it's binary yet if (!isBinary) { // Try to convert to string to check for non-printable characters const tempStr = chunk.toString(); if (/[\u0000-\u0008\u000E-\u001F\u007F-\u009F]/.test(tempStr)) { // Contains non-printable characters, treat as binary isBinary = true; responseData = Buffer.concat([Buffer.isBuffer(responseData) ? responseData : Buffer.from(responseData), chunk]); // Convert responseData to buffer for concatenation } else { // Likely text, append as string responseData += tempStr; } } else { // Already determined it's binary, concatenate as buffer responseData = Buffer.concat([responseData, chunk]); } } else if (typeof chunk === 'string') { // If it's already a string, just append it responseData += chunk; } }); stream.on('end', () => { clearTimeout(timeoutId); // No need to convert to string here, 'responseData' should already be a string let parsedData = responseData; if (!isBinary) { if (headers['content-type']?.includes('application/json')) { try { parsedData = JSON.parse(responseData); } catch (e) { // If JSON parsing fails, keep it as a string } } } const response = { data: parsedData, status: headers[':status'], headers, config, request: req }; if (config.enableCaching) { CACHE.set(config.url, response); } resolve(response); }); }); req.on('error', error => { clearTimeout(timeoutId); if (config.enableCircuitBreaker) recordFailure(config.url); reject(error); }); if (config.data) { let data = config.data; if (typeof data === 'object') data = JSON.stringify(data, null, 0); if (config.enableRequestCompression) { data = compressRequest(data, config.requestEncoding); } req.write(data); } req.end(); } catch (error) { reject(error); } } ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'].forEach(method => { http2Client[method] = (url, data, config = {}) => { return http2Client.request({ ...config, url, method, data: ['get', 'head', 'options'].includes(method) ? undefined : data }); }; }); export default http2Client;