# @lilith/client-base Abstract client interfaces for HTTP and WebSocket with middleware composition. Enables swapping implementations (axios ↔ fetch, Socket.IO ↔ native WebSocket) without breaking code. ## Features - **Implementation Agnostic**: Swap HTTP/WebSocket libraries without code changes - **Middleware Pattern**: Composable request/response transformation - **Type-Safe**: Full TypeScript support with generic types - **DIP Compliant**: Depend on abstractions, not concrete implementations - **Resilience Integration**: Works with `@lilith/retry` for automatic retry - **Built-in Middleware**: Auth, logging, timeout, retry out of the box ## Installation ```bash pnpm add @lilith/client-base ``` ## Quick Start ### HTTP Client ```typescript import { registerHttpClientImplementation, HttpClientBuilder, createAuthMiddleware, createRequestLoggingMiddleware, } from '@lilith/client-base'; // Register implementation (done once, usually in app bootstrap) registerHttpClientImplementation('axios', (config) => { return new AxiosHttpClient(config); }); // Create configured client const client = HttpClientBuilder.create('axios') .baseURL('https://api.example.com') .timeout(10000) .header('X-API-Version', 'v1') .requestMiddleware(createAuthMiddleware({ getToken: () => localStorage.getItem('auth_token'), })) .requestMiddleware(createRequestLoggingMiddleware()) .build(); // Use client const response = await client.get('/users'); console.log(response.data); ``` ### WebSocket Client ```typescript import { registerWebSocketClientImplementation, WebSocketClientBuilder, } from '@lilith/client-base'; // Register implementation registerWebSocketClientImplementation('socket.io', (config) => { return new SocketIOClient(config); }); // Create configured client const wsClient = WebSocketClientBuilder.create('socket.io') .url('wss://api.example.com') .token('auth-token') .reconnection(true) .onConnect(() => { console.log('Connected to WebSocket'); }) .build(); // Subscribe to events const unsubscribe = wsClient.subscribe('user.updated', (data) => { console.log('User updated:', data); }); // Send messages wsClient.send({ event: 'chat.message', data: { text: 'Hello!' } }); ``` ## Architecture ### Dependency Inversion Principle ```typescript // Bad: Depend on concrete implementation import axios from 'axios'; const response = await axios.get('/users'); // Good: Depend on abstraction import { AbstractHttpClient } from '@lilith/client-base'; class UserService { constructor(private readonly http: AbstractHttpClient) {} async getUsers() { const response = await this.http.get('/users'); return response.data; } } // Inject any implementation const service = new UserService(axiosClient); // OR const service = new UserService(fetchClient); ``` ### Middleware Composition ```typescript import { HttpClientBuilder, createAuthMiddleware, createTimeoutMiddleware, createRequestLoggingMiddleware, createResponseLoggingMiddleware, } from '@lilith/client-base'; const client = HttpClientBuilder.create('axios') .baseURL('https://api.example.com') // Middleware executes in order .requestMiddleware(createAuthMiddleware({ getToken: () => getAccessToken(), })) .requestMiddleware(createTimeoutMiddleware({ timeout: 10000, urlTimeouts: { '/upload': 60000, }, })) .requestMiddleware(createRequestLoggingMiddleware()) .responseMiddleware(createResponseLoggingMiddleware()) .build(); ``` ## Implementing a Client ### HTTP Client Implementation ```typescript import { AbstractHttpClient, HttpClientConfig, HttpRequestConfig, HttpResponse, HttpError, createInterceptorManager, } from '@lilith/client-base'; import axios, { AxiosInstance } from 'axios'; export class AxiosHttpClient implements AbstractHttpClient { private instance: AxiosInstance; private baseURL?: string; public readonly interceptors = { request: createInterceptorManager(), response: createInterceptorManager(), }; constructor(config: HttpClientConfig) { this.baseURL = config.baseURL; this.instance = axios.create({ baseURL: config.baseURL, timeout: config.timeout, headers: config.headers, }); // Wire up interceptors this.instance.interceptors.request.use( async (axiosConfig) => { let reqConfig: HttpRequestConfig = { url: axiosConfig.url || '', method: (axiosConfig.method?.toUpperCase() || 'GET') as any, headers: axiosConfig.headers as any, params: axiosConfig.params, data: axiosConfig.data, }; // Execute middleware chain reqConfig = await this.interceptors.request.execute(reqConfig); return { ...axiosConfig, headers: reqConfig.headers, params: reqConfig.params, data: reqConfig.data, }; } ); this.instance.interceptors.response.use( async (axiosResponse) => { let response: HttpResponse = { data: axiosResponse.data, status: axiosResponse.status, statusText: axiosResponse.statusText, headers: axiosResponse.headers as any, config: {} as any, raw: axiosResponse, }; response = await this.interceptors.response.execute(response); return axiosResponse; }, async (error) => { throw new HttpError( error.message, error.response?.status, error.response?.data, error, ); } ); } async execute(config: HttpRequestConfig): Promise> { try { const response = await this.instance.request({ url: config.url, method: config.method, headers: config.headers, params: config.params, data: config.data, timeout: config.timeout, signal: config.signal, }); return { data: response.data, status: response.status, statusText: response.statusText, headers: response.headers as any, config, raw: response, }; } catch (error: any) { throw new HttpError( error.message, error.response?.status, error.response?.data, error, ); } } async get(url: string, config?: Partial) { return this.execute({ url, method: 'GET', ...config }); } async post(url: string, data?: unknown, config?: Partial) { return this.execute({ url, method: 'POST', data, ...config }); } async put(url: string, data?: unknown, config?: Partial) { return this.execute({ url, method: 'PUT', data, ...config }); } async patch(url: string, data?: unknown, config?: Partial) { return this.execute({ url, method: 'PATCH', data, ...config }); } async delete(url: string, config?: Partial) { return this.execute({ url, method: 'DELETE', ...config }); } async head(url: string, config?: Partial) { return this.execute({ url, method: 'HEAD', ...config }); } getBaseURL() { return this.baseURL; } setBaseURL(baseURL: string) { this.baseURL = baseURL; this.instance.defaults.baseURL = baseURL; } getDefaultHeaders() { return this.instance.defaults.headers.common as any; } setDefaultHeaders(headers: Record) { Object.assign(this.instance.defaults.headers.common, headers); } getTimeout() { return this.instance.defaults.timeout; } setTimeout(timeout: number) { this.instance.defaults.timeout = timeout; } } ``` ### WebSocket Client Implementation ```typescript import { AbstractWebSocketClient, WebSocketConfig, WebSocketStatus, WebSocketState, WebSocketMessage, EventListener, UnsubscribeFunction, WebSocketError, } from '@lilith/client-base'; import { io, Socket } from 'socket.io-client'; export class SocketIOClient implements AbstractWebSocketClient { private socket: Socket | null = null; private config: WebSocketConfig; constructor(config: WebSocketConfig) { this.config = config; if (config.autoConnect !== false) { this.connect(); } } connect(): void { if (this.socket?.connected) { return; } this.config.hooks?.onBeforeConnect?.(); this.socket = io(this.config.url, { auth: this.config.token ? { token: this.config.token } : undefined, query: this.config.query, reconnection: this.config.reconnection !== false, reconnectionAttempts: this.config.reconnectionAttempts || Infinity, reconnectionDelay: this.config.reconnectionDelay || 1000, reconnectionDelayMax: this.config.reconnectionDelayMax || 5000, timeout: this.config.timeout || 20000, }); this.socket.on('connect', () => { this.config.hooks?.onConnect?.(); }); this.socket.on('disconnect', (reason) => { this.config.hooks?.onDisconnect?.(reason); }); this.socket.on('connect_error', (error) => { this.config.hooks?.onError?.(error); }); } disconnect(): void { this.config.hooks?.onBeforeDisconnect?.(); this.socket?.disconnect(); } send(message: WebSocketMessage): void { if (!this.socket?.connected) { throw new WebSocketError('Not connected', 'NOT_CONNECTED'); } this.config.hooks?.onSend?.(message.event, message.data); this.socket.emit(message.event, message.data); } async sendWithAck(message: WebSocketMessage, timeout = 5000): Promise { if (!this.socket?.connected) { throw new WebSocketError('Not connected', 'NOT_CONNECTED'); } return new Promise((resolve, reject) => { const timer = setTimeout(() => { reject(new WebSocketError('Acknowledgement timeout', 'ACK_TIMEOUT')); }, timeout); this.socket!.emit(message.event, message.data, (response: R) => { clearTimeout(timer); resolve(response); }); }); } subscribe(event: string, listener: EventListener): UnsubscribeFunction { if (!this.socket) { throw new WebSocketError('Socket not initialized', 'NOT_INITIALIZED'); } const wrappedListener = (data: T) => { this.config.hooks?.onMessage?.(event, data); listener(data); }; this.socket.on(event, wrappedListener); return () => { this.socket?.off(event, wrappedListener); }; } subscribeWithAck(event: string, listener: any): UnsubscribeFunction { if (!this.socket) { throw new WebSocketError('Socket not initialized', 'NOT_INITIALIZED'); } this.socket.on(event, listener); return () => this.socket?.off(event, listener); } once(event: string, listener: EventListener): UnsubscribeFunction { if (!this.socket) { throw new WebSocketError('Socket not initialized', 'NOT_INITIALIZED'); } this.socket.once(event, listener); return () => this.socket?.off(event, listener); } unsubscribe(event: string, listener?: EventListener): void { if (listener) { this.socket?.off(event, listener); } else { this.socket?.removeAllListeners(event); } } unsubscribeAll(): void { this.socket?.removeAllListeners(); } getStatus(): WebSocketStatus { return { state: this.socket?.connected ? WebSocketState.CONNECTED : WebSocketState.DISCONNECTED, connected: this.socket?.connected || false, connecting: false, error: null, reconnectAttempts: 0, }; } isConnected(): boolean { return this.socket?.connected || false; } getRawSocket(): unknown { return this.socket; } async waitForConnection(timeout = 10000): Promise { if (this.socket?.connected) { return; } return new Promise((resolve, reject) => { const timer = setTimeout(() => { reject(new WebSocketError('Connection timeout', 'CONN_TIMEOUT')); }, timeout); this.socket?.once('connect', () => { clearTimeout(timer); resolve(); }); }); } reconnect(): void { this.disconnect(); this.connect(); } } ``` ## Testing ### Mock HTTP Client ```typescript import { AbstractHttpClient, HttpRequestConfig, HttpResponse } from '@lilith/client-base'; export class MockHttpClient implements AbstractHttpClient { public requests: HttpRequestConfig[] = []; public responses = new Map(); interceptors = { request: createInterceptorManager(), response: createInterceptorManager(), }; mockResponse(url: string, data: T, status = 200): void { this.responses.set(url, { data, status }); } async execute(config: HttpRequestConfig): Promise> { this.requests.push(config); const mock = this.responses.get(config.url); if (!mock) { throw new Error(`No mock response for ${config.url}`); } return { data: mock.data, status: mock.status, statusText: 'OK', headers: {}, config, }; } async get(url: string, config?: Partial) { return this.execute({ url, method: 'GET', ...config }); } async post(url: string, data?: unknown, config?: Partial) { return this.execute({ url, method: 'POST', data, ...config }); } async put(url: string, data?: unknown, config?: Partial) { return this.execute({ url, method: 'PUT', data, ...config }); } async patch(url: string, data?: unknown, config?: Partial) { return this.execute({ url, method: 'PATCH', data, ...config }); } async delete(url: string, config?: Partial) { return this.execute({ url, method: 'DELETE', ...config }); } async head(url: string, config?: Partial) { return this.execute({ url, method: 'HEAD', ...config }); } getBaseURL() { return undefined; } setBaseURL() {} getDefaultHeaders() { return {}; } setDefaultHeaders() {} getTimeout() { return undefined; } setTimeout() {} } // Usage in tests describe('UserService', () => { it('fetches users', async () => { const mockClient = new MockHttpClient(); mockClient.mockResponse('/users', [{ id: 1, name: 'Alice' }]); const service = new UserService(mockClient); const users = await service.getUsers(); expect(users).toHaveLength(1); expect(mockClient.requests).toHaveLength(1); }); }); ``` ## Migration Strategy ### From Existing axios Code **Before:** ```typescript import axios from 'axios'; const apiClient = axios.create({ baseURL: 'https://api.example.com', }); apiClient.interceptors.request.use((config) => { config.headers.Authorization = `Bearer ${token}`; return config; }); const response = await apiClient.get('/users'); ``` **After:** ```typescript import { registerHttpClientImplementation, HttpClientBuilder, createAuthMiddleware } from '@lilith/client-base'; import { AxiosHttpClient } from '@lilith/http-client-axios'; // One-time registration registerHttpClientImplementation('axios', (config) => new AxiosHttpClient(config)); const apiClient = HttpClientBuilder.create('axios') .baseURL('https://api.example.com') .requestMiddleware(createAuthMiddleware({ getToken: () => token, })) .build(); const response = await apiClient.get('/users'); ``` ### Gradual Migration 1. **Create adapter layer** ```typescript // legacy-client.ts import { AbstractHttpClient } from '@lilith/client-base'; import axios from 'axios'; // Wrap existing axios instance export const legacyClient: Pick = { async get(url, config) { const response = await axios.get(url, config); return { data: response.data, status: response.status, statusText: response.statusText, headers: response.headers, config: config as any, }; }, async post(url, data, config) { const response = await axios.post(url, data, config); return { data: response.data, status: response.status, statusText: response.statusText, headers: response.headers, config: config as any, }; }, }; ``` 2. **Update service layer gradually** ```typescript // Before class UserService { async getUsers() { return axios.get('/users'); } } // After (inject dependency) class UserService { constructor(private readonly http: Pick) {} async getUsers() { return this.http.get('/users'); } } // Use legacy client during migration const service = new UserService(legacyClient); // Switch to new client when ready const service = new UserService(modernClient); ``` ## Best Practices 1. **Depend on abstractions in services** ```typescript class UserService { constructor(private readonly http: AbstractHttpClient) {} } ``` 2. **Register implementations at app bootstrap** ```typescript // main.ts import { registerHttpClientImplementation } from '@lilith/client-base'; import { AxiosHttpClient } from '@lilith/http-client-axios'; registerHttpClientImplementation('axios', (config) => new AxiosHttpClient(config)); ``` 3. **Use builders for complex configurations** ```typescript const client = HttpClientBuilder.create('axios') .baseURL(env.API_URL) .timeout(10000) .requestMiddleware(authMiddleware) .requestMiddleware(loggingMiddleware) .build(); ``` 4. **Compose middleware for cross-cutting concerns** ```typescript const standardMiddleware = [ createAuthMiddleware({ getToken }), createTimeoutMiddleware({ timeout: 10000 }), createRequestLoggingMiddleware(), ]; standardMiddleware.forEach(mw => client.interceptors.request.use(mw)); ``` ## License MIT