rouzer 3.0.2 → 3.2.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/docs/context.md CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  Rouzer is for applications that want one TypeScript HTTP route tree to drive
4
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.
5
+ patterns, named actions, HTTP method schemas, and optional compile-time success,
6
+ error, or plugin response types.
7
7
 
8
8
  ## When to use Rouzer
9
9
 
@@ -17,9 +17,12 @@ Use Rouzer when:
17
17
  - generated clients should stay close to route definitions instead of being
18
18
  produced by a separate OpenAPI build step
19
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.
20
+ Rouzer is not a server response validator, an OpenAPI generator, or a complete
21
+ server framework. It focuses on typed route contracts, request validation,
22
+ routing, and a small client wrapper. Response markers are type contracts; if
23
+ response data comes from an untrusted source, validate it where it enters your
24
+ server or client code instead of relying on the router to re-check handler
25
+ returns.
23
26
 
24
27
  ## Core abstractions
25
28
 
@@ -79,9 +82,9 @@ them out of resource/base-path composition.
79
82
 
80
83
  Method schemas describe the request pieces Rouzer should validate:
81
84
 
82
- | Action helper | Request schemas | Notes |
83
- | ------------------------------------- | -------------------------------------- | ---------------- |
84
- | `http.get(...)` | `path`, `query`, `headers`, `response` | No request body. |
85
+ | Action helper | Request schemas | Notes |
86
+ | --------------------------------- | -------------------------------------- | ---------------- |
87
+ | `http.get(...)` | `path`, `query`, `headers`, `response` | No request body. |
85
88
  | `http.post/put/patch/delete(...)` | `path`, `body`, `headers`, `response` | No query schema. |
86
89
 
87
90
  If you omit a `path` schema, TypeScript infers path params from the pattern and
@@ -92,15 +95,76 @@ The HTTP action API models explicit operations. It does not expose the old
92
95
  method-map `ALL` fallback route shape; declare the concrete methods your client
93
96
  and server support.
94
97
 
95
- ### `$type<T>()`
98
+ ### Response markers and maps
96
99
 
97
- `response: $type<T>()` is a TypeScript-only marker. It tells handlers and client
98
- action functions what response payload type to expect, but Rouzer does not
99
- validate response bodies at runtime.
100
+ `response: $type<T>()` is a TypeScript-only marker for JSON success payloads. It
101
+ tells handlers and client action functions what payload type to expect, but
102
+ Rouzer does not validate handler return values at the server boundary. Validate
103
+ response data where it enters your system, such as an external API client,
104
+ database decoder, or UI/client boundary, when runtime integrity is required.
105
+
106
+ Use a status-keyed response map when callers need to branch on declared statuses:
107
+
108
+ ```ts
109
+ import { $error, $type } from 'rouzer'
110
+ import * as http from 'rouzer/http'
111
+
112
+ type User = { id: string; name: string }
113
+ type NotFound = { code: 'NOT_FOUND'; message: string }
114
+
115
+ export const getUser = http.get('users/:id', {
116
+ response: {
117
+ 200: $type<User>(),
118
+ 201: $type<User>(),
119
+ 404: $error<NotFound>(),
120
+ },
121
+ })
122
+ ```
123
+
124
+ Success entries use `$type<T>()` or a response plugin marker. Error entries use
125
+ `$error<T>()` and are encoded as JSON. Generated client action functions resolve
126
+ declared statuses as tuples:
127
+
128
+ - success: `[null, value, status]`
129
+ - error: `[error, null, status]`
130
+
131
+ Declared error statuses do not reject the client promise. Undeclared statuses
132
+ still go through `onJsonError` or throw the default error.
133
+
134
+ Handlers for response-map actions may return the default success value directly,
135
+ use `ctx.success(status, body)` to choose a declared success status, or use
136
+ `ctx.error(status, body)` to return a declared error status. The `ctx.error` and
137
+ `ctx.success` helpers only accept statuses and bodies declared in the response
138
+ map.
139
+
140
+ `response: ndjson.$type<T>()` is a TypeScript-only marker for newline-delimited
141
+ JSON response streams from the `rouzer/ndjson` subpath. Register
142
+ `ndjson.routerPlugin` with `createRouter(...)` and `ndjson.clientPlugin` with
143
+ `createClient(...)` for routes that use this marker. Handlers return an
144
+ `Iterable<T>` or `AsyncIterable<T>`; Rouzer serializes each item as one JSON line
145
+ and sets the response content type to `application/x-ndjson; charset=utf-8`.
146
+ Client action functions resolve to an `AsyncIterable<T>` parsed from the
147
+ response body. Streamed items are parsed as JSON but are not validated against a
148
+ Zod schema.
100
149
 
