fixed all libs to be buildiable and dependency hell from removing some

This commit is contained in:
Bojan Kucera 2025-06-04 16:07:08 -04:00
parent 5c64b1ccf8
commit a282dac6cd
40 changed files with 4050 additions and 8219 deletions

View file

@ -0,0 +1,291 @@
import type { Logger } from '@stock-bot/logger';
import type {
HttpClientConfig,
RequestConfig,
HttpResponse,
HttpMethod,
ProxyConfig,
} from './types.js';
import {
HttpError,
TimeoutError,
HttpClientConfigSchema,
RequestConfigSchema,
} from './types.js';
import { RateLimiter } from './rate-limiter.js';
import { ProxyManager } from './proxy-manager.js';
export class HttpClient {
private readonly config: HttpClientConfig;
private readonly rateLimiter?: RateLimiter;
private readonly logger?: Logger;
constructor(config: Partial<HttpClientConfig> = {}, logger?: Logger) {
// Validate and set default configuration
this.config = HttpClientConfigSchema.parse(config);
this.logger = logger;
// Initialize rate limiter if configured
if (this.config.rateLimit) {
this.rateLimiter = new RateLimiter(this.config.rateLimit);
}
// Validate proxy configuration if provided
if (this.config.proxy) {
ProxyManager.validateConfig(this.config.proxy);
}
}
/**
* Make an HTTP request
*/
async request<T = any>(config: RequestConfig): Promise<HttpResponse<T>> {
// Validate request configuration
const validatedConfig = RequestConfigSchema.parse(config);
// Merge with default configuration
const finalConfig = this.mergeConfig(validatedConfig);
// Check rate limiting
if (this.rateLimiter) {
await this.rateLimiter.checkRateLimit();
}
this.logger?.debug('Making HTTP request', {
method: finalConfig.method,
url: finalConfig.url
});
let lastError: Error | undefined;
const maxRetries = finalConfig.retries ?? this.config.retries;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const response = await this.executeRequest<T>(finalConfig);
// Record successful request for rate limiting
if (this.rateLimiter) {
this.rateLimiter.recordRequest(true);
}
this.logger?.debug('HTTP request successful', {
method: finalConfig.method,
url: finalConfig.url,
status: response.status,
attempt: attempt + 1,
});
return response;
} catch (error) {
lastError = error as Error;
// Record failed request for rate limiting
if (this.rateLimiter) {
this.rateLimiter.recordRequest(false);
}
this.logger?.warn('HTTP request failed', {
method: finalConfig.method,
url: finalConfig.url,
attempt: attempt + 1,
error: lastError.message,
});
// Don't retry on certain errors
if (error instanceof TimeoutError ||
(error instanceof HttpError && error.status && error.status < 500)) {
break;
}
// Wait before retrying (except on last attempt)
if (attempt < maxRetries) {
const delay = finalConfig.retryDelay ?? this.config.retryDelay;
await this.sleep(delay * Math.pow(2, attempt)); // Exponential backoff
}
}
}
throw lastError;
}
/**
* Convenience methods for common HTTP methods
*/
async get<T = any>(url: string, config: Omit<RequestConfig, 'method' | 'url'> = {}): Promise<HttpResponse<T>> {
return this.request<T>({ ...config, method: 'GET', url });
}
async post<T = any>(url: string, body?: any, config: Omit<RequestConfig, 'method' | 'url' | 'body'> = {}): Promise<HttpResponse<T>> {
return this.request<T>({ ...config, method: 'POST', url, body });
}
async put<T = any>(url: string, body?: any, config: Omit<RequestConfig, 'method' | 'url' | 'body'> = {}): Promise<HttpResponse<T>> {
return this.request<T>({ ...config, method: 'PUT', url, body });
}
async delete<T = any>(url: string, config: Omit<RequestConfig, 'method' | 'url'> = {}): Promise<HttpResponse<T>> {
return this.request<T>({ ...config, method: 'DELETE', url });
}
async patch<T = any>(url: string, body?: any, config: Omit<RequestConfig, 'method' | 'url' | 'body'> = {}): Promise<HttpResponse<T>> {
return this.request<T>({ ...config, method: 'PATCH', url, body });
}
/**
* Execute the actual HTTP request
*/
private async executeRequest<T>(config: RequestConfig): Promise<HttpResponse<T>> {
const url = this.buildUrl(config.url);
const timeout = config.timeout ?? this.config.timeout;
// Create abort controller for timeout
const abortController = new AbortController();
const timeoutId = setTimeout(() => {
abortController.abort();
}, timeout);
try {
// Prepare request options
const requestOptions: RequestInit = {
method: config.method,
headers: config.headers,
signal: abortController.signal,
};
// Add body for non-GET requests
if (config.body && config.method !== 'GET' && config.method !== 'HEAD') {
if (typeof config.body === 'object') {
requestOptions.body = JSON.stringify(config.body);
requestOptions.headers = {
'Content-Type': 'application/json',
...requestOptions.headers,
};
} else {
requestOptions.body = config.body;
}
}
// Add proxy agent if configured
if (this.config.proxy) {
const agent = ProxyManager.createAgent(this.config.proxy);
(requestOptions as any).agent = agent;
}
// Make the request
const response = await fetch(url, requestOptions);
// Clear timeout
clearTimeout(timeoutId);
// Check if response status is valid
const validateStatus = config.validateStatus ?? this.config.validateStatus ?? this.defaultValidateStatus;
if (!validateStatus(response.status)) {
throw new HttpError(
`Request failed with status ${response.status}`,
response.status,
undefined,
config
);
}
// Parse response
const responseHeaders: Record<string, string> = {};
response.headers.forEach((value, key) => {
responseHeaders[key] = value;
});
let data: T;
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
data = await response.json();
} else if (contentType.includes('text/')) {
data = await response.text() as any;
} else {
data = await response.arrayBuffer() as any;
}
return {
data,
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
config,
};
} catch (error) {
clearTimeout(timeoutId);
if (abortController.signal.aborted) {
throw new TimeoutError(config, timeout);
}
if (error instanceof HttpError) {
throw error;
}
throw new HttpError(`Request failed: ${(error as Error).message}`, undefined, undefined, config);
}
}
/**
* Merge request config with default config
*/
private mergeConfig(config: RequestConfig): RequestConfig {
return {
...config,
headers: {
...this.config.defaultHeaders,
...config.headers,
},
timeout: config.timeout ?? this.config.timeout,
retries: config.retries ?? this.config.retries,
retryDelay: config.retryDelay ?? this.config.retryDelay,
validateStatus: config.validateStatus ?? this.config.validateStatus,
};
}
/**
* Build full URL from base URL and request URL
*/
private buildUrl(url: string): string {
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
if (this.config.baseURL) {
return new URL(url, this.config.baseURL).toString();
}
return url;
}
/**
* Default status validator
*/
private defaultValidateStatus(status: number): boolean {
return status >= 200 && status < 300;
}
/**
* Sleep utility for retries
*/
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Get current rate limiting status
*/
getRateLimitStatus() {
if (!this.rateLimiter) {
return null;
}
return {
currentCount: this.rateLimiter.getCurrentCount(),
maxRequests: this.config.rateLimit!.maxRequests,
windowMs: this.config.rateLimit!.windowMs,
timeUntilReset: this.rateLimiter.getTimeUntilReset(),
};
}
}

