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

@lindorm/conduit

v0.6.3

Published

Middleware-based HTTP client built on Axios with retries, circuit breaking, rate limiting, request deduplication, response caching, OAuth2 client credentials, DPoP, and Zod schema validation.

Readme

@lindorm/conduit

Middleware-based HTTP client built on Axios with retries, circuit breaking, rate limiting, request deduplication, response caching, OAuth2 client credentials, DPoP, and Zod schema validation.

This package is ESM-only. All examples use import; require is not supported.

Installation

npm install @lindorm/conduit

@lindorm/logger is an optional peer dependency — it only needs to be installed if you pass a logger to the Conduit constructor.

Quick Start

import { Conduit } from "@lindorm/conduit";

const client = new Conduit({ baseURL: "https://api.example.com" });

const { data } = await client.get<Array<User>>("/users");

const { data: created } = await client.post<User>("/users", {
  body: { name: "Jane", email: "[email protected]" },
});

const { data: user } = await client.get<User>("/users/:id/posts", {
  params: { id: "123" },
  query: { limit: 10, offset: 0 },
});
// resolves to /users/123/posts?limit=10&offset=0

Constructor Options

import { Conduit } from "@lindorm/conduit";

const client = new Conduit({
  adapter: "http",
  alias: "MyAPI",
  baseURL: "https://api.example.com",
  config: {},
  environment: "production",
  headers: { "X-Client": "v1" },
  logger,
  middleware: [],
  retryCallback: (err, attempt, config) => attempt < config.maxAttempts,
  retryOptions: {
    maxAttempts: 5,
    strategy: "exponential",
    timeout: 250,
    timeoutMax: 10000,
  },
  timeout: 30000,
  withCredentials: false,
});

| Option | Type | Default | Description | | ----------------- | -------------------------------- | --------------- | -------------------------------------------------------------------------------------------- | | adapter | "http" \| "fetch" | "http" | Axios adapter. "http" uses Node http/https; "fetch" uses native fetch / undici. | | alias | string | null | Human-readable name used in log entries. | | baseURL | URL \| string | undefined | Base URL prepended to every request path. | | config | RawAxiosRequestConfig (subset) | {} | Native Axios config pass-through (excluding fields Conduit owns: method, url, headers, etc). | | environment | Environment | null | Sent as the X-Environment request header. | | headers | Dict<string> | {} | Default headers merged into every request. | | logger | ILogger | undefined | When set, request and response logging middleware are added automatically. | | middleware | Array<ConduitMiddleware> | [] | Instance-wide middleware pipeline. | | retryCallback | RetryCallback | network + 5xx* | Predicate deciding whether a failed request is retried. | | retryOptions | RetryOptions | see below | Retry config from @lindorm/retry. | | timeout | number | 30000 | Per-request timeout in milliseconds. | | withCredentials | boolean | undefined | Whether to send credentials with cross-origin requests. |

* The default predicate retries on network errors and HTTP 502, 503, 504.

Request Options

Every HTTP method (get, post, put, patch, delete, head, options) accepts a path/URL and an optional options object:

const { data, status, headers } = await client.get<ResponseType>("/path", {
  adapter: "fetch",
  body: { key: "value" },
  config: {},
  expectedResponse: "json",
  filename: "upload.zip",
  form: formData,
  headers: { "X-Custom": "value" },
  middleware: [myMiddleware],
  onDownloadProgress: ({ loaded, total }) => {},
  onRetry: (err, attempt, config) => {},
  onUploadProgress: ({ loaded, total }) => {},
  params: { id: "123" },
  query: { search: "foo" },
  retryCallback: (err, attempt, config) => false,
  retryOptions: { maxAttempts: 3 },
  signal: abortController.signal,
  stream: readableStream,
  timeout: 5000,
  withCredentials: true,
});

expectedResponse accepts "arraybuffer" | "blob" | "document" | "formdata" | "json" | "stream" | "text".

The generic request() method takes a single combined options object:

const result = await client.request<Data>({
  method: "POST",
  path: "/items",
  body: { name: "item" },
});

const result2 = await client.request<Data>({
  method: "GET",
  url: "https://other-api.com/items",
});

request throws if neither path nor url is provided.

Response Shape

All methods return a ConduitResponse<D>:

type ConduitResponse<D> = {
  data: D;
  status: number;
  statusText: string;
  headers: Dict<Header>;
};

Default Headers

Every request automatically sets:

  • Date — current timestamp (toUTCString)
  • X-Correlation-Id — random UUID per request (override with conduitCorrelationMiddleware)
  • X-Request-Id — random UUID per request
  • X-Environment — only when environment is configured on the constructor

