rouzer 2.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # Rouzer
2
+
3
+ Rouzer lets you declare a route once and share its TypeScript types and Zod
4
+ validation between a Hattip-compatible server and a typed fetch client.
5
+
6
+ ## What it does
7
+
8
+ A Rouzer route declaration defines a URL pattern, method schemas, and optional
9
+ response type once, then reuses that contract to:
10
+
11
+ - validate client arguments before `fetch`
12
+ - match and validate server requests before handlers run
13
+ - type handler context from path, query/body, headers, and middleware
14
+ - attach typed client shorthand methods such as `client.helloRoute.GET(...)`
15
+
16
+ Rouzer optimizes for shared TypeScript route modules over language-agnostic API
17
+ schemas or generated SDKs.
18
+
19
+ ## Is this for you?
20
+
21
+ Use Rouzer if:
22
+
23
+ - your server and client can import the same TypeScript route declarations
24
+ - you want Zod request validation on both sides of an HTTP boundary
25
+ - a Hattip-compatible handler fits your server runtime
26
+ - you prefer a small routing/client contract over a full web framework
27
+
28
+ Consider something else if:
29
+
30
+ - you need OpenAPI-first workflows, schema files, or generated clients for other
31
+ languages
32
+ - you need runtime response-body validation; `response: $type<T>()` is
33
+ compile-time only
34
+ - you want a framework that owns controllers, data loading, rendering, and
35
+ deployment adapters
36
+ - you cannot use ESM or Zod v4+
37
+
38
+ ## Requirements
39
+
40
+ - ESM runtime and tooling
41
+ - Zod v4 or newer
42
+ - a Hattip adapter when using `createRouter(...)`
43
+ - a Fetch API implementation when using `createClient(...)`
44
+ - an absolute `baseURL` for pathname route patterns
45
+
46
+ ## Installation
47
+
48
+ ```sh
49
+ pnpm add rouzer zod
50
+ ```
51
+
52
+ Import the public API from the root package:
53
+
54
+ ```ts
55
+ import { $type, chain, createClient, createRouter, route } from 'rouzer'
56
+ ```
57
+
58
+ `chain` is re-exported from `alien-middleware` for typed server middleware.
59
+
60
+ ## Quick example
61
+
62
+ This example shows the core loop: one route contract defines validation, server
63
+ handler types, and the typed client call.
64
+
65
+ ```ts
66
+ import * as z from 'zod'
67
+ import { $type, createClient, createRouter, route } from 'rouzer'
68
+
69
+ export const helloRoute = route('hello/:name', {
70
+ GET: {
71
+ query: z.object({
72
+ excited: z.optional(z.boolean()),
73
+ }),
74
+ response: $type<{ message: string }>(),
75
+ },
76
+ })
77
+
78
+ export const routes = { helloRoute }
79
+
80
+ export const handler = createRouter({ basePath: 'api/' }).use(routes, {
81
+ helloRoute: {
82
+ GET(ctx) {
83
+ return {
84
+ message: `Hello, ${ctx.path.name}${ctx.query.excited ? '!' : '.'}`,
85
+ }
86
+ },
87
+ },
88
+ })
89
+
90
+ const client = createClient({
91
+ baseURL: 'https://example.com/api/',
92
+ routes,
93
+ })
94
+
95
+ const { message } = await client.helloRoute.GET({
96
+ path: { name: 'world' },
97
+ query: { excited: true },
98
+ })
99
+ ```
100
+
101
+ `handler` can be mounted with any Hattip adapter. Client calls validate route
102
+ arguments before `fetch`; server handlers validate matched path, query, headers,
103
+ and JSON bodies before your handler runs.
104
+
105
+ ## Documentation
106
+
107
+ - [Concepts and API selection](docs/context.md)
108
+ - [Runnable shared-route example](examples/basic-usage.ts)
109
+ - Generated declarations in the published package provide the exact signatures
110
+ for every public export.
111
+ - Public TSDoc in `src/` owns symbol-level behavior and option details.
@@ -1,61 +1,87 @@
1
1
  import { Promisable } from '../common.js';
2
2
  import { Route } from '../route.js';
3
3
  import type { InferRouteResponse, RouteArgs, RouteRequest, RouteSchema } from '../types.js';
4
+ /** Client type inferred from a route map passed to `createClient`. */
4
5
  export type RouzerClient<TRoutes extends Record<string, Route> = Record<string, never>> = ReturnType<typeof createClient<TRoutes>>;
