renamed http-client to http and fixed tests

This commit is contained in:
Bojan Kucera 2025-06-07 13:20:58 -04:00
parent e87acb5e18
commit 3d9afd711e
26 changed files with 472 additions and 496 deletions

283
libs/http/README.md Normal file
View file

@ -0,0 +1,283 @@
# HTTP Client Library
A comprehensive HTTP client library for the Stock Bot platform with built-in support for:
- ✅ **Fetch API** - Modern, promise-based HTTP requests
- ✅ **Proxy Support** - HTTP, HTTPS, SOCKS4, and SOCKS5 proxies
- ✅ **Rate Limiting** - Configurable request rate limiting
- ✅ **Timeout Handling** - Request timeouts with abort controllers
- ✅ **Retry Logic** - Automatic retries with exponential backoff
- ✅ **TypeScript** - Full TypeScript support with type safety
- ✅ **Logging Integration** - Optional logger integration
## Installation
```bash
bun add @stock-bot/http
```
## Basic Usage
```typescript
import { HttpClient } from '@stock-bot/http';
// Create a client with default configuration
const client = new HttpClient();
// Make a GET request
const response = await client.get('https://api.example.com/data');
console.log(response.data);
// Make a POST request
const postResponse = await client.post('https://api.example.com/users', {
name: 'John Doe',
email: 'john@example.com'
});
```
## Advanced Configuration
```typescript
import { HttpClient } from '@stock-bot/http';
import { logger } from '@stock-bot/logger';
const client = new HttpClient({
baseURL: 'https://api.example.com',
timeout: 10000, // 10 seconds
retries: 3,
retryDelay: 1000, // 1 second base delay
defaultHeaders: {
'Authorization': 'Bearer token',
'User-Agent': 'Stock-Bot/1.0'
},
validateStatus: (status) => status < 400
}, logger);
```
## Proxy Support
### HTTP/HTTPS Proxy
```typescript
const client = new HttpClient({
proxy: {
type: 'http',
host: 'proxy.example.com',
port: 8080,
username: 'user', // optional
password: 'pass' // optional
}
});
```
### SOCKS Proxy
```typescript
const client = new HttpClient({
proxy: {
type: 'socks5',
host: 'socks-proxy.example.com',
port: 1080,
username: 'user', // optional
password: 'pass' // optional
}
});
```
## Rate Limiting
```typescript
const client = new HttpClient({
rateLimit: {
maxRequests: 100, // Max 100 requests
windowMs: 60 * 1000, // Per 1 minute
skipSuccessfulRequests: false,
skipFailedRequests: true // Don't count failed requests
}
});
// Check rate limit status
const status = client.getRateLimitStatus();
console.log(`${status.currentCount}/${status.maxRequests} requests used`);
```
## Request Methods
```typescript
// GET request
const getData = await client.get('/api/data');
// POST request with body
const postData = await client.post('/api/users', {
name: 'John',
email: 'john@example.com'
});
// PUT request
const putData = await client.put('/api/users/1', updatedUser);
// DELETE request
const deleteData = await client.delete('/api/users/1');
// PATCH request
const patchData = await client.patch('/api/users/1', { name: 'Jane' });
// Custom request
const customResponse = await client.request({
method: 'POST',
url: '/api/custom',
headers: { 'X-Custom': 'value' },
body: { data: 'custom' },
timeout: 5000
});
```
## Error Handling
```typescript
import { HttpError, TimeoutError, RateLimitError } from '@stock-bot/http';
try {
const response = await client.get('/api/data');
} catch (error) {
if (error instanceof TimeoutError) {
console.log('Request timed out');
} else if (error instanceof RateLimitError) {
console.log(`Rate limited: retry after ${error.retryAfter}ms`);
} else if (error instanceof HttpError) {
console.log(`HTTP error ${error.status}: ${error.message}`);
}
}
```
## Retry Configuration
```typescript
const client = new HttpClient({
retries: 3, // Retry up to 3 times
retryDelay: 1000, // Base delay of 1 second
// Exponential backoff: 1s, 2s, 4s
});
// Or per-request retry configuration
const response = await client.get('/api/data', {
retries: 5,
retryDelay: 500
});
```
## Timeout Handling
```typescript
// Global timeout
const client = new HttpClient({
timeout: 30000 // 30 seconds
});
// Per-request timeout
const response = await client.get('/api/data', {
timeout: 5000 // 5 seconds for this request
});
```
## Custom Status Validation
```typescript
const client = new HttpClient({
validateStatus: (status) => {
// Accept 2xx and 3xx status codes
return status >= 200 && status < 400;
}
});
// Or per-request validation
const response = await client.get('/api/data', {
validateStatus: (status) => status === 200 || status === 404
});
```
## TypeScript Support
The library is fully typed with TypeScript:
```typescript
interface User {
id: number;
name: string;
email: string;
}
// Response data is properly typed
const response = await client.get<User[]>('/api/users');
const users: User[] = response.data;
// Request configuration is validated
const config: RequestConfig = {
method: 'POST',
url: '/api/users',
body: { name: 'John' },
timeout: 5000
};
```
## Integration with Logger
```typescript
import { logger } from '@stock-bot/logger';
import { HttpClient } from '@stock-bot/http';
const client = new HttpClient({
baseURL: 'https://api.example.com'
}, logger);
// All requests will be logged with debug/warn/error levels
```
## Testing
```bash
# Run tests
bun test
# Run with coverage
bun test --coverage
# Watch mode
bun test --watch
```
## Features
### Proxy Support
- HTTP and HTTPS proxies
- SOCKS4 and SOCKS5 proxies
- Authentication support
- Automatic agent creation
### Rate Limiting
- Token bucket algorithm
- Configurable window and request limits
- Skip successful/failed requests options
- Real-time status monitoring
### Retry Logic
- Exponential backoff
- Configurable retry attempts
- Smart retry conditions (5xx errors only)
- Per-request retry override
### Error Handling
- Typed error classes
- Detailed error information
- Request/response context
- Timeout detection
### Performance
- Built on modern Fetch API
- Minimal dependencies
- Tree-shakeable exports
- TypeScript optimization
## License
MIT License - see LICENSE file for details.

