@studnicky/fetch
Portable HTTP client contracts with Node Undici and browser-native adapters.
Install
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:
Usage
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:
| Methods | Options |
|---|---|
get, head, options, delete | FetchOptionsInterface |
post, put, patch | BodyRequestOptionsInterface 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.
| Hook | Signature | Purpose |
|---|---|---|
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.
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
| Export | Purpose |
|---|---|
UrlQueryString | Static 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.
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.
import type { RequestIdGeneratorInterface } from '@studnicky/fetch/interfaces';Exports
| Symbol | Purpose | Import path |
|---|---|---|
FetchClient | Creates configured Node HTTP clients. | @studnicky/fetch/node |
UndiciDispatcher | Manages a caller-owned undici connection pool. | @studnicky/fetch/node |
UrlQueryString | Builds and parses URL query strings. | @studnicky/fetch |
DEFAULT_DISPATCHER_CONFIG | Provides default connection-pool settings. | @studnicky/fetch/node |
AbortError | Represents caller-aborted requests. | @studnicky/fetch |
BodyTimeoutError | Represents response-body timeout failures. | @studnicky/fetch |
ConfigurationError | Represents invalid fetch configuration. | @studnicky/fetch |
ConnectTimeoutError | Represents connection timeout failures. | @studnicky/fetch |
FetchBaseError | Base error for fetch failures. | @studnicky/fetch |
HeadersTimeoutError | Represents response-header timeout failures. | @studnicky/fetch |
HTTPError | Represents non-success HTTP responses. | @studnicky/fetch |
SocketError | Represents socket failures. | @studnicky/fetch |
SocketExhaustionError | Represents exhausted connection pools. | @studnicky/fetch |
TimeoutError | Represents request timeout failures. | @studnicky/fetch |
BodyRequestOptionsInterface | Defines options for body-bearing requests. | @studnicky/fetch |
ClientConfigInterface | Defines configured-client options. | @studnicky/fetch |
FetchClientInterface | Defines the client contract for composition. | @studnicky/fetch |
FetchOptionsInterface | Defines options for non-body requests. | @studnicky/fetch |
QueryParametersInterface | Defines URL query parameter values. | @studnicky/fetch |
RequestContextInterface | Defines the request lifecycle context. | @studnicky/fetch |
RequestIdGeneratorInterface | Defines the request-ID collaborator contract. | @studnicky/fetch |
ResponseContextInterface | Defines the response lifecycle context. | @studnicky/fetch |
UndiciDispatcherInterface | Defines the dispatcher lifecycle contract. | @studnicky/fetch |
BrowserFetchClient | Provides native browser fetch through the shared client contract. | @studnicky/fetch/browser |
FetchTransport | Routes 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.
| Hook | When it fires | Args |
|---|---|---|
onRequestStart | Before the request is sent | method, path, requestId, url |
onResponseSuccess | HTTP 2xx response received | method, requestId, statusCode, durationMs |
onResponseError | HTTP non-2xx response received | method, requestId, statusCode, durationMs |
onRequestError | Network-level error (connect fail, etc.) | error, method, requestId, url, durationMs |
onTimeout | Request aborted by timeout | method, requestId, url, timeoutMs |
onAbort | Request aborted by caller | method, requestId, url |
onDispatcherDestroy | Dispatcher is about to be destroyed | (none) |
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.