101
150
  Actions without a `response` marker return a raw `Response` from client action
102
- functions. Actions with a `response` marker use `client.json(...)` under the hood
103
- and return parsed JSON typed as `T`.
151
+ functions. Actions with `response: $type<T>()` return parsed JSON typed as `T`.
152
+ Actions with a response map return the tuple union described by that map.
153
+
154
+ ### Response plugins
155
+
156
+ Response plugins add non-JSON response codecs without changing route matching or
157
+ request validation. A plugin package provides a compile-time response marker and
158
+ matching runtime plugins. For NDJSON, those are `ndjson.$type<T>()`,
159
+ `ndjson.routerPlugin`, and `ndjson.clientPlugin`.
160
+
161
+ The router plugin encodes non-`Response` handler results into an HTTP `Response`.
162
+ The client plugin decodes successful HTTP responses for generated client action
163
+ functions. Plugin markers can also be success entries in a status-keyed response
164
+ map. Rouzer validates plugin registration when routes are attached to a router or
165
+ client, so routes that use an unregistered response marker fail fast instead of
166
+ falling back to JSON. Response plugins do not automatically validate response
167
+ payloads unless the plugin itself implements validation.
104
168
 
105
169
  ### Router
106
170
 
@@ -133,7 +197,12 @@ Handlers receive a context typed from middleware plus the action schema:
133
197
  - `GET` handlers receive `ctx.path`, `ctx.query`, and `ctx.headers`
134
198
  - mutation handlers receive `ctx.path`, `ctx.body`, and `ctx.headers`
135
199
  - handlers may return a plain JSON-serializable value or a `Response`
200
+ - response-map handlers can return a default success value directly or use
201
+ `ctx.success(status, body)` and `ctx.error(status, body)`
202
+ - `ndjson.$type<T>()` handlers return an `Iterable<T>` or `AsyncIterable<T>`
203
+ unless they return a custom `Response`
136
204
  - plain values are returned with `Response.json(value)`
205
+ - NDJSON iterables are returned as `application/x-ndjson` streams
137
206
  - return a `Response` when you need custom status, headers, or body handling
138
207
 
139
208
  `basePath` is prepended to route tree paths, `debug` adds matched-route debug
@@ -148,6 +217,10 @@ requests with an `Origin` header.
148
217
  request factory contains the full path you want to call
149
218
  - `client.json(action.request(args))` for parsed JSON and default non-2xx
150
219
  throwing
220
+ - response-map support for generated client action functions, returning
221
+ `[error, value, status]` tuples for declared statuses
222
+ - response plugin support for generated client action functions, such as
223
+ `ndjson.clientPlugin` for NDJSON response streams
151
224
  - a client tree that mirrors `routes`, with action functions such as
152
225
  `client.profiles.get(args)` when `routes` is supplied
153
226
 
@@ -167,14 +240,18 @@ runtimes.
167
240
  ## Lifecycle
168
241
 
169
242
  1. Define shared HTTP actions/resources with `rouzer/http` and Zod schemas.
170
- 2. Attach that route tree to a server with `createRouter().use(routes, handlers)`.
171
- 3. Create a client with the same route tree.
243
+ 2. Attach that route tree to a server with `createRouter().use(routes, handlers)`
244
+ or `createRouter({ plugins }).use(routes, handlers)` when response plugins
245
+ are needed.
246
+ 3. Create a client with the same route tree, plus matching client response
247
+ plugins when needed.
172
248
  4. Client action calls validate `path`, `query`, `body`, and `headers` before
173
249
  `fetch`.
174
250
  5. The router matches the request, validates the matched inputs, and calls the
175
251
  handler.
176
- 6. Plain handler results become JSON responses; explicit `Response` objects pass
177
- through unchanged.
252
+ 6. Plain handler results become JSON responses, response-map helpers choose
253
+ declared statuses, plugin handler results become plugin-encoded responses, and
254
+ explicit `Response` objects pass through unchanged.
178
255
 
179
256
  On the server, `path`, `query`, and `headers` values originate as strings. Rouzer
