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.
@@ -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.
@@ -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