35
libs/http/bun.lock Normal file
View file

@ -0,0 +1,35 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"dependencies": {
"https-proxy-agent": "^7.0.6",
"socks-proxy-agent": "^8.0.5",
"zod": "^3.25.51",
},
},
},
"packages": {
"agent-base": ["agent-base@7.1.3", "", {}, "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw=="],
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"ip-address": ["ip-address@9.0.5", "", { "dependencies": { "jsbn": "1.1.0", "sprintf-js": "^1.1.3" } }, "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g=="],
"jsbn": ["jsbn@1.1.0", "", {}, "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="],
"socks": ["socks@2.8.4", "", { "dependencies": { "ip-address": "^9.0.5", "smart-buffer": "^4.2.0" } }, "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ=="],
"socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="],
"sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
"zod": ["zod@3.25.51", "", {}, "sha512-TQSnBldh+XSGL+opiSIq0575wvDPqu09AqWe1F7JhUMKY+M91/aGlK4MhpVNO7MgYfHcVCB1ffwAUTJzllKJqg=="],
}
}

0
libs/http/bunfig.toml Normal file
View file

47
libs/http/package.json Normal file
View file

@ -0,0 +1,47 @@
{
"name": "@stock-bot/http",
"version": "1.0.0",
"description": "HTTP client library with proxy support, rate limiting, and timeout for Stock Bot platform",
"main": "src/index.ts",
"type": "module",
"scripts": {
"build": "tsc",
"test": "bun test",
"test:watch": "bun test --watch",
"test:coverage": "bun test --coverage",
"lint": "eslint src/**/*.ts",
"type-check": "tsc --noEmit",
"dev": "tsc --watch"
},
"dependencies": {
"@stock-bot/logger": "*",
"@stock-bot/types": "*",
"got": "^14.4.7",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"socks-proxy-agent": "^8.0.5"
},
"devDependencies": {
"@types/node": "^20.11.0",
"typescript": "^5.3.0",
"eslint": "^8.56.0",
"@typescript-eslint/eslint-plugin": "^6.19.0",
"@typescript-eslint/parser": "^6.19.0",
"bun-types": "^1.2.15"
},
"keywords": [
"http",
"client",
"fetch",
"proxy",
"rate-limiting",
"timeout",
"stock-bot"
],
"exports": {
".": {
"import": "./src/index.ts",
"require": "./dist/index.js"
}
}
}

