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