View file

@ -0,0 +1,8 @@
// Re-export all types and classes
export * from './types.js';
export * from './client.js';
export * from './rate-limiter.js';
export * from './proxy-manager.js';
// Default export
export { HttpClient as default } from './client.js';

View file

@ -0,0 +1,48 @@
import { HttpsProxyAgent } from 'https-proxy-agent';
import { SocksProxyAgent } from 'socks-proxy-agent';
import type { ProxyConfig } from './types.js';
export class ProxyManager {
/**
* Create appropriate proxy agent based on configuration
*/
static createAgent(proxy: ProxyConfig): HttpsProxyAgent<string> | SocksProxyAgent {
const { type, host, port, username, password } = proxy;
let proxyUrl: string;
if (username && password) {
proxyUrl = `${type}://${encodeURIComponent(username)}:${encodeURIComponent(password)}@${host}:${port}`;
} else {
proxyUrl = `${type}://${host}:${port}`;
}
switch (type) {
case 'http':
case 'https':
return new HttpsProxyAgent(proxyUrl);
case 'socks4':
case 'socks5':
return new SocksProxyAgent(proxyUrl);
default:
throw new Error(`Unsupported proxy type: ${type}`);
}
}
/**
* Validate proxy configuration
*/
static validateConfig(proxy: ProxyConfig): void {
if (!proxy.host) {
throw new Error('Proxy host is required');
}
if (!proxy.port || proxy.port < 1 || proxy.port > 65535) {
throw new Error('Invalid proxy port');
}
if (!['http', 'https', 'socks4', 'socks5'].includes(proxy.type)) {
throw new Error(`Invalid proxy type: ${proxy.type}`);
}
}
}

View file

