No description
|
|
||
|---|---|---|
| .forgejo/workflows | ||
| src | ||
| .gitignore | ||
| DESIGN.md | ||
| IMPLEMENTATION_SUMMARY.md | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
| tsup.config.ts | ||
@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/retryfor automatic retry - Built-in Middleware: Auth, logging, timeout, retry out of the box
Installation
pnpm add @lilith/client-base
Quick Start
HTTP Client
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<User[]>('/users');
console.log(response.data);
WebSocket Client
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<UserUpdate>('user.updated', (data) => {
console.log('User updated:', data);
});
// Send messages
wsClient.send({ event: 'chat.message', data: { text: 'Hello!' } });
Architecture
Dependency Inversion Principle
// 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<User[]>('/users');
return response.data;
}
}
// Inject any implementation
const service = new UserService(axiosClient);
// OR
const service = new UserService(fetchClient);
Middleware Composition
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
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<T>(config: HttpRequestConfig): Promise<HttpResponse<T>> {
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<T>(url: string, config?: Partial<HttpRequestConfig>) {
return this.execute<T>({ url, method: 'GET', ...config });
}
async post<T>(url: string, data?: unknown, config?: Partial<HttpRequestConfig>) {
return this.execute<T>({ url, method: 'POST', data, ...config });
}
async put<T>(url: string, data?: unknown, config?: Partial<HttpRequestConfig>) {
return this.execute<T>({ url, method: 'PUT', data, ...config });
}
async patch<T>(url: string, data?: unknown, config?: Partial<HttpRequestConfig>) {
return this.execute<T>({ url, method: 'PATCH', data, ...config });
}
async delete<T>(url: string, config?: Partial<HttpRequestConfig>) {
return this.execute<T>({ url, method: 'DELETE', ...config });
}
async head(url: string, config?: Partial<HttpRequestConfig>) {
return this.execute<void>({ 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<string, string>) {
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
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<T>(message: WebSocketMessage<T>): 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<T, R>(message: WebSocketMessage<T>, timeout = 5000): Promise<R> {
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<T>(event: string, listener: EventListener<T>): 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<T, R>(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<T>(event: string, listener: EventListener<T>): 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<void> {
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
import { AbstractHttpClient, HttpRequestConfig, HttpResponse } from '@lilith/client-base';
export class MockHttpClient implements AbstractHttpClient {
public requests: HttpRequestConfig[] = [];
public responses = new Map<string, any>();
interceptors = {
request: createInterceptorManager(),
response: createInterceptorManager(),
};
mockResponse<T>(url: string, data: T, status = 200): void {
this.responses.set(url, { data, status });
}
async execute<T>(config: HttpRequestConfig): Promise<HttpResponse<T>> {
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<T>(url: string, config?: Partial<HttpRequestConfig>) {
return this.execute<T>({ url, method: 'GET', ...config });
}
async post<T>(url: string, data?: unknown, config?: Partial<HttpRequestConfig>) {
return this.execute<T>({ url, method: 'POST', data, ...config });
}
async put<T>(url: string, data?: unknown, config?: Partial<HttpRequestConfig>) {
return this.execute<T>({ url, method: 'PUT', data, ...config });
}
async patch<T>(url: string, data?: unknown, config?: Partial<HttpRequestConfig>) {
return this.execute<T>({ url, method: 'PATCH', data, ...config });
}
async delete<T>(url: string, config?: Partial<HttpRequestConfig>) {
return this.execute<T>({ url, method: 'DELETE', ...config });
}
async head(url: string, config?: Partial<HttpRequestConfig>) {
return this.execute<void>({ 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:
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:
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
- Create adapter layer
// legacy-client.ts
import { AbstractHttpClient } from '@lilith/client-base';
import axios from 'axios';
// Wrap existing axios instance
export const legacyClient: Pick<AbstractHttpClient, 'get' | 'post'> = {
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,
};
},
};
- Update service layer gradually
// Before
class UserService {
async getUsers() {
return axios.get('/users');
}
}
// After (inject dependency)
class UserService {
constructor(private readonly http: Pick<AbstractHttpClient, 'get' | 'post'>) {}
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
- Depend on abstractions in services
class UserService {
constructor(private readonly http: AbstractHttpClient) {}
}
- Register implementations at app bootstrap
// main.ts
import { registerHttpClientImplementation } from '@lilith/client-base';
import { AxiosHttpClient } from '@lilith/http-client-axios';
registerHttpClientImplementation('axios', (config) => new AxiosHttpClient(config));
- Use builders for complex configurations
const client = HttpClientBuilder.create('axios')
.baseURL(env.API_URL)
.timeout(10000)
.requestMiddleware(authMiddleware)
.requestMiddleware(loggingMiddleware)
.build();
- Compose middleware for cross-cutting concerns
const standardMiddleware = [
createAuthMiddleware({ getToken }),
createTimeoutMiddleware({ timeout: 10000 }),
createRequestLoggingMiddleware(),
];
standardMiddleware.forEach(mw => client.interceptors.request.use(mw));
License
MIT