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

fetch-braid

v0.2.0

Published

Fetch interceptor chain + tiny REST client. Zero deps, framework-agnostic, SSR-safe.

Downloads

60

Readme

fetch-braid

A fetch interceptor chain and a small REST client, in ~250 lines with no dependencies.

  • createFetch braids middleware around a transport — add headers, retry, count in-flight requests.
  • rest binds one endpoint to a set of verbs, serialises the body, parses the response, throws on non-2xx.

No framework, no globals, no window. Runs unchanged in the browser, under SSR, and in a worker.

npm i fetch-braid   # bun add fetch-braid

createFetch

Every interceptor is a strand wrapped around the transport:

import { createFetch } from 'fetch-braid'

const api = createFetch() // or createFetch(someOtherFetch)
api.use(next => (input, init) => next(input, api.withHeader(init, 'authorization', token)))

await api.fetch('/api/products/123')

First registered ends up outermost, so registration order is reading order. api.fetch is a stable reference — the chain is composed per call, so a consumer can capture it before any interceptor exists and still see them all. That's what makes a module-scope resource safe while auth wires itself up later.

An interceptor is just next => (input, init) => Response, so skipping next short-circuits and calling it twice retries:

api.use(next => async (input, init) => {
  const response = await next(input, init)
  return (response.status === 401 && (await refresh()) && next(input, init)) || response
})

Two helpers hang off the client so an interceptor needs no second import:

  • api.withHeader(init, name, value) returns a copy with one more header. Never mutate the init you were handed — it belongs to the caller and is reused across a retry.
  • api.toUrl(input) gets the url whichever of the three forms it arrived in. A Request stringifies to '[object Request]', so you cannot template it.

Both are plain exports too — import { toUrl, withHeader } from 'fetch-braid'. Reach for those when the chain is composed somewhere other than createFetch: an Angular app wiring interceptors through DI multi-providers still wants the helpers, and shouldn't have to build a throwaway client to get at them. toUrlForm and toFormData are exported the same way.

As a DI token

FetchClient is an abstract class, not an interface, so it can double as a dependency-injection key. Subclass it with an empty body when an app needs two independent clients:

export abstract class ApiFetch extends FetchClient {}
export abstract class PaymentsFetch extends FetchClient {}

Not using DI? Ignore it — createFetch() returns a plain object.

rest

Hand it the braided transport, or nothing at all and it uses global fetch:

import { rest } from 'fetch-braid'

const products = rest('https://api.example.com/products', api.fetch)

await products.query({ params: { page: 2, sort: 'name' } }) // GET /products?page=2&sort=name
await products.get('123') // GET /products/123
await products.post({ name: 'Widget' }) // POST /products
await products.postAt('123/publish', {}) // POST /products/123/publish
await products.put('123', { name: 'Widget 2' }) // PUT /products/123
await products.patch('123', { name: 'Widget 3' }) // PATCH /products/123
await products.delete('123') // DELETE /products/123
await products.head('123') // HEAD — resolves undefined, for the status alone

id is any path suffix, so one client covers a subtree.

Pass a function as the endpoint and it is read at call time — a client built once at startup still follows a base url that depends on the current locale, tenant or signed-in user:

const orders = rest(() => `${config.baseUrl}/${locale}/users/${userId}/orders`, api.fetch)

Bodies

Serialisation follows the value you pass:

| Value | Sent as | Content-Type | | --- | --- | --- | | object / array | JSON.stringify | application/json | | URLSearchParams | toString() | application/x-www-form-urlencoded | | FormData | as-is | none — the transport adds the multipart boundary | | Blob / ArrayBuffer | as-is | none | | string | as-is | none — set it yourself |

Two helpers ride along for endpoints that want a form rather than json:

await groups.postAt(`${id}/members`, groups.toUrlForm({ userId })) // falsy values dropped
await uploads.post(uploads.toFormData({ meta: { id: 5 }, file })) // → meta.id=5, file kept whole

toFormData flattens nested objects to dotted keys and array items to list[0].id. File, Blob and Date survive whole, Date as an ISO string.

Responses

The content-type decides by default — json is parsed, anything else is text, 204 resolves undefined. Override with responseType:

await files.get('a1', { responseType: 'blob' }) // Blob
await feed.get('a1', { responseType: 'document' }) // the raw text, NOT a DOM

'document' returns text because DOMParser is browser-only and this runs under SSR too.

Errors

A non-2xx rejects with a RestError — a real Error, and the only way to tell the failure cases apart:

import { RestError } from 'fetch-braid'

try {
  await orders.post(order)
} catch (e) {
  if (!(e instanceof RestError)) throw e // never got a response, or your handler threw
  if (e.status === 409) return // already exists
  message = e.body?.errors?.map(x => x.message).join(', ')
}

e.body is parsed json, or raw text, or undefined when empty. e.url is the url this client built, query string included — not response.url, which a redirect would have rewritten.

API

| Export | What | | --- | --- | | createFetch(base?) | a FetchClient over a transport; defaults to global fetch | | rest(endpoint, doFetch?) | a RestClient bound to one endpoint | | RestError | thrown on non-2xx; status, statusText, url, body | | toUrl, withHeader | the interceptor helpers, standalone as well as on the client | | toUrlForm, toFormData | the body helpers, standalone as well as on the client | | flatten, isObject | object helpers, exported because toFormData needs them | | FetchClient, FetchInterceptor, RestClient, RequestOptions, HttpResponseType | types |

Requirements

fetch, Response, Headers, FormData, File, Blob and URLSearchParams as globals — Node 22+, Bun, Deno, or any current browser. Nothing is imported from node:*. ESM and CJS both shipped.

Development

bun install
bun run check   # lint + type-check + test
bun run build   # tsdown → dist, validated by publint and attw
bun run smoke   # import the built dist/ under plain node, esm and cjs

Licence

MIT