Skip to content

@studnicky/fetch

Portable HTTP client contracts with Node Undici and browser-native adapters.

Install

bash
pnpm add @studnicky/fetch

@studnicky/fetch exports portable contracts, errors, and URL utilities. The Node client and Undici connection pooling are available from @studnicky/fetch/node. Browser applications use @studnicky/fetch/browser, whose BrowserFetchClient delegates directly to native fetch.

FetchClient owns an enabled connection-pool Agent internally. Direct UndiciDispatcher use accepts a caller-owned undici Agent; retain that Agent for request dispatch and use UndiciDispatcher for health checks and lifecycle management.

Try it

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

Loading example…

Usage

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

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

// Execute requests directly through the verb methods:
// await api.get('/users?page=1&limit=20', {
//   headers: { 'X-Correlation-ID': 'abc-123' },
//   metadata: { operation: 'listUsers', source: 'dashboard' },
//   timeout: 3000
// });

Request methods

FetchClient.create(config?) accepts shared baseURL, headers, query parameters, timeout, metadata, request-ID, fetch-option, hook-timeout, dispatcher settings, an optional Signal composer, and an optional clock provider for Node request durations. Requests execute through the canonical verb methods:

MethodsOptions
get, head, options, deleteFetchOptionsInterface
post, put, patchBodyRequestOptionsInterface with optional body serialization

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: RequestContextInterface): Promise<RequestContextInterface>Mutate context.url, context.options, or context.metadata before the request is sent
onResponse(context: ResponseContextInterface): Promise<ResponseContextInterface>Inspect or replace context.response after the raw response arrives

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

ts
import type { RequestContextInterface, ResponseContextInterface } from '../src/node/index.js';

import { FetchClient } from '../src/node/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 {
  readonly requestLog: string[] = [];
  readonly responseLog: number[] = [];

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

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

await (async function runOverrideHooksExample(): Promise<void> {
  const originalFetch = globalThis.fetch;
  globalThis.fetch = (_input, init) => {
    const echoed: Record<string, string> = {};
    for (const [name, value] of new Headers(init?.headers).entries()) {
      Reflect.set(echoed, name, value);
    }

    const result = Promise.resolve(new Response(JSON.stringify({ 'echoed': echoed }), {
      'headers': { 'Content-Type': 'application/json' },
      'status': 200
    }));
    return result;
  };

  const client = AuthClient.create({ 'baseURL': 'https://example.test' });

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

    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');
  } finally {
    globalThis.fetch = originalFetch;
  }
})();

URL utilities

ExportPurpose
UrlQueryStringStatic helpers for building and parsing URLs

Entities

ClientConfigDataEntity.intake is the configuration data boundary. It accepts the JSON-shaped configuration fields (autoGenerateRequestId, baseURL, pool dispatcher settings, headers, hook timeout, metadata, default options, parameters, and timeout), clones and normalizes them, and rejects invalid data. FetchClient translates a failed intake to ConfigurationError.

requestIdGenerator remains an injected RequestIdGeneratorInterface collaborator. signal accepts an injected @studnicky/signal Signal composer, which combines each request timeout and caller AbortSignal identically in Node and browser clients. clock accepts a ClockProviderInterface and measures Node request lifecycle durations. Default fetch options can also carry runtime values such as request bodies, abort signals, and a per-request dispatcher; those retain their typed runtime contracts and are not represented as JSON schema data.

@studnicky/fetch/entities exports every schema namespace in src/entities, including client and dispatcher configuration, request and response metadata, events, and dispatcher health data.

typescript
import { ClientConfigDataEntity } from '@studnicky/fetch/entities';

Interfaces

@studnicky/fetch/interfaces exports every TypeScript contract in src/interfaces, including request, client, dispatcher, lifecycle-context, and request-ID-generator contracts.

typescript
import type { RequestIdGeneratorInterface } from '@studnicky/fetch/interfaces';

Exports

SymbolPurposeImport path
FetchClientCreates configured Node HTTP clients.@studnicky/fetch/node
UndiciDispatcherManages a caller-owned undici connection pool.@studnicky/fetch/node
UrlQueryStringBuilds and parses URL query strings.@studnicky/fetch
DEFAULT_DISPATCHER_CONFIGProvides default connection-pool settings.@studnicky/fetch/node
AbortErrorRepresents caller-aborted requests.@studnicky/fetch
BodyTimeoutErrorRepresents response-body timeout failures.@studnicky/fetch
ConfigurationErrorRepresents invalid fetch configuration.@studnicky/fetch
ConnectTimeoutErrorRepresents connection timeout failures.@studnicky/fetch
FetchBaseErrorBase error for fetch failures.@studnicky/fetch
HeadersTimeoutErrorRepresents response-header timeout failures.@studnicky/fetch
HTTPErrorRepresents non-success HTTP responses.@studnicky/fetch
SocketErrorRepresents socket failures.@studnicky/fetch
SocketExhaustionErrorRepresents exhausted connection pools.@studnicky/fetch
TimeoutErrorRepresents request timeout failures.@studnicky/fetch
BodyRequestOptionsInterfaceDefines options for body-bearing requests.@studnicky/fetch
ClientConfigInterfaceDefines configured-client options.@studnicky/fetch
FetchClientInterfaceDefines the client contract for composition.@studnicky/fetch
FetchOptionsInterfaceDefines options for non-body requests.@studnicky/fetch
QueryParametersInterfaceDefines URL query parameter values.@studnicky/fetch
RequestContextInterfaceDefines the request lifecycle context.@studnicky/fetch
RequestIdGeneratorInterfaceDefines the request-ID collaborator contract.@studnicky/fetch
ResponseContextInterfaceDefines the response lifecycle context.@studnicky/fetch
UndiciDispatcherInterfaceDefines the dispatcher lifecycle contract.@studnicky/fetch
BrowserFetchClientProvides native browser fetch through the shared client contract.@studnicky/fetch/browser
FetchTransportRoutes browser requests to native fetch.@studnicky/fetch/browser

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 { RequestContextInterface, ResponseContextInterface } from '../src/node/index.js';

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

class ObservedFetch extends FetchClient {
  readonly hookLog: string[] = [];

  protected override onRequest(context: RequestContextInterface): Promise<RequestContextInterface> {
    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: RequestContextInterface = { ...context, 'options': { ...context.options, 'headers': headers } };
    const response = Promise.resolve(result);
    return response;
  }

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

  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: Error, 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');
  }
}

const originalFetch = globalThis.fetch;
globalThis.fetch = (input) => {
  const url = new URL(String(input));

  if (url.pathname === '/ok') {
    const result = Promise.resolve(new Response(JSON.stringify({ 'status': 'ok' }), {
      'headers': { 'Content-Type': 'application/json' },
      'status': 200
    }));
    return result;
  }

  if (url.pathname === '/error') {
    const result = Promise.resolve(new Response(JSON.stringify({ 'error': 'unavailable' }), {
      'headers': { 'Content-Type': 'application/json' },
      'status': 503
    }));
    return result;
  }

  const result = Promise.resolve(new Response('', { 'status': 404 }));
  return result;
};

const client = ObservedFetch.create({
  'baseURL': 'https://example.test'
});

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

  // Scenario 2: non-2xx response
  await client.get('/error');
} finally {
  globalThis.fetch = originalFetch;
}

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