Skip to content

API Overview ​

SymbolPurposeExecution modeCommon gotcha
createCourier()Creates transport clientSyncDispose only when whole scope ends
request()Sends one HTTP request of any methodAsyncDirect calls never deduplicate
get()Sends a GET, optionally through the explicit cacheAsyncOne key must identify one representation
prefetch()Warms a cached GETAsyncRequest failures resolve after surfacing through tap()
invalidateCache() / clearCache()Removes cached valuesSyncInvalidation matches structured key prefixes
post() / put() / patch() / delete()Common write method conveniencesAsyncUse request() for custom methods
withBearerAuth()Adds authorization middlewareSyncToken provider runs per request
withRequestId()Adds request identifier middlewareSyncDefault generator uses crypto.randomUUID()
withLogging()Logs request result metadataSyncRequires explicit logger; URLs may contain sensitive query values
tap()Observe structured transport eventsSyncObservation only; optional signal owns lifetime
CourierHttpError.problemParsed RFC 9457 problem details from an error bodySyncPresent only when the response is application/problem+json
ProblemDetailRFC 9457 problem members and extension membersSyncExtension values are unknown; Courier does not validate their shapes
problemDetailError()Build an HTTP error that carries problem detailsSyncIntended for mocks and middleware, not for parsing a response

Package Entry Point ​

ImportPurpose
@vielzeug/courierClient factory, errors, middleware helpers, and public types

Client ​

createCourier() ​

ts
createCourier(options?: CourierOptions): Courier;

Returns an HTTP client sharing base URL, default headers, immutable middleware, optional parsed-value cache, timeout, and cancellation lifecycle.

CourierOptions fieldTypeDefaultDescription
baseUrlstring''Prefix for relative request paths
cache.capacitynumber100Maximum cached entries; positive integer or Infinity
cache.ttlMsnumber30_000Default cache freshness; non-negative milliseconds or Infinity
fetchtypeof globalThis.fetchglobalThis.fetchFetch implementation
headersHeadersInit{}Default request headers (immutable; merged lowercase)
middlewarereadonly Middleware[][]Immutable middleware chain configured at construction
timeoutnumber30_000Integer milliseconds from 1 to 2,147,483,647, or Infinity

Returns: Courier.

ts
import { createCourier, withBearerAuth } from '@vielzeug/courier';

const courier = createCourier({
  baseUrl: 'https://api.example.com',
  middleware: [withBearerAuth('token')],
});
Courier memberSignatureDescription
request<T, P>(path, config?) => Promise<T>Sends one HTTP request; config.method defaults to GET
get<T, P>(path, config?) => Promise<T>Sends GET; cache opts parsed responses into bounded caching
prefetch<T, P>(path, config) => Promise<void>Warms one required structured cache key; failures are tap-observed
invalidateCache(prefix: CourierCacheKey) => voidInvalidates matching settled and pending key prefixes
clearCache() => voidRemoves every cached and pending entry
post<T, P>(path, config?) => Promise<T>Sends POST
put<T, P>(path, config?) => Promise<T>Sends PUT
patch<T, P>(path, config?) => Promise<T>Sends PATCH
delete<T, P>(path, config?) => Promise<T>Sends DELETE
tap(handler, options?: { signal?: AbortSignal }) => () => voidObserves structured request and disposal events
cancelAll() => voidAborts active requests; client remains usable
dispose() => voidFinal teardown; aborts work and marks client unusable
disposedbooleanWhether final disposal occurred
disposalSignalAbortSignalAborts on final disposal

Requests ​

request() ​

ts
request<T, P extends string>(path: P, config?: RequestConfig<P, T> & { method?: string }): Promise<T>;

Sends one HTTP request. config.method defaults to GET; pass any method (including custom verbs) to support it consistently.

RequestConfig<P, T> fieldTypeDescription
methodstringHTTP method; defaults to GET
paramsRecord<string, string | number | boolean>Path parameters for {param} placeholders
queryParamsQuery string parameters
bodyunknownBodyInit values pass through; streams use Node duplex; GET/HEAD reject bodies; other values encode as JSON
headersHeadersInitPer-request headers merged with (and overriding) defaults
responseTypeResponseTypeResponse parsing strategy
schema{ parse(data: unknown): T }Optional parsed-response validator; incompatible with responseType: 'raw'
signalAbortSignalExternal abort signal merged with internal cancellation
timeoutnumberInteger milliseconds from 1 to 2,147,483,647, or Infinity; overrides default
fetchInitOmit<RequestInit, 'body' | 'headers' | 'method' | 'signal'>Raw fetch options for advanced use

