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

@vibedout/provider-kit

v0.1.1

Published

The shared plumbing for provider adapters (payments, shipping, maps and the like): an HTTP client with timeouts, abort chaining, secret redaction, status classification and opt-in retries for safe calls; an error-family factory with one shared code vocabu

Readme

@vibedout/provider-kit

The shared plumbing for provider adapters (payments, shipping, maps, anything that talks to a third party's API). An adapter is createHttpClient plus a mapError hook plus its own domain mappers; none of them writes its own timeout, retry, redaction, status mapping or error base class.

  • No dependencies, and no Node. It is fetch, AbortController, Headers and URLSearchParams.
  • ESM only, with types. Each module is also a subpath (@vibedout/provider-kit/http) for a narrower import. The types use the standard fetch globals (the DOM lib or @cloudflare/workers-types).
  • Crypto (HMAC, webhook verification, receipts) is @vibedout/web-crypto; money is @vibedout/money. Nothing here needs either.
import { createHttpClient, defineErrorFamily, assertIdempotencyKey } from "@vibedout/provider-kit";

An adapter, end to end

import { createHttpClient, defineErrorFamily, readString } from "@vibedout/provider-kit";

// 1. One error family: the shared codes plus the domain's own.
const PaymentsFamily = defineErrorFamily<"card_declined" | "refund_not_supported">("PaymentsError", ["timeout", "unavailable"]);
export class PaymentsError extends PaymentsFamily {}

// 2. One HTTP client: base URL, credentials, every secret that must never appear in an error.
const http = createHttpClient({
  provider: "stripe",
  baseUrl: "https://api.stripe.com",
  auth: () => ({ authorization: `Bearer ${secretKey}` }),
  secrets: [secretKey, webhookSecret],
  ErrorClass: PaymentsError,
  timeoutMs: 20_000,
  // 3. The provider's own error body, mapped to the domain's codes. Return nothing to take the default.
  mapError: ({ status, body }) => {
    if (readString(body, "error", "type") === "card_error") {
      return new PaymentsError("card_declined", readString(body, "error", "message") ?? "declined", { status });
    }
  },
});

// 4. Calls.
const { body } = await http.request({
  method: "POST",
  path: "/v1/refunds",
  body: { form: { payment_intent: id } },
  idempotencyKey: `${paymentId}:refund`,   // checked, then sent as `Idempotency-Key`
  mutating: true,
  signal,                                  // the caller's abort signal, chained to the timeout
});

http

createHttpClient({ provider, baseUrl, auth?, fetch?, timeoutMs?, secrets, ErrorClass?, mapError?, retry?, headers?, ambiguousNote? }) and .request({ method, path, query?, body?, headers?, idempotencyKey?, idempotencyHeader?, mutating?, signal?, timeoutMs? }).

  • Body: { json }, { form } (an object is bracket-encoded, line_items[0][price]=p; a string goes as it is) or { text, contentType? }. query is bracket-encoded too. A GET with a body throws.
  • Response: { status, headers, body, text, isJson }. A body that is not JSON comes back as text with isJson: false; an empty one as undefined. A 2xx is returned, any other status throws.
  • Timeout (15 seconds unless set; a value that is not above 0 and at most 2147483647 ms is refused) covers the whole call, body included. It cuts a fetch that ignores its signal too.
  • Redirects are not followed by default (redirect: "manual"): a 3xx is an invalid_response, so a credential header cannot be carried to another host. Pass redirect: "follow" if a provider needs it.
  • Abort: the caller's signal is chained to the timeout, its listener is removed when the call ends, and a signal that is already aborted sends nothing. A caller's abort is unavailable and never retryable: giving up is the caller's decision.
  • Redaction: every secret in secrets (and its URL-encoded form), and any Bearer ... or Basic ... credential, is removed from the message and stack of every error the client throws, the whole cause chain (an Error, a plain object with a message, a string), the providerCode and the issues, and from the ones your mapError builds. Text is redacted before it is clipped, so a secret cut in half by the clip cannot leak a prefix. http.excerpt(body) and http.redact(text) do the same for text you put into a message yourself.
  • mapError is called for every response, 2xx included (Envia answers some errors with a 200). Return an error to throw it, or nothing for the default.
  • A bad header value (a key with a newline in it) is configuration_invalid, not a network error.

Status to error code

classifyStatus(status, headers) gives { code, retryable, retryAfterMs }:

| Status | Code | Retryable | |---|---|---| | 401, 403 | auth_failed | no | | 404 | not_found | no | | 408 | timeout | yes | | 429 | rate_limited (honours Retry-After) | yes | | 423, 424, 5xx | unavailable | yes | | 3xx | invalid_response | no | | any other 4xx (402 and 409 included) | invalid_request | no |

A provider that gives 402 or 409 a meaning (an empty balance, an already-processed payment) says so in mapError.

Writes that may have happened

A write that fails with a timeout, a dropped connection or a 5xx may already have been applied. mutating defaults to true for every method but GET, so a write that forgets the flag is treated as the dangerous kind; a POST that only reads (a rate quote, a search) says mutating: false. For a mutating call:

  • no idempotency key: any failure that is not a 429 is reported retryable: false, whatever built the error (the client or your mapError), with ambiguousNote appended ("The operation may already have been applied: check the provider before repeating it"). Reconcile first.
  • with an idempotency key: the provider dedupes a repeat, so the failure stays retryable.
  • a 429 is always retryable: it was refused before it did anything.
  • a call that never reached the network (the caller's signal was already aborted) says nothing about the outcome.

Retries

Off by default. retry: { attempts, backoff?, maxDelayMs?, sleep? } retries only a GET or a write that carries an idempotency key, and only an error that is retryable. attempts counts the first try, is at most 10, and anything that is not a finite number is one try. The wait is the Retry-After the provider sent, else backoff(attempt) (250 ms doubling to 4 s); a wait longer than maxDelayMs (10 s) is not waited for, and the error is thrown instead. The caller's abort stops it.

errors

defineErrorFamily<ExtraCodes>(name, retryableCodes) builds a ProviderError subclass with code, retryable, status?, providerCode?, retryAfterMs?, issues and cause. The shared codes:

timeout, unavailable, rate_limited, auth_failed, configuration_invalid, invalid_request, invalid_response, not_found, unsupported_operation, webhook_signature_invalid, webhook_expired, validation_failed.

retryable says whether the same call may succeed if repeated unchanged. It never means "repeat a purchase that might already have happened".

Everything else

| Module | What | |---|---| | body | readCapped(stream, max) (a body read with a ceiling: it stops at the first chunk that goes past the cap and cancels the stream, whatever the body declared) and BodyTooLargeError | | json | isPlainObject, readString(body, ...path), str (non-empty string), idStr (string or finite number as text), num, errorMessageOf(body) (Stripe, Envia, Mercado Pago and queries shapes), deepMerge (drops __proto__, constructor, prototype) | | time | isoFromZoned (a timestamp that states its zone, as ISO UTC; refuses one that does not, because Date.parse would read it in the machine's zone), zonedMs, hasZone, isoFromSeconds, DAY_MS and friends | | idempotency | assertIdempotencyKey(key, { maxLength?, ErrorClass? }): present, at most 255 characters, printable ASCII (it travels as a header) | | validation | ValidationIssue, ValidationResult, ok, fail, validationError(what, issues, ErrorClass?) | | retry | expBackoff(attempt, { baseMs, capMs, jitter?, random? }) (min(baseMs * 2 ** attempt, capMs), zero-based, optionally with full jitter), sleep(ms, signal?) | | deadline | fetchWithTimeout(input, init, { timeoutMs, fetch? }), withDeadline(work, ms, expired), abortAfter(ms, { signal?, reason? }): see below |

Deadlines

createHttpClient is the client for a provider's JSON API. For everything else that must not wait for ever there are three small functions in @vibedout/provider-kit/deadline:

import { abortAfter, fetchWithTimeout, withDeadline } from "@vibedout/provider-kit/deadline";

// one HTTP call that gives up: rejects with a TimeoutError DOMException (name "TimeoutError"), aborts the request, and covers the body too
const response = await fetchWithTimeout(url, { method: "POST", body, signal: callerSignal }, { timeoutMs: 5_000, fetch: deps.fetch });

// any promise: the caller says what to reject with (and may clean up in the same function)
const menu = await withDeadline(binding.getMenu(args), 1_500, () => new Error("menu service: no answer in 1500 ms"));

// a signal for a call that takes one; `done()` in a finally
const deadline = abortAfter(30_000, { signal: outer, reason: () => new Error("timeout") });
try { await model.run(input, { signal: deadline.signal }); } finally { deadline.done(); }

They are a leaf (no imports, only AbortController and setTimeout), so a package or template that cannot depend on this one carries a generated copy of the file. They do not use AbortSignal.timeout: it is missing from phones older than 2022, a fetch that ignores its signal would still hang, and a test cannot move it with fake timers. fetchWithTimeout rejects at the deadline even then.

testing

@vibedout/provider-kit/testing is for tests only and is not on the main barrel.

import { recordedFetch, jsonResponse, hangUntilAborted } from "@vibedout/provider-kit/testing";

const { fetch, calls } = recordedFetch([
  jsonResponse({ id: "pi_1" }),                     // answers in order
  { status: 503, body: { message: "down" } },       // or describes a response
  new TypeError("fetch failed"),                    // or throws as a network failure
  hangUntilAborted,                                 // or never answers: for timeout and abort tests
]);
const http = createHttpClient({ provider: "x", baseUrl, fetch, secrets: [] });
// calls[0] is { url, method, headers (lower-cased), body, json, form, signal }

A call nobody expected throws, naming it. A function fixture decides per call.

Testing this package

pnpm test (vitest). The status mapping is checked for every status from 100 to 599, the request and the redaction against a recorded fetch, and the small helpers (JSON readers, time, idempotency keys, form encoding, deadlines) against fixed expected values.