composable-http-client
TypeScript icon, indicating that this package has built-in type declarations

1.2.0 โ€ข Public โ€ข Published

Composable HTTP Client

License: MIT npm version TypeScript All Contributors

A TypeScript-first composable HTTP client library that revolutionizes API interactions through procedure builders, schema validation, and intelligent retry logic. Works seamlessly across Node.js, browsers, and all modern JavaScript environments.

๐Ÿ’ก Why This Library?

The modern web development landscape offers many excellent tools, but often forces you to make difficult trade-offs. This library was created to solve a fundamental problem: how do you get the benefits of composable, type-safe API interactions without being locked into specific frameworks or requiring particular backend setups?

The Problem:

  • Framework Lock-in: Many solutions tie you to specific frameworks (React-only, server-only, etc.)
  • Backend Requirements: Some tools require you to control the backend or use specific server implementations
  • Type Safety Gaps: Manual typing between frontend and API calls leads to runtime errors
  • Validation Scattered: Input/output validation often duplicated across frontend and backend
  • Boilerplate Everywhere: Repetitive error handling, retry logic, and transformation code

The Solution: A framework-agnostic, composable HTTP client that brings the power of procedure builders and schema validation to any HTTP API, any JavaScript environment, and any framework - without requiring changes to your backend.

Whether you're building with React, Vue, Svelte, or vanilla JavaScript, working with REST APIs, JSON-RPC over HTTP, or custom HTTP-based backends, this library adapts to your stack instead of forcing your stack to adapt to it.

Features

  • ๐ŸŽฏ Composable: Build complex HTTP procedures using a fluent API
  • ๐Ÿ”’ Type-safe: Full TypeScript support with Zod schema validation
  • ๐Ÿ”„ Retry Logic: Built-in retry mechanisms with customizable delays
  • ๐Ÿงช Dual HTTP Support: Works with both Axios and native Fetch
  • ๐ŸŽฃ Lifecycle Hooks: onStart, onSuccess, onComplete hooks
  • ๐Ÿ”ง Transform & Hooks: Transform responses and handle errors with lifecycle hooks
  • โšก Rich Error Handling: Specialized error classes with type guards for precise error handling
  • ๐ŸŒ Framework Agnostic: Works in Node.js (20+) and all modern browsers
  • ๐Ÿชถ Tiny Bundle: Only ~3.2KB gzipped - perfect for performance-conscious applications
  • ๐Ÿ“ฆ Multiple Formats: Supports both CJS and ESM for maximum compatibility

Installation

# npm
npm install composable-http-client zod

# pnpm (recommended)
pnpm add composable-http-client zod

# yarn
yarn add composable-http-client zod

Note: Zod is a dependency required for schema validation. While you can skip using .input() and .output() methods, Zod will still be included in your bundle.

๐Ÿ“ฆ Modular Entry Points

The library provides four tree-shakable entry points:

  • composable-http-client - Core functionality (procedures, builders)
  • composable-http-client/axios - Axios HTTP client adapter
  • composable-http-client/fetch - Fetch HTTP client adapter
  • composable-http-client/errors - Error classes and type guards (optional)

Quick Start

Basic Usage with Axios

import { createHttpClientProcedure } from 'composable-http-client';
import { createHttpClient } from 'composable-http-client/axios';
import { isHttpError } from 'composable-http-client/errors';
import { z } from 'zod';

// 1. Create HTTP client
const client = createHttpClient({
  baseURL: 'https://api.example.com',
  headers: { Authorization: 'Bearer your-token' },
});

// 2. Create procedure builder
const procedure = createHttpClientProcedure(client);

// Define schemas
const userSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
});

// 3. Build a type-safe procedure
const getUser = procedure()
  .input(z.object({ userId: z.number() }))
  .onStart(() => console.log('Fetching user...'))
  .retry({ retries: 3, delay: 1000 })
  .handler(async ({ input, client }) => {
    return client.get(`/users/${input.userId}`);
  })
  .output(userSchema)
  .onSuccess(() => console.log('User fetched successfully'))
  .catchAll(error => {
    if (isHttpError(error) && error.hasStatus(404)) {
      return { error: 'User not found' };
    }
    return { error: error.message };
  });

// 4. Use the procedure
const result = await getUser({ userId: 123 });
if (result.error) {
  console.error('Error:', result.error);
} else {
  console.log('User:', result.data); // Fully typed!
}

Basic Usage with Fetch (Node.js 20+ & Browsers)

import { createHttpClientProcedure } from 'composable-http-client';
import { createHttpClient } from 'composable-http-client/fetch';

// Create HTTP client using native fetch (available in Node.js 20+ and all modern browsers)
const client = createHttpClient({
  baseURL: 'https://api.example.com',
  headers: { Authorization: 'Bearer your-token' },
});

const procedure = createHttpClientProcedure(client);
// ... rest is the same

Optional baseURL for Fetch Client

The fetch client supports optional baseURL parameter, which is particularly useful in Next.js and browser environments:

import { createHttpClient } from 'composable-http-client/fetch';

// With baseURL (traditional approach)
const clientWithBase = createHttpClient({
  baseURL: 'https://api.example.com',
  headers: { Authorization: 'Bearer your-token' },
});

// Without baseURL (new feature)
const clientWithoutBase = createHttpClient({
  headers: { Authorization: 'Bearer your-token' },
});

// No parameters at all (most minimal)
const minimalClient = createHttpClient();

// Usage examples:
// With baseURL: relative paths are resolved against baseURL
await clientWithBase.get('/users'); // -> https://api.example.com/users

// Without baseURL: URLs are passed directly to fetch()
await clientWithoutBase.get('/api/users'); // -> /api/users (relative to current page in browser/Next.js)
await clientWithoutBase.get('https://api.example.com/users'); // -> absolute URL works too

// Minimal client: same behavior as without baseURL
await minimalClient.get('/api/users'); // -> /api/users (relative to current page)
await minimalClient.get('https://api.example.com/users'); // -> absolute URL works too

