New Research: Supply Chain Attack on Axios Pulls Malicious Dependency from npm.Details →
Socket
Book a DemoSign in
Socket

@brika/http

Package Overview
Dependencies
Maintainers
1
Versions
1
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@brika/http

latest
Source
npmnpm
Version
0.3.0
Version published
Maintainers
1
Created
Source

@brika/http

Modern, fully-typed HTTP client for Bun with advanced features including performant caching, interceptors, retry logic, and request deduplication.

Tests TypeScript License

Table of Contents

Features

  • Fully Typed: Generic types for request/response with automatic type inference
  • Fluent API: Chainable builder pattern for elegant request configuration
  • Smart Caching: Pluggable cache system with in-memory adapter and TTL support
  • Interceptors: Request, response, and error interceptors for cross-cutting concerns
  • Automatic Retry: Configurable retry logic with exponential/linear backoff
  • Request Deduplication: Prevent duplicate in-flight requests
  • DI Integration: Works seamlessly with @brika/di
  • Testing Support: Built-in mocking utilities for easy testing
  • Zero Dependencies: No external runtime dependencies (uses Bun's native fetch)

Installation

This package is part of the Brika workspace. Add it to your dependencies:

{
  "dependencies": {
    "@brika/http": "workspace:*"
  }
}

Then run:

bun install

Quick Start

Simple GET request

import { HttpClient } from '@brika/http';

const client = new HttpClient();

// Fetch and get typed response
const response = await client
  .get<{ userId: number; id: number; title: string }>('https://jsonplaceholder.typicode.com/todos/1')
  .send();

console.log(response.data.title);

With caching

import { HttpClient } from '@brika/http';

const client = new HttpClient();

// First call - hits the API
const response1 = await client
  .get('https://api.example.com/users')
  .cache({ ttl: 60_000 }) // Cache for 1 minute
  .send();

console.log(response1.cached); // false

// Second call - returns cached result
const response2 = await client
  .get('https://api.example.com/users')
  .cache({ ttl: 60_000 })
  .send();

console.log(response2.cached); // true

Usage

Basic Requests

import { HttpClient } from '@brika/http';

const client = new HttpClient();

// GET request
const users = await client.get<User[]>('/users').send();

// POST request with JSON body
const newUser = await client
  .post<User>('/users')
  .json({ name: 'John Doe', email: 'john@example.com' })
  .send();

// PUT request
await client
  .put('/users/123')
  .json({ name: 'Jane Doe' })
  .send();

// PATCH request
await client
  .patch('/users/123')
  .json({ email: 'new@example.com' })
  .send();

// DELETE request
await client.delete('/users/123').send();

// Get only the data (not full response)
const userData = await client.get<User>('/users/123').data();

With Dependency Injection

HttpClient uses the @singleton() decorator, making it easy to inject into your services:

import { HttpClient } from '@brika/http';
import { singleton, inject } from '@brika/di';

@singleton()
class UserService {
  readonly #http = inject(HttpClient);

  async getUser(id: string): Promise<User> {
    return this.#http
      .get<User>(`/users/${id}`)
      .cache({ ttl: 300_000 }) // Cache for 5 minutes
      .data();
  }

  async createUser(name: string, email: string): Promise<User> {
    return this.#http
      .post<User>('/users')
      .json({ name, email })
      .data();
  }

  async searchUsers(query: string): Promise<User[]> {
    return this.#http
      .get<User[]>('/users/search')
      .params({ q: query, limit: '20' })
      .cache({ ttl: 60_000, tags: ['users', 'search'] })
      .data();
  }
}

Configuration

Configure the client with defaults:

import { HttpClient, MemoryCache } from '@brika/http';

const client = new HttpClient({
  // Base URL for all requests
  baseUrl: 'https://api.example.com',

  // Default headers
  headers: {
    'User-Agent': 'MyApp/1.0',
    'Accept': 'application/json',
  },

  // Request timeout (default: 30000ms)
  timeout: 30_000,

  // Enable caching (default: enabled with MemoryCache)
  cache: new MemoryCache(),

  // Retry configuration
  retry: {
    maxAttempts: 3,
    backoff: 'exponential',
    delay: 1000,
    maxDelay: 30_000,
    retryableStatusCodes: [408, 429, 500, 502, 503, 504],
  },

  // Custom interceptors
  interceptors: {
    request: [(config) => {
      // Add auth token to all requests
      return {
        ...config,
        headers: {
          ...config.headers,
          'Authorization': `Bearer ${getToken()}`,
        },
      };
    }],
  },
});

API Reference

HttpClient Methods

MethodDescription
get<T>(url)Make a GET request
post<T>(url, body?)Make a POST request
put<T>(url, body?)Make a PUT request
patch<T>(url, body?)Make a PATCH request
delete<T>(url)Make a DELETE request
head<T>(url)Make a HEAD request
options<T>(url)Make an OPTIONS request
execute<T>(config)Execute with full RequestConfig
clearCache()Clear all cached entries
invalidateCache(tag)Invalidate cache by tag
invalidateCacheTags(tags[])Invalidate cache by multiple tags

RequestBuilder Methods

All HTTP methods return a RequestBuilder with these chainable methods:

MethodDescriptionExample
.params(params)Set query parameters.params({ page: '1', limit: '10' })
.headers(headers)Set multiple headers.headers({ 'Accept': 'application/json' })
.header(key, value)Set single header.header('Authorization', 'Bearer token')
.body(body)Set request body.body(formData)
.json(data)Set JSON body.json({ name: 'John' })
.timeout(ms)Set request timeout.timeout(5000)
.cache(options)Set cache options.cache({ ttl: 60000 })
.retry(config)Set retry config.retry({ maxAttempts: 3 })
.signal(signal)Set AbortSignal.signal(controller.signal)
.send()Execute and return full responseawait .send()
.data()Execute and return only dataawait .data()

Configuration Options

HttpClientConfig

interface HttpClientConfig {
  baseUrl?: string;              // Base URL for all requests
  headers?: HttpHeaders;          // Default headers
  timeout?: number;               // Default timeout (ms)
  cache?: CacheAdapter;           // Cache adapter instance
  retry?: RetryConfig;            // Retry configuration
  interceptors?: {                // Custom interceptors
    request?: RequestInterceptor[];
    response?: ResponseInterceptor[];
    error?: ErrorInterceptor[];
  };
}

CacheOptions

interface CacheOptions {
  ttl: number;                    // Time to live (ms)
  key?: string;                   // Custom cache key
  tags?: string[];                // Tags for group invalidation
  skip?: boolean;                 // Skip cache for this request
  refresh?: boolean;              // Force refresh (ignore cache)
}

RetryConfig

interface RetryConfig {
  maxAttempts: number;                        // Max retry attempts
  backoff: 'linear' | 'exponential';          // Backoff strategy
  delay: number;                              // Initial delay (ms)
  maxDelay?: number;                          // Maximum delay (ms)
  retryableStatusCodes?: number[];            // Status codes to retry
  shouldRetry?: (error, attempt) => boolean;  // Custom predicate
}

Caching

Basic Caching

// Cache for 1 minute
await client.get('/users')
  .cache({ ttl: 60_000 })
  .send();

// Cache with custom key
await client.get('/users')
  .cache({ ttl: 60_000, key: 'all-users' })
  .send();

// Skip cache for this request
await client.get('/users')
  .cache({ ttl: 60_000, skip: true })
  .send();

// Force refresh (bypass cache)
await client.get('/users')
  .cache({ ttl: 60_000, refresh: true })
  .send();

Tag-Based Invalidation

// Cache with tags
await client.get('/users')
  .cache({ ttl: 60_000, tags: ['users', 'list'] })
  .send();

await client.get('/users/123')
  .cache({ ttl: 60_000, tags: ['users', 'user-123'] })
  .send();

// Invalidate all 'users' tagged cache
client.invalidateCache('users');

// Invalidate multiple tags
client.invalidateCacheTags(['users', 'posts']);

Custom Cache Adapter

Implement the CacheAdapter interface:

import type { CacheAdapter } from '@brika/http';

class RedisCache implements CacheAdapter {
  #redis: Redis;

  constructor(redis: Redis) {
    this.#redis = redis;
  }

  async get<T>(key: string): Promise<T | null> {
    const value = await this.#redis.get(key);
    return value ? JSON.parse(value) : null;
  }

  async set<T>(key: string, value: T, ttl: number, tags?: string[]): Promise<void> {
    await this.#redis.setex(key, Math.floor(ttl / 1000), JSON.stringify(value));

    // Store tag associations
    if (tags) {
      for (const tag of tags) {
        await this.#redis.sadd(`tag:${tag}`, key);
      }
    }
  }

  async delete(key: string): Promise<void> {
    await this.#redis.del(key);
  }

  async has(key: string): Promise<boolean> {
    return (await this.#redis.exists(key)) > 0;
  }

  async clear(): Promise<void> {
    await this.#redis.flushdb();
  }

  async invalidateByTag(tag: string): Promise<void> {
    const keys = await this.#redis.smembers(`tag:${tag}`);
    if (keys.length > 0) {
      await this.#redis.del(...keys);
    }
    await this.#redis.del(`tag:${tag}`);
  }

  async invalidateByTags(tags: string[]): Promise<void> {
    for (const tag of tags) {
      await this.invalidateByTag(tag);
    }
  }
}

// Use custom cache
const client = new HttpClient({
  cache: new RedisCache(redis),
});

Interceptors

Built-in Interceptors

RetryInterceptor

Automatically retries failed requests with configurable backoff:

import { HttpClient } from '@brika/http';

const client = new HttpClient({
  retry: {
    maxAttempts: 3,
    backoff: 'exponential', // or 'linear'
    delay: 1000,            // Initial delay: 1s
    maxDelay: 30_000,       // Max delay: 30s
    retryableStatusCodes: [408, 429, 500, 502, 503, 504],
  },
});

TimeoutInterceptor

Automatically added when timeout is configured:

const client = new HttpClient({
  timeout: 30_000, // 30 seconds
});

LoggerInterceptor

Log all requests, responses, and errors:

import { HttpClient, LoggerInterceptor } from '@brika/http';

const logger = new LoggerInterceptor({
  logRequests: true,
  logResponses: true,
  logErrors: true,
  logger: console, // Custom logger
});

const client = new HttpClient({
  interceptors: {
    request: [logger],
    response: [logger],
    error: [logger],
  },
});

DeduplicationInterceptor

Automatically prevents duplicate in-flight GET requests (enabled by default).

Custom Interceptors

import { HttpClient } from '@brika/http';

const client = new HttpClient({
  interceptors: {
    // Request interceptor - modify request before sending
    request: [
      (config) => {
        // Add timestamp to all requests
        return {
          ...config,
          params: {
            ...config.params,
            _t: Date.now(),
          },
        };
      },
    ],

    // Response interceptor - transform response data
    response: [
      (response) => {
        // Unwrap data from API envelope
        return {
          ...response,
          data: response.data.result,
        };
      },
    ],

    // Error interceptor - handle or recover from errors
    error: [
      async (error, config) => {
        if (error.status === 401) {
          // Refresh token and retry
          await refreshToken();
          return client.execute(config);
        }
        throw error;
      },
    ],
  },
});

Error Handling

Error Types

import { HttpError, TimeoutError, isHttpError, isTimeoutError } from '@brika/http';

try {
  await client.get('/users/123').send();
} catch (error) {
  if (isTimeoutError(error)) {
    console.error('Request timed out:', error.timeout);
  } else if (isHttpError(error)) {
    console.error('HTTP error:', error.status, error.message);

    // Check error type
    if (error.isClientError) {
      console.log('Client error (4xx)');
    } else if (error.isServerError) {
      console.log('Server error (5xx)');
    } else if (error.isNetworkError) {
      console.log('Network error (no response)');
    }

    // Check if retryable
    if (error.isRetryable) {
      console.log('This error can be retried');
    }
  }
}

HttpError Properties

class HttpError {
  message: string;              // Error message
  status?: number;              // HTTP status code
  response?: Response;          // Original Response object
  config?: RequestConfig;       // Request configuration

  isNetworkError: boolean;      // No response received
  isClientError: boolean;       // 4xx status
  isServerError: boolean;       // 5xx status
  isRetryable: boolean;         // Can be retried
}

Testing

Using MockHttpClient

import { describe, test, expect } from 'bun:test';
import { createMockClient } from '@brika/http/testing';
import type { User } from './types';

describe('UserService', () => {
  test('should fetch user by id', async () => {
    const client = createMockClient();

    // Mock a response
    client.mockResponse('GET', '/users/123', {
      data: { id: '123', name: 'John Doe' },
      status: 200,
      statusText: 'OK',
      headers: new Headers(),
      config: { method: 'GET', url: '/users/123' },
      cached: false,
    });

    // Make request
    const response = await client.get<User>('/users/123').send();

    // Assertions
    expect(response.data.name).toBe('John Doe');
    expect(response.status).toBe(200);

    // Verify request was made
    const requests = client.getRequests();
    expect(requests).toHaveLength(1);
    expect(requests[0].config.method).toBe('GET');
    expect(requests[0].config.url).toBe('/users/123');
  });

  test('should handle errors', async () => {
    const client = createMockClient();

    client.mockResponse('GET', '/users/999', {
      data: { error: 'Not found' },
      status: 404,
      statusText: 'Not Found',
      headers: new Headers(),
      config: { method: 'GET', url: '/users/999' },
      cached: false,
    });

    await expect(async () => {
      await client.get('/users/999').send();
    }).toThrow();
  });
});

Using MockFetch

import { createMockFetch } from '@brika/http/testing';

const mockFetch = createMockFetch();

// Mock specific endpoint
mockFetch.mock(
  { method: 'GET', url: '/users' },
  { status: 200, data: [{ id: '1', name: 'John' }] }
);

// Mock with delay
mockFetch.mock(
  { method: 'POST', url: '/users' },
  { status: 201, data: { id: '2' }, delay: 100 }
);

// Use the mock
const fetchFn = mockFetch.getFetchFn();
const response = await fetchFn('/users');

Performance

Cache Performance

With caching enabled, repeated requests are significantly faster:

  • First request: ~500ms (network call)
  • Cached request: ~2ms (memory read)
  • Improvement: ~99% faster
// Frequently changing data
.cache({ ttl: 30_000 })        // 30 seconds

// Moderately stable data
.cache({ ttl: 300_000 })       // 5 minutes

// Stable reference data
.cache({ ttl: 3600_000 })      // 1 hour

// Very stable data
.cache({ ttl: 86400_000 })     // 24 hours

Performance Tips

  • Use caching aggressively for read operations
  • Tag your cache for easy invalidation
  • Enable request deduplication (on by default)
  • Use .data() instead of .send() when you only need the response data
  • Set appropriate timeouts to prevent hanging requests
  • Use retry for transient failures but avoid for client errors

Migration Guide

From native fetch

Before:

const url = new URL('https://api.example.com/users');
url.searchParams.set('limit', '10');
url.searchParams.set('page', '1');

const response = await fetch(url, {
  method: 'GET',
  headers: {
    'Authorization': 'Bearer token',
    'Accept': 'application/json',
  },
});

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

const data = await response.json();

After:

const data = await this.#http
  .get<User[]>('https://api.example.com/users')
  .params({ limit: '10', page: '1' })
  .header('Authorization', 'Bearer token')
  .cache({ ttl: 60_000 })
  .data();

Benefits:

  • 70% less code
  • Automatic caching
  • Type safety
  • Automatic retries
  • Better error handling

Examples

import { HttpClient } from '@brika/http';
import { singleton, inject } from '@brika/di';

interface NpmSearchResult {
  objects: Array<{
    package: {
      name: string;
      version: string;
      description?: string;
    };
  }>;
  total: number;
}

@singleton()
class NpmSearchService {
  readonly #http = inject(HttpClient);

  async search(query: string, limit = 20) {
    const data = await this.#http
      .get<NpmSearchResult>('https://registry.npmjs.org/-/v1/search')
      .params({ text: query, size: String(limit) })
      .cache({ ttl: 300_000, tags: ['npm-search'] }) // 5 min cache
      .data();

    return data.objects.map(obj => obj.package);
  }

  async getPackage(name: string) {
    return this.#http
      .get(`https://registry.npmjs.org/${name}`)
      .cache({ ttl: 600_000, tags: ['npm-package'] }) // 10 min cache
      .data();
  }
}

Authentication Example

import { HttpClient } from '@brika/http';

class ApiClient {
  #http: HttpClient;
  #token: string | null = null;

  constructor() {
    this.#http = new HttpClient({
      baseUrl: 'https://api.example.com',
      interceptors: {
        request: [(config) => {
          if (this.#token) {
            return {
              ...config,
              headers: {
                ...config.headers,
                'Authorization': `Bearer ${this.#token}`,
              },
            };
          }
          return config;
        }],
        error: [async (error, config) => {
          if (error.status === 401) {
            // Token expired, refresh it
            this.#token = await this.refreshToken();
            // Retry the request
            return this.#http.execute(config);
          }
          throw error;
        }],
      },
    });
  }

  async login(email: string, password: string) {
    const response = await this.#http
      .post<{ token: string }>('/auth/login')
      .json({ email, password })
      .send();

    this.#token = response.data.token;
    return response.data;
  }

  async refreshToken(): Promise<string> {
    const response = await this.#http
      .post<{ token: string }>('/auth/refresh')
      .send();

    return response.data.token;
  }
}

Contributing

This package is part of the Brika monorepo. To contribute:

  • Make changes to the package
  • Run tests: bun test
  • Type check: bun run typecheck
  • Submit a pull request

License

MIT

Built with ❤️ for Brika

Keywords

brika

FAQs

Package last updated on 21 Feb 2026

Did you know?

Socket

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Install

Related posts