@okeav/web-client
v0.1.0
Published
A tiny, pluggable HTTP client for browsers and Node — fetch wrapper with baseURL, query params, timeouts, retry/backoff, cancellable requests, de-duplicated silent token refresh, optional request signing, and configurable error handling. Bring your own ba
Maintainers
Readme
@okeav/web-client
A fetch wrapper for browsers and Node (≥20) built around the one thing
that's genuinely fiddly to hand-roll correctly: de-duplicated silent token
refresh — when a request 401s, refresh once, queue any other requests that
401 while that refresh is in flight, then retry all of them, without ever
firing a second concurrent refresh call. It also does the table-stakes stuff
(baseURL, query params, timeout, retry/backoff, cancellation, interceptors)
that plenty of small fetch wrappers already cover well — this one just adds
the refresh-queue piece on top instead of leaving it as your problem.
It has zero required dependencies and no opinion about your backend's domain — you bring your own API shape, your own error convention, your own auth scheme. This package brings the plumbing.
If your app doesn't do 401-triggered token refresh, a general-purpose fetch
wrapper like ky or
ofetch is more established and likely a
better default. Reach for this one when the refresh-dedup behavior above is
what you actually need.
Install
npm install @okeav/web-clientQuick start
import { createClient } from '@okeav/web-client';
const api = createClient({ baseURL: 'https://api.example.com' });
const user = await api.get('/users/me');
await api.post('/users', { body: { name: 'Ada' } });
await api.get('/users', { params: { active: true, tag: ['a', 'b'] } }); // ?active=true&tag=a&tag=bcreateClient() returns an independent instance — its config, in-flight
refresh state, and (if configured) signing key are private to that instance.
Talking to two APIs, or writing tests that don't leak state between cases, is
just calling createClient() again.
See examples/quickstart/ for a runnable end-to-end demo (silent refresh,
retry, timeout, error handling) against a real (if tiny) server.
Requests
api.get(path, options);
api.post(path, options);
api.put(path, options);
api.patch(path, options);
api.delete(path, options); // .del(...) also works
api.request(method, path, options); // any verb, e.g. HEADoptions:
| Option | Default | Notes |
|---|---|---|
| params | — | Query object. Skips null/undefined/''. Arrays become repeated keys. |
| body | — | Plain objects are JSON.stringify'd with content-type: application/json. A FormData body is sent as-is with no content-type override. |
| headers | — | Merged over the client's static/dynamic/signed headers, so a call site always wins on conflicts. |
| signal | — | Standard AbortSignal. Combined with (not replaced by) the client's timeout. |
| timeout | client's timeout | Per-request override, in ms. |
| responseType | client's responseType | 'json' (default) | 'text' | 'blob' | 'arrayBuffer' | 'raw' (returns the untouched Response, even on error status). |
A 204/205 or empty body resolves to null instead of throwing a JSON
parse error.
Client configuration
createClient({
baseURL: 'https://api.example.com',
fetch: myFetch, // inject a fetch impl — defaults to globalThis.fetch
credentials: 'include', // passed straight to fetch; omitted entirely unless set
timeout: 30000, // ms; false/0 disables the built-in timeout
headers: () => ({ 'accept-language': getLang() }), // object or (sync/async) function
responseType: 'json',
retry: { attempts: 0, delay: 300, backoff: 'exponential', shouldRetry },
refresh: { path: '/auth/refresh', onRefreshed, shouldRefresh },
sign: createHmacSigner({ key: 'my-signing-key' }),
parseErrorBody: (body, res) => ({ message, code, details }),
statusHandlers: { 401: (info) => {...}, default: (info) => {...} },
onRequest: async (init) => ({ ...init, headers: {...} }),
onResponse: (data, res) => data,
});Timeout
Every request gets an AbortController timeout (default 30s). A caller's own
signal still works — it's combined with the timeout, not overridden by it —
and a caller-triggered abort surfaces as the native AbortError, while a
client-triggered timeout surfaces as TimeoutError, so you can tell "I
cancelled this" apart from "the network was too slow":
import { TimeoutError } from '@okeav/web-client';
try {
await api.get('/slow', { timeout: 2000 });
} catch (err) {
if (err instanceof TimeoutError) { /* ... */ }
}Retry
Disabled by default (attempts: 0) — auto-retrying a non-idempotent request
after a network blip risks double-submitting it. When enabled, only
GET/HEAD/PUT/DELETE are retried, and only on a network error or a
502/503/504 response. Override shouldRetry to change either axis:
createClient({
retry: {
attempts: 3,
delay: 300, // base delay in ms
backoff: 'exponential', // or 'fixed'
maxDelay: 30000, // caps exponential growth — a naive attempt 9 would otherwise wait ~77s
shouldRetry: ({ method, error, response }) => method === 'GET' && (error || response?.status >= 500),
},
});Silent token refresh
On a 401 (or whatever shouldRefresh decides), the client POSTs to
refresh.path once, then retries the original request. Concurrent requests
that all hit 401 while a refresh is already in flight wait on that single
refresh instead of firing their own — this is the one piece of behavior in
this package that's genuinely fiddly to get right by hand, which is the
reason to reach for a shared implementation instead of rewriting it per app.
createClient({
baseURL: 'https://api.example.com',
refresh: {
path: '/auth/refresh',
onRefreshed: (data) => saveNewAccessToken(data.accessToken),
shouldRefresh: (res) => res.status === 401, // default
},
});Omit refresh entirely to disable this — a 401 then just flows through
statusHandlers/ApiError like any other error status.
Request signing (optional)
createHmacSigner HMAC-signs timestamp:nonce:method:pathname with Web
Crypto and returns nonce/timestamp/signature headers — a generic anti-replay
building block (binds a request to one moment, one use), not an
authentication mechanism. Pair it with real auth; don't rely on it alone.
Entirely opt-in — a client with no sign configured never touches
crypto.subtle.
import { createHmacSigner } from '@okeav/web-client';
createClient({
sign: createHmacSigner({ key: process.env.CLIENT_SIGNING_KEY }),
});Bring your own sign function instead if you need a different scheme — it's
just ({ method, url }) => headers | Promise<headers>.
Errors
Every non-2xx response throws ApiError:
class ApiError extends Error {
status?: number;
code?: string;
details?: unknown;
response?: Response; // the raw Response, if you need more than status/body
}The default parseErrorBody reads { error: { message, code, details } } or
a flat { message }, falling back to res.statusText. Override it for any
other backend convention:
createClient({
parseErrorBody: (body, res) => ({
message: body?.errors?.[0]?.detail ?? res.statusText,
code: body?.errors?.[0]?.code,
}),
});statusHandlers run before the throw, for side effects keyed by status
(session-expiry redirects, toast notifications, etc.) without wrapping every
call site in a try/catch:
createClient({
statusHandlers: {
401: () => redirectToLogin(),
403: ({ message }) => toast.warning(message),
default: ({ message }) => toast.error(message), // anything else >= 400
},
});What this package deliberately does NOT do
- No bundled domain vocabulary — no constants, no enums, no RBAC/scopes. That's your app's concern, not an HTTP client's.
- No assumption about your auth scheme beyond "a 401 might mean I should refresh and retry" — cookies, bearer tokens, or nothing at all all work.
- No request/response caching layer.
- No built-in storage adapter (localStorage, cookies, etc.) —
headersis a function precisely so you can read whatever you're persisting elsewhere and hand it over fresh on every request.
Testing
npm testFull unit coverage (node --test) for query building, retry/backoff, response
parsing, HMAC signing, and an integration suite covering the client end to
end — including concurrent-401 refresh de-duplication and the
timeout/cancellation interplay. test/scenarios/ additionally replays a real
production app's actual auth/refresh/error-handling config against a mock
backend, to catch integration gaps before anyone adopts this for real (not
shipped in the published package — dev-only via files in package.json).
