rouzer 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,339 @@
1
+ # Rouzer context
2
+
3
+ Rouzer is for applications that want one TypeScript HTTP route tree to drive
4
+ both the server and the client that calls it. A route tree combines URL
5
+ patterns, named actions, HTTP method schemas, and optional compile-time response
6
+ types.
7
+
8
+ ## When to use Rouzer
9
+
10
+ Use Rouzer when:
11
+
12
+ - the same TypeScript project, package, or workspace can share route
13
+ declarations between server and client code
14
+ - request validation should run before server handlers and before client `fetch`
15
+ calls
16
+ - a Hattip-compatible handler fits your server runtime
17
+ - generated clients should stay close to route definitions instead of being
18
+ produced by a separate OpenAPI build step
19
+
20
+ Rouzer is not a response validation library, an OpenAPI generator, or a complete
21
+ server framework. It focuses on typed route contracts, validation, routing, and a
22
+ small client wrapper.
23
+
24
+ ## Core abstractions
25
+
26
+ ### HTTP route trees
27
+
28
+ Declare shared routes with the `rouzer/http` subpath:
29
+
30
+ ```ts
31
+ import { $type } from 'rouzer'
32
+ import * as http from 'rouzer/http'
33
+
34
+ export const getProfile = http.get('profiles/:id', {
35
+ response: $type<Profile>(),
36
+ })
37
+
38
+ export const routes = { getProfile }
39
+ ```
40
+
41
+ An action is a callable endpoint leaf. Use `http.get`, `http.post`, `http.put`,
42
+ `http.patch`, or `http.del`/`http.delete` to declare one HTTP operation. The key
43
+ you put the action under is the client and handler name; the action path is the
44
+ URL pattern.
45
+
46
+ Use `http.resource(path, children)` when several actions share a path prefix or
47
+ when you want nested client/handler namespaces:
48
+
49
+ ```ts
50
+ export const profiles = http.resource('profiles/:id', {
51
+ get: http.get({
52
+ response: $type<Profile>(),
53
+ }),
54
+ update: http.patch({
55
+ body: updateProfileSchema,
56
+ response: $type<Profile>(),
57
+ }),
58
+ posts: http.resource('posts', {
59
+ list: http.get({
60
+ response: $type<Post[]>(),
61
+ }),
62
+ }),
63
+ })
64
+
65
+ export const routes = { profiles }
66
+ ```
67
+
68
+ Resource property names do not affect the URL. Resource paths and action-local
69
+ paths are joined, so the examples above expose `profiles/:id`, `profiles/:id`,
70
+ and `profiles/:id/posts`. Path params from parent resources are accumulated into
71
+ child action types.
72
+
73
+ Patterns are parsed by `@remix-run/route-pattern` v0.21. Params can be inferred
74
+ from patterns such as `hello/:name`, `v:major.:minor`,
75
+ `api(/v:major(.:minor))`, `assets/*path`, and `search?q`. Full URL patterns such
76
+ as `https://:store.shopify.com/orders` are supported for top-level actions; keep
77
+ them out of resource/base-path composition.
78
+
79
+ ### Method schemas
80
+
81
+ Method schemas describe the request pieces Rouzer should validate:
82
+
83
+ | Action helper | Request schemas | Notes |
84
+ | ------------------------------------- | -------------------------------------- | ---------------- |
85
+ | `http.get(...)` | `path`, `query`, `headers`, `response` | No request body. |
86
+ | `http.post/put/patch/delete/del(...)` | `path`, `body`, `headers`, `response` | No query schema. |
87
+
88
+ If you omit a `path` schema, TypeScript infers path params from the pattern and
89
+ server handlers receive them as strings. Add a Zod `path` schema when you need
90
+ runtime validation, transforms, or non-string handler types.
91
+
92
+ The HTTP action API models explicit operations. It does not expose the old
93
+ method-map `ALL` fallback route shape; declare the concrete methods your client
94
+ and server support.
95
+
96
+ ### `$type<T>()`
97
+
98
+ `response: $type<T>()` is a TypeScript-only marker. It tells handlers and client
99
+ action functions what response payload type to expect, but Rouzer does not
100
+ validate response bodies at runtime.
101
+
102
+ Actions without a `response` marker return a raw `Response` from client action
103
+ functions. Actions with a `response` marker use `client.json(...)` under the hood
104
+ and return parsed JSON typed as `T`.
105
+
106
+ ### Router
107
+
108
+ `createRouter()` returns a Hattip-compatible handler. Use `.use(middleware)` to
109
+ append typed `alien-middleware` middleware and `.use(routes, handlers)` to attach
110
+ an HTTP route tree.
111
+
112
+ The handler object mirrors the route tree:
113
+
114
+ ```ts
115
+ createRouter().use(routes, {
116
+ profiles: {
117
+ get(ctx) {
118
+ return loadProfile(ctx.path.id)
119
+ },
120
+ update(ctx) {
121
+ return updateProfile(ctx.path.id, ctx.body)
122
+ },
123
+ posts: {
124
+ list(ctx) {
125
+ return listPosts(ctx.path.id)
126
+ },
127
+ },
128
+ },
129
+ })
130
+ ```
131
+
132
+ Handlers receive a context typed from middleware plus the action schema:
133
+
134
+ - `GET` handlers receive `ctx.path`, `ctx.query`, and `ctx.headers`
135
+ - mutation handlers receive `ctx.path`, `ctx.body`, and `ctx.headers`
136
+ - handlers may return a plain JSON-serializable value or a `Response`
137
+ - plain values are returned with `Response.json(value)`
138
+ - return a `Response` when you need custom status, headers, or body handling
139
+
140
+ `basePath` is prepended to route tree paths, `debug` adds matched-route debug
141
+ headers and more detailed validation errors, and `cors.allowOrigins` restricts
142
+ requests with an `Origin` header.
143
+
144
+ ### Client
145
+
146
+ `createClient({ baseURL, routes })` creates:
147
+
148
+ - `client.request(action.request(args))` for a raw `Response` when the action
149
+ request factory contains the full path you want to call
150
+ - `client.json(action.request(args))` for parsed JSON and default non-2xx
151
+ throwing
152
+ - a client tree that mirrors `routes`, with action functions such as
153
+ `client.profiles.get(args)` when `routes` is supplied
154
+
155
+ Prefer an absolute `baseURL` for generated client URLs:
156
+
157
+ ```ts
158
+ const client = createClient({
159
+ baseURL: new URL('/api/', window.location.origin).href,
160
+ routes,
161
+ })
162
+ ```
163
+
164
+ Default headers can be supplied with `headers`, per-request headers are merged on
165
+ top, and a custom `fetch` implementation can be supplied for tests or non-browser
166
+ runtimes.
167
+
168
+ ### Low-level `route(...)` descriptors
169
+
170
+ The root package still exports `route(pattern, methods)`. It creates method-keyed
171
+ request descriptor factories such as `legacyRoute.GET(args)` for explicit
172
+ `client.request(...)` or `client.json(...)` calls.
173
+
174
+ Prefer `rouzer/http` route trees for shared server/client routing. The router and
175
+ client shorthand registration APIs expect `HttpAction`/`HttpResource` trees, not
176
+ the older method-map objects produced by `route(...)`.
177
+
178
+ ## Lifecycle
179
+
180
+ 1. Define shared HTTP actions/resources with `rouzer/http` and Zod schemas.
181
+ 2. Attach that route tree to a server with `createRouter().use(routes, handlers)`.
182
+ 3. Create a client with the same route tree.
183
+ 4. Client action calls validate `path`, `query`, `body`, and `headers` before
184
+ `fetch`.
185
+ 5. The router matches the request, validates the matched inputs, and calls the
186
+ handler.
187
+ 6. Plain handler results become JSON responses; explicit `Response` objects pass
188
+ through unchanged.
189
+
190
+ On the server, `path`, `query`, and `headers` values originate as strings. Rouzer
191
+ coerces Zod `number` schemas with `Number(value)` and Zod `boolean` schemas from
192
+ `"true"` and `"false"`. JSON request bodies are parsed and validated without that
193
+ string-coercion step.
194
+
195
+ ## Common tasks
196
+
197
+ ### Choose a client call style
198
+
199
+ Use client action functions for normal application calls:
200
+
201
+ ```ts
202
+ await client.profiles.get({ path: { id: '42' } })
203
+ await client.profiles.update({
204
+ path: { id: '42' },
205
+ body: { name: 'Ada' },
206
+ })
207
+ ```
208
+
209
+ Use longhand calls when you need to choose response handling explicitly. The
210
+ action request factory must include the full path you want to call, so this style
211
+ is most convenient for top-level actions:
212
+
213
+ ```ts
214
+ export const getProfile = http.get('profiles/:id', {
215
+ response: $type<Profile>(),
216
+ })
217
+ export const routes = { getProfile }
218
+
219
+ const response = await client.request(
220
+ routes.getProfile.request({ path: { id: '42' } })
221
+ )
222
+
223
+ const json = await client.json(
224
+ routes.getProfile.request({ path: { id: '42' } })
225
+ )
226
+ ```
227
+
228
+ ### Group resource actions
229
+
230
+ Use resources when the public API reads better as a tree or when actions share
231
+ path params:
232
+
233
+ ```ts
234
+ export const organizations = http.resource('orgs/:orgId', {
235
+ members: http.resource('members/:memberId', {
236
+ get: http.get({ response: $type<Member>() }),
237
+ remove: http.delete({}),
238
+ }),
239
+ })
240
+
241
+ await client.organizations.members.get({
242
+ path: { orgId: 'acme', memberId: '42' },
243
+ })
244
+ ```
245
+
246
+ ### Return custom responses
247
+
248
+ Return a `Response` from a handler for non-JSON payloads, custom status codes, or
249
+ custom headers. Return a plain value for the default `Response.json(value)` path.
250
+
251
+ ### Customize JSON errors
252
+
253
+ By default, `client.json(...)` throws for non-2xx responses. If the response body
254
+ is JSON, its properties are copied onto the thrown `Error`.
255
+
256
+ `onJsonError` can override that behavior. Its return value is returned from
257
+ `client.json(...)` as-is; Rouzer does not automatically parse a returned
258
+ `Response` from `onJsonError`.
259
+
260
+ ### Update code written for v2.0.1
261
+
262
+ Rouzer now uses action/resource route trees for router registration and client
263
+ shorthands. A v2.0.1 method-map route such as this:
264
+
265
+ ```ts
266
+ export const profileRoute = route('profiles/:id', {
267
+ GET: { response: $type<Profile>() },
268
+ PATCH: { body: updateProfileSchema, response: $type<Profile>() },
269
+ })
270
+
271
+ export const routes = { profileRoute }
272
+ ```
273
+
274
+ becomes a named action tree:
275
+
276
+ ```ts
277
+ import * as http from 'rouzer/http'
278
+
279
+ export const profiles = http.resource('profiles/:id', {
280
+ get: http.get({ response: $type<Profile>() }),
281
+ update: http.patch({
282
+ body: updateProfileSchema,
283
+ response: $type<Profile>(),
284
+ }),
285
+ })
286
+
287
+ export const routes = { profiles }
288
+ ```
289
+
290
+ Handler maps and client calls mirror the new action names:
291
+
292
+ ```ts
293
+ createRouter().use(routes, {
294
+ profiles: {
295
+ get(ctx) {
296
+ return loadProfile(ctx.path.id)
297
+ },
298
+ update(ctx) {
299
+ return updateProfile(ctx.path.id, ctx.body)
300
+ },
301
+ },
302
+ })
303
+
304
+ await client.profiles.get({ path: { id: '42' } })
305
+ await client.profiles.update({
306
+ path: { id: '42' },
307
+ body: { name: 'Ada' },
308
+ })
309
+ ```
310
+
311
+ ## Patterns to prefer
312
+
313
+ - Export route trees from a small shared module and import that module on both
314
+ server and client.
315
+ - Use `rouzer/http` actions for routes that are registered with
316
+ `createRouter().use(...)` or `createClient({ routes })`.
317
+ - Add Zod schemas when you need runtime guarantees; rely on inferred path params
318
+ only when string params are sufficient.
319
+ - Use `response: $type<T>()` for JSON endpoints that should have typed client
320
+ action functions.
321
+ - Name actions after domain operations (`get`, `list`, `update`, `archive`) and
322
+ let `http.get/post/put/patch/delete` own the transport method.
323
+ - Set `content-type: application/json` yourself when your server or middleware
324
+ depends on that header.
325
+
326
+ ## Constraints and gotchas
327
+
328
+ - `$type<T>()` is compile-time only and does not validate response payloads.
329
+ - Pathname route patterns expect an absolute client `baseURL`.
330
+ - Resource and action keys are API names only; paths come from the pattern
331
+ strings passed to `http.resource(...)` and action helpers.
332
+ - Nested action `.request(...)` factories do not include parent resource paths;
333
+ prefer client action functions for nested resources.
334
+ - Extra `RequestInit` fields in route args, such as `signal` or `credentials`,
335
+ are accepted by the type surface but are not forwarded by `createClient`.
336
+ - The HTTP action API has no `ALL` fallback route. Declare explicit actions for
337
+ supported methods.
338
+ - Rouzer does not automatically set `Access-Control-Allow-Credentials`; set it in
339
+ your handler when credentialed cross-origin requests need it.
@@ -0,0 +1,116 @@
1
+ import type { HattipHandler } from '@hattip/core'
2
+ import * as z from 'zod'
3
+ import { $type, chain, createClient, createRouter } from 'rouzer'
4
+ import * as http from 'rouzer/http'
5
+
6
+ type Profile = {
7
+ id: string
8
+ name: string
9
+ includePosts: boolean
10
+ requestId: string
11
+ }
12
+
13
+ export const profiles = http.resource('profiles/:id', {
14
+ get: http.get({
15
+ query: z.object({
16
+ includePosts: z.optional(z.boolean()),
17
+ }),
18
+ response: $type<Profile>(),
19
+ }),
20
+ update: http.patch({
21
+ body: z.object({
22
+ name: z.string().check(z.minLength(1)),
23
+ }),
24
+ headers: z.object({
25
+ 'content-type': z.literal('application/json'),
26
+ }),
27
+ response: $type<Profile>(),
28
+ }),
29
+ })
30
+
31
+ export const routes = { profiles }
32
+
33
+ /**
34
+ * Tiny Hattip adapter used only to keep this example self-contained. Real apps
35
+ * mount the handler with a Hattip adapter for their runtime.
36
+ */
37
+ function createLocalFetch(handler: HattipHandler): typeof fetch {
38
+ return async (input, init) => {
39
+ const request = new Request(input, init)
40
+ const response = await handler({
41
+ request,
42
+ ip: '127.0.0.1',
43
+ platform: undefined,
44
+ env() {
45
+ return undefined
46
+ },
47
+ passThrough() {},
48
+ waitUntil(promise) {
49
+ void promise
50
+ },
51
+ })
52
+
53
+ return response ?? new Response(null, { status: 404 })
54
+ }
55
+ }
56
+
57
+ export async function runBasicUsageExample() {
58
+ const profileMap = new Map([['42', { id: '42', name: 'Ada' }]])
59
+
60
+ const requestMiddleware = chain().use(ctx => ({
61
+ requestId: ctx.request.headers.get('x-request-id') ?? 'local',
62
+ }))
63
+
64
+ const handler = createRouter({ basePath: 'api/' })
65
+ .use(requestMiddleware)
66
+ .use(routes, {
67
+ profiles: {
68
+ get(ctx) {
69
+ const profile = profileMap.get(ctx.path.id)
70
+ if (!profile) {
71
+ return new Response('Profile not found', { status: 404 })
72
+ }
73
+ return {
74
+ ...profile,
75
+ includePosts: ctx.query.includePosts ?? false,
76
+ requestId: ctx.requestId,
77
+ }
78
+ },
79
+ update(ctx) {
80
+ const current = profileMap.get(ctx.path.id)
81
+ if (!current) {
82
+ return new Response('Profile not found', { status: 404 })
83
+ }
84
+ const profile = { ...current, name: ctx.body.name }
85
+ profileMap.set(ctx.path.id, profile)
86
+ return {
87
+ ...profile,
88
+ includePosts: false,
89
+ requestId: ctx.requestId,
90
+ }
91
+ },
92
+ },
93
+ })
94
+
95
+ const client = createClient({
96
+ baseURL: 'https://example.test/api/',
97
+ routes,
98
+ headers: {
99
+ 'content-type': 'application/json',
100
+ },
101
+ fetch: createLocalFetch(handler),
102
+ })
103
+
104
+ const fetched = await client.profiles.get({
105
+ path: { id: '42' },
106
+ query: { includePosts: false },
107
+ headers: { 'x-request-id': 'docs' },
108
+ })
109
+
110
+ const updated = await client.profiles.update({
111
+ path: { id: '42' },
112
+ body: { name: 'Grace' },
113
+ })
114
+
115
+ return { fetched, updated }
116
+ }
package/package.json CHANGED
@@ -1,11 +1,15 @@
1
1
  {
2
2
  "name": "rouzer",
3
- "version": "2.0.0",
3
+ "version": "3.0.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
7
7
  "types": "./dist/index.d.ts",
8
8
  "import": "./dist/index.js"
9
+ },
10
+ "./http": {
11
+ "types": "./dist/http.d.ts",
12
+ "import": "./dist/http.js"
9
13
  }