Environment Behavior:

  • Browser/Next.js: Relative URLs like /api/users resolve against the current page URL
  • Node.js: Relative URLs require a base context, so use absolute URLs or provide a baseURL

Note: Axios client requires baseURL to maintain consistency with axios behavior.

Environment Compatibility

This library is framework agnostic and works seamlessly across different JavaScript environments:

๐ŸŸข Node.js Support

  • Minimum version: Node.js 20.0.0+
  • Axios adapter: Full support with interceptors
  • Fetch adapter: Uses built-in fetch API (Node.js 20+)
  • CommonJS & ESM: Both module systems supported

๐ŸŒ Browser Support

  • Modern browsers: Chrome, Firefox, Safari, Edge (ES2020+ compatible)
  • Axios adapter: Full support in all browsers
  • Fetch adapter: Uses native fetch API
  • Bundlers: Webpack, Vite, Rollup, Parcel compatible

๐Ÿ“ฑ React Native

  • Axios adapter: Full support
  • Fetch adapter: Uses React Native's built-in fetch

โš™๏ธ Other Environments

  • Deno: Compatible with both adapters
  • Bun: Full compatibility
  • Web Workers: Both adapters work
  • Service Workers: Fetch adapter recommended

๐ŸŽฏ Choosing the Right Adapter

// For maximum compatibility across all environments
import { createHttpClient } from 'composable-http-client/axios';

// For modern environments with native fetch (Node.js 20+, browsers)
import { createHttpClient } from 'composable-http-client/fetch';

// The core procedure builder works with any HTTP client
import { createHttpClientProcedure } from 'composable-http-client';

// Import error classes for type-safe error handling (tree-shakable)
import { HttpError, isHttpError } from 'composable-http-client/errors';

๐ŸŒ Supported Backend Types

This library is specifically designed for HTTP-based APIs and excels with:

โœ… Fully Supported

  • REST APIs - Perfect fit with full HTTP verb support (GET, POST, PUT, DELETE, PATCH)
  • JSON-RPC over HTTP - Excellent for procedure-based APIs
  • Custom HTTP APIs - Any API that communicates over HTTP/HTTPS
  • Microservices - Great for composing calls across multiple HTTP services
  • Third-party APIs - Works with any external HTTP API (Stripe, GitHub, etc.)

๐ŸŸก Limited Support

  • GraphQL over HTTP - Technically possible but not recommended
    • Use dedicated GraphQL clients (Apollo, Relay) for better DX
    • GraphQL already provides its own type system and validation

โŒ Not Supported

  • WebSockets - Real-time communication (use Socket.io, native WebSockets)
  • gRPC - Protocol buffer-based communication
  • Database drivers - Direct database connections (use ORMs, query builders)
  • File system operations - Local file access
  • Message queues - Pub/sub systems (Redis, RabbitMQ, etc.)

Why HTTP-only? This library focuses on HTTP to provide the best possible developer experience for the most common API communication pattern, rather than trying to be a universal communication layer.

API Reference

createHttpClientProcedure(client)

Creates a procedure builder that can be used to compose HTTP operations.

Procedure Methods

.input(schema)

Validates input parameters using a Zod schema.

.handler(fn)

Defines the main logic for the procedure.

.output(schemaOrFn)

Validates output using a Zod schema or dynamic schema function.

.retry(options)

Configures retry behavior:

.retry({
  retries: 3,
  delay: 1000 // or (currentAttempt, error) => currentAttempt * 1000
})

.transform(fn)

Transforms the output before validation:

.transform((output) => ({
  ...output,
  timestamp: new Date().toISOString()
}))

Lifecycle Hooks

  • .onStart(fn) - Called before execution
  • .onSuccess(fn) - Called on successful completion
  • .onComplete(fn) - Called after execution (success or failure)

.catchAll(fn)

Handles errors and makes the procedure callable:

.catchAll((error) => ({
  error: error.message,
  code: error.code
}))

HTTP Client Configuration

Both Axios and Fetch clients support comprehensive configuration:

interface ClientConfig<Tokens = Record<string, string>> {
  baseURL: string;
  timeout?: number;
  headers?: Record<string, string> | ((tokens: Tokens) => Record<string, string>);
  getTokens?: () => Tokens;
  refreshToken?: () => Promise<void>;
  logError?: (error: unknown) => Promise<void>;
  addTracing?: (context: TracingContext) => Promise<void>;
}

Advanced Features

Automatic Token Refresh

Automatically refresh tokens on 401 responses:

const client = createHttpClient({
  baseURL: 'https://api.example.com',
  getTokens: () => ({
    accessToken: localStorage.getItem('accessToken') || '',
    refreshToken: localStorage.getItem('refreshToken') || '',
  }),
  refreshToken: async () => {
    const refreshToken = localStorage.getItem('refreshToken');
    const response = await fetch('/auth/refresh', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ refreshToken }),
    });

    const { accessToken, refreshToken: newRefreshToken } = await response.json();
    localStorage.setItem('accessToken', accessToken);
    localStorage.setItem('refreshToken', newRefreshToken);
  },
  headers: tokens => ({
    Authorization: `Bearer ${tokens.accessToken}`,
  }),
});

Complex Error Handling

Handle different error types with structured responses:

const robustProcedure = procedure()
  .input(userInputSchema)
  .retry({
    retries: 3,
    delay: (currentAttempt, error) => {
      // Exponential backoff for server errors
      if (error.response?.status >= 500) {
        return Math.pow(2, currentAttempt) * 1000;
      }
      // Fixed delay for rate limiting
      if (error.response?.status === 429) {
        return 5000;
      }
      // No retry for client errors
      return 0;
    },
  })
  .handler(async ({ input, client }) => {
    return client.post('/users', input);
  })
  .catchAll(error => {
    if (error.response?.status === 400) {
      return {
        type: 'VALIDATION_ERROR',
        message: 'Invalid input data',
        details: error.response.data.errors,
        retryable: false,
      };
    }

    if (error.response?.status >= 500) {
      return {
        type: 'SERVER_ERROR',
        message: 'Server temporarily unavailable',
        retryable: true,
        retryAfter: 30000,
      };
    }

    return {
      type: 'UNKNOWN_ERROR',
      message: error.message,
      retryable: false,
    };
  });

