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

v1.1.3

Published

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

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

Requires 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 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 — an un-encodable body (circular / BigInt), a bad header name/value, a bad timeoutMs, or a throwing prepareHeaders — 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 } | null

Per-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: 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.

Module-global config vs isolated instances: configureFetch sets a single process-global config, so on the default surface baseUrl and credentials cannot vary per in-flight request (only prepareHeaders runs per call). When multiple origins / credential-sets must coexist (per-tenant, multi-origin, SSR-per-request), build an isolated instance with createFetch — 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.

maxResponseBytes is an opt-in cap on the response body size (unset = unlimited, the default). 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.

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 a FetchInstance exposing requestRaw, request, all twelve verb helpers, and configure(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 to ApiResult<T>.
  • request<T>(method, path, opts?) — null-collapsing wrapper: data on success, null on 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 *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.
  • Decoder<T> — a runtime validator: returns the typed value or throws.
  • HttpMethod"GET" | "POST" | "PUT" | "PATCH" | "DELETE".
  • RequestOptions<T> — per-request body, 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.