180
257
  coerces Zod `number` schemas with `Number(value)` and Zod `boolean` schemas from
@@ -214,6 +291,110 @@ const json = await client.json(
214
291
  )
215
292
  ```
216
293
 
294
+ Response maps and response plugins are applied by generated client action
295
+ functions. For longhand calls to mapped or plugin-backed routes, use
296
+ `client.request(...)` for the raw `Response` and decode the response yourself.
297
+
298
+ ### Handle declared error responses
299
+
300
+ Use `$error<T>()` inside a response map when an error status is part of the route
301
+ contract:
302
+
303
+ ```ts
304
+ import { $error, $type, createClient, createRouter } from 'rouzer'
305
+ import * as http from 'rouzer/http'
306
+
307
+ type User = { id: string; name: string }
308
+ type NotFound = { code: 'NOT_FOUND'; message: string }
309
+
310
+ export const getUser = http.get('users/:id', {
311
+ response: {
312
+ 200: $type<User>(),
313
+ 404: $error<NotFound>(),
314
+ },
315
+ })
316
+ export const routes = { getUser }
317
+
318
+ createRouter().use(routes, {
319
+ getUser(ctx) {
320
+ if (ctx.path.id === 'missing') {
321
+ return ctx.error(404, {
322
+ code: 'NOT_FOUND',
323
+ message: 'User not found',
324
+ })
325
+ }
326
+ return { id: ctx.path.id, name: 'Ada' }
327
+ },
328
+ })
329
+
330
+ const client = createClient({
331
+ baseURL: 'https://example.com/api/',
332
+ routes,
333
+ })
334
+
335
+ const [error, user, status] = await client.getUser({
336
+ path: { id: 'missing' },
337
+ })
338
+
339
+ if (status === 404) {
340
+ console.log(error.message)
341
+ } else {
342
+ console.log(user.name)
343
+ }
344
+ ```
345
+
346
+ A complete runnable version lives in
347
+ [`examples/error-responses.ts`](../examples/error-responses.ts).
348
+
349
+ When a response map declares multiple success statuses, return a plain value for
350
+ the default success status or use `ctx.success(status, body)` to choose a
351
+ specific declared success status.
352
+
353
+ ### Stream newline-delimited JSON
354
+
355
+ Use `ndjson.$type<T>()` when a handler should produce a sequence of JSON values
356
+ without buffering the whole response:
357
+
358
+ ```ts
359
+ import { createClient, createRouter } from 'rouzer'
360
+ import * as http from 'rouzer/http'
361
+ import * as ndjson from 'rouzer/ndjson'
362
+
363
+ export const events = http.get('events', {
364
+ response: ndjson.$type<{ id: number; message: string }>(),
365
+ })
366
+ export const routes = { events }
367
+
368
+ createRouter({ plugins: [ndjson.routerPlugin] }).use(routes, {
369
+ async *events() {
370
+ yield { id: 1, message: 'ready' }
371
+ yield { id: 2, message: 'done' }
372
+ },
373
+ })
374
+
375
+ const client = createClient({
376
+ baseURL: 'https://example.com/api/',
377
+ routes,
378
+ plugins: [ndjson.clientPlugin],
379
+ })
380
+ for await (const event of await client.events()) {
381
+ console.log(event.message)
382
+ }
383
+ ```
384
+
385
+ A complete runnable version lives in
386
+ [`examples/ndjson-stream.ts`](../examples/ndjson-stream.ts).
387
+
388
+ Rouzer's decoder accepts `\n` and `\r\n`, handles UTF-8 chunk boundaries, and
389
+ throws a `SyntaxError` with a line number for malformed JSON. If a consumer stops
390
+ reading early, the response body is cancelled.
391
+
392
+ Rouzer does not convert handler or generator failures into extra NDJSON items. If
393
+ an async generator throws after the response starts, the response stream errors
394
+ and the client's `for await` loop throws. Model application-level stream errors
395
+ as part of your item type, for example `{ type: 'error'; message: string }`, when
396
+ clients should receive them as data.
397
+
217
398
  ### Group resource actions
218
399
 
219
400
  Use resources when the public API reads better as a tree or when actions share
@@ -239,17 +420,18 @@ custom headers. Return a plain value for the default `Response.json(value)` path
239
420
 
240
421
  ### Customize JSON errors
241
422
 
242
- By default, `client.json(...)` throws for non-2xx responses. If the response body
423
+ By default, `client.json(...)` and generated client action functions throw for
424
+ non-2xx responses that are not declared in a response map. If the response body
243
425
  is JSON, its properties are copied onto the thrown `Error`.
244
426
 
245
- `onJsonError` can override that behavior. Its return value is returned from
246
- `client.json(...)` as-is; Rouzer does not automatically parse a returned
247
- `Response` from `onJsonError`.
427
+ `onJsonError` can override that behavior. Its return value is returned from the
428
+ response helper as-is; Rouzer does not automatically parse a returned `Response`
429
+ from `onJsonError`.
248
430
 
249
- ### Update code written for v2.0.1
431
+ ### v2->v3 migration
250
432
 
251
433
  Rouzer now uses action/resource route trees for router registration and client
252
- shorthands. A v2.0.1 method-map route such as this:
434
+ shorthands. In the v2->v3 migration, a method-map route such as this:
253
435
 
254
436
  ```ts