Dynamic Output Schemas

Use dynamic schemas based on context:

.output(({ ctx, input, output }) => {
  if (input.includeMetadata) {
    return userWithMetadataSchema;
  }
  return userSchema;
})

Extended Procedures

Extend existing procedures with additional context:

import { extendProcedure } from 'composable-http-client';

const baseProcedure = createHttpClientProcedure(client);

const withAuth = extendProcedure(baseProcedure).handler(({ ctx }) => {
  const user = getCurrentUser();
  if (user.role !== 'admin') {
    throw new Error('Insufficient permissions');
  }
  return { ...ctx, user };
});

const authenticatedProcedure = withAuth()
  .input(z.object({ userId: z.string() }))
  .handler(async ({ input, ctx, client }) => {
    return client.get(`/admin/users/${input.userId}`);
  })
  .catchAll(error => ({ error: error.message }));

File Upload & Form Data

Handle file uploads with proper form data:

const uploadFile = procedure()
  .input(
    z.object({
      file: z.instanceof(File),
      metadata: z.object({
        title: z.string(),
        description: z.string().optional(),
      }),
    })
  )
  .handler(async ({ input, client }) => {
    const formData = new FormData();
    formData.append('file', input.file);
    formData.append('metadata', JSON.stringify(input.metadata));

    return client.post('/upload', formData, {
      headers: { 'Content-Type': 'multipart/form-data' },
    });
  })
  .output(
    z.object({
      fileId: z.string(),
      url: z.string().url(),
      size: z.number(),
    })
  )
  .catchAll(error => ({ error: error.message }));

Error Handling

Import Error Classes

import {
  HttpError,
  TimeoutError,
  ValidationError,
  RetryError,
  TokenRefreshError,
  NetworkError,
  ConfigurationError,
  isHttpError,
  isTimeoutError,
  isValidationError,
  isRetryError,
  isTokenRefreshError,
  isNetworkError,
  isConfigurationError,
  type ComposableHttpErrorType,
} from 'composable-http-client/errors';

Error Types

This library provides specialized error classes for different failure scenarios:

HttpError

Thrown when HTTP requests fail with specific status codes.

const getUserProcedure = procedure()
  .input(z.object({ userId: z.number() }))
  .handler(async ({ input, client }) => {
    return client.get(`/users/${input.userId}`);
  })
  .catchAll(error => {
    if (isHttpError(error)) {
      console.log(`HTTP ${error.response.status}: ${error.message}`);

      // Check error categories
      if (error.isClientError) {
        console.log('Client error (4xx)');
      }
      if (error.isServerError) {
        console.log('Server error (5xx)');
      }

      // Check specific status codes
      if (error.hasStatus(404)) {
        return { type: 'NOT_FOUND', message: 'User not found' };
      }
      if (error.hasStatus(401)) {
        return { type: 'UNAUTHORIZED', message: 'Authentication required' };
      }

      // Access response data
      console.log('Response data:', error.response.data);
      console.log('Response headers:', error.response.headers);
    }

    return { error: error.message };
  });

TimeoutError

Thrown when requests exceed the configured timeout.

.catchAll((error) => {
  if (isTimeoutError(error)) {
    console.log(`Request timed out after ${error.timeout}ms`);
    return { type: 'TIMEOUT', message: 'Request took too long' };
  }

  return { error: error.message };
});

ValidationError

Thrown when input or output schema validation fails.

.catchAll((error) => {
  if (isValidationError(error)) {
    console.log(`${error.validationType} validation failed:`, error.zodError);
    return {
      type: 'VALIDATION_ERROR',
      message: `Invalid ${error.validationType}`,
      details: error.zodError
    };
  }

  return { error: error.message };
});

RetryError

Thrown when all retry attempts are exhausted.

.catchAll((error) => {
  if (isRetryError(error)) {
    console.log(`All ${error.attempts} retry attempts failed`);
    console.log('Last error:', error.lastError.message);
    return { type: 'RETRY_EXHAUSTED', message: 'Service temporarily unavailable' };
  }

  return { error: error.message };
});

TokenRefreshError

Thrown when automatic token refresh fails.

.catchAll((error) => {
  if (isTokenRefreshError(error)) {
    console.log('Token refresh failed:', error.originalError?.message);
    return { type: 'AUTH_ERROR', message: 'Please log in again' };
  }

  return { error: error.message };
});

NetworkError

Thrown for network-related failures (connection refused, DNS issues, etc.).

.catchAll((error) => {
  if (isNetworkError(error)) {
    console.log('Network error:', error.originalError?.message);
    return { type: 'NETWORK_ERROR', message: 'Check your internet connection' };
  }

  return { error: error.message };
});

ConfigurationError

Thrown for configuration or setup issues.

.catchAll((error) => {
  if (isConfigurationError(error)) {
    console.log(`Configuration error in field: ${error.field}`);
    return { type: 'CONFIG_ERROR', message: 'Invalid configuration' };
  }

  return { error: error.message };
});

Comprehensive Error Handling

Use type guards to handle different error types in a single .catchAll():