Middleware

Conduit uses a Koa-style middleware pipeline. Each middleware receives (ctx, next) and may modify the request before next() and/or the response after next().

The execution order is: response logger (if logger is set) → default headers → instance middleware → per-request middleware → request logger (if logger is set) → terminal Axios handler.

Writing custom middleware

import type { ConduitMiddleware } from "@lindorm/conduit";

const timingMiddleware: ConduitMiddleware = async (ctx, next) => {
  const start = Date.now();
  ctx.req.headers["X-Request-Start"] = String(start);

  await next();

  const elapsed = Date.now() - start;
  ctx.logger?.debug("Request finished", {
    method: ctx.req.config.method,
    url: ctx.req.url,
    elapsed,
  });
};

Authentication

import {
  conduitBasicAuthMiddleware,
  conduitBearerAuthMiddleware,
} from "@lindorm/conduit";

// Authorization: Basic <base64(user:pass)>
const basic = conduitBasicAuthMiddleware("username", "password");

// Authorization: Bearer <token>
const bearer = conduitBearerAuthMiddleware("my-access-token");

// Authorization: <type> <token>
const dpopBearer = conduitBearerAuthMiddleware("my-token", "DPoP");

DPoP (RFC 9449)

createConduitDpopAuthMiddleware is a curried factory. The outer call binds a long-lived signer; the inner call binds a per-request access token. Each request signs a fresh DPoP proof JWT.

import { createConduitDpopAuthMiddleware } from "@lindorm/conduit";

const dpopAuth = createConduitDpopAuthMiddleware(signer);

await client.get("/orders", {
  middleware: [dpopAuth(accessToken)],
});

await client.get("/orders", {
  middleware: [dpopAuth(accessToken, { nonce: serverIssuedNonce })],
});

A DPoP signer needs an algorithm (a JwksAlgorithm), the public JWK, and a sign(data: Uint8Array) => Promise<Uint8Array> function. The webCryptoToDpopSigner helper builds one from a Web Crypto CryptoKeyPair:

import { webCryptoToDpopSigner } from "@lindorm/conduit";

const keyPair = await crypto.subtle.generateKey(
  { name: "ECDSA", namedCurve: "P-256" },
  false,
  ["sign", "verify"],
);

const signer = await webCryptoToDpopSigner(keyPair);

Supported algorithms: ES256 / ES384 / ES512 (ECDSA P-256/384/521), RS256 / RS384 / RS512 (RSASSA-PKCS1-v1_5), and PS256 / PS384 / PS512 (RSA-PSS).

OAuth2 Client Credentials

conduitClientCredentialsMiddlewareFactory returns an async factory that performs OIDC discovery (unless tokenUri is supplied), fetches and caches access tokens, deduplicates concurrent token requests for the same (audience, issuer), and emits a per-request middleware that attaches the token via Bearer auth (or DPoP, when dpopSigner is supplied).

import { conduitClientCredentialsMiddlewareFactory } from "@lindorm/conduit";

const getAuthMiddleware = conduitClientCredentialsMiddlewareFactory({
  authLocation: "body",
  clientId: "my-client-id",
  clientSecret: "my-client-secret",
  clockTolerance: 10,
  contentType: "application/json",
  defaultExpiration: 3600,
  issuer: "https://auth.example.com",
  tokenUri: "https://auth.example.com/oauth/token",
});

const client = new Conduit({
  baseURL: "https://api.example.com",
  middleware: [
    await getAuthMiddleware(
      { audience: "https://api.example.com", scope: ["read", "write"] },
      logger,
    ),
  ],
});

Factory configuration:

| Option | Type | Default | Description | | ------------------- | ----------------------------------------------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------- | | authLocation | "body" \| "header" | "body" | "body" puts client_id/client_secret in the body. "header" uses HTTP Basic. | | clientId | string | required | OAuth2 client identifier. | | clientSecret | string | required | OAuth2 client secret. | | clockTolerance | number | 10 | Seconds subtracted from the token TTL when caching, to refresh before expiry. | | contentType | "application/json" \| "application/x-www-form-urlencoded" | "application/json" | Token request body encoding. | | defaultExpiration | number | undefined | Fallback TTL in seconds when the token response provides neither exp nor expires_in. | | dpopSigner | DpopSigner | undefined | When set, the token request carries a DPoP proof and the issued token is bound via Authorization: DPoP <token>. | | grantType | "client_credentials" | "client_credentials" | Grant type sent to the token endpoint. | | issuer | string | required | OIDC issuer URL. Used to discover the token endpoint when tokenUri is not given. | | tokenUri | string | undefined | Skip OIDC discovery and POST directly to this URL. |

