rouzer 5.1.0 → 5.2.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 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
@@ -106,7 +107,8 @@ const { message } = await client.hello({
106
107
  validate flat route arguments before `fetch`; server handlers validate matched
107
108
  path, query, headers, and JSON bodies before your handler runs. Per-request
108
109
  headers, abort signals, and other `RequestInit` options are passed as a second
109
- client action argument.
110
+ client action argument. Routes declared with `body: http.rawBody()` pass a
111
+ `BodyInit` payload through to `fetch` without JSON encoding.
110
112
 
111
113
  ### Typed status responses
112
114
 
@@ -151,6 +153,56 @@ const [error, user, status] = await client.getUser({ id: '42' })
151
153
  Success entries resolve as `[null, value, status]`; declared error entries
152
154
  resolve as `[error, null, status]`.
153
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
+
184
+ ### Client lifecycle hooks
185
+
186
+ Pass `clientHook` to observe generated client action calls without wrapping the
187
+ client tree:
188
+
189
+ ```ts
190
+ const client = createClient({
191
+ baseURL: 'https://example.com/api/',
192
+ routes,
193
+ clientHook(event) {
194
+ if (event.type === 'request.success') {
195
+ console.log(event.routeName, event.durationMs)
196
+ }
197
+ },
198
+ })
199
+ ```
200
+
201
+ Rouzer emits `request.start` before client-side validation, then
202
+ `request.success` when the action resolves or `request.error` when it rejects.
203
+ Terminal events include the parsed response or thrown error plus `durationMs`.
204
+ Hook errors are swallowed.
205
+
154
206
  ### NDJSON response streams
155
207
 
156
208
  Use `response: ndjson.$type<T>()` for endpoints that stream
@@ -5,6 +5,37 @@ import type { RouteFetchOptions, RouteInput, RouteOptions } from '../types/args.
5
5
  import type { RawBodySchema } from '../types/schema.js';
6
6
  import type { InferRouteResponse } from '../types/response.js';
7
7
  import type { RouteSchema } from '../types/schema.js';
8
+ /** Lifecycle event emitted by generated client action functions. */
9
+ export type RouzerClientHookEvent = {
10
+ type: 'request.start';
11
+ opId: string;
12
+ routeName: string;
13
+ method: string;
14
+ pathPattern: string;
15
+ payload: unknown;
16
+ } | {
17
+ type: 'request.success';
18
+ opId: string;
19
+ routeName: string;
20
+ method: string;
21
+ pathPattern: string;
22
+ payload: unknown;
23
+ response: unknown;
24
+ status?: number;
25
+ durationMs: number;
26
+ } | {
27
+ type: 'request.error';
28
+ opId: string;
29
+ routeName: string;
30
+ method: string;
31
+ pathPattern: string;
32
+ payload: unknown;
33
+ error: unknown;
34
+ status?: number;
35
+ durationMs: number;
36
+ };
37
+ /** Best-effort observer for generated client action lifecycles. */
38
+ export type RouzerClientHook = (event: RouzerClientHookEvent) => void;
8
39
  /** Client type inferred from an HTTP route tree passed to `createClient`. */
9
40
  export type RouzerClient<TRoutes extends HttpRouteTree = Record<string, never>> = ReturnType<typeof createClient<TRoutes>>;
10
41
  /**
@@ -51,6 +82,12 @@ export declare function createClient<TRoutes extends HttpRouteTree = Record<stri
51
82
  onJsonError?: (response: Response) => Promisable<unknown>;
52
83
  /** Custom `fetch` implementation to use for requests. */
53
84
  fetch?: typeof globalThis.fetch;
85
+ /**
86
+ * Best-effort lifecycle observer for generated client action calls.
87
+ *
88
+ * @remarks Hook errors are swallowed and never change request behavior.
89
+ */
90
+ clientHook?: RouzerClientHook;
54
91
  }): ClientTree<TRoutes, ""> & {
55
92
  clientConfig: {
56
93
  /**
@@ -90,6 +127,12 @@ export declare function createClient<TRoutes extends HttpRouteTree = Record<stri
90
127
  onJsonError?: (response: Response) => Promisable<unknown>;
91
128
  /** Custom `fetch` implementation to use for requests. */
92
129
  fetch?: typeof globalThis.fetch;
130
+ /**
131
+ * Best-effort lifecycle observer for generated client action calls.
132
+ *
133
+ * @remarks Hook errors are swallowed and never change request behavior.
134
+ */
135
+ clientHook?: RouzerClientHook;
93
136
  };
94
137
  };
