stock-bot/libs/http-client/src/BunHttpClient.ts

199 lines
6.2 KiB
TypeScript

import { EventEmitter } from 'eventemitter3';
import type {
HttpClientConfig,
RequestConfig,
HttpResponse,
ConnectionStats,
HttpClientError,
TimeoutError
} from './types';
import { ConnectionPool } from './ConnectionPool';
import { RetryHandler } from './RetryHandler';
export class BunHttpClient extends EventEmitter {
private connectionPool: ConnectionPool;
private retryHandler: RetryHandler;
private defaultConfig: Required<HttpClientConfig>;
constructor(config: HttpClientConfig = {}) {
super();
this.defaultConfig = {
baseURL: '',
timeout: 30000,
headers: {},
retries: 3,
retryDelay: 1000,
maxConcurrency: 10,
keepAlive: true,
validateStatus: (status: number) => status < 400,
...config
};
this.connectionPool = new ConnectionPool({
maxConnections: this.defaultConfig.maxConcurrency,
maxConnectionsPerHost: Math.ceil(this.defaultConfig.maxConcurrency / 4),
keepAlive: this.defaultConfig.keepAlive,
maxIdleTime: 60000,
connectionTimeout: this.defaultConfig.timeout
});
this.retryHandler = new RetryHandler({
maxRetries: this.defaultConfig.retries,
baseDelay: this.defaultConfig.retryDelay,
maxDelay: 30000,
exponentialBackoff: true
});
// Forward events from connection pool and retry handler
this.connectionPool.on('response', (data) => this.emit('response', data));
this.connectionPool.on('error', (data) => this.emit('error', data));
this.retryHandler.on('retryAttempt', (data) => this.emit('retryAttempt', data));
this.retryHandler.on('retrySuccess', (data) => this.emit('retrySuccess', data));
this.retryHandler.on('retryExhausted', (data) => this.emit('retryExhausted', data));
}
async request<T = any>(config: RequestConfig): Promise<HttpResponse<T>> {
const fullConfig = this.mergeConfig(config);
return this.retryHandler.execute(async () => {
const startTime = performance.now();
try {
// Add timing metadata
fullConfig.metadata = {
...fullConfig.metadata,
startTime
};
const response = await this.connectionPool.request(fullConfig);
return response as HttpResponse<T>;
} catch (error: any) {
// Convert fetch errors to our error types
if (error.name === 'AbortError') {
throw new TimeoutError(fullConfig, fullConfig.timeout || this.defaultConfig.timeout);
}
// Re-throw as HttpClientError if not already
if (!(error instanceof HttpClientError)) {
const httpError = new HttpClientError(
error.message || 'Request failed',
error.code,
error.status,
error.response,
fullConfig
);
throw httpError;
}
throw error;
}
}, fullConfig);
}
// Convenience methods
async get<T = any>(url: string, config?: Partial<RequestConfig>): Promise<HttpResponse<T>> {
return this.request<T>({ ...config, url, method: 'GET' });
}
async post<T = any>(url: string, data?: any, config?: Partial<RequestConfig>): Promise<HttpResponse<T>> {
return this.request<T>({ ...config, url, method: 'POST', body: data });
}
async put<T = any>(url: string, data?: any, config?: Partial<RequestConfig>): Promise<HttpResponse<T>> {
return this.request<T>({ ...config, url, method: 'PUT', body: data });
}
async patch<T = any>(url: string, data?: any, config?: Partial<RequestConfig>): Promise<HttpResponse<T>> {
return this.request<T>({ ...config, url, method: 'PATCH', body: data });
}
async delete<T = any>(url: string, config?: Partial<RequestConfig>): Promise<HttpResponse<T>> {
return this.request<T>({ ...config, url, method: 'DELETE' });
}
async head<T = any>(url: string, config?: Partial<RequestConfig>): Promise<HttpResponse<T>> {
return this.request<T>({ ...config, url, method: 'HEAD' });
}
async options<T = any>(url: string, config?: Partial<RequestConfig>): Promise<HttpResponse<T>> {
return this.request<T>({ ...config, url, method: 'OPTIONS' });
}
private mergeConfig(config: RequestConfig): RequestConfig {
return {
timeout: this.defaultConfig.timeout,
retries: this.defaultConfig.retries,
headers: { ...this.defaultConfig.headers, ...config.headers },
validateStatus: this.defaultConfig.validateStatus,
url: this.buildUrl(config.url),
...config
};
}
private buildUrl(url: string): string {
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
if (this.defaultConfig.baseURL) {
const baseURL = this.defaultConfig.baseURL.replace(/\/$/, '');
const path = url.replace(/^\//, '');
return `${baseURL}/${path}`;
}
return url;
}
// Configuration methods
setBaseURL(baseURL: string): void {
this.defaultConfig.baseURL = baseURL;
}
setDefaultHeaders(headers: Record<string, string>): void {
this.defaultConfig.headers = { ...this.defaultConfig.headers, ...headers };
}
setTimeout(timeout: number): void {
this.defaultConfig.timeout = timeout;
}
setMaxConcurrency(maxConcurrency: number): void {
this.defaultConfig.maxConcurrency = maxConcurrency;
}
// Statistics and monitoring
getStats(): ConnectionStats {
return this.connectionPool.getStats();
}
async healthCheck(): Promise<{ healthy: boolean; details: any }> {
return this.connectionPool.healthCheck();
}
// Lifecycle management
async close(): Promise<void> {
await this.connectionPool.close();
this.removeAllListeners();
}
// Create a new instance with different configuration
create(config: HttpClientConfig): BunHttpClient {
const mergedConfig = { ...this.defaultConfig, ...config };
return new BunHttpClient(mergedConfig);
}
// Interceptor-like functionality through events
onRequest(handler: (config: RequestConfig) => RequestConfig | Promise<RequestConfig>): void {
this.on('beforeRequest', handler);
}
onResponse(handler: (response: HttpResponse) => HttpResponse | Promise<HttpResponse>): void {
this.on('afterResponse', handler);
}
onError(handler: (error: any) => void): void {
this.on('requestError', handler);
}
}