266
libs/http/src/client.ts Normal file
View file

@ -0,0 +1,266 @@
import type { Logger } from '@stock-bot/logger';
import type {
HttpClientConfig,
RequestConfig,
HttpResponse,
} from './types.js';
import { HttpError } from './types.js';
import { ProxyManager } from './proxy-manager.js';
import got from 'got';
export class HttpClient {
private readonly config: HttpClientConfig;
private readonly logger?: Logger;
constructor(config: HttpClientConfig = {}, logger?: Logger) {
this.config = config;
this.logger = logger;
}
// Convenience 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 del<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 });
} /**
* Main request method - unified and simplified
*/
async request<T = any>(config: RequestConfig): Promise<HttpResponse<T>> {
const finalConfig = this.mergeConfig(config);
this.logger?.debug('Making HTTP request', {
method: finalConfig.method,
url: finalConfig.url,
hasProxy: !!finalConfig.proxy
});
try {
// Single decision point for proxy type - only request-level proxy
const proxy = finalConfig.proxy;
const useBunFetch = !proxy || ProxyManager.shouldUseBunFetch(proxy);
const response = await this.makeRequest<T>(finalConfig, useBunFetch);
this.logger?.debug('HTTP request successful', {
method: finalConfig.method,
url: finalConfig.url,
status: response.status,
});
return response;
} catch (error) {
this.logger?.warn('HTTP request failed', {
method: finalConfig.method,
url: finalConfig.url,
error: (error as Error).message,
});
throw error;
}
}
/**
* Unified request method with consolidated timeout handling
*/
private async makeRequest<T>(config: RequestConfig, useBunFetch: boolean): Promise<HttpResponse<T>> {
const timeout = config.timeout ?? this.config.timeout ?? 30000;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = useBunFetch
? await this.fetchRequest<T>(config, controller.signal)
: await this.gotRequest<T>(config, controller.signal);
clearTimeout(timeoutId);
return response;
} catch (error) {
clearTimeout(timeoutId);
// Unified timeout error handling
if (controller.signal.aborted) {
throw new HttpError(`Request timeout after ${timeout}ms`);
}
if ((error as any).name === 'TimeoutError') {
throw new HttpError(`Request timeout after ${timeout}ms`);
}
throw error; // Re-throw other errors as-is
}
}
/**
* Bun fetch implementation (simplified)
*/
private async fetchRequest<T>(config: RequestConfig, signal: AbortSignal): Promise<HttpResponse<T>> {
try {
const options = this.buildFetchOptions(config, signal);
const response = await fetch(config.url, options);
return this.parseFetchResponse<T>(response);
} catch (error) {
throw signal.aborted
? new HttpError(`Request timeout`)
: new HttpError(`Request failed: ${(error as Error).message}`);
}
} /**
* Got implementation (simplified for SOCKS proxies)
*/
private async gotRequest<T>(config: RequestConfig, signal: AbortSignal): Promise<HttpResponse<T>> {
try {
const options = this.buildGotOptions(config, signal);
const response = await got(config.url, options);
return this.parseGotResponse<T>(response);
} catch (error) {
// Handle both AbortSignal timeout and Got-specific timeout errors
if (signal.aborted) {
throw new HttpError(`Request timeout`);
}
if ((error as any).name === 'TimeoutError') {
throw new HttpError(`Request timeout`);
}
throw new HttpError(`Request failed: ${(error as Error).message}`);
}
}
/**
* Build fetch options (extracted for clarity)
*/
private buildFetchOptions(config: RequestConfig, signal: AbortSignal): RequestInit {
const options: RequestInit = {
method: config.method || 'GET',
headers: config.headers || {},
signal,
};
// Add body
if (config.body && config.method !== 'GET') {
if (typeof config.body === 'object') {
options.body = JSON.stringify(config.body);
options.headers = { 'Content-Type': 'application/json', ...options.headers };
} else {
options.body = config.body;
}
}
// Add proxy (HTTP/HTTPS only) - request level only
if (config.proxy && ProxyManager.shouldUseBunFetch(config.proxy)) {
(options as any).proxy = ProxyManager.createBunProxyUrl(config.proxy);
}
return options;
} /**
* Build Got options (extracted for clarity)
*/
private buildGotOptions(config: RequestConfig, signal: AbortSignal): any {
const options: any = {
method: config.method || 'GET',
headers: config.headers || {},
signal, // Use AbortSignal instead of Got's timeout
retry: {
limit: 3,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']
},
throwHttpErrors: false,
responseType: 'json'
};
// Add body
if (config.body && config.method !== 'GET') {
if (typeof config.body === 'object') {
options.json = config.body;
} else {
options.body = config.body;
options.headers = { 'Content-Type': 'text/plain', ...options.headers };
}
}
// Add SOCKS proxy via agent - request level only
if (config.proxy && !ProxyManager.shouldUseBunFetch(config.proxy)) {
ProxyManager.validateConfig(config.proxy);
const agent = ProxyManager.createGotAgent(config.proxy);
options.agent = {
http: agent,
https: agent
};
}
return options;
}
/**
* Parse fetch response (simplified)
*/
private async parseFetchResponse<T>(response: Response): Promise<HttpResponse<T>> {
const data = await this.parseResponseBody<T>(response);
const headers = Object.fromEntries(response.headers.entries());
if (!response.ok) {
throw new HttpError(
`Request failed with status ${response.status}`,
response.status,
{ data, status: response.status, headers, ok: response.ok }
);
}
return { data, status: response.status, headers, ok: response.ok };
}
/**
* Parse Got response (simplified)
*/
private parseGotResponse<T>(response: any): HttpResponse<T> {
const headers = response.headers as Record<string, string>;
const status = response.statusCode;
const ok = status >= 200 && status < 300;
const data = response.body as T;
if (!ok) {
throw new HttpError(
`Request failed with status ${status}`,
status,
{ data, status, headers, ok }
);
}
return { data, status, headers, ok };
}
/**
* Unified body parsing (works for fetch response)
*/
private async parseResponseBody<T>(response: Response): Promise<T> {
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
return response.json();
} else if (contentType.includes('text/')) {
return response.text() as any;
} else {
return response.arrayBuffer() as any;
}
}
/**
* Merge configs - request-level proxy only
*/
private mergeConfig(config: RequestConfig): RequestConfig {
return {
...config,
headers: { ...this.config.headers, ...config.headers },
timeout: config.timeout ?? this.config.timeout,
};
}
}

