---
title: "@vinicunca/request"
description: "Typed oRPC and ofetch REST clients, each with TanStack Query helpers."
canonical_url: "https://vinicunca.dev/taman/packages/request"
---
# @vinicunca/request

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

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

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

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

```ts
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' } }` |
| --- | --- |
| `repeat` | `ids=1&ids=2&user[name]=Ana` |
| `brackets` | `ids[]=1&ids[]=2&user[name]=Ana` |
| `indices` | `ids[0]=1&ids[1]=2&user[name]=Ana` |
| `comma` | `ids=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`

```ts
export function createHttpQueryUtils(
  client: HttpClient,
  options?: { key?: ReadonlyArray<unknown> },
): HttpQueryUtils;
```

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

---

- [GitHub](https://github.com/vinicunca/taman/tree/main/packages/request)
- [npm](https://www.npmjs.com/package/@vinicunca/request)


## Sitemap

See the full [sitemap](https://vinicunca.dev/sitemap.md) for all pages.
