rouzer 5.3.1 → 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/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
package/docs/runtime.md
ADDED
|
@@ -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).
|
package/examples/basic-usage.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
import type { HattipHandler } from '@hattip/core'
|
|
2
1
|
import * as z from 'zod'
|
|
3
|
-
import {
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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(
|
|
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
|
|
2
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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": "
|
|
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.
|
|
43
|
+
"alien-middleware": "^0.12.0"
|
|
46
44
|
},
|
|
47
45
|
"prettier": "@alloc/prettier-config",
|
|
48
46
|
"license": "MIT",
|