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

@okeav/web-client

v0.1.0

Published

A tiny, pluggable HTTP client for browsers and Node — fetch wrapper with baseURL, query params, timeouts, retry/backoff, cancellable requests, de-duplicated silent token refresh, optional request signing, and configurable error handling. Bring your own ba

Readme

@okeav/web-client

A fetch wrapper for browsers and Node (≥20) built around the one thing that's genuinely fiddly to hand-roll correctly: de-duplicated silent token refresh — when a request 401s, refresh once, queue any other requests that 401 while that refresh is in flight, then retry all of them, without ever firing a second concurrent refresh call. It also does the table-stakes stuff (baseURL, query params, timeout, retry/backoff, cancellation, interceptors) that plenty of small fetch wrappers already cover well — this one just adds the refresh-queue piece on top instead of leaving it as your problem.

It has zero required dependencies and no opinion about your backend's domain — you bring your own API shape, your own error convention, your own auth scheme. This package brings the plumbing.

If your app doesn't do 401-triggered token refresh, a general-purpose fetch wrapper like ky or ofetch is more established and likely a better default. Reach for this one when the refresh-dedup behavior above is what you actually need.

Install

npm install @okeav/web-client

Quick start

import { createClient } from '@okeav/web-client';

const api = createClient({ baseURL: 'https://api.example.com' });

const user = await api.get('/users/me');
await api.post('/users', { body: { name: 'Ada' } });
await api.get('/users', { params: { active: true, tag: ['a', 'b'] } }); // ?active=true&tag=a&tag=b

createClient() returns an independent instance — its config, in-flight refresh state, and (if configured) signing key are private to that instance. Talking to two APIs, or writing tests that don't leak state between cases, is just calling createClient() again.

See examples/quickstart/ for a runnable end-to-end demo (silent refresh, retry, timeout, error handling) against a real (if tiny) server.

Requests

api.get(path, options);
api.post(path, options);
api.put(path, options);
api.patch(path, options);
api.delete(path, options); // .del(...) also works
api.request(method, path, options); // any verb, e.g. HEAD

options:

| Option | Default | Notes | |---|---|---| | params | — | Query object. Skips null/undefined/''. Arrays become repeated keys. | | body | — | Plain objects are JSON.stringify'd with content-type: application/json. A FormData body is sent as-is with no content-type override. | | headers | — | Merged over the client's static/dynamic/signed headers, so a call site always wins on conflicts. | | signal | — | Standard AbortSignal. Combined with (not replaced by) the client's timeout. | | timeout | client's timeout | Per-request override, in ms. | | responseType | client's responseType | 'json' (default) | 'text' | 'blob' | 'arrayBuffer' | 'raw' (returns the untouched Response, even on error status). |

A 204/205 or empty body resolves to null instead of throwing a JSON parse error.

Client configuration

createClient({
  baseURL: 'https://api.example.com',
  fetch: myFetch,               // inject a fetch impl — defaults to globalThis.fetch
  credentials: 'include',       // passed straight to fetch; omitted entirely unless set
  timeout: 30000,                // ms; false/0 disables the built-in timeout
  headers: () => ({ 'accept-language': getLang() }), // object or (sync/async) function
  responseType: 'json',
  retry: { attempts: 0, delay: 300, backoff: 'exponential', shouldRetry },
  refresh: { path: '/auth/refresh', onRefreshed, shouldRefresh },
  sign: createHmacSigner({ key: 'my-signing-key' }),
  parseErrorBody: (body, res) => ({ message, code, details }),
  statusHandlers: { 401: (info) => {...}, default: (info) => {...} },
  onRequest: async (init) => ({ ...init, headers: {...} }),
  onResponse: (data, res) => data,
});

Timeout

Every request gets an AbortController timeout (default 30s). A caller's own signal still works — it's combined with the timeout, not overridden by it — and a caller-triggered abort surfaces as the native AbortError, while a client-triggered timeout surfaces as TimeoutError, so you can tell "I cancelled this" apart from "the network was too slow":