7
libs/http/src/index.ts Normal file
View file

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

View file

@ -0,0 +1,91 @@
import got from 'got';
import { SocksProxyAgent } from 'socks-proxy-agent';
import { HttpsProxyAgent } from 'https-proxy-agent';
import { HttpProxyAgent } from 'http-proxy-agent';
import type { ProxyConfig } from './types.js';
export class ProxyManager {
/**
* Determine if we should use Bun fetch (HTTP/HTTPS) or Got (SOCKS)
*/
static shouldUseBunFetch(proxy: ProxyConfig): boolean {
return proxy.protocol === 'http' || proxy.protocol === 'https';
}
/**
* Create Bun fetch proxy URL for HTTP/HTTPS proxies
*/
static createBunProxyUrl(proxy: ProxyConfig): string {
const { protocol, host, port, username, password } = proxy;
if (username && password) {
return `${protocol}://${encodeURIComponent(username)}:${encodeURIComponent(password)}@${host}:${port}`;
}
return `${protocol}://${host}:${port}`;
}
/**
* Create appropriate agent for Got based on proxy type
*/
static createGotAgent(proxy: ProxyConfig) {
this.validateConfig(proxy);
const proxyUrl = this.buildProxyUrl(proxy);
switch (proxy.protocol) {
case 'socks4':
case 'socks5':
return new SocksProxyAgent(proxyUrl);
case 'http':
return new HttpProxyAgent(proxyUrl);
case 'https':
return new HttpsProxyAgent(proxyUrl);
default:
throw new Error(`Unsupported proxy protocol: ${proxy.protocol}`);
}
}
/**
* Create Got instance with proxy configuration
*/
static createGotInstance(proxy: ProxyConfig) {
const agent = this.createGotAgent(proxy);
return got.extend({
agent: {
http: agent,
https: agent
},
timeout: {
request: 30000,
connect: 10000
},
retry: {
limit: 3,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']
},
throwHttpErrors: false // We'll handle errors ourselves
});
}
private static buildProxyUrl(proxy: ProxyConfig): string {
const { protocol, host, port, username, password } = proxy;
if (username && password) {
return `${protocol}://${encodeURIComponent(username)}:${encodeURIComponent(password)}@${host}:${port}`;
}
return `${protocol}://${host}:${port}`;
}
/**
* Simple proxy config validation
*/
static validateConfig(proxy: ProxyConfig): void {
if (!proxy.host || !proxy.port) {
throw new Error('Proxy host and port are required');
}
if (!['http', 'https', 'socks4', 'socks5'].includes(proxy.protocol)) {
throw new Error(`Unsupported proxy protocol: ${proxy.protocol}`);
}
}
}

42
libs/http/src/types.ts Normal file
View file

@ -0,0 +1,42 @@
// Minimal types for fast HTTP client
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
export interface ProxyConfig {
protocol: 'http' | 'https' | 'socks4' | 'socks5';
host: string;
port: number;
username?: string;
password?: string;
}
export interface HttpClientConfig {
timeout?: number;
headers?: Record<string, string>;
}
export interface RequestConfig {
method?: HttpMethod;
url: string;
headers?: Record<string, string>;
body?: any;
timeout?: number;
proxy?: ProxyConfig;
}
export interface HttpResponse<T = any> {
data: T;
status: number;
headers: Record<string, string>;
ok: boolean;
}
export class HttpError extends Error {
constructor(
message: string,
public status?: number,
public response?: HttpResponse
) {
super(message);
this.name = 'HttpError';
}
}

View file

@ -0,0 +1,156 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { HttpClient, HttpError } from '../src/index.js';
import { MockServer } from './mock-server.js';
/**
* Integration tests for HTTP client with real network scenarios
* These tests use external services and may be affected by network conditions
*/
let mockServer: MockServer;
let mockServerBaseUrl: string;
beforeAll(async () => {
mockServer = new MockServer();
await mockServer.start();
mockServerBaseUrl = mockServer.getBaseUrl();
});
afterAll(async () => {
await mockServer.stop();
});
describe('HTTP Integration Tests', () => {
let client: HttpClient;
beforeAll(() => {
client = new HttpClient({
timeout: 10000,
retries: 1
});
});
describe('Real-world scenarios', () => {
test('should handle JSON API responses', async () => {
try {
const response = await client.get('https://jsonplaceholder.typicode.com/posts/1');
expect(response.status).toBe(200);
expect(response.data).toHaveProperty('id');
expect(response.data).toHaveProperty('title');
expect(response.data).toHaveProperty('body');
} catch (error) {
console.warn('External API test skipped due to network issues:', error.message);
}
});
test('should handle large responses', async () => {
try {
const response = await client.get('https://jsonplaceholder.typicode.com/posts');
expect(response.status).toBe(200);
expect(Array.isArray(response.data)).toBe(true);
expect(response.data.length).toBeGreaterThan(0);
} catch (error) {
console.warn('Large response test skipped due to network issues:', error.message);
}
});
test('should handle POST with JSON data', async () => {
try {
const postData = {
title: 'Integration Test Post',
body: 'This is a test post from integration tests',
userId: 1
};
const response = await client.post('https://jsonplaceholder.typicode.com/posts', postData);
expect(response.status).toBe(201);
expect(response.data).toHaveProperty('id');
expect(response.data.title).toBe(postData.title);
} catch (error) {
console.warn('POST integration test skipped due to network issues:', error.message);
}
});
});
describe('Error scenarios with mock server', () => { test('should handle various HTTP status codes', async () => {
const successCodes = [200, 201];
const errorCodes = [400, 401, 403, 404, 500, 503];
// Test success codes
for (const statusCode of successCodes) {
const response = await client.get(`${mockServerBaseUrl}/status/${statusCode}`);
expect(response.status).toBe(statusCode);
}
// Test error codes (should throw HttpError)
for (const statusCode of errorCodes) {
await expect(
client.get(`${mockServerBaseUrl}/status/${statusCode}`)
).rejects.toThrow(HttpError);
}
});
test('should handle malformed responses gracefully', async () => {
// Mock server returns valid JSON, so this test verifies our client handles it properly
const response = await client.get(`${mockServerBaseUrl}/`);
expect(response.status).toBe(200);
expect(typeof response.data).toBe('object');
});
test('should handle concurrent requests', async () => {
const requests = Array.from({ length: 5 }, (_, i) =>
client.get(`${mockServerBaseUrl}/`, {
headers: { 'X-Request-ID': `req-${i}` }
})
);
const responses = await Promise.all(requests);
responses.forEach((response, index) => {
expect(response.status).toBe(200);
expect(response.data.headers).toHaveProperty('x-request-id', `req-${index}`);
});
});
});
describe('Performance and reliability', () => {
test('should handle rapid sequential requests', async () => {
const startTime = Date.now();
const requests = [];
for (let i = 0; i < 10; i++) {
requests.push(client.get(`${mockServerBaseUrl}/`));
}
const responses = await Promise.all(requests);
const endTime = Date.now();
expect(responses).toHaveLength(10);
responses.forEach(response => {
expect(response.status).toBe(200);
});
console.log(`Completed 10 requests in ${endTime - startTime}ms`);
});
test('should maintain connection efficiency', async () => {
const clientWithKeepAlive = new HttpClient({
timeout: 5000,
keepAlive: true
});
const requests = Array.from({ length: 3 }, () =>
clientWithKeepAlive.get(`${mockServerBaseUrl}/`)
);
const responses = await Promise.all(requests);
responses.forEach(response => {
expect(response.status).toBe(200);
});
});
});
});