const robustProcedure = procedure()
  .input(userInputSchema)
  .retry({ retries: 3, delay: 1000 })
  .handler(async ({ input, client }) => {
    return client.get(`/users/${input.userId}`);
  })
  .catchAll((error: Error) => {
    // HTTP errors
    if (isHttpError(error)) {
      if (error.hasStatus(404)) {
        return { type: 'user_not_found', message: 'User not found' };
      }
      if (error.hasStatus(401)) {
        return { type: 'unauthorized', message: 'Authentication required' };
      }
      if (error.isServerError) {
        return { type: 'server_error', message: 'Server is unavailable' };
      }
    }

    // Timeout errors
    if (isTimeoutError(error)) {
      return { type: 'timeout', message: 'Request took too long' };
    }

    // Validation errors
    if (isValidationError(error)) {
      return {
        type: 'validation_error',
        message: 'Invalid data',
        validationType: error.validationType,
      };
    }

    // Retry errors
    if (isRetryError(error)) {
      return { type: 'retry_exhausted', message: 'Service temporarily unavailable' };
    }

    // Token refresh errors
    if (isTokenRefreshError(error)) {
      return { type: 'auth_error', message: 'Please log in again' };
    }

    // Network errors
    if (isNetworkError(error)) {
      return { type: 'network_error', message: 'Check your internet connection' };
    }

    // Configuration errors
    if (isConfigurationError(error)) {
      return { type: 'config_error', message: 'Invalid configuration' };
    }

    // Generic error fallback
    return { type: 'unknown_error', message: 'Something went wrong' };
  });

Creating Custom Error Handlers

Create reusable error handlers for consistent error management:

// Reusable error handler
function createErrorHandler<T>(defaultResponse: T) {
  return (error: Error): T => {
    if (isHttpError(error)) {
      // Log HTTP errors for monitoring
      console.error('HTTP Error:', {
        status: error.response.status,
        url: error.response.url,
        data: error.response.data,
      });

      return defaultResponse;
    }

    if (isTimeoutError(error)) {
      // Log timeout errors
      console.warn(`Request timeout: ${error.timeout}ms`);
      return defaultResponse;
    }

    // Log unexpected errors
    console.error('Unexpected error:', error.message);
    return defaultResponse;
  };
}

// Use the reusable handler
const getUser = procedure()
  .input(z.object({ userId: z.number() }))
  .handler(async ({ input, client }) => {
    return client.get(`/users/${input.userId}`);
  })
  .catchAll(createErrorHandler({ error: 'Failed to fetch user' }));

Testing Your Procedures

Unit Testing

import { describe, it, expect, vi } from 'vitest';
import { createHttpClientProcedure } from 'composable-http-client';

describe('User Procedures', () => {
  // Mock HTTP client
  const mockClient = {
    get: vi.fn(),
    post: vi.fn(),
    put: vi.fn(),
    delete: vi.fn(),
  };

  const procedure = createHttpClientProcedure(mockClient);

  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('should fetch user successfully', async () => {
    // Arrange
    mockClient.get.mockResolvedValue({
      id: 1,
      name: 'John Doe',
      email: 'john@example.com',
    });

    const getUser = procedure()
      .input(z.object({ userId: z.number() }))
      .handler(async ({ input, client }) => {
        return client.get(`/users/${input.userId}`);
      })
      .output(userSchema)
      .catchAll(error => ({ error: error.message }));

    // Act
    const result = await getUser({ userId: 1 });

    // Assert
    expect(result.error).toBeNull();
    expect(result.data).toEqual({
      id: 1,
      name: 'John Doe',
      email: 'john@example.com',
    });
    expect(mockClient.get).toHaveBeenCalledWith('/users/1');
  });
});

Integration Testing with MSW

import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';

const server = setupServer(
  http.get('https://api.example.com/users/:id', ({ params }) => {
    const { id } = params;
    return HttpResponse.json({
      id: Number(id),
      name: `User ${id}`,
      email: `user${id}@example.com`,
    });
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Troubleshooting

Common Issues

Type Inference Problems

// โŒ Type inference lost without input schema
const procedure = createHttpClientProcedure(client).handler(({ input }) => {
  // input type is 'unknown' because no .input() was called
  return client.get('/users');
});

// โœ… Proper type inference with input schema
const userInputSchema = z.object({ userId: z.string() });

const procedure = createHttpClientProcedure(client)
  .input(userInputSchema) // This enables type inference
  .handler(({ input }) => {
    // input is now properly typed as { userId: string }
    return client.get(`/users/${input.userId}`);
  });

Schema Validation Failures

// โœ… Detailed error handling
.catchAll(error => {
  if (error instanceof z.ZodError) {
    return {
      error: 'Validation failed',
      details: error.issues.map(issue => ({
        path: issue.path.join('.'),
        message: issue.message
      }))
    };
  }

  return { error: error.message };
});

Debug Mode

Enable detailed logging for troubleshooting:

const client = createHttpClient({
  baseURL: 'https://api.example.com',
  logError: async error => {
    console.error('HTTP Error:', {
      message: error.message,
      status: error.response?.status,
      data: error.response?.data,
      config: error.config,
    });
  },
  addTracing: async ({ method, url, config }) => {
    console.log(`๐Ÿ” ${method.toUpperCase()} ${url}`, {
      headers: config.headers,
      data: config.data,
    });
  },
});

Real-World Examples

E-commerce API Integration

// Product catalog with pagination
const getProducts = procedure()
  .input(
    z.object({
      category: z.string().optional(),
      page: z.number().default(1),
      limit: z.number().max(100).default(20),
      sortBy: z.enum(['price', 'name', 'rating']).default('name'),
    })
  )
  .handler(async ({ input, client }) => {
    const params = new URLSearchParams();
    Object.entries(input).forEach(([key, value]) => {
      if (value !== undefined) params.set(key, String(value));
    });

    return client.get(`/products?${params}`);
  })
  .output(
    z.object({
      products: z.array(productSchema),
      total: z.number(),
      hasMore: z.boolean(),
    })
  )
  .retry({ retries: 2, delay: 1000 })
  .catchAll(error => ({ error: error.message }));

// Order creation with inventory validation
const createOrder = procedure()
  .input(
    z.object({
      items: z.array(
        z.object({
          productId: z.string(),
          quantity: z.number().positive(),
        })
      ),
      shippingAddress: addressSchema,
      paymentMethod: z.string(),
    })
  )
  .onStart(() => analytics.track('order_creation_started'))
  .handler(async ({ input, client }) => {
    // Validate inventory first
    const inventoryCheck = await client.post('/inventory/check', {
      items: input.items,
    });

    if (!inventoryCheck.available) {
      throw new Error('Some items are out of stock');
    }

    return client.post('/orders', input);
  })
  .output(orderSchema)
  .onSuccess(() => {
    analytics.track('order_created');
  })
  .catchAll(error => ({
    error: error.message,
    code: error.code,
    recoverable: error.response?.status !== 400,
  }));

Framework Integration

React & Next.js

// hooks/useApiProcedure.ts
import { useCallback, useMemo } from 'react';
import { createHttpClient } from 'composable-http-client/fetch';
import { createHttpClientProcedure } from 'composable-http-client';

export const useApiProcedure = () => {
  const client = useMemo(() =>
    createHttpClient({
      baseURL: process.env.NEXT_PUBLIC_API_URL || '/api',
      headers: (tokens) => ({
        'Content-Type': 'application/json',
        ...(tokens.accessToken && { Authorization: `Bearer ${tokens.accessToken}` })
      }),
      getTokens: () => ({
        accessToken: localStorage.getItem('token') || ''
      })
    }), []
  );

  return useCallback(() => createHttpClientProcedure(client), [client]);
};

// components/UserProfile.tsx
import { useState, useEffect } from 'react';
import { useApiProcedure } from '../hooks/useApiProcedure';
import { z } from 'zod';

const userSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email()
});

export function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const procedure = useApiProcedure();

  useEffect(() => {
    const getUser = procedure()
      .input(z.object({ id: z.string() }))
      .handler(async ({ input, client }) => {
        return client.get(`/users/${input.id}`);
      })
      .output(userSchema)
      .onStart(() => setLoading(true))
      .onSuccess(() => setLoading(false))
      .catchAll(error => ({ error: error.message }));

    getUser({ id: userId }).then(result => {
      if (result.error) {
        setError(result.error);
      } else {
        setUser(result.data);
      }
      setLoading(false);
    });
  }, [userId, procedure]);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  return <div>Welcome, {user?.name}!</div>;
}

