rouzer 5.1.1 → 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
@@ -181,6 +181,28 @@ await client.upload(file, { headers: { 'content-type': file.type } })
181
181
  Server handlers for raw-body routes read from `ctx.request` directly with Fetch
182
182
  APIs such as `arrayBuffer()`, `blob()`, `formData()`, or `text()`.
183
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
+
184
206
  ### NDJSON response streams
185
207
 
186
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}`;
@@ -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/docs/context.md CHANGED
@@ -242,6 +242,54 @@ top, and a custom `fetch` implementation can be supplied for tests or non-browse
242
242
  runtimes. The returned client exposes the original options as `clientConfig`, so
243
243
  route actions named `config` remain available as `client.config(...)`.
244
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
+
245
293
  ## Lifecycle
246
294
 
247
295
  1. Define shared HTTP actions/resources with `rouzer/http` and Zod schemas.
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "rouzer",
3
- "version": "5.1.1",
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
+ }