rouzer 3.2.0 → 5.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 CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  Rouzer lets you declare an HTTP route tree once and share its TypeScript types
4
4
  and Zod validation between a Hattip-compatible server and a typed fetch client.
5
+ The client is always created from that route tree.
5
6
 
6
7
  ## What it does
7
8
 
@@ -45,7 +46,7 @@ Consider something else if:
45
46
  - Zod v4 or newer
46
47
  - a Hattip adapter when using `createRouter(...)`
47
48
  - a Fetch API implementation when using `createClient(...)`
48
- - an absolute `baseURL` for generated client URLs
49
+ - an absolute `baseURL` and shared `routes` tree for generated client URLs
49
50
 
50
51
  ## Installation
51
52
 
@@ -96,14 +97,14 @@ const client = createClient({
96
97
  })
97
98
 
98
99
  const { message } = await client.hello({
99
- path: { name: 'world' },
100
- query: { excited: true },
100
+ name: 'world',
101
+ excited: true,
101
102
  })
102
103
  ```
103
104
 
104
- `handler` can be mounted with any Hattip adapter. Client action calls validate
105
- route arguments before `fetch`; server handlers validate matched path, query,
106
- headers, and JSON bodies before your handler runs.
105
+ `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
108
 
108
109
  ### Typed status responses
109
110
 
@@ -142,7 +143,7 @@ const client = createClient({
142
143
  routes,
143
144
  })
144
145
 
145
- const [error, user, status] = await client.getUser({ path: { id: '42' } })
146
+ const [error, user, status] = await client.getUser({ id: '42' })
146
147
  ```
147
148
 
148
149
  Success entries resolve as `[null, value, status]`; declared error entries
@@ -1,8 +1,7 @@
1
1
  import { Promisable } from '../common.js';
2
- import type { HttpAction, HttpResource, HttpRouteTree } from '../http.js';
2
+ import { type HttpAction, type HttpResource, type HttpRouteTree } from '../http.js';
3
3
  import { type ClientResponsePlugin } from '../response.js';
4
- import type { RouteArgs } from '../types/args.js';
5
- import type { RouteRequest } from '../types/request.js';
4
+ import type { RouteInput, RouteOptions } from '../types/args.js';
6
5
  import type { InferRouteResponse } from '../types/response.js';
7
6
  import type { RouteSchema } from '../types/schema.js';
8
7
  /** Client type inferred from an HTTP route tree passed to `createClient`. */
@@ -10,9 +9,8 @@ export type RouzerClient<TRoutes extends HttpRouteTree = Record<string, never>>
10
9
  /**
11
10
  * Create a typed fetch client for an HTTP route tree.
12
11
  *
13
- * @remarks The returned client always includes `request(...)` for raw responses
14
- * and `json(...)` for parsed JSON. Passing `routes` also mirrors the resource
15
- * tree and attaches direct action functions such as `client.users.list(...)`.
12
+ * @remarks The returned client mirrors the resource tree and attaches direct
13
+ * action functions such as `client.users.list(...)`.
16
14
  */
17
15
  export declare function createClient<TRoutes extends HttpRouteTree = Record<string, never>>(config: {
18
16
  /**
@@ -38,22 +36,22 @@ export declare function createClient<TRoutes extends HttpRouteTree = Record<stri
38
36
  * await client.users.list({ query: { page: 1 } })
39
37
  * ```
40
38
  */
41
- routes?: TRoutes;
39
+ routes: TRoutes;
42
40
  /** Response codec plugins used by generated action functions. */
43
41
  plugins?: readonly ClientResponsePlugin[];
44
42
  /**
45
- * Custom handler for non-2xx responses from `.json()` and generated response
46
- * helpers.
43
+ * Custom handler for non-2xx responses from generated client action
44
+ * functions.
47
45
  *
48
- * @remarks When provided, the return value is returned from the response
49
- * helper as-is; Rouzer does not automatically parse a `Response` returned by
50
- * this hook.
46
+ * @remarks When provided, the return value is returned from the client action
47
+ * as-is; Rouzer does not automatically parse a `Response` returned by this
48
+ * hook.
51
49
  */
52
50
  onJsonError?: (response: Response) => Promisable<unknown>;
53
51
  /** Custom `fetch` implementation to use for requests. */
54
52
  fetch?: typeof globalThis.fetch;