Vue 3 & Nuxt

// composables/useApi.ts
import { createHttpClient } from 'composable-http-client/axios';
import { createHttpClientProcedure } from 'composable-http-client';

export const useApi = () => {
  const config = useRuntimeConfig();

  const client = createHttpClient({
    baseURL: config.public.apiBase,
    headers: (tokens) => ({
      'Content-Type': 'application/json',
      ...(tokens.token && { Authorization: `Bearer ${tokens.token}` })
    }),
    getTokens: () => {
      const token = useCookie('auth-token');
      return { token: token.value || '' };
    }
  });

  return createHttpClientProcedure(client);
};

// pages/users/[id].vue
<template>
  <div>
    <div v-if="pending">Loading...</div>
    <div v-else-if="error">Error: {{ error }}</div>
    <div v-else>{{ data?.name }}</div>
  </div>
</template>

<script setup>
const route = useRoute();
const api = useApi();

const getUser = api()
  .input(z.object({ id: z.string() }))
  .handler(async ({ input, client }) => {
    return client.get(`/users/${input.id}`);
  })
  .output(userSchema)
  .catchAll(error => ({ error: error.message }));

const { data, pending, error } = await useAsyncData('user', () =>
  getUser({ id: route.params.id as string })
);
</script>

Svelte & SvelteKit

// lib/api.ts
import { createHttpClient } from 'composable-http-client/fetch';
import { createHttpClientProcedure } from 'composable-http-client';
import { browser } from '$app/environment';
import { page } from '$app/stores';

const client = createHttpClient({
  baseURL: browser ? '/api' : 'http://localhost:5173/api',
  headers: tokens => ({
    'Content-Type': 'application/json',
    ...(tokens.sessionId && { 'X-Session-ID': tokens.sessionId }),
  }),
  getTokens: () => ({
    sessionId: browser ? document.cookie.split('sessionId=')[1]?.split(';')[0] : '',
  }),
});

export const api = createHttpClientProcedure(client);

// routes/users/[id]/+page.ts
import { api } from '$lib/api';
import { z } from 'zod';

const userSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string(),
});

export async function load({ params }) {
  const getUser = api()
    .input(z.object({ id: z.string() }))
    .handler(async ({ input, client }) => {
      return client.get(`/users/${input.id}`);
    })
    .output(userSchema)
    .catchAll(error => ({ error: error.message }));

  const result = await getUser({ id: params.id });

  if (result.error) {
    throw error(500, result.error);
  }

  return {
    user: result.data,
  };
}

Detailed API Reference

Core Types

// Result type for all procedures
type Result<TData, TError> = {
  data: TData | null;
  error: TError | null;
};

// Procedure configuration interface
interface ProcedureConfig {
  readonly inputSchema?: ZodType;
  readonly outputSchemaOrFn?: OutputSchemaOrFn;
  readonly mainHandler?: HandlerFunction;
  readonly transformFn?: TransformFunction;
  readonly retryOptions: RetryOptions;
  readonly onStartFn?: () => void | Promise<void>;
  readonly onSuccessFn?: () => void | Promise<void>;
  readonly onCompleteFn?: (info: {
    readonly isSuccess: boolean;
    readonly isError: boolean;
    readonly input: unknown;
    readonly output: unknown;
    readonly error: Error | undefined;
  }) => void | Promise<void>;
  readonly catchAllFn?: CatchAllFn;
  readonly ctx: unknown;
  readonly client: unknown;
}

// Retry configuration
interface RetryOptions {
  retries: number;
  delay: number | RetryDelay;
}

type RetryDelay = (currentAttempt: number, error: Error) => number;

type CompleteFn = (info: {
  readonly isSuccess: boolean;
  readonly isError: boolean;
  readonly input: unknown;
  readonly output: unknown;
  readonly error: Error | undefined;
}) => void | Promise<void>;

Procedure Builder Methods

.input<TInputSchema>(schema: TInputSchema)

Purpose: Validates and types input parameters using Zod schema.

