rouzer 5.3.1 → 6.0.1

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/routes.md ADDED
@@ -0,0 +1,206 @@
1
+ # Route contracts
2
+
3
+ > Define each HTTP operation once so its URL, validated input, handler type, and
4
+ > generated client call stay aligned.
5
+
6
+ Declare route contracts with the `rouzer/http` subpath. A route contract is the
7
+ shared source of truth for server handler types, client action types, URL
8
+ construction, and request validation.
9
+
10
+ ```ts
11
+ import { $type, metadata } from 'rouzer'
12
+ import * as http from 'rouzer/http'
13
+ import * as z from 'zod'
14
+
15
+ type Profile = {
16
+ id: string
17
+ name: string
18
+ }
19
+
20
+ export const profiles = http.resource('profiles/:id', {
21
+ ...metadata({ description: 'Profile operations' }),
22
+ get: http.get({
23
+ query: z.object({
24
+ includePosts: z.optional(z.boolean()),
25
+ }),
26
+ response: $type<Profile>(),
27
+ }),
28
+ update: http.patch({
29
+ body: z.object({
30
+ name: z.string().check(z.minLength(1)),
31
+ }),
32
+ response: $type<Profile>(),
33
+ }),
34
+ })
35
+
36
+ export const routes = { profiles }
37
+ ```
38
+
39
+ ## Resources
40
+
41
+ Use `http.resource(path, children)` when actions share a path prefix or when the
42
+ client API should be namespaced.
43
+
44
+ ```ts
45
+ export const organizations = http.resource('orgs/:orgId', {
46
+ members: http.resource('members/:memberId', {
47
+ get: http.get({ response: $type<Member>() }),
48
+ remove: http.delete({}),
49
+ }),
50
+ })
51
+
52
+ await client.organizations.members.get({
53
+ orgId: 'acme',
54
+ memberId: '42',
55
+ })
56
+ ```
57
+
58
+ Resource keys are API names. They do not affect the URL. Resource path patterns
59
+ and action-local path patterns are joined to produce the final route path.
60
+
61
+ ## Actions
62
+
63
+ Use an action for each HTTP operation:
64
+
65
+ | Helper | Request schemas | Notes |
66
+ | ------------------ | -------------------------------------- | ------------------------------------------- |
67
+ | `http.get(...)` | `path`, `query`, `headers`, `response` | `GET` actions do not accept request bodies. |
68
+ | `http.post(...)` | `path`, `body`, `headers`, `response` | Mutation action. No `query` schema. |
69
+ | `http.put(...)` | `path`, `body`, `headers`, `response` | Mutation action. No `query` schema. |
70
+ | `http.patch(...)` | `path`, `body`, `headers`, `response` | Mutation action. No `query` schema. |
71
+ | `http.delete(...)` | `path`, `body`, `headers`, `response` | Mutation action. No `query` schema. |
72
+
73
+ Each helper accepts either `(schema)` or `(path, schema)`.
74
+
75
+ ```ts
76
+ export const listProfiles = http.get('profiles', {
77
+ query: z.object({ page: z.optional(z.number()) }),
78
+ response: $type<Profile[]>(),
79
+ })
80
+
81
+ export const getProfile = http.get({
82
+ response: $type<Profile>(),
83
+ })
84
+ ```
85
+
86
+ The HTTP action API models explicit operations. It does not have an `ALL`
87
+ fallback route. Declare each supported method directly.
88
+
89
+ ## Path Patterns
90
+
91
+ Rouzer uses `@remix-run/route-pattern` for path patterns. Common patterns
92
+ include:
93
+
94
+ - `profiles/:id`
95
+ - `v:major.:minor`
96
+ - `api(/v:major(.:minor))`
97
+ - `assets/*path`
98
+ - `search?q`
99
+
100
+ If you omit a `path` schema, TypeScript infers path params from the route
101
+ pattern and server handlers receive strings. Add a Zod `path` schema when you
102
+ need runtime validation, transforms, or non-string handler types.
103
+
104
+ ```ts
105
+ export const profile = http.get('profiles/:id', {
106
+ path: z.object({
107
+ id: z.uuid(),
108
+ }),
109
+ response: $type<Profile>(),
110
+ })
111
+ ```
112
+
113
+ Full URL patterns can be used for top-level actions. Keep full URL patterns out
114
+ of resource/base-path composition because resources and router `basePath`
115
+ compose path segments.
116
+
117
+ ## Request Schemas
118
+
119
+ Request schemas are Zod objects except for `body: http.rawBody()`.
120
+
121
+ ```ts
122
+ export const updateProfile = http.patch('profiles/:id', {
123
+ path: z.object({ id: z.uuid() }),
124
+ body: z.object({ name: z.string() }),
125
+ headers: z.object({
126
+ 'content-type': z.literal('application/json'),
127
+ }),
128
+ response: $type<Profile>(),
129
+ })
130
+ ```
131
+
132
+ On the client, path, query, and JSON body fields are flattened into the first
133
+ action argument.
134
+
135
+ > [!IMPORTANT]
136
+ > Keep field names unique across path, query, and JSON body schemas. A flat
137
+ > client input cannot represent separate values for the same key.
138
+
139
+ ```ts
140
+ await client.updateProfile({
141
+ id: 'a0f12d72-24e5-4eb0-bb70-6345f574e62a',
142
+ name: 'Ada',
143
+ })
144
+ ```
145
+
146
+ Per-request `RequestInit` options, including headers and abort signals, are
147
+ passed as the second argument.
148
+
149
+ ## Raw Bodies
150
+
151
+ Use `http.rawBody()` when an action should pass a `BodyInit` through to `fetch`
152
+ without JSON encoding.
153
+
154
+ ```ts
155
+ export const uploadAvatar = http.post('profiles/:id/avatar', {
156
+ body: http.rawBody(),
157
+ headers: z.object({ 'content-type': z.string() }),
158
+ })
159
+
160
+ await client.uploadAvatar(
161
+ { id: '42' },
162
+ { body: file, headers: { 'content-type': file.type } }
163
+ )
164
+ ```
165
+
166
+ > [!NOTE]
167
+ > Raw-body argument placement depends on route input. With path or query input,
168
+ > pass the body as `options.body`; without route input, pass the body as the
169
+ > first argument.
170
+
171
+ For a raw-body route without path or query input, the generated client accepts
172
+ the body as the first argument.
173
+
174
+ ```ts
175
+ export const upload = http.post('uploads', {
176
+ body: http.rawBody(),
177
+ })
178
+
179
+ await client.upload(file, {
180
+ headers: { 'content-type': file.type },
181
+ })
182
+ ```
183
+
184
+ Server handlers for raw-body routes read from `ctx.request` with Fetch APIs such
185
+ as `arrayBuffer()`, `blob()`, `formData()`, or `text()`. Rouzer does not parse
186
+ or validate raw request bodies.
187
+
188
+ ## Metadata
189
+
190
+ Use `metadata(...)` to attach optional runtime metadata to resources or actions.
191
+ Metadata does not affect routing, validation, client typing, or handler
192
+ behavior.
193
+
194
+ ```ts
195
+ export const sessions = http.resource('sessions', {
196
+ ...metadata({ description: 'Session control' }),
197
+ list: http.post('list', {
198
+ ...metadata({ description: 'List sessions' }),
199
+ response: $type<SessionList>(),
200
+ }),
201
+ })
202
+ ```
203
+
204
+ Constructed route nodes expose metadata through `node.metadata`. Use it for
205
+ generated documentation, CLIs, route inspectors, or application-specific
206
+ tooling.
@@ -0,0 +1,145 @@
1
+ # Runtime and adapters
2
+
3
+ > Adapt a Rouzer request handler to a Fetch runtime while keeping host-specific
4
+ > data behind the request context boundary.
5
+
6
+ Rouzer routers are request handlers. Use adapter helpers to turn them into
7
+ functions accepted by your HTTP server, framework, or tests.
8
+
9
+ ## Plain Fetch Handler
10
+
11
+ For a plain Web `Request`, use the root `toFetchHandler` re-export.
12
+
13
+ ```ts
14
+ import { createRouter, toFetchHandler } from 'rouzer'
15
+
16
+ const router = createRouter().use(routes, handlers)
17
+ const fetchHandler = toFetchHandler(router)
18
+
19
+ const response = await fetchHandler(new Request('https://example.test/users'))
20
+ ```
21
+
22
+ `toFetchHandler(handler)` creates a Rouzer request context for each request and
23
+ calls the handler.
24
+
25
+ ## Host Data
26
+
27
+ Pass host data when middleware or handlers need environment variables, runtime
28
+ metadata, client IP, or background work support.
29
+
30
+ ```ts
31
+ const fetchHandler = toFetchHandler(router, {
32
+ host: request => ({
33
+ ip: request.headers.get('x-forwarded-for') ?? undefined,
34
+ runtime: { name: 'custom' },
35
+ env: name => process.env[name],
36
+ waitUntil: promise => {
37
+ void promise
38
+ },
39
+ }),
40
+ })
41
+ ```
42
+
43
+ Handlers read that data from the request context:
44
+
45
+ ```ts
46
+ ctx.host.ip
47
+ ctx.host.runtime?.name
48
+ ctx.env('DATABASE_URL')
49
+ ctx.waitUntil(writeAuditLog())
50
+ ```
51
+
52
+ Host runtime data lives under `ctx.host.runtime`.
53
+
54
+ ## Fetch-Compatible Servers
55
+
56
+ Many servers and frameworks accept a function that receives a Web `Request` and
57
+ returns a `Response`. Mount `toFetchHandler(router)` in those environments.
58
+
59
+ ```ts
60
+ const router = createRouter().use(routes, handlers)
61
+ const fetch = toFetchHandler(router)
62
+ ```
63
+
64
+ If the server exposes extra request metadata, pass it through the `host` option
65
+ so middleware and handlers can read it from `ctx.host`.
66
+
67
+ ## Custom Contexts
68
+
69
+ Use `createContext` when writing custom adapters or tests that call a handler
70
+ directly.
71
+
72
+ ```ts
73
+ import { createContext } from 'rouzer'
74
+
75
+ const context = createContext({
76
+ request: new Request('https://example.test/api/health'),
77
+ host: {
78
+ runtime: { name: 'test' },
79
+ env: name => process.env[name],
80
+ },
81
+ })
82
+
83
+ const response = await router(context)
84
+ ```
85
+
86
+ Most tests should prefer a local fetch wrapper because it exercises URL
87
+ construction and request creation through the client.
88
+
89
+ ```ts
90
+ import { toFetchHandler, type RequestHandler } from 'rouzer'
91
+
92
+ function createLocalFetch(handler: RequestHandler): typeof fetch {
93
+ const fetchHandler = toFetchHandler(handler)
94
+ return (input, init) => fetchHandler(new Request(input, init))
95
+ }
96
+ ```
97
+
98
+ ## CORS
99
+
100
+ Rouzer can restrict requests with an `Origin` header through router config.
101
+
102
+ ```ts
103
+ createRouter({
104
+ cors: {
105
+ allowOrigins: [
106
+ 'example.net',
107
+ 'https://*.example.com',
108
+ '*://localhost:3000',
109
+ ],
110
+ },
111
+ })
112
+ ```
113
+
114
+ Origins may contain wildcard protocol and subdomain segments. Origins without a
115
+ protocol default to `https`.
116
+
117
+ For allowed non-preflight requests, Rouzer sets
118
+ `Access-Control-Allow-Origin`. For preflight requests, Rouzer returns
119
+ `Access-Control-Allow-Origin`, `Access-Control-Allow-Methods`, and
120
+ `Access-Control-Allow-Headers`.
121
+
122
+ > [!WARNING]
123
+ > Rouzer does not set `Access-Control-Allow-Credentials`. Set it yourself before
124
+ > relying on credentialed cross-origin requests.
125
+
126
+ ## Background Work
127
+
128
+ Use `ctx.waitUntil(promise)` in middleware or handlers when the host supports
129
+ background work.
130
+
131
+ ```ts
132
+ ctx.waitUntil(
133
+ writeAuditLog({
134
+ route: ctx.url.pathname,
135
+ runtime: ctx.host.runtime?.name,
136
+ })
137
+ )
138
+ ```
139
+
140
+ The adapter delegates to `host.waitUntil` when you provide it.
141
+
142
+ > [!NOTE]
143
+ > Without `host.waitUntil`, the request context has no runtime-specific lifetime
144
+ > extension to delegate to. Provide it when background work must outlive the
145
+ > response.
@@ -0,0 +1,139 @@
1
+ # NDJSON streaming
2
+
3
+ > Stream a sequence of JSON values through a typed route while preserving
4
+ > incremental delivery, cancellation, and explicit error modeling.
5
+
6
+ Rouzer includes a response plugin for newline-delimited JSON response streams.
7
+ Use it when a route should send a sequence of JSON values without buffering the
8
+ whole response.
9
+
10
+ ```ts
11
+ import { createClient, createRouter } from 'rouzer'
12
+ import * as http from 'rouzer/http'
13
+ import * as ndjson from 'rouzer/ndjson'
14
+
15
+ type Event = {
16
+ id: number
17
+ message: string
18
+ }
19
+
20
+ export const events = http.get('events', {
21
+ response: ndjson.$type<Event>(),
22
+ })
23
+
24
+ export const routes = { events }
25
+
26
+ const router = createRouter({
27
+ plugins: [ndjson.routerPlugin],
28
+ }).use(routes, {
29
+ async *events() {
30
+ yield { id: 1, message: 'ready' }
31
+ yield { id: 2, message: 'done' }
32
+ },
33
+ })
34
+
35
+ const client = createClient({
36
+ baseURL: 'https://example.com/api/',
37
+ routes,
38
+ plugins: [ndjson.clientPlugin],
39
+ })
40
+
41
+ for await (const event of await client.events()) {
42
+ console.log(event.message)
43
+ }
44
+ ```
45
+
46
+ Handlers return an `Iterable<T>` or `AsyncIterable<T>`. The client action
47
+ resolves to an `AsyncIterable<T>`.
48
+
49
+ ## Plugin Registration
50
+
51
+ > [!IMPORTANT]
52
+ > Register both sides when a route tree contains `ndjson.$type<T>()`:
53
+
54
+ > - `ndjson.routerPlugin` in `createRouter({ plugins })`
55
+ > - `ndjson.clientPlugin` in `createClient({ plugins })`
56
+
57
+ Rouzer fails fast if a route uses a response plugin marker and the matching
58
+ plugin is not registered.
59
+
60
+ ## POST Streams
61
+
62
+ NDJSON is a response codec. Requests still use normal Rouzer request schemas.
63
+ A route can receive a JSON body and return an NDJSON stream.
64
+
65
+ ```ts
66
+ export const streamEvents = http.post('events/stream', {
67
+ body: z.object({
68
+ topic: z.string(),
69
+ }),
70
+ response: ndjson.$type<Event>(),
71
+ })
72
+
73
+ createRouter({ plugins: [ndjson.routerPlugin] }).use(
74
+ { streamEvents },
75
+ {
76
+ async *streamEvents(ctx) {
77
+ yield { id: 1, message: `topic:${ctx.body.topic}` }
78
+ },
79
+ }
80
+ )
81
+ ```
82
+
83
+ Use `http.rawBody()` only when the request body itself should pass through as a
84
+ `BodyInit`.
85
+
86
+ ## Encoding And Decoding
87
+
88
+ `ndjson.routerPlugin` serializes each yielded value with `JSON.stringify` and
89
+ adds a newline. The response content type defaults to
90
+ `application/x-ndjson; charset=utf-8`.
91
+
92
+ `ndjson.clientPlugin` decodes UTF-8 chunks, accepts `\n` and `\r\n` line endings,
93
+ and parses each line with `JSON.parse`. A final line does not need a trailing
94
+ newline. Malformed JSON throws a `SyntaxError` with the line number.
95
+
96
+ > [!NOTE]
97
+ > Streamed items are not validated against a Zod schema. If item validation is
98
+ > needed, validate before yielding on the server or while consuming on the
99
+ > client.
100
+
101
+ ## Cancellation
102
+
103
+ If a client aborts the request signal or stops iteration early by breaking from
104
+ `for await` or calling the iterator's `return()`, Rouzer cancels the response
105
+ body and calls the server source iterator's `return()`.
106
+
107
+ > [!TIP]
108
+ > Make waits for future events abort-aware when cleanup must run while an
109
+ > awaited operation is still pending.
110
+
111
+ ```ts
112
+ async function readFirst<T>(source: AsyncIterable<T>) {
113
+ const iterator = source[Symbol.asyncIterator]()
114
+ try {
115
+ return (await iterator.next()).value
116
+ } finally {
117
+ await iterator.return?.()
118
+ }
119
+ }
120
+ ```
121
+
122
+ ## Stream Errors
123
+
124
+ > [!NOTE]
125
+ > Rouzer does not convert handler or generator failures into extra NDJSON items.
126
+ > If an async generator throws after the response starts, the response stream
127
+ > errors and the client's `for await` loop throws.
128
+
129
+ Model application-level stream errors as part of your item type when clients
130
+ should receive them as data.
131
+
132
+ ```ts
133
+ type StreamItem =
134
+ | { type: 'event'; id: number; message: string }
135
+ | { type: 'error'; message: string }
136
+ ```
137
+
138
+ The [complete runnable NDJSON example](https://github.com/alloc/rouzer/blob/main/examples/ndjson-stream.ts)
139
+ includes the shared route, router, client, and stream consumption loop.
@@ -1,6 +1,11 @@
1
- import type { HattipHandler } from '@hattip/core'
2
1
  import * as z from 'zod'
3
- import { $type, chain, createClient, createRouter } from 'rouzer'
2
+ import {
3
+ $type,
4
+ chain,
5
+ createClient,
6
+ createRouter,
7
+ toFetchHandler,
8
+ } from 'rouzer'
4
9
  import * as http from 'rouzer/http'
5
10
 
6
11
  type Profile = {
@@ -30,28 +35,11 @@ export const profiles = http.resource('profiles/:id', {
30
35
 
31
36
  export const routes = { profiles }
32
37
 
33
- /**
34
- * Tiny Hattip adapter used only to keep this example self-contained. Real apps
35
- * mount the handler with a Hattip adapter for their runtime.
36
- */
37
- function createLocalFetch(handler: HattipHandler): typeof fetch {
38
- return async (input, init) => {
39
- const request = new Request(input, init)
40
- const response = await handler({
41
- request,
42
- ip: '127.0.0.1',
43
- platform: undefined,
44
- env() {
45
- return undefined
46
- },
47
- passThrough() {},
48
- waitUntil(promise) {
49
- void promise
50
- },
51
- })
52
-
53
- return response ?? new Response(null, { status: 404 })
54
- }
38
+ function createLocalFetch(
39
+ handler: ReturnType<typeof createRouter>
40
+ ): typeof fetch {
41
+ const fetchHandler = toFetchHandler(handler)
42
+ return (input, init) => fetchHandler(new Request(input, init))
55
43
  }
56
44
 
57
45
  export async function runBasicUsageExample() {
@@ -101,7 +89,10 @@ export async function runBasicUsageExample() {
101
89
  fetch: createLocalFetch(handler),
102
90
  })
103
91
 
104
- const fetched = await client.profiles.get({ id: '42', includePosts: false }, { headers: { 'x-request-id': 'docs' } })
92
+ const fetched = await client.profiles.get(
93
+ { id: '42', includePosts: false },
94
+ { headers: { 'x-request-id': 'docs' } }
95
+ )
105
96
 
106
97
  const updated = await client.profiles.update({ id: '42', name: 'Grace' })
107
98
 
@@ -1,5 +1,10 @@
1
- import type { HattipHandler } from '@hattip/core'
2
- import { $error, $type, createClient, createRouter } from 'rouzer'
1
+ import {
2
+ $error,
3
+ $type,
4
+ createClient,
5
+ createRouter,
6
+ toFetchHandler,
7
+ } from 'rouzer'
3
8
  import * as http from 'rouzer/http'
4
9
 
5
10
  type User = {
@@ -28,28 +33,11 @@ export const getUser = http.get('users/:id', {
28
33
 
29
34
  export const routes = { getUser }
30
35
 
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
- }
36
+ function createLocalFetch(
37
+ handler: ReturnType<typeof createRouter>
38
+ ): typeof fetch {
39
+ const fetchHandler = toFetchHandler(handler)
40
+ return (input, init) => fetchHandler(new Request(input, init))
53
41
  }
54
42
 
55
43
  export async function runErrorResponsesExample() {
@@ -1,5 +1,4 @@
1
- import type { HattipHandler } from '@hattip/core'
2
- import { createClient, createRouter } from 'rouzer'
1
+ import { createClient, createRouter, toFetchHandler } from 'rouzer'
3
2
  import * as http from 'rouzer/http'
4
3
  import * as ndjson from 'rouzer/ndjson'
5
4
  import { z } from 'zod'
@@ -31,28 +30,11 @@ export const stream = http.post('events/stream', {
31
30
 
32
31
  export const routes = { events, stream }
33
32
 
34
- /**
35
- * Tiny Hattip adapter used only to keep this example self-contained. Real apps
36
- * mount the handler with a Hattip adapter for their runtime.
37
- */
38
- function createLocalFetch(handler: HattipHandler): typeof fetch {
39
- return async (input, init) => {
40
- const request = new Request(input, init)
41
- const response = await handler({
42
- request,
43
- ip: '127.0.0.1',
44
- platform: undefined,
45
- env() {
46
- return undefined
47
- },
48
- passThrough() {},
49
- waitUntil(promise) {
50
- void promise
51
- },
52
- })
53
-
54
- return response ?? new Response(null, { status: 404 })
55
- }
33
+ function createLocalFetch(
34
+ handler: ReturnType<typeof createRouter>
35
+ ): typeof fetch {
36
+ const fetchHandler = toFetchHandler(handler)
37
+ return (input, init) => fetchHandler(new Request(input, init))
56
38
  }
57
39
 
58
40
  async function collect<T>(source: AsyncIterable<T>) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rouzer",
3
- "version": "5.3.1",
3
+ "version": "6.0.1",
4
4
  "packageManager": "pnpm@11.5.1",
5
5
  "type": "module",
6
6
  "exports": {
@@ -28,9 +28,9 @@
28
28
  },
29
29
  "devDependencies": {
30
30
  "@alloc/prettier-config": "^1.0.0",
31
- "@hattip/adapter-test": "^0.0.49",
32
31
  "@types/node": "^25.8.0",
33
32
  "@typescript/native-preview": "7.0.0-dev.20260515.1",
33
+ "lildocs": "^0.1.15",
34
34
  "prettier": "^3.8.3",
35
35
  "rouzer": "link:.",
36
36
  "tsc-lint": "^0.1.9",
@@ -40,9 +40,8 @@
40
40
  "zod": "^4.4.3"
41
41
  },
42
42
  "dependencies": {
43
- "@hattip/core": "^0.0.49",
44
43
  "@remix-run/route-pattern": "^0.22.1",
45
- "alien-middleware": "^0.11.6"
44
+ "alien-middleware": "^0.12.0"
46
45
  },
47
46
  "prettier": "@alloc/prettier-config",
48
47
  "license": "MIT",