@ -0,0 +1,79 @@
import type { RateLimitConfig } from './types.js';
import { RateLimitError } from './types.js';
interface RequestRecord {
timestamp: number;
success: boolean;
}
export class RateLimiter {
private requests: RequestRecord[] = [];
private readonly config: RateLimitConfig;
constructor(config: RateLimitConfig) {
this.config = config;
}
/**
* Check if request is allowed based on rate limiting configuration
* @returns Promise that resolves when request can proceed
*/
async checkRateLimit(): Promise<void> {
const now = Date.now();
const windowStart = now - this.config.windowMs;
// Remove old requests outside the window
this.requests = this.requests.filter(req => req.timestamp > windowStart);
// Filter requests based on configuration
const relevantRequests = this.requests.filter(req => {
if (this.config.skipSuccessfulRequests && req.success) {
return false;
}
if (this.config.skipFailedRequests && !req.success) {
return false;
}
return true;
});
if (relevantRequests.length >= this.config.maxRequests) {
const oldestRequest = relevantRequests[0];
const retryAfter = oldestRequest.timestamp + this.config.windowMs - now;
throw new RateLimitError(this.config.maxRequests, this.config.windowMs, retryAfter);
}
}
/**
* Record a request for rate limiting purposes
*/
recordRequest(success: boolean): void {
this.requests.push({
timestamp: Date.now(),
success,
});
}
/**
* Get current request count in the window
*/
getCurrentCount(): number {
const now = Date.now();
const windowStart = now - this.config.windowMs;
return this.requests.filter(req => req.timestamp > windowStart).length;
}
/**
* Get time until next request is allowed
*/
getTimeUntilReset(): number {
if (this.requests.length === 0) {
return 0;
}
const now = Date.now();
const oldestRequest = this.requests[0];
const resetTime = oldestRequest.timestamp + this.config.windowMs;
return Math.max(0, resetTime - now);
}
}

View file

@ -0,0 +1,93 @@
import { z } from 'zod';
// HTTP Methods
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS';
// Proxy configuration
export const ProxyConfigSchema = z.object({
type: z.enum(['http', 'https', 'socks4', 'socks5']),
host: z.string(),
port: z.number().min(1).max(65535),
username: z.string().optional(),
password: z.string().optional(),
});
export type ProxyConfig = z.infer<typeof ProxyConfigSchema>;
// Rate limiting configuration
export const RateLimitConfigSchema = z.object({
maxRequests: z.number().min(1),
windowMs: z.number().min(1),
skipSuccessfulRequests: z.boolean().default(false),
skipFailedRequests: z.boolean().default(false),
});
export type RateLimitConfig = z.infer<typeof RateLimitConfigSchema>;
// HTTP client configuration
export const HttpClientConfigSchema = z.object({
baseURL: z.string().url().optional(),
timeout: z.number().min(1).default(30000), // 30 seconds default
retries: z.number().min(0).default(3),
retryDelay: z.number().min(0).default(1000), // 1 second default
proxy: ProxyConfigSchema.optional(),
rateLimit: RateLimitConfigSchema.optional(),
defaultHeaders: z.record(z.string()).default({}),
validateStatus: z.function().args(z.number()).returns(z.boolean()).optional(),
});
export type HttpClientConfig = z.infer<typeof HttpClientConfigSchema>;
// Request configuration
export const RequestConfigSchema = z.object({
method: z.enum(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']).default('GET'),
url: z.string(),
headers: z.record(z.string()).default({}).optional(),
body: z.any().optional(),
timeout: z.number().min(1).optional(),
retries: z.number().min(0).optional(),
retryDelay: z.number().min(0).optional(),
validateStatus: z.function().args(z.number()).returns(z.boolean()).optional(),
});
export type RequestConfig = z.infer<typeof RequestConfigSchema>;
// Response type
export interface HttpResponse<T = any> {
data: T;
status: number;
statusText: string;
headers: Record<string, string>;
config: RequestConfig;
}
// Error types
export class HttpError extends Error {
constructor(
message: string,
public status?: number,
public response?: HttpResponse,
public config?: RequestConfig
) {
super(message);
this.name = 'HttpError';
}
}
export class TimeoutError extends HttpError {
constructor(config: RequestConfig, timeout: number) {
super(`Request timeout after ${timeout}ms`, undefined, undefined, config);
this.name = 'TimeoutError';
}
}
export class RateLimitError extends HttpError {
constructor(
public maxRequests: number,
public windowMs: number,
public retryAfter?: number
) {
super(`Rate limit exceeded: ${maxRequests} requests per ${windowMs}ms`);
this.name = 'RateLimitError';
}
}