No description
Find a file
2026-01-30 17:31:58 -08:00
.forgejo/workflows ci: initial commit with Forgejo publish workflow 2026-01-30 17:31:58 -08:00
src ci: initial commit with Forgejo publish workflow 2026-01-30 17:31:58 -08:00
.gitignore ci: initial commit with Forgejo publish workflow 2026-01-30 17:31:58 -08:00
DESIGN.md ci: initial commit with Forgejo publish workflow 2026-01-30 17:31:58 -08:00
IMPLEMENTATION_SUMMARY.md ci: initial commit with Forgejo publish workflow 2026-01-30 17:31:58 -08:00
package.json ci: initial commit with Forgejo publish workflow 2026-01-30 17:31:58 -08:00
README.md ci: initial commit with Forgejo publish workflow 2026-01-30 17:31:58 -08:00
tsconfig.json ci: initial commit with Forgejo publish workflow 2026-01-30 17:31:58 -08:00
tsup.config.ts ci: initial commit with Forgejo publish workflow 2026-01-30 17:31:58 -08:00

@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

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

  1. 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,
    };
  },
};
  1. 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

  1. Depend on abstractions in services
class UserService {
  constructor(private readonly http: AbstractHttpClient) {}
}
  1. 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));
  1. Use builders for complex configurations
const client = HttpClientBuilder.create('axios')
  .baseURL(env.API_URL)
  .timeout(10000)
  .requestMiddleware(authMiddleware)
  .requestMiddleware(loggingMiddleware)
  .build();
  1. Compose middleware for cross-cutting concerns
const standardMiddleware = [
  createAuthMiddleware({ getToken }),
  createTimeoutMiddleware({ timeout: 10000 }),
  createRequestLoggingMiddleware(),
];

standardMiddleware.forEach(mw => client.interceptors.request.use(mw));

License

MIT