@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.
API Reference
General · 14
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
Flattened hook lists and merged defaults produced by composePlugins.
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
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.