rouzer 5.3.0 → 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.
- package/README.md +113 -231
- package/dist/http.d.ts +12 -13
- package/dist/metadata.d.ts +1 -4
- package/dist/server/router.d.ts +6 -7
- package/dist/server/router.js +2 -2
- package/docs/client.md +203 -0
- package/docs/concepts.md +113 -0
- package/docs/handlers.md +202 -0
- package/docs/index.md +78 -0
- package/docs/middleware.md +185 -0
- package/docs/migration-v5-to-v6.md +200 -0
- package/docs/patterns.md +139 -0
- package/docs/responses.md +183 -0
- package/docs/routes.md +195 -0
- package/docs/runtime.md +136 -0
- package/docs/streaming.md +131 -0
- package/examples/basic-usage.ts +16 -25
- package/examples/error-responses.ts +12 -24
- package/examples/ndjson-stream.ts +6 -24
- package/package.json +2 -4
- package/docs/context.md +0 -623
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# Middleware and request context
|
|
2
|
+
|
|
3
|
+
Rouzer includes request context and middleware composition in its root API, so
|
|
4
|
+
most applications can import route, middleware, and adapter helpers from one
|
|
5
|
+
package.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import {
|
|
9
|
+
chain,
|
|
10
|
+
filterRuntime,
|
|
11
|
+
type Middleware,
|
|
12
|
+
type MiddlewareContext,
|
|
13
|
+
type RequestContext,
|
|
14
|
+
type RequestHandler,
|
|
15
|
+
} from 'rouzer'
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Middleware Chain
|
|
19
|
+
|
|
20
|
+
`chain()` creates a `MiddlewareChain`. Rouzer routers are middleware chains too,
|
|
21
|
+
so you can append middleware before attaching routes.
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
const requestInfo = chain().use(ctx => ({
|
|
25
|
+
requestId: ctx.request.headers.get('x-request-id') ?? crypto.randomUUID(),
|
|
26
|
+
}))
|
|
27
|
+
|
|
28
|
+
const router = createRouter()
|
|
29
|
+
.use(requestInfo)
|
|
30
|
+
.use(routes, {
|
|
31
|
+
getProfile(ctx) {
|
|
32
|
+
return loadProfile(ctx.path.id, ctx.requestId)
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Middleware runs in order. A middleware can:
|
|
38
|
+
|
|
39
|
+
- return `void` or `undefined` to continue without adding context
|
|
40
|
+
- return a `Response` to stop the chain and send that response
|
|
41
|
+
- return a request plugin object whose properties are merged into the downstream
|
|
42
|
+
context
|
|
43
|
+
- call `ctx.passThrough()` to skip the rest of the current chain
|
|
44
|
+
|
|
45
|
+
## Request Context
|
|
46
|
+
|
|
47
|
+
Every middleware and Rouzer handler receives a `RequestContext`.
|
|
48
|
+
|
|
49
|
+
| Property | Purpose |
|
|
50
|
+
| ---------------------------- | ----------------------------------------------------------------------------- |
|
|
51
|
+
| `ctx.request` | The Web `Request`. |
|
|
52
|
+
| `ctx.url` | Lazily parsed `URL` for `ctx.request.url`. |
|
|
53
|
+
| `ctx.host` | Host data such as `ip`, `runtime`, `env`, and `waitUntil`. |
|
|
54
|
+
| `ctx.env(name)` | Typed environment lookup backed by `ctx.host.env` and middleware env plugins. |
|
|
55
|
+
| `ctx.waitUntil(promise)` | Delegates background work to `ctx.host.waitUntil` when available. |
|
|
56
|
+
| `ctx.setHeader(name, value)` | Sets a response header from request middleware. |
|
|
57
|
+
| `ctx.onResponse(callback)` | Registers a callback that can mutate or replace the final response. |
|
|
58
|
+
| `ctx.passThrough()` | Chain-local control flow for unresolved requests. |
|
|
59
|
+
|
|
60
|
+
Host-provided values live under `ctx.host`.
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
const runtimeName = ctx.host.runtime?.name
|
|
64
|
+
const ip = ctx.host.ip
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Runtime-specific request data should stay behind `ctx.host.runtime`.
|
|
68
|
+
|
|
69
|
+
## Adding Context Properties
|
|
70
|
+
|
|
71
|
+
Return a plain object to make properties available to downstream middleware and
|
|
72
|
+
handlers.
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
const sessionMiddleware = chain().use(async ctx => {
|
|
76
|
+
const token = ctx.request.headers.get('authorization')
|
|
77
|
+
const session = token ? await readSession(token) : null
|
|
78
|
+
|
|
79
|
+
if (!session) {
|
|
80
|
+
return new Response('Unauthorized', { status: 401 })
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { session }
|
|
84
|
+
})
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Attach the middleware before route handlers that need the property.
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
createRouter()
|
|
91
|
+
.use(sessionMiddleware)
|
|
92
|
+
.use(routes, {
|
|
93
|
+
me(ctx) {
|
|
94
|
+
return ctx.session.user
|
|
95
|
+
},
|
|
96
|
+
})
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Treat context property names as owned by the middleware that introduces them.
|
|
100
|
+
Avoid collisions between unrelated middleware.
|
|
101
|
+
|
|
102
|
+
## Environment Bindings
|
|
103
|
+
|
|
104
|
+
Return an `env` object to add typed variables behind `ctx.env(...)`.
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
const envMiddleware = chain().use(() => ({
|
|
108
|
+
env: {
|
|
109
|
+
DATABASE_URL: process.env.DATABASE_URL,
|
|
110
|
+
},
|
|
111
|
+
}))
|
|
112
|
+
|
|
113
|
+
const router = createRouter()
|
|
114
|
+
.use(envMiddleware)
|
|
115
|
+
.use(routes, {
|
|
116
|
+
health(ctx) {
|
|
117
|
+
return { configured: Boolean(ctx.env('DATABASE_URL')) }
|
|
118
|
+
},
|
|
119
|
+
})
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The `env` key is reserved for environment bindings. Values are accessed through
|
|
123
|
+
`ctx.env(name)`, not as `ctx.env.DATABASE_URL`.
|
|
124
|
+
|
|
125
|
+
## Runtime Type Markers
|
|
126
|
+
|
|
127
|
+
The `runtime` request plugin key is reserved as a type-level marker for
|
|
128
|
+
`ctx.host.runtime`; it does not add `ctx.runtime`.
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
const nodeOnly = chain()
|
|
132
|
+
.use(filterRuntime<{ name: 'node' }>('node'))
|
|
133
|
+
.use(ctx => {
|
|
134
|
+
ctx.host.runtime?.name
|
|
135
|
+
})
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
`filterRuntime(name)` checks `ctx.host.runtime?.name`. If the runtime name does
|
|
139
|
+
not match, it calls `ctx.passThrough()`.
|
|
140
|
+
|
|
141
|
+
## Response Callbacks
|
|
142
|
+
|
|
143
|
+
Use `ctx.onResponse(callback)` or return `{ onResponse }` when middleware needs
|
|
144
|
+
to finalize a response after a route handler runs.
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
const timing = chain().use(ctx => {
|
|
148
|
+
const startedAt = Date.now()
|
|
149
|
+
|
|
150
|
+
ctx.onResponse(response => {
|
|
151
|
+
response.headers.set('server-timing', `app;dur=${Date.now() - startedAt}`)
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Use `ctx.setHeader(name, value)` from request middleware for simple headers.
|
|
157
|
+
Inside response callbacks, mutate `response.headers` directly or return a new
|
|
158
|
+
`Response`.
|
|
159
|
+
|
|
160
|
+
## Isolation
|
|
161
|
+
|
|
162
|
+
Use `.isolate()` when you want to run a chain for side effects or early
|
|
163
|
+
responses without leaking its plugin properties into the parent chain.
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
const optionalAuth = chain()
|
|
167
|
+
.use(readOptionalSession)
|
|
168
|
+
.use(auditSession)
|
|
169
|
+
.isolate()
|
|
170
|
+
|
|
171
|
+
const router = createRouter().use(optionalAuth).use(routes, handlers)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
An isolated chain can still return a `Response`. Its context additions stay
|
|
175
|
+
inside the isolated chain.
|
|
176
|
+
|
|
177
|
+
## Pass Through
|
|
178
|
+
|
|
179
|
+
`ctx.passThrough()` is chain-local control flow. In a nested or isolated chain,
|
|
180
|
+
it skips the rest of that chain and lets the parent chain continue. In a final
|
|
181
|
+
fetch handler, an unresolved request becomes the default `404 Not Found`
|
|
182
|
+
response.
|
|
183
|
+
|
|
184
|
+
Use `passThrough` for optional middleware branches or runtime filters. Use a
|
|
185
|
+
`Response` when you want to deliberately stop the request.
|
|
@@ -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.
|
package/docs/patterns.md
ADDED
|
@@ -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
|