@robonen/fetch
A lightweight, type-safe fetch wrapper with interceptors, retry, timeout, and a composable plugin system — V8-optimized internals, zero runtime dependencies beyond the standard library.
globalThis.fetch is great primitive plumbing, but every app re-implements the same layer on top of it: JSON parsing, throwing on 4xx/5xx, base URLs, query strings, retries, timeouts, auth headers. @robonen/fetch is that layer — small, fully typed, and built so attaching features costs nothing on the hot path.
Type-safe end to end
Response data, request options, and plugin-contributed fields are all inferred — the parsed body comes back typed, no casting required.
Smart bodies & parsing
Plain objects are JSON-serialized; FormData/Blob/streams pass through untouched. Responses are decoded from Content-Type or forced via responseType.
Retry, timeout & errors
Built-in retry and per-attempt timeout with sensible defaults, and non-2xx responses reject with a rich FetchError carrying status, request, and parsed body.
Hooks & plugins
Lifecycle hooks plus a typed, composable plugin system with onion-style execute middleware — composed once, with zero per-request overhead beyond the hooks themselves.
Install
pnpm add @robonen/fetchQuick start
Import the default $fetch instance — it is backed by globalThis.fetch and ready to use. The first type parameter types the parsed body.
import { $fetch } from '@robonen/fetch';
interface Todo {
id: number;
title: string;
done: boolean;
}
// GET + automatic JSON parse — the body is typed for you
const todo = await $fetch<Todo>('https://api.example.com/todos/1');
// POST a plain object — serialized to JSON, content-type set automatically
const created = await $fetch<Todo>('https://api.example.com/todos', {
method: 'POST',
body: { title: 'Ship it', done: false },
});
// Method shortcuts
await $fetch.get<Todo>('https://api.example.com/todos/1');
await $fetch.delete('https://api.example.com/todos/1');Configured instances
Use create (or its alias extend) to derive instances with a baseURL, default headers, retry policy, and plugins. Configuration is merged down the chain; the child wins on conflicts.
import { $fetch } from '@robonen/fetch';
// Derive a pre-configured instance — defaults & plugins are inherited
const api = $fetch.create({
baseURL: 'https://api.example.com/v1',
headers: { 'x-app': 'web' },
retry: 2,
});
await api('/users'); // → /v1/users, retry:2, x-app header
await api('/search', { query: { q: 'vue', page: 2 } });
// 'extend' layers on more defaults / plugins; child wins on conflicts
const billing = api.extend({ baseURL: 'https://billing.example.com' });Where to next
The full API reference is listed below. A few good places to start:
createFetch— build a fully configured instance with defaults and plugins.definePlugin— bundle defaults, typed options, hooks, andexecutemiddleware into a reusable plugin.FetchError— the rich error thrown on non-2xx responses.buildURLanddetectResponseType— the URL and response-type helpers used internally, exported for reuse.
general · 15
Default $fetch instance backed by globalThis.fetch
Appends serialised query parameters to a URL string Null and undefined values are omitted. Existing query strings are preserved.
Invokes one or more lifecycle hooks with the given context
Flattens plugin defaults and hook arrays into a single shape suitable for long-lived storage on a fetch instance. Runs exactly once per createFetch call. Ordering: plugin defaults (in declaration order) → user defaults (user wins). Headers are merged independently through a single Headers instance.
Creates a configured $fetch instance
Builds a FetchError from a FetchContext, extracting URL, status, and error message
Declares a typed fetch plugin. Identity function — returns its input verbatim at runtime, used only to narrow generics for strong option inference.
Infers the response body parsing strategy from a Content-Type header value
Error thrown by $fetch on network failures or non-2xx responses
Returns true when a value can be serialised with JSON.stringify
Returns true for HTTP methods that carry a request body
Joins a base URL with a relative path, normalising the slash boundary
HTTP status codes whose responses never have a body
Merges per-request options with global defaults
Runs all instance-level (plugin) hooks for a single phase, then the optional user per-request hook(s). Avoids allocating an intermediate array per call.
plugins · 2
Retries failed attempts based on status code, respecting `retry` / `retryDelay` / `retryStatusCodes` request options. Auto-registered by `createFetch`; disable per-request via `retry: false`.
Composes an `AbortSignal.timeout(ms)` with any caller-supplied signal when `options.timeout` is set. Implemented as an `execute` middleware (inner to `retry`) so every retry attempt gets a brand-new timeout signal — a single timeout no longer poisons all subsequent attempts. The timeout therefore applies per attempt, not to the whole retry sequence. Auto-registered by `createFetch`; no-op when `timeout` is unset.