95
138
  type Join<A extends string, B extends string> = A extends '' ? B : B extends '' ? A : `${A}/${B}`;
@@ -105,7 +148,9 @@ export type ClientTree<T extends HttpRouteTree, TPrefix extends string = ''> = {
105
148
  * union of `[null, value, status]` success entries and `[error, null, status]`
106
149
  * error entries. Actions whose schema has a plugin response marker return the
107
150
  * plugin's client result type. Actions without a response marker return the raw
108
- * `Response`.
151
+ * `Response`. Raw-body actions with no path or query input accept
152
+ * `(body, options)`; raw-body actions with route input accept
153
+ * `(input, { body, ...options })`.
109
154
  */
110
155
  export type RouteFunction<T extends RouteSchema, P extends string> = T extends {
111
156
  body: RawBodySchema;
@@ -4,6 +4,7 @@ import { shake } from '../common.js';
4
4
  import { isRawBodySchema, } from '../http.js';
5
5
  import { getResponseMapPluginIds, isErrorMarker, isResponseMap, } from '../response-map.js';
6
6
  import { createResponsePluginMap, getResponsePluginMarkerId, } from '../response.js';
7
+ let nextClientOpId = 0;
7
8
  /**
8
9
  * Create a typed fetch client for an HTTP route tree.
9
10
  *
@@ -16,7 +17,10 @@ export function createClient(config) {
16
17
  const fetch = config.fetch ?? globalThis.fetch;
17
18
  const responsePlugins = createResponsePluginMap(config.plugins, 'client response');
18
19
  validateClientResponsePlugins(config.routes, responsePlugins);
19
- async function plainRequest({ path: pathPattern, method, input = {}, options: { body: rawBody, headers, ...init } = {}, schema, }) {
20
+ async function plainRequest(props) {
21
+ const { path: pathPattern, method, input = {}, schema } = props;
22
+ const { body: rawBody, ...options } = props.options ?? {};
23
+ let { headers, ...init } = options;
20
24
  const path = schema.path
21
25
  ? schema.path.parse(pickObjectSchemaFields(schema.path, input))
22
26
  : input;
@@ -48,7 +52,7 @@ export function createClient(config) {
48
52
  if (schema.headers) {
49
53
  headers = schema.headers.parse(headers);
50
54
  }
51
- return fetch(url, {
55
+ return (await fetch(url, {
52
56
  ...init,
53
57
  method,
54
58
  body: isRawBodySchema(schema.body)
@@ -57,10 +61,9 @@ export function createClient(config) {
57
61
  ? JSON.stringify(body)
58
62
  : undefined,
59
63
  headers: (headers ?? defaultHeaders),
60
- });
64
+ }));
61
65
  }
62
- async function parsedRequest(props) {
63
- const response = await plainRequest(props);
66
+ async function parseResponse(response, props) {
64
67
  const responseSchema = props.schema.response;
65
68
  // Handle status-keyed response maps
66
69
  if (isResponseMap(responseSchema)) {
@@ -106,6 +109,25 @@ export function createClient(config) {
106
109
  }
107
110
  return response.json();
108
111
  }
112
+ async function plainClientRequest(props) {
113
+ const response = await plainRequest(props);
114
+ return {
115
+ value: response,
116
+ status: response.status,
117
+ };
118
+ }
119
+ async function parsedClientRequest(props) {
120
+ const response = await plainRequest(props);
121
+ try {
122
+ return {
123
+ value: await parseResponse(response, props),
124
+ status: response.status,
125
+ };
126
+ }
127
+ catch (error) {
128
+ throw new ClientRequestFailure(error, response.status);
129
+ }
130
+ }
109
131
  async function handleResponseError(response, props) {
110
132
  if (config.onJsonError) {
111
133
  return config.onJsonError(response);
@@ -118,39 +140,118 @@ export function createClient(config) {
118
140
  throw error;
119
141
  }
120
142
  return {
121
- ...connectTree(config.routes, '', plainRequest, parsedRequest),
143
+ ...connectTree(config.routes, '', '', plainClientRequest, parsedClientRequest, config.clientHook),
122
144
  clientConfig: config,
123
145
  };
124
146
  }
125
- function connectTree(tree, prefix, plainRequest, parsedRequest) {
147
+ class ClientRequestFailure {
148
+ error;
149
+ status;
150
+ constructor(error, status) {
151
+ this.error = error;
152
+ this.status = status;
153
+ }
154
+ }
155
+ function connectTree(tree, prefix, namePrefix, plainRequest, parsedRequest, clientHook) {
126
156
  return Object.fromEntries(Object.entries(tree).map(([key, node]) => {
127
157
  if (node.kind === 'resource') {
128
158
  return [
129
159
  key,
130
- connectTree(node.children, joinPaths(prefix, node.path.source), plainRequest, parsedRequest),
160
+ connectTree(node.children, joinPaths(prefix, node.path.source), joinNames(namePrefix, key), plainRequest, parsedRequest, clientHook),
131
161
  ];
132
162
  }
133
163
  const path = RoutePattern.parse(joinPaths(prefix, node.path?.source ?? ''));
134
164
  const fetch = node.schema.response ? parsedRequest : plainRequest;
165
+ const routeName = joinNames(namePrefix, key);
135
166
  return [
136
167
  key,
137
168
  (input, options) => {
169
+ const payload = input;
138
170
  if (isRawBodySchema(node.schema.body) && !hasRouteInput(node, path)) {
139
171
  options = { ...options, body: input };
140
172
  input = undefined;
141
173
  }
142
- return fetch({
174
+ return runClientRequest({
143
175
  schema: node.schema,
144
176
  path,
177
+ routeName,
145
178
  method: node.method,
146
179
  input,
180
+ payload,
147
181
  options,
148
182
  $result: undefined,
149
- });
183
+ }, fetch, clientHook);
150
184
  },
151
185
  ];
152
186
  }));
153
187
  }
188
+ async function runClientRequest(request, fetch, clientHook) {
189
+ if (!clientHook) {
190
+ try {
191
+ return (await fetch(request)).value;
192
+ }
193
+ catch (error) {
194
+ throw getClientRequestFailure(error)?.error ?? error;
195
+ }
196
+ }
197
+ const opId = createClientOpId();
198
+ const startTime = Date.now();
199
+ const baseEvent = {
200
+ opId,
201
+ routeName: request.routeName,
202
+ method: request.method,
203
+ pathPattern: request.path.source,
204
+ payload: request.payload,
205
+ };
206
+ emitClientHook(clientHook, {
207
+ type: 'request.start',
208
+ ...baseEvent,
209
+ });
210
+ try {
211
+ const result = await fetch(request);
212
+ emitClientHook(clientHook, {
213
+ type: 'request.success',
214
+ ...baseEvent,
215
+ response: result.value,
216
+ ...clientRequestStatus(result.status),
217
+ durationMs: Date.now() - startTime,
218
+ });
219
+ return result.value;
220
+ }
221
+ catch (error) {
222
+ const failure = getClientRequestFailure(error);
223
+ const eventError = failure ? failure.error : error;
224
+ emitClientHook(clientHook, {
225
+ type: 'request.error',
226
+ ...baseEvent,
227
+ error: eventError,
228
+ ...clientRequestStatus(failure?.status),
229
+ durationMs: Date.now() - startTime,
230
+ });
231
+ throw eventError;
232
+ }
233
+ }
234
+ function emitClientHook(clientHook, event) {
235
+ try {
236
+ clientHook(event);
237
+ }
238
+ catch {
239
+ // Lifecycle hooks are observability-only and must not affect requests.
240
+ }
241
+ }
242
+ function createClientOpId() {
243
+ nextClientOpId += 1;
244
+ return `rouzer:${Date.now().toString(36)}:${nextClientOpId.toString(36)}`;
245
+ }
246
+ function getClientRequestFailure(error) {
247
+ return error instanceof ClientRequestFailure ? error : undefined;
248
+ }
249
+ function clientRequestStatus(status) {
250
+ return status === undefined ? {} : { status };
251
+ }
252
+ function joinNames(left, right) {
253
+ return [left, right].filter(Boolean).join('.');
254
+ }
154
255
  function validateClientResponsePlugins(tree, plugins) {
155
256
  for (const node of Object.values(tree)) {
156
257
  if (node.kind === 'resource') {
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
  }
@@ -25,10 +25,12 @@ 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
  */
33
35
  export type RouteFetchOptions<T extends RouteSchema = any> = Omit<RequestInit, 'method' | 'body' | 'headers'> & {
34
36
  /** Headers for this request. Undefined values are removed before `fetch`. */
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
@@ -215,7 +215,9 @@ requests with an `Origin` header.
215
215
  `routes`, with action functions such as `client.profiles.get(args)`.
216
216
  Generated action functions accept a flattened first argument containing path,
217
217
  query, and JSON body fields. Per-request `RequestInit` options, including
218
- headers and abort signals, are passed as the optional second argument.
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.
219
221
 
220
222
  Generated action functions include:
221
223
 
@@ -240,6 +242,54 @@ top, and a custom `fetch` implementation can be supplied for tests or non-browse
240
242
  runtimes. The returned client exposes the original options as `clientConfig`, so
241
243
  route actions named `config` remain available as `client.config(...)`.
242
244
 
245
+ ### Client lifecycle hooks
246
+
247
+ `createClient({ clientHook })` observes generated client action calls without
248
+ wrapping the returned client tree:
249
+
250
+ ```ts
251
+ const client = createClient({
252
+ baseURL: 'https://example.com/api/',
253
+ routes,
254
+ clientHook(event) {
255
+ if (event.type === 'request.success') {
256
+ console.log({
257
+ opId: event.opId,
258
+ routeName: event.routeName,
259
+ durationMs: event.durationMs,
260
+ })
261
+ }
262
+ },
263
+ })
264
+ ```
265
+
266
+ Rouzer emits:
267
+
268
+ - `request.start` before client-side validation
269
+ - `request.success` when the generated action resolves
270
+ - `request.error` when the generated action rejects
271
+
272
+ Each event includes an opaque per-call `opId`, the generated client route name
273
+ such as `session.create`, the HTTP method, the joined route path pattern, and
274
+ the generated action's first argument as `payload`. Terminal events include
275
+ `durationMs`, either the resolved `response` or thrown `error`, and `status` when
276
+ an HTTP response was received.
277
+
278
+ `request.error` covers client validation failures, transport failures,
279
+ undeclared HTTP errors, JSON parsing failures, response plugin decode failures,
280
+ and rejected `onJsonError` handlers. A declared error response returned as data
281
+ from a response map is a successful generated action and emits
282
+ `request.success`.
283
+
284
+ Lifecycle hooks are best-effort observability only. If `clientHook` throws,
285
+ Rouzer swallows that hook error and preserves the generated action's original
286
+ behavior.
287
+
288
+ For response plugins that return streams, such as NDJSON, `request.success` is
289
+ emitted when the generated action resolves to the stream object. Errors that
290
+ happen later while the caller consumes the stream are outside the first lifecycle
291
+ hook surface.
292
+
243
293
  ## Lifecycle
244
294
 
245
295
  1. Define shared HTTP actions/resources with `rouzer/http` and Zod schemas.
@@ -248,8 +298,8 @@ route actions named `config` remain available as `client.config(...)`.
248
298
  are needed.
249
299
  3. Create a client with the same route tree, plus matching client response
250
300
  plugins when needed.
251
- 4. Client action calls validate `path`, `query`, `body`, and `headers` before
252
- `fetch`.
301
+ 4. Client action calls validate `path`, `query`, JSON object `body`, and
302
+ `headers` before `fetch`. Raw bodies are passed through without validation.
253
303
  5. The router matches the request, validates the matched inputs, and calls the
254
304
  handler.
255
305
  6. Plain handler results become JSON responses, response-map helpers choose
@@ -259,7 +309,8 @@ route actions named `config` remain available as `client.config(...)`.
259
309
  On the server, `path`, `query`, and `headers` values originate as strings. Rouzer
260
310
  coerces Zod `number` schemas with `Number(value)` and Zod `boolean` schemas from
261
311
  `"true"` and `"false"`. JSON request bodies are parsed and validated without that
262
- string-coercion step.
312
+ string-coercion step. Raw request bodies declared with `http.rawBody()` are not
313
+ parsed by Rouzer.
263
314
 
264
315
  ## Common tasks
265
316
 
@@ -412,8 +463,26 @@ await client.uploadAvatar(
412
463
  )
413
464
  ```
414
465
 
415
- Path fields still live in the flat first argument. The raw body itself is passed
416
- as `body` in the second argument because it is a `RequestInit` value.
466
+ When a raw-body route has path or query input, path/query fields still live in
467
+ the flat first argument. The raw body itself is passed as `body` in the second
468
+ argument because it is a `RequestInit` value.
469
+
470
+ For raw-body routes without path or query input, the generated client action
471
+ accepts the body as the first argument and fetch options as the second:
472
+
473
+ ```ts
474
+ export const upload = http.post('uploads', {
475
+ body: http.rawBody(),
476
+ })
477
+
478
+ await client.upload(file, {
479
+ headers: { 'content-type': file.type },
480
+ })
481
+ ```
482
+
483
+ Server handlers for raw-body routes read from `ctx.request` directly with Fetch
484
+ APIs such as `arrayBuffer()`, `blob()`, `formData()`, or `text()`. Rouzer does
485
+ not parse or validate raw request bodies.
417
486
 
418
487
  ### Return custom responses
419
488
 
@@ -515,8 +584,10 @@ await client.profiles.update({ id: '42', name: 'Ada' })
515
584
  strings passed to `http.resource(...)` and action helpers.
516
585
  - Path, query, and JSON body fields are flattened into the first client action
517
586
  argument. Per-request `RequestInit` fields, such as `signal`, `credentials`,
518
- and `headers`, belong in the second argument. `method` is reserved by Rouzer;
519
- `body` is only accepted in the second argument for `http.rawBody()` actions.
587
+ and `headers`, belong in the second argument. `method` is reserved by Rouzer.
588
+ For `http.rawBody()` actions, `body` is accepted in the second argument when
589
+ the route has path or query input; raw-body actions without route input accept
590
+ the body as the first argument.
520
591
  - The HTTP action API has no `ALL` fallback route. Declare explicit actions for
521
592
  supported methods.
522
593
  - Rouzer does not automatically set `Access-Control-Allow-Credentials`; set it in
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "rouzer",
3
- "version": "5.1.0",
3
+ "version": "5.2.0",
4
+ "packageManager": "pnpm@11.5.1",
4
5
  "type": "module",
5
6
  "exports": {
6
7
  ".": {
@@ -16,6 +17,12 @@
16
17
  "import": "./dist/ndjson.js"
17
18
  }
18
19
  },
20
+ "scripts": {
21
+ "build": "rm -rf dist && tsgo -b tsconfig.json",
22
+ "format": "prettier --write src test",
23
+ "test": "vitest run",
24
+ "prepublishOnly": "pnpm build"
25
+ },
19
26
  "peerDependencies": {
20
27
  "zod": ">=4"
21
28
  },
@@ -49,10 +56,5 @@
49
56
  "examples",
50
57
  "README.md",
51
58
  "!*.tsbuildinfo"
52
- ],
53
- "scripts": {
54
- "build": "rm -rf dist && tsgo -b tsconfig.json",
55
- "format": "prettier --write src test",
56
- "test": "vitest run"
57
- }
58
- }
59
+ ]
60
+ }