rouzer 5.3.1 → 6.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,200 @@
1
+ # Migrating from v5 to v6
2
+
3
+ Rouzer v6 moves the server runtime boundary from Hattip-compatible handlers to
4
+ fetch-compatible Rouzer handlers. Route declarations, handler maps, generated
5
+ clients, response maps, response plugins, raw bodies, and NDJSON routes keep the
6
+ same Rouzer shape.
7
+
8
+ Most applications only need to update server mounting code, tests, and any
9
+ explicit Hattip handler/context types.
10
+
11
+ ## Replace Hattip adapters with Fetch handlers
12
+
13
+ Remove Hattip packages that were used only to mount Rouzer handlers.
14
+
15
+ ```sh
16
+ pnpm remove @hattip/core @hattip/adapter-node @hattip/adapter-test
17
+ pnpm add rouzer zod
18
+ ```
19
+
20
+ Use Rouzer's root `toFetchHandler` helper when a server or test harness expects
21
+ a function that receives a Web `Request` and returns a `Response`.
22
+
23
+ ```ts
24
+ import { createRouter, toFetchHandler } from 'rouzer'
25
+
26
+ const router = createRouter({ basePath: 'api/' }).use(routes, handlers)
27
+
28
+ export const fetch = toFetchHandler(router)
29
+ ```
30
+
31
+ If your server already accepts a Fetch API handler, mount `fetch` directly. If
32
+ your server exposes host metadata such as client IP, runtime data, environment
33
+ lookups, or background work, pass it through `toFetchHandler`.
34
+
35
+ ```ts
36
+ const fetch = toFetchHandler(router, {
37
+ host: request => ({
38
+ ip: request.headers.get('x-forwarded-for') ?? undefined,
39
+ runtime: { name: 'node' },
40
+ env: name => process.env[name],
41
+ waitUntil: promise => {
42
+ void promise
43
+ },
44
+ }),
45
+ })
46
+ ```
47
+
48
+ ## Replace Hattip types
49
+
50
+ Rouzer no longer requires Hattip types in application code.
51
+
52
+ ```ts
53
+ // v5
54
+ import type { AdapterRequestContext, HattipHandler } from '@hattip/core'
55
+
56
+ // v6
57
+ import type { RequestContext, RequestHandler } from 'rouzer'
58
+ ```
59
+
60
+ Rouzer routers are `RequestHandler` values. Middleware and route handlers receive
61
+ `RequestContext` plus the route-specific fields that Rouzer infers from your
62
+ route tree.
63
+
64
+ ## Update tests
65
+
66
+ If tests used `@hattip/adapter-test`, replace it with a small local fetch
67
+ wrapper.
68
+
69
+ ```ts
70
+ // v5
71
+ import { createTestClient } from '@hattip/adapter-test'
72
+
73
+ const client = createClient({
74
+ baseURL,
75
+ routes,
76
+ fetch: createTestClient({
77
+ handler,
78
+ baseUrl: baseURL,
79
+ }),
80
+ })
81
+ ```
82
+
83
+ ```ts
84
+ // v6
85
+ import { createClient, toFetchHandler, type RequestHandler } from 'rouzer'
86
+
87
+ function createLocalFetch(handler: RequestHandler): typeof fetch {
88
+ const fetchHandler = toFetchHandler(handler)
89
+ return (input, init) => fetchHandler(new Request(input, init))
90
+ }
91
+
92
+ const client = createClient({
93
+ baseURL,
94
+ routes,
95
+ fetch: createLocalFetch(handler),
96
+ })
97
+ ```
98
+
99
+ The wrapper matters because generated clients call `fetch(input, init)`, while a
100
+ Rouzer fetch handler receives one `Request`.
101
+
102
+ ## Use `ctx.host` for host data
103
+
104
+ Host-provided values now live under `ctx.host`.
105
+
106
+ ```ts
107
+ const ip = ctx.host.ip
108
+ const runtimeName = ctx.host.runtime?.name
109
+ ```
110
+
111
+ If old code read adapter-specific fields directly from the context, move those
112
+ values behind the `host` option when creating the fetch handler.
113
+
114
+ ```ts
115
+ const fetch = toFetchHandler(router, {
116
+ host: request => ({
117
+ ip: request.headers.get('x-forwarded-for') ?? undefined,
118
+ runtime: { name: 'custom' },
119
+ }),
120
+ })
121
+ ```
122
+
123
+ ## Rename runtime filters
124
+
125
+ If code used the old platform filter helper from Rouzer's root exports, use
126
+ `filterRuntime`.
127
+
128
+ ```ts
129
+ // v5
130
+ import { filterPlatform } from 'rouzer'
131
+
132
+ const middleware = chain().use(filterPlatform('node'))
133
+ ```
134
+
135
+ ```ts
136
+ // v6
137
+ import { filterRuntime } from 'rouzer'
138
+
139
+ const middleware = chain().use(filterRuntime<{ name: 'node' }>('node'))
140
+ ```
141
+
142
+ `filterRuntime(name)` checks `ctx.host.runtime?.name` and narrows the downstream
143
+ runtime type.
144
+
145
+ ## Update custom contexts
146
+
147
+ If tests or custom adapters constructed request contexts directly, use
148
+ `createContext` with a Web `Request` and optional `host` data.
149
+
150
+ ```ts
151
+ import { createContext } from 'rouzer'
152
+
153
+ const context = createContext({
154
+ request: new Request('https://example.test/api/health'),
155
+ host: {
156
+ ip: '127.0.0.1',
157
+ runtime: { name: 'test' },
158
+ env: name => process.env[name],
159
+ waitUntil: promise => {
160
+ void promise
161
+ },
162
+ },
163
+ })
164
+
165
+ const response = await router(context)
166
+ ```
167
+
168
+ ## Update pass-through assumptions
169
+
170
+ `ctx.passThrough()` is middleware-chain control flow. It skips the rest of the
171
+ current chain and lets the parent chain continue. In a final fetch handler, an
172
+ unresolved request becomes Rouzer's default `404 Not Found` response.
173
+
174
+ Use `ctx.passThrough()` for optional middleware branches and runtime filters.
175
+ Return a `Response` when you want to stop the request deliberately.
176
+
177
+ ## What stays the same
178
+
179
+ These Rouzer APIs keep the same shape:
180
+
181
+ - `rouzer/http` resources and actions
182
+ - `createRouter().use(routes, handlers)` route registration
183
+ - middleware added with `.use(middleware)`
184
+ - generated clients from `createClient({ baseURL, routes })`
185
+ - flat client action input objects
186
+ - `$type<T>()`, `$error<T>()`, and response maps
187
+ - response plugins such as `ndjson.routerPlugin` and `ndjson.clientPlugin`
188
+ - `http.rawBody()` request bodies
189
+
190
+ ## Checklist
191
+
192
+ 1. Remove Hattip packages that were only used for Rouzer serving or tests.
193
+ 2. Mount routers with `toFetchHandler(router)`.
194
+ 3. Replace Hattip handler/context types with Rouzer `RequestHandler` and
195
+ `RequestContext`.
196
+ 4. Replace test adapters with a local fetch wrapper.
197
+ 5. Move host metadata into the `host` option and read it from `ctx.host`.
198
+ 6. Replace platform filters with `filterRuntime`.
199
+ 7. Keep route trees, handler maps, clients, responses, and plugins unchanged
200
+ unless your application code was also changing them.
@@ -0,0 +1,139 @@
1
+ # Patterns, constraints, and migrations
2
+
3
+ Use this page as a checklist when designing route modules, middleware, and
4
+ client usage. For the current server-boundary upgrade, use
5
+ [Migration from v5 to v6](migration-v5-to-v6.md).
6
+
7
+ ## Preferred Patterns
8
+
9
+ - Export route trees from small shared modules and import them from server and
10
+ client code.
11
+ - Keep route declarations close to domain boundaries, not scattered through
12
+ handler implementations.
13
+ - Use `http.resource(...)` for resource namespaces and shared path params.
14
+ - Name actions after domain operations such as `get`, `list`, `update`,
15
+ `archive`, and `stream`.
16
+ - Let `http.get`, `http.post`, `http.put`, `http.patch`, and `http.delete` own
17
+ the transport method.
18
+ - Add Zod schemas when runtime guarantees matter; rely on inferred path params
19
+ only when string params are enough.
20
+ - Use `$type<T>()` for typed JSON success responses.
21
+ - Use response maps with `$error<T>()` for declared application errors that
22
+ callers should handle as data.
23
+ - Use `Response` returns for redirects, binary payloads, custom non-JSON error
24
+ bodies, or unusual headers.
25
+ - Use `ndjson.$type<T>()` only for response streams where each line is a JSON
26
+ value.
27
+ - Put shared auth, tracing, request IDs, environment bindings, and host-runtime
28
+ checks in middleware before route registration.
29
+
30
+ ## Constraints And Gotchas
31
+
32
+ - `$type<T>()`, `$error<T>()`, and `ndjson.$type<T>()` are compile-time type
33
+ contracts. They do not re-validate handler return values.
34
+ - Client action input is flat across path, query, and JSON body fields. Avoid
35
+ duplicate field names across those schemas.
36
+ - Per-request `RequestInit` fields belong in the second client action argument.
37
+ Rouzer reserves `method`.
38
+ - Raw-body actions with path or query input accept `body` in the second
39
+ argument. Raw-body actions without route input accept the body as the first
40
+ argument.
41
+ - `GET` actions do not accept request bodies. Mutation actions do not accept
42
+ query schemas.
43
+ - The action API has no `ALL` fallback route. Declare concrete actions for
44
+ supported HTTP methods.
45
+ - Pathname route patterns expect an absolute client `baseURL`.
46
+ - Resource and action keys are API names only. URL paths come from the pattern
47
+ strings passed to resources and actions.
48
+ - Routes that use response plugin markers require matching router and client
49
+ plugins.
50
+ - Declared `$error<T>()` responses are JSON responses. Use a custom `Response`
51
+ for non-JSON error payloads.
52
+ - Rouzer CORS support does not set `Access-Control-Allow-Credentials`.
53
+
54
+ ## Middleware Constraints
55
+
56
+ - Host data is under `ctx.host`, including `ctx.host.ip` and
57
+ `ctx.host.runtime`.
58
+ - The `env`, `runtime`, and `onResponse` request plugin keys are reserved.
59
+ - `runtime` is a type-level marker for `ctx.host.runtime`; it does not create
60
+ `ctx.runtime`.
61
+ - Use `ctx.setHeader` from request middleware and `response.headers.set(...)`
62
+ inside response callbacks.
63
+ - `ctx.passThrough()` skips the rest of the current chain. It is not an adapter
64
+ pass-through mechanism.
65
+ - Use `.isolate()` when a chain should run without leaking context properties to
66
+ the parent chain.
67
+
68
+ ## Project Shape
69
+
70
+ A common layout:
71
+
72
+ ```text
73
+ src/
74
+ routes/
75
+ profiles.ts
76
+ server/
77
+ middleware.ts
78
+ router.ts
79
+ client/
80
+ api.ts
81
+ ```
82
+
83
+ `routes/profiles.ts` exports route contracts. `server/router.ts` imports those
84
+ contracts and attaches handlers. `client/api.ts` imports the same contracts and
85
+ creates the typed client.
86
+
87
+ Keep route modules free of server-only dependencies when browser clients import
88
+ them.
89
+
90
+ ## v2 To v3 Route Shape
91
+
92
+ Older Rouzer code used method-map routes. Current Rouzer uses action/resource
93
+ route trees.
94
+
95
+ ```ts
96
+ // Old shape
97
+ export const profileRoute = route('profiles/:id', {
98
+ GET: { response: $type<Profile>() },
99
+ PATCH: { body: updateProfileSchema, response: $type<Profile>() },
100
+ })
101
+ ```
102
+
103
+ Use named actions instead:
104
+
105
+ ```ts
106
+ export const profiles = http.resource('profiles/:id', {
107
+ get: http.get({ response: $type<Profile>() }),
108
+ update: http.patch({
109
+ body: updateProfileSchema,
110
+ response: $type<Profile>(),
111
+ }),
112
+ })
113
+
114
+ export const routes = { profiles }
115
+ ```
116
+
117
+ Handler maps and clients mirror those action names.
118
+
119
+ ```ts
120
+ createRouter().use(routes, {
121
+ profiles: {
122
+ get(ctx) {
123
+ return loadProfile(ctx.path.id)
124
+ },
125
+ update(ctx) {
126
+ return updateProfile(ctx.path.id, ctx.body)
127
+ },
128
+ },
129
+ })
130
+
131
+ await client.profiles.get({ id: '42' })
132
+ await client.profiles.update({ id: '42', name: 'Ada' })
133
+ ```
134
+
135
+ ## Runtime Context Notes
136
+
137
+ Use `ctx.host.runtime` for host runtime metadata and `filterRuntime(...)` for
138
+ runtime-specific branches. Keep runtime checks at the middleware boundary so
139
+ route handlers stay focused on request validation and application behavior.
@@ -0,0 +1,183 @@
1
+ # Responses, errors, and plugins
2
+
3
+ Rouzer response schemas shape handler return types and generated client result
4
+ types. They do not automatically validate handler return values at runtime.
5
+
6
+ ## Plain JSON Success
7
+
8
+ Use `$type<T>()` for JSON responses that should be typed on the server and
9
+ client.
10
+
11
+ ```ts
12
+ export const getProfile = http.get('profiles/:id', {
13
+ response: $type<Profile>(),
14
+ })
15
+
16
+ createRouter().use(
17
+ { getProfile },
18
+ {
19
+ getProfile(ctx) {
20
+ return { id: ctx.path.id, name: 'Ada' }
21
+ },
22
+ }
23
+ )
24
+
25
+ const profile = await client.getProfile({ id: '42' })
26
+ ```
27
+
28
+ Handlers may return a plain JSON-serializable value or a custom `Response`.
29
+ Plain values are sent with `Response.json(value)`.
30
+
31
+ ## Raw Response
32
+
33
+ If an action has no `response` marker, generated client action functions resolve
34
+ to the raw `Response`.
35
+
36
+ ```ts
37
+ export const download = http.get('exports/:id', {})
38
+
39
+ const response = await client.download({ id: 'exp_123' })
40
+ const blob = await response.blob()
41
+ ```
42
+
43
+ Return a `Response` from handlers for redirects, custom headers, binary bodies,
44
+ non-JSON content, or custom status handling.
45
+
46
+ ## Declared Error Responses
47
+
48
+ Use `$error<T>()` inside a response map when an error status is part of the route
49
+ contract and callers should handle it as typed data instead of an exception.
50
+
51
+ ```ts
52
+ type User = { id: string; name: string }
53
+ type NotFound = { code: 'NOT_FOUND'; message: string }
54
+
55
+ export const getUser = http.get('users/:id', {
56
+ response: {
57
+ 200: $type<User>(),
58
+ 404: $error<NotFound>(),
59
+ },
60
+ })
61
+ ```
62
+
63
+ Response-map handlers can return a default success value directly or use helper
64
+ methods.
65
+
66
+ ```ts
67
+ createRouter().use(
68
+ { getUser },
69
+ {
70
+ getUser(ctx) {
71
+ const user = users.get(ctx.path.id)
72
+ if (!user) {
73
+ return ctx.error(404, {
74
+ code: 'NOT_FOUND',
75
+ message: 'User not found',
76
+ })
77
+ }
78
+ return user
79
+ },
80
+ }
81
+ )
82
+ ```
83
+
84
+ Generated clients resolve declared statuses as tuples:
85
+
86
+ - success: `[null, value, status]`
87
+ - error: `[error, null, status]`
88
+
89
+ ```ts
90
+ const [error, user, status] = await client.getUser({ id: 'missing' })
91
+
92
+ if (status === 404) {
93
+ console.log(error.message)
94
+ } else {
95
+ console.log(user.name)
96
+ }
97
+ ```
98
+
99
+ Declared error statuses do not reject the client promise. Undeclared statuses
100
+ still go through `onJsonError` or throw the default error.
101
+
102
+ ## Multiple Success Statuses
103
+
104
+ When a response map declares multiple success statuses, return a plain value for
105
+ the default success status or use `ctx.success(status, body)` to choose a
106
+ specific declared success status.
107
+
108
+ ```ts
109
+ export const createUser = http.post('users', {
110
+ body: createUserSchema,
111
+ response: {
112
+ 200: $type<User>(),
113
+ 201: $type<User>(),
114
+ 409: $error<Conflict>(),
115
+ },
116
+ })
117
+
118
+ createRouter().use(
119
+ { createUser },
120
+ {
121
+ createUser(ctx) {
122
+ if (alreadyExists(ctx.body.email)) {
123
+ return ctx.error(409, {
124
+ code: 'CONFLICT',
125
+ message: 'Email already exists',
126
+ })
127
+ }
128
+
129
+ return ctx.success(201, create(ctx.body))
130
+ },
131
+ }
132
+ )
133
+ ```
134
+
135
+ The helpers only accept statuses and bodies declared in the map.
136
+
137
+ ## Response Plugins
138
+
139
+ Response plugins add non-JSON response codecs without changing route matching or
140
+ request validation. A plugin has:
141
+
142
+ - a compile-time response marker
143
+ - a router plugin that encodes handler results into `Response` objects
144
+ - a client plugin that decodes successful `Response` objects into client results
145
+
146
+ NDJSON is the built-in example:
147
+
148
+ ```ts
149
+ export const events = http.get('events', {
150
+ response: ndjson.$type<Event>(),
151
+ })
152
+
153
+ createRouter({
154
+ plugins: [ndjson.routerPlugin],
155
+ })
156
+
157
+ createClient({
158
+ baseURL,
159
+ routes,
160
+ plugins: [ndjson.clientPlugin],
161
+ })
162
+ ```
163
+
164
+ Rouzer validates plugin registration when routes are attached to a router or
165
+ client. Routes that use an unregistered response marker fail fast instead of
166
+ falling back to JSON.
167
+
168
+ Plugin markers can be used directly as an action response or as success entries
169
+ in a response map. `$error<T>()` entries are JSON error responses.
170
+
171
+ ## Runtime Validation Boundary
172
+
173
+ `$type<T>()`, `$error<T>()`, and plugin markers are TypeScript contracts. Rouzer
174
+ does not re-check handler return values against those types at the server
175
+ boundary.
176
+
177
+ Validate untrusted data where it enters your system, such as:
178
+
179
+ - external API clients
180
+ - database decoders
181
+ - queue/event consumers
182
+ - form-data or file-processing layers
183
+ - UI/client boundaries that consume untrusted responses
package/docs/routes.md ADDED
@@ -0,0 +1,195 @@
1
+ # Route contracts
2
+
3
+ Declare route contracts with the `rouzer/http` subpath. A route contract is the
4
+ shared source of truth for server handler types, client action types, URL
5
+ construction, and request validation.
6
+
7
+ ```ts
8
+ import { $type, metadata } from 'rouzer'
9
+ import * as http from 'rouzer/http'
10
+ import * as z from 'zod'
11
+
12
+ type Profile = {
13
+ id: string
14
+ name: string
15
+ }
16
+
17
+ export const profiles = http.resource('profiles/:id', {
18
+ ...metadata({ description: 'Profile operations' }),
19
+ get: http.get({
20
+ query: z.object({
21
+ includePosts: z.optional(z.boolean()),
22
+ }),
23
+ response: $type<Profile>(),
24
+ }),
25
+ update: http.patch({
26
+ body: z.object({
27
+ name: z.string().check(z.minLength(1)),
28
+ }),
29
+ response: $type<Profile>(),
30
+ }),
31
+ })
32
+
33
+ export const routes = { profiles }
34
+ ```
35
+
36
+ ## Resources
37
+
38
+ Use `http.resource(path, children)` when actions share a path prefix or when the
39
+ client API should be namespaced.
40
+
41
+ ```ts
42
+ export const organizations = http.resource('orgs/:orgId', {
43
+ members: http.resource('members/:memberId', {
44
+ get: http.get({ response: $type<Member>() }),
45
+ remove: http.delete({}),
46
+ }),
47
+ })
48
+
49
+ await client.organizations.members.get({
50
+ orgId: 'acme',
51
+ memberId: '42',
52
+ })
53
+ ```
54
+
55
+ Resource keys are API names. They do not affect the URL. Resource path patterns
56
+ and action-local path patterns are joined to produce the final route path.
57
+
58
+ ## Actions
59
+
60
+ Use an action for each HTTP operation:
61
+
62
+ | Helper | Request schemas | Notes |
63
+ | ------------------ | -------------------------------------- | ------------------------------------------- |
64
+ | `http.get(...)` | `path`, `query`, `headers`, `response` | `GET` actions do not accept request bodies. |
65
+ | `http.post(...)` | `path`, `body`, `headers`, `response` | Mutation action. No `query` schema. |
66
+ | `http.put(...)` | `path`, `body`, `headers`, `response` | Mutation action. No `query` schema. |
67
+ | `http.patch(...)` | `path`, `body`, `headers`, `response` | Mutation action. No `query` schema. |
68
+ | `http.delete(...)` | `path`, `body`, `headers`, `response` | Mutation action. No `query` schema. |
69
+
70
+ Each helper accepts either `(schema)` or `(path, schema)`.
71
+
72
+ ```ts
73
+ export const listProfiles = http.get('profiles', {
74
+ query: z.object({ page: z.optional(z.number()) }),
75
+ response: $type<Profile[]>(),
76
+ })
77
+
78
+ export const getProfile = http.get({
79
+ response: $type<Profile>(),
80
+ })
81
+ ```
82
+
83
+ The HTTP action API models explicit operations. It does not have an `ALL`
84
+ fallback route. Declare each supported method directly.
85
+
86
+ ## Path Patterns
87
+
88
+ Rouzer uses `@remix-run/route-pattern` for path patterns. Common patterns
89
+ include:
90
+
91
+ - `profiles/:id`
92
+ - `v:major.:minor`
93
+ - `api(/v:major(.:minor))`
94
+ - `assets/*path`
95
+ - `search?q`
96
+
97
+ If you omit a `path` schema, TypeScript infers path params from the route
98
+ pattern and server handlers receive strings. Add a Zod `path` schema when you
99
+ need runtime validation, transforms, or non-string handler types.
100
+
101
+ ```ts
102
+ export const profile = http.get('profiles/:id', {
103
+ path: z.object({
104
+ id: z.uuid(),
105
+ }),
106
+ response: $type<Profile>(),
107
+ })
108
+ ```
109
+
110
+ Full URL patterns can be used for top-level actions. Keep full URL patterns out
111
+ of resource/base-path composition because resources and router `basePath`
112
+ compose path segments.
113
+
114
+ ## Request Schemas
115
+
116
+ Request schemas are Zod objects except for `body: http.rawBody()`.
117
+
118
+ ```ts
119
+ export const updateProfile = http.patch('profiles/:id', {
120
+ path: z.object({ id: z.uuid() }),
121
+ body: z.object({ name: z.string() }),
122
+ headers: z.object({
123
+ 'content-type': z.literal('application/json'),
124
+ }),
125
+ response: $type<Profile>(),
126
+ })
127
+ ```
128
+
129
+ On the client, path, query, and JSON body fields are flattened into the first
130
+ action argument. Avoid duplicate field names across those schemas because a flat
131
+ object cannot represent separate values for the same key.
132
+
133
+ ```ts
134
+ await client.updateProfile({
135
+ id: 'a0f12d72-24e5-4eb0-bb70-6345f574e62a',
136
+ name: 'Ada',
137
+ })
138
+ ```
139
+
140
+ Per-request `RequestInit` options, including headers and abort signals, are
141
+ passed as the second argument.
142
+
143
+ ## Raw Bodies
144
+
145
+ Use `http.rawBody()` when an action should pass a `BodyInit` through to `fetch`
146
+ without JSON encoding.
147
+
148
+ ```ts
149
+ export const uploadAvatar = http.post('profiles/:id/avatar', {
150
+ body: http.rawBody(),
151
+ headers: z.object({ 'content-type': z.string() }),
152
+ })
153
+
154
+ await client.uploadAvatar(
155
+ { id: '42' },
156
+ { body: file, headers: { 'content-type': file.type } }
157
+ )
158
+ ```
159
+
160
+ For a raw-body route without path or query input, the generated client accepts
161
+ the body as the first argument.
162
+
163
+ ```ts
164
+ export const upload = http.post('uploads', {
165
+ body: http.rawBody(),
166
+ })
167
+
168
+ await client.upload(file, {
169
+ headers: { 'content-type': file.type },
170
+ })
171
+ ```
172
+
173
+ Server handlers for raw-body routes read from `ctx.request` with Fetch APIs such
174
+ as `arrayBuffer()`, `blob()`, `formData()`, or `text()`. Rouzer does not parse
175
+ or validate raw request bodies.
176
+
177
+ ## Metadata
178
+
179
+ Use `metadata(...)` to attach optional runtime metadata to resources or actions.
180
+ Metadata does not affect routing, validation, client typing, or handler
181
+ behavior.
182
+
183
+ ```ts
184
+ export const sessions = http.resource('sessions', {
185
+ ...metadata({ description: 'Session control' }),
186
+ list: http.post('list', {
187
+ ...metadata({ description: 'List sessions' }),
188
+ response: $type<SessionList>(),
189
+ }),
190
+ })
191
+ ```
192
+
193
+ Constructed route nodes expose metadata through `node.metadata`. Use it for
194
+ generated documentation, CLIs, route inspectors, or application-specific
195
+ tooling.