Skip to content

@studnicky/fetch

HTTP client with timeout, override hooks, and configured clients for Node.js.

Install

bash
pnpm add @studnicky/fetch

@studnicky/fetch runs in the browser and Node alike: every request goes through the runtime's native fetch, and the override hook pipeline, timeout, request builder, and URL utilities work in both. The undici connection-pool dispatcher (socket pools, HTTP/1.1 keep-alive) is a Node-only enhancement — disabled by default and enabled with dispatcher: { enabled: true }. In the browser, native fetch handles connection management, so enabling the undici dispatcher there throws a clear error.

Try it

A real GET over native fetch, with override hooks and a timeout — press Run to watch it fetch live:

Live GET over native fetch with override hooks
/** browserFetch — a real HTTP GET over the runtime's native fetch, via a subclass with onRequest/onResponse hooks. Run: npx tsx packages/fetch/examples/browserFetch.ts */

import assert from 'node:assert/strict';

// #region usage
import type { RequestContextType } from '../src/types/RequestContextType.js';
import type { ResponseContextType } from '../src/types/ResponseContextType.js';

import { FetchClient } from '../src/index.js';

// A configured subclass over the runtime's native `fetch`. The undici
// connection-pool dispatcher is a Node-only enhancement (disabled by default),
// so this exact code runs unchanged in the browser.
class TraceClient extends FetchClient {
  static override create(config: Parameters<typeof FetchClient.create>[0] = {}): TraceClient {
    return new this(config);
  }

  requestHookCount = 0;
  responseHookCount = 0;

  protected override onRequest(context: RequestContextType): Promise<RequestContextType> {
    this.requestHookCount += 1;
    const headers: Record<string, string> = context.options.headers ?? {};
    headers['X-Demo-Trace'] = 'substrate-fetch';
    const result: RequestContextType = { ...context, 'options': { ...context.options, 'headers': headers } };
    return Promise.resolve(result);
  }

  protected override onResponse(context: ResponseContextType): Promise<ResponseContextType> {
    this.responseHookCount += 1;
    return Promise.resolve(context);
  }
}

await (async function runBrowserFetchExample(): Promise<void> {
  const api = TraceClient.create({
    'baseURL': 'https://jsonplaceholder.typicode.com',
    'timeout': 8000
  });

  console.log('GET https://jsonplaceholder.typicode.com/todos/1 (native fetch + onRequest/onResponse hooks)');

  const response = await api.get('/todos/1');
  const todo: { 'completed': boolean; 'id': number; 'title': string } =
    await response.json() as { 'completed': boolean; 'id': number; 'title': string };

  console.log(`status: ${response.status}`);
  console.log(`onRequest fired ${api.requestHookCount}x, onResponse fired ${api.responseHookCount}x`);
  console.log(`todo #${todo.id}: "${todo.title}" (completed: ${todo.completed})`);

  assert.equal(response.status, 200, 'expected HTTP 200');
  assert.equal(api.requestHookCount, 1, 'onRequest fires once per request');
  assert.equal(api.responseHookCount, 1, 'onResponse fires once per request');
  assert.equal(todo.id, 1, 'fetched todo #1');

  console.log('browserFetch: all assertions passed');
})();
// #endregion usage
Output
Press Execute to run this example against the real library.

Usage

ts
import { FetchClient, RequestBuilder } from '../src/index.js';

// Create a configured client with base URL, headers, and timeout
const api = FetchClient.create({
  'autoGenerateRequestId': true,
  'baseURL': 'https://api.example.com',
  'headers': { 'Authorization': 'Bearer demo-token', 'X-Client-Name': 'example' },
  'timeout': 8000
});

console.log('FetchClient created with baseURL, headers, and timeout');

// Chain request builder options: path, query params, extra header, per-request timeout
const builder = api
  .request('/users')
  .queryString('page', 1)
  .queryString('limit', 20)
  .header('X-Correlation-ID', 'abc-123')
  .timeout(3000)
  .metadata({ 'operation': 'listUsers', 'source': 'dashboard' });

console.log('RequestBuilder chained: path=/users, page=1, limit=20, timeout=3000ms');

Override hooks

