@cplieger/fetch
v1.1.3
Published
Small, zero-dependency universal fetch wrapper for TypeScript with typed result envelopes
Readme
fetch
Small, zero-dependency universal fetch wrapper with a typed, non-throwing result envelope.
A standalone TypeScript wrapper around the platform fetch. The core never throws: every request resolves to an ApiResult<T> — a discriminated union of a success envelope ({ ok: true, status, data }) and an error envelope ({ ok: false, status, error, code?, requestId? }) — so network failures, timeouts, cancellations, non-2xx responses, and decode errors are all values you branch on rather than exceptions you catch. On top of the core sit thin per-verb helpers: a null-collapsing form (apiGet → data | null), a full-envelope form (apiGetRaw → ApiResult), and a decoder-validated form (apiGetTyped). Base URL, credentials, a header-preparation hook, and a custom fetch implementation are injected once via configureFetch. Zero runtime dependencies, ESM-only, published as TypeScript source.
@cplieger/fetch is the browser-side JSON-fetch primitive in the toolkit: it is the inbound-shaped counterpart to httpx (the resilient outbound HTTP library for Go), and it composes cleanly under @cplieger/actions (which owns retry, dedupe, optimistic updates, and notification wiring). It deliberately owns only the request/response envelope — see Unsupported by design.
Install
npx jsr add @cplieger/fetch
# or
npm i @cplieger/fetchRequires TypeScript ≥ 5.0 and a bundler that supports ESM.
Usage
Configure the global layer once at boot, then call the verb helpers:
import { configureFetch, apiGet, apiPost, apiGetRaw } from "@cplieger/fetch";
configureFetch({
baseUrl: "https://api.example.com/v1",
credentials: "include",
prepareHeaders: (headers) => {
headers.set("Authorization", `Bearer ${getToken()}`);
},
});
// Null-collapsing: the decoded body on success, null on any error.
const user = await apiGet<{ id: string; name: string }>("/users/me");
if (user) {
console.log(user.name);
}
// Create a resource with a JSON body.
const created = await apiPost<{ id: string }>("/items", { name: "widget" });The result envelope
When you need the status code or the error details, reach for the *Raw helpers (or requestRaw directly). They resolve to an ApiResult<T> and never throw:
const res = await apiGetRaw<{ id: string }>("/users/me");
if (res.ok) {
console.log(res.status, res.data);
} else {
// res.status is the HTTP status, or 0 for a network / timeout / cancelled /
// invalid failure.
// res.code is one of "network" | "timeout" | "cancelled" | "decode" |
// "invalid", or a server-supplied code lifted from the error body.
console.error(res.status, res.code, res.error, res.requestId);
}On a 204 or empty-body 2xx response, a success envelope carries
data: undefined. The null-collapsing helpers (request/apiGet/ …) turn that intonull; when you use the*Rawhelpers on a 204-capable endpoint, typeTto includeundefined(or branch onstatus). A JSONnull/0/false/""body is real data and passes through unchanged.
code: "invalid"marks a client-side build failure — an un-encodable body (circular / BigInt), a bad header name/value, a badtimeoutMs, or a throwingprepareHeaders— that never reached the network, so it is reported distinctly from"network".
Runtime validation
Pass a Decoder<T> — a function that returns the typed value or throws — to validate a 2xx body. A decoder throw becomes an ApiErr with code: "decode" (or null via the *Typed helpers):
import { apiGetTyped, type Decoder } from "@cplieger/fetch";
const decodeUser: Decoder<{ id: string }> = (v) => {
if (typeof v !== "object" || v === null || typeof (v as { id?: unknown }).id !== "string") {
throw new Error("expected { id: string }");
}
return v as { id: string };
};
const user = await apiGetTyped("/users/me", decodeUser); // { id: string } | nullPer-request options
Every helper accepts a trailing RequestOptions: a caller AbortSignal, per-request headers, a decoder, and a timeoutMs override (default 30 000 ms). The caller signal is composed with the request timeout, so whichever fires first aborts the request. The timeout covers the network round-trip only — the global prepareHeaders hook runs before the fetch and is not bounded by it, so a hook that may hang (e.g. an async token refresh) must self-bound.
const controller = new AbortController();
const res = await apiGetRaw("/slow", {
signal: controller.signal,
timeoutMs: 5_000,
headers: { "X-Request-Id": crypto.randomUUID() },
});Path contract:
pathis expected to be a relative path. WithbaseUrlset, the configured scheme+host always precede it, so an absolute (https://…) or protocol-relative (//host) path is neutralised (kept as a path segment) and cannot override the origin. A relativepathalso cannot escape the configured base path via../ dot-segment or backslash navigation — those are percent-encoded so the base path prefix always stands, while the query string and fragment are preserved verbatim. For this origin-override protection to hold,baseUrlmust be an absolute URL (scheme + host); an empty or relativebaseUrldoes not neutralise a protocol-relativepath. WithbaseUrlunset,pathis passed tofetch()verbatim — the caller owns the full URL and must never pass untrusted input as the whole path.Module-global config vs isolated instances:
configureFetchsets a single process-global config, so on the default surfacebaseUrlandcredentialscannot vary per in-flight request (onlyprepareHeadersruns per call). When multiple origins / credential-sets must coexist (per-tenant, multi-origin, SSR-per-request), build an isolated instance withcreateFetch— each instance holds its own config and shares nothing with the default or with other instances.
Isolated instances
For multiple origins or credential-sets in one app (per-tenant, multi-origin, SSR-per-request), createFetch builds an instance with its own config — independent of the module-global default and of every other instance:
import { createFetch } from "@cplieger/fetch";
const tenantA = createFetch({ baseUrl: "https://a.example.com", credentials: "include" });
const tenantB = createFetch({ baseUrl: "https://b.example.com" });
const [a, b] = await Promise.all([tenantA.apiGet<User>("/me"), tenantB.apiGet<User>("/me")]);
// Adjust an instance later (shallow-merge, like configureFetch):
tenantA.configure({
prepareHeaders: (headers) => {
headers.set("Authorization", `Bearer ${tokenA()}`);
},
});Each instance exposes the same surface as the default (requestRaw, request, and all twelve verb helpers), plus a per-instance configure.
API
Configuration
configureFetch(config)— shallow-merge into the global fetch layer (baseUrl,credentials,prepareHeaders,fetchFn,maxResponseBytes). Call at boot; successive calls accumulate.FetchConfig— the configuration shape.
maxResponseBytesis an opt-in cap on the response body size (unset = unlimited, the default). When set, a response whosecontent-lengthexceeds it — or whose streamed body grows past it — is rejected rather than buffered, a defense-in-depth guard against a hostile upstream (e.g. the SSR / Node path). An over-cap 2xx body surfaces ascode: "network"(status 0); an over-cap error body falls back to theHTTP <status>message.
Instance factory
createFetch(initialConfig?)— build an isolated fetch instance with its own config, so multiple origins / credential-sets / SSR-per-request configs coexist without touching the module-global default. Returns aFetchInstanceexposingrequestRaw,request, all twelve verb helpers, andconfigure(config)(a per-instance shallow-merge). Two instances share nothing.FetchInstance— the isolated-instance shape.
Request core
requestRaw<T>(method, path, opts?)— the non-throwing core; resolves toApiResult<T>.request<T>(method, path, opts?)— null-collapsing wrapper:dataon success,nullon any error.
Verb helpers
apiGet/apiPost/apiPut/apiPatch/apiDelete— null-collapsing (Promise<T | null>).apiGetRaw/apiPostRaw/apiPutRaw/apiPatchRaw/apiDeleteRaw— full envelope (Promise<ApiResult<T>>).apiGetTyped/apiPostTyped— decoder-validated, null-collapsing.
Decoder validation on
apiPut/apiPatch/apiDelete(and their*Rawforms) is available via thedecoderoption — e.g.apiPut(path, body, { decoder })— rather than dedicated*Typedhelpers.
Timeout
withTimeout(signal, ms)— compose an optional caller signal with a fresh timeout signal (viaAbortSignal.anywhen available).API_TIMEOUT_MS— default request timeout (30 000 ms).
Runtime baseline:
AbortSignal.timeoutis required (Chrome 103 / Safari 16 / Firefox 100 / Node 18+). Composing a caller signal with the timeout additionally needsAbortSignal.any(Chrome 116 / Safari 17.4 / Firefox 124 / Node 20.3+); on a runtime without it,withTimeoutdegrades to timeout-only (the caller signal is dropped, the timeout still applies) rather than failing to build the request.
Types
ApiOk<T>/ApiErr/ApiResult<T>— the result envelope union.Decoder<T>— a runtime validator: returns the typed value or throws.HttpMethod—"GET" | "POST" | "PUT" | "PATCH" | "DELETE".RequestOptions<T>— per-requestbody,signal,headers,decoder,timeoutMs.
Unsupported by design
These features are intentionally out of scope. @cplieger/fetch is the request/response envelope, nothing more:
| Feature | Reason |
| ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Retries / backoff | A dispatch-lifecycle concern. Compose with @cplieger/actions or a retry helper. |
| Idempotency-key / X-Request-ID injection | The caller passes these per request via opts.headers (or a global prepareHeaders hook). |
| Interceptor / middleware chains | The single prepareHeaders seam plus fetchFn injection cover the real cases without a plugin pipeline. |
| Decoder combinators | Ships only the Decoder<T> type and the optional invocation seam. Each app keeps its own validators (hand-written, zod, valibot, …). |
| Response caching / revalidation | Out of paradigm — this is a fetch envelope, not a data cache. |
| Non-JSON bodies / raw Response / response headers + statusText | JSON-envelope by design: the request body is JSON-encoded and the response is read as JSON (or empty). For binary / streaming bodies, response-header access, or statusText, drop to raw fetch. |
Disclaimer
This project is built with care and follows security best practices, but it is intended for personal / self-hosted use. No guarantees of fitness for production environments. Use at your own risk.
This project was built with AI-assisted tooling using Claude Opus and Kiro. The human maintainer defines architecture, supervises implementation, and makes all final decisions.
License
GPL-3.0 — see LICENSE.
