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