FetchClient exposes two protected lifecycle hooks that subclasses override to transform the outgoing request or incoming response. These two hooks are in-band behavioral seams: they can mutate the request/response flow directly, and if they throw, the request fails through the normal error path.

HookSignaturePurpose
onRequest(context: RequestContextType): Promise<RequestContextType>Mutate context.url, context.options, or context.metadata before the request is sent
onResponse(context: ResponseContextType): Promise<ResponseContextType>Inspect or replace context.response after the raw response arrives

RequestContextType carries url, options, and metadata. ResponseContextType carries response and request. The base implementations return the context unchanged; un-subclassed instances behave as if the hooks are absent.

ts
import type { RequestContextType } from '../src/types/RequestContextType.js';
import type { ResponseContextType } from '../src/types/ResponseContextType.js';

import { FetchClient } from '../src/index.js';

/**
 * AuthClient — stamps every outgoing request with an Authorization header.
 *
 * Override onRequest to mutate the request context before the HTTP call.
 * Return the context unchanged in onResponse for a no-op response stage.
 */
class AuthClient extends FetchClient {
  static override create(config: Parameters<typeof FetchClient.create>[0] = {}): AuthClient {
    return new this(config);
  }

  readonly requestLog: string[] = [];
  readonly responseLog: number[] = [];

  protected override onRequest(context: RequestContextType): Promise<RequestContextType> {
    this.requestLog.push(context.url);
    const result: RequestContextType = {
      ...context,
      'options': {
        ...context.options,
        'headers': {
          ...context.options.headers,
          'Authorization': 'Bearer example-token',
          'X-Client': 'AuthClient'
        }
      }
    };
    return Promise.resolve(result);
  }

  protected override onResponse(context: ResponseContextType): Promise<ResponseContextType> {
    this.responseLog.push(context.response.status);
    return Promise.resolve(context);
  }
}

await (async function runOverrideHooksExample(): Promise<void> {
  // Start an in-process server that echoes received headers back in the response body
  const server = createServer((req, res) => {
    const headers: Record<string, string> = {};
    for (const [name, value] of Object.entries(req.headers)) {
      if (typeof value === 'string') {
        headers[name] = value;
      }
    }
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ 'echoed': headers }));
  });

  const baseURL = await new Promise<string>((resolve) => {
    server.listen(0, '127.0.0.1', () => {
      const addr = server.address();
      resolve(`http://127.0.0.1:${(addr as { 'port': number }).port}`);
    });
  });

  const client = AuthClient.create({ 'baseURL': baseURL });

  const res = await client.get('/check');
  const body = await res.json() as { 'echoed': Record<string, string> };

  server.close();

  assert.ok(client instanceof FetchClient, 'AuthClient is-a FetchClient');
  assert.ok(client instanceof AuthClient, 'instanceof works for subclass');
  assert.strictEqual(client.requestLog.length, 1, 'onRequest fired once');
  assert.ok(client.requestLog[0]?.includes('/check') === true, 'onRequest received the correct url');
  assert.strictEqual(client.responseLog.length, 1, 'onResponse fired once');
  assert.strictEqual(client.responseLog[0], 200, 'onResponse received 200 status');
  assert.strictEqual(body.echoed.authorization, 'Bearer example-token', 'Authorization header was injected');
  assert.strictEqual(body.echoed['x-client'], 'AuthClient', 'X-Client header was injected');

  console.log('02-override-hooks: all assertions passed');
})();

URL utilities

ExportPurpose
UrlUtilsStatic helpers for building and parsing URLs

Subpath exports

SubpathContents
@studnicky/fetchFetchClient, HttpMethods, RequestBuilder, UndiciDispatcher, UrlUtils, all error classes
@studnicky/fetch/constantsDEFAULT_DISPATCHER_CONFIG
@studnicky/fetch/errorsHTTPError, TimeoutError, NetworkError, AbortError, and more
@studnicky/fetch/interfacesAll interface types
@studnicky/fetch/modulesModule re-exports
@studnicky/fetch/typesQueryParamsType, RequestContextType, ResponseContextType

Observability hooks

