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

@panoscool/fetch-client

v0.1.0

Published

A tiny, typed HTTP client for the fetch API — interceptors, JSON handling, and structured errors.

Readme

@panoscool/fetch-client

A tiny, typed HTTP client built on the native fetch API. It adds the ergonomics you usually reach for — a base URL, default and dynamic headers, automatic JSON handling, request/response interceptors, and structured errors — without pulling in a heavy dependency.

Features

  • Thin wrapper over fetch: No polyfills, no transport magic. Uses the platform fetch, Headers, and Response.
  • Typed responses: client.get<User>('/me') resolves to a fully typed { data, status, statusText, headers, request, response }.
  • Smart bodies: Plain objects are serialized to JSON with the right Content-Type; string, FormData, URLSearchParams, Blob, and ArrayBuffer pass through untouched.
  • Flexible response parsing: auto (JSON/text by content type), or force json, text, blob, arrayBuffer, formData, or raw.
  • Interceptors: Mutate the outgoing request draft, transform responses, or recover from errors.
  • Dynamic headers: Provide static headers or an (async) resolver that sees the finalized request — handy for signing/auth.
  • Structured errors: Every failure becomes an ApiError with status, data, request, and response, plus a redacting toJSON() that's safe to log.
  • Pluggable transport: Inject a custom fetch implementation for testing or advanced control.
  • ESM & CJS: Ships both, with TypeScript types.

Installation

npm install @panoscool/fetch-client

Requirements: Node 20+ (or any runtime with a global fetch). The published types rely only on fetch's globals — @types/node is enough; you do not need the "DOM" lib in your tsconfig.

Quick start

import { createApiClient } from '@panoscool/fetch-client';

const api = createApiClient({ baseUrl: 'https://api.example.com' });

type User = { id: number; name: string };

const { data, status } = await api.get<User>('/users/1');
console.log(status, data.name);

await api.post('/users', { name: 'Ada' });

CommonJS

const { createApiClient } = require('@panoscool/fetch-client');

Creating a client

const api = createApiClient({
  baseUrl: 'https://api.example.com', // optional; prepended to each path
  headers: { 'x-app': 'web' },        // static headers, or a resolver (see below)
  transport: createFetchTransport(),  // optional; override the fetch transport
});

| Option | Type | Description | | --- | --- | --- | | baseUrl | string | Prepended to request paths, preserving any base path (e.g. /v1). A trailing / on the base and a leading / on the path are normalized. A path that is itself an absolute URL (https://… or //…) overrides baseUrl. | | headers | HeadersInit | (request) => HeadersInit \| Promise<HeadersInit> | Default headers, or a resolver invoked per request. | | transport | ApiTransport | Custom transport. Defaults to a fetch-based transport. |

Request methods

api.request<TResponse, TBody>(path, options?)
api.get<TResponse>(path, options?)
api.post<TResponse, TBody>(path, body?, options?)
api.put<TResponse, TBody>(path, body?, options?)
api.patch<TResponse, TBody>(path, body?, options?)
api.delete<TResponse, TBody>(path, body?, options?)

options extends the standard RequestInit (so signal, credentials, mode, cache, … all pass through), plus:

| Option | Type | Default | Description | | --- | --- | --- | --- | | headers | HeadersInit | — | Per-request headers. Override client headers. | | responseType | 'auto' \| 'json' \| 'text' \| 'blob' \| 'arrayBuffer' \| 'formData' \| 'raw' | 'auto' | How to read the response body. |

The response

Every successful call resolves to the full response (not just the body):

const res = await api.get<User>('/users/1');

res.data;        // parsed body, typed as User
res.status;      // 200
res.statusText;  // 'OK'
res.headers;     // Headers
res.request;     // the finalized request config (url, method, headers, body)
res.response;    // the raw Response (body still readable — parsing uses a clone)

Bodies

Plain objects are JSON-serialized and get Content-Type: application/json automatically:

await api.post('/users', { name: 'Ada' });
// body: '{"name":"Ada"}', Content-Type: application/json

string, FormData, URLSearchParams, Blob, and ArrayBuffer/typed-array bodies are sent as-is, and Content-Type is left to the platform:

const form = new FormData();
form.set('file', file);
await api.post('/upload', form); // no Content-Type forced; boundary handled by fetch

Response types

await api.get('/data', { responseType: 'json' });        // res.data: parsed JSON
await api.get('/page', { responseType: 'text' });         // res.data: string
await api.get('/file', { responseType: 'blob' });         // res.data: Blob
await api.get('/buf', { responseType: 'arrayBuffer' });   // res.data: ArrayBuffer
await api.get('/form', { responseType: 'formData' });     // res.data: FormData
await api.get('/raw', { responseType: 'raw' });           // res.data: Response (unread)

With auto (the default), application/json responses are parsed as JSON, everything else as text. A 204 No Content resolves with data: null.

Headers

Precedence, from lowest to highest: built-in defaults (Accept: application/json) → client headers → per-request headers.

The client headers resolver receives the finalized request (URL, method, serialized body, and headers), which makes request signing straightforward:

const api = createApiClient({
  baseUrl: 'https://api.example.com',
  headers: async (request) => ({
    Authorization: `Bearer ${await getToken()}`,
    'x-signature': sign(request.method, request.url, String(request.body ?? '')),
  }),
});

Interceptors

Request interceptors

Run against the raw request draft before it is finalized (before JSON serialization and header merging). Mutate the URL, body, or headers:

const id = api.interceptors.request.use((draft) => ({
  ...draft,
  url: `${draft.url}?trace=1`,
  headers: { 'x-trace-id': crypto.randomUUID() },
}));

api.interceptors.request.eject(id); // remove a single interceptor
api.interceptors.request.clear();   // remove all

Response interceptors

Run against the full response, in registration order:

api.interceptors.response.use((res) => {
  console.log(`${res.request.method} ${res.request.url} -> ${res.status}`);
  return res;
});

Recovering from errors

A response interceptor's second argument handles errors. Return a response to recover, or throw to propagate:

api.interceptors.response.use(undefined, (error) => {
  if (error.status === 401) {
    return refreshAndRetry(error.request); // must return a response-shaped object
  }
  throw error;
});

Errors

Any non-2xx status, network failure, or unreadable body is thrown as an ApiError:

import { ApiError } from '@panoscool/fetch-client';

try {
  await api.get('/missing');
} catch (error) {
  if (error instanceof ApiError) {
    error.status;   // HTTP status, or 0 for network/abort failures
    error.data;     // parsed error payload, when present
    error.request;  // the finalized request config
    error.response; // the raw Response (body still readable)
    error.cause;    // the original thrown error
  }
}

ApiError extracts a useful message from common payload shapes ({ detail }, { message }, { error }, { errors: [{ message }] }, plain text). Aborted requests report "Request aborted" with status: 0.

error.toJSON() returns a log-safe view with sensitive request headers (Authorization, Cookie, Set-Cookie, Proxy-Authorization, X-Api-Key) redacted.

Cancellation

Pass an AbortSignal like any other fetch call:

const controller = new AbortController();
const promise = api.get('/slow', { signal: controller.signal });
controller.abort();
// rejects with an ApiError: "Request aborted"

Custom transport

createFetchTransport lets you inject a fetch implementation — useful in tests or to wrap fetch:

import { createApiClient, createFetchTransport } from '@panoscool/fetch-client';

const api = createApiClient({
  transport: createFetchTransport(myFetch),
});

Development

bun install
bun run lint    # biome
bun test        # bun test runner
bun run build   # ESM + CJS + type declarations into dist/

License

MIT — see LICENSE