255
437
  export const profileRoute = route('profiles/:id', {
@@ -307,6 +489,11 @@ await client.profiles.update({
307
489
  only when string params are sufficient.
308
490
  - Use `response: $type<T>()` for JSON endpoints that should have typed client
309
491
  action functions.
492
+ - Use response maps with `$error<T>()` when callers should handle declared error
493
+ statuses as typed data instead of exceptions.
494
+ - Use `response: ndjson.$type<T>()` plus `ndjson.routerPlugin` and
495
+ `ndjson.clientPlugin` for response streams where each line is a JSON value and
496
+ the client should consume an `AsyncIterable<T>`.
310
497
  - Name actions after domain operations (`get`, `list`, `update`, `archive`) and
311
498
  let `http.get/post/put/patch/delete` own the transport method.
312
499
  - Set `content-type: application/json` yourself when your server or middleware
@@ -314,7 +501,15 @@ await client.profiles.update({
314
501
 
315
502
  ## Constraints and gotchas
316
503
 
317
- - `$type<T>()` is compile-time only and does not validate response payloads.
504
+ - `$type<T>()`, `$error<T>()`, and `ndjson.$type<T>()` are compile-time-only type
505
+ contracts. Rouzer does not re-validate handler return values at the server
506
+ boundary.
507
+ - NDJSON support is for response streams; request bodies still use the existing
508
+ JSON body schema path.
509
+ - Declared `$error<T>()` responses are JSON responses. Use a custom `Response`
510
+ for non-JSON error payloads.
511
+ - Routes that use a response plugin fail fast if the matching client or router
512
+ plugin is not registered.
318
513
  - Pathname route patterns expect an absolute client `baseURL`.
319
514
  - Resource and action keys are API names only; paths come from the pattern
320
515
  strings passed to `http.resource(...)` and action helpers.
@@ -0,0 +1,98 @@
1
+ import type { HattipHandler } from '@hattip/core'
2
+ import { $error, $type, createClient, createRouter } from 'rouzer'
3
+ import * as http from 'rouzer/http'
4
+
5
+ type User = {
6
+ id: string
7
+ name: string
8
+ }
9
+
10
+ type AuthError = {
11
+ code: 'UNAUTHORIZED'
12
+ message: string
13
+ }
14
+
15
+ type NotFoundError = {
16
+ code: 'NOT_FOUND'
17
+ message: string
18
+ }
19
+
20
+ export const getUser = http.get('users/:id', {
21
+ response: {
22
+ 200: $type<User>(),
23
+ 201: $type<User>(),
24
+ 401: $error<AuthError>(),
25
+ 404: $error<NotFoundError>(),
26
+ },
27
+ })
28
+
29
+ export const routes = { getUser }
30
+
31
+ /**
32
+ * Tiny Hattip adapter used only to keep this example self-contained. Real apps
33
+ * mount the handler with a Hattip adapter for their runtime.
34
+ */
35
+ function createLocalFetch(handler: HattipHandler): typeof fetch {
36
+ return async (input, init) => {
37
+ const request = new Request(input, init)
38
+ const response = await handler({
39
+ request,
40
+ ip: '127.0.0.1',
41
+ platform: undefined,
42
+ env() {
43
+ return undefined
44
+ },
45
+ passThrough() {},
46
+ waitUntil(promise) {
47
+ void promise
48
+ },
49
+ })
50
+
51
+ return response ?? new Response(null, { status: 404 })
52
+ }
53
+ }
54
+
55
+ export async function runErrorResponsesExample() {
56
+ const users = new Map([['42', { id: '42', name: 'Ada' }]])
57
+
58
+ const handler = createRouter({ basePath: 'api/' }).use(routes, {
59
+ getUser(ctx) {
60
+ if (ctx.path.id === 'unauthorized') {
61
+ return ctx.error(401, {
62
+ code: 'UNAUTHORIZED',
63
+ message: 'Login required',
64
+ })
65
+ }
66
+
67
+ if (ctx.path.id === 'created') {
68
+ return ctx.success(201, {
69
+ id: 'created',
70
+ name: 'Grace',
71
+ })
72
+ }
73
+
74
+ const user = users.get(ctx.path.id)
75
+ if (!user) {
76
+ return ctx.error(404, {
77
+ code: 'NOT_FOUND',
78
+ message: 'User not found',
79
+ })
80
+ }
81
+
82
+ return user
83
+ },
84
+ })
85
+
86
+ const client = createClient({
87
+ baseURL: 'https://example.test/api/',
88
+ routes,
89
+ fetch: createLocalFetch(handler),
90
+ })
91
+
92
+ const found = await client.getUser({ path: { id: '42' } })
93
+ const created = await client.getUser({ path: { id: 'created' } })
94
+ const missing = await client.getUser({ path: { id: 'missing' } })
95
+ const unauthorized = await client.getUser({ path: { id: 'unauthorized' } })
96
+
97
+ return { found, created, missing, unauthorized }
98
+ }
@@ -0,0 +1,68 @@
1
+ import type { HattipHandler } from '@hattip/core'
2
+ import { createClient, createRouter } from 'rouzer'
3
+ import * as http from 'rouzer/http'
4
+ import * as ndjson from 'rouzer/ndjson'
5
+
6
+ type Event = {
7
+ id: number
8
+ message: string
9
+ }
10
+
11
+ export const events = http.get('events', {
12
+ response: ndjson.$type<Event>(),
13
+ })
14
+
15
+ export const routes = { events }
16
+
17
+ /**
18
+ * Tiny Hattip adapter used only to keep this example self-contained. Real apps
19
+ * mount the handler with a Hattip adapter for their runtime.
20
+ */
21
+ function createLocalFetch(handler: HattipHandler): typeof fetch {
22
+ return async (input, init) => {
23
+ const request = new Request(input, init)
24
+ const response = await handler({
25
+ request,
26
+ ip: '127.0.0.1',
27
+ platform: undefined,
28
+ env() {
29
+ return undefined
30
+ },
31
+ passThrough() {},
32
+ waitUntil(promise) {
33
+ void promise
34
+ },
35
+ })
36
+
37
+ return response ?? new Response(null, { status: 404 })
38
+ }
39
+ }
40
+
41
+ async function collect<T>(source: AsyncIterable<T>) {
42
+ const values: T[] = []
43
+ for await (const value of source) {
44
+ values.push(value)
45
+ }
46
+ return values
47
+ }
48
+
49
+ export async function runNdjsonStreamExample() {
50
+ const handler = createRouter({
51
+ basePath: 'api/',
52
+ plugins: [ndjson.routerPlugin],
53
+ }).use(routes, {
54
+ async *events() {
55
+ yield { id: 1, message: 'ready' }
56
+ yield { id: 2, message: 'done' }
57
+ },
58
+ })
59
+
60
+ const client = createClient({
61
+ baseURL: 'https://example.test/api/',
62
+ routes,
63
+ plugins: [ndjson.clientPlugin],
64
+ fetch: createLocalFetch(handler),
65
+ })
66
+
67
+ return collect(await client.events())
68
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rouzer",
3
- "version": "3.0.2",
3
+ "version": "3.2.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -10,6 +10,10 @@
10
10
  "./http": {
11
11
  "types": "./dist/http.d.ts",
12
12
  "import": "./dist/http.js"
13
+ },
14
+ "./ndjson": {
15
+ "types": "./dist/ndjson.d.ts",
16
+ "import": "./dist/ndjson.js"
13
17
  }
14
18
  },
15
19
  "peerDependencies": {
@@ -48,6 +52,7 @@
48
52
  ],
49
53
  "scripts": {
50
54
  "build": "rm -rf dist && tsgo -b tsconfig.json",
55
+ "format": "prettier --write src test",
51
56
  "test": "vitest run"
52
57
  }
53
58
  }