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

@dmytromykhailiuk/create-api

v1.0.0

Published

A fetch client built from three honest layers — chainable interceptors, offline-aware retries and Cache API strategies. Typed, tested, dependency-light.

Downloads

76

Readme

@dmytromykhailiuk/create-api

A fetch client built from three honest layers — chainable interceptors, offline-aware retries and Cache API strategies — behind one small, fully typed surface.

Full documentation: open Docs in a browser — every option, with examples, a table of contents and cross-links. This README is the short form.

⚠️ NetworkConnection.init() must be called first. This library uses NetworkConnection from @dmytromykhailiuk/network-connection for the real network state, and none of the layers below have an honest default for it. A request made before that setup rejects immediately with [create-api] NetworkConnection.init() must be called before using the API and is never sent. See Setup.

fetch is a good transport and a poor client. Every app ends up rewriting the same four things around it: a base url, a place to put the auth header, retries that do not make things worse, and some notion of what may be served from a cache. createAPI is those four things, assembled in the one order that keeps each of them honest:

cache strategy → retry loop → interceptors → fetch

A cached answer costs nothing, so it is asked for first. Retries sit outside the interceptor chain, so every attempt re-enters it from the top — which is what lets an interceptor put a freshly refreshed token on the second try. And fetch is wrapped so a non-2xx response throws, because every layer above it needs a failure to look like one.

Install

npm i @dmytromykhailiuk/create-api

Its three dependencies — retry-request, cache-request and network-connection — come with it.

Setup

Once, at startup, before the first request:

import { NetworkConnection } from "@dmytromykhailiuk/network-connection";

// Any URL your server answers cheaply. A 404 still proves the network is
// reachable — this measures connectivity, not server health.
await NetworkConnection.init("/healthcheck", {
  pingInterval: 30_000, // catch the silent drops: Wi-Fi up, no internet
});

navigator.onLine is not a substitute: refresh a PWA while offline and it still reports true. The retry loop parks on this state, and network-first decides between the network and the cache with it, so there is no fallback to a plain fetch — a client that cannot tell a dead connection from a dead server is not the client you configured.

Quick start

import { createAPI } from "@dmytromykhailiuk/create-api";

export const api = createAPI({
  baseUrl: "https://api.example.com/v1",
  defaultOptions: { maxRetries: 2, credentials: "include" },
});

const response = await api.get("users/1");
const user = await response.json();

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

Every method resolves with the Response itself, unread — the body is yours to parse. A non-2xx response is thrown, not returned:

try {
  await api.get("users/404");
} catch (error) {
  if (error instanceof Response) console.warn(error.status, await error.text());
}

An auth interceptor

The reason interceptors exist. The token is read when the request is made, not when the client is built, so a refresh anywhere in the app lands on the next request:

import { createAPI, createInterceptor } from "@dmytromykhailiuk/create-api";

const authInterceptor = createInterceptor(async (url, config, next) => {
  const token = getAccessToken(); // your store, your rules
  if (!token) return next(url, config);

  const headers = new Headers(config.headers);
  headers.set("Authorization", `Bearer ${token}`);

  return next(url, { ...config, headers });
});

export const api = createAPI({
  baseUrl: "https://api.example.com/v1",
  interceptors: [authInterceptor],
});

new Headers(config.headers) rather than a spread: headers may arrive as an object, as a Headers instance or as an array of pairs, and only the constructor reads all three.

Put a refresh in front of it and the pair becomes a session:

const refreshInterceptor = createInterceptor(async (url, config, next) =>
  // `next` is the rest of the chain, so the replay goes through the auth
  // interceptor again — and picks up the token that was just refreshed.
  next(url, config).catch(async (error) => {
    if (!(error instanceof Response) || error.status !== 401) throw error;
    await refreshSession();
    return next(url, config);
  })
);

const api = createAPI({ interceptors: [refreshInterceptor, authInterceptor] });

The first interceptor in the array is the outermost: it sees the request first and the response last. Interceptors can also be added later — registerInterceptor returns the function that removes it again:

const unregister = api.registerInterceptor(loggingInterceptor);
unregister();

API

createAPI(options?: CreateApiOptions): Api

interface CreateApiOptions {
  baseUrl?: string;                 // prefixed to relative urls; absolute urls are left alone
  interceptors?: HttpInterceptor[]; // outermost first; the array is copied, never mutated
  defaultOptions?: RequestOptions;  // house style, overridden per call
}

interface Api {
  get(url: string, options?: RequestOptions): Promise<Response>;    // and head — no body
  post(url: string, options?: RequestOptions): Promise<Response>;   // put, patch, delete, query
  registerInterceptor(interceptor: HttpInterceptor): () => void;
}

type HttpInterceptor = (
  url: string,
  config: RequestInit,
  next: (url: string, config: RequestInit) => Promise<Response>
) => Promise<Response>;

Every option is optional, baseUrl included. Leave it out and the client is a thin wrapper around the urls you already write — same-origin paths, or absolute urls to wherever they point:

// The whole client: retries, interceptors and strategies, no url rewriting.
export const api = createAPI();

await api.get("/api/users"); // root-relative, exactly as written
await api.get("https://cdn.example.com/a.json"); // absolute, left alone