Returns: Parsed response body (or undefined for empty bodies). With responseType: 'raw' the resolved value is the Response itself; a 204/205 answer whose transport carried an unexpected empty body stream is normalized to body: null — the stray stream is cancelled and headers, status, statusText, URL, redirect and type metadata are preserved (on the response and its clones). A null-body raw response owns no pending resource, so no consumption is required to release the request.

ts
const created = await courier.request<User>('/users', {
  method: 'POST',
  body: { name: 'Ada' },
});

get() ​

ts
get<T, P extends string>(path: P, config?: GetRequestConfig<P, T>): Promise<T>;

Convenience for request(path, { ...config, method: 'GET' }). A cache descriptor opts a parsed response into the bounded cache. It requires a structured key and may override the default TTL. Raw responses cannot be cached.

ts
const user = await courier.get<User>('/users/{id}', {
  cache: { key: ['users', 1], ttlMs: 10_000 },
  params: { id: 1 },
});

Cache ​

prefetch() ​

ts
prefetch<T, P extends string>(path: P, config: PrefetchConfig<P, T>): Promise<void>;

Starts the same cached GET used by get(). config.cache is required; signal, per-call timeout, and responseType: 'raw' are unavailable. Fresh values and pending loads are reused. The promise settles when the attempt finishes. Request failures emit request-error through tap() and resolve without caching data.

invalidateCache() and clearCache() ​

ts
invalidateCache(prefix: CourierCacheKey): void;
clearCache(): void;

invalidateCache() removes every settled entry whose structured key starts with prefix and prevents matching pending loads from being stored. clearCache() applies that behavior to all entries. Existing waiters may still receive an invalidated in-flight result; cancelAll() or dispose() aborts physical work.


Middleware ​

Middleware is an immutable chain configured at construction. Each middleware receives an immutable FetchContext and a next function.

ts
type Middleware = (ctx: FetchContext, next: (ctx: FetchContext) => Promise<Response>) => Promise<Response>;

Middleware helpers ​

ts
withBearerAuth(token: string | (() => string | null | undefined | Promise<string | null | undefined>)): Middleware;
withRequestId(options?: { generate?: () => string; header?: string }): Middleware;
withLogging(options: {
  logger: (message: string, meta: { duration: number; method: string; status: number; url: string }) => void;
}): Middleware;

Each helper returns a Middleware passed in the middleware option. withLogging requires an explicit logger function, isolates logger failures from requests, and has no default console output.

ts
const courier = createCourier({
  middleware: [
    withBearerAuth(() => tokenStore.getAccessToken()),
    withRequestId(),
    withLogging({ logger: (msg) => console.log(msg) }),
  ],
});

Types ​

ts
type TransportOptions = {
  baseUrl?: string;
  fetch?: typeof globalThis.fetch;
  headers?: HeadersInit;
  middleware?: readonly Middleware[];
  timeout?: number;
};

type CourierCacheKeyAtom = string | number | boolean | null;
type CourierCacheKey = readonly [CourierCacheKeyAtom, ...CourierCacheKeyAtom[]];

type CourierCacheOptions = {
  capacity?: number;
  ttlMs?: number;
};

type CourierReadCache = {
  key: CourierCacheKey;
  ttlMs?: number;
};

type CourierOptions = TransportOptions & {
  cache?: CourierCacheOptions;
};

type FetchContext = {
  readonly headers: Readonly<Record<string, string>>;
  readonly init: Readonly<Omit<RequestInit, 'headers'>>;
  readonly url: string;
  withHeaders(updates: Record<string, string | undefined>): FetchContext;
  withInit(updates: Partial<Omit<RequestInit, 'body' | 'headers' | 'method' | 'signal'>>): FetchContext;
};

type Middleware = (ctx: FetchContext, next: (ctx: FetchContext) => Promise<Response>) => Promise<Response>;

type CourierEvent =
  | { type: 'request-start'; method: string; url: string }
  | { type: 'request-success'; method: string; url: string; status: number; duration: number }
  | { type: 'request-error'; method: string; url: string; error: unknown }
  | { type: 'dispose' };
ts
type ParamValue = string | number | boolean | null | readonly (string | number | boolean | null)[] | undefined;
type Params = Record<string, ParamValue>;
type ResponseType = 'auto' | 'json' | 'text' | 'blob' | 'arrayBuffer' | 'raw';
type RequestConfig<P extends string = string, T = unknown> = {
  body?: unknown;
  method?: string;
  fetchInit?: Omit<RequestInit, 'body' | 'headers' | 'method' | 'signal'>;
  headers?: HeadersInit;
  params?: Record<string, string | number | boolean>;
  query?: Params;
  responseType?: ResponseType;
  schema?: { parse(data: unknown): T };
  signal?: AbortSignal;
  timeout?: number;
};