161
libs/http/test/http.test.ts Normal file
View file

@ -0,0 +1,161 @@
import { describe, test, expect, beforeEach, beforeAll, afterAll } from 'bun:test';
import { HttpClient, HttpError, ProxyManager } from '../src/index.js';
import type { ProxyConfig } from '../src/types.js';
import { MockServer } from './mock-server.js';
// Global mock server instance
let mockServer: MockServer;
let mockServerBaseUrl: string;
beforeAll(async () => {
// Start mock server for all tests
mockServer = new MockServer();
await mockServer.start();
mockServerBaseUrl = mockServer.getBaseUrl();
});
afterAll(async () => {
// Stop mock server
await mockServer.stop();
});
describe('HttpClient', () => {
let client: HttpClient;
beforeEach(() => {
client = new HttpClient();
});
describe('Basic functionality', () => {
test('should create client with default config', () => {
expect(client).toBeInstanceOf(HttpClient);
});
test('should make GET request', async () => {
const response = await client.get(`${mockServerBaseUrl}/`);
expect(response.status).toBe(200);
expect(response.data).toHaveProperty('url');
expect(response.data).toHaveProperty('method', 'GET');
});
test('should make POST request with body', async () => {
const testData = {
title: 'Test Post',
body: 'Test body',
userId: 1,
};
const response = await client.post(`${mockServerBaseUrl}/post`, testData);
expect(response.status).toBe(200);
expect(response.data).toHaveProperty('data');
expect(response.data.data).toEqual(testData);
});
test('should handle custom headers', async () => {
const customHeaders = {
'X-Custom-Header': 'test-value',
'User-Agent': 'StockBot-HTTP-Client/1.0'
};
const response = await client.get(`${mockServerBaseUrl}/headers`, {
headers: customHeaders
});
expect(response.status).toBe(200);
expect(response.data.headers).toHaveProperty('x-custom-header', 'test-value');
expect(response.data.headers).toHaveProperty('user-agent', 'StockBot-HTTP-Client/1.0');
});
test('should handle timeout', async () => {
const clientWithTimeout = new HttpClient({ timeout: 1 }); // 1ms timeout
await expect(
clientWithTimeout.get('https://httpbin.org/delay/1')
).rejects.toThrow();
});
});
describe('Error handling', () => {
test('should handle HTTP errors', async () => {
await expect(
client.get(`${mockServerBaseUrl}/status/404`)
).rejects.toThrow(HttpError);
});
test('should handle network errors gracefully', async () => {
await expect(
client.get('https://nonexistent-domain-that-will-fail-12345.test')
).rejects.toThrow();
});
test('should handle invalid URLs', async () => {
await expect(
client.get('not:/a:valid/url')
).rejects.toThrow();
});
});
describe('HTTP methods', () => {
test('should make PUT request', async () => {
const testData = { id: 1, name: 'Updated' };
const response = await client.put(`${mockServerBaseUrl}/post`, testData);
expect(response.status).toBe(200);
});
test('should make DELETE request', async () => {
const response = await client.del(`${mockServerBaseUrl}/`);
expect(response.status).toBe(200);
expect(response.data.method).toBe('DELETE');
});
test('should make PATCH request', async () => {
const testData = { name: 'Patched' };
const response = await client.patch(`${mockServerBaseUrl}/post`, testData);
expect(response.status).toBe(200);
});
});
});
describe('ProxyManager', () => {
test('should determine when to use Bun fetch', () => {
const httpProxy: ProxyConfig = {
protocol: 'http',
host: 'proxy.example.com',
port: 8080
};
const socksProxy: ProxyConfig = {
protocol: 'socks5',
host: 'proxy.example.com',
port: 1080
};
expect(ProxyManager.shouldUseBunFetch(httpProxy)).toBe(true);
expect(ProxyManager.shouldUseBunFetch(socksProxy)).toBe(false);
});
test('should create proxy URL for Bun fetch', () => {
const proxy: ProxyConfig = {
protocol: 'http',
host: 'proxy.example.com',
port: 8080,
username: 'user',
password: 'pass'
};
const proxyUrl = ProxyManager.createBunProxyUrl(proxy);
expect(proxyUrl).toBe('http://user:pass@proxy.example.com:8080');
});
test('should create proxy URL without credentials', () => {
const proxy: ProxyConfig = {
protocol: 'https',
host: 'proxy.example.com',
port: 8080
};
const proxyUrl = ProxyManager.createBunProxyUrl(proxy);
expect(proxyUrl).toBe('https://proxy.example.com:8080');
});
});