Override any protected observer hook to add logging, metrics, or tracing without modifying core behavior. These hooks are observational; they do not replace the request result or the canonical request error path.

HookWhen it firesArgs
onRequestStartBefore the request is sentmethod, path, requestId, url
onResponseSuccessHTTP 2xx response receivedmethod, requestId, statusCode, durationMs
onResponseErrorHTTP non-2xx response receivedmethod, requestId, statusCode, durationMs
onRequestErrorNetwork-level error (connect fail, etc.)error, method, requestId, url, durationMs
onTimeoutRequest aborted by timeoutmethod, requestId, url, timeoutMs
onAbortRequest aborted by callermethod, requestId, url
onDispatcherDestroyDispatcher is about to be destroyed(none)
ts
import type { RequestContextType } from '../src/types/RequestContextType.js';
import type { ResponseContextType } from '../src/types/ResponseContextType.js';

import { FetchClient } from '../src/index.js';

// Start an in-process server
const server = createServer((req, res) => {
  const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);

  if (url.pathname === '/ok') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ 'status': 'ok' }));
  } else if (url.pathname === '/error') {
    res.writeHead(503, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ 'error': 'unavailable' }));
  } else {
    res.writeHead(404);
    res.end();
  }
});

const baseURL = await new Promise<string>((resolve) => {
  server.listen(0, '127.0.0.1', () => {
    const addr = server.address();

    resolve(`http://127.0.0.1:${(addr as { 'port': number }).port}`);
  });
});

class ObservedFetch extends FetchClient {
  static override create(config: Parameters<typeof FetchClient.create>[0] = {}): ObservedFetch {
    return new this(config);
  }

  readonly hookLog: string[] = [];

  protected override onRequest(context: RequestContextType): Promise<RequestContextType> {
    console.log(`[fetch] onRequest url=${context.url}`);
    this.hookLog.push('onRequest');
    // Stamp a correlation header on every outgoing request
    const headers: Record<string, string> = context.options.headers ?? {};
    headers['X-Observed'] = 'true';
    const result: RequestContextType = { ...context, 'options': { ...context.options, 'headers': headers } };
    return Promise.resolve(result);
  }

  protected override onResponse(context: ResponseContextType): Promise<ResponseContextType> {
    console.log(`[fetch] onResponse status=${context.response.status}`);
    this.hookLog.push('onResponse');
    return Promise.resolve(context);
  }

  protected override onRequestStart(method: string, _path: string, requestId: string, url: string): void {
    const line = `[fetch] onRequestStart method=${method} url=${url} requestId=${requestId}`;

    console.log(line);
    this.hookLog.push('onRequestStart');
  }

  protected override onResponseSuccess(method: string, requestId: string, statusCode: number, durationMs: number): void {
    const line = `[fetch] onResponseSuccess method=${method} status=${statusCode} durationMs=${durationMs} requestId=${requestId}`;

    console.log(line);
    this.hookLog.push('onResponseSuccess');
  }

  protected override onResponseError(method: string, requestId: string, statusCode: number, durationMs: number): void {
    const line = `[fetch] onResponseError method=${method} status=${statusCode} durationMs=${durationMs} requestId=${requestId}`;

    console.log(line);
    this.hookLog.push('onResponseError');
  }

  protected override onRequestError(error: unknown, method: string, requestId: string, url: string, durationMs: number): void {
    const line = `[fetch] onRequestError method=${method} url=${url} error=${String(error)} durationMs=${durationMs} requestId=${requestId}`;

    console.log(line);
    this.hookLog.push('onRequestError');
  }

  protected override onDispatcherDestroy(): void {
    console.log('[fetch] onDispatcherDestroy');
    this.hookLog.push('onDispatcherDestroy');
  }
}

const client = ObservedFetch.create({
  'baseURL': baseURL,
  'dispatcher': { 'connections': 2, 'enabled': true }
});

// Scenario 1: successful request
await client.get('/ok');

// Scenario 2: non-2xx response
await client.get('/error');

// Scenario 3: destroy
await client.destroy();

The base class never calls any logger or metrics library. Observer hooks are no-ops by default; onRequest and onResponse are the in-band transform seams.

Source on GitHub