Parameters:

  • schema: ZodType - Zod schema for input validation

Returns: Procedure builder with typed input

Example:

.input(z.object({
  userId: z.string().uuid(),
  includeProfile: z.boolean().optional().default(false),
  fields: z.array(z.string()).optional()
}))

Error Handling: Throws validation error if input doesn't match schema.

.handler<TOutput>(fn: HandlerFunction)

Purpose: Defines the main procedure logic.

Parameters:

  • fn: HandlerFunction - Function that executes the HTTP request

Function Signature:

type HandlerFunction = (params: {
  readonly input: TInput;
  readonly ctx: TContext;
  readonly client: TClient;
}) => Promise<TOutput> | TOutput;

Returns: Procedure builder with output type

Example:

.handler(async ({ input, ctx, client }) => {
  // input is fully typed based on .input() schema
  // ctx contains context from extended procedures
  // client is the HTTP client instance

  const response = await client.get(`/users/${input.userId}`, {
    params: {
      include_profile: input.includeProfile,
      fields: input.fields?.join(',')
    }
  });

  return response;
})

.output<TOutputSchema>(schemaOrFn: TOutputSchema)

Purpose: Validates output using static or dynamic schema.

Parameters:

  • schemaOrFn: ZodType | OutputSchemaFunction - Schema or function returning schema

Static Schema Example:

.output(z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
  profile: z.object({
    avatar: z.string().url(),
    bio: z.string()
  }).optional()
}))

Dynamic Schema Example:

.output(({ input, ctx, output }) => {
  if (input.includeProfile) {
    return userWithProfileSchema;
  }
  return basicUserSchema;
})

.retry(options: RetryOptions)

Purpose: Configures retry behavior for failed requests.

Options:

interface RetryOptions {
  retries: number; // Number of retry attempts (default: 1)
  delay: number | RetryDelay; // Delay between retries
}

type RetryDelay = (currentAttempt: number, error: Error) => number;

Examples:

// Fixed delay
.retry({ retries: 3, delay: 1000 })

// Exponential backoff
.retry({
  retries: 5,
  delay: (currentAttempt, error) => Math.pow(2, currentAttempt) * 1000
})

// Conditional retry based on error
.retry({
  retries: 3,
  delay: (currentAttempt, error) => {
    if (error.response?.status === 429) return 5000; // Rate limit
    if (error.response?.status >= 500) return currentAttempt * 2000; // Server error
    return 0; // Don't retry client errors
  }
})

.transform<TTransformed>(fn: TransformFunction)

Purpose: Transforms output before validation.

Function Signature:

type TransformFunction<TOutput, TTransformed> = (
  output: TOutput
) => TTransformed | Promise<TTransformed>;

Examples:

// Add metadata
.transform((output) => ({
  ...output,
  fetchedAt: new Date().toISOString(),
  version: '1.0'
}))

// Transform data structure
.transform((output) => ({
  ...output,
  displayName: output.name?.split(' ')[0] || 'Unknown'
}))

// Async transformation
.transform(async (output) => {
  const enriched = await enrichUserData(output);
  return enriched;
})

Lifecycle Hooks

.onStart(fn: () => void | Promise<void>)

Called before procedure execution.

.onStart(async () => {
  console.log('Starting user fetch...');
  analytics.track('user_fetch_started');
  showLoadingSpinner();
})

.onSuccess(fn: () => void | Promise<void>)

Called on successful completion.

.onSuccess(async () => {
  console.log('User fetched successfully');
  analytics.track('user_fetch_success');
})

.onComplete(fn: CompleteFn)

Called after execution (success or failure).

.onComplete(async ({ isSuccess, isError, input, output, error }) => {
  console.log(`Request completed`);
  hideLoadingSpinner();

  if (isError && error) {
    analytics.track('user_fetch_error', { error: error.message });
  } else if (isSuccess && output) {
    analytics.track('user_fetch_complete', { userId: output.id });
  }
})

Production Deployment Guide

Environment Configuration

// config/http-client.ts
import { createHttpClient } from 'composable-http-client/fetch';

const createProductionClient = () => {
  return createHttpClient({
    baseURL: process.env.API_BASE_URL,
    timeout: parseInt(process.env.API_TIMEOUT || '30000'),
    headers: tokens => ({
      'Content-Type': 'application/json',
      'User-Agent': `${process.env.APP_NAME}/${process.env.APP_VERSION}`,
      ...(tokens.accessToken && {
        Authorization: `Bearer ${tokens.accessToken}`,
      }),
    }),
    getTokens: () => ({
      accessToken: getSecureToken(),
      refreshToken: getRefreshToken(),
    }),
    refreshToken: async () => {
      await refreshAuthTokens();
    },
    logError: async error => {
      // Send to monitoring service
      await logger.error('HTTP Client Error', {
        message: error.message,
        stack: error.stack,
        url: error.config?.url,
        method: error.config?.method,
        status: error.response?.status,
        data: error.response?.data,
      });
    },
    addTracing: async ({ method, url, config }) => {
      // Add distributed tracing
      const span = tracer.startSpan(`http_${method.toLowerCase()}`);
      span.setAttributes({
        'http.method': method,
        'http.url': url,
        'http.user_agent': config.headers?.['User-Agent'],
      });
    },
  });
};

Error Monitoring

// utils/error-monitoring.ts
import { captureException, addBreadcrumb } from '@sentry/node';

export const createMonitoredProcedure = (client: HttpClient) => {
  return createHttpClientProcedure(client)
    .onStart(() => {
      addBreadcrumb({
        message: 'HTTP request started',
        category: 'http',
        level: 'info',
      });
    })
    .onComplete(({ isSuccess, isError, input, output, error }) => {
      if (isError && error) {
        captureException(error, {
          tags: {
            component: 'http-client',
          },
        });
      }
    });
};

Caching Strategies

// utils/cache.ts
import { LRUCache } from 'lru-cache';

const cache = new LRUCache<string, any>({
  max: 500,
  ttl: 1000 * 60 * 5, // 5 minutes
});

