API Overview
| Symbol | Purpose | Execution mode | Common gotcha |
|---|---|---|---|
createCourier() | Creates transport client | Sync | Dispose only when whole scope ends |
request() | Sends one HTTP request of any method | Async | Direct calls never deduplicate |
get() | Sends a GET, optionally through the explicit cache | Async | One key must identify one representation |
prefetch() | Warms a cached GET | Async | Request failures resolve after surfacing through tap() |
invalidateCache() / clearCache() | Removes cached values | Sync | Invalidation matches structured key prefixes |
post() / put() / patch() / delete() | Common write method conveniences | Async | Use request() for custom methods |
withBearerAuth() | Adds authorization middleware | Sync | Token provider runs per request |
withRequestId() | Adds request identifier middleware | Sync | Default generator uses crypto.randomUUID() |
withLogging() | Logs request result metadata | Sync | Requires explicit logger; URLs may contain sensitive query values |
tap() | Observe structured transport events | Sync | Observation only; optional signal owns lifetime |
CourierHttpError.problem | Parsed RFC 9457 problem details from an error body | Sync | Present only when the response is application/problem+json |
ProblemDetail | RFC 9457 problem members and extension members | Sync | Extension values are unknown; Courier does not validate their shapes |
problemDetailError() | Build an HTTP error that carries problem details | Sync | Intended for mocks and middleware, not for parsing a response |
Package Entry Point
| Import | Purpose |
|---|---|
@vielzeug/courier | Client factory, errors, middleware helpers, and public types |
Client
createCourier()
createCourier(options?: CourierOptions): Courier;Returns an HTTP client sharing base URL, default headers, immutable middleware, optional parsed-value cache, timeout, and cancellation lifecycle.
CourierOptions field | Type | Default | Description |
|---|---|---|---|
baseUrl | string | '' | Prefix for relative request paths |
cache.capacity | number | 100 | Maximum cached entries; positive integer or Infinity |
cache.ttlMs | number | 30_000 | Default cache freshness; non-negative milliseconds or Infinity |
fetch | typeof globalThis.fetch | globalThis.fetch | Fetch implementation |
headers | HeadersInit | {} | Default request headers (immutable; merged lowercase) |
middleware | readonly Middleware[] | [] | Immutable middleware chain configured at construction |
timeout | number | 30_000 | Integer milliseconds from 1 to 2,147,483,647, or Infinity |
Returns: Courier.
import { createCourier, withBearerAuth } from '@vielzeug/courier';
const courier = createCourier({
baseUrl: 'https://api.example.com',
middleware: [withBearerAuth('token')],
});Courier member | Signature | Description |
|---|---|---|
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) => void | Invalidates matching settled and pending key prefixes |
clearCache | () => void | Removes 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 }) => () => void | Observes structured request and disposal events |
cancelAll | () => void | Aborts active requests; client remains usable |
dispose | () => void | Final teardown; aborts work and marks client unusable |
disposed | boolean | Whether final disposal occurred |
disposalSignal | AbortSignal | Aborts on final disposal |
Requests
request()
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> field | Type | Description |
|---|---|---|
method | string | HTTP method; defaults to GET |
params | Record<string, string | number | boolean> | Path parameters for {param} placeholders |
query | Params | Query string parameters |
body | unknown | BodyInit values pass through; streams use Node duplex; GET/HEAD reject bodies; other values encode as JSON |
headers | HeadersInit | Per-request headers merged with (and overriding) defaults |
responseType | ResponseType | Response parsing strategy |
schema | { parse(data: unknown): T } | Optional parsed-response validator; incompatible with responseType: 'raw' |
signal | AbortSignal | External abort signal merged with internal cancellation |
timeout | number | Integer milliseconds from 1 to 2,147,483,647, or Infinity; overrides default |
fetchInit | Omit<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.
const created = await courier.request<User>('/users', {
method: 'POST',
body: { name: 'Ada' },
});get()
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.
const user = await courier.get<User>('/users/{id}', {
cache: { key: ['users', 1], ttlMs: 10_000 },
params: { id: 1 },
});Cache
prefetch()
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()
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.
type Middleware = (ctx: FetchContext, next: (ctx: FetchContext) => Promise<Response>) => Promise<Response>;Middleware helpers
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.
const courier = createCourier({
middleware: [
withBearerAuth(() => tokenStore.getAccessToken()),
withRequestId(),
withLogging({ logger: (msg) => console.log(msg) }),
],
});Types
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' };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
| Error | Trigger | Notable properties |
|---|---|---|
CourierError | Base class for all Courier errors | Use instanceof to narrow |
CourierHttpError | Non-2xx HTTP response | status, data, headers, method, url, problem; CourierHttpError.is(e, status?) narrows by status |
CourierNetworkError | Request failure without response | method, url, cause |
CourierTimeoutError | Timeout signal aborts request | method, url, cause |
CourierAbortError | Caller or client cancellation | method, url, cause |
CourierSchemaValidationError | Response schema fails | data, cause |
CourierParseError | Response body cannot parse | N/A |
CourierConfigError | Invalid request config (GET/HEAD body, raw + schema) | N/A |
CourierDisposedError | Work starts after disposal | N/A |
Errors thrown directly by custom middleware are preserved rather than reclassified.
Problem details
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.
| Member | Purpose | Common gotcha |
|---|---|---|
ProblemDetail | The five registered RFC 9457 members plus extensions | Every member is optional; extension values have type unknown |
CourierHttpError.problem | Parsed problem document | For 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_TYPE | Registered media type constant | Compare with isProblemMediaType(), not ===, to allow parameters |
isProblemMediaType() | Match application/problem+json and its parameterised forms | Plain application/json never matches |
problemDetailError() | Build a problem-carrying CourierHttpError without a server | Status defaults to numeric problem.status, then 500; a supplied options.status takes precedence |
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.
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);