import { TimeoutError } from '@okeav/web-client';

try {
  await api.get('/slow', { timeout: 2000 });
} catch (err) {
  if (err instanceof TimeoutError) { /* ... */ }
}

Retry

Disabled by default (attempts: 0) — auto-retrying a non-idempotent request after a network blip risks double-submitting it. When enabled, only GET/HEAD/PUT/DELETE are retried, and only on a network error or a 502/503/504 response. Override shouldRetry to change either axis:

createClient({
  retry: {
    attempts: 3,
    delay: 300,        // base delay in ms
    backoff: 'exponential', // or 'fixed'
    maxDelay: 30000,   // caps exponential growth — a naive attempt 9 would otherwise wait ~77s
    shouldRetry: ({ method, error, response }) => method === 'GET' && (error || response?.status >= 500),
  },
});

Silent token refresh

On a 401 (or whatever shouldRefresh decides), the client POSTs to refresh.path once, then retries the original request. Concurrent requests that all hit 401 while a refresh is already in flight wait on that single refresh instead of firing their own — this is the one piece of behavior in this package that's genuinely fiddly to get right by hand, which is the reason to reach for a shared implementation instead of rewriting it per app.

createClient({
  baseURL: 'https://api.example.com',
  refresh: {
    path: '/auth/refresh',
    onRefreshed: (data) => saveNewAccessToken(data.accessToken),
    shouldRefresh: (res) => res.status === 401, // default
  },
});

Omit refresh entirely to disable this — a 401 then just flows through statusHandlers/ApiError like any other error status.

Request signing (optional)

createHmacSigner HMAC-signs timestamp:nonce:method:pathname with Web Crypto and returns nonce/timestamp/signature headers — a generic anti-replay building block (binds a request to one moment, one use), not an authentication mechanism. Pair it with real auth; don't rely on it alone. Entirely opt-in — a client with no sign configured never touches crypto.subtle.

import { createHmacSigner } from '@okeav/web-client';

createClient({
  sign: createHmacSigner({ key: process.env.CLIENT_SIGNING_KEY }),
});

Bring your own sign function instead if you need a different scheme — it's just ({ method, url }) => headers | Promise<headers>.

Errors

Every non-2xx response throws ApiError:

class ApiError extends Error {
  status?: number;
  code?: string;
  details?: unknown;
  response?: Response; // the raw Response, if you need more than status/body
}

The default parseErrorBody reads { error: { message, code, details } } or a flat { message }, falling back to res.statusText. Override it for any other backend convention:

createClient({
  parseErrorBody: (body, res) => ({
    message: body?.errors?.[0]?.detail ?? res.statusText,
    code: body?.errors?.[0]?.code,
  }),
});

statusHandlers run before the throw, for side effects keyed by status (session-expiry redirects, toast notifications, etc.) without wrapping every call site in a try/catch:

createClient({
  statusHandlers: {
    401: () => redirectToLogin(),
    403: ({ message }) => toast.warning(message),
    default: ({ message }) => toast.error(message), // anything else >= 400
  },
});

What this package deliberately does NOT do

  • No bundled domain vocabulary — no constants, no enums, no RBAC/scopes. That's your app's concern, not an HTTP client's.
  • No assumption about your auth scheme beyond "a 401 might mean I should refresh and retry" — cookies, bearer tokens, or nothing at all all work.
  • No request/response caching layer.
  • No built-in storage adapter (localStorage, cookies, etc.) — headers is a function precisely so you can read whatever you're persisting elsewhere and hand it over fresh on every request.

Testing

npm test

Full unit coverage (node --test) for query building, retry/backoff, response parsing, HMAC signing, and an integration suite covering the client end to end — including concurrent-401 refresh de-duplication and the timeout/cancellation interplay. test/scenarios/ additionally replays a real production app's actual auth/refresh/error-handling config against a mock backend, to catch integration gaps before anyone adopts this for real (not shipped in the published package — dev-only via files in package.json).