# @tetsujs/client

> A typed fetch client for a Tetsu application, typed by a contract generated from its routes, with TanStack Query options.

`@tetsujs/client` calls a Tetsu application with the types its routes already declare. It is a small `fetch` wrapper with no dependencies. A frontend gets the types from a contract, a `.d.ts` file generated from the application. Setting it up in Next.js or SvelteKit is in the [guide](/docs/guides/typed-client/).

```bash
bun add @tetsujs/client
```

## Usage

```ts
import { createClient } from "@tetsujs/client";
import type { Api } from "./api.contract";


const api = createClient<Api>({ baseUrl: "https://api.example.com" });


const res = await api.get("/users/:id", { params: { id: 42 } });


if (res.ok) {
  console.log(res.data.createdAt);
} else {
  console.log(res.status);
}
```

`api.get` suggests the paths that answer `GET`. A wrong path, a missing parameter or a wrong body is a compile error. `data` is what JSON delivers, so a `Date` is a string.

`Api` comes from the [contract](#the-contract). In tests and projects on Bun, which have Bun’s types anyway, `createClient<typeof app>()` reads the application’s type directly.

## A call

A call takes the path, then the parts the route declares:

| Option    |                                                                                             |
| --------- | ------------------------------------------------------------------------------------------- |
| `params`  | the path’s `:name` segments                                                                 |
| `query`   | the query string; an array repeats its key                                                  |
| `body`    | the body: an object goes as JSON; required with a schema, `{}` when every field is optional |
| `form`    | the body of a route with `bodyType: "form"`, sent as `FormData`                             |
| `headers` | the headers the route declares, and any other                                               |
| `init`    | the rest of `RequestInit`: `signal`, `credentials`…                                         |

Parameters, the query and headers travel as text; a `Date` goes as its ISO string. A part the route does not declare is a compile error. `api.url(path, { params, query })` returns the address without sending, for an image or an `EventSource`.

## The result

A call does not throw on an error status. It resolves to one member per status the route declares, told apart by `ok` and `status`:

```ts
if (res.status === 404) {
  console.log(res.error.error);
}
```

A status the route does not declare comes with the core’s [error envelope](/docs/concepts/errors/#the-envelope). Only what is not a response throws: a network failure, an aborted request, JSON that does not parse; and, before anything is sent, a `TypeError` for an address that would go elsewhere: a parameter that is missing, empty, `.` or `..`, or a path that does not start with a single `/`.

`unwrap()` returns the data, or throws a `ClientError` with `status`, `body` and `response`:

```ts
const user = unwrap(await api.get("/users/:id", { params: { id } }));
```

## Streams

A success that comes with `content-type: text/event-stream` has `events`: the events of the stream, read as the loop asks for them. The route’s `body` declares the `data` of one event:

```ts
const res = await api.post("/agents/:name/stream", {
  params: { name: "chat" },
  body: { input: "Hello" },
});


if (res.ok) {
  for await (const event of res.events) {
    if (event.data.type === "delta") {
      console.log(event.data.delta);
    }
  }
}
```

An event is `{ data, text, event?, id?, retry? }`. `text` is the data as it came; `data` is that text parsed as JSON, and `undefined` when it is not JSON. `sse()` sends a string as it is and anything else as JSON, so a `body` that may be a string, such as `z.string()`, types `data` as `unknown`, as no `body` does. A stream of text reads `text`: a token such as `" 42"` would be the number `42` as `data`. `event` is the name the server gave the event, there only when it gave one; `id` and `retry` are the event’s own. An `id` or a `retry` sent without `data` is no event, and is lost.

A failure that comes before the stream, such as the `404` above, is an ordinary failure without `events`. On a route that declares no response, `events` is optional: there when the response is a stream.

The body is one stream, read once, and the connection stays open while it is:

* Leaving the loop, by `break`, `return` or a throw, closes it.
* A stream that is never read keeps it open: read it, or abort the call. `unwrap()` throws a `TypeError` for a stream, and closes it.
* The call’s `init.signal` closes it from outside, for a timeout or when the user leaves. The loop then throws the signal’s reason: an `AbortError` for `abort()`, a `TimeoutError` for `AbortSignal.timeout()`.
* A network failure throws. A connection that dies without closing is not noticed: the loop waits until the signal aborts it.
* A handler that fails mid-stream ends it like a stream that is done. Send an event that says so, such as `done` or `error`, and check for it.

There is no reconnecting: to resume, send `last-event-id` in the call’s `headers` with the last `id` read. For a `GET` without headers, `new EventSource(api.url(path))` reconnects by itself, untyped. For TanStack Query, see [the recipe](/docs/guides/typed-client/#a-stream).

## Options

| Option    | Default            |                                                                                                                                                                    |
| --------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `baseUrl` | `""`               | put before every path                                                                                                                                              |
| `headers` | none               | sent with every request; a function is called for each one                                                                                                         |
| `fetch`   | the global `fetch` | what sends the request: SvelteKit’s `event.fetch` (with [a hook](/docs/guides/typed-client/#sveltekit) that lets `content-type` through), a test client, a wrapper |
| `init`    | none               | the rest of `RequestInit` for every request: `credentials`, `cache`, `signal`                                                                                      |

A client is a plain object, cheap enough to create one per request.

A frontend on another site than the API sends its cookies with `credentials: "include"`, set once for the client:

```ts
import { createClient } from "@tetsujs/client";


const api = createClient<typeof app>({
  baseUrl: "https://api.example.com",
  init: { credentials: "include" },
});
```

A call’s own `init` wins over the client’s key by key. A signal is the exception: the client’s and the call’s both abort the request.

A second type argument names the headers the client sends itself, so a route that requires one does not require it of each call:

```ts
import { createClient } from "@tetsujs/client";


const api = createClient<typeof app, { headers: "authorization" }>({
  headers: () => ({ authorization: `Bearer ${token}` }),
});
```

For an application with [its own error format](/docs/concepts/errors/#an-error-format-of-your-own), add `error: AppError` next to `headers`.

In tests, the core’s [test client](/docs/guides/testing/#a-client-with-a-session) is a `fetch` with a cookie jar: `createClient<typeof app>({ fetch: request.client() })`.

## The contract

`tetsu-client contract` writes the contract of the application a module exports:

```bash
bunx tetsu-client contract src/app.ts --out ../web/src/api.contract.d.ts
```

| Option            |                                                                                 |
| ----------------- | ------------------------------------------------------------------------------- |
| `--out <path>`    | where to write; printed without it                                              |
| `--export <name>` | the export with the application, or a function that builds it; `app` by default |
| `--check`         | write nothing, fail when the file is out of date                                |
| `--watch`         | write again whenever the application changes                                    |

`--name` and `--tsconfig` change the interface’s name and the `tsconfig.json` used. The contract holds plain types only: no server code, no validators, no Bun. A brand is dropped and an `enum` becomes a union of its values. A type it cannot hold, such as a function, is an error that says where.

The command runs on Bun. On first run, Bun downloads TypeScript 6.0.3 into its own cache to work the types out. From a script, `generateContract()` takes the same options and returns the contract’s text:

```ts
import { generateContract } from "@tetsujs/client/contract";


const text = await generateContract({ entry: "src/app.ts", export: "app" });
```

## TanStack Query

`@tetsujs/client/tanstack` turns a client into [TanStack Query](https://tanstack.com/query) options for any adapter:

```ts
import { queryUtils } from "@tetsujs/client/tanstack";


const q = queryUtils(api);
```

|                                             |                                                             |
| ------------------------------------------- | ----------------------------------------------------------- |
| `q.get(path).queryOptions(call)`            | a query                                                     |
| `q.get(path).infiniteOptions({ input, … })` | an infinite query; `input` turns a page param into the call |
| `q.post(path).mutationOptions()`            | a mutation; its variables are the call                      |
| `q.get(path).key()`                         | the start of the route’s keys, for `invalidateQueries`      |

A query’s error is a `ClientError`: `error.status === 404` narrows `error.body` to that status’s body. Its key is `[method, path, call]`, the call without its `init` and without what is `undefined`, and `{ infinite: true }` after it for an infinite query. It is typed, so `getQueryData` knows what it holds. The types need `@tanstack/query-core`, which every adapter installs. Usage is in the [guide](/docs/guides/typed-client/#tanstack-query).

## What the types don’t know

* A declared `404` or `422` also covers the router’s `404` and the validation `422`.
* `fetch` follows redirects, so a `3xx` arrives as the target’s response.
* A string body goes as text: for a JSON route that takes a string, send `JSON.stringify(text)`.
* A route with only `rawBody: true` takes no body from the client: give it a `bodyType`.
* A query or form array of one item reaches the server as a string, and an empty one is not sent at all; see [Converting strings](/docs/concepts/validation/#converting-strings).
* `z.coerce.boolean()` reads the text `"false"` as `true`: the client sends `false` as `"false"`. Use `z.stringbool()` in a query, a form or headers.
* `const { get } = api` loses the types: call `api.get()`.
* An undeclared `5xx` from a proxy may have no body, so its `error` can be `undefined`.
* In TanStack Query, a network failure is a `TypeError`, not a `ClientError`; `error?.status` is safe either way.
* With pnpm, keep one version of `@tanstack/query-core`, or the keys lose their types.
* On TypeScript 5.7, type the client with an interface: `interface Api extends ContractOf<typeof app> {}`.