export const createCachedProcedure = (client: HttpClient) => {
  return createHttpClientProcedure(client).handler(async ({ input, client }) => {
    const cacheKey = JSON.stringify(input);

    // Check cache first
    const cached = cache.get(cacheKey);
    if (cached) {
      return cached;
    }

    // Make request
    const result = await client.get('/data', { params: input });

    // Cache result
    cache.set(cacheKey, result);

    return result;
  });
};

Supercharge with Data Fetching Libraries

This library is designed to work seamlessly with data fetching and caching libraries, giving your HTTP procedures superpowers:

With React Query / TanStack Query

import { useQuery, useMutation } from '@tanstack/react-query';
import { createHttpClient } from 'composable-http-client/fetch';
import { createHttpClientProcedure } from 'composable-http-client';

const client = createHttpClient({ baseURL: '/api' });
const procedure = createHttpClientProcedure(client);

// Create type-safe procedures
const getUser = procedure()
  .input(z.object({ id: z.string() }))
  .handler(async ({ input, client }) => client.get(`/users/${input.id}`))
  .output(userSchema)
  .catchAll(error => ({ error: error.message }));

const updateUser = procedure()
  .input(z.object({ id: z.string(), data: updateUserSchema }))
  .handler(async ({ input, client }) =>
    client.put(`/users/${input.id}`, input.data)
  )
  .output(userSchema)
  .catchAll(error => ({ error: error.message }));

// Use with React Query for caching, background updates, etc.
function UserProfile({ userId }: { userId: string }) {
  const { data: user, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => getUser({ id: userId }),
    staleTime: 5 * 60 * 1000, // 5 minutes
  });

  const mutation = useMutation({
    mutationFn: updateUser,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['user', userId] });
    },
  });

  // React Query handles caching, loading states, error states
  // Composable HTTP Client handles validation, retry logic, type safety
  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  return <UserForm user={user} onSave={mutation.mutate} />;
}

Why This Approach Works

  • ๐ŸŽฏ Focused Responsibilities: Data fetching libraries handle caching, background updates, and state management
  • ๐Ÿ”’ Type Safety: Composable HTTP Client ensures end-to-end type safety and validation
  • ๐Ÿ”„ Retry Logic: Built-in retry with exponential backoff
  • ๐Ÿ“ Input Validation: Prevent invalid requests before they're sent
  • ๐Ÿ›ก๏ธ Error Handling: Structured error responses that work with library error boundaries
  • ๐ŸŒ Framework Agnostic: Use the same procedures across React, Vue, Svelte, etc.

Comparison with Other Solutions

Feature Composable HTTP Client Axios Native Fetch
Type Safety โœ… End-to-end with Zod โŒ Manual typing โŒ No typing
Input Validation โœ… Built-in with schemas โŒ Manual validation โŒ None
Output Validation โœ… Runtime validation โŒ No validation โŒ No validation
Retry Logic โœ… Built-in configurable ๐Ÿ”ง Plugin required โŒ Manual implementation
Composability โœ… Procedure builders โŒ Not composable โŒ Not composable
Error Handling โœ… Structured & typed ๐Ÿ”ง Manual setup ๐Ÿ”ง Manual try/catch
Request/Response Transformation โœ… Built-in โœ… Interceptors ๐Ÿ”ง Manual
Lifecycle Hooks โœ… onStart, onSuccess, onComplete โŒ None โŒ None
Interceptors โŒ None (has lifecycle hooks) โœ… Full interceptor API โŒ None
Browser Support โœ… Modern browsers โœ… IE11+ โœ… Modern browsers
Node.js Support โœ… 20+ โœ… All versions โœ… 18+ (native)
Learning Curve ๐Ÿ“š Medium ๐Ÿ“š Low ๐Ÿ“š Low

Migration Guides

From Axios

Before (Axios):

import axios from 'axios';

const api = axios.create({
  baseURL: 'https://api.example.com',
  timeout: 10000,
  headers: {
    'Content-Type': 'application/json',
  },
});