The factory takes an optional second argument cache: Array<CacheItem> — pass your own array to share a token cache between factories.

The returned function signature is (options?: { audience?: string; scope?: Array<string> }, logger?: ILogger) => Promise<ConduitMiddleware>.

Case Conversion

import {
  conduitChangeRequestBodyMiddleware,
  conduitChangeRequestHeadersMiddleware,
  conduitChangeRequestQueryMiddleware,
  conduitChangeResponseDataMiddleware,
} from "@lindorm/conduit";

const client = new Conduit({
  baseURL: "https://api.example.com",
  middleware: [
    conduitChangeRequestBodyMiddleware("snake"),
    conduitChangeRequestQueryMiddleware("snake"),
    conduitChangeRequestHeadersMiddleware("header"),
    conduitChangeResponseDataMiddleware("camel"),
  ],
});

Modes are any ChangeCase value from @lindorm/case: "camel" | "capital" | "constant" | "dot" | "header" | "kebab" | "lower" | "pascal" | "path" | "sentence" | "snake" | "none". Defaults: body → snake, query → snake, headers → header, response data → camel.

Response Caching

In-memory cache for GET requests with 2xx status. The cache key is method + URL + JSON-serialised query.

import { createConduitCacheMiddleware } from "@lindorm/conduit";

const cacheMiddleware = createConduitCacheMiddleware({
  maxAge: 300_000,
  maxEntries: 1000,
});

| Option | Default | Description | | ------------ | -------- | ------------------------------------------------- | | maxAge | 300000 | TTL in milliseconds. | | maxEntries | 1000 | Cap on cached responses; oldest is evicted first. |

Responses with Cache-Control: no-cache or no-store are not cached. Cached entries are returned as shallow copies.

Request Deduplication

Coalesces concurrent identical GET and HEAD requests into a single in-flight request:

import { createConduitDeduplicationMiddleware } from "@lindorm/conduit";

const client = new Conduit({
  baseURL: "https://api.example.com",
  middleware: [createConduitDeduplicationMiddleware()],
});

const [a, b] = await Promise.all([
  client.get("/expensive-data"),
  client.get("/expensive-data"),
]);
// only one HTTP request fires; both promises resolve with the same response

Rate Limiting

Token-bucket rate limiter. Throws a TooManyRequestsError (from @lindorm/errors) when the bucket is empty.

import { createConduitRateLimitMiddleware } from "@lindorm/conduit";

const rateLimit = createConduitRateLimitMiddleware({
  maxRequests: 100,
  windowMs: 60_000,
  perOrigin: true,
});

| Option | Default | Description | | ------------- | ------- | ------------------------------------------------------------------------- | | maxRequests | 100 | Bucket capacity (and refill target across one window). | | windowMs | 60000 | Refill window in milliseconds. Tokens refill continuously, not in bursts. | | perOrigin | true | Use a separate bucket per origin. When false, a single global bucket. |

Circuit Breaker

Per-origin circuit breaker built on @lindorm/breaker. The breaker name is conduit:<origin>.

import { createConduitCircuitBreakerMiddleware } from "@lindorm/conduit";

const breaker = createConduitCircuitBreakerMiddleware(
  {
    threshold: 5,
    window: 60_000,
    halfOpenDelay: 30_000,
    halfOpenBackoff: 2,
    halfOpenMaxDelay: 600_000,
  },
  logger,
);

| Option | Type | Description | | ------------------ | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | threshold | number | Number of transient failures within window before the breaker opens. | | window | number | Sliding failure window in milliseconds. | | halfOpenDelay | number | Initial delay before the breaker probes (transitions to half-open). | | halfOpenBackoff | number | Multiplier applied to the half-open delay on repeated probe failures. | | halfOpenMaxDelay | number | Upper bound on the half-open delay. | | classifier | (error: Error) => "transient" \| "permanent" \| "ignorable" | Custom error classifier. The default treats LindormError instances as: permanent for status 501/505/506/510/511, transient for any other ServerError, ignorable for everything else. |

Defaults are inherited from @lindorm/breaker. The signature also accepts a third argument cache: Map<string, ICircuitBreaker> for sharing breaker state between middleware instances. When a logger is supplied, the middleware logs open / half-open / closed state changes. When the breaker is open, requests reject with a ServiceUnavailableError (from @lindorm/errors) whose message is "Circuit breaker is open".

Schema Validation

Validate response data against a Zod schema:

import { conduitSchemaMiddleware } from "@lindorm/conduit";
import { z } from "zod";

const userSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.email(),
});

const client = new Conduit({
  baseURL: "https://api.example.com",
  middleware: [conduitSchemaMiddleware(userSchema)],
});

const { data } = await client.get("/user/123");
// throws BadGatewayError if data does not match userSchema

Accepts ZodObject (object schemas are parsed in loose mode, preserving extra keys) or ZodArray. Validation failures are wrapped in BadGatewayError (from @lindorm/errors) — the upstream returned data that did not match the contract.

Headers

import { conduitHeaderMiddleware, conduitHeadersMiddleware } from "@lindorm/conduit";

const versionHeader = conduitHeaderMiddleware("X-API-Version", "v2");

const headers = conduitHeadersMiddleware({
  "X-API-Version": "v2",
  "X-Client-ID": "my-app",
});

Correlation and Session Tracking

import { conduitCorrelationMiddleware, conduitSessionMiddleware } from "@lindorm/conduit";

const correlation = conduitCorrelationMiddleware("correlation-id-123");
const session = conduitSessionMiddleware("session-id-456");

conduitCorrelationMiddleware overrides ctx.req.metadata.correlationId (which then becomes the X-Correlation-Id request header). conduitSessionMiddleware sets ctx.req.metadata.sessionId, which the request logger picks up.

Retry

By default Conduit retries up to 5 times with exponential backoff (250 ms base, 10 s cap) on network errors and HTTP 502, 503, 504. Override per instance or per request:

import { ClientError } from "@lindorm/errors";

const client = new Conduit({
  baseURL: "https://api.example.com",
  retryOptions: {
    maxAttempts: 3,
    strategy: "exponential",
    timeout: 500,
    timeoutMax: 15_000,
  },
  retryCallback: (error, attempt, config) => {
    if (error instanceof ClientError) return false;
    return attempt <= config.maxAttempts;
  },
});

await client.get("/flaky-endpoint", {
  onRetry: (error, attempt, config) => {
    console.error(`Retry ${attempt}/${config.maxAttempts}: ${error.message}`);
  },
});

strategy is "exponential" | "linear" (re-exported from @lindorm/retry as RetryStrategy). Aborted requests stop retrying.

Abort / Cancellation

const controller = new AbortController();

setTimeout(() => controller.abort(), 5000);

const { data } = await client.get("/slow-endpoint", {
  signal: controller.signal,
});

File Uploads

const form = new FormData();
form.append("file", blob, "document.pdf");
form.append("description", "Important document");

await client.post("/upload", { form });

import { createReadStream } from "node:fs";

await client.post("/upload", {
  stream: createReadStream("large-file.zip"),
  filename: "large-file.zip",
});

Stream uploads require the "http" adapter.

Streaming and Progress

const { data: stream } = await client.get("/large-file", {
  expectedResponse: "stream",
});

await client.get("/large-file", {
  onDownloadProgress: ({ loaded, total }) => {
    console.log(`Downloaded ${loaded}/${total ?? "unknown"} bytes`);
  },
});

await client.post("/upload", {
  form: formData,
  onUploadProgress: ({ loaded, total }) => {
    console.log(`Uploaded ${loaded}/${total ?? "unknown"} bytes`);
  },
});

Error Handling

Failed requests are reconstructed into the appropriate class from @lindorm/errors so callers can branch on instanceof rather than inspecting status codes:

import {
  ClientError,
  LindormError,
  NetworkError,
  NotFoundError,
  ServerError,
} from "@lindorm/errors";

try {
  await client.get("/users/123");
} catch (error) {
  if (error instanceof NetworkError) {
    // DNS failure, connection refused, no response received
  } else if (error instanceof NotFoundError) {
    // 404 specifically — the registry resolved this from status or class name
  } else if (error instanceof ClientError) {
    // any other 4xx
  } else if (error instanceof ServerError) {
    // any 5xx
  } else if (error instanceof LindormError) {
    // anything else thrown through Conduit
  }
}

How errors are reconstructed

When an Axios error reaches Conduit, reconstructFromAxiosError extracts the status, message, and any Pylon error envelope from the response body, then calls errorRegistry.reconstruct(...) from @lindorm/errors. The registry resolves the right class in this order:

  1. By name — if the response carries a Pylon envelope with error.name, the registry returns the registered class with that name (e.g. NotFoundError, or any custom subclass the consumer registered with errorRegistry.register(...)).
  2. By status — if no class is registered under the envelope's name, the registry falls back to the class registered for the exact status code (e.g. 404NotFoundError).
  3. By status range — if no exact-status match exists, falls back to ClientError for 4xx or ServerError for 5xx.
  4. LindormError — final fallback when nothing else matches.

