API Overview
| Symbol | Purpose | Execution mode | Common gotcha |
|---|---|---|---|
createApi() | Create an HTTP client with defaults and interceptors | Sync | Uses TransportOptions, not ApiClientOptions |
createQuery() | Create cache/query orchestration utilities | Sync | Use fetch(), not query() |
createMutation() | Create tracked write handles with cancellation | Sync | times: 1 means no retries; lifecycle callbacks receive variables |
createCourier() | Create a unified client with shared transport | Sync | REST timeout defaults to 30s; streams default to Infinity |
createStream() | Open SSE or readable HTTP streams | Sync | reconnect: true means up to 5 reconnects after a failure |
bindRefetch() | Opt-in focus/reconnect revalidation binding | Sync | Returns unbind fn; call it on cleanup |
withBearerAuth() | Interceptor preset for Bearer token injection | Sync | Accepts static string or async token factory |
withRequestId() | Interceptor preset adding a unique request ID header | Sync | Defaults to x-request-id with crypto.randomUUID() |
withLogging() | Interceptor preset logging method/URL/status/ms | Sync | Defaults to console.debug; override with logger option |
persistQueryCache() | Subscribe to cache and write successful entries | Sync | Eagerly persists existing successful entries on setup |
hydrateQueryCache() | Read persisted entries and seed the cache | Async | Runs all keys in parallel; restores original updatedAt |
CourierError | Base class for all courier errors | — | CourierError.is(e) catches any courier error; narrow further after |
HttpError | Structured non-2xx HTTP error with status + body | — | Use HttpError.is(err, status?) for narrowing |
NetworkError | Connection-level failure (no response received) | — | Use instanceof NetworkError for narrowing |
TimeoutError | Request timed out via transport or AbortSignal | — | Use instanceof TimeoutError for narrowing |
AbortError | Request was cancelled via cancel() or signal | — | Use instanceof AbortError for narrowing |
SchemaValidationError | Thrown when schema.parse() rejects the response body | — | Wraps the original parse error; data holds the raw pre-parse body |
Package Entry Point
| Import | Purpose |
|---|---|
@vielzeug/courier | Main API and types |
Core Functions
createApi()
createApi(opts?: TransportOptions): ApiClient;Creates an HTTP client. Use createCourier() to share one transport across REST, streams, and the query cache.
Returns: ApiClient
Parameters — TransportOptions:
| Option | Type | Default | Description |
|---|---|---|---|
baseUrl | string | '' | Base URL prepended to every request |
fetch | typeof globalThis.fetch | globalThis.fetch | Optional custom fetch implementation |
headers | Record<string, string> | {} | Default headers sent with every request |
timeout | number | 30000 | Request timeout in ms; must be > 0 or Infinity |
Methods:
| Method | Signature | Description |
|---|---|---|
get | <T, P>(url: P, cfg?) => Promise<T> | GET request |
post | <T, P>(url: P, cfg?) => Promise<T> | POST request |
put | <T, P>(url: P, cfg?) => Promise<T> | PUT request |
patch | <T, P>(url: P, cfg?) => Promise<T> | PATCH request |
delete | <T, P>(url: P, cfg?) => Promise<T> | DELETE request |
request | <T, P>(method, url: P, cfg?) => Promise<T> | Custom HTTP method |
cancelAll | () => void | Abort every active request without disposing the client |
getHeaders | () => Readonly<Record<string, string>> | Returns a snapshot copy — mutating it has no effect on the client |
headers | (updates: Record<string, string | undefined>) => void | Update global headers; undefined removes a header |
use | (interceptor: Interceptor) => () => void | Add an interceptor; returns a dispose function |
disposalSignal | AbortSignal (getter) | Aborted when dispose() is called; use to tie external lifetimes |
dispose | () => void | Dispose the underlying transport when owned by this client |
disposed | boolean (getter) | Whether dispose() has been called |
[Symbol.dispose] | — | Delegates to dispose(); enables using declarations |
Example:
import { createApi } from '@vielzeug/courier';
const api = createApi({ baseUrl: 'https://api.example.com', timeout: 30_000 });
const user = await api.get<User>('/users/{id}', { params: { id: 1 } });createQuery()
createQuery(options?: QueryClientOptions): QueryClient;Creates a query client with caching, deduplication, prefix invalidation, and reactive subscriptions.
fetch() always throws on error. Use observe() for reactive subscriptions that surface errors as store state.
Returns: QueryClient
Parameters — QueryClientOptions:
| Option | Type | Default | Description |
|---|---|---|---|
staleTime | number | 0 | ms a successful entry is served from cache before the next fetch() refetches |
gcTime | number | 300000 | ms before an unobserved cache entry is collected; Infinity disables GC |
times | number | 1 | Total attempts per fetch; 1 means a single try with no retries |
delay | number | (attempt) => number | full jitter | Delay between retries; attempt is zero-based (0 = before the 2nd try) |
shouldRetry | (error, attempt) => boolean | — | Return false to stop retrying; attempt is zero-based |
Methods:
| Method | Signature | Description |
|---|---|---|
fetch | <T>(options: QueryOptions<T>) => Promise<T> | Fetch with caching, deduplication, and retry; always throws on error |
fetchMany | <T>(queries: QueryOptions<T>[]) => Promise<T[]> | Parallel fetch for multiple keys; throws if any query fails |
observe | <T, S = T>(options: ObserveOptions<T, S>) => SyncStore<QueryState<S>> | Return a store and trigger a background fetch; pass fetch: false to skip the fetch |
get | <T>(key) => T | undefined | Read cached data |
set | <T>(key, data | updater, opts?) => void | Set or update cached data; opts.updatedAt restores a historical timestamp |
getState | <T>(key) => QueryState<T> | null | Full state snapshot |
watchKey | <T>(key: QueryKey) => SyncStore<QueryState<T>> | Read-through store for one key; no fetch triggered |
observeMany | <T>(keys: QueryKey[]) => SyncStore<QueryState<T>[]> | Observe multiple keys as one combined store; updates on any key change |
invalidate | (key) => void | Evict or background-revalidate a key/prefix |
remove | (key: QueryKey) => void | Evict a single entry; aborts any in-flight fetch; resets observers to idle if active |
cancel | (key) => void | Cancel an in-flight fetch; state rolls back to 'idle' or previous success |
clear | () => void | Clear all entries; active subscribers see 'idle' |
refetchStale | () => void | Manually revalidate all stale observed entries |
keys | () => QueryKey[] | Returns all currently cached keys — useful for SSR serialization |
size | number (getter) | Number of entries currently held in the cache |
cancelAll | () => void | Abort all in-flight cache fetches without disposing the client |
disposalSignal | AbortSignal (getter) | Aborted when dispose() is called; use to tie external lifecycles |
dispose | () => void | Cancel all in-flight requests and clear all timers |
disposed | boolean (getter) | Whether dispose() has been called |
[Symbol.dispose] | — | Delegates to dispose() |
Example:
import { createQuery } from '@vielzeug/courier';
const qc = createQuery({ staleTime: 30_000 });
const user = await qc.fetch({
key: ['users', 1],
fn: ({ signal }) => api.get<User>('/users/{id}', { params: { id: 1 }, signal }),
});createMutation()
createMutation<TData, TVariables = void>(
fn: (input: TVariables, signal: AbortSignal) => Promise<TData>,
options?: MutationOptions<TData, TVariables>,
): Mutation<TData, TVariables>;Creates a standalone, observable mutation handle.
Returns: Mutation<TData, TVariables>
MutationOptions<TData, TVariables>:
| Option | Type | Default | Description |
|---|---|---|---|
times | number | 1 | Total attempts; 1 means a single try with no retries |
delay | number | (attempt) => number | full jitter | Delay between retries; attempt is zero-based (0 = before the 2nd try) |
shouldRetry | (error, attempt) => boolean | — | Return false to skip retrying; attempt is zero-based |
onSuccess | (data: TData, variables: TVariables) => void | Promise<void> | — | Called after a successful run |
onError | (error: Error, variables: TVariables) => void | Promise<void> | — | Called after a failed run; not called on abort |
onSettled | (result: SettledResult<TData, TVariables>) => void | Promise<void> | — | Called after every run; switch on result.status ('success', 'error', 'aborted') for exhaustive handling |
onCallbackError | (error: Error) => void | — | Called when onSuccess/onError/onSettled throws; does not affect mutate() result |
Mutation methods:
| Method | Signature | Description |
|---|---|---|
mutate | (variables, opts?) => Promise<TData> | Execute a run |
cancel | () => Promise<void> | Abort the active run and wait for it to settle |
getState | () => MutationState<TData> | Read current state |
store | SyncStore<MutationState<TData>> (property) | Framework-friendly external store; stable reference |
reset | () => void | Reset back to the idle state |
dispose | () => void | Abort active run, clear observers, and mark as disposed |
disposed | boolean (getter) | Whether dispose() has been called |
[Symbol.dispose] | — | Delegates to dispose(); enables using declarations |
Example:
import { createMutation } from '@vielzeug/courier';
const addUser = createMutation(
(input: NewUser, signal: AbortSignal) => api.post<User>('/users', { body: input, signal }),
{
onSuccess: (user, variables) => {
qc.set(['users', user.id], user);
console.log('Created user with input:', variables);
},
onError: (err, variables) => console.error('Failed for input:', variables, err),
onSettled: (result) => {
if (result.status === 'success') console.log('Done:', result.data);
else if (result.status === 'error') console.error('Error:', result.error);
// result.status === 'aborted' — user cancelled
},
},
);
await addUser.mutate({ name: 'Alice' });Concurrent mutations
When multiple mutate() calls run simultaneously, state updates reflect the latest call. Lifecycle callbacks (onSuccess, onError, onSettled) fire independently for every call — not just the last one. Use mutation.cancel() before calling mutate() again if you need last-call-wins semantics.
createCourier()
createCourier(opts?: CourierOptions): Courier;Creates a unified Courier client backed by one shared transport.
Returns: Courier
CourierOptions:
| Option | Type | Default | Description |
|---|---|---|---|
baseUrl | string | '' | Shared base URL for api and stream |
fetch | typeof fetch | global | Shared fetch implementation |
headers | Record<string,string> | {} | Shared global headers |
timeout | number | 30000 | REST timeout; streaming connections still default to Infinity |
query | QueryClientOptions | — | Defaults for the embedded query client |
mutationDefaults | Pick<MutationOptions, 'times' | 'delay' | 'shouldRetry' | 'onCallbackError'> | — | Retry/error-handling defaults merged into every mutation() call; lifecycle callbacks are per-mutation since they receive typed variables |
Courier interface:
| Property / Method | Type / Signature | Description |
|---|---|---|
api | ApiClient | Shared REST client |
stream | StreamClient | Shared SSE/readable stream client |
query | QueryClient | Embedded query client |
mutation | (fn, opts?) => Mutation | Create a mutation; accepts invalidates and sets cache shorthands in addition to MutationOptions |
use | (interceptor) => () => void | Register an interceptor shared by api and stream |
headers | (updates) => void | Update shared global headers |
cancelAll | () => void | Abort all active transport-backed requests and streams |
disposalSignal | AbortSignal | Aborted when the client is disposed. Use to tie external lifetimes to this client. |
dispose | () => void | Dispose the transport and embedded query client. Idempotent. |
disposed | boolean | true after dispose() is called |
[Symbol.dispose] | — | Delegates to dispose() |
Example:
import { createCourier } from '@vielzeug/courier';
const client = createCourier({
baseUrl: 'https://api.example.com',
query: { staleTime: 30_000 },
});
const user = await client.query.fetch({
key: ['users', 1],
fn: ({ signal }) => client.api.get<User>('/users/{id}', { params: { id: 1 }, signal }),
});createStream()
createStream(opts?: TransportOptions): StreamClient;Creates a streaming client for SSE and readable HTTP responses. Use createCourier() to share one transport across REST, streams, and the query cache.
Returns: StreamClient
Stream methods:
| Method | Signature | Description |
|---|---|---|
sse | <TEvents>(url, opts?) => SseSource<TEvents> | Open a Server-Sent Events connection |
readable | <T>(url, opts?) => AsyncGenerator<T> | Stream text or NDJSON chunks |
cancelAll | () => void | Abort every active SSE or readable stream |
getHeaders | () => Readonly<Record<string, string>> | Read current global headers |
headers | (updates: Record<string, string | undefined>) => void | Update shared global headers |
use | (interceptor: Interceptor) => () => void | Add an interceptor shared by all stream requests |
disposalSignal | AbortSignal (getter) | Aborted when dispose() is called; use to tie external lifetimes |
dispose | () => void | Dispose the underlying transport when owned by this client |
disposed | boolean (getter) | Whether dispose() has been called |
[Symbol.dispose] | — | Delegates to dispose() |
Example:
import { createStream } from '@vielzeug/courier';
const stream = createStream({ baseUrl: 'https://api.example.com' });
const source = stream.sse<{ message: { text: string } }>('/events', { reconnect: true });
source.on('message', (data) => console.log(data.text));
source.dispose();Errors
CourierError
Base class for all courier errors. Catch with CourierError.is(e) to handle any courier error in one branch, then narrow further.
name:'CourierError'(overridden by subclasses)message: prefixed with[@vielzeug/courier]- Static helper:
CourierError.is(err)
Hierarchy:
CourierError
├── HttpError — non-2xx response (has status + body)
├── NetworkError — connection failed, no response received
├── TimeoutError — request aborted by timeout
├── AbortError — request cancelled via signal or cancel()
└── SchemaValidationErrorHttpError
Thrown for non-2xx HTTP responses. Carries the full response metadata.
status,method,url,data,headers- Static helpers:
fromResponse(),is(err, status?)
import { HttpError } from '@vielzeug/courier';
try {
await api.get('/users/1');
} catch (err) {
if (HttpError.is(err, 404)) console.log('Not found');
else if (HttpError.is(err)) {
console.log(err.status, err.method, err.url);
console.log(err.headers?.get('x-request-id'));
}
}NetworkError
Thrown when the connection fails before any response is received (e.g. DNS failure, refused connection).
method,url,cause- Use
instanceof NetworkErrorfor narrowing.
TimeoutError
Thrown when the request is aborted by the timeout (transport-level or via a timeout AbortSignal).
method,url,cause- Use
instanceof TimeoutErrorfor narrowing.
AbortError
Thrown when the request is cancelled explicitly via cancel(), cancelAll(), or an external AbortSignal.
method,url- Use
instanceof AbortErrorfor narrowing.
import { AbortError, HttpError, NetworkError, TimeoutError } from '@vielzeug/courier';
try {
await api.get('/data');
} catch (err) {
if (HttpError.is(err, 404)) console.log('Not found');
else if (err instanceof TimeoutError) console.log('Timed out');
else if (err instanceof AbortError) console.log('Cancelled');
else if (err instanceof NetworkError) console.log('Network failure:', (err as NetworkError).cause);
}SchemaValidationError
Thrown when schema.parse() rejects the parsed response body.
name:'SchemaValidationError'data: the raw (pre-validation) response bodycause: the original error thrown byschema.parse()- Static helper:
SchemaValidationError.is(err)
import { SchemaValidationError } from '@vielzeug/courier';
try {
const user = await api.get<User>('/users/1', { schema: UserSchema });
} catch (err) {
if (SchemaValidationError.is(err)) {
console.error('Validation failed for body:', err.data);
console.error('Cause:', err.cause);
}
}Request and Stream Config
TransportOptions
type TransportOptions = {
baseUrl?: string;
fetch?: typeof globalThis.fetch;
headers?: Record<string, string>;
timeout?: number;
};HttpRequestConfig<P>
type HttpRequestConfig<P extends string = string> = CourierRequestConfig<P> & {
fetchInit?: Omit<RequestInit, 'body' | 'headers' | 'method' | 'signal'>;
headers?: Record<string, string>;
signal?: AbortSignal;
};CourierRequestConfig<P> adds:
| Field | Type | Description |
|---|---|---|
body | unknown | Plain objects are serialized as JSON; BodyInit values pass through |
dedupe | boolean | Set to false to opt out of in-flight deduplication |
dedupeKey | StableValue | Explicit stable key for deduplicating non-idempotent writes |
query | Params | Query string parameters |
responseType | ResponseType | Response parsing strategy |
schema | { parse(data: unknown): T } | Response validation schema; T matches the request return type. Throws SchemaValidationError on failure |
timeout | number | Per-request timeout override |
Idempotent requests (GET, HEAD, OPTIONS) dedupe by method + URL + responseType automatically. DELETE does not auto-dedupe (it has side effects); provide an explicit dedupeKey to opt in. Request headers are not part of the automatic dedupe key.
StreamRequestConfig<P>
type StreamRequestConfig<P extends string = string> = {
body?: unknown;
/** Raw fetch options for advanced use (credentials, cache, mode, referrer, etc.). */
fetchInit?: Omit<RequestInit, 'body' | 'headers' | 'method' | 'signal'>;
headers?: Record<string, string>;
method?: string;
params?: P extends string ? Record<string, string | number | boolean> : never;
query?: Params;
signal?: AbortSignal;
timeout?: number;
};Streaming requests default to Infinity timeout per connection when timeout is omitted.
ReadableConfig<P>
type ReadableConfig<P extends string = string> = StreamRequestConfig<P> & {
onError?: (error: Error) => void;
parse?: 'ndjson' | 'text';
reconnect?: boolean | ReconnectOptions;
};parse: 'ndjson'— splits by newline and JSON-parses each complete line, including any partial line at EOF.reconnect— auto-reconnect on connection loss using the same full-jitter backoff assse().trueuses defaults (5 attempts). When the budget is exhausted: throws ifonErroris omitted, callsonErrorif provided.onError— called when the reconnect budget is exhausted or a non-retriable error occurs. Not called when aborted via signal orcancelAll().
Extends StreamRequestConfig with a parse option for stream.readable(). 'text' (default) yields raw decoded string chunks; 'ndjson' splits by newline and JSON-parses each complete line — use the type parameter T to type the parsed values.
SseOptions<P>
type SseOptions<P extends string = string> = StreamRequestConfig<P> & {
onError?: (error: Error) => void;
reconnect?: boolean | ReconnectOptions;
};| Field | Type | Description |
|---|---|---|
reconnect | boolean | ReconnectOptions | true uses 5 reconnect attempts with full-jitter backoff |
onError | (error: Error) => void | Called when reconnect budget is exhausted |
ReconnectOptions
type ReconnectOptions = {
times?: number;
delay?: number | ((attempt: number) => number);
};timescounts reconnects after the first failure- default
timesis5 - clean server closes do not reset the reconnect budget
Types
QueryOptions<T>
type QueryOptions<T> = {
enabled?: boolean;
fn: (ctx: QueryFnContext) => Promise<T>;
gcTime?: number;
initialData?: T | (() => T | undefined);
key: QueryKey;
staleTime?: number;
} & RetryOptions;fetch() always throws on error. Errors surface as rejected promises — no swallow option. For reactive subscriptions that surface errors as state, use observe().
ObserveOptions<T, S = T>
type ObserveOptions<T, S = T> = QueryOptions<T> & {
fetch?: boolean;
placeholderData?: S | (() => S | undefined);
select?: (data: T | undefined) => S | undefined;
};Extends QueryOptions with view-layer options consumed exclusively by observe(). fetch: false skips the background network call — the store only reflects the current cache. placeholderData and select do not affect the underlying cache or fetch() behaviour. S defaults to T; provide a second generic when using select (e.g. ObserveOptions<User, string> with select: (u) => u?.name).
QueryClientOptions
type QueryClientOptions = {
gcTime?: number;
staleTime?: number;
} & RetryOptions;MutationFn<TData, TVariables>
type MutationFn<TData, TVariables = void> = (input: TVariables, signal: AbortSignal) => Promise<TData>;MutationOptions<TData, TVariables>
type MutationOptions<TData = unknown, TVariables = void> = RetryOptions & {
onCallbackError?: (error: Error) => void;
onError?: (error: Error, variables: TVariables) => void | Promise<void>;
onSettled?: (result: SettledResult<TData, TVariables>) => void | Promise<void>;
onSuccess?: (data: TData, variables: TVariables) => void | Promise<void>;
};onError is not called when the mutation is aborted. onSettled is always called — switch on result.status for exhaustive handling of 'success', 'error', and 'aborted'.
CourierMutationOptions<TData, TVariables>
type CourierMutationOptions<TData, TVariables> = MutationOptions<TData, TVariables> & {
invalidates?: QueryKey[];
sets?: (data: TData, variables: TVariables) => Array<[QueryKey, unknown]>;
};Extra options accepted by client.mutation() (not createMutation()). Applied automatically on success before onSuccess fires.
| Option | Type | Description |
|---|---|---|
invalidates | QueryKey[] | Keys to invalidate in the embedded query cache after a successful run |
sets | (data, variables) => Array<[QueryKey, unknown]> | Seed one or more cache entries; always return an array of [key, data] pairs |
Example:
const createUser = client.mutation(
(input: NewUser, signal) => client.api.post<User>('/users', { body: input, signal }),
{
sets: (user) => [
[['users', user.id], user],
[['users', 'latest'], user],
],
invalidates: [['users']],
},
);
// On success: seeds ['users', user.id] and ['users', 'latest'], then invalidates ['users']SettledResult<TData, TVariables>
type SettledResult<TData, TVariables> =
| { readonly data: TData; readonly status: 'success'; readonly variables: TVariables }
| { readonly error: Error; readonly status: 'error'; readonly variables: TVariables }
| { readonly status: 'aborted'; readonly variables: TVariables };Discriminated union passed to onSettled. Switch on status for exhaustive handling:
onSettled: (result) => {
if (result.status === 'success') console.log(result.data, result.variables);
else if (result.status === 'error') console.error(result.error, result.variables);
// 'aborted' — user cancelled; only result.variables is available
},RetryOptions
type RetryOptions = {
delay?: number | ((attempt: number) => number);
shouldRetry?: (error: unknown, attempt: number) => boolean;
times?: number;
};times: 1 (the default) means one try with no retries. delay defaults to full-jitter exponential backoff capped at 30 s.
SyncStore<T>
interface SyncStore<T> {
peek(): T;
subscribe(onStoreChange: () => void): () => void;
}Returned by mutation.store (property), query.watchKey(), query.observe(), and query.observeMany().
QueryKey
type QueryKeyAtom = string | number | boolean | null | { readonly [k: string]: string | number | boolean | null };
type QueryKey = readonly [QueryKeyAtom, ...QueryKeyAtom[]];A non-empty tuple of JSON-safe atoms. Object atoms are allowed and serialized stably — no Date, Map, Set, or bigint. This prevents silent serialization bugs when keys are used in persistence.
QueryFnContext
type QueryFnContext = {
key: QueryKey;
signal: AbortSignal;
};AsyncState<T>
type AsyncState<T = unknown> =
| {
readonly data: undefined;
readonly error: null;
readonly isFetching: false;
readonly status: 'idle';
readonly updatedAt: undefined;
}
| {
readonly data: undefined;
readonly error: null;
readonly isFetching: true;
readonly status: 'pending';
readonly updatedAt: number | undefined;
}
| {
readonly data: T;
readonly error: null;
readonly isFetching: boolean;
readonly status: 'success';
readonly updatedAt: number;
}
| {
readonly data: T | undefined;
readonly error: Error;
readonly isFetching: boolean;
readonly status: 'error';
readonly updatedAt: number;
};QueryState<T>
type QueryState<T = unknown> = AsyncState<T>;MutationState<TData>
type MutationState<TData = unknown> = AsyncState<TData>;SseSource<TEvents>
type SseStatus = 'connecting' | 'open' | 'reconnecting' | 'closed';
type SseSource<TEvents extends Record<string, unknown> = Record<string, string>> = {
readonly closed: boolean;
readonly status: SseStatus;
[Symbol.dispose](): void;
dispose(): void;
on<K extends keyof TEvents & string>(event: K, handler: (data: TEvents[K]) => void): () => void;
};statustransitions:connecting→open→ (reconnecting→connecting)* →closedclosedis a shorthand forstatus === 'closed'
type FetchContext = {
headers: Record<string, string>;
init: Omit<RequestInit, 'headers'>;
url: string;
withHeaders(updates: Record<string, string>): FetchContext;
};
type Interceptor = (ctx: FetchContext, next: (ctx: FetchContext) => Promise<Response>) => Promise<Response>;Interceptors must use ctx.withHeaders(updates) to add or override headers — this returns a new immutable FetchContext and prevents interceptors from stomping each other.
Return Types
type ApiClient = ReturnType<typeof createApi>;
type QueryClient = ReturnType<typeof createQuery>;
type Mutation<TData, TVariables = void> = ReturnType<typeof createMutation<TData, TVariables>>;
type StreamClient = ReturnType<typeof createStream>;
type Courier = ReturnType<typeof createCourier>;PersistOptions
interface PersistOptions {
/**
* Keys to persist/hydrate. Either:
* - `QueryKey[]` — explicit list of keys.
* - `(key: QueryKey) => boolean` — predicate applied to all cached keys.
*/
keys: QueryKey[] | ((key: QueryKey) => boolean);
maxAge?: number;
onError?: (err: unknown, key: QueryKey) => void;
prefix?: string; // default: 'courier:'
storage: PersistStorage;
}keys— required. Pass an array of explicit keys or a predicate function filtered against all cached keys.
PersistStorage
interface PersistStorage {
getItem(key: string): Promise<string | null> | string | null;
setItem(key: string, value: string): Promise<void> | void;
}Utility Functions
bindRefetch()
bindRefetch(qc: { refetchStale(): void }, opts?: { signal?: AbortSignal }): () => void;Wires up qc.refetchStale() to browser lifecycle events — document visibilitychange (when tab becomes visible) and window online. Returns an unbind function. Fully opt-in.
Pass opts.signal (e.g. qc.disposalSignal) to automatically remove listeners when the signal aborts — eliminating the need to call the returned unbind function manually on teardown.
Returns: () => void (unbind function)
Example:
import { bindRefetch, createQuery } from '@vielzeug/courier';
const qc = createQuery({ staleTime: 30_000 });
const unbind = bindRefetch(qc);
// On cleanup:
unbind();createBatcher()
createBatcher<K, V>(opts: BatcherOptions<K, V>): Batcher<K, V>;BatcherOptions requires exactly one of resolve or resolveSettled (mutually exclusive). resolve() must return results in the same order as keys. A length mismatch rejects all pending promises. resolveSettled returns PromiseSettledResult<V>[] for per-key error isolation — each load() fulfills or rejects independently. After dispose(), any subsequent load() call rejects immediately.
Returns: Batcher<K, V>
Example:
import { createBatcher } from '@vielzeug/courier';
const userLoader = createBatcher<number, User>({
resolve: async (ids) => api.post<User[]>('/users/batch', { body: { ids } }),
});
const [alice, bob] = await Promise.all([userLoader.load(1), userLoader.load(2)]);Built-in Interceptor Presets
withBearerAuth(token: string | (() => string | Promise<string>)): Interceptor;
withRequestId(opts?: { header?: string; generate?: () => string }): Interceptor;
withLogging(opts?: {
logger?: (msg: string, meta: { duration: number; method: string; status: number; url: string }) => void;
}): Interceptor;withBearerAuth accepts a static token or an async factory (for token refresh flows). It correctly handles all HeadersInit forms — plain object, Headers instance, or array of tuples.
withRequestId defaults to x-request-id populated with crypto.randomUUID().
withLogging defaults to console.debug.
Example:
import { withBearerAuth, withLogging, withRequestId } from '@vielzeug/courier';
api.use(withBearerAuth(async () => tokenStore.getAccessToken()));
api.use(withRequestId());
api.use(withLogging());persistQueryCache() and hydrateQueryCache()
persistQueryCache(
qc: QueryClient,
opts: PersistOptions,
): () => void;
hydrateQueryCache(
qc: QueryClient,
opts: PersistOptions,
): Promise<void>;
interface PersistOptions {
/** Keys to persist/hydrate: explicit `QueryKey[]` list or predicate function. */
keys: QueryKey[] | ((key: QueryKey) => boolean);
maxAge?: number;
onError?: (err: unknown, key: QueryKey) => void;
prefix?: string; // default: 'courier:'
storage: PersistStorage;
}
interface PersistStorage {
getItem(key: string): Promise<string | null> | string | null;
setItem(key: string, value: string): Promise<void> | void;
}persistQueryCachereturns a stop function. It eagerly persists any already-successful entries on setup.hydrateQueryCacherestores the originalupdatedAttimestamp so staleTime checks are accurate after hydration.maxAge(ms) — entries older thanDate.now() - maxAgeare skipped during hydration.onErroris called for each failing storage operation; errors are silently swallowed when omitted.
Returns: persistQueryCache returns () => void (stop function); hydrateQueryCache returns Promise<void>
Example:
import { createQuery, hydrateQueryCache, persistQueryCache } from '@vielzeug/courier';
const qc = createQuery({ staleTime: 60_000 });
await hydrateQueryCache(qc, {
keys: [['users', userId]],
maxAge: 24 * 60 * 60_000,
storage: localStorage,
});
const stop = persistQueryCache(qc, {
keys: [['users', userId]],
storage: localStorage,
});