rouzer 2.0.1 → 3.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/context.md CHANGED
@@ -1,8 +1,9 @@
1
1
  # Rouzer context
2
2
 
3
- Rouzer is for applications that want one route contract to drive both the HTTP
4
- server and the client that calls it. A route declaration combines a URL pattern,
5
- HTTP method schemas, and an optional compile-time response type.
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.
6
7
 
7
8
  ## When to use Rouzer
8
9
 
@@ -13,7 +14,7 @@ Use Rouzer when:
13
14
  - request validation should run before server handlers and before client `fetch`
14
15
  calls
15
16
  - a Hattip-compatible handler fits your server runtime
16
- - generated clients should stay close to the route definitions instead of being
17
+ - generated clients should stay close to route definitions instead of being
17
18
  produced by a separate OpenAPI build step
18
19
 
19
20
  Rouzer is not a response validation library, an OpenAPI generator, or a complete
@@ -22,43 +23,112 @@ small client wrapper.
22
23
 
23
24
  ## Core abstractions
24
25
 
25
- ### Route declarations
26
+ ### HTTP route trees
26
27
 
27
- Declare routes with `route(pattern, methods)`. The pattern is parsed by
28
- `@remix-run/route-pattern`, so route params can be inferred from patterns such
29
- as `hello/:name`, `v:major.:minor`, `api(/v:major(.:minor))`, `assets/*path`,
30
- `search?q`, or full URL patterns such as
31
- `https://:store.shopify.com/orders`.
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.delete` to declare one HTTP operation. The key you put the
43
+ action under is the client and handler name; the action path is the URL pattern.
44
+
45
+ Use `http.resource(path, children)` when several actions share a path prefix or
46
+ when you want nested client/handler namespaces:
47
+
48
+ ```ts
49
+ export const profiles = http.resource('profiles/:id', {
50
+ get: http.get({
51
+ response: $type<Profile>(),
52
+ }),
53
+ update: http.patch({
54
+ body: updateProfileSchema,
55
+ response: $type<Profile>(),
56
+ }),
57
+ posts: http.resource('posts', {
58
+ list: http.get({
59
+ response: $type<Post[]>(),
60
+ }),
61
+ }),
62
+ })
63
+
64
+ export const routes = { profiles }
65
+ ```
66
+
67
+ Resource property names do not affect the URL. Resource paths and action-local
68
+ paths are joined, so the examples above expose `profiles/:id`, `profiles/:id`,
69
+ and `profiles/:id/posts`. Path params from parent resources are accumulated into
70
+ child action types.
71
+
72
+ Patterns are parsed by `@remix-run/route-pattern` v0.21. Params can be inferred
73
+ from patterns such as `hello/:name`, `v:major.:minor`,
74
+ `api(/v:major(.:minor))`, `assets/*path`, and `search?q`. Full URL patterns such
75
+ as `https://:store.shopify.com/orders` are supported for top-level actions; keep
76
+ them out of resource/base-path composition.
77
+
78
+ ### Method schemas
32
79
 
33
80
  Method schemas describe the request pieces Rouzer should validate:
34
81
 
35
- | Method kind | Request schemas | Notes |
36
- | -------------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------- |
37
- | `GET` | `path`, `query`, `headers`, `response` | No request body. |
38
- | `POST`, `PUT`, `PATCH`, `DELETE` | `path`, `body`, `headers`, `response` | No query schema. |
39
- | `ALL` | `path`, `query`, `headers` | Fallback when the incoming method is not explicitly declared. No body or response type. |
82
+ | Action helper | Request schemas | Notes |
83
+ | ------------------------------------- | -------------------------------------- | ---------------- |
84
+ | `http.get(...)` | `path`, `query`, `headers`, `response` | No request body. |
85
+ | `http.post/put/patch/delete(...)` | `path`, `body`, `headers`, `response` | No query schema. |
40
86
 
41
87
  If you omit a `path` schema, TypeScript infers path params from the pattern and
42
88
  server handlers receive them as strings. Add a Zod `path` schema when you need
43
89
  runtime validation, transforms, or non-string handler types.
44
90
 
91
+ The HTTP action API models explicit operations. It does not expose the old
92
+ method-map `ALL` fallback route shape; declare the concrete methods your client
93
+ and server support.
94
+
45
95
  ### `$type<T>()`
46
96
 
47
97
  `response: $type<T>()` is a TypeScript-only marker. It tells handlers and client
48
- shorthand methods what response payload type to expect, but Rouzer does not
98
+ action functions what response payload type to expect, but Rouzer does not
49
99
  validate response bodies at runtime.
50
100
 
51
- Routes without a `response` marker return a raw `Response` from client shorthand
52
- methods. Routes with a `response` marker use `client.json(...)` under the hood
101
+ 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
53
103
  and return parsed JSON typed as `T`.
54
104
 
55
105
  ### Router
56
106
 
57
107
  `createRouter()` returns a Hattip-compatible handler. Use `.use(middleware)` to
58
108
  append typed `alien-middleware` middleware and `.use(routes, handlers)` to attach
59
- route handlers.
109
+ an HTTP route tree.
110
+
111
+ The handler object mirrors the route tree:
60
112
 
61
- Handlers receive a context typed from middleware plus the route schema:
113
+ ```ts
114
+ createRouter().use(routes, {
115
+ profiles: {
116
+ get(ctx) {
117
+ return loadProfile(ctx.path.id)
118
+ },
119
+ update(ctx) {
120
+ return updateProfile(ctx.path.id, ctx.body)
121
+ },
122
+ posts: {
123
+ list(ctx) {
124
+ return listPosts(ctx.path.id)
125
+ },
126
+ },
127
+ },
128
+ })
129
+ ```
130
+
131
+ Handlers receive a context typed from middleware plus the action schema:
62
132
 
63
133
  - `GET` handlers receive `ctx.path`, `ctx.query`, and `ctx.headers`
64
134
  - mutation handlers receive `ctx.path`, `ctx.body`, and `ctx.headers`
@@ -66,7 +136,7 @@ Handlers receive a context typed from middleware plus the route schema:
66
136
  - plain values are returned with `Response.json(value)`
67
137
  - return a `Response` when you need custom status, headers, or body handling
68
138
 
69
- `basePath` is prepended to route patterns, `debug` adds matched-route debug
139
+ `basePath` is prepended to route tree paths, `debug` adds matched-route debug
70
140
  headers and more detailed validation errors, and `cors.allowOrigins` restricts
71
141
  requests with an `Origin` header.
72
142
 
@@ -74,12 +144,14 @@ requests with an `Origin` header.
74
144
 
75
145
  `createClient({ baseURL, routes })` creates:
76
146
 
77
- - `client.request(route.GET(args))` for a raw `Response`
78
- - `client.json(route.GET(args))` for parsed JSON and default non-2xx throwing
79
- - shorthand methods such as `client.helloRoute.GET(args)` when `routes` is
80
- supplied
147
+ - `client.request(action.request(args))` for a raw `Response` when the action
148
+ request factory contains the full path you want to call
149
+ - `client.json(action.request(args))` for parsed JSON and default non-2xx
150
+ throwing
151
+ - a client tree that mirrors `routes`, with action functions such as
152
+ `client.profiles.get(args)` when `routes` is supplied
81
153
 
82
- Prefer an absolute `baseURL` for pathname route patterns:
154
+ Prefer an absolute `baseURL` for generated client URLs:
83
155
 
84
156
  ```ts
85
157
  const client = createClient({
@@ -94,10 +166,11 @@ runtimes.
94
166
 
95
167
  ## Lifecycle
96
168
 
97
- 1. Define shared route declarations with `route(...)` and Zod schemas.
98
- 2. Attach those routes to a server with `createRouter().use(routes, handlers)`.
99
- 3. Create a client with the same route map.
100
- 4. Client calls validate `path`, `query`, `body`, and `headers` before `fetch`.
169
+ 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.
172
+ 4. Client action calls validate `path`, `query`, `body`, and `headers` before
173
+ `fetch`.
101
174
  5. The router matches the request, validates the matched inputs, and calls the
102
175
  handler.
103
176
  6. Plain handler results become JSON responses; explicit `Response` objects pass
@@ -112,20 +185,51 @@ string-coercion step.
112
185
 
113
186
  ### Choose a client call style
114
187
 
115
- Use shorthand methods for normal application calls:
188
+ Use client action functions for normal application calls:
116
189
 
117
190
  ```ts
118
- await client.helloRoute.GET({ path: { name: 'Ada' } })
191
+ await client.profiles.get({ path: { id: '42' } })
192
+ await client.profiles.update({
193
+ path: { id: '42' },
194
+ body: { name: 'Ada' },
195
+ })
119
196
  ```
120
197
 
121
- Use longhand calls when you need to choose response handling explicitly:
198
+ Use longhand calls when you need to choose response handling explicitly. The
199
+ action request factory must include the full path you want to call, so this style
200
+ is most convenient for top-level actions:
122
201
 
123
202
  ```ts
203
+ export const getProfile = http.get('profiles/:id', {
204
+ response: $type<Profile>(),
205
+ })
206
+ export const routes = { getProfile }
207
+
124
208
  const response = await client.request(
125
- routes.helloRoute.GET({ path: { name: 'Ada' } })
209
+ routes.getProfile.request({ path: { id: '42' } })
210
+ )
211
+
212
+ const json = await client.json(
213
+ routes.getProfile.request({ path: { id: '42' } })
126
214
  )
215
+ ```
216
+
217
+ ### Group resource actions
127
218
 
128
- const json = await client.json(routes.helloRoute.GET({ path: { name: 'Ada' } }))
219
+ Use resources when the public API reads better as a tree or when actions share
220
+ path params:
221
+
222
+ ```ts
223
+ export const organizations = http.resource('orgs/:orgId', {
224
+ members: http.resource('members/:memberId', {
225
+ get: http.get({ response: $type<Member>() }),
226
+ remove: http.delete({}),
227
+ }),
228
+ })
229
+
230
+ await client.organizations.members.get({
231
+ path: { orgId: 'acme', memberId: '42' },
232
+ })
129
233
  ```
130
234
 
131
235
  ### Return custom responses
@@ -142,16 +246,69 @@ is JSON, its properties are copied onto the thrown `Error`.
142
246
  `client.json(...)` as-is; Rouzer does not automatically parse a returned
143
247
  `Response` from `onJsonError`.
144
248
 
249
+ ### Update code written for v2.0.1
250
+
251
+ Rouzer now uses action/resource route trees for router registration and client
252
+ shorthands. A v2.0.1 method-map route such as this:
253
+
254
+ ```ts
255
+ export const profileRoute = route('profiles/:id', {
256
+ GET: { response: $type<Profile>() },
257
+ PATCH: { body: updateProfileSchema, response: $type<Profile>() },
258
+ })
259
+
260
+ export const routes = { profileRoute }
261
+ ```
262
+
263
+ becomes a named action tree:
264
+
265
+ ```ts
266
+ import * as http from 'rouzer/http'
267
+
268
+ export const profiles = http.resource('profiles/:id', {
269
+ get: http.get({ response: $type<Profile>() }),
270
+ update: http.patch({
271
+ body: updateProfileSchema,
272
+ response: $type<Profile>(),
273
+ }),
274
+ })
275
+
276
+ export const routes = { profiles }
277
+ ```
278
+
279
+ Handler maps and client calls mirror the new action names:
280
+
281
+ ```ts
282
+ createRouter().use(routes, {
283
+ profiles: {
284
+ get(ctx) {
285
+ return loadProfile(ctx.path.id)
286
+ },
287
+ update(ctx) {
288
+ return updateProfile(ctx.path.id, ctx.body)
289
+ },
290
+ },
291
+ })
292
+
293
+ await client.profiles.get({ path: { id: '42' } })
294
+ await client.profiles.update({
295
+ path: { id: '42' },
296
+ body: { name: 'Ada' },
297
+ })
298
+ ```
299
+
145
300
  ## Patterns to prefer
146
301
 
147
- - Export route declarations from a small shared module and import that module on
148
- both server and client.
302
+ - Export route trees from a small shared module and import that module on both
303
+ server and client.
304
+ - Use `rouzer/http` actions for routes that are registered with
305
+ `createRouter().use(...)` or `createClient({ routes })`.
149
306
  - Add Zod schemas when you need runtime guarantees; rely on inferred path params
150
307
  only when string params are sufficient.
151
308
  - Use `response: $type<T>()` for JSON endpoints that should have typed client
152
- shorthand methods.
153
- - Use explicit HTTP methods when you want precise handler context types; reserve
154
- `ALL` for true fallback behavior.
309
+ action functions.
310
+ - Name actions after domain operations (`get`, `list`, `update`, `archive`) and
311
+ let `http.get/post/put/patch/delete` own the transport method.
155
312
  - Set `content-type: application/json` yourself when your server or middleware
156
313
  depends on that header.
157
314
 
@@ -159,9 +316,14 @@ is JSON, its properties are copied onto the thrown `Error`.
159
316
 
160
317
  - `$type<T>()` is compile-time only and does not validate response payloads.
161
318
  - Pathname route patterns expect an absolute client `baseURL`.
319
+ - Resource and action keys are API names only; paths come from the pattern
320
+ strings passed to `http.resource(...)` and action helpers.
321
+ - Nested action `.request(...)` factories do not include parent resource paths;
322
+ prefer client action functions for nested resources.
162
323
  - Extra `RequestInit` fields in route args, such as `signal` or `credentials`,
163
- are accepted by the type surface but are not forwarded by `createClient`.
164
- - `ALL` can declare `query`, but handler context typing is less precise than
165
- explicit `GET` handlers.
324
+ are forwarded by `createClient`; `method`, `body`, and `headers` are reserved
325
+ for Rouzer's action metadata and validated call arguments.
326
+ - The HTTP action API has no `ALL` fallback route. Declare explicit actions for
327
+ supported methods.
166
328
  - Rouzer does not automatically set `Access-Control-Allow-Credentials`; set it in
167
329
  your handler when credentialed cross-origin requests need it.
@@ -1,6 +1,7 @@
1
1
  import type { HattipHandler } from '@hattip/core'
2
2
  import * as z from 'zod'
3
- import { $type, chain, createClient, createRouter, route } from 'rouzer'
3
+ import { $type, chain, createClient, createRouter } from 'rouzer'
4
+ import * as http from 'rouzer/http'
4
5
 
5
6
  type Profile = {
6
7
  id: string
@@ -9,14 +10,14 @@ type Profile = {
9
10
  requestId: string
10
11
  }
11
12
 
12
- export const profileRoute = route('profiles/:id', {
13
- GET: {
13
+ export const profiles = http.resource('profiles/:id', {
14
+ get: http.get({
14
15
  query: z.object({
15
16
  includePosts: z.optional(z.boolean()),
16
17
  }),
17
18
  response: $type<Profile>(),
18
- },
19
- PATCH: {
19
+ }),
20
+ update: http.patch({
20
21
  body: z.object({
21
22
  name: z.string().check(z.minLength(1)),
22
23
  }),
@@ -24,10 +25,10 @@ export const profileRoute = route('profiles/:id', {
24
25
  'content-type': z.literal('application/json'),
25
26
  }),
26
27
  response: $type<Profile>(),
27
- },
28
+ }),
28
29
  })
29
30
 
30
- export const routes = { profileRoute }
31
+ export const routes = { profiles }
31
32
 
32
33
  /**
33
34
  * Tiny Hattip adapter used only to keep this example self-contained. Real apps
@@ -54,7 +55,7 @@ function createLocalFetch(handler: HattipHandler): typeof fetch {
54
55
  }
55
56
 
56
57
  export async function runBasicUsageExample() {
57
- const profiles = new Map([['42', { id: '42', name: 'Ada' }]])
58
+ const profileMap = new Map([['42', { id: '42', name: 'Ada' }]])
58
59
 
59
60
  const requestMiddleware = chain().use(ctx => ({
60
61
  requestId: ctx.request.headers.get('x-request-id') ?? 'local',
@@ -63,9 +64,9 @@ export async function runBasicUsageExample() {
63
64
  const handler = createRouter({ basePath: 'api/' })
64
65
  .use(requestMiddleware)
65
66
  .use(routes, {
66
- profileRoute: {
67
- GET(ctx) {
68
- const profile = profiles.get(ctx.path.id)
67
+ profiles: {
68
+ get(ctx) {
69
+ const profile = profileMap.get(ctx.path.id)
69
70
  if (!profile) {
70
71
  return new Response('Profile not found', { status: 404 })
71
72
  }
@@ -75,13 +76,13 @@ export async function runBasicUsageExample() {
75
76
  requestId: ctx.requestId,
76
77
  }
77
78
  },
78
- PATCH(ctx) {
79
- const current = profiles.get(ctx.path.id)
79
+ update(ctx) {
80
+ const current = profileMap.get(ctx.path.id)
80
81
  if (!current) {
81
82
  return new Response('Profile not found', { status: 404 })
82
83
  }
83
84
  const profile = { ...current, name: ctx.body.name }
84
- profiles.set(ctx.path.id, profile)
85
+ profileMap.set(ctx.path.id, profile)
85
86
  return {
86
87
  ...profile,
87
88
  includePosts: false,
@@ -100,13 +101,13 @@ export async function runBasicUsageExample() {
100
101
  fetch: createLocalFetch(handler),
101
102
  })
102
103
 
103
- const fetched = await client.profileRoute.GET({
104
+ const fetched = await client.profiles.get({
104
105
  path: { id: '42' },
105
106
  query: { includePosts: false },
106
107
  headers: { 'x-request-id': 'docs' },
107
108
  })
108
109
 
109
- const updated = await client.profileRoute.PATCH({
110
+ const updated = await client.profiles.update({
110
111
  path: { id: '42' },
111
112
  body: { name: 'Grace' },
112
113
  })
package/package.json CHANGED
@@ -1,11 +1,15 @@
1
1
  {
2
2
  "name": "rouzer",
3
- "version": "2.0.1",
3
+ "version": "3.0.1",
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.3",
18
- "@typescript/native-preview": "7.0.0-dev.20251208.1",
19
- "prettier": "^3.7.4",
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": "^5.9.3",
23
- "vite": "^7.3.0",
24
- "vitest": "^4.0.16",
25
- "zod": "^4.1.13"
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.15.3",
30
- "alien-middleware": "^0.11.4"
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",
package/dist/route.d.ts DELETED
@@ -1,49 +0,0 @@
1
- import { RoutePattern } from '@remix-run/route-pattern';
2
- import { Unchecked } from './common.js';
3
- import type { RouteRequestFactory, RouteSchema, RouteSchemaMap } from './types.js';
4
- /**
5
- * Create a compile-time-only marker for a route's JSON response payload type.
6
- *
7
- * @remarks `$type<T>()` does not perform runtime validation. It lets Rouzer type
8
- * server handler return values and client shorthand methods for routes whose
9
- * responses are expected to be JSON.
10
- *
11
- * @example
12
- * ```ts
13
- * const helloRoute = route('hello/:name', {
14
- * GET: {
15
- * response: $type<{ message: string }>(),
16
- * },
17
- * })
18
- * ```
19
- */
20
- export declare function $type<T>(): Unchecked<T>;
21
- export declare namespace $type {
22
- var symbol: symbol;
23
- }
24
- /**
25
- * Shared route declaration produced by `route(...)`.
26
- *
27
- * @remarks A `Route` stores the parsed URL pattern, the method schema map, and a
28
- * request factory for each declared method. Pass route maps to both
29
- * `createRouter().use(...)` and `createClient({ routes })` to share the same
30
- * contract on both sides of an HTTP boundary.
31
- */
32
- export type Route<P extends string = string, T extends RouteSchemaMap = RouteSchemaMap> = {
33
- /** Parsed route pattern used for URL generation and server-side matching. */
34
- path: RoutePattern<P>;
35
- /** Method schemas declared for this route. */
36
- methods: T;
37
- } & {
38
- [K in keyof T]: RouteRequestFactory<Extract<T[K], RouteSchema>, P>;
39
- };
40
- /**
41
- * Declare one URL pattern and its supported HTTP method schemas.
42
- *
43
- * @param pattern Route pattern parsed by `@remix-run/route-pattern`.
44
- * @param methods Method schemas that describe request validation and optional
45
- * response typing.
46
- * @returns A shared route declaration with request factories such as `.GET(...)`
47
- * and `.POST(...)` for the declared methods.
48
- */
49
- export declare function route<P extends string, T extends RouteSchemaMap>(pattern: P, methods: T): Route<P, T>;
package/dist/route.js DELETED
@@ -1,47 +0,0 @@
1
- import { RoutePattern } from '@remix-run/route-pattern';
2
- import { mapEntries } from './common.js';
3
- /**
4
- * Create a compile-time-only marker for a route's JSON response payload type.
5
- *
6
- * @remarks `$type<T>()` does not perform runtime validation. It lets Rouzer type
7
- * server handler return values and client shorthand methods for routes whose
8
- * responses are expected to be JSON.
9
- *
10
- * @example
11
- * ```ts
12
- * const helloRoute = route('hello/:name', {
13
- * GET: {
14
- * response: $type<{ message: string }>(),
15
- * },
16
- * })
17
- * ```
18
- */
19
- export function $type() {
20
- return $type.symbol;
21
- }
22
- $type.symbol = Symbol();
23
- /**
24
- * Declare one URL pattern and its supported HTTP method schemas.
25
- *
26
- * @param pattern Route pattern parsed by `@remix-run/route-pattern`.
27
- * @param methods Method schemas that describe request validation and optional
28
- * response typing.
29
- * @returns A shared route declaration with request factories such as `.GET(...)`
30
- * and `.POST(...)` for the declared methods.
31
- */
32
- export function route(pattern, methods) {
33
- const path = new RoutePattern(pattern);
34
- const createFetch = (method, schema) => (args = {}) => {
35
- return {
36
- schema,
37
- path,
38
- method,
39
- args,
40
- $result: undefined,
41
- };
42
- };
43
- return Object.assign({ path, methods }, mapEntries(methods, (method, schema) => [
44
- method,
45
- createFetch(method, schema),
46
- ]));
47
- }