That is the shape for a client that talks to several hosts, or for paths that already come from somewhere else. A baseUrl is a convenience, not a boundary: even with one set, an absolute url is left alone.

RequestOptions is everything the three layers take, in one object:

type RequestOptions =
  & Omit<RequestInit, "method">                              // body, headers, credentials, signal …
  & Omit<RetryOptions, "ignoreConnectionForFirstAttempt">    // maxRetries, shouldRetry, onRetry …
  & { cacheStrategy?: "cache-first" | "network-first" | "no-cache" }
  & (CacheFirstOptions | NetworkFirstOptions);               // cacheName, maxSize, isOnlineFn

The two omissions are the two things the client already knows: method is the function you called, and the first-attempt connection rule is derived from it. The cache half is discriminated by cacheStrategycacheName, maxSize and isOnlineFn only exist where a strategy reads them.

get and head take the same options without body, because fetch throws a TypeError for a GET with one and a type error is the cheaper way to find that out.

Retries

Every option from retry-request is a request option, and maxRetries still defaults to 0 — nothing is retried until you ask:

await api.get("report", {
  maxRetries: 4,
  retryBaseDelay: 500, // 500 ms, 1 s, 2 s, 4 s — doubling, with maxDelay as the ceiling
  maxDelay: 10_000,
});

Two behaviours come from the connection layer and are not configurable:

  • GET, HEAD and QUERY start their first attempt without waiting for the network. A service worker or an HTTP cache may answer them with no connection at all. Everything that changes something on the server waits until the connection is verified — a POST fired into a dead network is a POST you have to reason about later.
  • Retries always wait, for every method. A call parked offline for ten minutes has spent none of its budget, and a backoff in progress ends early when the connection comes back.

For anything non-idempotent, add retryOnlyOnConnectionFailure: true: a POST the server answered with a 500 is not repeated, while the same POST killed by a dying connection is.

signal goes to both layers — it cancels the request in flight and the retry loop around it:

const controller = new AbortController();
const call = api.get("feed", { maxRetries: 5, signal: controller.signal });
onCleanup(() => controller.abort());

Caching

cacheStrategy picks a strategy from cache-request, backed by the browser Cache API:

// Content that does not change under this url: served from storage forever.
await api.get("assets/logo.svg", {
  cacheStrategy: "cache-first",
  cacheName: "assets",
  maxSize: 50, // oldest entries evicted past the cap
});

// Fresh when the network is there, readable when it is not.
await api.get("feed", { cacheStrategy: "network-first", cacheName: "api" });

// Neither stored nor served from any cache, HTTP cache included.
await api.get("nonce", { cacheStrategy: "no-cache" });

| Strategy | Online | Offline | | ---------------- | --------------------------------- | --------------------------------------- | | (none) | network, browser HTTP cache rules | the request fails | | "cache-first" | stored entry, else network | stored entry, else the request fails | | "network-first"| network, entry refreshed | stored entry, else Offline and no cache | | "no-cache" | network, HTTP cache bypassed | the request fails |

Three things worth knowing:

  • A cache hit is not a request. It skips the interceptors and the retry loop entirely — no auth header is computed, no attempt is made. That is the point, and it is also why a per-user response belongs in a bucket you can clear on sign-out.
  • Any strategy bypasses the HTTP cache (cache: "no-store" plus Cache-Control: no-cache), so there are never two caches disagreeing about the same url with only one of them under your control. Your other headers are left alone.
  • The key is the final url alone — the method and the body are not part of it. Strategies belong on reads; caching a POST would let two different bodies share one entry.
  • maxSize needs a cacheName of your own. The default bucket is shared with every call that did not name one, so a cap there would evict someone else's entries.

Offline, network-first answers from storage without asking the network — and it asks NetworkConnection, not navigator.onLine. Override isOnlineFn if you have a better question to ask.

What a call rejects with

| Situation | Rejection | | ------------------------------------- | ----------------------------------------------------------- | | Non-2xx response | the Response itself, unread — status, headers, body intact | | Transport failure | the TypeError fetch threw | | An interceptor threw | whatever it threw | | Retries exhausted | the last attempt's own error, never wrapped | | The signal aborted | signal.reason | | network-first, offline, nothing stored | Error("Offline and no cache") | | NetworkConnection not initialized | Error("[create-api] NetworkConnection.init() …") | | An option is out of range | Error("[create-api] …"), before the request is made |

Every failure mode is a rejection, never a synchronous throw, so one catch covers all of them.

TypeScript

RequestOptions, HttpInterceptor, NextHandler, Api and CreateApiOptions are all exported for wrappers that pass things through. createInterceptor is identity at runtime and exists for the types — it gives an inline interceptor its parameter types without annotating them:

import { type RequestOptions, createInterceptor } from "@dmytromykhailiuk/create-api";

const HOUSE_STYLE: RequestOptions = { maxRetries: 2, credentials: "include" };

const traceInterceptor = createInterceptor((url, config, next) => {
  //                                        ^? string, RequestInit
  console.time(url);
  return next(url, config).finally(() => console.timeEnd(url));
});

Exports

createAPI · createInterceptor · RequestOptions · BodylessRequestOptions · CreateApiOptions · Api · ApiRequest · BodylessApiRequest · HttpInterceptor · NextHandler · HttpMethod · CacheStrategy · BaseRequestOptions

License

MIT