6
+ /**
7
+ * Create a typed fetch client for Rouzer route declarations.
8
+ *
9
+ * @remarks The returned client always includes `request(...)` for raw responses
10
+ * and `json(...)` for parsed JSON. Passing `routes` also attaches shorthand
11
+ * methods such as `client.helloRoute.GET(...)`.
12
+ */
5
13
  export declare function createClient<TRoutes extends Record<string, Route> = Record<string, never>>(config: {
6
14
  /**
7
- * Base URL to use for all requests.
15
+ * Absolute base URL used for pathname route patterns.
16
+ *
17
+ * @remarks A trailing slash is added when missing. In browsers, derive a
18
+ * relative API path with `new URL('/api/', window.location.origin).href`.
8
19
  */
9
20
  baseURL: string;
10
21
  /**
11
- * Default headers to send with every request.
22
+ * Default headers sent with every request.
23
+ *
24
+ * @remarks Per-request headers are merged on top of these values. Undefined
25
+ * per-request headers are removed before `fetch`.
12
26
  */
13
27
  headers?: Record<string, string>;
14
28
  /**
15
- * Pass in routes to attach them as methods on the client.
29
+ * Route map to attach as shorthand methods on the client.
30
+ *
16
31
  * @example
17
32
  * ```ts
18
- * const client = createClient({ baseURL: '/api/', routes: { helloRoute } })
19
- * client.helloRoute.GET({ path: { name: 'world' } })
33
+ * const client = createClient({ baseURL: 'https://example.com/api/', routes })
34
+ * await client.helloRoute.GET({ path: { name: 'world' } })
20
35
  * ```
21
36
  */
22
37
  routes?: TRoutes;
23
38
  /**
24
- * Custom handler for non-200 response to a `.json()` request. By default, the
25
- * response is always parsed as JSON, regardless of the HTTP status code.
39
+ * Custom handler for non-2xx responses from `.json()`.
40
+ *
41
+ * @remarks When provided, the return value is returned from `.json()` as-is;
42
+ * Rouzer does not automatically parse a `Response` returned by this hook.
43
+ * Without this hook, `.json()` throws an `Error` and copies JSON error-body
44
+ * properties onto it when the response has a JSON content type.
26
45
  */
27
46
  onJsonError?: (response: Response) => Promisable<Response>;
28
- /**
29
- * Custom fetch implementation to use for requests.
30
- */
47
+ /** Custom `fetch` implementation to use for requests. */
31
48
  fetch?: typeof globalThis.fetch;
32
49
  }): { [K in keyof TRoutes]: TRoutes[K]["methods"] extends infer TMethods ? { [M in keyof TMethods]: RouteFunction<Extract<TMethods[M], RouteSchema>, TRoutes[K]["path"]["source"]>; } : never; } & {
33
50
  config: {
34
51
  /**
35
- * Base URL to use for all requests.
52
+ * Absolute base URL used for pathname route patterns.
53
+ *
54
+ * @remarks A trailing slash is added when missing. In browsers, derive a
55
+ * relative API path with `new URL('/api/', window.location.origin).href`.
36
56
  */
37
57
  baseURL: string;
38
58
  /**
39
- * Default headers to send with every request.
59
+ * Default headers sent with every request.
60
+ *
61
+ * @remarks Per-request headers are merged on top of these values. Undefined
62
+ * per-request headers are removed before `fetch`.
40
63
  */
41
64
  headers?: Record<string, string> | undefined;
42
65
  /**
43
- * Pass in routes to attach them as methods on the client.
66
+ * Route map to attach as shorthand methods on the client.
67
+ *
44
68
  * @example
45
69
  * ```ts
46
- * const client = createClient({ baseURL: '/api/', routes: { helloRoute } })
47
- * client.helloRoute.GET({ path: { name: 'world' } })
70
+ * const client = createClient({ baseURL: 'https://example.com/api/', routes })
71
+ * await client.helloRoute.GET({ path: { name: 'world' } })
48
72
  * ```
49
73
  */
50
74
  routes?: TRoutes | undefined;
51
75
  /**
52
- * Custom handler for non-200 response to a `.json()` request. By default, the
53
- * response is always parsed as JSON, regardless of the HTTP status code.
76
+ * Custom handler for non-2xx responses from `.json()`.
77
+ *
78
+ * @remarks When provided, the return value is returned from `.json()` as-is;
79
+ * Rouzer does not automatically parse a `Response` returned by this hook.
80
+ * Without this hook, `.json()` throws an `Error` and copies JSON error-body
81
+ * properties onto it when the response has a JSON content type.
54
82
  */
55
83
  onJsonError?: ((response: Response) => Promisable<Response>) | undefined;
56
- /**
57
- * Custom fetch implementation to use for requests.
58
- */
84
+ /** Custom `fetch` implementation to use for requests. */
59
85
  fetch?: typeof globalThis.fetch | undefined;
60
86
  };
61
87
  request: <T extends RouteRequest>({ path: pathBuilder, method, args: { path, query, body, headers }, schema, }: T) => Promise<Response & {
@@ -64,9 +90,11 @@ export declare function createClient<TRoutes extends Record<string, Route> = Rec
64
90
  json: <T extends RouteRequest>(props: T) => Promise<T["$result"]>;
65
91
  };
66
92
  /**
67
- * This function sends a request to a route of the same name. Such a function is
68
- * accessible by setting the `routes` option when creating a Rouzer client,
69
- * where it will exist as a method on the client.
93
+ * Shorthand client method attached for each route method when `routes` is passed
94
+ * to `createClient`.
95
+ *
96
+ * @remarks Methods whose schema has `response: $type<T>()` return parsed JSON as
97
+ * `T`. Methods without a response marker return the raw `Response`.
70
98
  */
71
99
  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 {
72
100
  response: any;
@@ -1,4 +1,11 @@
1
1
  import { mapValues, shake } from '../common.js';
2
+ /**
3
+ * Create a typed fetch client for Rouzer route declarations.
4
+ *
5
+ * @remarks The returned client always includes `request(...)` for raw responses
6
+ * and `json(...)` for parsed JSON. Passing `routes` also attaches shorthand
7
+ * methods such as `client.helloRoute.GET(...)`.
8
+ */
2
9
  export function createClient(config) {
3
10
  const baseURL = config.baseURL.replace(/\/?$/, '/');
4
11
  const defaultHeaders = config.headers && shake(config.headers);
@@ -31,9 +38,9 @@ export function createClient(config) {
31
38
  }
32
39
  if (headers) {
33
40
  headers = shake(headers);
34
- if (defaultHeaders) {
35
- headers = { ...defaultHeaders, ...headers };
36
- }
41
+ }
42
+ if (defaultHeaders) {
43
+ headers = headers ? { ...defaultHeaders, ...headers } : defaultHeaders;
37
44
  }
38
45
  if (schema.headers) {
39
46
  headers = schema.headers.parse(headers);
package/dist/common.d.ts CHANGED
@@ -1,4 +1,11 @@
1
1
  export type Promisable<T> = T | Promise<T>;
2
+ /**
3
+ * Compile-time-only marker used by `$type<T>()` to carry an unchecked response
4
+ * type through route declarations.
5
+ *
6
+ * @remarks Consumers usually use `$type<T>()` instead of constructing this type
7
+ * directly.
8
+ */
2
9
  export type Unchecked<T> = {
3
10
  __unchecked__: T;
4
11
  };
package/dist/route.d.ts CHANGED
@@ -1,14 +1,49 @@
1
1
  import { RoutePattern } from '@remix-run/route-pattern';
2
2
  import { Unchecked } from './common.js';
3
3
  import type { RouteRequestFactory, RouteSchema, RouteSchemaMap } from './types.js';
4
+ /**
5
+ * Create a compile-time-only marker for a route's JSON response payload type.
6
+ *
7
+ * @remarks `$type<T>()` does not perform runtime validation. It lets Rouzer type
8
+ * server handler return values and client shorthand methods for routes whose
9
+ * responses are expected to be JSON.
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * const helloRoute = route('hello/:name', {
14
+ * GET: {
15
+ * response: $type<{ message: string }>(),
16
+ * },
17
+ * })
18
+ * ```
19
+ */
4
20
  export declare function $type<T>(): Unchecked<T>;
5
21
  export declare namespace $type {
6
22
  var symbol: symbol;
7
23
  }
24
+ /**
25
+ * Shared route declaration produced by `route(...)`.
26
+ *
27
+ * @remarks A `Route` stores the parsed URL pattern, the method schema map, and a
28
+ * request factory for each declared method. Pass route maps to both
29
+ * `createRouter().use(...)` and `createClient({ routes })` to share the same
30
+ * contract on both sides of an HTTP boundary.
31
+ */
8
32
  export type Route<P extends string = string, T extends RouteSchemaMap = RouteSchemaMap> = {
33
+ /** Parsed route pattern used for URL generation and server-side matching. */
9
34
  path: RoutePattern<P>;
35
+ /** Method schemas declared for this route. */
10
36
  methods: T;
11
37
  } & {
12
38
  [K in keyof T]: RouteRequestFactory<Extract<T[K], RouteSchema>, P>;
13
39
  };
40
+ /**
41
+ * Declare one URL pattern and its supported HTTP method schemas.
42
+ *
43
+ * @param pattern Route pattern parsed by `@remix-run/route-pattern`.
44
+ * @param methods Method schemas that describe request validation and optional
45
+ * response typing.
46
+ * @returns A shared route declaration with request factories such as `.GET(...)`
47
+ * and `.POST(...)` for the declared methods.
48
+ */
14
49
  export declare function route<P extends string, T extends RouteSchemaMap>(pattern: P, methods: T): Route<P, T>;
package/dist/route.js CHANGED
@@ -1,9 +1,34 @@
1
1
  import { RoutePattern } from '@remix-run/route-pattern';
2
2
  import { mapEntries } from './common.js';
3
+ /**
4
+ * Create a compile-time-only marker for a route's JSON response payload type.
5
+ *
6
+ * @remarks `$type<T>()` does not perform runtime validation. It lets Rouzer type
7
+ * server handler return values and client shorthand methods for routes whose
8
+ * responses are expected to be JSON.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * const helloRoute = route('hello/:name', {
13
+ * GET: {
14
+ * response: $type<{ message: string }>(),
15
+ * },
16
+ * })
17
+ * ```
18
+ */
3
19
  export function $type() {
4
20
  return $type.symbol;
5
21
  }
6
22
  $type.symbol = Symbol();
23
+ /**
24
+ * Declare one URL pattern and its supported HTTP method schemas.
25
+ *
26
+ * @param pattern Route pattern parsed by `@remix-run/route-pattern`.
27
+ * @param methods Method schemas that describe request validation and optional
28
+ * response typing.
29
+ * @returns A shared route declaration with request factories such as `.GET(...)`
30
+ * and `.POST(...)` for the declared methods.
31
+ */
7
32
  export function route(pattern, methods) {
8
33
  const path = new RoutePattern(pattern);
9
34
  const createFetch = (method, schema) => (args = {}) => {
@@ -3,34 +3,36 @@ import { ApplyMiddleware, chain, ExtractMiddleware, MiddlewareChain, MiddlewareT
3
3
  import type { Routes } from '../types.js';
4
4
  import type { RouteRequestHandlerMap } from './types.js';
5
5
  export { chain };
6
+ /** Configuration for `createRouter`. */
6
7
  export type RouterConfig = {
7
8
  /**
8
- * Base path to prepend to all routes.
9
+ * Base path to prepend to all route patterns.
10
+ *
11
+ * @remarks Leading and trailing slashes are normalized so `api`, `/api`, and
12
+ * `api/` all mount routes under `/api/`.
13
+ *
9
14
  * @example
10
15
  * ```ts
11
- * basePath: 'api/',
16
+ * createRouter({ basePath: 'api/' })
12
17
  * ```
13
18
  */
14
19
  basePath?: string;
15
20
  /**
16
- * Enable debugging features.
17
- * - When a handler throws an error, include its message in the response body.
18
- * - Throw an error if a handler is not found for a route.
19
- * @example
20
- * ```ts
21
- * debug: process.env.NODE_ENV !== 'production',
22
- * ```
21
+ * Enable debug behavior for local development.
22
+ *
23
+ * @remarks Debug mode adds an `X-Route-Name` response header for matched
24
+ * routes, includes specific Zod error messages in `400` validation responses,
25
+ * and logs missing route handlers to the console.
23
26
  */
24
27
  debug?: boolean;
25
- /**
26
- * CORS configuration.
27
- */
28
+ /** CORS configuration for requests with an `Origin` header. */
28
29
  cors?: {
29
30
  /**
30
- * If defined, requests must have an `Origin` header that is in this list.
31
+ * Allowed origins for CORS requests.
31
32
  *
32
- * Origins may contain wildcards for protocol and subdomain. The protocol is
33
- * optional and defaults to `https`.
33
+ * @remarks Origins may contain wildcards for protocol and subdomain. The
34
+ * protocol is optional and defaults to `https`. Requests with an `Origin`
35
+ * header outside this list receive `403`.
34
36
  *
35
37
  * @example
36
38
  * ```ts
@@ -40,6 +42,10 @@ export type RouterConfig = {
40
42
  allowOrigins?: string[];
41
43
  };
42
44
  };
45
+ /**
46
+ * Hattip-compatible Rouzer handler with chainable middleware and route
47
+ * registration.
48
+ */
43
49
  export interface Router<T extends MiddlewareTypes = any> extends HattipHandler<T['platform']>, MiddlewareChain<T> {
44
50
  /**
45
51
  * Clone this router and add the given middleware to the end of the chain.
@@ -54,4 +60,12 @@ export interface Router<T extends MiddlewareTypes = any> extends HattipHandler<T
54
60
  */
55
61
  use<TRoutes extends Routes>(routes: TRoutes, handlers: RouteRequestHandlerMap<TRoutes, this>): Router<T>;
56
62
  }
63
+ /**
64
+ * Create a Rouzer router that can be mounted by any Hattip adapter.
65
+ *
66
+ * @param config Optional router configuration for base path, debug behavior, and
67
+ * CORS origin restrictions.
68
+ * @returns A Hattip-compatible handler with `.use(...)` methods for middleware
69
+ * and route registration.
70
+ */
57
71
  export declare function createRouter<TEnv extends object = {}, TProperties extends object = {}, TPlatform = unknown>(config?: RouterConfig): Router<MiddlewareTypes<TEnv, TProperties, TPlatform>>;
@@ -139,6 +139,14 @@ class RouterObject extends MiddlewareChain {
139
139
  });
140
140
  }
141
141
  }
142
+ /**
143
+ * Create a Rouzer router that can be mounted by any Hattip adapter.
144
+ *
145
+ * @param config Optional router configuration for base path, debug behavior, and
146
+ * CORS origin restrictions.
147
+ * @returns A Hattip-compatible handler with `.use(...)` methods for middleware
148
+ * and route registration.
149
+ */
142
150
  export function createRouter(config = {}) {
143
151
  const router = new RouterObject(config);
144
152
  const handler = router.toHandler();
@@ -26,6 +26,14 @@ type InferRouteRequestHandler<TMiddleware extends AnyMiddlewareChain, TSchema ex
26
26
  headers: any;
27
27
  } ? z.infer<TSchema['headers']> : undefined;
28
28
  }, InferRouteResponse<TSchema>>;
29
+ /**
30
+ * Handler map shape required by `createRouter().use(routes, handlers)`.
31
+ *
32
+ * @remarks Each route key must provide handlers for the methods declared by its
33
+ * route schema. Handler context is inferred from middleware plus the route's
34
+ * path, query/body, and header schemas. An optional `OPTIONS` handler can
35
+ * customize CORS preflight responses for a route.
36
+ */
29
37
  export type RouteRequestHandlerMap<TRoutes extends Routes = Routes, TMiddleware extends AnyMiddlewareChain = MiddlewareChain> = {
30
38
  [K in keyof TRoutes]: {
31
39
  [TMethod in keyof TRoutes[K]['methods']]: InferRouteRequestHandler<TMiddleware, Extract<TRoutes[K]['methods'][TMethod], RouteSchema>, Extract<TMethod, string>, TRoutes[K]['path']['source']>;
package/dist/types.d.ts CHANGED
@@ -1,21 +1,46 @@
1
1
  import { Params, RoutePattern } from '@remix-run/route-pattern';
2
2
  import * as z from 'zod';
3
3
  import { Unchecked } from './common.js';
4
+ /**
5
+ * Compile-time-only marker used by `$type<T>()` for unchecked response types.
6
+ *
7
+ * @remarks Application code should usually call `$type<T>()` instead of naming
8
+ * this marker directly.
9
+ */
4
10
  export type { Unchecked };
11
+ /** Schema shape for `GET` route methods. */
5
12
  export type QueryRouteSchema = {
13
+ /** Optional Zod object used to validate path params. */
6
14
  path?: z.ZodObject<any>;
15
+ /** Optional Zod object used to validate URL query params. */
7
16
  query?: z.ZodObject<any>;
17
+ /** `GET` routes do not accept request bodies. */
8
18
  body?: never;
19
+ /** Optional Zod object used to validate request headers. */
9
20
  headers?: z.ZodObject<any>;
21
+ /** Optional compile-time-only JSON response type marker. */
10
22
  response?: Unchecked<any>;
11
23
  };
24
+ /** Schema shape for mutation route methods. */
12
25
  export type MutationRouteSchema = {
26
+ /** Optional Zod object used to validate path params. */
13
27
  path?: z.ZodObject<any>;
28
+ /** Mutation routes do not accept query schemas. */
14
29
  query?: never;
30
+ /** Optional Zod schema used to validate the JSON request body. */
15
31
  body?: z.ZodType<any, any>;
32
+ /** Optional Zod object used to validate request headers. */
16
33
  headers?: z.ZodObject<any>;
34
+ /** Optional compile-time-only JSON response type marker. */
17
35
  response?: Unchecked<any>;
18
36
  };
37
+ /**
38
+ * Method schema map accepted by `route(...)`.
39
+ *
40
+ * @remarks `GET` validates query input, mutation methods validate JSON body
41
+ * input, and `ALL` acts as a fallback for methods that are not declared
42
+ * explicitly.
43
+ */
19
44
  export type RouteSchemaMap = {
20
45
  GET?: QueryRouteSchema;
21
46
  POST?: MutationRouteSchema;
@@ -23,14 +48,21 @@ export type RouteSchemaMap = {
23
48
  PATCH?: MutationRouteSchema;
24
49
  DELETE?: MutationRouteSchema;
25
50
  ALL?: {
51
+ /** Optional Zod object used to validate path params. */
26
52
  path?: z.ZodObject<any>;
53
+ /** Optional Zod object used to validate URL query params. */
27
54
  query?: z.ZodObject<any>;
55
+ /** `ALL` fallback routes do not accept request bodies. */
28
56
  body?: never;
57
+ /** Optional Zod object used to validate request headers. */
29
58
  headers?: z.ZodObject<any>;
59
+ /** `ALL` fallback routes do not define typed JSON responses. */
30
60
  response?: never;
31
61
  };
32
62
  };
63
+ /** Any route method schema Rouzer can execute. */
33
64
  export type RouteSchema = QueryRouteSchema | MutationRouteSchema;
65
+ /** Route map accepted by `createRouter().use(...)` and `createClient(...)`. */
34
66
  export type Routes = {
35
67
  [key: string]: {
36
68
  path: RoutePattern;
@@ -67,23 +99,46 @@ type MutationArgs<T> = T extends MutationRouteSchema ? T extends {
67
99
  } : {
68
100
  body?: unknown;
69
101
  } : unknown;
102
+ /**
103
+ * Arguments accepted by a route request factory such as `route.GET(...)`.
104
+ *
105
+ * @remarks The type is derived from a method schema and route pattern. `path`,
106
+ * `query`, `body`, and `headers` are validated by the client before `fetch` when
107
+ * a matching schema exists. The client forwards the HTTP method, JSON body, and
108
+ * headers; extra `RequestInit` fields are accepted by the type surface but are
109
+ * not forwarded.
110
+ */
70
111
  export type RouteArgs<T extends RouteSchema = any, P extends string = string> = ([T] extends [Any] ? {
71
112
  query?: any;
72
113
  body?: any;
73
114
  path?: any;
74
115
  } : QueryArgs<T> & MutationArgs<T> & PathArgs<T, P>) & Omit<RequestInit, 'method' | 'body' | 'headers'> & {
116
+ /** Headers for this request. Undefined values are removed before `fetch`. */
75
117
  headers?: Record<string, string | undefined>;
76
118
  };
119
+ /**
120
+ * Request descriptor produced by a route request factory.
121
+ *
122
+ * @remarks Pass this object to `client.request(...)` for a raw `Response` or
123
+ * `client.json(...)` for parsed JSON handling.
124
+ */
77
125
  export type RouteRequest<TResult = any> = {
126
+ /** Method schema used for client-side validation. */
78
127
  schema: RouteSchema;
128
+ /** Parsed route pattern used to generate the request URL. */
79
129
  path: RoutePattern;
130
+ /** HTTP method to send. */
80
131
  method: string;
132
+ /** Validated route arguments and request options. */
81
133
  args: RouteArgs;
134
+ /** Phantom result type consumed by `client.json(...)`. */
82
135
  $result: TResult;
83
136
  };
137
+ /** `Response` whose `.json()` method resolves to a known payload type. */
84
138
  export type RouteResponse<TResult = any> = Response & {
85
139
  json(): Promise<TResult>;
86
140
  };
141
+ /** Infer the JSON response payload type from a method schema. */
87
142
  export type InferRouteResponse<T extends RouteSchema> = T extends {
88
143
  response: Unchecked<infer TResponse>;
89
144
  } ? TResponse : void;
@@ -93,12 +148,32 @@ type InferRouteSchemaBody<TSchema> = TSchema extends MutationRouteSchema ? TSche
93
148
  type InferRouteArgsBody<TArgs> = TArgs extends {
94
149
  body?: infer TBody;
95
150
  } ? TBody : never;
151
+ /**
152
+ * Infer the request body type from a mutation schema or route request factory.
153
+ *
154
+ * @remarks Route request factories for mutation methods infer their `body`
155
+ * argument type. Mutation schemas without a body schema infer `unknown`.
156
+ */
96
157
  export type InferRouteBody<T> = T extends RouteRequestFactory<any, any> ? InferRouteArgsBody<T['$args']> : T extends RouteSchema ? InferRouteSchemaBody<T> : never;
158
+ /**
159
+ * Infer the request body type for a named method on a `Route`.
160
+ *
161
+ * @remarks `GET` and `ALL` infer `never` because they do not accept request
162
+ * bodies.
163
+ */
97
164
  export type InferRouteMethodBody<TRoute extends {
98
165
  methods: RouteSchemaMap;
99
166
  }, TMethod extends keyof TRoute['methods']> = TMethod extends 'GET' | 'ALL' ? never : TMethod extends keyof TRoute ? InferRouteBody<TRoute[TMethod]> : InferRouteBody<Extract<TRoute['methods'][TMethod], RouteSchema>>;
167
+ /**
168
+ * Callable factory attached to a `Route` for each declared method.
169
+ *
170
+ * @remarks Calling a factory validates no data by itself; it creates a typed
171
+ * `RouteRequest` descriptor for `createClient` to validate and send.
172
+ */
100
173
  export type RouteRequestFactory<T extends RouteSchema, P extends string> = {
101
174
  (...p: RouteArgs<T, P> extends infer TArgs ? {} extends TArgs ? [args?: TArgs] : [args: TArgs] : never): RouteRequest<InferRouteResponse<T>>;
175
+ /** Inferred argument type for this request factory. */
102
176
  $args: RouteArgs<T, P>;
177
+ /** Inferred JSON response type for this request factory. */
103
178
  $response: InferRouteResponse<T>;
104
179
  };
@@ -0,0 +1,167 @@
1
+ # Rouzer context
2
+
3
+ Rouzer is for applications that want one route contract to drive both the HTTP
4
+ server and the client that calls it. A route declaration combines a URL pattern,
5
+ HTTP method schemas, and an optional compile-time response type.
6
+
7
+ ## When to use Rouzer
8
+
9
+ Use Rouzer when:
10
+
11
+ - the same TypeScript project, package, or workspace can share route
12
+ declarations between server and client code
13
+ - request validation should run before server handlers and before client `fetch`
14
+ calls
15
+ - a Hattip-compatible handler fits your server runtime
16
+ - generated clients should stay close to the route definitions instead of being
17
+ produced by a separate OpenAPI build step
18
+
19
+ Rouzer is not a response validation library, an OpenAPI generator, or a complete
20
+ server framework. It focuses on typed route contracts, validation, routing, and a
21
+ small client wrapper.
22
+
23
+ ## Core abstractions
24
+
25
+ ### Route declarations
26
+
27
+ Declare routes with `route(pattern, methods)`. The pattern is parsed by
28
+ `@remix-run/route-pattern`, so route params can be inferred from patterns such
29
+ as `hello/:name`, `v:major.:minor`, `api(/v:major(.:minor))`, `assets/*path`,
30
+ `search?q`, or full URL patterns such as
31
+ `https://:store.shopify.com/orders`.
32
+
33
+ Method schemas describe the request pieces Rouzer should validate:
34
+
35
+ | Method kind | Request schemas | Notes |
36
+ | -------------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------- |
37
+ | `GET` | `path`, `query`, `headers`, `response` | No request body. |
38
+ | `POST`, `PUT`, `PATCH`, `DELETE` | `path`, `body`, `headers`, `response` | No query schema. |
39
+ | `ALL` | `path`, `query`, `headers` | Fallback when the incoming method is not explicitly declared. No body or response type. |
40
+
41
+ If you omit a `path` schema, TypeScript infers path params from the pattern and
42
+ server handlers receive them as strings. Add a Zod `path` schema when you need
43
+ runtime validation, transforms, or non-string handler types.
44
+
45
+ ### `$type<T>()`
46
+
47
+ `response: $type<T>()` is a TypeScript-only marker. It tells handlers and client
48
+ shorthand methods what response payload type to expect, but Rouzer does not
49
+ validate response bodies at runtime.
50
+
51
+ Routes without a `response` marker return a raw `Response` from client shorthand
52
+ methods. Routes with a `response` marker use `client.json(...)` under the hood
53
+ and return parsed JSON typed as `T`.
54
+
55
+ ### Router
56
+
57
+ `createRouter()` returns a Hattip-compatible handler. Use `.use(middleware)` to
58
+ append typed `alien-middleware` middleware and `.use(routes, handlers)` to attach
59
+ route handlers.
60
+
61
+ Handlers receive a context typed from middleware plus the route schema:
62
+
63
+ - `GET` handlers receive `ctx.path`, `ctx.query`, and `ctx.headers`
64
+ - mutation handlers receive `ctx.path`, `ctx.body`, and `ctx.headers`
65
+ - handlers may return a plain JSON-serializable value or a `Response`
66
+ - plain values are returned with `Response.json(value)`
67
+ - return a `Response` when you need custom status, headers, or body handling
68
+
69
+ `basePath` is prepended to route patterns, `debug` adds matched-route debug
70
+ headers and more detailed validation errors, and `cors.allowOrigins` restricts
71
+ requests with an `Origin` header.
72
+
73
+ ### Client
74
+
75
+ `createClient({ baseURL, routes })` creates:
76
+
77
+ - `client.request(route.GET(args))` for a raw `Response`
78
+ - `client.json(route.GET(args))` for parsed JSON and default non-2xx throwing
79
+ - shorthand methods such as `client.helloRoute.GET(args)` when `routes` is
80
+ supplied
81
+
82
+ Prefer an absolute `baseURL` for pathname route patterns:
83
+
84
+ ```ts
85
+ const client = createClient({
86
+ baseURL: new URL('/api/', window.location.origin).href,
87
+ routes,
88
+ })
89
+ ```
90
+
91
+ Default headers can be supplied with `headers`, per-request headers are merged on
92
+ top, and a custom `fetch` implementation can be supplied for tests or non-browser
93
+ runtimes.
94
+
95
+ ## Lifecycle
96
+
97
+ 1. Define shared route declarations with `route(...)` and Zod schemas.
98
+ 2. Attach those routes to a server with `createRouter().use(routes, handlers)`.
99
+ 3. Create a client with the same route map.
100
+ 4. Client calls validate `path`, `query`, `body`, and `headers` before `fetch`.
101
+ 5. The router matches the request, validates the matched inputs, and calls the
102
+ handler.
103
+ 6. Plain handler results become JSON responses; explicit `Response` objects pass
104
+ through unchanged.
105
+
106
+ On the server, `path`, `query`, and `headers` values originate as strings. Rouzer
107
+ coerces Zod `number` schemas with `Number(value)` and Zod `boolean` schemas from
108
+ `"true"` and `"false"`. JSON request bodies are parsed and validated without that
109
+ string-coercion step.
110
+
111
+ ## Common tasks
112
+
113
+ ### Choose a client call style
114
+
115
+ Use shorthand methods for normal application calls:
116
+
117
+ ```ts
118
+ await client.helloRoute.GET({ path: { name: 'Ada' } })
119
+ ```
120
+
121
+ Use longhand calls when you need to choose response handling explicitly:
122
+
123
+ ```ts
124
+ const response = await client.request(
125
+ routes.helloRoute.GET({ path: { name: 'Ada' } })
126
+ )
127
+
128
+ const json = await client.json(routes.helloRoute.GET({ path: { name: 'Ada' } }))
129
+ ```
130
+
131
+ ### Return custom responses
132
+
133
+ Return a `Response` from a handler for non-JSON payloads, custom status codes, or
134
+ custom headers. Return a plain value for the default `Response.json(value)` path.
135
+
136
+ ### Customize JSON errors
137
+
138
+ By default, `client.json(...)` throws for non-2xx responses. If the response body
139
+ is JSON, its properties are copied onto the thrown `Error`.
140
+
141
+ `onJsonError` can override that behavior. Its return value is returned from
142
+ `client.json(...)` as-is; Rouzer does not automatically parse a returned
143
+ `Response` from `onJsonError`.
144
+
145
+ ## Patterns to prefer
146
+
147
+ - Export route declarations from a small shared module and import that module on
148
+ both server and client.
149
+ - Add Zod schemas when you need runtime guarantees; rely on inferred path params
150
+ only when string params are sufficient.
151
+ - Use `response: $type<T>()` for JSON endpoints that should have typed client
152
+ shorthand methods.
153
+ - Use explicit HTTP methods when you want precise handler context types; reserve
154
+ `ALL` for true fallback behavior.
155
+ - Set `content-type: application/json` yourself when your server or middleware
156
+ depends on that header.
157
+
158
+ ## Constraints and gotchas
159
+
160
+ - `$type<T>()` is compile-time only and does not validate response payloads.
161
+ - Pathname route patterns expect an absolute client `baseURL`.
162
+ - Extra `RequestInit` fields in route args, such as `signal` or `credentials`,
163
+ are accepted by the type surface but are not forwarded by `createClient`.
164
+ - `ALL` can declare `query`, but handler context typing is less precise than
165
+ explicit `GET` handlers.
166
+ - Rouzer does not automatically set `Access-Control-Allow-Credentials`; set it in
167
+ your handler when credentialed cross-origin requests need it.
@@ -0,0 +1,115 @@
1
+ import type { HattipHandler } from '@hattip/core'
2
+ import * as z from 'zod'
3
+ import { $type, chain, createClient, createRouter, route } from 'rouzer'
4
+
5
+ type Profile = {
6
+ id: string
7
+ name: string
8
+ includePosts: boolean
9
+ requestId: string
10
+ }
11
+
12
+ export const profileRoute = route('profiles/:id', {
13
+ GET: {
14
+ query: z.object({
15
+ includePosts: z.optional(z.boolean()),
16
+ }),
17
+ response: $type<Profile>(),
18
+ },
19
+ PATCH: {
20
+ body: z.object({
21
+ name: z.string().check(z.minLength(1)),
22
+ }),
23
+ headers: z.object({
24
+ 'content-type': z.literal('application/json'),
25
+ }),
26
+ response: $type<Profile>(),
27
+ },
28
+ })
29
+
30
+ export const routes = { profileRoute }
31
+
32
+ /**
33
+ * Tiny Hattip adapter used only to keep this example self-contained. Real apps
34
+ * mount the handler with a Hattip adapter for their runtime.
35
+ */
36
+ function createLocalFetch(handler: HattipHandler): typeof fetch {
37
+ return async (input, init) => {
38
+ const request = new Request(input, init)
39
+ const response = await handler({
40
+ request,
41
+ ip: '127.0.0.1',
42
+ platform: undefined,
43
+ env() {
44
+ return undefined
45
+ },
46
+ passThrough() {},
47
+ waitUntil(promise) {
48
+ void promise
49
+ },
50
+ })
51
+
52
+ return response ?? new Response(null, { status: 404 })
53
+ }
54
+ }
55
+
56
+ export async function runBasicUsageExample() {
57
+ const profiles = new Map([['42', { id: '42', name: 'Ada' }]])
58
+
59
+ const requestMiddleware = chain().use(ctx => ({
60
+ requestId: ctx.request.headers.get('x-request-id') ?? 'local',
61
+ }))
62
+
63
+ const handler = createRouter({ basePath: 'api/' })
64
+ .use(requestMiddleware)
65
+ .use(routes, {
66
+ profileRoute: {
67
+ GET(ctx) {
68
+ const profile = profiles.get(ctx.path.id)
69
+ if (!profile) {
70
+ return new Response('Profile not found', { status: 404 })
71
+ }
72
+ return {
73
+ ...profile,
74
+ includePosts: ctx.query.includePosts ?? false,
75
+ requestId: ctx.requestId,
76
+ }
77
+ },
78
+ PATCH(ctx) {
79
+ const current = profiles.get(ctx.path.id)
80
+ if (!current) {
81
+ return new Response('Profile not found', { status: 404 })
82
+ }
83
+ const profile = { ...current, name: ctx.body.name }
84
+ profiles.set(ctx.path.id, profile)
85
+ return {
86
+ ...profile,
87
+ includePosts: false,
88
+ requestId: ctx.requestId,
89
+ }
90
+ },
91
+ },
92
+ })
93
+
94
+ const client = createClient({
95
+ baseURL: 'https://example.test/api/',
96
+ routes,
97
+ headers: {
98
+ 'content-type': 'application/json',
99
+ },
100
+ fetch: createLocalFetch(handler),
101
+ })
102
+
103
+ const fetched = await client.profileRoute.GET({
104
+ path: { id: '42' },
105
+ query: { includePosts: false },
106
+ headers: { 'x-request-id': 'docs' },
107
+ })
108
+
109
+ const updated = await client.profileRoute.PATCH({
110
+ path: { id: '42' },
111
+ body: { name: 'Grace' },
112
+ })
113
+
114
+ return { fetched, updated }
115
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rouzer",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -37,6 +37,9 @@
37
37
  },
38
38
  "files": [
39
39
  "dist",
40
+ "docs",
41
+ "examples",
42
+ "README.md",
40
43
  "!*.tsbuildinfo"
41
44
  ],
42
45
  "scripts": {
package/readme.md DELETED
@@ -1,176 +0,0 @@
1
- # rouzer
2
-
3
- Type-safe routes shared by your server and client, powered by `zod` (input validation + transforms), `@remix-run/route-pattern` (URL matching), and `alien-middleware` (typed middleware chaining). The router output is intended to be used with `@hattip/core` adapters.
4
-
5
- ## Install
6
-
7
- ```sh
8
- pnpm add rouzer zod
9
- ```
10
-
11
- Everything is imported directly from `rouzer`.
12
-
13
- ## Define routes (shared)
14
-
15
- ```ts
16
- // routes.ts
17
- import * as z from 'zod'
18
- import { $type, route } from 'rouzer'
19
-
20
- export const helloRoute = route('hello/:name', {
21
- GET: {
22
- query: z.object({
23
- excited: z.optional(z.boolean()),
24
- }),
25
- // The response is only type-checked at compile time.
26
- response: $type<{ message: string }>(),
27
- },
28
- })
29
- ```
30
-
31
- The following request parts can be validated with Zod:
32
-
33
- - `path`
34
- - `query`
35
- - `body`
36
- - `headers`
37
-
38
- Zod validation happens on both the server and client.
39
-
40
- ## Route URL patterns
41
-
42
- Rouzer uses `@remix-run/route-pattern` for matching and generation. Patterns can include:
43
-
44
- - Pathname-only patterns like `blog/:slug` (default).
45
- - Full URLs with protocol/hostname/port like `https://:store.shopify.com/orders`.
46
- - Dynamic segments with `:param` names (valid JS identifiers), including multiple params in one segment like `v:major.:minor`.
47
- - Optional segments wrapped in parentheses, which can be nested like `api(/v:major(.:minor))`.
48
- - Wildcards with `*name` (captured) or `*` (uncaptured) for multi-segment paths like `assets/*path` or `files/*`.
49
- - Query matching with `?` to require parameters or exact values like `search?q` or `search?q=routing`.
50
-
51
- ## Server router
52
-
53
- ```ts
54
- import { chain, createRouter } from 'rouzer'
55
- import { routes } from './routes'
56
-
57
- const middlewares = chain().use(ctx => {
58
- // An example middleware. For more info, see https://github.com/alien-rpc/alien-middleware#readme
59
- return {
60
- db: postgres(ctx.env('POSTGRES_URL')),
61
- }
62
- })
63
-
64
- export const handler = createRouter({
65
- debug: process.env.NODE_ENV === 'development',
66
- })
67
- .use(middlewares)
68
- .use(routes, {
69
- helloRoute: {
70
- GET(ctx) {
71
- const message = `Hello, ${ctx.path.name}${
72
- ctx.query.excited ? '!' : '.'
73
- }`
74
- return { message }
75
- },
76
- },
77
- })
78
- ```
79
-
80
- ## Router options
81
-
82
- ```ts
83
- export const handler = createRouter({
84
- basePath: 'api/',
85
- cors: {
86
- allowOrigins: [
87
- 'example.net',
88
- 'https://*.example.com',
89
- '*://localhost:3000',
90
- ],
91
- },
92
- debug: process.env.NODE_ENV === 'development',
93
- }).use(routes, {
94
- helloRoute: {
95
- GET(ctx) {
96
- const message = `Hello, ${ctx.path.name}${ctx.query.excited ? '!' : '.'}`
97
- return { message }
98
- },
99
- },
100
- })
101
- ```
102
-
103
- - `basePath` is prepended to every route (leading/trailing slashes are trimmed).
104
- - CORS preflight (`OPTIONS`) is handled automatically for matched routes.
105
- - `cors.allowOrigins` restricts preflight requests to a list of origins (default is to allow any origin).
106
- - Wildcards are supported for protocol and subdomain; the protocol is optional and defaults to `https`.
107
- - If you rely on `Cookie` or `Authorization` request headers, you must set
108
- `Access-Control-Allow-Credentials` in your handler.
109
-
110
- ## Client wrapper
111
-
112
- ```ts
113
- import { createClient } from 'rouzer'
114
- import { helloRoute } from './routes'
115
-
116
- const client = createClient({ baseURL: '/api/' })
117
-
118
- const { message } = await client.json(
119
- helloRoute.GET({ path: { name: 'world' }, query: { excited: true } })
120
- )
121
-
122
- // If you want the Response object, use `client.request` instead.
123
- const response = await client.request(
124
- helloRoute.GET({ path: { name: 'world' } })
125
- )
126
-
127
- const { message } = await response.json()
128
- ```
129
-
130
- ### Custom fetch
131
-
132
- You can also pass a custom fetch implementation:
133
-
134
- ```ts
135
- const client = createClient({
136
- baseURL: '/api/',
137
- fetch: myFetch,
138
- })
139
- ```
140
-
141
- ### Shorthand route methods
142
-
143
- Optionally pass your routes map to `createClient` to get per-route methods on the client:
144
-
145
- ```ts
146
- import * as routes from './routes'
147
-
148
- const client = createClient({
149
- baseURL: '/api/',
150
- routes, // <–– Pass the routes
151
- })
152
-
153
- // Shorthand methods now available:
154
- await client.fooRoute.GET()
155
- // …same as the longhand:
156
- await client.json(routes.fooRoute.GET())
157
- ```
158
-
159
- Routes that define a `response` type will call `client.json()` under the hood and return the parsed value; routes without one return the raw `Response`:
160
-
161
- ```ts
162
- // helloRoute has a response schema, so you get the parsed payload
163
- const { message } = await client.helloRoute.GET({
164
- path: { name: 'world' },
165
- })
166
-
167
- // imagine pingRoute has no response schema; you get a Response object
168
- const pingResponse = await client.pingRoute.GET({})
169
- const pingText = await pingResponse.text()
170
- ```
171
-
172
- ## Add an endpoint
173
-
174
- 1. Declare it in `routes.ts` with `route(…)` and `zod` schemas.
175
- 2. Implement the handler in your router assembly with `createRouter(…).use(routes, { … })`.
176
- 3. Call it from the client with the generated helper via `client.json` or `client.request`.