10
14
  },
11
15
  "peerDependencies": {
@@ -14,20 +18,20 @@
14
18
  "devDependencies": {
15
19
  "@alloc/prettier-config": "^1.0.0",
16
20
  "@hattip/adapter-test": "^0.0.49",
17
- "@types/node": "^25.0.3",
18
- "@typescript/native-preview": "7.0.0-dev.20251208.1",
19
- "prettier": "^3.7.4",
21
+ "@types/node": "^25.8.0",
22
+ "@typescript/native-preview": "7.0.0-dev.20260515.1",
23
+ "prettier": "^3.8.3",
20
24
  "rouzer": "link:.",
21
25
  "tsc-lint": "^0.1.9",
22
- "typescript": "^5.9.3",
23
- "vite": "^7.3.0",
24
- "vitest": "^4.0.16",
25
- "zod": "^4.1.13"
26
+ "typescript": "^6.0.3",
27
+ "vite": "^8.0.13",
28
+ "vitest": "^4.1.6",
29
+ "zod": "^4.4.3"
26
30
  },
27
31
  "dependencies": {
28
32
  "@hattip/core": "^0.0.49",
29
- "@remix-run/route-pattern": "^0.15.3",
30
- "alien-middleware": "^0.11.4"
33
+ "@remix-run/route-pattern": "^0.21.1",
34
+ "alien-middleware": "^0.11.6"
31
35
  },
32
36
  "prettier": "@alloc/prettier-config",
33
37
  "license": "MIT",
@@ -37,6 +41,9 @@
37
41
  },