55
53
  }): ClientTree<TRoutes, ""> & {
56
- config: {
54
+ clientConfig: {
57
55
  /**
58
56
  * Absolute base URL used for generated request URLs.
59
57
  *
@@ -77,25 +75,21 @@ export declare function createClient<TRoutes extends HttpRouteTree = Record<stri
77
75
  * await client.users.list({ query: { page: 1 } })
78
76
  * ```
79
77
  */
80
- routes?: TRoutes;
78
+ routes: TRoutes;
81
79
  /** Response codec plugins used by generated action functions. */
82
80
  plugins?: readonly ClientResponsePlugin[];
83
81
  /**
84
- * Custom handler for non-2xx responses from `.json()` and generated response
85
- * helpers.
82
+ * Custom handler for non-2xx responses from generated client action
83
+ * functions.
86
84
  *
87
- * @remarks When provided, the return value is returned from the response
88
- * helper as-is; Rouzer does not automatically parse a `Response` returned by
89
- * this hook.
85
+ * @remarks When provided, the return value is returned from the client action
86
+ * as-is; Rouzer does not automatically parse a `Response` returned by this
87
+ * hook.
90
88
  */
91
89
  onJsonError?: (response: Response) => Promisable<unknown>;
92
90
  /** Custom `fetch` implementation to use for requests. */
93
91
  fetch?: typeof globalThis.fetch;
94
92
  };
95
- request: <T extends RouteRequest>({ path: pathBuilder, method, args, schema, }: T) => Promise<Response & {
96
- json(): Promise<T['$result']>;
97
- }>;
98
- json: <T extends RouteRequest>(props: T) => Promise<T['$result']>;
99
93
  };
100
94
  type Join<A extends string, B extends string> = A extends '' ? B : B extends '' ? A : `${A}/${B}`;
101
95
  /** Client object shape produced from an HTTP route tree. */
@@ -112,7 +106,7 @@ export type ClientTree<T extends HttpRouteTree, TPrefix extends string = ''> = {
112
106
  * plugin's client result type. Actions without a response marker return the raw
113
107
  * `Response`.
114
108
  */
115
- export type RouteFunction<T extends RouteSchema, P extends string> = (...p: RouteArgs<T, P> extends infer TArgs ? {} extends TArgs ? [args?: TArgs] : [args: TArgs] : never) => Promise<T extends {
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 {
116
110
  response: any;
117
111
  } ? InferRouteResponse<T> : Response>;
118
112
  export {};
@@ -1,30 +1,27 @@
1
1
  import { RoutePattern } from '@remix-run/route-pattern';
2
2
  import { createHref } from '@remix-run/route-pattern/href';
3
3
  import { shake } from '../common.js';
4
- import { createResponsePluginMap, getResponsePluginMarkerId, } from '../response.js';
4
+ import { isRawBodySchema, } from '../http.js';
5
5
  import { getResponseMapPluginIds, isErrorMarker, isResponseMap, } from '../response-map.js';
6
+ import { createResponsePluginMap, getResponsePluginMarkerId, } from '../response.js';
6
7
  /**
7
8
  * Create a typed fetch client for an HTTP route tree.
8
9
  *
9
- * @remarks The returned client always includes `request(...)` for raw responses
10
- * and `json(...)` for parsed JSON. Passing `routes` also mirrors the resource
11
- * tree and attaches direct action functions such as `client.users.list(...)`.
10
+ * @remarks The returned client mirrors the resource tree and attaches direct
11
+ * action functions such as `client.users.list(...)`.
12
12
  */
13
13
  export function createClient(config) {
14
14
  const baseURL = config.baseURL.replace(/\/?$/, '/');
15
15
  const defaultHeaders = config.headers && shake(config.headers);
16
16
  const fetch = config.fetch ?? globalThis.fetch;
17
17
  const responsePlugins = createResponsePluginMap(config.plugins, 'client response');
18
- if (config.routes) {
19
- validateClientResponsePlugins(config.routes, responsePlugins);
20
- }
21
- async function request({ path: pathBuilder, method, args, schema, }) {
22
- let { path, query, body, headers, ...init } = args;
23
- if (schema.path) {
24
- path = schema.path.parse(path);
25
- }
18
+ validateClientResponsePlugins(config.routes, responsePlugins);
19
+ async function plainRequest({ path: pathPattern, method, input = {}, options: { body: rawBody, headers, ...init } = {}, schema, }) {
20
+ const path = schema.path
21
+ ? schema.path.parse(pickObjectSchemaFields(schema.path, input))
22
+ : input;
26
23
  let url;
27
- const href = createHref(pathBuilder, path);
24
+ const href = createHref(pathPattern, path);
28
25
  if (href[0] === '/') {
29
26
  url = new URL(baseURL);
30
27
  url.pathname += href.slice(1);
@@ -33,17 +30,14 @@ export function createClient(config) {
33
30
  url = new URL(href, baseURL);
34
31
  }
35
32
  if (schema.query) {
36
- query = schema.query.parse(query ?? {});
33
+ const query = schema.query.parse(pickObjectSchemaFields(schema.query, input));
37
34
  url.search = new URLSearchParams(shake(query)).toString();
38
35
  }
39
- else if (query) {
40
- throw new Error('Unexpected query parameters');
41
- }
36
+ let body;
42
37
  if (schema.body) {
43
- body = schema.body.parse(body !== undefined ? body : {});
44
- }
45
- else if (body !== undefined) {
46
- throw new Error('Unexpected body');
38
+ body = isRawBodySchema(schema.body)
39
+ ? rawBody
40
+ : schema.body.parse(pickObjectSchemaFields(schema.body, input));
47
41
  }
48
42
  if (headers) {
49
43
  headers = shake(headers);
@@ -57,27 +51,24 @@ export function createClient(config) {
57
51
  return fetch(url, {
58
52
  ...init,
59
53
  method,
60
- body: body !== undefined ? JSON.stringify(body) : undefined,
54
+ body: isRawBodySchema(schema.body)
55
+ ? body
56
+ : body !== undefined
57
+ ? JSON.stringify(body)
58
+ : undefined,
61
59
  headers: (headers ?? defaultHeaders),
62
60
  });
63
61
  }
64
- async function json(props) {
65
- const response = await request(props);
66
- if (!response.ok) {
67
- return handleResponseError(response, props);
68
- }
69
- return response.json();
70
- }
71
- async function response(props) {
72
- const httpResponse = await request(props);
62
+ async function parsedRequest(props) {
63
+ const response = await plainRequest(props);
73
64
  const responseSchema = props.schema.response;
74
65
  // Handle status-keyed response maps
75
66
  if (isResponseMap(responseSchema)) {
76
- const status = httpResponse.status;
67
+ const status = response.status;
77
68
  if (status in responseSchema) {
78
69
  const marker = responseSchema[status];
79
70
  if (isErrorMarker(marker)) {
80
- return [await httpResponse.json(), null, status];
71
+ return [await response.json(), null, status];
81
72
  }
82
73
  const pluginId = getResponsePluginMarkerId(marker);
83
74
  if (pluginId) {
@@ -87,20 +78,20 @@ export function createClient(config) {
87
78
  }
88
79
  return [
89
80
  null,
90
- await plugin.decode(httpResponse, {
81
+ await plugin.decode(response, {
91
82
  marker: marker,
92
83
  request: props,
93
84
  }),
94
85
  status,
95
86
  ];
96
87
  }
97
- return [null, await httpResponse.json(), status];
88
+ return [null, await response.json(), status];
98
89
  }
99
90
  // Undeclared status — reject
100
- return handleResponseError(httpResponse, props);
91
+ return handleResponseError(response, props);
101
92
  }
102
- if (!httpResponse.ok) {
103
- return handleResponseError(httpResponse, props);
93
+ if (!response.ok) {
94
+ return handleResponseError(response, props);
104
95
  }
105
96
  const pluginId = getResponsePluginMarkerId(responseSchema);
106
97
  if (pluginId) {
@@ -108,18 +99,18 @@ export function createClient(config) {
108
99
  if (!plugin) {
109
100
  throw missingClientResponsePlugin(pluginId);
110
101
  }
111
- return plugin.decode(httpResponse, {
102
+ return plugin.decode(response, {
112
103
  marker: responseSchema,
113
104
  request: props,
114
105
  });
115
106
  }
116
- return httpResponse.json();
107
+ return response.json();
117
108
  }
118
109
  async function handleResponseError(response, props) {
119
110
  if (config.onJsonError) {
120
111
  return config.onJsonError(response);
121
112
  }
122
- const error = new Error(`Request to ${props.method} ${createHref(props.path, props.args.path)} failed with status ${response.status}`);
113
+ const error = new Error(`Request to ${props.method} ${createHref(props.path, props.input)} failed with status ${response.status}`);
123
114
  const contentType = response.headers.get('content-type');
124
115
  if (contentType?.includes('application/json')) {
125
116
  Object.assign(error, await response.json());
@@ -127,31 +118,28 @@ export function createClient(config) {
127
118
  throw error;
128
119
  }
129
120
  return {
130
- ...(config.routes
131
- ? connectTree(config.routes, '', request, response)
132
- : null),
133
- config,
134
- request,
135
- json,
121
+ ...connectTree(config.routes, '', plainRequest, parsedRequest),
122
+ clientConfig: config,
136
123
  };
137
124
  }
138
- function connectTree(tree, prefix, request, response) {
125
+ function connectTree(tree, prefix, plainRequest, parsedRequest) {
139
126
  return Object.fromEntries(Object.entries(tree).map(([key, node]) => {
140
127
  if (node.kind === 'resource') {
141
128
  return [
142
129
  key,
143
- connectTree(node.children, joinPaths(prefix, node.path.source), request, response),
130
+ connectTree(node.children, joinPaths(prefix, node.path.source), plainRequest, parsedRequest),
144
131
  ];
145
132
  }
146
133
  const path = RoutePattern.parse(joinPaths(prefix, node.path?.source ?? ''));
147
- const fetch = node.schema.response ? response : request;
134
+ const fetch = node.schema.response ? parsedRequest : plainRequest;
148
135
  return [
149
136
  key,
150
- (args = {}) => fetch({
137
+ (input, options) => fetch({
151
138
  schema: node.schema,
152
139
  path,
153
140
  method: node.method,
154
- args,
141
+ input,
142
+ options,
155
143
  $result: undefined,
156
144
  }),
157
145
  ];
@@ -177,6 +165,14 @@ function validateClientResponsePlugins(tree, plugins) {
177
165
  function missingClientResponsePlugin(pluginId) {
178
166
  return new Error(`Missing client response plugin for ${pluginId}`);
179
167
  }
168
+ function pickObjectSchemaFields(schema, input) {
169
+ if (typeof input !== 'object' || input === null) {
170
+ return input;
171
+ }
172
+ return Object.fromEntries(Object.keys(schema.shape)
173
+ .filter(key => key in input)
174
+ .map(key => [key, input[key]]));
175
+ }
180
176
  function joinPaths(left, right) {
181
177
  return [left, right].filter(Boolean).join('/').replace(/\/+/g, '/');
182
178
  }
package/dist/http.d.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import { RoutePattern } from '@remix-run/route-pattern';
2
- import type { RouteRequestFactory } from './types/request.js';
3
- import type { RouteSchema } from './types/schema.js';
2
+ import type { RawBodySchema, RouteSchema } from './types/schema.js';
4
3
  /** HTTP methods supported by Rouzer action declarations. */
5
4
  export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
6
5
  /**
@@ -18,8 +17,6 @@ export type HttpAction<P extends string = string, T extends RouteSchema = RouteS
18
17
  method: M;
19
18
  /** Request validation and optional response type schema. */
20
19
  schema: T;
21
- /** Low-level request descriptor factory for this action. */
22
- request: RouteRequestFactory<T, P>;
23
20
  };
24
21
  /**
25
22
  * Path-scoped namespace in an HTTP route tree.
@@ -65,3 +62,6 @@ export declare function patch<const T extends RouteSchema>(schema: T): HttpActio
65
62
  declare function deleteAction<const P extends string, const T extends RouteSchema>(path: P, schema: T): HttpAction<P, T, 'DELETE'>;
66
63
  declare function deleteAction<const T extends RouteSchema>(schema: T): HttpAction<'', T, 'DELETE'>;
67
64
  export { deleteAction as delete };
65
+ /** Declare a request body that is passed through to `fetch` without JSON encoding. */
66
+ export declare function rawBody(): RawBodySchema;
67
+ export declare function isRawBodySchema(schema: unknown): schema is RawBodySchema;
package/dist/http.js CHANGED
@@ -29,17 +29,17 @@ 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. */
33
+ export function rawBody() {
34
+ return { __rawBody__: Symbol('rouzer.rawBody') };
35
+ }
36
+ export function isRawBodySchema(schema) {
37
+ return Boolean(schema && typeof schema === 'object' && '__rawBody__' in schema);
38
+ }
32
39
  function action(method, pathOrSchema, schema) {
33
40
  const path = typeof pathOrSchema === 'string'
34
41
  ? RoutePattern.parse(pathOrSchema)
35
42
  : undefined;
36
43
  schema ??= typeof pathOrSchema === 'string' ? {} : pathOrSchema;
37
- const request = ((args = {}) => ({
38
- schema,
39
- path: path ?? RoutePattern.parse(''),
40
- method,
41
- args,
42
- $result: undefined,
43
- }));
44
- return { kind: 'action', path, method, schema, request };
44
+ return { kind: 'action', path, method, schema };
45
45
  }
@@ -1,5 +1,7 @@
1
1
  import type { Promisable } from './common.js';
2
- import type { RouteRequest } from './types/request.js';
2
+ import type { RoutePattern } from '@remix-run/route-pattern';
3
+ import type { RouteOptions } from './types/args.js';
4
+ import type { RouteSchema } from './types/schema.js';
3
5
  /** Runtime key carried by response plugin markers. */
4
6
  export declare const responsePluginMarker: unique symbol;
5
7
  /**
@@ -24,9 +26,17 @@ export type ClientResponsePlugin = {
24
26
  /** Decode a successful `Response` into the client action result. */
25
27
  decode(response: Response, context: {
26
28
  marker: ResponsePluginMarker<any, any>;
27
- request: RouteRequest;
29
+ request: ClientResponsePluginRequest;
28
30
  }): Promisable<unknown>;
29
31
  };
32
+ /** Request metadata passed to client response plugins. */
33
+ export type ClientResponsePluginRequest = {
34
+ schema: RouteSchema;
35
+ path: RoutePattern;
36
+ method: string;
37
+ input?: unknown;
38
+ options?: RouteOptions;
39
+ };
30
40
  /** Router-side response plugin used by `createRouter({ plugins })`. */
31
41
  export type RouterResponsePlugin = {
32
42
  /** Stable response codec id matched against route response markers. */
@@ -1,6 +1,6 @@
1
1
  import type { HattipHandler } from '@hattip/core';
2
2
  import { ApplyMiddleware, chain, ExtractMiddleware, MiddlewareChain, MiddlewareTypes } from 'alien-middleware';
3
- import type { HttpRouteTree } from '../http.js';
3
+ import { type HttpRouteTree } from '../http.js';
4
4
  import { type RouterResponsePlugin } from '../response.js';
5
5
  import type { RouteRequestHandlerMap } from '../types/server.js';
6
6
  export { chain };
@@ -3,6 +3,7 @@ import { createMatcher } from '@remix-run/route-pattern/match';
3
3
  import { chain, MiddlewareChain, } from 'alien-middleware';
4
4
  import * as z from 'zod';
5
5
  import { mapValues } from '../common.js';
6
+ import { isRawBodySchema } from '../http.js';
6
7
  import { createResponsePluginMap, getResponsePluginMarkerId, } from '../response.js';
7
8
  import { getDefaultSuccessStatus, getResponseMapPluginIds, isErrorMarker, isResponseMap, } from '../response-map.js';
8
9
  export { chain };
@@ -103,7 +104,7 @@ class RouterObject extends MiddlewareChain {
103
104
  return httpClientError(error, 'Invalid query string', config);
104
105
  }
105
106
  }
106
- if (schema.body) {
107
+ if (schema.body && !isRawBodySchema(schema.body)) {
107
108
  const error = await parseRequestBody(context, schema.body);
108
109
  if (error) {
109
110
  addDebugHeaders?.(context, route);
@@ -1,51 +1,42 @@
1
1
  import type { MatchParams } from '@remix-run/route-pattern/match';
2
2
  import type * as z from 'zod';
3
- import type { MutationRouteSchema, QueryRouteSchema, RouteSchema } from './schema.js';
3
+ import type { MutationRouteSchema, QueryRouteSchema, RawBodySchema, RouteSchema } from './schema.js';
4
4
  declare class Any {
5
5
  private isAny;
6
6
  }
7
- type PathArgs<T, P extends string> = T extends {
7
+ type PathInput<T, P extends string> = T extends {
8
8
  path: infer TPath;
9
- } ? {} extends z.infer<TPath> ? {
10
- [K in keyof T as 'path']?: z.infer<TPath>;
11
- } : {
12
- [K in keyof T as 'path']: z.infer<TPath>;
13
- } : MatchParams<P> extends infer TParams ? {} extends TParams ? {
14
- [K in keyof T as 'path']?: TParams;
15
- } : {
16
- [K in keyof T as 'path']: TParams;
17
- } : unknown;
18
- type QueryArgs<T> = T extends QueryRouteSchema & {
9
+ } ? z.infer<TPath> : MatchParams<P>;
10
+ type QueryInput<T> = T extends QueryRouteSchema & {
19
11
  query: infer TQuery;
20
- } ? {} extends z.infer<TQuery> ? {
21
- [K in keyof T as 'query']?: z.infer<TQuery>;
22
- } : {
23
- [K in keyof T as 'query']: z.infer<TQuery>;
24
- } : unknown;
25
- type MutationArgs<T> = T extends MutationRouteSchema ? T extends {
12
+ } ? z.infer<TQuery> : unknown;
13
+ type BodyInput<T> = T extends MutationRouteSchema ? T extends {
26
14
  body: infer TBody;
27
- } ? {} extends z.infer<TBody> ? {
28
- [K in keyof T as 'body']?: z.infer<TBody>;
29
- } : {
30
- [K in keyof T as 'body']: z.infer<TBody>;
31
- } : {
32
- body?: unknown;
33
- } : unknown;
15
+ } ? TBody extends RawBodySchema ? unknown : z.infer<TBody> : unknown : unknown;
16
+ type HeaderInput<T> = T extends {
17
+ headers: infer THeaders;
18
+ } ? Partial<z.infer<THeaders>> : Record<string, string | undefined>;
34
19
  /**
35
- * Arguments accepted by a client action function or low-level request factory.
20
+ * Semantic input accepted by a generated client action function.
36
21
  *
37
- * @remarks The type is derived from an action schema and route pattern. `path`,
38
- * `query`, `body`, and `headers` are validated by the client before `fetch` when
39
- * a matching schema exists. Other `RequestInit` fields are forwarded to `fetch`,
40
- * except `method`, `body`, and `headers`, which Rouzer derives from the action
41
- * schema and call arguments.
22
+ * @remarks Path params, query params, and JSON body fields are flattened into a
23
+ * single object. Avoid declaring duplicate keys across path/query/body schemas,
24
+ * since a flat input cannot distinguish their source.
42
25
  */
43
- export type RouteArgs<T extends RouteSchema = any, P extends string = string> = ([T] extends [Any] ? {
44
- query?: any;
45
- body?: any;
46
- path?: any;
47
- } : QueryArgs<T> & MutationArgs<T> & PathArgs<T, P>) & Omit<RequestInit, 'method' | 'body' | 'headers'> & {
26
+ export type RouteInput<T extends RouteSchema = any, P extends string = string> = [T] extends [Any] ? any : PathInput<T, P> & QueryInput<T> & BodyInput<T>;
27
+ /**
28
+ * Fetch options accepted as the second argument to a generated client action.
29
+ *
30
+ * @remarks `headers` remains optional because required route headers may be
31
+ * supplied by `createClient({ headers })` defaults.
32
+ */
33
+ type RouteBodyOption<T> = T extends {
34
+ body: RawBodySchema;
35
+ } ? {
36
+ body: BodyInit | null;
37
+ } : {};
38
+ export type RouteOptions<T extends RouteSchema = any> = Omit<RequestInit, 'method' | 'body' | 'headers'> & RouteBodyOption<T> & {
48
39
  /** Headers for this request. Undefined values are removed before `fetch`. */
49
- headers?: Record<string, string | undefined>;
40
+ headers?: HeaderInput<T>;
50
41
  };
51
42
  export {};
@@ -4,7 +4,10 @@ import type * as z from 'zod';
4
4
  import { Promisable } from '../common.js';
5
5
  import type { HttpAction } from '../http.js';
6
6
  import type { InferRouteHandlerResult, InferResponseMapErrors, InferResponseMapSuccesses } from './response.js';
7
- import type { RouteResponseMap, RouteSchema } from './schema.js';
7
+ import type { RawBodySchema, RouteResponseMap, RouteSchema } from './schema.js';
8
+ type InferHandlerBody<T> = T extends {
9
+ body: infer TBody;
10
+ } ? TBody extends RawBodySchema ? undefined : z.infer<TBody> : undefined;
8
11
  type RequestContext<TMiddleware extends AnyMiddlewareChain> = MiddlewareContext<TMiddleware>;
9
12
  /**
10
13
  * Error response returned by `ctx.error(status, body)` in route handlers.
@@ -51,9 +54,7 @@ export type InferActionHandler<TMiddleware extends AnyMiddlewareChain, TAction e
51
54
  path: TAction['schema'] extends {
52
55
  path: any;
53
56
  } ? z.infer<TAction['schema']['path']> : MatchParams<TPath>;
54
- body: TAction['schema'] extends {
55
- body: any;
56
- } ? z.infer<TAction['schema']['body']> : undefined;
57
+ body: InferHandlerBody<TAction['schema']>;
57
58
  headers: TAction['schema'] extends {
58
59
  headers: any;
59
60
  } ? z.infer<TAction['schema']['headers']> : undefined;
@@ -71,9 +72,7 @@ export type InferActionHandler<TMiddleware extends AnyMiddlewareChain, TAction e
71
72
  path: TAction['schema'] extends {
72
73
  path: any;
73
74
  } ? z.infer<TAction['schema']['path']> : MatchParams<TPath>;
74
- body: TAction['schema'] extends {
75
- body: any;
76
- } ? z.infer<TAction['schema']['body']> : undefined;
75
+ body: InferHandlerBody<TAction['schema']>;
77
76
  headers: TAction['schema'] extends {
78
77
  headers: any;
79
78
  } ? z.infer<TAction['schema']['headers']> : undefined;
@@ -1,7 +1,6 @@
1
1
  export type * from './args.js';
2
2
  export type * from './handler.js';
3
3
  export type * from './infer.js';
4
- export type * from './request.js';
5
4
  export type * from './response.js';
6
5
  export type * from './schema.js';
7
6
  export type * from './server.js';
@@ -1,19 +1,14 @@
1
1
  import type * as z from 'zod';
2
2
  import type { MutationRouteSchema, RouteSchema } from './schema.js';
3
- import type { RouteRequestFactory } from './request.js';
4
3
  type InferRouteSchemaBody<TSchema> = TSchema extends MutationRouteSchema ? TSchema extends {
5
4
  body: infer TBody;
6
5
  } ? z.infer<TBody> : unknown : never;
7
- type InferRouteArgsBody<TArgs> = TArgs extends {
8
- body?: infer TBody;
9
- } ? TBody : never;
10
6
  /**
11
- * Infer the request body type from an action schema or request factory.
7
+ * Infer the request body type from an action schema.
12
8
  *
13
9
  * @remarks HTTP action schemas can be inspected with
14
- * `InferRouteBody<typeof action.schema>`. Request factories for mutation actions
15
- * infer their `body` argument type. Schemas without a body schema infer
10
+ * `InferRouteBody<typeof action.schema>`. Schemas without a body schema infer
16
11
  * `unknown`.
17
12
  */
18
- export type InferRouteBody<T> = T extends RouteRequestFactory<any, any> ? InferRouteArgsBody<T['$args']> : T extends RouteSchema ? InferRouteSchemaBody<T> : never;
13
+ export type InferRouteBody<T> = T extends RouteSchema ? InferRouteSchemaBody<T> : never;
19
14
  export {};
@@ -36,14 +36,18 @@ export type QueryRouteSchema = {
36
36
  /** Optional compile-time-only JSON or plugin response type marker. */
37
37
  response?: RouteResponseSchema;
38
38
  };
39
+ /** Marker for request bodies passed through to `fetch` without JSON encoding. */
40
+ export type RawBodySchema = {
41
+ readonly __rawBody__: unique symbol;
42
+ };
39
43
  /** Schema shape for mutation route methods. */
40
44
  export type MutationRouteSchema = {
41
45
  /** Optional Zod object used to validate path params. */
42
46
  path?: z.ZodObject<any>;
43
47
  /** Mutation routes do not accept query schemas. */
44
48
  query?: never;
45
- /** Optional Zod schema used to validate the JSON request body. */
46
- body?: z.ZodType<any, any>;
49
+ /** Optional Zod schema used to validate the JSON request body, or raw body marker. */
50
+ body?: z.ZodObject<any> | RawBodySchema;
47
51
  /** Optional Zod object used to validate request headers. */
48
52
  headers?: z.ZodObject<any>;
49
53
  /** Optional compile-time-only JSON or plugin response type marker. */
@@ -1,4 +1,4 @@
1
- import type { AnyMiddlewareChain, MiddlewareChain } from 'alien-middleware';
1
+ import type { AnyMiddlewareChain } from 'alien-middleware';
2
2
  import type { HttpAction, HttpResource, HttpRouteTree } from '../http.js';
3
3
  import type { InferActionHandler } from './handler.js';
4
4
  import type { Join } from './path.js';
@@ -10,6 +10,6 @@ import type { Join } from './path.js';
10
10
  * Handler context is inferred from middleware plus accumulated path params,
11
11
  * query/body schemas, and header schemas.
12
12
  */
13
- export type RouteRequestHandlerMap<TRoutes extends HttpRouteTree = HttpRouteTree, TMiddleware extends AnyMiddlewareChain = MiddlewareChain, TPrefix extends string = ''> = {
13
+ export type RouteRequestHandlerMap<TRoutes extends HttpRouteTree = HttpRouteTree, TMiddleware extends AnyMiddlewareChain = never, TPrefix extends string = ''> = {
14
14
  [K in keyof TRoutes]: TRoutes[K] extends HttpResource<infer P, infer C> ? RouteRequestHandlerMap<C, TMiddleware, Join<TPrefix, P>> : TRoutes[K] extends HttpAction<infer P, any, any> ? InferActionHandler<TMiddleware, TRoutes[K], Join<TPrefix, P>> : never;
15
15
  };
package/docs/context.md CHANGED
@@ -211,18 +211,16 @@ requests with an `Origin` header.
211
211
 
212
212
  ### Client
213
213
 
214
- `createClient({ baseURL, routes })` creates:
215
-
216
- - `client.request(action.request(args))` for a raw `Response` when the action
217
- request factory contains the full path you want to call
218
- - `client.json(action.request(args))` for parsed JSON and default non-2xx
219
- throwing
220
- - response-map support for generated client action functions, returning
221
- `[error, value, status]` tuples for declared statuses
222
- - response plugin support for generated client action functions, such as
223
- `ndjson.clientPlugin` for NDJSON response streams
224
- - a client tree that mirrors `routes`, with action functions such as
225
- `client.profiles.get(args)` when `routes` is supplied
214
+ `createClient({ baseURL, routes })` creates a client tree that mirrors
215
+ `routes`, with action functions such as `client.profiles.get(args)`.
216
+ Generated action functions include:
217
+
218
+ - raw `Response` results for actions without a response schema
219
+ - parsed JSON and default non-2xx throwing for `$type<T>()` responses
220
+ - response-map support, returning `[error, value, status]` tuples for declared
221
+ statuses
222
+ - response plugin support, such as `ndjson.clientPlugin` for NDJSON response
223
+ streams
226
224
 
227
225
  Prefer an absolute `baseURL` for generated client URLs:
228
226
 
@@ -235,7 +233,8 @@ const client = createClient({
235
233
 
236
234
  Default headers can be supplied with `headers`, per-request headers are merged on
237
235
  top, and a custom `fetch` implementation can be supplied for tests or non-browser
238
- runtimes.
236
+ runtimes. The returned client exposes the original options as `clientConfig`, so
237
+ route actions named `config` remain available as `client.config(...)`.
239
238
 
240
239
  ## Lifecycle
241
240
 
@@ -260,9 +259,9 @@ string-coercion step.
260
259
 
261
260
  ## Common tasks
262
261
 
263
- ### Choose a client call style
262
+ ### Call client actions
264
263
 
265
- Use client action functions for normal application calls:
264
+ Use generated client action functions for application calls:
266
265
 
267
266
  ```ts
268
267
  await client.profiles.get({ path: { id: '42' } })
@@ -272,29 +271,6 @@ await client.profiles.update({
272
271
  })
273
272
  ```
274
273
 
275
- Use longhand calls when you need to choose response handling explicitly. The
276
- action request factory must include the full path you want to call, so this style
277
- is most convenient for top-level actions:
278
-
279
- ```ts
280
- export const getProfile = http.get('profiles/:id', {
281
- response: $type<Profile>(),
282
- })
283
- export const routes = { getProfile }
284
-
285
- const response = await client.request(
286
- routes.getProfile.request({ path: { id: '42' } })
287
- )
288
-
289
- const json = await client.json(
290
- routes.getProfile.request({ path: { id: '42' } })
291
- )
292
- ```
293
-
294
- Response maps and response plugins are applied by generated client action
295
- functions. For longhand calls to mapped or plugin-backed routes, use
296
- `client.request(...)` for the raw `Response` and decode the response yourself.
297
-
298
274
  ### Handle declared error responses
299
275
 
300
276
  Use `$error<T>()` inside a response map when an error status is part of the route
@@ -420,7 +396,7 @@ custom headers. Return a plain value for the default `Response.json(value)` path
420
396
 
421
397
  ### Customize JSON errors
422
398
 
423
- By default, `client.json(...)` and generated client action functions throw for
399
+ By default, generated client action functions throw for
424
400
  non-2xx responses that are not declared in a response map. If the response body
425
401
  is JSON, its properties are copied onto the thrown `Error`.
426
402
 
@@ -484,7 +460,7 @@ await client.profiles.update({
484
460
  - Export route trees from a small shared module and import that module on both
485
461
  server and client.
486
462
  - Use `rouzer/http` actions for routes that are registered with
487
- `createRouter().use(...)` or `createClient({ routes })`.
463
+ `createRouter().use(...)` or the required `createClient({ routes })` option.
488
464
  - Add Zod schemas when you need runtime guarantees; rely on inferred path params
489
465
  only when string params are sufficient.
490
466
  - Use `response: $type<T>()` for JSON endpoints that should have typed client
@@ -513,11 +489,10 @@ await client.profiles.update({
513
489
  - Pathname route patterns expect an absolute client `baseURL`.
514
490
  - Resource and action keys are API names only; paths come from the pattern
515
491
  strings passed to `http.resource(...)` and action helpers.
516
- - Nested action `.request(...)` factories do not include parent resource paths;
517
- prefer client action functions for nested resources.
518
492
  - Extra `RequestInit` fields in route args, such as `signal` or `credentials`,
519
- are forwarded by `createClient`; `method`, `body`, and `headers` are reserved
520
- for Rouzer's action metadata and validated call arguments.
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.
521
496
  - The HTTP action API has no `ALL` fallback route. Declare explicit actions for
522
497
  supported methods.
523
498
  - Rouzer does not automatically set `Access-Control-Allow-Credentials`; set it in
@@ -101,16 +101,9 @@ export async function runBasicUsageExample() {
101
101
  fetch: createLocalFetch(handler),
102
102
  })
103
103
 
104
- const fetched = await client.profiles.get({
105
- path: { id: '42' },
106
- query: { includePosts: false },
107
- headers: { 'x-request-id': 'docs' },
108
- })
104
+ const fetched = await client.profiles.get({ id: '42', includePosts: false }, { headers: { 'x-request-id': 'docs' } })
109
105
 
110
- const updated = await client.profiles.update({
111
- path: { id: '42' },
112
- body: { name: 'Grace' },
113
- })
106
+ const updated = await client.profiles.update({ id: '42', name: 'Grace' })
114
107
 
115
108
  return { fetched, updated }
116
109
  }
@@ -89,10 +89,10 @@ export async function runErrorResponsesExample() {
89
89
  fetch: createLocalFetch(handler),
90
90
  })
91
91
 
92
- const found = await client.getUser({ path: { id: '42' } })
93
- const created = await client.getUser({ path: { id: 'created' } })
94
- const missing = await client.getUser({ path: { id: 'missing' } })
95
- const unauthorized = await client.getUser({ path: { id: 'unauthorized' } })
92
+ const found = await client.getUser({ id: '42' })
93
+ const created = await client.getUser({ id: 'created' })
94
+ const missing = await client.getUser({ id: 'missing' })
95
+ const unauthorized = await client.getUser({ id: 'unauthorized' })
96
96
 
97
97
  return { found, created, missing, unauthorized }
98
98
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rouzer",
3
- "version": "3.2.0",
3
+ "version": "5.0.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -1,35 +0,0 @@
1
- import type { RoutePattern } from '@remix-run/route-pattern';
2
- import type { RouteArgs } from './args.js';
3
- import type { InferRouteResponse } from './response.js';
4
- import type { RouteSchema } from './schema.js';
5
- /**
6
- * Request descriptor produced by an HTTP action request factory.
7
- *
8
- * @remarks Pass this object to `client.request(...)` for a raw `Response` or
9
- * `client.json(...)` for parsed JSON handling.
10
- */
11
- export type RouteRequest<TResult = any> = {
12
- /** Method schema used for client-side validation. */
13
- schema: RouteSchema;
14
- /** Parsed route pattern used to generate the request URL. */
15
- path: RoutePattern;
16
- /** HTTP method to send. */
17
- method: string;
18
- /** Validated route arguments and request options. */
19
- args: RouteArgs;
20
- /** Phantom result type consumed by `client.json(...)`. */
21
- $result: TResult;
22
- };
23
- /**
24
- * Callable factory attached to an HTTP action.
25
- *
26
- * @remarks Calling a factory validates no data by itself; it creates a typed
27
- * `RouteRequest` descriptor for `createClient` to validate and send.
28
- */
29
- export type RouteRequestFactory<T extends RouteSchema, P extends string> = {
30
- (...p: RouteArgs<T, P> extends infer TArgs ? {} extends TArgs ? [args?: TArgs] : [args: TArgs] : never): RouteRequest<InferRouteResponse<T>>;
31
- /** Inferred argument type for this request factory. */
32
- $args: RouteArgs<T, P>;
33
- /** Inferred response type for this request factory. */
34
- $response: InferRouteResponse<T>;
35
- };
@@ -1 +0,0 @@
1
- export {};