Skip to Content
HTTP ClientAPI Reference

API Reference

HttpClient

new HttpClient({ adapter, // HttpAdapter — required baseURL, // string? headers, // HeadersInput? timeout, // number? credentials, // 'omit' | 'same-origin' | 'include'? responseType, // ResponseType? paramsSerializer, // (params) => string? fileSerializer, // FileSerializer? — default WebFileSerializer, see Serializers below });
MethodReturnsNotes
use(pluginOrMiddleware)thisRegisters a plugin — chainable
request<T>(init)Promise<HttpResponse<T>>The only method returning the full response, not just data
get<T>(url, init?)Promise<T>
delete<T>(url, init?)Promise<T>
head(url, init?)Promise<HttpHeaders>Returns the response headers directly
post<T, B>(url, body?, init?)Promise<T>
put<T, B>(url, body?, init?)Promise<T>
patch<T, B>(url, body?, init?)Promise<T>
upload<T>(url, data, init?)Promise<T>data is a plain object or an existing FormData — see Serializers
download(url, init?)Promise<Blob>Forces responseType: 'blob'
extend(options?)HttpClientNew client, options merged over the parent’s, inheriting the plugins registered so far (a snapshot — later .use() calls on either client don’t affect the other)

extend() is what you use to build a client for recover()’s own callback to call the refresh endpoint with — call it before registering auth/recover on the main client, so the extended client inherits neither and can’t recurse into its own recovery logic. Every field on its options falls back to the parent’s via ??, so passing a field as explicit undefined doesn’t unset it, and passing headers replaces the parent’s wholesale rather than merging with them (unlike a per-request headers override, which layers on top).

resolve()

function resolve<TBody>( init: HttpRequestInit<TBody>, defaults?: ResolveDefaults, ): HttpRequest<TBody>;

The function HttpClient calls internally to turn an HttpRequestInit into the immutable HttpRequest every plugin sees — see Concepts: the request lifecycle for the full resolution rules. Exported so you can build a real HttpRequest for testing a plugin directly, without a full HttpClient.

withHeaders() / withMeta() / metaOptOut()

function withHeaders(request: HttpRequest, headers: HeadersInput): HttpRequest; function withMeta(request: HttpRequest, meta: HttpMeta): HttpRequest; function metaOptOut(key: keyof HttpMeta): (request: HttpRequest) => boolean;

Pure helpers for writing your own plugin — see Writing your own plugin below. withHeaders/withMeta never mutate the request passed in; both return a new one, following the same normalization rules resolve() itself uses (header keys lowercased, a null/undefined value deletes an existing key). metaOptOut(key) is (request) => request.meta[key] === false — strict equality, used as the default skip for auth/recover/errorMapper (see Concepts: meta flags).

Adapters

fetchAdapter()

import { fetchAdapter } from '@lamstack/http-client/adapters/fetch'; const client = new HttpClient({ adapter: fetchAdapter() }); // Inject a replacement (e.g. undici's fetch in older Node, or a test double): const testClient = new HttpClient({ adapter: fetchAdapter({ fetch: myFetch }) });

Wraps global fetch. Handles JSON/FormData/Blob/string/ArrayBuffer/typed-array (Uint8Array, DataView, …)/URLSearchParams/ReadableStream bodies automatically (sets content-type: application/json only when it has to JSON-stringify a plain object — never for FormData, so multipart uploads keep their browser/Node-generated boundary). Combines your signal with an internal timeout-derived one via AbortSignal.any(...), which needs Node 20.3+ or Safari 17.4+ — narrower than this package’s own engines.node: ">=20". Chrome/Firefox/Edge have supported it since 2023; if you must support Node 20.0–20.2 or an older Safari, polyfill AbortSignal.any before constructing the adapter.

axiosAdapter()

import axios from 'axios'; import { axiosAdapter } from '@lamstack/http-client/adapters/axios'; const client = new HttpClient({ adapter: axiosAdapter(axios.create({ baseURL: '...' })) });

Wraps a caller-supplied AxiosInstance as an opaque transport — it doesn’t matter whether that instance itself uses XHR, Node’s http, or axios’s own newer adapter: 'fetch' option internally. The adapter disables axios’s own JSON auto-parsing/validateStatus (both vary across environments) so behavior stays identical to the fetch adapter regardless of your axios configuration, and uses the same AbortSignal.any(...)-based timeout handling (same Node 20.3+/Safari 17.4+ requirement as fetchAdapter() above).

Writing your own adapter

Anything implementing HttpAdapter works — e.g. to target undici directly, React Native’s networking stack, or a fully scripted adapter for tests:

import type { HttpAdapter, HttpRequest, HttpResponse } from '@lamstack/http-client'; function myAdapter(): HttpAdapter { return { name: 'my-adapter', capabilities: { uploadProgress: false, downloadProgress: false, stream: false }, async send<T>(request: HttpRequest) { // ...call your transport, then normalize into an HttpResponse... // Any non-2xx outcome (or a network/timeout/cancellation failure) must // throw an HttpError — that's the one contract every plugin relies on. return {} as HttpResponse<T>; }, }; }

auth and Authenticator

import { auth } from '@lamstack/http-client'; client.use(auth(myAuthenticator)); client.use(auth(myAuthenticator, { skip: (request) => request.url.startsWith('/public') }));
OptionTypeDescription
skip(request: HttpRequest) => booleanDefaults to metaOptOut('auth'). Replaces the default entirely rather than adding to it.
ordernumberDefaults to PluginOrder.auth (100).

Built-in authenticators

import { allOf, apiKey, basic, bearer, withHeaders } from '@lamstack/http-client'; // The common case — a Bearer token from any source with getAccessToken(): client.use(auth(bearer(session))); client.use(auth(bearer(session, { header: 'x-api-key', scheme: '' }))); // custom header, no "Bearer " prefix client.use(auth(bearer(() => currentToken))); // or a plain function // A static or dynamically-resolved API key, as a header or a query parameter: client.use(auth(apiKey({ in: 'header', name: 'x-api-key', value: process.env.API_KEY! }))); client.use(auth(apiKey({ in: 'query', name: 'key', value: async () => rotateKey() }))); // HTTP Basic auth: client.use(auth(basic(username, password))); // Compose several — e.g. a bearer token plus a request signature: client.use( auth( allOf(bearer(session), async (request) => withHeaders(request, { 'x-signature': await sign(request) }), ), ), );

bearer() never emits a literal "Bearer null" — if its source resolves null/no token, the header is simply left unset. Its source contract is just { getAccessToken(): Awaitable<string | null> } (or a plain function).

recover

import { metaOptOut, onStatus, recover } from '@lamstack/http-client'; client.use( recover({ recover: () => session.renew(), // required shouldRecover: onStatus(401, { exclude: ['/auth/login', '/auth/refresh'] }), skip: metaOptOut('recover'), canRecover: () => session.canRenew(), maxAttempts: 1, maxStaleRetries: 1, cooldownMs: 1000, events: recoveryEvents, }), );
OptionTypeDescription
recover(context: RecoveryContext) => Promise<void>Required. Runs exactly once per cycle, shared by every request queued behind it.
shouldRecover(context: RecoveryContext) => Awaitable<boolean>Decides whether a given failure is eligible for recovery. Defaults to onStatus(401).
skip(request: HttpRequest) => booleanChecked first, before shouldRecover, independent of it. Defaults to metaOptOut('recover').
canRecover() => Awaitable<boolean>Optional optimization: skip a doomed recovery attempt before it starts.
maxAttemptsnumberMaximum recovery cycles per logical request. Defaults to 1. Independent of maxStaleRetries.
maxStaleRetriesnumberMaximum consecutive stale-generation retries before giving up. Defaults to 1. Independent of maxAttempts — see Concepts: Stale retries.
cooldownMsnumberCooldown after a failed cycle before another request may start a fresh one. Defaults to 1000; 0 disables it. See Concepts: Refresh storms.
eventsEventBus<RecoveryEventMap>Optional — see Recovery events below.
ordernumberDefaults to PluginOrder.recover (0).

onStatus(status, { exclude? }) matches an eligible status (default 401), excluding requests whose URL matches one of exclude — typically the recovery endpoint itself, to avoid a loop.

Wiring to a token store

There’s no built-in session helper yet — see Concepts: wiring auth + recover to a token store for the JSON-body-refresh-token and HttpOnly-cookie worked examples.

errorMapper

import { errorMapper, HttpError } from '@lamstack/http-client'; class ValidationError extends Error { constructor(public readonly fields: Record<string, string>) { super('Validation failed'); } } client.use( errorMapper((error) => (error.status === 422 ? new ValidationError(error.data as never) : error)), ); await client.get('/x', { meta: { mapError: false } }); // opt this one request out
OptionTypeDescription
skip(request: HttpRequest) => booleanDefaults to metaOptOut('mapError').
ordernumberDefaults to PluginOrder.normalize (-100) — outside recover/auth, so recover still inspects the raw HttpError; only errors that survive a recovery retry ever reach the mapper.

HttpError

Every adapter throws an HttpError for any non-2xx response, network failure, timeout, or cancellation — never a raw transport-specific error:

class HttpError<T = unknown> extends Error { code: 'HTTP_ERROR' | 'NETWORK_ERROR' | 'TIMEOUT' | 'CANCELED' | 'PARSE_ERROR' | 'UNKNOWN'; status: number; // 0 when there is no HTTP response at all data?: T; // the parsed error response body, when there is one request: HttpRequest; response?: HttpResponse<T>; cause?: unknown; // non-enumerable, matching native Error.cause get isNetworkError(): boolean; // code === 'NETWORK_ERROR' get isCanceled(): boolean; // code === 'CANCELED' static is(error: unknown): error is HttpError; static from(error: unknown, request: HttpRequest): HttpError; // wraps anything else, passes an existing HttpError through unchanged }

HttpError.from()’s fallback is 'UNKNOWN', not 'NETWORK_ERROR' — it’s used by recover()/errorMapper() on anything they catch that isn’t already an HttpError, which normally only happens for a bug in a plugin between them and the adapter. Claiming NETWORK_ERROR for that would make isNetworkError lie and could get a real bug silently retried by recover().

try { await client.get('/x'); } catch (error) { if (HttpError.is(error)) { console.log(error.status, error.code, error.data); } }

Recovery events (EventBus)

A generic typed pub/sub, not specific to auth — recover() defines its own event map on top of it:

import { EventBus } from '@lamstack/http-client'; import type { RecoveryEventMap } from '@lamstack/http-client'; const recoveryEvents = new EventBus<RecoveryEventMap>(); const unsubscribe = recoveryEvents.on('recovery:failed', ({ error }) => { session.end(); redirectToLogin(); }); // later: unsubscribe(); — or recoveryEvents.off('recovery:failed', listener) recoveryEvents.on('recovery:succeeded', () => console.log('session renewed')); recoveryEvents.on('recovery:unavailable', ({ error }) => reportToSentry(error)); client.use(recover({ recover: renewSession, events: recoveryEvents }));
MethodSignatureDescription
on(event, listener) => () => voidSubscribes. Returns an unsubscribe function, which composes naturally with a React useEffect cleanup.
off(event, listener) => voidExplicit unsubscribe — same signature as on.
emit(event, payload) => voidTriggers every listener for event. A throwing listener never prevents its siblings from running.
clearAll() => voidRemoves every listener for every event — handy in a test’s afterEach.

EventBus is not a singleton — create one per app (or per independent set of clients that should share recovery state) and pass it explicitly.

EventPayloadFires
recovery:succeeded{}Once per successful recovery cycle, never once per queued request
recovery:failed{ error: unknown }Once per failed recovery cycle
recovery:unavailable{ error: HttpError }Once per request whose canRecover() check failed, or that hit an active cooldownMs window — recovery never attempted

Serializers

By default, upload()’s non-primitive values are handled by WebFileSerializer (File/Blob). For React Native (no File/Blob; a FormData polyfill that expects { uri, type?, name? } objects instead), pass NativeFileSerializer:

import { NativeFileSerializer } from '@lamstack/http-client'; const client = new HttpClient({ adapter: fetchAdapter(), fileSerializer: new NativeFileSerializer(), }); await client.upload('/files', { avatar: { uri: 'file://photo.jpg' } });
await client.upload('/files', { title: 'Vacation photo', taken: new Date(), // -> ISO string tags: ['beach', 'sun'], // -> repeated form field metadata: { camera: 'Pixel' }, // -> JSON-stringified avatar: someFile, // File/Blob -> handled by the configured FileSerializer });

FormBuilder is the class doing this conversion (also exported from the package root) — client.upload() is a thin wrapper around new FormBuilder(fileSerializer).build(data); construct one directly if you need a FormData without going through HttpClient. An existing FormData is sent through untouched instead of being rebuilt; upload() never sets an explicit Content-Type — the adapter’s transport generates the multipart boundary itself.

Write your own FileSerializer (accepts(value) / serialize(formData, key, value)) for anything else — e.g. a Buffer-based Node upload path.

Downloads

const report = await client.download('/report.pdf'); // Blob

Equivalent to client.get(url, { responseType: 'blob' }), exposed as its own method for clarity at the call site.

Cancellation

AbortSignal is already first-class on every request (HttpRequestInit.signal), so cancellation isn’t a special HttpClient method — cancelable() is a small standalone helper for the common “start a request, get a way to cancel it” shape:

import { cancelable } from '@lamstack/http-client'; const { promise, cancel } = cancelable((signal) => client.get('/slow', { signal })); cancel(); // rejects `promise` with an HttpError whose code is 'CANCELED'

Writing your own plugin

auth, recover, and errorMapper are not privileged — they’re written against the exact same Middleware/HttpPlugin contract available to you:

import { withHeaders } from '@lamstack/http-client'; import type { HttpPlugin } from '@lamstack/http-client'; function clientIdPlugin(clientId: string): HttpPlugin { return { name: 'client-id', order: 50, // between recover and auth — see PluginOrder handler: async (request, next) => { return next(withHeaders(request, { 'x-client-id': clientId })); }, }; } client.use(clientIdPlugin('abc123'));

Never build the header object by hand ({ ...request.headers, 'X-Client-Id': ... }) — withHeaders normalizes the key the same way resolve() does, so a header that differs only in case from one already on the request overwrites it instead of adding a duplicate. withMeta(request, meta) does the equivalent for meta. Both are pure — they return a new request, never mutate the one you pass in.

A retry-style plugin that inspects the response after next() resolves/rejects:

function loggingPlugin(): HttpPlugin { return { name: 'logging', order: PluginOrder.observe, handler: async (request, next) => { const start = Date.now(); try { const response = await next(request); console.log(request.method, request.url, response.status, `${Date.now() - start}ms`); return response; } catch (error) { console.log(request.method, request.url, 'failed', `${Date.now() - start}ms`); throw error; } }, }; }

Remember next() is re-entrant (see Concepts: the middleware pipeline) — a plugin that retries by calling next() again only re-runs middleware registered after itself, which is exactly what makes that safe to do from inside .use().

Last updated on