Skip to content

API Overview

SymbolPurposeExecution modeCommon gotcha
createApi()Create an HTTP client with defaults and interceptorsSyncUses TransportOptions, not ApiClientOptions
createQuery()Create cache/query orchestration utilitiesSyncUse fetch(), not query()
createMutation()Create tracked write handles with cancellationSynctimes: 1 means no retries; lifecycle callbacks receive variables
createCourier()Create a unified client with shared transportSyncREST timeout defaults to 30s; streams default to Infinity
createStream()Open SSE or readable HTTP streamsSyncreconnect: true means up to 5 reconnects after a failure
bindRefetch()Opt-in focus/reconnect revalidation bindingSyncReturns unbind fn; call it on cleanup
withBearerAuth()Interceptor preset for Bearer token injectionSyncAccepts static string or async token factory
withRequestId()Interceptor preset adding a unique request ID headerSyncDefaults to x-request-id with crypto.randomUUID()
withLogging()Interceptor preset logging method/URL/status/msSyncDefaults to console.debug; override with logger option
persistQueryCache()Subscribe to cache and write successful entriesSyncEagerly persists existing successful entries on setup
hydrateQueryCache()Read persisted entries and seed the cacheAsyncRuns all keys in parallel; restores original updatedAt
CourierErrorBase class for all courier errorsCourierError.is(e) catches any courier error; narrow further after
HttpErrorStructured non-2xx HTTP error with status + bodyUse HttpError.is(err, status?) for narrowing
NetworkErrorConnection-level failure (no response received)Use instanceof NetworkError for narrowing
TimeoutErrorRequest timed out via transport or AbortSignalUse instanceof TimeoutError for narrowing
AbortErrorRequest was cancelled via cancel() or signalUse instanceof AbortError for narrowing
SchemaValidationErrorThrown when schema.parse() rejects the response bodyWraps the original parse error; data holds the raw pre-parse body

Package Entry Point

ImportPurpose
@vielzeug/courierMain API and types

Core Functions

createApi()

ts
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:

OptionTypeDefaultDescription
baseUrlstring''Base URL prepended to every request
fetchtypeof globalThis.fetchglobalThis.fetchOptional custom fetch implementation
headersRecord<string, string>{}Default headers sent with every request
timeoutnumber30000Request timeout in ms; must be > 0 or Infinity

Methods:

MethodSignatureDescription
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() => voidAbort 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>) => voidUpdate global headers; undefined removes a header
use(interceptor: Interceptor) => () => voidAdd an interceptor; returns a dispose function
disposalSignalAbortSignal (getter)Aborted when dispose() is called; use to tie external lifetimes
dispose() => voidDispose the underlying transport when owned by this client
disposedboolean (getter)Whether dispose() has been called
[Symbol.dispose]Delegates to dispose(); enables using declarations

Example:

ts
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()

ts
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:

OptionTypeDefaultDescription
staleTimenumber0ms a successful entry is served from cache before the next fetch() refetches
gcTimenumber300000ms before an unobserved cache entry is collected; Infinity disables GC
timesnumber1Total attempts per fetch; 1 means a single try with no retries
delaynumber | (attempt) => numberfull jitterDelay between retries; attempt is zero-based (0 = before the 2nd try)
shouldRetry(error, attempt) => booleanReturn false to stop retrying; attempt is zero-based

Methods:

MethodSignatureDescription
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 | undefinedRead cached data
set<T>(key, data | updater, opts?) => voidSet or update cached data; opts.updatedAt restores a historical timestamp
getState<T>(key) => QueryState<T> | nullFull 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) => voidEvict or background-revalidate a key/prefix
remove(key: QueryKey) => voidEvict a single entry; aborts any in-flight fetch; resets observers to idle if active
cancel(key) => voidCancel an in-flight fetch; state rolls back to 'idle' or previous success
clear() => voidClear all entries; active subscribers see 'idle'
refetchStale() => voidManually revalidate all stale observed entries
keys() => QueryKey[]Returns all currently cached keys — useful for SSR serialization
sizenumber (getter)Number of entries currently held in the cache
cancelAll() => voidAbort all in-flight cache fetches without disposing the client
disposalSignalAbortSignal (getter)Aborted when dispose() is called; use to tie external lifecycles
dispose() => voidCancel all in-flight requests and clear all timers
disposedboolean (getter)Whether dispose() has been called
[Symbol.dispose]Delegates to dispose()

