rouzer 5.0.0 → 5.1.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/README.md CHANGED
@@ -14,6 +14,7 @@ that contract to:
14
14
  - match and validate server requests before handlers run
15
15
  - type handler context from path, query/body, headers, and middleware
16
16
  - attach typed client action functions such as `client.profiles.get(...)`
17
+ - send JSON object request bodies or raw `BodyInit` payloads
17
18
  - parse typed JSON responses, declared error responses, and NDJSON streams
18
19
 
19
20
  Rouzer optimizes for shared TypeScript route modules over language-agnostic API
@@ -103,8 +104,11 @@ const { message } = await client.hello({
103
104
  ```
104
105
 
105
106
  `handler` can be mounted with any Hattip adapter. Generated client action calls
106
- validate route arguments before `fetch`; server handlers validate matched path,
107
- query, headers, and JSON bodies before your handler runs.
107
+ validate flat route arguments before `fetch`; server handlers validate matched
108
+ path, query, headers, and JSON bodies before your handler runs. Per-request
109
+ headers, abort signals, and other `RequestInit` options are passed as a second
110
+ client action argument. Routes declared with `body: http.rawBody()` pass a
111
+ `BodyInit` payload through to `fetch` without JSON encoding.
108
112
 
109
113
  ### Typed status responses
110
114
 
@@ -149,6 +153,34 @@ const [error, user, status] = await client.getUser({ id: '42' })
149
153
  Success entries resolve as `[null, value, status]`; declared error entries
150
154
  resolve as `[error, null, status]`.
151
155
 
156
+ ### Raw request bodies
157
+
158
+ Use `http.rawBody()` when an action needs to send a `BodyInit` payload such as a
159
+ `Blob`, `Uint8Array`, `ReadableStream`, `FormData`, or string without JSON
160
+ encoding.
161
+
162
+ ```ts
163
+ export const uploadAvatar = http.post('profiles/:id/avatar', {
164
+ body: http.rawBody(),
165
+ })
166
+
167
+ await client.uploadAvatar({ id: '42' }, { body: file })
168
+ ```
169
+
170
+ For raw-body routes without path or query input, the generated client accepts the
171
+ body as the first argument:
172
+
173
+ ```ts
174
+ export const upload = http.post('upload', {
175
+ body: http.rawBody(),
176
+ })
177
+
178
+ await client.upload(file, { headers: { 'content-type': file.type } })
179
+ ```
180
+
181
+ Server handlers for raw-body routes read from `ctx.request` directly with Fetch
182
+ APIs such as `arrayBuffer()`, `blob()`, `formData()`, or `text()`.
183
+
152
184
  ### NDJSON response streams
153
185
 
154
186
  Use `response: ndjson.$type<T>()` for endpoints that stream
@@ -185,7 +217,7 @@ for await (const event of await client.events()) {
185
217
 
186
218
  ## Documentation
187
219
 
188
- - [Concepts, API selection, and v2->v3 migration notes](docs/context.md)
220
+ - [Concepts, API selection, v5 client input notes, and migration notes](docs/context.md)
189
221
  - [Runnable shared-route example](examples/basic-usage.ts)
190
222
  - [Runnable typed error response example](examples/error-responses.ts)
191
223
  - [Runnable NDJSON response-stream example](examples/ndjson-stream.ts)
@@ -1,7 +1,8 @@
1
1
  import { Promisable } from '../common.js';
2
2
  import { type HttpAction, type HttpResource, type HttpRouteTree } from '../http.js';
3
3
  import { type ClientResponsePlugin } from '../response.js';
4
- import type { RouteInput, RouteOptions } from '../types/args.js';
4
+ import type { RouteFetchOptions, RouteInput, RouteOptions } from '../types/args.js';
5
+ import type { RawBodySchema } from '../types/schema.js';
5
6
  import type { InferRouteResponse } from '../types/response.js';
6
7
  import type { RouteSchema } from '../types/schema.js';
7
8
  /** Client type inferred from an HTTP route tree passed to `createClient`. */
@@ -33,7 +34,7 @@ export declare function createClient<TRoutes extends HttpRouteTree = Record<stri
33
34
  * @example
34
35
  * ```ts
35
36
  * const client = createClient({ baseURL: 'https://example.com/api/', routes })
36
- * await client.users.list({ query: { page: 1 } })
37
+ * await client.users.list({ page: 1 })
37
38
  * ```
38
39
  */
39
40
  routes: TRoutes;
@@ -72,7 +73,7 @@ export declare function createClient<TRoutes extends HttpRouteTree = Record<stri
72
73
  * @example
73
74
  * ```ts
74
75
  * const client = createClient({ baseURL: 'https://example.com/api/', routes })
75
- * await client.users.list({ query: { page: 1 } })
76
+ * await client.users.list({ page: 1 })
76
77
  * ```
77
78
  */
78
79
  routes: TRoutes;
@@ -104,9 +105,17 @@ export type ClientTree<T extends HttpRouteTree, TPrefix extends string = ''> = {
104
105
  * union of `[null, value, status]` success entries and `[error, null, status]`
105
106
  * error entries. Actions whose schema has a plugin response marker return the
106
107
  * plugin's client result type. Actions without a response marker return the raw
107
- * `Response`.
108
+ * `Response`. Raw-body actions with no path or query input accept
109
+ * `(body, options)`; raw-body actions with route input accept
110
+ * `(input, { body, ...options })`.
108
111
  */
109
- export type RouteFunction<T extends RouteSchema, P extends string> = (...p: RouteInput<T, P> extends infer TInput ? {} extends TInput ? [input?: TInput, options?: RouteOptions<T>] : [input: TInput, options?: RouteOptions<T>] : never) => Promise<T extends {
112
+ export type RouteFunction<T extends RouteSchema, P extends string> = T extends {
113
+ body: RawBodySchema;
114
+ } ? RouteInput<T, P> extends infer TInput ? {} extends TInput ? (body: BodyInit | null, options?: RouteFetchOptions<T>) => Promise<T extends {
115
+ response: any;
116
+ } ? InferRouteResponse<T> : Response> : (input: TInput, options: RouteOptions<T>) => Promise<T extends {
117
+ response: any;
118
+ } ? InferRouteResponse<T> : Response> : never : (...p: RouteInput<T, P> extends infer TInput ? {} extends TInput ? [input?: TInput, options?: RouteOptions<T>] : [input: TInput, options?: RouteOptions<T>] : never) => Promise<T extends {
110
119
  response: any;
111
120
  } ? InferRouteResponse<T> : Response>;
112
121
  export {};
@@ -134,14 +134,20 @@ function connectTree(tree, prefix, plainRequest, parsedRequest) {
134
134
  const fetch = node.schema.response ? parsedRequest : plainRequest;
135
135
  return [
136
136
  key,
137
- (input, options) => fetch({
138
- schema: node.schema,
139
- path,
140
- method: node.method,
141
- input,
142
- options,
143
- $result: undefined,
144
- }),
137
+ (input, options) => {
138
+ if (isRawBodySchema(node.schema.body) && !hasRouteInput(node, path)) {
139
+ options = { ...options, body: input };
140
+ input = undefined;
141
+ }
142
+ return fetch({
143
+ schema: node.schema,
144
+ path,
145
+ method: node.method,
146
+ input,
147
+ options,
148
+ $result: undefined,
149
+ });
150
+ },
145
151
  ];
146
152
  }));
147
153
  }
@@ -165,6 +171,9 @@ function validateClientResponsePlugins(tree, plugins) {
165
171
  function missingClientResponsePlugin(pluginId) {
166
172
  return new Error(`Missing client response plugin for ${pluginId}`);
167
173
  }
174
+ function hasRouteInput(node, path) {
175
+ return Boolean(node.schema.path || node.schema.query || /(^|\/)[:*]/.test(path.source));
176
+ }
168
177
  function pickObjectSchemaFields(schema, input) {
169
178
  if (typeof input !== 'object' || input === null) {
170
179
  return input;
package/dist/http.d.ts CHANGED
@@ -62,6 +62,12 @@ export declare function patch<const T extends RouteSchema>(schema: T): HttpActio
62
62
  declare function deleteAction<const P extends string, const T extends RouteSchema>(path: P, schema: T): HttpAction<P, T, 'DELETE'>;
63
63
  declare function deleteAction<const T extends RouteSchema>(schema: T): HttpAction<'', T, 'DELETE'>;
64
64
  export { deleteAction as delete };
65
- /** Declare a request body that is passed through to `fetch` without JSON encoding. */
65
+ /**
66
+ * Declare a request body that is passed through to `fetch` without JSON encoding.
67
+ *
68
+ * @remarks For routes with path or query input, pass the body as
69
+ * `options.body`. For raw-body routes without input, generated client actions
70
+ * accept the body as their first argument.
71
+ */
66
72
  export declare function rawBody(): RawBodySchema;
67
73
  export declare function isRawBodySchema(schema: unknown): schema is RawBodySchema;
package/dist/http.js CHANGED
@@ -29,7 +29,13 @@ function deleteAction(pathOrSchema, schema) {
29
29
  return action('DELETE', pathOrSchema, schema);
30
30
  }
31
31
  export { deleteAction as delete };
32
- /** Declare a request body that is passed through to `fetch` without JSON encoding. */
32
+ /**
33
+ * Declare a request body that is passed through to `fetch` without JSON encoding.
34
+ *
35
+ * @remarks For routes with path or query input, pass the body as
36
+ * `options.body`. For raw-body routes without input, generated client actions
37
+ * accept the body as their first argument.
38
+ */
33
39
  export function rawBody() {
34
40
  return { __rawBody__: Symbol('rouzer.rawBody') };
35
41
  }
@@ -1,4 +1,4 @@
1
- import { getResponsePluginMarkerId, responsePluginMarker, } from './response.js';
1
+ import { getResponsePluginMarkerId, responsePluginMarker } from './response.js';
2
2
  import { $error } from './type.js';
3
3
  /** Return true when the response schema is a status-keyed response map. */
4
4
  export function isResponseMap(response) {
@@ -25,18 +25,21 @@ type HeaderInput<T> = T extends {
25
25
  */
26
26
  export type RouteInput<T extends RouteSchema = any, P extends string = string> = [T] extends [Any] ? any : PathInput<T, P> & QueryInput<T> & BodyInput<T>;
27
27
  /**
28
- * Fetch options accepted as the second argument to a generated client action.
28
+ * Fetch options accepted by a generated client action.
29
29
  *
30
30
  * @remarks `headers` remains optional because required route headers may be
31
- * supplied by `createClient({ headers })` defaults.
31
+ * supplied by `createClient({ headers })` defaults. Raw-body routes with path or
32
+ * query input accept `body` here; raw-body routes without input accept the body
33
+ * as the first client action argument and these options as the second.
32
34
  */
35
+ export type RouteFetchOptions<T extends RouteSchema = any> = Omit<RequestInit, 'method' | 'body' | 'headers'> & {
36
+ /** Headers for this request. Undefined values are removed before `fetch`. */
37
+ headers?: HeaderInput<T>;
38
+ };
33
39
  type RouteBodyOption<T> = T extends {
34
40
  body: RawBodySchema;
35
41
  } ? {
36
42
  body: BodyInit | null;
37
43
  } : {};
38
- export type RouteOptions<T extends RouteSchema = any> = Omit<RequestInit, 'method' | 'body' | 'headers'> & RouteBodyOption<T> & {
39
- /** Headers for this request. Undefined values are removed before `fetch`. */
40
- headers?: HeaderInput<T>;
41
- };
44
+ export type RouteOptions<T extends RouteSchema = any> = RouteFetchOptions<T> & RouteBodyOption<T>;
42
45
  export {};
package/docs/context.md CHANGED
@@ -85,7 +85,7 @@ Method schemas describe the request pieces Rouzer should validate:
85
85
  | Action helper | Request schemas | Notes |
86
86
  | --------------------------------- | -------------------------------------- | ---------------- |
87
87
  | `http.get(...)` | `path`, `query`, `headers`, `response` | No request body. |
88
- | `http.post/put/patch/delete(...)` | `path`, `body`, `headers`, `response` | No query schema. |
88
+ | `http.post/put/patch/delete(...)` | `path`, `body`, `headers`, `response` | No query schema. `body` is a Zod object for JSON or `http.rawBody()` for pass-through payloads. |
89
89
 
90
90
  If you omit a `path` schema, TypeScript infers path params from the pattern and
91
91
  server handlers receive them as strings. Add a Zod `path` schema when you need
@@ -213,6 +213,12 @@ requests with an `Origin` header.
213
213
 
214
214
  `createClient({ baseURL, routes })` creates a client tree that mirrors
215
215
  `routes`, with action functions such as `client.profiles.get(args)`.
216
+ Generated action functions accept a flattened first argument containing path,
217
+ query, and JSON body fields. Per-request `RequestInit` options, including
218
+ headers and abort signals, are passed as the optional second argument. For
219
+ `http.rawBody()` routes, the raw `BodyInit` payload is passed through to `fetch`
220
+ without JSON encoding.
221
+
216
222
  Generated action functions include:
217
223
 
218
224
  - raw `Response` results for actions without a response schema
@@ -244,8 +250,8 @@ route actions named `config` remain available as `client.config(...)`.
244
250
  are needed.
245
251
  3. Create a client with the same route tree, plus matching client response
246
252
  plugins when needed.
247
- 4. Client action calls validate `path`, `query`, `body`, and `headers` before
248
- `fetch`.
253
+ 4. Client action calls validate `path`, `query`, JSON object `body`, and
254
+ `headers` before `fetch`. Raw bodies are passed through without validation.
249
255
  5. The router matches the request, validates the matched inputs, and calls the
250
256
  handler.
251
257
  6. Plain handler results become JSON responses, response-map helpers choose
@@ -255,22 +261,29 @@ route actions named `config` remain available as `client.config(...)`.
255
261
  On the server, `path`, `query`, and `headers` values originate as strings. Rouzer
256
262
  coerces Zod `number` schemas with `Number(value)` and Zod `boolean` schemas from
257
263
  `"true"` and `"false"`. JSON request bodies are parsed and validated without that
258
- string-coercion step.
264
+ string-coercion step. Raw request bodies declared with `http.rawBody()` are not
265
+ parsed by Rouzer.
259
266
 
260
267
  ## Common tasks
261
268
 
262
269
  ### Call client actions
263
270
 
264
- Use generated client action functions for application calls:
271
+ Use generated client action functions for application calls. The first argument
272
+ is a flat object containing all path, query, and JSON body fields. The optional
273
+ second argument is for per-request `RequestInit` options such as headers or an
274
+ abort signal.
265
275
 
266
276
  ```ts
267
- await client.profiles.get({ path: { id: '42' } })
268
- await client.profiles.update({
269
- path: { id: '42' },
270
- body: { name: 'Ada' },
271
- })
277
+ await client.profiles.get({ id: '42', includePosts: true })
278
+ await client.profiles.update(
279
+ { id: '42', name: 'Ada' },
280
+ { headers: { 'x-request-id': 'docs' } }
281
+ )
272
282
  ```
273
283
 
284
+ Avoid duplicate field names across an action's path, query, and body schemas;
285
+ the client input is flat, so duplicate keys cannot represent separate values.
286
+
274
287
  ### Handle declared error responses
275
288
 
276
289
  Use `$error<T>()` inside a response map when an error status is part of the route
@@ -308,9 +321,7 @@ const client = createClient({
308
321
  routes,
309
322
  })
310
323
 
311
- const [error, user, status] = await client.getUser({
312
- path: { id: 'missing' },
313
- })
324
+ const [error, user, status] = await client.getUser({ id: 'missing' })
314
325
 
315
326
  if (status === 404) {
316
327
  console.log(error.message)
@@ -384,11 +395,47 @@ export const organizations = http.resource('orgs/:orgId', {
384
395
  }),
385
396
  })
386
397
 
387
- await client.organizations.members.get({
388
- path: { orgId: 'acme', memberId: '42' },
398
+ await client.organizations.members.get({ orgId: 'acme', memberId: '42' })
399
+ ```
400
+
401
+ ### Send raw request bodies
402
+
403
+ Use `http.rawBody()` for mutation actions whose client should pass a `BodyInit`
404
+ through to `fetch` without JSON encoding or Zod body parsing:
405
+
406
+ ```ts
407
+ export const uploadAvatar = http.post('profiles/:id/avatar', {
408
+ body: http.rawBody(),
409
+ headers: z.object({ 'content-type': z.string() }),
410
+ })
411
+
412
+ await client.uploadAvatar(
413
+ { id: '42' },
414
+ { body: file, headers: { 'content-type': file.type } }
415
+ )
416
+ ```
417
+
418
+ When a raw-body route has path or query input, path/query fields still live in
419
+ the flat first argument. The raw body itself is passed as `body` in the second
420
+ argument because it is a `RequestInit` value.
421
+
422
+ For raw-body routes without path or query input, the generated client action
423
+ accepts the body as the first argument and fetch options as the second:
424
+
425
+ ```ts
426
+ export const upload = http.post('uploads', {
427
+ body: http.rawBody(),
428
+ })
429
+
430
+ await client.upload(file, {
431
+ headers: { 'content-type': file.type },
389
432
  })
390
433
  ```
391
434
 
435
+ Server handlers for raw-body routes read from `ctx.request` directly with Fetch
436
+ APIs such as `arrayBuffer()`, `blob()`, `formData()`, or `text()`. Rouzer does
437
+ not parse or validate raw request bodies.
438
+
392
439
  ### Return custom responses
393
440
 
394
441
  Return a `Response` from a handler for non-JSON payloads, custom status codes, or
@@ -434,7 +481,8 @@ export const profiles = http.resource('profiles/:id', {
434
481
  export const routes = { profiles }
435
482
  ```
436
483
 
437
- Handler maps and client calls mirror the new action names:
484
+ Handler maps mirror the action names, while v5 client calls use flat input
485
+ objects:
438
486
 
439
487
  ```ts
440
488
  createRouter().use(routes, {
@@ -448,11 +496,8 @@ createRouter().use(routes, {
448
496
  },
449
497
  })
450
498
 
451
- await client.profiles.get({ path: { id: '42' } })
452
- await client.profiles.update({
453
- path: { id: '42' },
454
- body: { name: 'Ada' },
455
- })
499
+ await client.profiles.get({ id: '42' })
500
+ await client.profiles.update({ id: '42', name: 'Ada' })
456
501
  ```
457
502
 
458
503
  ## Patterns to prefer
@@ -480,8 +525,8 @@ await client.profiles.update({
480
525
  - `$type<T>()`, `$error<T>()`, and `ndjson.$type<T>()` are compile-time-only type
481
526
  contracts. Rouzer does not re-validate handler return values at the server
482
527
  boundary.
483
- - NDJSON support is for response streams; request bodies still use the existing
484
- JSON body schema path.
528
+ - NDJSON support is for response streams; request bodies use JSON body schemas
529
+ unless an action declares `body: http.rawBody()`.
485
530
  - Declared `$error<T>()` responses are JSON responses. Use a custom `Response`
486
531
  for non-JSON error payloads.
487
532
  - Routes that use a response plugin fail fast if the matching client or router
@@ -489,10 +534,12 @@ await client.profiles.update({
489
534
  - Pathname route patterns expect an absolute client `baseURL`.
490
535
  - Resource and action keys are API names only; paths come from the pattern
491
536
  strings passed to `http.resource(...)` and action helpers.
492
- - Extra `RequestInit` fields in route args, such as `signal` or `credentials`,
493
- are forwarded by `createClient`; `method` and `body` are reserved for Rouzer's
494
- action metadata and validated call arguments. Use route args or client defaults
495
- for request headers.
537
+ - Path, query, and JSON body fields are flattened into the first client action
538
+ argument. Per-request `RequestInit` fields, such as `signal`, `credentials`,
539
+ and `headers`, belong in the second argument. `method` is reserved by Rouzer.
540
+ For `http.rawBody()` actions, `body` is accepted in the second argument when
541
+ the route has path or query input; raw-body actions without route input accept
542
+ the body as the first argument.
496
543
  - The HTTP action API has no `ALL` fallback route. Declare explicit actions for
497
544
  supported methods.
498
545
  - Rouzer does not automatically set `Access-Control-Allow-Credentials`; set it in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rouzer",
3
- "version": "5.0.0",
3
+ "version": "5.1.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {