JSPM

  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 41
  • Score
    100M100P100Q94329F
  • License MIT

TypeScript HTTP client with Result<T,E> returns, zero-dep OpenTelemetry tracing, request deduplication, retry, circuit breaker, offline queue, and GraphQL — for Node.js, Bun, Deno, and browsers

Package Exports

  • reixo
  • reixo/package.json

Readme

reixo

TypeScript HTTP client built on the Fetch API. Supports Result<T, E> returns, retry with exponential backoff, circuit breaker, request deduplication, offline queue, caching, GraphQL, WebSocket, Server-Sent Events, OpenTelemetry tracing, and more — for Node.js 20+, Bun, Deno, Cloudflare Workers, Vercel Edge Runtime, and browsers.

Installation

npm install reixo

Contents


Quick start

import { HTTPClient } from 'reixo';

const client = new HTTPClient({
  baseURL: 'https://api.example.com/v1',
  timeoutMs: 10_000,
  retry: true, // 3 retries, exponential backoff, retries on 5xx / 429 / 408
});

const response = await client.get<User>('/users/1');
console.log(response.data); // User

HTTPClient configuration

import { HTTPClient, ConsoleLogger, LogLevel } from 'reixo';

const client = new HTTPClient({
  baseURL: 'https://api.example.com/v1',
  timeoutMs: 15_000,
  headers: {
    Authorization: 'Bearer <token>',
    Accept: 'application/json',
  },
  retry: { maxRetries: 3, backoffFactor: 2, initialDelayMs: 500 },
  cacheConfig: { ttl: 30_000 },
  enableDeduplication: true,
  enableMetrics: true,
  rateLimit: { requests: 60, interval: 60_000 },
  circuitBreaker: { failureThreshold: 5, resetTimeoutMs: 30_000 },
  offlineQueue: true,
  revalidateOnFocus: true,
  revalidateOnReconnect: true,
  logger: new ConsoleLogger(LogLevel.DEBUG),
});

Making requests

All request methods are generic — the type parameter sets the type of response.data.

// GET
const { data } = await client.get<User[]>('/users');

// GET with query parameters
const { data } = await client.get<User[]>('/users', {
  params: { role: 'admin', page: 1, limit: 20 },
});
// → GET /users?role=admin&page=1&limit=20

// POST
const { data: created } = await client.post<User>('/users', { name: 'Alice' });

// PUT
await client.put<User>('/users/1', { name: 'Alice Updated' });

// PATCH
await client.patch<User>('/users/1', { name: 'Alice Patched' });

// DELETE
await client.delete('/users/1');

// HEAD / OPTIONS
await client.head('/users');
await client.options('/users');

Per-request options

await client.post('/upload', formData, {
  timeoutMs: 60_000,
  headers: { 'X-Custom': 'value' },
  retry: false,
  signal: abortController.signal,
  onUploadProgress: ({ loaded, total, progress }) => {
    console.log(`${Math.round((progress ?? 0) * 100)}%`);
  },
});

FormData helper

import { objectToFormData } from 'reixo';

const form = objectToFormData({ name: 'Alice', avatar: fileBlob });
await client.post('/profile', form);

Result API — no-throw error handling

Use the try* methods to receive a Result<T, E> instead of throwing on HTTP errors. TypeScript enforces you to handle both branches before accessing the data.

const result = await client.tryGet<User>('/users/1');

if (!result.ok) {
  console.error(result.error.status); // fully typed HTTPError
  return;
}

console.log(result.data.name); // TypeScript knows data is User here

Available try-methods: tryGet, tryPost, tryPut, tryPatch, tryDelete, tryRequest.


Result utilities

import { ok, err, toResult, mapResult, unwrap, unwrapOr } from 'reixo';

// Construct
const success = ok({ id: 1, name: 'Alice' });
const failure = err(new Error('Something went wrong'));

// Wrap any promise — never throws
const result = await toResult(client.get<User>('/me'));
if (!result.ok) handleError(result.error);

// Transform the success value without unwrapping
const nameResult = mapResult(result, (r) => r.data.name);

// Unwrap — throws if result.ok === false
const data = unwrap(result);

// Unwrap with fallback — never throws
const data = unwrapOr(result, defaultUser);

Interceptors

Interceptors run in push order (request) or reverse push order (response).

// Request interceptor — inject auth header dynamically
client.interceptors.request.push({
  onFulfilled: (config) => ({
    ...config,
    headers: { ...config.headers, Authorization: `Bearer ${getToken()}` },
  }),
  onRejected: (error) => Promise.reject(error),
});

// Response interceptor — unwrap a data envelope or log errors
client.interceptors.response.push({
  onFulfilled: (response) => {
    response.data = (response.data as { payload: unknown }).payload;
    return response;
  },
  onRejected: (error) => {
    console.error('Request failed:', error);
    return Promise.reject(error);
  },
});

Retry

Pass true for sensible defaults (3 retries, exponential backoff, retries on 5xx / 429 / 408 and network errors), false to disable, or a RetryOptions object for full control.

const client = new HTTPClient({
  retry: {
    maxRetries: 5,
    initialDelayMs: 500,
    maxDelayMs: 30_000,
    backoffFactor: 2,
    jitter: true, // randomise delays to avoid thundering herd
    retryCondition: (error) => error.status >= 500,
    onRetry: (error, attempt) => {
      console.log(`Retry ${attempt}:`, error.message);
    },
  },
});

Per-URL retry policies

The first matching pattern wins, overriding the global retry setting.

const client = new HTTPClient({
  retry: true,
  retryPolicies: [
    { pattern: /\/auth\//, retry: false }, // never retry auth endpoints
    { pattern: '/api/upload', retry: { maxRetries: 1 } },
  ],
});

withRetry standalone utility

import { withRetry, RetryError } from 'reixo';

const data = await withRetry(() => fetch('/api/data'), {
  maxRetries: 3,
  initialDelayMs: 1000,
});

Circuit breaker

Protects downstream services by stopping requests after a failure threshold is reached, then probing again after a reset timeout. States cycle: CLOSED (normal) → OPEN (rejecting) → HALF_OPEN (probing).

import { CircuitBreaker } from 'reixo';

// Inline configuration — creates a CircuitBreaker automatically
const client = new HTTPClient({
  circuitBreaker: {
    failureThreshold: 5, // open after 5 consecutive failures
    resetTimeoutMs: 30_000, // try again after 30 s
    halfOpenRetries: 2, // require 2 consecutive successes to close
    fallback: async () => ({ data: cachedData }), // return when circuit is open
    onStateChange: (from, to) => {
      console.log(`Circuit breaker: ${from}${to}`);
    },
  },
});

// Shared instance — share state across multiple clients
const breaker = new CircuitBreaker({ failureThreshold: 3, resetTimeoutMs: 15_000 });
const clientA = new HTTPClient({ circuitBreaker: breaker });
const clientB = new HTTPClient({ circuitBreaker: breaker });

Caching

import { MemoryAdapter, WebStorageAdapter } from 'reixo';

// In-memory LRU cache with defaults
const client = new HTTPClient({ cacheConfig: true });

// Full options
const client = new HTTPClient({
  cacheConfig: {
    ttl: 60_000, // ms
    strategy: 'stale-while-revalidate', // 'cache-first' | 'network-first' | 'stale-while-revalidate'
    maxSize: 500,
    storage: new MemoryAdapter(200), // LRU with 200-entry cap
  },
});

// localStorage-backed (browser only)
const client = new HTTPClient({
  cacheConfig: {
    ttl: 300_000,
    storage: new WebStorageAdapter('local'), // 'local' | 'session'
  },
});

// Revalidate stale data on window focus or network reconnect
const client = new HTTPClient({
  cacheConfig: { ttl: 30_000 },
  revalidateOnFocus: true,
  revalidateOnReconnect: true,
});

subscribe — observe URL data changes

// Callback fires whenever the cached value for this URL is revalidated
const unsubscribe = client.subscribe('/api/users', (data) => {
  console.log('Users updated:', data);
});

unsubscribe(); // stop observing

Request deduplication

Multiple simultaneous GET requests to the same URL share one network call. Useful in data-loading patterns where multiple components request the same resource.

const client = new HTTPClient({ enableDeduplication: true });

// Only one network request is made, all three callers receive the same result
const [a, b, c] = await Promise.all([
  client.get('/users/1'),
  client.get('/users/1'),
  client.get('/users/1'),
]);

buildDedupKey and RequestDeduplicator are also exported for use in custom transports.


Rate limiting

Token-bucket limiter applied globally before every outgoing request. Callers that exceed the limit wait in a FIFO queue.

const client = new HTTPClient({
  rateLimit: { requests: 60, interval: 60_000 }, // 60 req / min
});

Offline queue

Buffers requests made while the device is offline and replays them automatically when connectivity is restored.

// In-memory queue with automatic replay
const client = new HTTPClient({ offlineQueue: true });

// Persistent queue — survives page reloads, auto-pauses when offline
const client = new HTTPClient({
  offlineQueue: {
    concurrency: 3,
    storage: 'local', // 'memory' | 'local' | 'session' | StorageAdapter
    storageKey: 'my-queue',
    syncWithNetwork: true, // auto-pause / resume on network changes
  },
});

Progress tracking

// Per-request upload progress
await client.post('/upload', fileData, {
  onUploadProgress: ({ loaded, total, progress }) => {
    const pct = Math.round((progress ?? 0) * 100);
    console.log(`Upload: ${pct}%`);
  },
});

// Per-request download progress
await client.get('/large-file', {
  onDownloadProgress: ({ loaded, total }) => {
    console.log(`Downloaded ${loaded} of ${total ?? '?'} bytes`);
  },
});

// Global progress handlers applied to all requests
const client = new HTTPClient({
  onUploadProgress: ({ loaded }) => console.log(`+${loaded} bytes uploaded`),
  onDownloadProgress: ({ progress }) => console.log(progress),
});

Metrics

const client = new HTTPClient({
  enableMetrics: true,
  onMetricsUpdate: (m) => {
    console.log(`Requests: ${m.requestCount}, Errors: ${m.errorCount}`);
  },
});

// Get a snapshot at any time
const snap = client.getMetrics();
// snap.requestCount, snap.errorCount, snap.totalLatency,
// snap.minLatency, snap.maxLatency, snap.averageLatency

Request cancellation

// Cancel all in-flight requests (e.g. component unmount)
client.cancelAll();

// Cancel a specific request by its ID
const { requestId, response } = client.requestWithId('/api/data');
client.cancel(requestId); // returns true if found and cancelled

// Standard AbortSignal
const controller = new AbortController();
client.get('/api/data', { signal: controller.signal });
controller.abort();

// Register cleanup callbacks
client.onCleanup(() => console.log('cleanup'));
client.dispose(); // cancels all requests, triggers cleanup callbacks

Events

HTTPClient extends EventEmitter and emits the following typed events.

client.on('request:start', ({ url, method, requestId }) => {});
client.on('response:success', ({ url, method, status, requestId, duration }) => {});
client.on('response:error', ({ url, method, error, requestId, duration }) => {});
client.on('upload:progress', ({ url, loaded, total, progress }) => {});
client.on('download:progress', ({ url, loaded, total, progress }) => {});
client.on('cache:revalidate', ({ url, key, data }) => {});
client.on('focus', () => {}); // window focused
client.on('online', () => {}); // network reconnected

Fluent builder — HTTPBuilder

HTTPBuilder is a chainable builder that produces an HTTPClient. Every with* method mirrors a field in HTTPClientConfig.

import { HTTPBuilder } from 'reixo';

const client = new HTTPBuilder()
  .withBaseURL('https://api.example.com/v1')
  .withTimeout(10_000)
  .withHeaders({ Accept: 'application/json' })
  .withRetry({ maxRetries: 3, backoffFactor: 2 })
  .withCache({ ttl: 30_000 })
  .withRateLimit(60, 60_000)
  .withCircuitBreaker({ failureThreshold: 5, resetTimeoutMs: 30_000 })
  .withDeduplication()
  .withMetrics()
  .withLogger(new ConsoleLogger(LogLevel.DEBUG))
  .withOTel({ endpoint: 'https://otel.example.com/v1/traces', serviceName: 'my-api' })
  .withApiVersion('v2', 'url')
  .withSSL({ rejectUnauthorized: true, ca: caCert })
  .withOfflineQueue({ storage: 'local', syncWithNetwork: true })
  .build();

Authentication interceptor

Injects a Bearer token on every request and automatically refreshes it when the server returns 401, then retries the original request.

import { createAuthInterceptor } from 'reixo';

createAuthInterceptor(client, {
  getAccessToken: async () => localStorage.getItem('token') ?? '',
  refreshTokens: async () => {
    const res = await fetch('/auth/refresh', { method: 'POST' });
    const { accessToken } = await res.json();
    localStorage.setItem('token', accessToken);
  },
  onRefreshFailed: (error) => {
    console.error('Token refresh failed, redirecting to login', error);
    window.location.href = '/login';
  },
});

Trace interceptor

Injects a unique trace ID header into every outgoing request for distributed tracing and log correlation.

import { createTraceInterceptor } from 'reixo';

client.interceptors.request.push(
  createTraceInterceptor({
    headerName: 'X-Correlation-ID', // default: 'x-request-id'
    generateId: () => crypto.randomUUID(),
  })
);

// Defaults are fine for most cases
client.interceptors.request.push(createTraceInterceptor());

OpenTelemetry

Zero-dependency W3C Traceparent / OpenTelemetry tracing — no OTEL SDK required.

import { HTTPBuilder, formatTraceparent, parseTraceparent } from 'reixo';

const client = new HTTPBuilder()
  .withOTel({
    endpoint: 'https://otel-collector.example.com/v1/traces',
    serviceName: 'my-api',
    onSpanStart: (span) => {
      console.log('Span started:', span.traceId);
    },
    onSpanEnd: (span, response) => {
      console.log(`Span ${span.traceId} ended in ${span.duration}ms`);
    },
  })
  .build();

// W3C Traceparent header helpers
const header = formatTraceparent({
  traceId: '4bf92f3577b34da6a3ce929d0e0e4736',
  spanId: '00f067aa0ba902b7',
  flags: '01',
});

const ctx = parseTraceparent('00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01');
// { version: '00', traceId: '...', spanId: '...', flags: '01' }

SSR / edge header forwarding

Forward select headers (cookies, auth tokens, correlation IDs) from the incoming server request to outgoing upstream calls — works with any framework.

import { createSSRInterceptor } from 'reixo';

// Next.js App Router
client.interceptors.request.push(
  createSSRInterceptor(
    () => Object.fromEntries(headers()), // next/headers
    ['Cookie', 'Authorization', 'X-Request-ID'] // optional whitelist; omit to forward all
  )
);

// Express
client.interceptors.request.push(createSSRInterceptor(() => req.headers));

GraphQL client

Built on top of HTTPClient. Supports queries, mutations, and Automatic Persisted Queries (APQ).

import { GraphQLClient } from 'reixo';

const gql = new GraphQLClient('https://api.example.com/graphql', {
  headers: { Authorization: 'Bearer <token>' },
  enablePersistedQueries: false, // set true to enable APQ
});

// Query
const { data, errors } = await gql.query<{ user: User }>(
  `query GetUser($id: ID!) {
    user(id: $id) { id name email }
  }`,
  { id: '1' }
);

// Mutation
const { data: created } = await gql.mutate<{ createUser: User }>(
  `mutation CreateUser($input: CreateUserInput!) {
    createUser(input: $input) { id name }
  }`,
  { input: { name: 'Alice', email: 'alice@example.com' } }
);

// Access the underlying HTTPClient (interceptors, events, etc.)
gql.client.interceptors.request.push(/* ... */);

WebSocket client

Full-featured WebSocket wrapper with automatic reconnection, heartbeat keep-alive, and typed events.

import { WebSocketClient } from 'reixo';

const ws = new WebSocketClient({
  url: 'wss://api.example.com/ws',
  protocols: 'json', // or string[] for multiple
  autoConnect: true, // default: true
  reconnect: {
    maxRetries: 10,
    initialDelayMs: 1_000,
    maxDelayMs: 30_000,
    backoffFactor: 2,
  },
  heartbeat: {
    interval: 30_000, // send ping every 30 s
    message: 'ping', // string or object (auto-JSON-serialised)
    timeout: 5_000, // close socket if no reply within 5 s
  },
});

ws.on('open', (e) => console.log('Connected'));
ws.on('message', (e) => console.log(JSON.parse(e.data)));
ws.on('error', (e) => console.error('WS error:', e));
ws.on('close', (e) => console.log('Closed:', e.code));
ws.on('reconnect', (n) => console.log(`Reconnect attempt #${n}`));
ws.on('reconnect:fail', (err) => console.error('Gave up reconnecting:', err));

ws.send('hello');
ws.sendJson({ type: 'subscribe', channel: 'prices' });

ws.close(1000, 'done');

Server-Sent Events client

import { SSEClient } from 'reixo';

const sse = new SSEClient({
  url: 'https://api.example.com/events',
  withCredentials: true,
  reconnect: { maxRetries: 5, initialDelayMs: 1_000 },
  // headers only work with fetch-based SSE polyfills
  headers: { Authorization: 'Bearer <token>' },
});

sse.on('open', (e) => console.log('Connected'));
sse.on('message', (e) => console.log(e.data));
sse.on('error', (e) => console.error(e));
sse.on('reconnect', (n) => console.log(`Reconnect attempt #${n}`));
sse.on('reconnect:fail', (e) => console.error('Gave up:', e));

// Named event types (server sends `event: user.created`)
sse.addEventListener('user.created', (e) => console.log(JSON.parse(e.data)));
sse.removeEventListener('user.created', handler);

sse.close();

Polling

import { poll, PollingController } from 'reixo';

// Simple helper — returns { promise, cancel }
const { promise, cancel } = poll(() => client.get<JobStatus>('/jobs/42'), {
  interval: 2_000,
  until: (res) => res.data.status === 'done',
  timeout: 120_000,
});

const result = await promise;

// Adaptive interval — slow then fast as job nears completion
const { promise } = poll(() => client.get<Job>('/jobs/123'), {
  interval: 5_000,
  until: (res) => res.data.status === 'completed',
  adaptiveInterval: (res) => (res.data.progress < 80 ? 5_000 : 1_000),
  timeout: 60_000,
});

// Exponential backoff
const { promise, cancel } = poll(fetchStatus, {
  interval: 1_000,
  backoff: { factor: 1.5, maxInterval: 30_000 },
  until: (res) => res.data.done,
});

// Bounded attempts with error handling
const { promise } = poll(fetchStatus, {
  interval: 2_000,
  maxAttempts: 20,
  onError: (error, attempts) => {
    if (attempts > 5) return false; // stop polling and re-throw
    // anything else continues
  },
});

// Low-level controller
const controller = new PollingController(task, options);
await controller.start();
controller.stop();
const signal = controller.signal; // AbortSignal

Pagination

Async generator that automatically fetches subsequent pages.

import { paginate } from 'reixo';

for await (const page of paginate<User>(client, '/users', {
  pageParam: 'page',
  limitParam: 'limit',
  limit: 20,
  initialPage: 1,
  resultsPath: 'data', // dot-path to the array in the response, e.g. 'meta.items'
})) {
  console.log('Page:', page); // User[]
}

// Custom stop condition
for await (const page of paginate<Post>(client, '/posts', {
  limit: 50,
  stopCondition: (response, items, totalFetched) => totalFetched >= 200,
})) {
  processPosts(page);
}

Infinite query

Cursor-based or page-number-based infinite scrolling.

import { InfiniteQuery } from 'reixo';

const query = new InfiniteQuery<PostsPage>({
  client,
  url: '/posts',
  initialPageParam: 1,
  getNextPageParam: (lastPage) => lastPage.nextCursor ?? null,
  getPreviousPageParam: (firstPage) => firstPage.prevCursor ?? null,
});

// Load first page
await query.fetchNextPage();
console.log(query.data.pages); // PostsPage[]
console.log(query.hasNextPage); // boolean

// Keep loading forward
while (query.hasNextPage) {
  await query.fetchNextPage();
}

// Load a previous page (requires getPreviousPageParam)
if (query.hasPreviousPage) {
  await query.fetchPreviousPage();
}

// Status flags
query.isFetching;
query.isFetchingNextPage;
query.isFetchingPreviousPage;
query.error; // null or the thrown error

// Cancel the in-flight fetch
query.abort();

// Reset all state
query.reset();

Task queue

Priority queue with concurrency control, dependency graph, optional persistence, and network-aware auto-pause.

import { TaskQueue } from 'reixo';

const queue = new TaskQueue({
  concurrency: 3,
  autoStart: true,
  storage: 'local', // 'memory' | 'local' | 'session' | StorageAdapter
  storageKey: 'upload-queue',
  syncWithNetwork: true, // auto-pause when offline, resume when online
});

// Add tasks
const result = await queue.add(() => client.post('/upload', file), {
  priority: 10, // higher number runs first
  id: 'upload-photo',
  dependencies: ['auth'], // wait for the 'auth' task to complete first
  data: { filename: 'photo.jpg' }, // serialisable metadata (persisted)
});

// Cancel a pending task
const cancelled = queue.cancel('upload-photo'); // returns boolean

// Pause / resume
queue.pause();
queue.resume();

// Wait for all tasks to finish
await queue.drain();

// Inspect
console.log(queue.size); // pending tasks
console.log(queue.active); // running tasks
console.log(queue.isQueuePaused);

// Async iterator
for await (const result of queue) {
  console.log('Task completed:', result);
  if (doneCondition) break;
}

// Events
queue.on('task:start', ({ id }) => {});
queue.on('task:completed', ({ id, result }) => {});
queue.on('task:error', ({ id, error }) => {});
queue.on('task:added', ({ id, priority }) => {});
queue.on('task:cancelled', ({ id }) => {});
queue.on('queue:drain', () => {});
queue.on('queue:paused', () => {});
queue.on('queue:resumed', () => {});
queue.on('queue:cleared', () => {});
queue.on('queue:restored', (metadata) => {
  // Rebuild task functions from persisted metadata
});

Pipeline

Chainable, fully type-safe data transformation — synchronous and async.

import { Pipeline, AsyncPipeline } from 'reixo';

// Synchronous
const process = Pipeline.from<string>()
  .map((s) => s.trim())
  .map((s) => s.toUpperCase())
  .tap((s) => console.log('Processing:', s))
  .map((s) => ({ value: s, length: s.length }));

const result = process.execute('  hello world  ');
// { value: 'HELLO WORLD', length: 11 }

// Asynchronous
const fetchAndTransform = AsyncPipeline.from<string>()
  .map(async (userId) => client.get<User>(`/users/${userId}`))
  .map((response) => response.data)
  .tap(async (user) => console.log('Fetched:', user.name));

const user = await fetchAndTransform.execute('42');

Resumable file upload

Chunked upload with parallel chunk support, progress tracking, and abort capability.

import { ResumableUploader } from 'reixo';

const uploader = new ResumableUploader(client);

const result = await uploader.upload('/upload', file, {
  chunkSize: 5 * 1024 * 1024, // 5 MB chunks
  parallel: 3, // upload 3 chunks concurrently
  onProgress: ({ loaded, total, progress, chunk, totalChunks }) => {
    console.log(`${Math.round(progress * 100)}% — chunk ${chunk}/${totalChunks}`);
  },
});

// Abort mid-upload
uploader.abort();

Batch processor

Groups many individual async calls into batched executions to reduce round-trips.

import { BatchProcessor } from 'reixo';

const batcher = new BatchProcessor<string, User>(
  async (userIds) => {
    const res = await client.post<User[]>('/users/batch', { ids: userIds });
    return res.data;
  },
  {
    maxSize: 50, // flush when 50 items are queued
    maxDelayMs: 20, // or after 20 ms — whichever comes first
  }
);

// Individual callers — automatically batched behind the scenes
const [alice, bob] = await Promise.all([batcher.add('user-1'), batcher.add('user-2')]);

Batch transport

Wraps any transport function to automatically batch eligible requests.

import { createBatchTransport } from 'reixo';

const batchedTransport = createBatchTransport(defaultTransport, {
  maxSize: 20,
  maxDelayMs: 50,
  shouldBatch: (url) => url.startsWith('/api/items'),
  executeBatch: async (items) => {
    const res = await fetch('/api/batch', {
      method: 'POST',
      body: JSON.stringify(items.map((i) => ({ url: i.url }))),
    });
    return res.json(); // must return HTTPResponse[] in same order
  },
});

const client = new HTTPClient({ transport: batchedTransport });

Network recorder

Records HTTP traffic during a session — useful for generating test fixtures and debugging.

import { NetworkRecorder } from 'reixo';

const recorder = new NetworkRecorder(client); // attaches interceptors automatically

recorder.start();

await client.get('/users');
await client.post('/users', { name: 'Alice' });

recorder.stop();

const records = recorder.getRecords();
// [{ id, timestamp, url, method, requestHeaders, requestBody, status, responseBody, duration }, ...]

// Generate test fixture JSON
const fixtures = recorder.generateFixtures();
// [{ url, method, status, response }, ...]

recorder.clear();

// Manually inject a fixture
recorder.record({
  url: '/users/1',
  method: 'GET',
  status: 200,
  duration: 42,
  requestHeaders: {},
  requestBody: null,
  responseHeaders: {},
  responseBody: { id: 1, name: 'Alice' },
});

Mock adapter

Stub HTTP responses in tests without making real network calls.

import { MockAdapter } from 'reixo';

const mock = new MockAdapter();

// Static responses
mock.onGet('/users').reply(200, [{ id: 1, name: 'Alice' }]);
mock.onPost('/users').reply(201, { id: 2, name: 'Bob' });
mock.onDelete('/users/1').reply(204);

// Dynamic response
mock.onGet('/users/:id').reply((config) => {
  const id = config.url?.split('/').pop();
  return [200, { id, name: 'User ' + id }];
});

// Simulate an error
mock.onGet('/flaky').reply(500, { message: 'Server error' });

// Simulate a network error
mock.onGet('/offline').networkError();

// Simulate latency
mock.onGet('/slow').reply(200, data, {}, { latency: 500 });

const client = new HTTPClient({ transport: mock.transport });

// Clear all stubs
mock.reset();

Network monitor

Reactive online/offline detection. Uses window browser events by default; optional active HEAD-ping polling for Node.js or restricted environments.

import { NetworkMonitor } from 'reixo';

// Shared singleton
const monitor = NetworkMonitor.getInstance();

monitor.on('online', () => console.log('Back online'));
monitor.on('offline', () => console.log('Gone offline'));

console.log(monitor.online); // current status (boolean)

// Active polling — useful in Node.js or behind strict corporate firewalls
monitor.configure({
  pingUrl: 'https://health.example.com/ping', // default: '/favicon.ico'
  checkInterval: 30_000,
});

// Isolated instance (useful for tests)
const m = new NetworkMonitor({ checkInterval: 10_000 });
m.destroy(); // clean up timers and listeners
NetworkMonitor.resetInstance(); // reset the singleton

Security utilities

Sanitise sensitive headers and body fields before they appear in logs.

import { SecurityUtils } from 'reixo';

const safeHeaders = SecurityUtils.sanitizeHeaders({
  Authorization: 'Bearer eyJhbGciOiJ...',
  'Content-Type': 'application/json',
  Cookie: 'session=abc123',
});
// { Authorization: '[REDACTED]', 'Content-Type': 'application/json', Cookie: '[REDACTED]' }

const safeBody = SecurityUtils.sanitizeBody({
  username: 'alice',
  password: 'secret123',
  creditCard: '4111111111111111',
});
// { username: 'alice', password: '[REDACTED]', creditCard: '[REDACTED]' }

ConsoleLogger

Pluggable logger that implements the Logger interface expected by HTTPClient.

import { ConsoleLogger, LogLevel } from 'reixo';

// Development — human-readable, debug level, redact auth headers
const logger = new ConsoleLogger({
  level: LogLevel.DEBUG,
  format: 'text',
  prefix: '[MyApp:HTTP]',
  redactHeaders: ['Authorization', 'Cookie', 'X-Api-Key'],
});

// Production — structured JSON, warnings and above only
const logger = new ConsoleLogger({
  level: LogLevel.WARN,
  format: 'json',
  redactHeaders: ['Authorization', 'Cookie'],
});

// Short form (level only, defaults to text format)
const logger = new ConsoleLogger(LogLevel.DEBUG);

const client = new HTTPClient({ logger });

Log levels: NONE = 0, ERROR = 1, WARN = 2, INFO = 3, DEBUG = 4.


Timing utilities

Available as static methods on HTTPClient and as standalone named exports.

import { debounce, throttle, delay } from 'reixo';
// or: HTTPClient.debounce, HTTPClient.throttle, HTTPClient.delay

// Debounce — execute at most once per delay window
const handleSearch = debounce(
  (query: string) => client.get('/search', { params: { q: query } }),
  300
);

// Throttle — execute at most once per interval
const trackEvent = throttle((event: AnalyticsEvent) => client.post('/analytics', event), 1_000);

// Delay — promisified setTimeout
await delay(500);

Runtime detection

import { detectRuntime, getRuntimeCapabilities, isBrowser, isNode, isEdgeRuntime } from 'reixo';

const runtime = detectRuntime();
// 'browser' | 'node' | 'bun' | 'deno' | 'workerd' | 'edge-light' | 'fastly' | 'unknown'

const caps = getRuntimeCapabilities();
// {
//   name: 'node',
//   hasFetch: true,
//   hasStreams: true,
//   hasCrypto: true,
//   hasXHR: false,
//   hasNodeErrorCodes: true,
//   hasHTTP2: true,
// }

if (isBrowser()) console.log('Running in a browser');
if (isNode()) console.log('Running in Node.js');
if (isEdgeRuntime()) console.log('Running on Cloudflare Workers / Vercel Edge / Fastly');

Error types

import { HTTPError, AbortError, ValidationError } from 'reixo';

try {
  await client.get('/api');
} catch (error) {
  if (error instanceof HTTPError) {
    console.log(error.status); // HTTP status code (number)
    console.log(error.response); // raw Response object
    console.log(error.message); // human-readable description
  }
  if (error instanceof AbortError) {
    console.log('Request was cancelled');
  }
  if (error instanceof ValidationError) {
    console.log('Request configuration is invalid:', error.message);
  }
}

Complete configuration reference

HTTPClientConfig

interface HTTPClientConfig {
  // Core
  baseURL?: string;
  timeoutMs?: number; // default: 30000
  headers?: HeadersWithSuggestions;
  transport?: HTTPRequestFunction;
  logger?: Logger;

  // Resilience
  retry?: RetryOptions | boolean; // default: true
  retryPolicies?: Array<{ pattern: string | RegExp; retry: RetryOptions | boolean }>;
  circuitBreaker?: CircuitBreakerOptions | CircuitBreaker;

  // Performance
  rateLimit?: { requests: number; interval: number };
  cacheConfig?: CacheOptions | boolean;
  revalidateOnFocus?: boolean; // default: false
  revalidateOnReconnect?: boolean; // default: false
  enableDeduplication?: boolean; // default: false

  // Observability
  enableMetrics?: boolean;
  onMetricsUpdate?: (metrics: Metrics) => void;

  // Offline
  offlineQueue?: boolean | PersistentQueueOptions;

  // Progress
  onUploadProgress?: (p: { loaded: number; total: number | null; progress: number | null }) => void;
  onDownloadProgress?: (p: {
    loaded: number;
    total: number | null;
    progress: number | null;
  }) => void;

  // API versioning
  apiVersion?: string;
  versioningStrategy?: 'url' | 'header'; // default: 'url'
  versionHeader?: string; // default: 'Accept-Version'

  // Node.js-specific
  pool?: ConnectionPoolOptions;
  ssl?: {
    rejectUnauthorized?: boolean;
    ca?: string | Buffer | Array<string | Buffer>;
    cert?: string | Buffer | Array<string | Buffer>;
    key?: string | Buffer | Array<string | Buffer>;
    passphrase?: string;
  };
}

RetryOptions

interface RetryOptions {
  maxRetries?: number; // default: 3
  initialDelayMs?: number; // default: 1000
  maxDelayMs?: number; // default: 30000
  backoffFactor?: number; // default: 2
  jitter?: boolean; // default: false
  retryCondition?: (error: HTTPError) => boolean;
  onRetry?: (error: HTTPError, attempt: number) => void;
}

CircuitBreakerOptions

interface CircuitBreakerOptions {
  failureThreshold?: number; // default: 5
  resetTimeoutMs?: number; // default: 60000
  halfOpenRetries?: number; // default: 1
  fallback?: () => Promise<unknown>;
  onStateChange?: (from: string, to: string) => void;
}

CacheOptions

interface CacheOptions {
  ttl?: number; // ms; default: 300000
  strategy?: 'cache-first' | 'network-first' | 'stale-while-revalidate';
  maxSize?: number; // max entries; default: 1000
  storage?: StorageAdapter; // MemoryAdapter | WebStorageAdapter | custom
}

PollingOptions

interface PollingOptions<T> {
  interval: number;
  timeout?: number;
  maxAttempts?: number;
  until?: (data: T) => boolean;
  stopCondition?: (data: T) => boolean; // deprecated, prefer until
  adaptiveInterval?: (data: T) => number;
  onError?: (error: unknown, attempts: number) => boolean | void;
  backoff?: boolean | { factor: number; maxInterval: number };
}

WebSocketConfig

interface WebSocketConfig {
  url: string;
  protocols?: string | string[];
  reconnect?: boolean | RetryOptions; // true = 5 retries, exponential backoff
  heartbeat?: {
    interval: number;
    message?: string | object; // default: 'ping'
    timeout?: number;
  };
  autoConnect?: boolean; // default: true
}

SSEConfig

interface SSEConfig {
  url: string;
  withCredentials?: boolean; // default: false
  reconnect?: boolean | RetryOptions; // true = 5 retries, exponential backoff
  headers?: HeadersRecord; // only supported with fetch-based polyfills
}

License

MIT