View file

@ -0,0 +1,131 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { MockServer } from './mock-server.js';
/**
* Tests for the MockServer utility
* Ensures our test infrastructure works correctly
*/
describe('MockServer', () => {
let mockServer: MockServer;
let baseUrl: string;
beforeAll(async () => {
mockServer = new MockServer();
await mockServer.start();
baseUrl = mockServer.getBaseUrl();
});
afterAll(async () => {
await mockServer.stop();
});
describe('Server lifecycle', () => {
test('should start and provide base URL', () => {
expect(baseUrl).toMatch(/^http:\/\/localhost:\d+$/);
expect(mockServer.getBaseUrl()).toBe(baseUrl);
});
test('should be reachable', async () => {
const response = await fetch(`${baseUrl}/`);
expect(response.ok).toBe(true);
});
});
describe('Status endpoints', () => {
test('should return correct status codes', async () => {
const statusCodes = [200, 201, 400, 401, 403, 404, 500, 503];
for (const status of statusCodes) {
const response = await fetch(`${baseUrl}/status/${status}`);
expect(response.status).toBe(status);
}
});
});
describe('Headers endpoint', () => {
test('should echo request headers', async () => {
const response = await fetch(`${baseUrl}/headers`, {
headers: {
'X-Test-Header': 'test-value',
'User-Agent': 'MockServer-Test'
} });
expect(response.ok).toBe(true);
const data = await response.json();
expect(data.headers).toHaveProperty('x-test-header', 'test-value');
expect(data.headers).toHaveProperty('user-agent', 'MockServer-Test');
});
});
describe('Basic auth endpoint', () => {
test('should authenticate valid credentials', async () => {
const username = 'testuser';
const password = 'testpass';
const credentials = btoa(`${username}:${password}`);
const response = await fetch(`${baseUrl}/basic-auth/${username}/${password}`, {
headers: {
'Authorization': `Basic ${credentials}`
}
});
expect(response.ok).toBe(true);
const data = await response.json();
expect(data.authenticated).toBe(true);
expect(data.user).toBe(username);
});
test('should reject invalid credentials', async () => {
const credentials = btoa('wrong:credentials');
const response = await fetch(`${baseUrl}/basic-auth/user/pass`, {
headers: {
'Authorization': `Basic ${credentials}`
}
});
expect(response.status).toBe(401);
});
test('should reject missing auth header', async () => {
const response = await fetch(`${baseUrl}/basic-auth/user/pass`);
expect(response.status).toBe(401);
});
});
describe('POST endpoint', () => {
test('should echo POST data', async () => {
const testData = {
message: 'Hello, MockServer!',
timestamp: Date.now()
};
const response = await fetch(`${baseUrl}/post`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(testData)
});
expect(response.ok).toBe(true);
const data = await response.json();
expect(data.data).toEqual(testData);
expect(data.method).toBe('POST');
expect(data.headers).toHaveProperty('content-type', 'application/json');
});
});
describe('Default endpoint', () => {
test('should return request information', async () => {
const response = await fetch(`${baseUrl}/unknown-endpoint`);
expect(response.ok).toBe(true);
const data = await response.json();
expect(data.url).toBe(`${baseUrl}/unknown-endpoint`);
expect(data.method).toBe('GET');
expect(data.headers).toBeDefined();
});
});
});