When the request fails before any response (network failure, timeout, DNS), NetworkError is thrown instead. Its status is -1.

Inspecting transport metadata

config, request, and response snapshots from the underlying Axios error are stashed on the reconstructed error under debug.transport:

type Transport = {
  config?: { method?: string; url?: string; headers?: Dict; /* ... */ };
  request?: { method?: string; path?: string; /* ... */ };
  response?: { status?: number; statusText?: string; data?: unknown; headers?: Dict };
};

catch (error) {
  if (error instanceof LindormError) {
    const transport = error.debug?.transport as Transport | undefined;
    transport?.response?.status;
    transport?.response?.headers;
  }
}

Reach for debug.transport only when you need wire-level details — the high-level error.status, error.message, error.code, error.data, error.support, error.title already carry the application-meaningful fields lifted from the Pylon envelope.

Throwing custom error subclasses

Servers throwing custom LindormError subclasses (e.g. class UserSuspendedError extends ForbiddenError) can have those classes round-trip through Conduit by registering them on both ends:

import { errorRegistry, ForbiddenError } from "@lindorm/errors";

export class UserSuspendedError extends ForbiddenError {
  public constructor(message: string, options = {}) {
    super(message, { code: "USER_SUSPENDED", ...options });
  }
}

errorRegistry.register(UserSuspendedError);

When a Pylon server throws UserSuspendedError and Conduit deserializes the response, name-based resolution returns the same class, and error instanceof UserSuspendedError is true on the client.

Public Exports

Classes

  • Conduit — HTTP client.

Interfaces

  • IConduit — public surface implemented by Conduit.

Middleware

  • conduitBasicAuthMiddleware(username, password)
  • conduitBearerAuthMiddleware(accessToken, tokenType?)
  • conduitChangeRequestBodyMiddleware(mode?)
  • conduitChangeRequestHeadersMiddleware(mode?)
  • conduitChangeRequestQueryMiddleware(mode?)
  • conduitChangeResponseDataMiddleware(mode?)
  • conduitClientCredentialsMiddlewareFactory(config, cache?)
  • conduitCorrelationMiddleware(correlationId)
  • conduitHeaderMiddleware(name, value)
  • conduitHeadersMiddleware(headers)
  • conduitSchemaMiddleware(schema)
  • conduitSessionMiddleware(sessionId)
  • createConduitCacheMiddleware(config?)
  • createConduitCircuitBreakerMiddleware(config?, logger?, cache?)
  • createConduitDeduplicationMiddleware()
  • createConduitDpopAuthMiddleware(signer)
  • createConduitRateLimitMiddleware(config?)

Utilities

  • webCryptoToDpopSigner(keyPair) — turn a Web Crypto CryptoKeyPair into a DpopSigner.

Types

AppContext, ConduitAdapter, ConduitCircuitBreakerCache, ConduitCircuitBreakerConfig, ConduitClientCredentialsCache, ConduitClientCredentialsMiddlewareFactory, ConduitContext, ConduitDpopAuthOptions, ConduitMiddleware, ConduitOptions, ConduitResponse, ConfigContext, ConfigOptions, ExpectedResponse, MethodOptions, OnRetryCallback, RequestContext, RequestMetadata, RequestOptions, RetryCallback, RetryStrategy (re-exported from @lindorm/retry).

Full Example

import {
  Conduit,
  conduitBearerAuthMiddleware,
  conduitChangeRequestBodyMiddleware,
  conduitChangeResponseDataMiddleware,
  createConduitCacheMiddleware,
  createConduitCircuitBreakerMiddleware,
  createConduitDeduplicationMiddleware,
  createConduitRateLimitMiddleware,
} from "@lindorm/conduit";

const client = new Conduit({
  alias: "ExampleAPI",
  baseURL: "https://api.example.com",
  headers: { "X-Client-Version": "1.0.0" },
  logger,
  middleware: [
    createConduitCircuitBreakerMiddleware({}, logger),
    createConduitRateLimitMiddleware({ maxRequests: 50 }),
    createConduitDeduplicationMiddleware(),
    createConduitCacheMiddleware({ maxAge: 60_000 }),
    conduitBearerAuthMiddleware(process.env.API_TOKEN!),
    conduitChangeRequestBodyMiddleware("snake"),
    conduitChangeResponseDataMiddleware("camel"),
  ],
  retryOptions: { maxAttempts: 3, strategy: "exponential" },
  timeout: 15_000,
});

const { data } = await client.get<Array<User>>("/v1/users", {
  query: { active: true },
});

License

AGPL-3.0-or-later