@lamstack/http-client
v0.1.0
Published
Framework-agnostic HTTP client core with a pluggable middleware pipeline — no hard dependency on axios or fetch.
Maintainers
Readme
@lamstack/http-client
A framework-agnostic HTTP client core built around a pluggable, Koa/onion-style
middleware pipeline. The core has zero runtime dependency on axios or fetch — you
choose a transport adapter (fetch or axios, both included) and layer behavior
(credential attachment, failure recovery, error mapping, or anything you write yourself)
on top via a single .use() API. No plugin shipped with this package — not auth, not
recover — has any capability you don't also have.
Pre-1.0 (
0.x): the API may still change between minor versions.retryPluginand upload/download progress are architected for (seePluginOrderandHttpAdapter.capabilities) but not implemented yet — see Roadmap for what's coming next.
Table of contents
- @lamstack/http-client
Why this exists
Most HTTP client wrappers pick a transport (axios, or fetch) and bolt auth/recovery
logic onto it via that transport's own interceptor system. That logic then can't move to
a different transport, and typically can't be unit-tested without mocking the transport
itself. This package inverts that: the pipeline (auth, recovery, error mapping, retry
policy, anything else) is transport-agnostic middleware; adapters are a thin,
interchangeable translation layer between that pipeline and a real transport. The same
auth/recover setup works identically whether the underlying adapter is fetch or an
existing axios instance — proven by a shared contract test suite that runs both
adapters through identical scenarios (200/404/500/timeout/abort/...) and asserts
identical results.
Install
pnpm add @lamstack/http-client
# or: npm install @lamstack/http-client
# or: yarn add @lamstack/http-client
# or: bun add @lamstack/http-clientaxios is an optional peer dependency — only needed if you use
@lamstack/http-client/adapters/axios. If you only use the fetch adapter, you never
install it.
Quick start
import { HttpClient } from '@lamstack/http-client';
import { fetchAdapter } from '@lamstack/http-client/adapters/fetch';
const client = new HttpClient({
adapter: fetchAdapter(),
baseURL: 'https://api.example.com',
});
interface User {
id: string;
name: string;
}
const user = await client.get<User>('/users/me');
await client.post('/users', { name: 'Ada' });
await client.put(`/users/${user.id}`, { name: 'Ada Lovelace' });
await client.delete(`/users/${user.id}`);get/post/put/patch/delete all return the parsed response body directly —
not a wrapper object. Use client.request() when you need the status
code, headers, or other response metadata.
Core concepts
The request lifecycle
HttpRequestInit → resolve() → HttpRequest (immutable) → pipeline → HttpAdapter → HttpResponse
(yours) (what plugins see) (your .use() chain) (fetch/axios)You never construct an HttpRequest yourself — you pass an HttpRequestInit to
client.get()/client.post()/client.request(), and resolve() turns it into the
immutable HttpRequest every middleware and the adapter actually see:
export interface HttpRequestInit<TBody = unknown> {
url?: string;
method?: HttpMethod; // 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS'
headers?: Record<string, string | number | null | undefined>;
params?: Record<string, QueryValue | QueryValue[]>; // string | number | boolean | Date | null | undefined
body?: TBody;
signal?: AbortSignal;
timeout?: number; // ms; 0 = unlimited
credentials?: 'omit' | 'same-origin' | 'include';
responseType?: 'json' | 'text' | 'blob' | 'arrayBuffer' | 'stream';
paramsSerializer?: (params: QueryParams) => string;
meta?: HttpMeta;
}Resolution rules
These run exactly once, before any middleware, and are the same regardless of which adapter you use:
| Rule | Behavior |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| baseURL | Combined with a relative url via new URL-style joining |
| Relative baseURL | baseURL: '/api' (or '', or a protocol-relative '//cdn.example.com') joins by string instead of new URL(), which requires an absolute base and would otherwise throw |
| Absolute url | Ignores baseURL entirely (detected via a leading scheme://) |
| Slash handling | baseURL: 'https://a.com/api' + url: 'users' (or '/users') → https://a.com/api/users — no doubled or missing slash either way, for a relative baseURL too |
| Header precedence | client-level headers ← request-level headers, later wins |
| Header deletion | a null/undefined header value at the request layer removes a client-level default |
| Header case | every header key is normalized to lowercase |
| params | null/undefined values omitted; arrays repeat the key (id=1&id=2); Date values become ISO strings |
| Existing query string | url: '/x?a=1' + params: { b: 2 } → /x?a=1&b=2 (merged, not replaced) |
| method | defaults to 'GET', uppercased |
| timeout | defaults to 0 (unlimited) |
| credentials | defaults to 'same-origin' |
| responseType | defaults to 'json' |
The middleware pipeline
type Next = (request: HttpRequest) => Promise<HttpResponse>;
type Middleware = (request: HttpRequest, next: Next) => Promise<HttpResponse>;
interface HttpPlugin {
name: string;
order: number; // smaller = further outside the pipeline
handler: Middleware;
}client.use() accepts either a bare Middleware function (defaults to
PluginOrder.normalize) or a full HttpPlugin object. Plugins run in order order —
ties preserve registration order.
next() is re-entrant. A middleware may call it more than once; each call re-runs
only the chain after that middleware, never anything that already ran. This is what
lets recover retry a request after recovering credentials without re-running whatever
is registered outside it (logging, tracing, ...):
register order: observe(-200) → recover(0) → auth(100) → transport (adapter)
401 on first attempt:
observe runs (once) → recover runs → auth runs → transport throws 401
↓
recover catches it, runs its recover() callback,
calls next() AGAIN — only auth + transport re-run:
↓
auth runs (2nd time, fresh credential) → transport → 200PluginOrder
export const PluginOrder = {
observe: -200,
normalize: -100,
recover: 0,
retry: 50, // reserved for v1.1's retryPlugin — nothing uses this slot yet
auth: 100,
transport: 200,
} as const;These are public, semver-stable ordering slots — errorMapper registers at normalize,
recover at recover, auth at auth. A plain client.use(fn) (not wrapped in an
HttpPlugin) defaults to PluginOrder.normalize too — deliberately not recover's slot,
so it never silently interleaves with recovery retries purely by registration order.
Write your own plugins against these constants (e.g. PluginOrder.auth - 1 to run just
inside auth) instead of hardcoded numbers.
meta flags
Every resolved HttpRequest carries a meta bag. auth, recover, and errorMapper
each read one flag off it automatically, via a shared metaOptOut(key) helper — a request
opts a plugin out by setting that flag to exactly false:
await client.get('/x', { meta: { auth: false } }); // auth() skips this request
await client.get('/x', { meta: { recover: false } }); // recover() skips this request
await client.get('/x', { meta: { mapError: false } }); // errorMapper leaves the error as-ismetaOptOut('auth') is (request) => request.meta.auth === false — strict equality, so
undefined/0/''/null never opt a request out, only a literal false does.
Each plugin also takes its own options.skip?: (request) => boolean, which replaces
the default check entirely rather than adding to it. To keep the default and add your own
condition, compose them yourself:
import { metaOptOut } from '@lamstack/http-client';
client.use(
auth(bearer(source), {
skip: (request) => metaOptOut('auth')(request) || request.url.startsWith('/public'),
}),
);For recover specifically, skip is independent of shouldRecover and is checked
first, as soon as an error is caught — before shouldRecover ever runs. That's
deliberate: if the opt-out were folded into shouldRecover's default instead, overriding
shouldRecover would silently lose the opt-out. shouldRecover keeps its own
onStatus(401) default no matter what skip does.
meta is also where you can stash your own per-request data for a custom plugin to
read. Internal plugin state (like recover's attempt/generation counters) uses a
Symbol(...) key instead of a string, specifically so it can never collide with anything
you put here. recover() creates its symbols fresh per call (Symbol(...), not the
global-registry Symbol.for(...)) for the same reason — two recover() plugins stacked
on one client (e.g. an outer one for 401s, an inner one for 419/CSRF) each get their own
attempt/generation bookkeeping, instead of a Symbol.for(...) key silently sharing one
counter across both instances.
Adapters
Adapters are the only place a transport library is imported — never from the package
root, so import { HttpClient } from '@lamstack/http-client' never pulls in axios or
adds fetch-specific types to your bundle analysis.
interface HttpAdapter {
name: string;
capabilities: { uploadProgress: boolean; downloadProgress: boolean; stream: boolean };
send<T>(request: HttpRequest): Promise<HttpResponse<T>>;
}Both shipped adapters currently report every capability false (progress support is
v1.1 — see Roadmap) and behave identically for the same request: same
HttpResponse shape on success, same HttpError code/status on failure — verified by a
single shared contract test suite run against both. One known, documented divergence:
credentials: 'omit' (see axiosAdapter() below).
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+ — this package's own
engines.node: ">=20.3.0" matches that floor exactly. Chrome/Firefox/Edge have supported
it since 2023; if you must support an older Safari (or a Node build predating 20.3
despite the declared floor), 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. Timeout handling
also combines your signal with an internal one via AbortSignal.any(...), same as
fetchAdapter() — see the Node 20.3+ or Safari 17.4+ note above.
Known divergence — credentials: 'omit' is not expressible through axios. Axios/XHR's
withCredentials is a single boolean: whether to send/accept cross-site cookies. There is
no axios request option distinguishing "never send cookies, even same-origin" ('omit')
from "send same-origin cookies, never cross-site" ('same-origin', the default) — both
map to withCredentials: false through axiosAdapter(). fetchAdapter() honours all
three credentials values, since native fetch's credentials option was designed with
that distinction. If your app depends on 'omit' specifically (e.g. a shared-hosting
subdomain where you must not send the parent domain's session cookie), use
fetchAdapter().
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>;
},
};
}HttpClient API
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 File uploads
});| Method | Returns | Notes |
| -------------------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| use(pluginOrMiddleware) | this | Registers 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 File uploads |
| download(url, init?) | Promise<Blob> | Forces responseType: 'blob' |
| extend(options?) | HttpClient | New 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:
const client = new HttpClient({ adapter: fetchAdapter(), baseURL: '/api' });
const refreshClient = client.extend({}); // no plugins yet — safe to use for the refresh call itself
client.use(
recover({
recover: () => session.renew(), // session: your own renew/getAccessToken object — see below
}),
);
client.use(auth(bearer(session)));Every field on extend()'s 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).
Authentication and recovery
Two independent, narrow contracts do the work: auth attaches credentials to every
request; recover detects an eligible failure, runs a recovery step, and retries.
Neither knows anything about the other, and neither is privileged over a plugin you
write yourself. Wiring auth + recover to a token store
(below) shows how to tie the two together around a stored token — there's no built-in
session helper yet (see Roadmap), so that's a plain object you write.
auth and Authenticator
import { auth } from '@lamstack/http-client';
type Authenticator = (request: HttpRequest) => Awaitable<HttpRequest>;
client.use(auth(myAuthenticator));
client.use(auth(myAuthenticator, { skip: (request) => request.url.startsWith('/public') }));auth() is deliberately thin: it applies an Authenticator — any
(request) => Awaitable<HttpRequest> — to every outgoing request, with an optional
skip predicate for requests that shouldn't be authenticated at all. Everything about
how (a Bearer token, an API key, a request signature, several combined) lives in the
Authenticator itself, not in the plugin.
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))); // session: your own getAccessToken object — see below
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. bearer()'s source contract is just
{ getAccessToken(): Awaitable<string | null> } (or a plain function) — the session
object shown below satisfies it directly, and so does anything else with a
getAccessToken() method.
recover
import { metaOptOut, onStatus, recover } from '@lamstack/http-client';
client.use(
recover({
recover: () => session.renew(), // session: your own renew/getAccessToken object — see below
shouldRecover: onStatus(401, { exclude: ['/auth/login', '/auth/refresh'] }), // default: onStatus(401)
skip: metaOptOut('recover'), // default — see meta flags above
canRecover: () => session.canRenew(), // optional — skips a doomed cycle before it starts
maxAttempts: 1, // default — one recovery cycle per logical request
maxStaleRetries: 1, // default — see "Stale retries" below; independent of maxAttempts
cooldownMs: 1000, // default — see "Refresh storms" below; 0 disables it
events: recoveryEvents, // optional EventBus<RecoveryEventMap> — see below
}),
);recover's only required option is recover: () => Promise<void> — a single async step
run once per cycle, shared by every request queued behind it. It doesn't have to be an
HTTP call: firebaseUser.getIdToken(true), an OS keychain refresh, or a resync over a
BroadcastChannel all fit the same shape.
skip is checked first, as soon as an error is caught — before shouldRecover runs, and
independent of it (see meta flags above for why the two are kept separate).
On an eligible failure (401 by default, via shouldRecover, once skip has let it through):
- If
canRecoveris given and resolvesfalse, emitsrecovery:unavailableand rethrows the original error immediately — no cycle attempted. - Otherwise runs
recover()(deduplicated — see Concurrency below). - Retries the original request via a re-entrant
next()call — never by re-running the pipeline from the top, so anything registered outsiderecover(includingauth, which re-runs and picks up the new credential) never re-runs from scratch. - If
recover()itself throws: emitsrecovery:failed, and rethrows the original request's error with the recovery failure attached via.cause.
recover() calls no cleanup itself on failure — wire that yourself via events (below),
e.g. events.on('recovery:failed', () => session.end()).
Refresh storms: without a cooldown, every request that fails while the refresh
endpoint itself is down would trigger its own brand-new cycle against that same down
endpoint, with no ceiling. cooldownMs (default 1000; 0 disables it) closes that gap:
once a cycle fails, any request that would otherwise start a fresh one within cooldownMs
instead throws its own original error immediately — .cause set to the most recent
recovery failure — and emits recovery:unavailable, with no attempt against the refresh
endpoint at all. A successful cycle resets the cooldown immediately, so it never blocks a
request that comes in right after a working recovery.
Concurrency: if several requests fail at once while a cycle is already in flight,
they share that one cycle (no duplicate recovery calls) — but each still resolves or
rejects independently. If the shared cycle fails, every queued request rejects with
its own original error (not one shared value), each carrying the same recovery
failure via .cause. A request that fails after a different request's cycle
already completed and rotated the credential retries directly with it instead of
starting a redundant cycle — tracked via an internal generation counter, no configuration
needed.
Stale retries: that direct retry (above) is tracked by its own counter, capped by
maxStaleRetries (default 1) — completely independent of maxAttempts. A request that
gets stale-retried once never spends its maxAttempts budget, so if its retry then hits a
genuine, unrelated 401, it can still start its own recovery cycle. Only repeated
staleness — losing the race to unrelated rotations over and over — is what
maxStaleRetries eventually gives up on, throwing the original error with no new cycle
attempted.
Wiring auth + recover to a token store
There's no built-in session helper in 0.1.0 — a first attempt shipped briefly during
development and was pulled before release to be redesigned (see Roadmap).
Until then, auth/recover need nothing more than a plain object of your own that
satisfies bearer()'s source contract — { getAccessToken(): Awaitable<string | null> }
— plus whatever renew/canRenew shape recover()'s options need:
Backends that return accessToken/refreshToken in the JSON response body — the
refresh token is persisted in localStorage and sent back as { refreshToken }:
function parseAccessToken(payload: unknown): string | null {
return (payload as { accessToken?: string } | null)?.accessToken ?? null;
}
let accessToken: string | null = window.localStorage.getItem('access_token');
const session = {
getAccessToken: async () => accessToken,
canRenew: async () => Boolean(window.localStorage.getItem('refresh_token')),
renew: async () => {
const refreshToken = window.localStorage.getItem('refresh_token');
const response = await refreshClient.request({
url: '/auth/refresh',
method: 'POST',
body: { refreshToken },
});
accessToken = parseAccessToken(response.data);
if (accessToken) window.localStorage.setItem('access_token', accessToken);
},
end: async () => {
accessToken = null;
window.localStorage.removeItem('access_token');
window.localStorage.removeItem('refresh_token');
},
};
// After a successful sign-in response elsewhere in the app:
accessToken = parseAccessToken(signInResponse);
if (accessToken) window.localStorage.setItem('access_token', accessToken);
client.use(recover({ recover: () => session.renew(), canRecover: () => session.canRenew() }));
client.use(auth(bearer(session)));Backends that issue the refresh token as an HttpOnly cookie the browser sends automatically — the access token is kept in memory only (never persisted, to limit XSS exposure):
let accessToken: string | null = null;
const session = {
getAccessToken: async () => accessToken,
canRenew: async () => Boolean(window.localStorage.getItem('SIGNED_IN')),
renew: async () => {
const response = await refreshClient.request({
url: '/auth/refresh',
method: 'GET',
credentials: 'include',
});
window.localStorage.setItem('SIGNED_IN', 'true');
accessToken = parseAccessToken(response.data); // kept in memory only, never persisted
},
end: async () => {
accessToken = null;
window.localStorage.removeItem('SIGNED_IN');
},
};
// auth() doesn't set credentials: 'include' automatically — declare it on the client itself:
const client = new HttpClient({ adapter: fetchAdapter(), credentials: 'include' });
client.use(recover({ recover: () => session.renew(), canRecover: () => session.canRenew() }));
client.use(auth(bearer(session)));Both examples build refreshClient via client.extend({}) (see HttpClient API
above) so the refresh call itself carries neither auth nor recover. Neither
renew/canRenew/end is special-cased by the plugins — recover() only ever calls
session.renew()/session.canRenew() because that's what you passed as its
recover/canRecover options; end() isn't called automatically on a failed recovery
cycle either, wire it through recoveryEvents yourself, e.g.
events.on('recovery:failed', () => session.end()). A multi-tenant token store, one
backed by a secure OS keychain, or Firebase's getIdToken all work the same way — just a
different getAccessToken/renew/canRenew implementation behind the same shape.
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();
recoveryEvents.on('recovery:succeeded', () => console.log('session renewed'));
recoveryEvents.on('recovery:unavailable', ({ error }) => reportToSentry(error));
client.use(recover({ recover: renewSession, events: recoveryEvents }));EventBus is not a singleton — create one per app (or per independent set of
clients that should share recovery state) and pass it explicitly; nothing here reaches
across unrelated HttpClient instances implicitly. on() returns an unsubscribe
function, which composes naturally with a React useEffect cleanup — the equivalent
explicit call is recoveryEvents.off('recovery:failed', listener), same signature as
on(). recoveryEvents.clearAll() removes every listener for every event at once (e.g.
in a test's afterEach). A throwing listener never prevents its siblings from running.
| Event | Payload | Fires |
| ---------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| 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 |
Error handling
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'
| 'UNSUPPORTED'
| '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().
'UNSUPPORTED' is different from 'UNKNOWN' — it means the request asked for something
this adapter instance's capabilities already reported it can't do (e.g.
responseType: 'stream' against capabilities.stream === false), caught before any
network activity, not a network/timeout/cancellation outcome or a plugin bug.
try {
await client.get('/x');
} catch (error) {
if (HttpError.is(error)) {
console.log(error.status, error.code, error.data);
}
}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 outFile uploads
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
});An existing FormData is sent through untouched instead of being rebuilt. upload()
clears any client-level Content-Type default ({ 'content-type': null }, same as an
explicit request-level null override elsewhere) rather than merely not setting one of
its own — without that, a client configured with headers: { 'Content-Type':
'application/json' } (a common default for its other requests) would send the multipart
FormData mislabelled as JSON, with no boundary, and the server would fail to parse it.
The adapter's transport generates the real multipart boundary itself. An explicit
per-request init.headers['content-type'] (e.g. your own multipart boundary) still wins
— it's layered on after that clearing default, not instead of it. This conversion is
FormBuilder (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.
By default, 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' } });Write your own by implementing 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'); // BlobEquivalent 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. For example, a plugin
that attaches a client-id header (the kind of extensibility a future SSE plugin would
build on, without needing any change to core):
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, preserving both string and
symbol keys already on the request. 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 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().
Testing code that uses this package
Because HttpAdapter is a tiny two-property interface, testing code that uses
HttpClient doesn't require mocking fetch or axios — write a scripted adapter:
import type { HttpAdapter, HttpRequest, HttpResponse } from '@lamstack/http-client';
function scriptedAdapter(response: Partial<HttpResponse>): HttpAdapter {
return {
name: 'scripted',
capabilities: { uploadProgress: false, downloadProgress: false, stream: false },
async send<T>(request: HttpRequest): Promise<HttpResponse<T>> {
return {
status: 200,
statusText: 'OK',
headers: {},
request,
...response,
} as HttpResponse<T>;
},
};
}
const client = new HttpClient({ adapter: scriptedAdapter({ data: { id: '1' } }) });Testing a Middleware/HttpPlugin directly — without a full HttpClient or adapter at
all — needs a real HttpRequest, which resolve() (the same function HttpClient
itself calls internally) builds from an HttpRequestInit:
import { resolve } from '@lamstack/http-client';
const request = resolve({ url: '/x', headers: { 'x-foo': 'bar' } });
const response = await myPlugin.handler(request, async (req) => ({
status: 200,
statusText: 'OK',
headers: {},
request: req,
data: { ok: true },
}));This is the same pattern this package's own test suite uses throughout — see
src/plugins/recover.plugin.test.ts for scripted-adapter and concurrency-testing
examples (the deferred()-promise pattern for controlling exactly when an in-flight
request settles).
TypeScript
Every public type is exported from the package root (HttpRequest, HttpResponse,
HttpPlugin, Authenticator, RecoveryContext, ...). Generic type
parameters flow through the whole chain: client.get<User>('/me') types the resolved
value; client.request<User>(init) types response.data;
client.post<CreatedUser, CreateUserInput>('/users', input) types both the body and the
result.
Roadmap
Not yet implemented:
retryPlugin— backoff/jitter,Retry-Aftersupport, method-safety rules (no automatic retry of non-idempotent requests).PluginOrder.retryis already reserved.- Upload/download progress (
onUploadProgress/onDownloadProgress) — both adapters currently reportcapabilities: { uploadProgress: false, downloadProgress: false, stream: false }honestly; this is where that flips totrue. - An SSE plugin — the plugin system is already extensible enough for one (see Writing your own plugin); it just doesn't ship yet.
- A session-layer helper (
0.2.0) — a built-in primitive tying a stored token to bothauth()andrecover(), so the hand-written session objects above aren't the only option. An earlier version of this shipped briefly during0.1.0development and was pulled before release to be redesigned rather than carried forward as-is.
Credits
This package generalizes a production HttpClient implementation (axios-only) from an
internal dashboard into an adapter-agnostic, publicly reusable one, keeping full behavior
parity with the original (HttpClient's verb methods, request/response interceptors, its
token storage/refresh strategy, FormBuilder/FileSerializer, createCancelable) while
making three deliberate improvements along the way:
- Per-request error identity on a failed shared recovery cycle — every request
queued behind an in-flight
recover()cycle rejects with its own original error (the recovery failure attached via.cause), not one shared rejection value across all of them. extend()replaces a manually-constructed second client for talking to the refresh endpoint without recursing into its own recovery logic.- The
Authenticator/recover()split — the original's monolithic token-provider interface is now two narrow, independent contracts (see Authentication and recovery), so credential attachment and failure recovery are useful — and testable — on their own.
License
MIT