38
42
  "files": [
39
43
  "dist",
44
+ "docs",
45
+ "examples",
46
+ "README.md",
40
47
  "!*.tsbuildinfo"
41
48
  ],
42
49
  "scripts": {
package/readme.md DELETED
@@ -1,176 +0,0 @@
1
- # rouzer
2
-
3
- Type-safe routes shared by your server and client, powered by `zod` (input validation + transforms), `@remix-run/route-pattern` (URL matching), and `alien-middleware` (typed middleware chaining). The router output is intended to be used with `@hattip/core` adapters.
4
-
5
- ## Install
6
-
7
- ```sh
8
- pnpm add rouzer zod
9
- ```
10
-
11
- Everything is imported directly from `rouzer`.
12
-
13
- ## Define routes (shared)
14
-
15
- ```ts
16
- // routes.ts
17
- import * as z from 'zod'
18
- import { $type, route } from 'rouzer'
19
-
20
- export const helloRoute = route('hello/:name', {
21
- GET: {
22
- query: z.object({
23
- excited: z.optional(z.boolean()),
24
- }),
25
- // The response is only type-checked at compile time.
26
- response: $type<{ message: string }>(),
27
- },
28
- })
29
- ```
30
-
31
- The following request parts can be validated with Zod:
32
-
33
- - `path`
34
- - `query`
35
- - `body`
36
- - `headers`
37
-
38
- Zod validation happens on both the server and client.
39
-
40
- ## Route URL patterns
41
-
42
- Rouzer uses `@remix-run/route-pattern` for matching and generation. Patterns can include:
43
-
44
- - Pathname-only patterns like `blog/:slug` (default).
45
- - Full URLs with protocol/hostname/port like `https://:store.shopify.com/orders`.
46
- - Dynamic segments with `:param` names (valid JS identifiers), including multiple params in one segment like `v:major.:minor`.
47
- - Optional segments wrapped in parentheses, which can be nested like `api(/v:major(.:minor))`.
48
- - Wildcards with `*name` (captured) or `*` (uncaptured) for multi-segment paths like `assets/*path` or `files/*`.
49
- - Query matching with `?` to require parameters or exact values like `search?q` or `search?q=routing`.
50
-
51
- ## Server router
52
-
53
- ```ts
54
- import { chain, createRouter } from 'rouzer'
55
- import { routes } from './routes'
56
-
57
- const middlewares = chain().use(ctx => {
58
- // An example middleware. For more info, see https://github.com/alien-rpc/alien-middleware#readme
59
- return {
60
- db: postgres(ctx.env('POSTGRES_URL')),
61
- }
62
- })
63
-
64
- export const handler = createRouter({
65
- debug: process.env.NODE_ENV === 'development',
66
- })
67
- .use(middlewares)
68
- .use(routes, {
69
- helloRoute: {
70
- GET(ctx) {
71
- const message = `Hello, ${ctx.path.name}${
72
- ctx.query.excited ? '!' : '.'
73
- }`
74
- return { message }
75
- },
76
- },
77
- })
78
- ```
79
-
80
- ## Router options
81
-
82
- ```ts
83
- export const handler = createRouter({
84
- basePath: 'api/',
85
- cors: {
86
- allowOrigins: [
87
- 'example.net',
88
- 'https://*.example.com',
89
- '*://localhost:3000',
90
- ],
91
- },
92
- debug: process.env.NODE_ENV === 'development',
93
- }).use(routes, {
94
- helloRoute: {
95
- GET(ctx) {
96
- const message = `Hello, ${ctx.path.name}${ctx.query.excited ? '!' : '.'}`
97
- return { message }
98
- },
99
- },
100
- })
101
- ```
102
-
103
- - `basePath` is prepended to every route (leading/trailing slashes are trimmed).
104
- - CORS preflight (`OPTIONS`) is handled automatically for matched routes.
105
- - `cors.allowOrigins` restricts preflight requests to a list of origins (default is to allow any origin).
106
- - Wildcards are supported for protocol and subdomain; the protocol is optional and defaults to `https`.
107
- - If you rely on `Cookie` or `Authorization` request headers, you must set
108
- `Access-Control-Allow-Credentials` in your handler.
109
-
110
- ## Client wrapper
111
-
112
- ```ts
113
- import { createClient } from 'rouzer'
114
- import { helloRoute } from './routes'
115
-
116
- const client = createClient({ baseURL: '/api/' })
117
-
118
- const { message } = await client.json(
119
- helloRoute.GET({ path: { name: 'world' }, query: { excited: true } })
120
- )
121
-
122
- // If you want the Response object, use `client.request` instead.
123
- const response = await client.request(
124
- helloRoute.GET({ path: { name: 'world' } })
125
- )
126
-
127
- const { message } = await response.json()
128
- ```
129
-
130
- ### Custom fetch
131
-
132
- You can also pass a custom fetch implementation:
133
-
134
- ```ts
135
- const client = createClient({
136
- baseURL: '/api/',
137
- fetch: myFetch,
138
- })
139
- ```
140
-
141
- ### Shorthand route methods
142
-
143
- Optionally pass your routes map to `createClient` to get per-route methods on the client:
144
-
145
- ```ts
146
- import * as routes from './routes'
147
-
148
- const client = createClient({
149
- baseURL: '/api/',
150
- routes, // <–– Pass the routes
151
- })
152
-
153
- // Shorthand methods now available:
154
- await client.fooRoute.GET()
155
- // …same as the longhand:
156
- await client.json(routes.fooRoute.GET())
157
- ```
158
-
159
- Routes that define a `response` type will call `client.json()` under the hood and return the parsed value; routes without one return the raw `Response`:
160
-
161
- ```ts
162
- // helloRoute has a response schema, so you get the parsed payload
163
- const { message } = await client.helloRoute.GET({
164
- path: { name: 'world' },
165
- })
166
-
167
- // imagine pingRoute has no response schema; you get a Response object
168
- const pingResponse = await client.pingRoute.GET({})
169
- const pingText = await pingResponse.text()
170
- ```
171
-
172
- ## Add an endpoint
173
-
174
- 1. Declare it in `routes.ts` with `route(…)` and `zod` schemas.
175
- 2. Implement the handler in your router assembly with `createRouter(…).use(routes, { … })`.
176
- 3. Call it from the client with the generated helper via `client.json` or `client.request`.