@studnicky/fetch
Make HTTP requests with Node and browser clients that share configuration and request contracts.
What it is
A composable HTTP-client primitive that provides runtime-specific transports, canonical request configuration, URL query handling, and lifecycle hooks. It gives consumers a boundary around platform fetch without defining their remote service or business flow.
What it is for
Northstar Books uses it for catalogue availability reads, basket pricing calls, and browser-facing product queries with one request contract across runtimes. Consumers own URLs, authentication, retry composition, response interpretation, and the bookstore policies that decide what to do with a result.
Northstar Books examples
The runnable browser-fetch example performs a live GET through the browser client, showing Northstar Books how a product-search screen can load a catalogue response through the shared request contract. It proves that the browser transport delegates to platform fetch while the caller retains the endpoint and response policy.
Public entrypoints
| Import path | Use it when |
|---|---|
@studnicky/fetch/node | A Northstar Books server needs configured HTTP requests, URL queries, or a caller-owned connection pool. |
@studnicky/fetch/browser | A storefront browser needs the native-fetch implementation behind the shared client contract. |
@studnicky/fetch/entities | An adapter validates the package's JSON-shaped request, response, query, and client-configuration data. |
@studnicky/fetch/interfaces | TypeScript composition shares HTTP client, request, response, query, and dispatcher contracts. |
@studnicky/fetch/retry | A client needs HTTP-aware retry classification and backoff for failed requests. |
Install
pnpm add @studnicky/fetchChoose a runtime
Import Node APIs from @studnicky/fetch/node and browser APIs from @studnicky/fetch/browser. Import shared types and JSON entities from @studnicky/fetch/interfaces and @studnicky/fetch/entities.
Usage
A FetchClient starts life as a single configuration object — base URL, default headers, a timeout — shared by every request it makes afterward. This example builds one for Northstar's catalogue API once, bearer token and client name already baked in, so every get/post/put call that follows just supplies the path and anything genuinely request-specific:
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
// });get, head, options, and delete accept FetchOptionsInterface. post, put, and patch also accept a request body.
Browser demo
The same client contract works unmodified in a browser. This demo swaps in BrowserFetchClient, points it at a stand-in fetch implementation, and performs a real GET for a single todo item, checking that the response status and parsed JSON body come back exactly as a storefront screen would expect:
Compose a resilient Node request
A single fetch call is rarely resilient on its own — Northstar's supplier endpoint can flake for a request or two before it recovers, and one deadline still has to apply across every attempt, not reset on each retry. This recipe wires FetchClient, Retry, and Signal together by hand: Signal.compose() establishes that one deadline, Retry.execute() reissues the request against a server that deliberately fails twice before succeeding, and the assertions confirm it took exactly two retries to land one real 200. Because it needs an actual local HTTP server to fail against, this stays a Node-only source example rather than a browser runnable demo.
/** resilientRequestComposition — composes FetchClient, Retry, and Signal directly while keeping every caller-owned primitive visible. Run: npx tsx examples/resilientRequestComposition.ts */
import { RuntimeError } from '@studnicky/errors/node';
import { FetchClient } from '@studnicky/fetch/node';
import { Retry } from '@studnicky/resilience/retry/node';
import { Signal } from '@studnicky/signal/node';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
const fetchClient = FetchClient.create({ 'baseURL': `http://localhost:${address.port}` });
const retry = Retry.create({
'errorClassifier': () => {return { 'retryable': true };},
'maximumRetries': 3
});
const signal = Signal.create();
const response = await (async (): Promise<Response> => {
const options: SignalComposeOptionsInterface = { 'deadlineMs': 5000 };
using composed = await signal.compose(
options.deadlineMs === undefined ? {} : { 'deadlineMs': options.deadlineMs }
);
const result = await retry.execute(async (): Promise<Response> => {
const attempt = await fetchClient.get('/flaky', { 'signal': composed.signal });
if (!attempt.ok) {
throw RuntimeError.create(`HTTP ${attempt.status}`);
}
return attempt;
});
return result;
})();
console.log('Response status:', response.status);Customize requests
Say every request from one client needs an Authorization header stamped on automatically — not something anyone wants to remember at every call site. This example subclasses FetchClient, overriding onRequest to add that header before the request goes out and onResponse to note the status code on the way back. The test server echoes the headers it actually received, so the assertions prove the header really arrived, not just that the override compiled.
import { JsonObject, Predicates } from '@studnicky/types/node';
import assert from 'node:assert/strict';
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 = JsonObject.fromEntries(new Headers(init?.headers).entries());
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 parsedBody: unknown = await res.json();
assert.ok(Predicates.isObject(parsedBody), 'response body is an object');
const { echoed } = parsedBody;
assert.ok(Predicates.isObject(echoed), 'response body has an echoed headers object');
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(echoed.authorization, 'Bearer example-token', 'Authorization header was injected');
assert.strictEqual(echoed['x-client'], 'AuthClient', 'X-Client header was injected');
console.log('02-override-hooks: all assertions passed');
} finally {
globalThis.fetch = originalFetch;
}
})();Configure and observe
Configure shared baseURL, headers, query parameters, timeouts, metadata, request IDs, and dispatcher settings with FetchClient.create. Timeouts use positive whole milliseconds. Use UrlQueryString to build and parse query strings.
Beyond configuring one client, it helps to see what it's actually doing on the wire — every request starting, every response landing, every failure. This example overrides both the request/response transform hooks and the observer hooks that fire around them, then drives one successful call and one that comes back 503, so the hook log shows exactly which events fired for each outcome:
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;
}Entities
ClientConfigDataEntity.intake accepts JSON-shaped client configuration fields (autoGenerateRequestId, baseURL, pool dispatcher settings, headers, hook timeout, metadata, default options, and timeout), clones and normalizes them, and rejects invalid data. FetchClient translates a failed intake to ConfigurationError; query parameters use QueryParametersEntity at their own runtime boundary.
QueryParametersEntity.intake accepts JSON-safe query scalars and scalar arrays. UrlQueryString accepts QueryParametersInterface; undefined values are omitted while JSON values, including null, are serialized. FetchClient.create({ parameters }) intakes configured parameters once and retains only their canonical JSON-safe representation.
@studnicky/fetch/entities exports the package's public schema namespaces, including client and dispatcher configuration, query parameters, request and response metadata, events, and dispatcher health data.
import { ClientConfigDataEntity, QueryParametersEntity } from "@studnicky/fetch/entities";Interfaces
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/node |
DEFAULT_DISPATCHER_CONFIG | Provides default connection-pool settings. | @studnicky/fetch/node |
AbortError | Represents caller-aborted requests. | @studnicky/fetch/node |
BodySerializationError | Represents a request body that cannot be serialized to JSON. | @studnicky/fetch/node |
BodyTimeoutError | Represents response-body timeout failures. | @studnicky/fetch/node |
ConfigurationError | Represents invalid fetch configuration. | @studnicky/fetch/node |
ConnectTimeoutError | Represents connection timeout failures. | @studnicky/fetch/node |
ConstructionError | Represents a create() that did not construct the requested subclass. | @studnicky/fetch/node |
DispatcherShutdownError | Represents a failed undici Agent close() or destroy(). | @studnicky/fetch/node |
FetchBaseError | Base error for fetch failures. | @studnicky/fetch/node |
HeadersTimeoutError | Represents response-header timeout failures. | @studnicky/fetch/node |
HTTPError | Represents non-success HTTP responses. | @studnicky/fetch/node |
InvalidUrlError | Represents a URL that fails to parse in the fetch test dispatcher. | @studnicky/fetch/node |
QueryEncodingError | Represents query parameters that cannot be percent-encoded. | @studnicky/fetch/node |
RequestFailedError | Wraps a platform fetch failure (network TypeError, undici error) with the original as cause. | @studnicky/fetch/node |
SocketError | Represents socket failures. | @studnicky/fetch/node |
SocketExhaustionError | Represents exhausted connection pools. | @studnicky/fetch/node |
TimeoutError | Represents request timeout failures. | @studnicky/fetch/node |
BodyRequestOptionsInterface | Defines options for body-bearing requests. | @studnicky/fetch/interfaces |
ClientConfigInterface | Defines configured-client options. | @studnicky/fetch/interfaces |
FetchClientInterface | Defines the client contract for composition. | @studnicky/fetch/interfaces |
FetchOptionsInterface | Defines options for non-body requests. | @studnicky/fetch/interfaces |
QueryParametersEntity | Validates JSON-safe URL query parameter data. | @studnicky/fetch/entities |
QueryParametersInterface | Defines runtime URL query parameter values and undefined omission markers. | @studnicky/fetch/interfaces |
RequestContextInterface | Defines the request lifecycle context. | @studnicky/fetch/interfaces |
RequestIdGeneratorInterface | Defines the request-ID collaborator contract. | @studnicky/fetch/interfaces |
ResponseContextInterface | Defines the response lifecycle context. | @studnicky/fetch/interfaces |
UndiciDispatcherInterface | Defines the dispatcher lifecycle contract. | @studnicky/fetch/interfaces |
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
Each of these seven hooks fires around the network call itself rather than around the request/response transform: before the call starts, after a clean response, after a non-success response, after a request failure, after a timeout or abort, and before the client's dispatcher is destroyed. The ObservedFetch example above exercises this lifecycle in practice.
| Hook | When it fires |
|---|---|
onRequestStart | Before sending a request |
onResponseSuccess | After a successful response |
onResponseError | After a non-success response |
onRequestError | After a request failure |
onTimeout | After a timeout |
onAbort | After a caller abort |
onDispatcherDestroy | Before the client's dispatcher is destroyed |
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;
}