@luwiostack/http
v0.1.0
Published
A small typed HTTP client over ofetch — createHttpClient + createRequest(client, params), whose chainable PendingRequest can delay or fail any call for testing loading/error UI. React <HttpClient> + useHttpClient() enrich the client with headers at runtim
Readme
@luwiostack/http
A small typed HTTP client over ofetch. createHttpClient(config)
gives you a transport; you make every call through createRequest(client, params), which returns a
chainable PendingRequest so any request can be delayed or failed on demand — for building and
testing loading, skeleton and error states. In React, a <HttpClient> + useHttpClient() hand that same
client to any component so it can also enrich it with headers — a token, the UI language, a
tenant — at runtime. That's the whole surface: no endpoint registry, no codegen, no auth strategy
object.
npm i @luwiostack/httpBuilt on
ofetch, which is bundled in — you don't install or import it directly. The root entry is React-free; the React bindings live at@luwiostack/http/react.
import { createHttpClient, createRequest } from '@luwiostack/http'
// Create once at startup with what you know then — the base URL.
export const client = createHttpClient({ baseUrl: '/api' })
// Make every call through createRequest(client, params), typed by the return you ask for.
const me = await createRequest<User>(client, '/me')
await createRequest(client, { path: '/users', method: 'POST', body: { name: 'Ada' } })
await createRequest(client, { path: '/search', query: { q: 'ada' } })Enrich the client with headers
Created up-front with the base URL, then enriched at runtime with what you learn later — a
token after login, the language on a locale change. Headers set with setHeader are read fresh on
every request; null clears one.
client.setHeader('Authorization', `Bearer ${jwt}`) // after login
client.setHeader('Authorization', null) // on logout
client.setHeader('Accept-Language', 'nl') // e.g. on a locale changeAn "endpoint" is just a small typed function that calls createRequest. Type it as Endpoint and it
must go through createRequest — a raw client.request returns a plain Promise, which won't
satisfy the PendingRequest return:
import { createRequest, type Endpoint } from '@luwiostack/http'
export const getUser: Endpoint<[id: string, signal?: AbortSignal], User> = (client, id, signal) =>
createRequest<User>(client, { path: `/users/${id}`, signal })Refreshing an expired token
A call 401ing because the access token expired — and needing a silent refresh-then-retry — is common
enough to design for. There's no dedicated hook for it: HttpClient is a plain { request, setHeader,
setBaseUrl } interface, so you get the retry by decorating it, the same way you'd wrap any object
with that shape. The retry has to re-issue the exact call that failed, which is exactly what wrapping
request gives you for free — no new concept to learn, and the client itself stays credential-agnostic
(see Enrich the client with headers above).
import { createHttpClient, HttpError, type HttpClient } from '@luwiostack/http'
const base = createHttpClient({ baseUrl: '/api' })
// The refresh token lives in an httpOnly cookie — a same-origin request sends it automatically, so
// there's nothing to read or attach here. The endpoint just hands back a fresh access token.
let refreshing: Promise<string> | null = null
async function refresh(): Promise<string> {
const { accessToken } = await base.request<{ accessToken: string }>('/auth/refresh', { method: 'POST' })
return accessToken
}
export const client: HttpClient = {
...base,
async request(path, options) {
try {
return await base.request(path, options)
} catch (error) {
if (!(error instanceof HttpError) || error.status !== 401) throw error
// Concurrent 401s share one refresh call instead of firing N of them.
refreshing ??= refresh().finally(() => { refreshing = null })
base.setHeader('Authorization', `Bearer ${await refreshing}`)
// Retry once, against `base` (not `client`) — a repeat 401 (refresh token also dead) propagates
// instead of looping, which is your signal to sign the user out.
return base.request(path, options)
}
},
}Export client — the wrapped one, not base — and use it exactly like any other client: hand it to
<HttpClient client={client}>, createRequest(client, …), everything. Same-origin cookies ride along
automatically; if the API and the refresh endpoint are on a different origin, add
credentials: 'include' when you create the client so the httpOnly cookie is sent cross-origin too:
const base = createHttpClient({ baseUrl: 'https://api.example.com', credentials: 'include' })Timeouts & retry tuning
Two independent knobs, both structural (set once, at createHttpClient):
const client = createHttpClient({
retry: 2, // retry a failed call up to twice — default: no retries at all
retryStatusCodes: [408, 429, 503], // which statuses count as "retry me" — default: ofetch's built-in
// set (408, 409, 425, 429, 500, 502, 503, 504)
retryDelay: 300, // wait between retries, in ms — default: 0 (no backoff)
})timeout, in contrast, is per-call — different endpoints have different natural time budgets (a
search-as-you-type call vs. a report export), so it lives on RequestOptions, not HttpConfig:
await createRequest(client, { path: '/search', query: { q }, timeout: 2000 })It's combined with signal — not a replacement for it — so a timeout and the caller's own cancellation
(TanStack Query's queryFn signal, most commonly) can both cancel the same request:
queryFn: ({ signal }) => createRequest<User>(client, { path: `/users/${id}`, signal, timeout: 5000 })A timeout is treated as a cancellation, not a failed response: it propagates uncaught (same as signal
aborting), not wrapped in HttpError, and — deliberately — it's never retried, even with retry
configured. (Under the hood: ofetch's own timeout option only engages when no signal is already
present, which is the opposite of this package's usual case, so the timeout is built by hand as an
abort combined with signal via AbortSignal.any. It fires with a plain cancellation reason rather than
AbortSignal.timeout's TimeoutError, specifically so ofetch's retry logic recognizes it as a
cancellation instead of burning retry more attempts — each already doomed, but still paying the full
retryDelay wait — before the error finally surfaces.)
timeout only clocks the real network call, not a .setDelay() chain from Testing loading & error
states — the artificial delay runs before timeout's clock starts,
so the two don't add up to one time budget.
Validate & transform at the boundary — parse
Do it once, in the endpoint — not in every queryFn. Give createRequest a parse and it
validates + maps the raw JSON (wire → domain) before it resolves, so T becomes your domain type and
every caller shares one validated shape. Point it at your schema library (Zod, Valibot, …); throw to
reject a bad body — the request rejects as a validation error (not wrapped in HttpError), so bad
data surfaces as a query error, distinct from a bad status. parse runs once, only on a real
response — never on an injected fault or an abort — and the PendingRequest<T> chain is preserved:
import * as v from 'valibot' // or zod — same idea
const UserWire = v.object({ name: v.string(), plan: v.string(), seats: v.number() })
export const getUser: Endpoint<[id: string, signal?: AbortSignal], User> = (client, id, signal) =>
createRequest<User>(client, { path: `/users/${id}`, signal, parse: (raw) => v.parse(UserWire, raw) })The queryFn then stays a thin one-liner over an already-validated User. Keep the boundary transform
(wire → domain) here; use TanStack Query's select for the other kind — deriving or subsetting the
domain object for one view (it's memoized and doesn't refetch). Omit parse for a raw pass-through.
Testing loading & error states
Skeletons, spinners and error UI are hard to see when the backend answers instantly and never fails.
Because every endpoint goes through createRequest, any call returns a chainable, awaitable
PendingRequest<T> you can tune before it runs:
// Feel the skeleton (400ms) and the error path (fails half the time):
useQuery({ queryKey: ['user', id], queryFn: () => getUser(client, id).setDelay(400).setFailRate(0.5) }).setDelay(ms) adds latency, .setFailRate(rate) ([0, 1]) fails with an HttpError instead of
hitting the network, and .setStatus(code) sets that failure's status (default 503); each setter
returns the request, so they chain. PendingRequest<T> extends Promise<T>, so it works seamlessly
as a TanStack Query queryFn (or a bare await) — a delay keeps the query pending, an injected
failure lands in error. Thread queryFn's signal into params (createRequest(client, { path,
signal })) and the request is cancelled automatically on unmount / refetch — the artificial delay
is aborted too. Defaults are 0 delay / 0 fail rate, so a plain await getUser(client, id) is a
transparent pass-through — add a chain only where you want to feel the UI.
.setDelay(ms)is not bounded by a per-calltimeout— the artificial delay runs beforeclient.requestis invoked, andtimeout's clock only starts then (see Timeouts & retry tuning).getUser(client, id).setDelay(5000)with{ timeout: 1000 }on the same call resolves (or times out) around 6000ms, not 1000ms — the two knobs don't compose the way "total time budget" would suggest.
In React — <HttpClient> + useHttpClient()
Mount the provider once; any component gets the client with useHttpClient() (returned directly).
Pair it with TanStack Query by writing your own queryKey + queryFn.
import { createHttpClient } from '@luwiostack/http'
import { HttpClient, useHttpClient } from '@luwiostack/http/react'
import { useQuery } from '@tanstack/react-query'
import { getUser } from './user.api' // an endpoint built on createRequest
const client = createHttpClient({ baseUrl: '/api' })
function App({ children }: { children: React.ReactNode }) {
return <HttpClient client={client}>{children}</HttpClient>
}
function Profile({ id }: { id: string }) {
const client = useHttpClient()
// Threading the signal makes the request cancel on unmount / refetch.
const { data } = useQuery({ queryKey: ['user', id], queryFn: ({ signal }) => getUser(client, id, signal) })
// …
}
// Auth is a header on the client — set it after login, clear it on logout:
function useAuth() {
const client = useHttpClient()
return {
login: (jwt: string) => client.setHeader('Authorization', `Bearer ${jwt}`),
logout: () => client.setHeader('Authorization', null),
}
}Outside React there's no provider — you exported the client, so import it and call (or enrich) it
directly, including in a router beforeLoad before anything renders:
import { createRequest } from '@luwiostack/http'
import { client } from './app/http'
client.setHeader('Authorization', `Bearer ${jwt}`)
const user = await createRequest<User>(client, '/me')Multiple clients & config
A client is just a transport; build as many as you like. Configuration splits by lifecycle:
up-front structural options (baseUrl, default headers, retry, retryStatusCodes, retryDelay,
credentials, fetchImpl) go to createHttpClient; runtime state is setHeader (read per request)
and setBaseUrl (rebuilds). timeout is the one exception — it's per-call, on RequestOptions (see
Timeouts & retry tuning above).
const v2 = createHttpClient({ baseUrl: '/api/v2', fetchImpl: myMock })
const data = await createRequest<User>(v2, '/users/1')Prefer two clients over re-pointing one with setBaseUrl when you talk to two different backends:
each keeps its own baseUrl and its own headers, so a token set on your API can't leak to a third-party
origin. setBaseUrl is for "same backend, address resolved at boot" — it rebuilds the transport and
carries the runtime headers forward, which is what you want there and exactly what you don't want
across origins.
Two clients in React — createHttpBinding
The default <HttpClient> / useHttpClient() is a single context, so two default providers would
shadow each other. Give the second backend its own provider + hook with createHttpBinding(name?)
— a fresh pair over an independent context (name shapes the devtools label and the "used outside
<name>" error):
import { createHttpClient } from '@luwiostack/http'
import { HttpClient, useHttpClient, createHttpBinding } from '@luwiostack/http/react'
const api = createHttpClient({ baseUrl: '/api' })
const cms = createHttpClient({ baseUrl: 'https://cms.example.com' })
// The default binding serves `api`; a second binding serves `cms`.
export const { HttpClient: CmsClient, useHttpClient: useCms } = createHttpBinding('Cms')
function App({ children }: { children: React.ReactNode }) {
return (
<HttpClient client={api}>
<CmsClient client={cms}>{children}</CmsClient>
</HttpClient>
)
}
function Page() {
const api = useHttpClient() // the default backend
const cms = useCms() // the CMS backend — independent headers, independent baseUrl
// …
}The default HttpClient / useHttpClient are just the first binding, exported for convenience — so a
single-client app never touches createHttpBinding.
API
| Export | What it does |
| --- | --- |
| createHttpClient(config?) | Create the client: baseUrl, headers, retry (count, default false), retryStatusCodes (default: ofetch's built-in set), retryDelay (ms, default 0), credentials (e.g. 'include' for a cross-origin cookie), fetchImpl. |
| createRequest<T>(client, params) | Make a request — used for every call. params is a path string or { path, method?, body?, query?, headers?, signal?, timeout?, parse? } (pass an AbortSignal — e.g. TanStack Query's — for automatic cancellation; it aborts the artificial delay too. timeout aborts the call after that many ms, combined with signal, never wrapped in HttpError, never retried. parse: (raw) => T validates + maps the body wire → domain at the boundary — throw to reject bad data). Returns a chainable PendingRequest<T> (extends Promise<T>) that resolves the parsed body typed as T, or throws HttpError on a non-2xx. |
| pendingRequest.setDelay(ms) / .setFailRate(rate) / .setStatus(code) | Tune a single call before it runs: add latency, fail with probability rate ([0,1]) throwing an HttpError, set that failure's status (default 503). Each returns the request. |
| Endpoint<Args, T> | Type for an endpoint: (client, ...args) => PendingRequest<T>. Typing an endpoint as Endpoint forces it through createRequest. |
| client.setHeader(name, value) | Set (value) or clear (null) a default header, read on every subsequent request. |
| client.setBaseUrl(url) | Set the base URL (rebuilds the transport; runtime headers carry forward). |
| client.request<T>(path, opts?) | The low-level transport createRequest is built on. Prefer createRequest; reach for this only when you deliberately want no fault knobs. |
| HttpClient | @luwiostack/http/react — supplies the client to the tree. <HttpClient client={…}>. |
| useHttpClient() | @luwiostack/http/react — the provider's client, returned directly (call it, or setHeader on it). Throws outside a provider. |
| createHttpBinding(name?) | @luwiostack/http/react — a fresh { HttpClient, useHttpClient } pair over its own context, for a second backend. name shapes the devtools label and the out-of-provider error. The default HttpClient / useHttpClient are the first such binding. |
| HttpError | Error thrown on a non-2xx response (status, statusText, url, data). |