Example:

ts
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()

ts
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>:

OptionTypeDefaultDescription
timesnumber1Total attempts; 1 means a single try with no retries
delaynumber | (attempt) => numberfull jitterDelay between retries; attempt is zero-based (0 = before the 2nd try)
shouldRetry(error, attempt) => booleanReturn 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) => voidCalled when onSuccess/onError/onSettled throws; does not affect mutate() result

Mutation methods:

MethodSignatureDescription
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
storeSyncStore<MutationState<TData>> (property)Framework-friendly external store; stable reference
reset() => voidReset back to the idle state
dispose() => voidAbort active run, clear observers, and mark as disposed
disposedboolean (getter)Whether dispose() has been called
[Symbol.dispose]Delegates to dispose(); enables using declarations

Example:

ts
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()

ts
createCourier(opts?: CourierOptions): Courier;

Creates a unified Courier client backed by one shared transport.

Returns: Courier

CourierOptions:

OptionTypeDefaultDescription
baseUrlstring''Shared base URL for api and stream
fetchtypeof fetchglobalShared fetch implementation
headersRecord<string,string>{}Shared global headers
timeoutnumber30000REST timeout; streaming connections still default to Infinity
queryQueryClientOptionsDefaults for the embedded query client
mutationDefaultsPick<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 / MethodType / SignatureDescription
apiApiClientShared REST client
streamStreamClientShared SSE/readable stream client
queryQueryClientEmbedded query client
mutation(fn, opts?) => MutationCreate a mutation; accepts invalidates and sets cache shorthands in addition to MutationOptions
use(interceptor) => () => voidRegister an interceptor shared by api and stream
headers(updates) => voidUpdate shared global headers
cancelAll() => voidAbort all active transport-backed requests and streams
disposalSignalAbortSignalAborted when the client is disposed. Use to tie external lifetimes to this client.
dispose() => voidDispose the transport and embedded query client. Idempotent.
disposedbooleantrue after dispose() is called
[Symbol.dispose]Delegates to dispose()

Example:

ts
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()

ts
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:

MethodSignatureDescription
sse<TEvents>(url, opts?) => SseSource<TEvents>Open a Server-Sent Events connection
readable<T>(url, opts?) => AsyncGenerator<T>Stream text or NDJSON chunks
cancelAll() => voidAbort every active SSE or readable stream
getHeaders() => Readonly<Record<string, string>>Read current global headers
headers(updates: Record<string, string | undefined>) => voidUpdate shared global headers
use(interceptor: Interceptor) => () => voidAdd an interceptor shared by all stream requests
disposalSignalAbortSignal (getter)Aborted when dispose() is called; use to tie external lifetimes
dispose() => voidDispose the underlying transport when owned by this client
disposedboolean (getter)Whether dispose() has been called
[Symbol.dispose]Delegates to dispose()

Example:

ts
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()
└── SchemaValidationError

HttpError

Thrown for non-2xx HTTP responses. Carries the full response metadata.

  • status, method, url, data, headers
  • Static helpers: fromResponse(), is(err, status?)
ts
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 NetworkError for narrowing.

TimeoutError

Thrown when the request is aborted by the timeout (transport-level or via a timeout AbortSignal).

  • method, url, cause
  • Use instanceof TimeoutError for narrowing.

AbortError

Thrown when the request is cancelled explicitly via cancel(), cancelAll(), or an external AbortSignal.

  • method, url
  • Use instanceof AbortError for narrowing.
ts
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 body
  • cause: the original error thrown by schema.parse()
  • Static helper: SchemaValidationError.is(err)
ts
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

ts
type TransportOptions = {
  baseUrl?: string;
  fetch?: typeof globalThis.fetch;
  headers?: Record<string, string>;
  timeout?: number;
};

HttpRequestConfig<P>

