@vinicunca/request

GitHubnpm
Typed oRPC and ofetch REST clients, each with TanStack Query helpers.

Version 0.1.0. Optional peers: ofetch ^1.5.1 (HTTP), @tanstack/query-core >=5.80.2 (query entries).

Nothing is sent with cookies unless you pass credentials: 'include'.

@vinicunca/request/orpc

export function createRpcClient<TClient extends NestedClient<RpcClientContext>>(
  options: RpcClientOptions,
): TClient;

export interface RpcClientOptions {
  url: string;
  fetch?: RpcFetch;
  headers?: Record<string, string> | (() => Record<string, string> | Promise<Record<string, string>>);
  credentials?: RequestCredentials;
}

export type ContractClient<TContract extends AnyContractRouter>
  = ContractRouterClient<TContract, RpcClientContext>;

export const LIVE_RETRY: RpcClientContext;
// retry: Infinity
// shouldRetry: not (ORPCError && status < 500)

export { isDefinedError, ORPCError, safe } from '@orpc/client';

For router-inferred APIs pass RouterClient<typeof router, RpcClientContext> instead of ContractClient.

With skipLibCheck: false, install @opentelemetry/api (types only).

@vinicunca/request/orpc-query

export function createRpcQueryUtils<TClient extends NestedClient<any>>(
  client: TClient,
  options?: { path?: Array<string> },
): RouterUtils<TClient>;

Wraps createTanstackQueryUtils. path prefixes every query key.

@vinicunca/request/http

export function createHttpClient(clientOptions?: HttpClientOptions): HttpClient;
export const DEFAULT_TIMEOUT = 10_000;
export function isHttpResponse(value: unknown): value is HttpResponse;

export class HttpError extends Error {
  kind: 'network' | 'timeout' | 'abort' | 'http' | 'envelope';
  request: { url: string; method: string };
  status?: number;
  code?: unknown;
  data?: unknown;
}
export function isHttpError(error: unknown): error is HttpError;

HttpClientOptions: baseURL, headers, credentials, timeout (default 10_000, 0 disables), retry, retryDelay, retryStatusCodes, responseReturn ('raw' | 'body' | 'data', default 'body'), arrayFormat, querySerializer, parseResponse, fetch.

Methods: get, post, put, patch, delete, head, request, plus addRequestInterceptor / addResponseInterceptor (each returns a remover) and raw (the ofetch instance — only inherits baseURL and injected fetch, not interceptors).

Request interceptors may mutate context.options. Response interceptors are a promise chain; rejected may recover. A fulfilled handler must return the received HttpResponse (or a spread of it). A freshly built object loses the internal brand — later handlers skip and responseReturn stops unwrapping.

responseReturn: 'data' returns the envelope payload when envelopeInterceptor is installed; a bad code throws HttpError{ kind: 'envelope' }.

Presets

Install envelope → refresh token → error message.

envelopeInterceptor({ codeField, dataField, successCode })
refreshTokenInterceptor({
  client,
  refresh,           // MUST pass skipAuthRefresh: true on the refresh call
  applyToken,
  onAuthFailure,
  enabled?,
})
errorMessageInterceptor({ notify, messages? })
DEFAULT_ERROR_MESSAGES

Concurrent 401s share one refresh(). Each request is retried once. skipAuthRefresh requests are never retried; their 401 still calls onAuthFailure (deferred while a refresh is in flight).

Query strings

Default: ofetch/ufo. Set arrayFormat for qs-style nested objects:

arrayFormat{ ids: [1, 2], user: { name: 'Ana' } }
repeatids=1&ids=2&user[name]=Ana
bracketsids[]=1&ids[]=2&user[name]=Ana
indicesids[0]=1&ids[1]=2&user[name]=Ana
commaids=1,2&user[name]=Ana

null → key=; undefined omitted; Dates ISO; arrays of objects always use indices.

Interceptor-thrown exceptions propagate unwrapped (not as HttpError). Transport failures are always HttpError.

skipLibCheck: false also needs undici and @types/node (ofetch's types).

@vinicunca/request/http-query

export function createHttpQueryUtils(
  client: HttpClient,
  options?: { key?: ReadonlyArray<unknown> },
): HttpQueryUtils;
api.get<T>(path).queryOptions({ query, ...tanstack })
api.post<T, Vars>(path | (vars) => path).mutationOptions({ body?: (vars) => unknown, ... })
api.put / patch / delete   // same as post
api.key()                  // [...root]
api.get(path).key()        // [...root, 'GET', path]

Query keys: [...root, 'GET', path, { query }]. Request option keys (query, headers, credentials, timeout, responseReturn, responseType, arrayFormat, querySerializer, parseResponse) are stripped from TanStack options. retry / retryDelay / meta belong to TanStack here.

Cancelled queries abort the fetch. TanStack retries 3× by default, and each attempt runs the full interceptor chain (toasts, refresh). Prefer a retry that only retries 5xx.

queryOptions({...}) does not reject unknown keys — a typo like quer for query is silent, same as oRPC utils.