type GetRequestConfig<P extends string = string, T = unknown> = Omit<
  RequestConfig<P, T>,
  'body' | 'method' | 'responseType'
> &
  ({ cache?: CourierReadCache; responseType?: Exclude<ResponseType, 'raw'> } | { cache?: never; responseType: 'raw' }) & {
    body?: never;
  };

type PrefetchConfig<P extends string = string, T = unknown> = Omit<
  GetRequestConfig<P, T>,
  'cache' | 'responseType' | 'signal' | 'timeout'
> & {
  cache: CourierReadCache;
  responseType?: Exclude<ResponseType, 'raw'>;
  signal?: never;
  timeout?: never;
};

Errors ​

ErrorTriggerNotable properties
CourierErrorBase class for all Courier errorsUse instanceof to narrow
CourierHttpErrorNon-2xx HTTP responsestatus, data, headers, method, url, problem; CourierHttpError.is(e, status?) narrows by status
CourierNetworkErrorRequest failure without responsemethod, url, cause
CourierTimeoutErrorTimeout signal aborts requestmethod, url, cause
CourierAbortErrorCaller or client cancellationmethod, url, cause
CourierSchemaValidationErrorResponse schema failsdata, cause
CourierParseErrorResponse body cannot parseN/A
CourierConfigErrorInvalid request config (GET/HEAD body, raw + schema)N/A
CourierDisposedErrorWork starts after disposalN/A

Errors thrown directly by custom middleware are preserved rather than reclassified.

Problem details ​

ts
export type ProblemDetail = Readonly<{
  type?: string;
  status?: number;
  title?: string;
  detail?: string;
  instance?: string;
  [extension: string]: unknown;
}>;

export const PROBLEM_JSON_MEDIA_TYPE = 'application/problem+json';

export function isProblemMediaType(contentType: string | null | undefined): boolean;

export function problemDetailError(
  problem: ProblemDetail,
  options?: { message?: string; method?: string; status?: number; url?: string },
): CourierHttpError;

When a non-2xx response answers with application/problem+json (RFC 9457), CourierHttpError.problem holds a shallow-frozen copy of the parsed JSON object and the error message prefers detail, then title, then the response status text (or HTTP <status> when status text is empty). Any other error body keeps the shape it already had on data, with problem left undefined. Courier checks that the parsed body is an object; it does not validate the types or contents of registered or extension members. RFC 9457 treats an omitted type as about:blank, but Courier does not insert that value into the object.

MemberPurposeCommon gotcha
ProblemDetailThe five registered RFC 9457 members plus extensionsEvery member is optional; extension values have type unknown
CourierHttpError.problemParsed problem documentFor response errors, it is a frozen shallow copy and is undefined unless the media type matches and the body is an object
PROBLEM_JSON_MEDIA_TYPERegistered media type constantCompare with isProblemMediaType(), not ===, to allow parameters
isProblemMediaType()Match application/problem+json and its parameterised formsPlain application/json never matches
problemDetailError()Build a problem-carrying CourierHttpError without a serverStatus defaults to numeric problem.status, then 500; a supplied options.status takes precedence
ts
problemDetailError(problem, options?)

problemDetailError() returns a CourierHttpError with the supplied problem object on both error.problem and error.data. Its defaults are method: 'GET', url: '', and status from problem.status when numeric or 500 otherwise. The error message uses options.message, then problem.detail, problem.title, and finally HTTP <status>. Unlike a parsed response problem, the supplied object is not copied or frozen.

The HTTP status line stays authoritative: error.status is the response status even when problem.status disagrees. A JSON array or primitive leaves data as that parsed value; malformed JSON leaves data as its raw text. Neither produces a partial problem.

ts
import { createCourier, CourierHttpError, problemDetailError, type ProblemDetail } from '@vielzeug/courier';

const courier = createCourier({ baseUrl: '/api' });

try {
  await courier.post('/invoices');
} catch (error) {
  if (CourierHttpError.is(error)) {
    const problem: ProblemDetail | undefined = error.problem;
    console.log(error.status, problem?.title, problem?.detail, problem?.type);
  }
}

const mockedError = problemDetailError(
  { title: 'Insufficient credit', detail: 'Choose a lower amount.', status: 402 },
  { method: 'POST', url: '/invoices' },
);
console.log(mockedError.status, mockedError.problem?.detail);