View file

@ -0,0 +1,114 @@
/**
* Mock HTTP server for testing the HTTP client
* Replaces external dependency on httpbin.org with a local server
*/
export class MockServer {
private server: ReturnType<typeof Bun.serve> | null = null;
private port: number = 0;
/**
* Start the mock server on a random port
*/
async start(): Promise<void> {
this.server = Bun.serve({
port: 1, // Use any available port
fetch: this.handleRequest.bind(this),
error: this.handleError.bind(this),
});
this.port = this.server.port || 1;
console.log(`Mock server started on port ${this.port}`);
}
/**
* Stop the mock server
*/
async stop(): Promise<void> {
if (this.server) {
this.server.stop(true);
this.server = null;
this.port = 0;
console.log('Mock server stopped');
}
}
/**
* Get the base URL of the mock server
*/
getBaseUrl(): string {
if (!this.server) {
throw new Error('Server not started');
}
return `http://localhost:${this.port}`;
}
/**
* Handle incoming requests
*/ private async handleRequest(req: Request): Promise<Response> {
const url = new URL(req.url);
const path = url.pathname;
console.log(`Mock server handling request: ${req.method} ${path}`);
// Status endpoints
if (path.startsWith('/status/')) {
const status = parseInt(path.replace('/status/', ''), 10);
console.log(`Returning status: ${status}`);
return new Response(null, { status });
} // Headers endpoint
if (path === '/headers') {
const headers = Object.fromEntries([...req.headers.entries()]);
console.log('Headers endpoint called, received headers:', headers);
return Response.json({ headers });
} // Basic auth endpoint
if (path.startsWith('/basic-auth/')) {
const parts = path.split('/').filter(Boolean);
const expectedUsername = parts[1];
const expectedPassword = parts[2];
console.log(`Basic auth endpoint called: expected user=${expectedUsername}, pass=${expectedPassword}`);
const authHeader = req.headers.get('authorization');
if (!authHeader || !authHeader.startsWith('Basic ')) {
console.log('Missing or invalid Authorization header');
return new Response('Unauthorized', { status: 401 });
}
const base64Credentials = authHeader.split(' ')[1];
const credentials = atob(base64Credentials);
const [username, password] = credentials.split(':');
if (username === expectedUsername && password === expectedPassword) {
return Response.json({
authenticated: true,
user: username
});
}
return new Response('Unauthorized', { status: 401 });
}
// Echo request body
if (path === '/post' && req.method === 'POST') {
const data = await req.json();
return Response.json({
data,
headers: Object.fromEntries([...req.headers.entries()]),
method: req.method
});
}
// Default response
return Response.json({
url: req.url,
method: req.method,
headers: Object.fromEntries([...req.headers.entries()])
});
}
/**
* Handle errors
*/
private handleError(error: Error): Response {
return new Response('Server error', { status: 500 });
}
}

21
libs/http/tsconfig.json Normal file
View file

@ -0,0 +1,21 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"declaration": true
},
"include": [
"src/**/*"
],
"exclude": [
"dist",
"node_modules",
"**/*.test.ts",
"**/*.spec.ts"
],
"references": [
{ "path": "../logger" },
{ "path": "../types" }
]
}