@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
Maintainers
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 usesNetworkConnectionfrom@dmytromykhailiuk/network-connectionfor 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 APIand 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 → fetchA 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-apiIts 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 aloneThat 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, isOnlineFnThe 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
cacheStrategy — cacheName, 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,HEADandQUERYstart 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 — aPOSTfired into a dead network is aPOSTyou 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"plusCache-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
POSTwould let two different bodies share one entry. maxSizeneeds acacheNameof 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