// Add interceptors
api.interceptors.request.use(
  config => {
    const token = localStorage.getItem('token');
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  error => Promise.reject(error)
);

api.interceptors.response.use(
  response => response,
  async error => {
    if (error.response?.status === 401) {
      await refreshToken();
      return api.request(error.config);
    }
    return Promise.reject(error);
  }
);

// Usage
const getUser = async (userId: string) => {
  try {
    const response = await api.get(`/users/${userId}`);
    return response.data;
  } catch (error) {
    console.error('Failed to fetch user:', error);
    throw error;
  }
};

After (Composable HTTP Client):

import { createHttpClient } from 'composable-http-client/axios';
import { createHttpClientProcedure } from 'composable-http-client';
import { z } from 'zod';

const client = createHttpClient({
  baseURL: 'https://api.example.com',
  timeout: 10000,
  headers: tokens => ({
    'Content-Type': 'application/json',
    ...(tokens.accessToken && { Authorization: `Bearer ${tokens.accessToken}` }),
  }),
  getTokens: () => ({
    accessToken: localStorage.getItem('token') || '',
  }),
  refreshToken: async () => {
    await refreshToken();
  },
});

const procedure = createHttpClientProcedure(client);

const getUser = procedure()
  .input(z.object({ userId: z.string() }))
  .handler(async ({ input, client }) => {
    return client.get(`/users/${input.userId}`);
  })
  .output(userSchema)
  .retry({ retries: 3, delay: 1000 })
  .catchAll(error => ({
    error: error.message,
    code: error.response?.status,
  }));

From Manual Fetch

Before (Manual Fetch):

const fetchUser = async (userId: string) => {
  try {
    const response = await fetch(`/api/users/${userId}`, {
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${getToken()}`,
      },
    });

    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }

    const data = await response.json();
    return data;
  } catch (error) {
    console.error('Failed to fetch user:', error);
    throw error;
  }
};

// Manual retry logic
const fetchWithRetry = async (userId: string, retries = 3) => {
  for (let i = 0; i < retries; i++) {
    try {
      return await fetchUser(userId);
    } catch (error) {
      if (i === retries - 1) throw error;
      await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i)));
    }
  }
};

After (Composable HTTP Client):

import { createHttpClient } from 'composable-http-client/fetch';
import { createHttpClientProcedure } from 'composable-http-client';
import { z } from 'zod';

const client = createHttpClient({
  baseURL: '/api',
  headers: tokens => ({
    'Content-Type': 'application/json',
    ...(tokens.accessToken && { Authorization: `Bearer ${tokens.accessToken}` }),
  }),
  getTokens: () => ({
    accessToken: getToken(),
  }),
});

const procedure = createHttpClientProcedure(client);

const getUser = procedure()
  .input(z.object({ userId: z.string() }))
  .handler(async ({ input, client }) => {
    return client.get(`/users/${input.userId}`);
  })
  .output(userSchema)
  .retry({
    retries: 3,
    delay: attempt => 1000 * Math.pow(2, attempt),
  })
  .catchAll(error => ({
    error: error.message,
    status: error.response?.status,
  }));

// Usage with type safety and validation
const result = await getUser({ userId: '123' });
if (result.error) {
  console.error('Error:', result.error);
} else {
  console.log('User:', result.data); // Fully typed!
}

FAQ

General Usage

Q: Can I use this library without Zod validation?

A: Yes! While Zod is included as a dependency, you can skip the .input() and .output() methods and just use .handler() and .catchAll() for runtime functionality without validation.

const getUser = procedure()
  .handler(async ({ client }) => {
    return client.get('/users/123');
  })
  .catchAll(error => ({ error: error.message }));

Q: Which HTTP adapter should I choose?

A:

  • Fetch adapter: Use for modern environments (Node.js 20+, modern browsers) when you want the smallest bundle size
  • Axios adapter: Use for maximum compatibility, better error handling, and when you need advanced HTTP features

Q: Can I use multiple HTTP clients in the same application?

A: Absolutely! You can create different clients for different APIs:

const authClient = createHttpClient({ baseURL: 'https://auth.api.com' });
const dataClient = createHttpClient({ baseURL: 'https://data.api.com' });

const authProcedure = createHttpClientProcedure(authClient);
const dataProcedure = createHttpClientProcedure(dataClient);

Type Safety

Q: How do I handle dynamic response shapes?

A: Use dynamic output schemas:

.output(({ input }) => {
  return input.detailed ? detailedSchema : basicSchema;
})

Q: Can I extend procedures with additional context?

A: Yes, use extendProcedure:

const baseProcedure = createHttpClientProcedure(client);
const authProcedure = extendProcedure(baseProcedure).handler(() => ({ user: getCurrentUser() }));

Q: What's the difference between lifecycle hooks and interceptors?

A: Lifecycle hooks are procedure-level callbacks that run at specific points in the procedure execution:

  • Lifecycle hooks: Procedure-specific, run for that specific procedure call
  • Interceptors: Client-level, run for all requests through that HTTP client
// Lifecycle hooks (procedure-level)
const getUser = procedure()
  .onStart(() => console.log('This procedure started'))
  .handler(({ client }) => client.get('/users/1'))
  .onSuccess(() => console.log('This procedure succeeded'));

// For interceptor-like behavior, use the underlying HTTP client's capabilities
const client = createHttpClient({
  // This runs for ALL requests through this client
  logError: async error => console.log('Global error:', error),
});

Error Handling

Q: How do I handle different types of errors?

A: Use structured error handling in .catchAll():

.catchAll((error) => {
  if (error instanceof z.ZodError) {
    return { type: 'VALIDATION_ERROR', details: error.issues };
  }
  if (error.response?.status === 401) {
    return { type: 'AUTH_ERROR', message: 'Please log in' };
  }
  return { type: 'UNKNOWN_ERROR', message: error.message };
})

Q: How do I implement global error handling?

A: Use the logError option in client configuration:

const client = createHttpClient({
  baseURL: 'https://api.example.com',
  logError: async error => {
    // Send to monitoring service
    errorReporter.capture(error);
  },
});

Performance

Q: How do I implement caching?

A: You can implement caching in the handler:

const cache = new Map();

.handler(async ({ input, client }) => {
  const cacheKey = JSON.stringify(input);

  if (cache.has(cacheKey)) {
    return cache.get(cacheKey);
  }

  const result = await client.get('/data');
  cache.set(cacheKey, result);
  return result;
})

Q: What's the bundle size impact?

A:

  • Core library: ~3.2KB gzipped
  • With Zod: ~12.4KB gzipped total (Zod adds ~9KB)
  • Axios adapter: +0.4KB gzipped
  • Fetch adapter: +0.6KB gzipped
  • Error classes: +0.8KB gzipped (optional import)

Testing

Q: How do I test procedures?

A: Mock the HTTP client:

const mockClient = {
  get: vi.fn().mockResolvedValue({ id: 1, name: 'Test' }),
};

const procedure = createHttpClientProcedure(mockClient);
const getUser = procedure().handler(/*...*/).catchAll(/*...*/);

const result = await getUser({ userId: '1' });
expect(mockClient.get).toHaveBeenCalledWith('/users/1');

Q: How do I test with MSW?

A: Set up MSW handlers and use real HTTP client:

const server = setupServer(
  http.get('/api/users/:id', ({ params }) => {
    return HttpResponse.json({ id: params.id, name: 'Test User' });
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Contributing

We welcome contributions! Please read our Contributing Guide for details on our development process.

Contributors โœจ

Thanks goes to these wonderful people (emoji key):

This project follows the all-contributors specification. Contributions of any kind welcome!

License

MIT License - see the LICENSE file for details.

Package Sidebar

Install

npm i composable-http-client

Weekly Downloads

28

Version

1.2.0

License

MIT

Unpacked Size

304 kB

Total Files

75

Last publish

Collaborators

  • thedammyking