ts
type HttpRequestConfig<P extends string = string> = CourierRequestConfig<P> & {
  fetchInit?: Omit<RequestInit, 'body' | 'headers' | 'method' | 'signal'>;
  headers?: Record<string, string>;
  signal?: AbortSignal;
};

CourierRequestConfig<P> adds:

FieldTypeDescription
bodyunknownPlain objects are serialized as JSON; BodyInit values pass through
dedupebooleanSet to false to opt out of in-flight deduplication
dedupeKeyStableValueExplicit stable key for deduplicating non-idempotent writes
queryParamsQuery string parameters
responseTypeResponseTypeResponse parsing strategy
schema{ parse(data: unknown): T }Response validation schema; T matches the request return type. Throws SchemaValidationError on failure
timeoutnumberPer-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>

ts
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>

ts
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 as sse(). true uses defaults (5 attempts). When the budget is exhausted: throws if onError is omitted, calls onError if provided.
  • onError — called when the reconnect budget is exhausted or a non-retriable error occurs. Not called when aborted via signal or cancelAll().

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>

ts
type SseOptions<P extends string = string> = StreamRequestConfig<P> & {
  onError?: (error: Error) => void;
  reconnect?: boolean | ReconnectOptions;
};
FieldTypeDescription
reconnectboolean | ReconnectOptionstrue uses 5 reconnect attempts with full-jitter backoff
onError(error: Error) => voidCalled when reconnect budget is exhausted

ReconnectOptions

ts
type ReconnectOptions = {
  times?: number;
  delay?: number | ((attempt: number) => number);
};
  • times counts reconnects after the first failure
  • default times is 5
  • clean server closes do not reset the reconnect budget

Types

QueryOptions<T>

ts
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>

ts
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

ts
type QueryClientOptions = {
  gcTime?: number;
  staleTime?: number;
} & RetryOptions;

MutationFn<TData, TVariables>

ts
type MutationFn<TData, TVariables = void> = (input: TVariables, signal: AbortSignal) => Promise<TData>;

MutationOptions<TData, TVariables>

ts
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>

ts
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.

OptionTypeDescription
invalidatesQueryKey[]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:

ts
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>

ts
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:

ts
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

ts
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>

ts
interface SyncStore<T> {
  peek(): T;
  subscribe(onStoreChange: () => void): () => void;
}

Returned by mutation.store (property), query.watchKey(), query.observe(), and query.observeMany().

QueryKey

ts
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

ts
type QueryFnContext = {
  key: QueryKey;
  signal: AbortSignal;
};

AsyncState<T>

ts
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>

ts
type QueryState<T = unknown> = AsyncState<T>;

MutationState<TData>

ts
type MutationState<TData = unknown> = AsyncState<TData>;

SseSource<TEvents>

ts
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;
};
  • status transitions: connectingopen → (reconnectingconnecting)* → closed
  • closed is a shorthand for status === 'closed'
ts
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

ts
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

ts
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

ts
interface PersistStorage {
  getItem(key: string): Promise<string | null> | string | null;
  setItem(key: string, value: string): Promise<void> | void;
}

Utility Functions

bindRefetch()

ts
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:

ts
import { bindRefetch, createQuery } from '@vielzeug/courier';

const qc = createQuery({ staleTime: 30_000 });
const unbind = bindRefetch(qc);

// On cleanup:
unbind();

createBatcher()

ts
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:

ts
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

ts
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:

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

api.use(withBearerAuth(async () => tokenStore.getAccessToken()));
api.use(withRequestId());
api.use(withLogging());

persistQueryCache() and hydrateQueryCache()

ts
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;
}
  • persistQueryCache returns a stop function. It eagerly persists any already-successful entries on setup.
  • hydrateQueryCache restores the original updatedAt timestamp so staleTime checks are accurate after hydration.
  • maxAge (ms) — entries older than Date.now() - maxAge are skipped during hydration.
  • onError is called for each failing storage operation; errors are silently swallowed when omitted.

Returns: persistQueryCache returns () => void (stop function); hydrateQueryCache returns Promise<void>

Example:

ts
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,
});