@cplieger/fetch
v2.1.2
Published
Small, zero-dependency universal fetch wrapper for TypeScript with typed result envelopes
Downloads
3,400
Maintainers
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?, headers?, body? }). 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). Configuration (base URL, credentials, a header-preparation hook, a custom fetch implementation) is captured immutably per instance by createFetch; there is no module-global state. Zero runtime dependencies, ESM-only, published as TypeScript source. Requires TypeScript ≥ 5.0 and an ESM bundler.
@cplieger/fetch is the browser-side JSON-fetch 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/fetchUsage
Create an instance once at boot (one line in a shared module), then call its verb helpers:
import { createFetch } from "@cplieger/fetch";
export const api = createFetch({
baseUrl: "https://api.example.com/v1",
credentials: "include",
prepareHeaders: (headers) => {
// Runs per request; read late-bound state (a token set after boot) here.
headers.set("Authorization", `Bearer ${getToken()}`);
},
});
// Null-collapsing: the decoded body on success, null on any error.
const user = await api.apiGet<{ id: string; name: string }>("/users/me");
if (user) {
console.log(user.name);
}
// Create a resource with a JSON body.
const created = await api.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 api.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);
// res.headers carries the response headers whenever a real HTTP response
// was received (any non-2xx, or a 2xx decode failure), e.g. Retry-After:
if (res.status === 429) {
console.warn("retry after", res.headers?.get("Retry-After"));
}
}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 that never reached the network: an un-encodable body (circular / BigInt), a bad header name/value, a badtimeoutMs, or a throwingprepareHeaders. 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 { 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 api.apiGetTyped("/users/me", decodeUser); // { id: string } | nullPer-request options
Every helper accepts a trailing RequestOptions: a caller AbortSignal, per-request headers, a decoder, a timeoutMs override (default 30 000 ms), ignoreBody, and rawBody. rawBody is a pre-encoded BodyInit sent as-is: no JSON encoding, no automatic Content-Type (set the type via headers), mutually exclusive with body. 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 instance's prepareHeaders hook runs before the fetch and is not bounded by it, so a hook that may hang (an async token refresh) must self-bound.
const controller = new AbortController();
const res = await api.apiGetRaw("/slow", {
signal: controller.signal,
timeoutMs: 5_000,
headers: { "X-Request-Id": crypto.randomUUID() },
});
// ignoreBody: skip reading a 2xx success body entirely (data: undefined; a
// supplied decoder is not invoked). Non-2xx error bodies are still parsed.
// For endpoints whose success body is irrelevant or non-JSON.
await api.apiDeleteRaw("/items/1", { ignoreBody: true });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.
Multiple backends
Instances are cheap and fully isolated: one per origin / credential-set / tenant, or one per request for SSR. Two instances share nothing:
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")]);API
Instance factory
createFetch(config?): build an isolated fetch instance.config(baseUrl,credentials,prepareHeaders,fetchFn,maxResponseBytes) is shallow-copied and frozen at construction. Returns aFetchInstanceexposingrequestRaw,request, and all twelve verb helpers.FetchConfig: the configuration shape.FetchInstance: the instance shape.
maxResponseBytesis an opt-in cap on the response body size (unset = unlimited, the default;Infinitymeans the same). 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. A cap ofNaN— whatNumber(process.env.MAX_BYTES)yields when the variable is unset — is refused bycreateFetchwith aTypeError, because nothing compares>against it and the read would be unbounded while looking capped.
Request core (per instance)
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 (per instance)
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.ApiErr.headerscarries the response headers whenever a real HTTP response was received (any non-2xx, or a 2xx decode failure); it is absent on network / timeout / cancelled / invalid failures.ApiErr.bodycarries the parsed JSON body of that response when one parsed (a 409 whose body is a meaningful conflict envelope, a decoder mismatch's raw value); absent on non-JSON / empty bodies and on the no-response failures. Treat it as server-controlled input: validate before reading fields, render text from it viatextContent.Decoder<T>: a runtime validator that returns the typed value or throws.HttpMethod:"GET" | "POST" | "PUT" | "PATCH" | "DELETE".RequestOptions<T>: per-requestbody,rawBody,signal,headers,decoder,timeoutMs,ignoreBody.
Migrating from v1
v2 removes the module-global config surface; instances are the only topology, and their config is immutable. Mechanical mapping:
| v1 | v2 |
| ------------------------------------------------- | ------------------------------------------------------------------------ |
| configureFetch(cfg) + top-level apiGet / … | export const api = createFetch(cfg) + api.apiGet / … |
| instance.configure(cfg) (shallow-merge) | createFetch({ ...oldCfg, ...cfg }): a new instance (replace semantics) |
| Late-bound token via a later configure call | Read the token inside prepareHeaders (runs per request) |
| resetFetchConfig() / getFetchConfig() (tests) | Build a fresh instance per test; nothing global to reset |
The envelope, verb helpers, path contract, timeout composition, and decoder seam are unchanged. New in v2: ApiErr.headers (error-response headers) and RequestOptions.ignoreBody (skip a 2xx body). New in v2.1: ApiErr.body (the parsed JSON body of a failed response) and RequestOptions.rawBody (pre-encoded request bodies).
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 the instance's 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. |
| Mutable / module-global configuration | Config is frozen at createFetch. A changed backend is a new instance; late-bound per-request state reads from inside prepareHeaders. |
| Non-JSON responses / raw Response / success-response metadata | The response side is JSON-envelope by design (request bodies may be pre-encoded via rawBody). Error-path headers and parsed JSON bodies ride ApiErr.headers / ApiErr.body; for binary / streaming responses, success-response header access, or statusText, drop to raw fetch. |
Contributing
Issues and PRs are welcome. See CONTRIBUTING.md for the conventions and how to run the checks locally.
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, GPT, and Kiro. The human maintainer defines architecture, supervises implementation, and makes all final decisions.
License
Apache-2.0. See LICENSE.
