rouzer 2.0.0 → 3.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 +110 -0
- package/dist/client/index.d.ts +62 -33
- package/dist/client/index.js +41 -16
- package/dist/common.d.ts +7 -0
- package/dist/http.d.ts +67 -0
- package/dist/http.js +44 -0
- package/dist/route.d.ts +42 -0
- package/dist/route.js +31 -1
- package/dist/server/router.d.ts +36 -18
- package/dist/server/router.js +43 -33
- package/dist/server/types.d.ts +29 -25
- package/dist/types.d.ts +88 -2
- package/docs/context.md +339 -0
- package/examples/basic-usage.ts +116 -0
- package/package.json +17 -10
- package/readme.md +0 -176
package/docs/context.md
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
# Rouzer context
|
|
2
|
+
|
|
3
|
+
Rouzer is for applications that want one TypeScript HTTP route tree to drive
|
|
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.
|
|
7
|
+
|
|
8
|
+
## When to use Rouzer
|
|
9
|
+
|
|
10
|
+
Use Rouzer when:
|
|
11
|
+
|
|
12
|
+
- the same TypeScript project, package, or workspace can share route
|
|
13
|
+
declarations between server and client code
|
|
14
|
+
- request validation should run before server handlers and before client `fetch`
|
|
15
|
+
calls
|
|
16
|
+
- a Hattip-compatible handler fits your server runtime
|
|
17
|
+
- generated clients should stay close to route definitions instead of being
|
|
18
|
+
produced by a separate OpenAPI build step
|
|
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.
|
|
23
|
+
|
|
24
|
+
## Core abstractions
|
|
25
|
+
|
|
26
|
+
### HTTP route trees
|
|
27
|
+
|
|
28
|
+
Declare shared routes with the `rouzer/http` subpath:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { $type } from 'rouzer'
|
|
32
|
+
import * as http from 'rouzer/http'
|
|
33
|
+
|
|
34
|
+
export const getProfile = http.get('profiles/:id', {
|
|
35
|
+
response: $type<Profile>(),
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
export const routes = { getProfile }
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
An action is a callable endpoint leaf. Use `http.get`, `http.post`, `http.put`,
|
|
42
|
+
`http.patch`, or `http.del`/`http.delete` to declare one HTTP operation. The key
|
|
43
|
+
you put the action under is the client and handler name; the action path is the
|
|
44
|
+
URL pattern.
|
|
45
|
+
|
|
46
|
+
Use `http.resource(path, children)` when several actions share a path prefix or
|
|
47
|
+
when you want nested client/handler namespaces:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
export const profiles = http.resource('profiles/:id', {
|
|
51
|
+
get: http.get({
|
|
52
|
+
response: $type<Profile>(),
|
|
53
|
+
}),
|
|
54
|
+
update: http.patch({
|
|
55
|
+
body: updateProfileSchema,
|
|
56
|
+
response: $type<Profile>(),
|
|
57
|
+
}),
|
|
58
|
+
posts: http.resource('posts', {
|
|
59
|
+
list: http.get({
|
|
60
|
+
response: $type<Post[]>(),
|
|
61
|
+
}),
|
|
62
|
+
}),
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
export const routes = { profiles }
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Resource property names do not affect the URL. Resource paths and action-local
|
|
69
|
+
paths are joined, so the examples above expose `profiles/:id`, `profiles/:id`,
|
|
70
|
+
and `profiles/:id/posts`. Path params from parent resources are accumulated into
|
|
71
|
+
child action types.
|
|
72
|
+
|
|
73
|
+
Patterns are parsed by `@remix-run/route-pattern` v0.21. Params can be inferred
|
|
74
|
+
from patterns such as `hello/:name`, `v:major.:minor`,
|
|
75
|
+
`api(/v:major(.:minor))`, `assets/*path`, and `search?q`. Full URL patterns such
|
|
76
|
+
as `https://:store.shopify.com/orders` are supported for top-level actions; keep
|
|
77
|
+
them out of resource/base-path composition.
|
|
78
|
+
|
|
79
|
+
### Method schemas
|
|
80
|
+
|
|
81
|
+
Method schemas describe the request pieces Rouzer should validate:
|
|
82
|
+
|
|
83
|
+
| Action helper | Request schemas | Notes |
|
|
84
|
+
| ------------------------------------- | -------------------------------------- | ---------------- |
|
|
85
|
+
| `http.get(...)` | `path`, `query`, `headers`, `response` | No request body. |
|
|
86
|
+
| `http.post/put/patch/delete/del(...)` | `path`, `body`, `headers`, `response` | No query schema. |
|
|
87
|
+
|
|
88
|
+
If you omit a `path` schema, TypeScript infers path params from the pattern and
|
|
89
|
+
server handlers receive them as strings. Add a Zod `path` schema when you need
|
|
90
|
+
runtime validation, transforms, or non-string handler types.
|
|
91
|
+
|
|
92
|
+
The HTTP action API models explicit operations. It does not expose the old
|
|
93
|
+
method-map `ALL` fallback route shape; declare the concrete methods your client
|
|
94
|
+
and server support.
|
|
95
|
+
|
|
96
|
+
### `$type<T>()`
|
|
97
|
+
|
|
98
|
+
`response: $type<T>()` is a TypeScript-only marker. It tells handlers and client
|
|
99
|
+
action functions what response payload type to expect, but Rouzer does not
|
|
100
|
+
validate response bodies at runtime.
|
|
101
|
+
|
|
102
|
+
Actions without a `response` marker return a raw `Response` from client action
|
|
103
|
+
functions. Actions with a `response` marker use `client.json(...)` under the hood
|
|
104
|
+
and return parsed JSON typed as `T`.
|
|
105
|
+
|
|
106
|
+
### Router
|
|
107
|
+
|
|
108
|
+
`createRouter()` returns a Hattip-compatible handler. Use `.use(middleware)` to
|
|
109
|
+
append typed `alien-middleware` middleware and `.use(routes, handlers)` to attach
|
|
110
|
+
an HTTP route tree.
|
|
111
|
+
|
|
112
|
+
The handler object mirrors the route tree:
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
createRouter().use(routes, {
|
|
116
|
+
profiles: {
|
|
117
|
+
get(ctx) {
|
|
118
|
+
return loadProfile(ctx.path.id)
|
|
119
|
+
},
|
|
120
|
+
update(ctx) {
|
|
121
|
+
return updateProfile(ctx.path.id, ctx.body)
|
|
122
|
+
},
|
|
123
|
+
posts: {
|
|
124
|
+
list(ctx) {
|
|
125
|
+
return listPosts(ctx.path.id)
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
})
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Handlers receive a context typed from middleware plus the action schema:
|
|
133
|
+
|
|
134
|
+
- `GET` handlers receive `ctx.path`, `ctx.query`, and `ctx.headers`
|
|
135
|
+
- mutation handlers receive `ctx.path`, `ctx.body`, and `ctx.headers`
|
|
136
|
+
- handlers may return a plain JSON-serializable value or a `Response`
|
|
137
|
+
- plain values are returned with `Response.json(value)`
|
|
138
|
+
- return a `Response` when you need custom status, headers, or body handling
|
|
139
|
+
|
|
140
|
+
`basePath` is prepended to route tree paths, `debug` adds matched-route debug
|
|
141
|
+
headers and more detailed validation errors, and `cors.allowOrigins` restricts
|
|
142
|
+
requests with an `Origin` header.
|
|
143
|
+
|
|
144
|
+
### Client
|
|
145
|
+
|
|
146
|
+
`createClient({ baseURL, routes })` creates:
|
|
147
|
+
|
|
148
|
+
- `client.request(action.request(args))` for a raw `Response` when the action
|
|
149
|
+
request factory contains the full path you want to call
|
|
150
|
+
- `client.json(action.request(args))` for parsed JSON and default non-2xx
|
|
151
|
+
throwing
|
|
152
|
+
- a client tree that mirrors `routes`, with action functions such as
|
|
153
|
+
`client.profiles.get(args)` when `routes` is supplied
|
|
154
|
+
|
|
155
|
+
Prefer an absolute `baseURL` for generated client URLs:
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
const client = createClient({
|
|
159
|
+
baseURL: new URL('/api/', window.location.origin).href,
|
|
160
|
+
routes,
|
|
161
|
+
})
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Default headers can be supplied with `headers`, per-request headers are merged on
|
|
165
|
+
top, and a custom `fetch` implementation can be supplied for tests or non-browser
|
|
166
|
+
runtimes.
|
|
167
|
+
|
|
168
|
+
### Low-level `route(...)` descriptors
|
|
169
|
+
|
|
170
|
+
The root package still exports `route(pattern, methods)`. It creates method-keyed
|
|
171
|
+
request descriptor factories such as `legacyRoute.GET(args)` for explicit
|
|
172
|
+
`client.request(...)` or `client.json(...)` calls.
|
|
173
|
+
|
|
174
|
+
Prefer `rouzer/http` route trees for shared server/client routing. The router and
|
|
175
|
+
client shorthand registration APIs expect `HttpAction`/`HttpResource` trees, not
|
|
176
|
+
the older method-map objects produced by `route(...)`.
|
|
177
|
+
|
|
178
|
+
## Lifecycle
|
|
179
|
+
|
|
180
|
+
1. Define shared HTTP actions/resources with `rouzer/http` and Zod schemas.
|
|
181
|
+
2. Attach that route tree to a server with `createRouter().use(routes, handlers)`.
|
|
182
|
+
3. Create a client with the same route tree.
|
|
183
|
+
4. Client action calls validate `path`, `query`, `body`, and `headers` before
|
|
184
|
+
`fetch`.
|
|
185
|
+
5. The router matches the request, validates the matched inputs, and calls the
|
|
186
|
+
handler.
|
|
187
|
+
6. Plain handler results become JSON responses; explicit `Response` objects pass
|
|
188
|
+
through unchanged.
|
|
189
|
+
|
|
190
|
+
On the server, `path`, `query`, and `headers` values originate as strings. Rouzer
|
|
191
|
+
coerces Zod `number` schemas with `Number(value)` and Zod `boolean` schemas from
|
|
192
|
+
`"true"` and `"false"`. JSON request bodies are parsed and validated without that
|
|
193
|
+
string-coercion step.
|
|
194
|
+
|
|
195
|
+
## Common tasks
|
|
196
|
+
|
|
197
|
+
### Choose a client call style
|
|
198
|
+
|
|
199
|
+
Use client action functions for normal application calls:
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
await client.profiles.get({ path: { id: '42' } })
|
|
203
|
+
await client.profiles.update({
|
|
204
|
+
path: { id: '42' },
|
|
205
|
+
body: { name: 'Ada' },
|
|
206
|
+
})
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Use longhand calls when you need to choose response handling explicitly. The
|
|
210
|
+
action request factory must include the full path you want to call, so this style
|
|
211
|
+
is most convenient for top-level actions:
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
export const getProfile = http.get('profiles/:id', {
|
|
215
|
+
response: $type<Profile>(),
|
|
216
|
+
})
|
|
217
|
+
export const routes = { getProfile }
|
|
218
|
+
|
|
219
|
+
const response = await client.request(
|
|
220
|
+
routes.getProfile.request({ path: { id: '42' } })
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
const json = await client.json(
|
|
224
|
+
routes.getProfile.request({ path: { id: '42' } })
|
|
225
|
+
)
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### Group resource actions
|
|
229
|
+
|
|
230
|
+
Use resources when the public API reads better as a tree or when actions share
|
|
231
|
+
path params:
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
export const organizations = http.resource('orgs/:orgId', {
|
|
235
|
+
members: http.resource('members/:memberId', {
|
|
236
|
+
get: http.get({ response: $type<Member>() }),
|
|
237
|
+
remove: http.delete({}),
|
|
238
|
+
}),
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
await client.organizations.members.get({
|
|
242
|
+
path: { orgId: 'acme', memberId: '42' },
|
|
243
|
+
})
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
### Return custom responses
|
|
247
|
+
|
|
248
|
+
Return a `Response` from a handler for non-JSON payloads, custom status codes, or
|
|
249
|
+
custom headers. Return a plain value for the default `Response.json(value)` path.
|
|
250
|
+
|
|
251
|
+
### Customize JSON errors
|
|
252
|
+
|
|
253
|
+
By default, `client.json(...)` throws for non-2xx responses. If the response body
|
|
254
|
+
is JSON, its properties are copied onto the thrown `Error`.
|
|
255
|
+
|
|
256
|
+
`onJsonError` can override that behavior. Its return value is returned from
|
|
257
|
+
`client.json(...)` as-is; Rouzer does not automatically parse a returned
|
|
258
|
+
`Response` from `onJsonError`.
|
|
259
|
+
|
|
260
|
+
### Update code written for v2.0.1
|
|
261
|
+
|
|
262
|
+
Rouzer now uses action/resource route trees for router registration and client
|
|
263
|
+
shorthands. A v2.0.1 method-map route such as this:
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
export const profileRoute = route('profiles/:id', {
|
|
267
|
+
GET: { response: $type<Profile>() },
|
|
268
|
+
PATCH: { body: updateProfileSchema, response: $type<Profile>() },
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
export const routes = { profileRoute }
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
becomes a named action tree:
|
|
275
|
+
|
|
276
|
+
```ts
|
|
277
|
+
import * as http from 'rouzer/http'
|
|
278
|
+
|
|
279
|
+
export const profiles = http.resource('profiles/:id', {
|
|
280
|
+
get: http.get({ response: $type<Profile>() }),
|
|
281
|
+
update: http.patch({
|
|
282
|
+
body: updateProfileSchema,
|
|
283
|
+
response: $type<Profile>(),
|
|
284
|
+
}),
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
export const routes = { profiles }
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Handler maps and client calls mirror the new action names:
|
|
291
|
+
|
|
292
|
+
```ts
|
|
293
|
+
createRouter().use(routes, {
|
|
294
|
+
profiles: {
|
|
295
|
+
get(ctx) {
|
|
296
|
+
return loadProfile(ctx.path.id)
|
|
297
|
+
},
|
|
298
|
+
update(ctx) {
|
|
299
|
+
return updateProfile(ctx.path.id, ctx.body)
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
await client.profiles.get({ path: { id: '42' } })
|
|
305
|
+
await client.profiles.update({
|
|
306
|
+
path: { id: '42' },
|
|
307
|
+
body: { name: 'Ada' },
|
|
308
|
+
})
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
## Patterns to prefer
|
|
312
|
+
|
|
313
|
+
- Export route trees from a small shared module and import that module on both
|
|
314
|
+
server and client.
|
|
315
|
+
- Use `rouzer/http` actions for routes that are registered with
|
|
316
|
+
`createRouter().use(...)` or `createClient({ routes })`.
|
|
317
|
+
- Add Zod schemas when you need runtime guarantees; rely on inferred path params
|
|
318
|
+
only when string params are sufficient.
|
|
319
|
+
- Use `response: $type<T>()` for JSON endpoints that should have typed client
|
|
320
|
+
action functions.
|
|
321
|
+
- Name actions after domain operations (`get`, `list`, `update`, `archive`) and
|
|
322
|
+
let `http.get/post/put/patch/delete` own the transport method.
|
|
323
|
+
- Set `content-type: application/json` yourself when your server or middleware
|
|
324
|
+
depends on that header.
|
|
325
|
+
|
|
326
|
+
## Constraints and gotchas
|
|
327
|
+
|
|
328
|
+
- `$type<T>()` is compile-time only and does not validate response payloads.
|
|
329
|
+
- Pathname route patterns expect an absolute client `baseURL`.
|
|
330
|
+
- Resource and action keys are API names only; paths come from the pattern
|
|
331
|
+
strings passed to `http.resource(...)` and action helpers.
|
|
332
|
+
- Nested action `.request(...)` factories do not include parent resource paths;
|
|
333
|
+
prefer client action functions for nested resources.
|
|
334
|
+
- Extra `RequestInit` fields in route args, such as `signal` or `credentials`,
|
|
335
|
+
are accepted by the type surface but are not forwarded by `createClient`.
|
|
336
|
+
- The HTTP action API has no `ALL` fallback route. Declare explicit actions for
|
|
337
|
+
supported methods.
|
|
338
|
+
- Rouzer does not automatically set `Access-Control-Allow-Credentials`; set it in
|
|
339
|
+
your handler when credentialed cross-origin requests need it.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { HattipHandler } from '@hattip/core'
|
|
2
|
+
import * as z from 'zod'
|
|
3
|
+
import { $type, chain, createClient, createRouter } from 'rouzer'
|
|
4
|
+
import * as http from 'rouzer/http'
|
|
5
|
+
|
|
6
|
+
type Profile = {
|
|
7
|
+
id: string
|
|
8
|
+
name: string
|
|
9
|
+
includePosts: boolean
|
|
10
|
+
requestId: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const profiles = http.resource('profiles/:id', {
|
|
14
|
+
get: http.get({
|
|
15
|
+
query: z.object({
|
|
16
|
+
includePosts: z.optional(z.boolean()),
|
|
17
|
+
}),
|
|
18
|
+
response: $type<Profile>(),
|
|
19
|
+
}),
|
|
20
|
+
update: http.patch({
|
|
21
|
+
body: z.object({
|
|
22
|
+
name: z.string().check(z.minLength(1)),
|
|
23
|
+
}),
|
|
24
|
+
headers: z.object({
|
|
25
|
+
'content-type': z.literal('application/json'),
|
|
26
|
+
}),
|
|
27
|
+
response: $type<Profile>(),
|
|
28
|
+
}),
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
export const routes = { profiles }
|
|
32
|
+
|
|
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
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function runBasicUsageExample() {
|
|
58
|
+
const profileMap = new Map([['42', { id: '42', name: 'Ada' }]])
|
|
59
|
+
|
|
60
|
+
const requestMiddleware = chain().use(ctx => ({
|
|
61
|
+
requestId: ctx.request.headers.get('x-request-id') ?? 'local',
|
|
62
|
+
}))
|
|
63
|
+
|
|
64
|
+
const handler = createRouter({ basePath: 'api/' })
|
|
65
|
+
.use(requestMiddleware)
|
|
66
|
+
.use(routes, {
|
|
67
|
+
profiles: {
|
|
68
|
+
get(ctx) {
|
|
69
|
+
const profile = profileMap.get(ctx.path.id)
|
|
70
|
+
if (!profile) {
|
|
71
|
+
return new Response('Profile not found', { status: 404 })
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
...profile,
|
|
75
|
+
includePosts: ctx.query.includePosts ?? false,
|
|
76
|
+
requestId: ctx.requestId,
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
update(ctx) {
|
|
80
|
+
const current = profileMap.get(ctx.path.id)
|
|
81
|
+
if (!current) {
|
|
82
|
+
return new Response('Profile not found', { status: 404 })
|
|
83
|
+
}
|
|
84
|
+
const profile = { ...current, name: ctx.body.name }
|
|
85
|
+
profileMap.set(ctx.path.id, profile)
|
|
86
|
+
return {
|
|
87
|
+
...profile,
|
|
88
|
+
includePosts: false,
|
|
89
|
+
requestId: ctx.requestId,
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
const client = createClient({
|
|
96
|
+
baseURL: 'https://example.test/api/',
|
|
97
|
+
routes,
|
|
98
|
+
headers: {
|
|
99
|
+
'content-type': 'application/json',
|
|
100
|
+
},
|
|
101
|
+
fetch: createLocalFetch(handler),
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
const fetched = await client.profiles.get({
|
|
105
|
+
path: { id: '42' },
|
|
106
|
+
query: { includePosts: false },
|
|
107
|
+
headers: { 'x-request-id': 'docs' },
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
const updated = await client.profiles.update({
|
|
111
|
+
path: { id: '42' },
|
|
112
|
+
body: { name: 'Grace' },
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
return { fetched, updated }
|
|
116
|
+
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rouzer",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
7
7
|
"types": "./dist/index.d.ts",
|
|
8
8
|
"import": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"./http": {
|
|
11
|
+
"types": "./dist/http.d.ts",
|
|
12
|
+
"import": "./dist/http.js"
|
|
9
13
|
}
|
|
10
14
|
},
|
|
11
15
|
"peerDependencies": {
|
|
@@ -14,20 +18,20 @@
|
|
|
14
18
|
"devDependencies": {
|
|
15
19
|
"@alloc/prettier-config": "^1.0.0",
|
|
16
20
|
"@hattip/adapter-test": "^0.0.49",
|
|
17
|
-
"@types/node": "^25.0
|
|
18
|
-
"@typescript/native-preview": "7.0.0-dev.
|
|
19
|
-
"prettier": "^3.
|
|
21
|
+
"@types/node": "^25.8.0",
|
|
22
|
+
"@typescript/native-preview": "7.0.0-dev.20260515.1",
|
|
23
|
+
"prettier": "^3.8.3",
|
|
20
24
|
"rouzer": "link:.",
|
|
21
25
|
"tsc-lint": "^0.1.9",
|
|
22
|
-
"typescript": "^
|
|
23
|
-
"vite": "^
|
|
24
|
-
"vitest": "^4.
|
|
25
|
-
"zod": "^4.
|
|
26
|
+
"typescript": "^6.0.3",
|
|
27
|
+
"vite": "^8.0.13",
|
|
28
|
+
"vitest": "^4.1.6",
|
|
29
|
+
"zod": "^4.4.3"
|
|
26
30
|
},
|
|
27
31
|
"dependencies": {
|
|
28
32
|
"@hattip/core": "^0.0.49",
|
|
29
|
-
"@remix-run/route-pattern": "^0.
|
|
30
|
-
"alien-middleware": "^0.11.
|
|
33
|
+
"@remix-run/route-pattern": "^0.21.1",
|
|
34
|
+
"alien-middleware": "^0.11.6"
|
|
31
35
|
},
|
|
32
36
|
"prettier": "@alloc/prettier-config",
|
|
33
37
|
"license": "MIT",
|
|
@@ -37,6 +41,9 @@
|
|
|
37
41
|
},
|
|
38
42
|
"files": [
|
|
39
43
|
"dist",
|
|
44
|
+
"docs",
|
|
45
|
+
"examples",
|
|
46
|
+
"README.md",
|
|
40
47
|
"!*.tsbuildinfo"
|
|
41
48
|
],
|
|
42
49
|
"scripts": {
|
package/readme.md
DELETED
|
@@ -1,176 +0,0 @@
|
|
|
1
|
-
# rouzer
|
|
2
|
-
|
|
3
|
-
Type-safe routes shared by your server and client, powered by `zod` (input validation + transforms), `@remix-run/route-pattern` (URL matching), and `alien-middleware` (typed middleware chaining). The router output is intended to be used with `@hattip/core` adapters.
|
|
4
|
-
|
|
5
|
-
## Install
|
|
6
|
-
|
|
7
|
-
```sh
|
|
8
|
-
pnpm add rouzer zod
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
Everything is imported directly from `rouzer`.
|
|
12
|
-
|
|
13
|
-
## Define routes (shared)
|
|
14
|
-
|
|
15
|
-
```ts
|
|
16
|
-
// routes.ts
|
|
17
|
-
import * as z from 'zod'
|
|
18
|
-
import { $type, route } from 'rouzer'
|
|
19
|
-
|
|
20
|
-
export const helloRoute = route('hello/:name', {
|
|
21
|
-
GET: {
|
|
22
|
-
query: z.object({
|
|
23
|
-
excited: z.optional(z.boolean()),
|
|
24
|
-
}),
|
|
25
|
-
// The response is only type-checked at compile time.
|
|
26
|
-
response: $type<{ message: string }>(),
|
|
27
|
-
},
|
|
28
|
-
})
|
|
29
|
-
```
|
|
30
|
-
|
|
31
|
-
The following request parts can be validated with Zod:
|
|
32
|
-
|
|
33
|
-
- `path`
|
|
34
|
-
- `query`
|
|
35
|
-
- `body`
|
|
36
|
-
- `headers`
|
|
37
|
-
|
|
38
|
-
Zod validation happens on both the server and client.
|
|
39
|
-
|
|
40
|
-
## Route URL patterns
|
|
41
|
-
|
|
42
|
-
Rouzer uses `@remix-run/route-pattern` for matching and generation. Patterns can include:
|
|
43
|
-
|
|
44
|
-
- Pathname-only patterns like `blog/:slug` (default).
|
|
45
|
-
- Full URLs with protocol/hostname/port like `https://:store.shopify.com/orders`.
|
|
46
|
-
- Dynamic segments with `:param` names (valid JS identifiers), including multiple params in one segment like `v:major.:minor`.
|
|
47
|
-
- Optional segments wrapped in parentheses, which can be nested like `api(/v:major(.:minor))`.
|
|
48
|
-
- Wildcards with `*name` (captured) or `*` (uncaptured) for multi-segment paths like `assets/*path` or `files/*`.
|
|
49
|
-
- Query matching with `?` to require parameters or exact values like `search?q` or `search?q=routing`.
|
|
50
|
-
|
|
51
|
-
## Server router
|
|
52
|
-
|
|
53
|
-
```ts
|
|
54
|
-
import { chain, createRouter } from 'rouzer'
|
|
55
|
-
import { routes } from './routes'
|
|
56
|
-
|
|
57
|
-
const middlewares = chain().use(ctx => {
|
|
58
|
-
// An example middleware. For more info, see https://github.com/alien-rpc/alien-middleware#readme
|
|
59
|
-
return {
|
|
60
|
-
db: postgres(ctx.env('POSTGRES_URL')),
|
|
61
|
-
}
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
export const handler = createRouter({
|
|
65
|
-
debug: process.env.NODE_ENV === 'development',
|
|
66
|
-
})
|
|
67
|
-
.use(middlewares)
|
|
68
|
-
.use(routes, {
|
|
69
|
-
helloRoute: {
|
|
70
|
-
GET(ctx) {
|
|
71
|
-
const message = `Hello, ${ctx.path.name}${
|
|
72
|
-
ctx.query.excited ? '!' : '.'
|
|
73
|
-
}`
|
|
74
|
-
return { message }
|
|
75
|
-
},
|
|
76
|
-
},
|
|
77
|
-
})
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
## Router options
|
|
81
|
-
|
|
82
|
-
```ts
|
|
83
|
-
export const handler = createRouter({
|
|
84
|
-
basePath: 'api/',
|
|
85
|
-
cors: {
|
|
86
|
-
allowOrigins: [
|
|
87
|
-
'example.net',
|
|
88
|
-
'https://*.example.com',
|
|
89
|
-
'*://localhost:3000',
|
|
90
|
-
],
|
|
91
|
-
},
|
|
92
|
-
debug: process.env.NODE_ENV === 'development',
|
|
93
|
-
}).use(routes, {
|
|
94
|
-
helloRoute: {
|
|
95
|
-
GET(ctx) {
|
|
96
|
-
const message = `Hello, ${ctx.path.name}${ctx.query.excited ? '!' : '.'}`
|
|
97
|
-
return { message }
|
|
98
|
-
},
|
|
99
|
-
},
|
|
100
|
-
})
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
- `basePath` is prepended to every route (leading/trailing slashes are trimmed).
|
|
104
|
-
- CORS preflight (`OPTIONS`) is handled automatically for matched routes.
|
|
105
|
-
- `cors.allowOrigins` restricts preflight requests to a list of origins (default is to allow any origin).
|
|
106
|
-
- Wildcards are supported for protocol and subdomain; the protocol is optional and defaults to `https`.
|
|
107
|
-
- If you rely on `Cookie` or `Authorization` request headers, you must set
|
|
108
|
-
`Access-Control-Allow-Credentials` in your handler.
|
|
109
|
-
|
|
110
|
-
## Client wrapper
|
|
111
|
-
|
|
112
|
-
```ts
|
|
113
|
-
import { createClient } from 'rouzer'
|
|
114
|
-
import { helloRoute } from './routes'
|
|
115
|
-
|
|
116
|
-
const client = createClient({ baseURL: '/api/' })
|
|
117
|
-
|
|
118
|
-
const { message } = await client.json(
|
|
119
|
-
helloRoute.GET({ path: { name: 'world' }, query: { excited: true } })
|
|
120
|
-
)
|
|
121
|
-
|
|
122
|
-
// If you want the Response object, use `client.request` instead.
|
|
123
|
-
const response = await client.request(
|
|
124
|
-
helloRoute.GET({ path: { name: 'world' } })
|
|
125
|
-
)
|
|
126
|
-
|
|
127
|
-
const { message } = await response.json()
|
|
128
|
-
```
|
|
129
|
-
|
|
130
|
-
### Custom fetch
|
|
131
|
-
|
|
132
|
-
You can also pass a custom fetch implementation:
|
|
133
|
-
|
|
134
|
-
```ts
|
|
135
|
-
const client = createClient({
|
|
136
|
-
baseURL: '/api/',
|
|
137
|
-
fetch: myFetch,
|
|
138
|
-
})
|
|
139
|
-
```
|
|
140
|
-
|
|
141
|
-
### Shorthand route methods
|
|
142
|
-
|
|
143
|
-
Optionally pass your routes map to `createClient` to get per-route methods on the client:
|
|
144
|
-
|
|
145
|
-
```ts
|
|
146
|
-
import * as routes from './routes'
|
|
147
|
-
|
|
148
|
-
const client = createClient({
|
|
149
|
-
baseURL: '/api/',
|
|
150
|
-
routes, // <–– Pass the routes
|
|
151
|
-
})
|
|
152
|
-
|
|
153
|
-
// Shorthand methods now available:
|
|
154
|
-
await client.fooRoute.GET()
|
|
155
|
-
// …same as the longhand:
|
|
156
|
-
await client.json(routes.fooRoute.GET())
|
|
157
|
-
```
|
|
158
|
-
|
|
159
|
-
Routes that define a `response` type will call `client.json()` under the hood and return the parsed value; routes without one return the raw `Response`:
|
|
160
|
-
|
|
161
|
-
```ts
|
|
162
|
-
// helloRoute has a response schema, so you get the parsed payload
|
|
163
|
-
const { message } = await client.helloRoute.GET({
|
|
164
|
-
path: { name: 'world' },
|
|
165
|
-
})
|
|
166
|
-
|
|
167
|
-
// imagine pingRoute has no response schema; you get a Response object
|
|
168
|
-
const pingResponse = await client.pingRoute.GET({})
|
|
169
|
-
const pingText = await pingResponse.text()
|
|
170
|
-
```
|
|
171
|
-
|
|
172
|
-
## Add an endpoint
|
|
173
|
-
|
|
174
|
-
1. Declare it in `routes.ts` with `route(…)` and `zod` schemas.
|
|
175
|
-
2. Implement the handler in your router assembly with `createRouter(…).use(routes, { … })`.
|
|
176
|
-
3. Call it from the client with the generated helper via `client.json` or `client.request`.
|