@studnicky/fetch
HTTP client with timeout, override hooks, and configured clients for Node.js.
Install
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:
/** 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
Usage
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.
| Hook | Signature | Purpose |
|---|---|---|
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.
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
| Export | Purpose |
|---|---|
UrlUtils | Static helpers for building and parsing URLs |
Subpath exports
| Subpath | Contents |
|---|---|
@studnicky/fetch | FetchClient, HttpMethods, RequestBuilder, UndiciDispatcher, UrlUtils, all error classes |
@studnicky/fetch/constants | DEFAULT_DISPATCHER_CONFIG |
@studnicky/fetch/errors | HTTPError, TimeoutError, NetworkError, AbortError, and more |
@studnicky/fetch/interfaces | All interface types |
@studnicky/fetch/modules | Module re-exports |
@studnicky/fetch/types | QueryParamsType, 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.
| 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 { 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.