npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@cplieger/fetch

v2.1.2

Published

Small, zero-dependency universal fetch wrapper for TypeScript with typed result envelopes

Downloads

3,400

Readme

fetch

npm JSR Test coverage Mutation (TS) OpenSSF Best Practices OpenSSF Scorecard

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 (apiGetdata | null), a full-envelope form (apiGetRawApiResult), 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/fetch

Usage

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 into null; when you use the *Raw helpers on a 204-capable endpoint, type T to include undefined (or branch on status). A JSON null / 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 bad timeoutMs, or a throwing prepareHeaders. 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 } | null

Per-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: path is expected to be a relative path. With baseUrl set, 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 relative path also 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, baseUrl must be an absolute URL (scheme + host); an empty or relative baseUrl does not neutralise a protocol-relative path. With baseUrl unset, path is passed to fetch() 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 a FetchInstance exposing requestRaw, request, and all twelve verb helpers.
  • FetchConfig: the configuration shape.
  • FetchInstance: the instance shape.

maxResponseBytes is an opt-in cap on the response body size (unset = unlimited, the default; Infinity means the same). When set, a response whose content-length exceeds 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 as code: "network" (status 0); an over-cap error body falls back to the HTTP <status> message. A cap of NaN — what Number(process.env.MAX_BYTES) yields when the variable is unset — is refused by createFetch with a TypeError, 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 to ApiResult<T>.
  • request<T>(method, path, opts?): null-collapsing wrapper: data on success, null on 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 *Raw forms) is available via the decoder option (e.g. apiPut(path, body, { decoder })) rather than dedicated *Typed helpers.

Timeout

  • withTimeout(signal, ms): compose an optional caller signal with a fresh timeout signal (via AbortSignal.any when available).
  • API_TIMEOUT_MS: default request timeout (30 000 ms).

Runtime baseline: AbortSignal.timeout is required (Chrome 103 / Safari 16 / Firefox 100 / Node 18+). Composing a caller signal with the timeout additionally needs AbortSignal.any (Chrome 116 / Safari 17.4 / Firefox 124 / Node 20.3+); on a runtime without it, withTimeout degrades 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.headers carries 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.body carries 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 via textContent.
  • Decoder<T>: a runtime validator that returns the typed value or throws.
  • HttpMethod: "GET" | "POST" | "PUT" | "PATCH" | "DELETE".
  • RequestOptions<T>: per-request body, 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.