rouzer 1.0.0-beta.9 → 1.1.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.
@@ -1,5 +1,7 @@
1
- import type { Promisable, RouteRequest } from '../types.js';
2
- export declare function createClient(config: {
1
+ import { Route } from '../route.js';
2
+ import type { InferRouteResponse, Promisable, RouteArgs, RouteRequest, RouteSchema } from '../types.js';
3
+ export type RouzerClient<TRoutes extends Record<string, Route> = Record<string, never>> = ReturnType<typeof createClient<TRoutes>>;
4
+ export declare function createClient<TRoutes extends Record<string, Route> = Record<string, never>>(config: {
3
5
  /**
4
6
  * Base URL to use for all requests.
5
7
  */
@@ -8,12 +10,25 @@ export declare function createClient(config: {
8
10
  * Default headers to send with every request.
9
11
  */
10
12
  headers?: Record<string, string>;
13
+ /**
14
+ * Pass in routes to attach them as methods on the client.
15
+ * @example
16
+ * ```ts
17
+ * const client = createClient({ baseURL: '/api/', routes: { helloRoute } })
18
+ * client.helloRoute.GET({ path: { name: 'world' } })
19
+ * ```
20
+ */
21
+ routes?: TRoutes;
11
22
  /**
12
23
  * Custom handler for non-200 response to a `.json()` request. By default, the
13
24
  * response is always parsed as JSON, regardless of the HTTP status code.
14
25
  */
15
26
  onJsonError?: (response: Response) => Promisable<Response>;
16
- }): {
27
+ /**
28
+ * Custom fetch implementation to use for requests.
29
+ */
30
+ fetch?: typeof globalThis.fetch;
31
+ }): { [K in keyof TRoutes]: TRoutes[K]["methods"] extends infer TMethods ? { [M in keyof TMethods]: RouteFunction<Extract<TMethods[M], RouteSchema>, TRoutes[K]["path"]["source"]>; } : never; } & {
17
32
  config: {
18
33
  /**
19
34
  * Base URL to use for all requests.
@@ -23,14 +38,35 @@ export declare function createClient(config: {
23
38
  * Default headers to send with every request.
24
39
  */
25
40
  headers?: Record<string, string> | undefined;
41
+ /**
42
+ * Pass in routes to attach them as methods on the client.
43
+ * @example
44
+ * ```ts
45
+ * const client = createClient({ baseURL: '/api/', routes: { helloRoute } })
46
+ * client.helloRoute.GET({ path: { name: 'world' } })
47
+ * ```
48
+ */
49
+ routes?: TRoutes | undefined;
26
50
  /**
27
51
  * Custom handler for non-200 response to a `.json()` request. By default, the
28
52
  * response is always parsed as JSON, regardless of the HTTP status code.
29
53
  */
30
54
  onJsonError?: ((response: Response) => Promisable<Response>) | undefined;
55
+ /**
56
+ * Custom fetch implementation to use for requests.
57
+ */
58
+ fetch?: typeof globalThis.fetch | undefined;
31
59
  };
32
- request<T extends RouteRequest>({ path: pathBuilder, method, args: { path, query, body, headers }, route, }: T): Promise<Response & {
60
+ request: <T extends RouteRequest>({ path: pathBuilder, method, args: { path, query, body, headers }, schema, }: T) => Promise<Response & {
33
61
  json(): Promise<T["$result"]>;
34
62
  }>;
35
- json<T extends RouteRequest>(request: T): Promise<T["$result"]>;
63
+ json: <T extends RouteRequest>(props: T) => Promise<T["$result"]>;
36
64
  };
65
+ /**
66
+ * This function sends a request to a route of the same name. Such a function is
67
+ * accessible by setting the `routes` option when creating a Rouzer client,
68
+ * where it will exist as a method on the client.
69
+ */
70
+ 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 {
71
+ response: any;
72
+ } ? InferRouteResponse<T> : Response>;
@@ -1,55 +1,79 @@
1
- import { shake } from '../common.js';
1
+ import { mapValues, shake } from '../common.js';
2
2
  export function createClient(config) {
3
- const baseURL = config.baseURL.replace(/\/$/, '');
4
- return {
5
- config,
6
- request({ path: pathBuilder, method, args: { path, query, body, headers }, route, }) {
7
- if (route.path) {
8
- path = route.path.parse(path);
9
- }
10
- let url;
11
- const href = pathBuilder.href(path);
12
- if (href[0] === '/') {
13
- url = new URL(baseURL);
14
- url.pathname += pathBuilder.href(path);
15
- }
16
- else {
17
- url = new URL(href);
18
- }
19
- if (route.query) {
20
- query = route.query.parse(query ?? {});
21
- url.search = new URLSearchParams(query).toString();
22
- }
23
- else if (query) {
24
- throw new Error('Unexpected query parameters');
25
- }
26
- if (route.body) {
27
- body = route.body.parse(body !== undefined ? body : {});
28
- }
29
- else if (body !== undefined) {
30
- throw new Error('Unexpected body');
3
+ const baseURL = config.baseURL.replace(/\/?$/, '/');
4
+ const defaultHeaders = config.headers && shake(config.headers);
5
+ const fetch = config.fetch ?? globalThis.fetch;
6
+ async function request({ path: pathBuilder, method, args: { path, query, body, headers }, schema, }) {
7
+ if (schema.path) {
8
+ path = schema.path.parse(path);
9
+ }
10
+ let url;
11
+ const href = pathBuilder.href(path);
12
+ if (href[0] === '/') {
13
+ url = new URL(baseURL);
14
+ url.pathname += href.slice(1);
15
+ }
16
+ else {
17
+ url = new URL(href);
18
+ }
19
+ if (schema.query) {
20
+ query = schema.query.parse(query ?? {});
21
+ url.search = new URLSearchParams(shake(query)).toString();
22
+ }
23
+ else if (query) {
24
+ throw new Error('Unexpected query parameters');
25
+ }
26
+ if (schema.body) {
27
+ body = schema.body.parse(body !== undefined ? body : {});
28
+ }
29
+ else if (body !== undefined) {
30
+ throw new Error('Unexpected body');
31
+ }
32
+ if (headers) {
33
+ headers = shake(headers);
34
+ if (defaultHeaders) {
35
+ headers = { ...defaultHeaders, ...headers };
31
36
  }
32
- if (config.headers || headers) {
33
- headers = {
34
- ...config.headers,
35
- ...(headers && shake(headers)),
36
- };
37
- }
38
- if (route.headers) {
39
- headers = route.headers.parse(headers);
40
- }
41
- return fetch(url, {
42
- method,
43
- body: body !== undefined ? JSON.stringify(body) : undefined,
44
- headers: headers,
45
- });
46
- },
47
- async json(request) {
48
- const response = await this.request(request);
49
- if (!response.ok && config.onJsonError) {
37
+ }
38
+ if (schema.headers) {
39
+ headers = schema.headers.parse(headers);
40
+ }
41
+ return fetch(url, {
42
+ method,
43
+ body: body !== undefined ? JSON.stringify(body) : undefined,
44
+ headers: (headers ?? defaultHeaders),
45
+ });
46
+ }
47
+ async function json(props) {
48
+ const response = await request(props);
49
+ if (!response.ok) {
50
+ if (config.onJsonError) {
50
51
  return config.onJsonError(response);
51
52
  }
52
- return response.json();
53
- },
53
+ const error = new Error(`Request to ${props.method} ${props.path.href(props.args.path)} failed with status ${response.status}`);
54
+ const contentType = response.headers.get('content-type');
55
+ if (contentType?.includes('application/json')) {
56
+ Object.assign(error, await response.json());
57
+ }
58
+ throw error;
59
+ }
60
+ return response.json();
61
+ }
62
+ return {
63
+ ...(config.routes
64
+ ? mapValues(config.routes, route => connectRoute(route, request, json))
65
+ : null),
66
+ config,
67
+ request,
68
+ json,
69
+ };
70
+ }
71
+ function connectRoute(route, request, json) {
72
+ return {
73
+ ...route,
74
+ ...mapValues(route.methods, (schema, key) => {
75
+ const fetch = schema.response ? json : request;
76
+ return (args) => fetch(route[key](args));
77
+ }),
54
78
  };
55
79
  }
package/dist/route.d.ts CHANGED
@@ -1,7 +1,13 @@
1
1
  import { RoutePattern } from '@remix-run/route-pattern';
2
- import type { MutationMethod, QueryMethod, RouteFunction, RouteMethods, Unchecked } from './types.js';
2
+ import type { RouteRequestFactory, RouteSchema, RouteSchemaMap, Unchecked } from './types.js';
3
3
  export declare function $type<T>(): Unchecked<T>;
4
- export declare function route<P extends string, T extends RouteMethods>(pattern: P, methods: T): {
4
+ export declare namespace $type {
5
+ var symbol: symbol;
6
+ }
7
+ export type Route<P extends string = string, T extends RouteSchemaMap = RouteSchemaMap> = {
5
8
  path: RoutePattern<P>;
6
9
  methods: T;
7
- } & { [K in keyof T]: RouteFunction<Extract<T[K], MutationMethod | QueryMethod>>; };
10
+ } & {
11
+ [K in keyof T]: RouteRequestFactory<Extract<T[K], RouteSchema>, P>;
12
+ };
13
+ export declare function route<P extends string, T extends RouteSchemaMap>(pattern: P, methods: T): Route<P, T>;
package/dist/route.js CHANGED
@@ -1,18 +1,22 @@
1
1
  import { RoutePattern } from '@remix-run/route-pattern';
2
2
  import { mapEntries } from './common.js';
3
3
  export function $type() {
4
- return null;
4
+ return $type.symbol;
5
5
  }
6
+ $type.symbol = Symbol();
6
7
  export function route(pattern, methods) {
7
8
  const path = new RoutePattern(pattern);
8
- const createFetch = (method, route) => (args) => {
9
+ const createFetch = (method, schema) => (args = {}) => {
9
10
  return {
10
- route,
11
+ schema,
11
12
  path,
12
13
  method,
13
14
  args,
14
15
  $result: undefined,
15
16
  };
16
17
  };
17
- return Object.assign({ path, methods }, mapEntries(methods, (method, route) => [method, createFetch(method, route)]));
18
+ return Object.assign({ path, methods }, mapEntries(methods, (method, schema) => [
19
+ method,
20
+ createFetch(method, schema),
21
+ ]));
18
22
  }
@@ -1,20 +1,8 @@
1
- import type { AdapterRequestContext } from '@hattip/core';
2
- import { type Params } from '@remix-run/route-pattern';
3
- import { chain, MiddlewareChain, type MiddlewareContext } from 'alien-middleware';
4
- import * as z from 'zod/mini';
5
- import type { InferRouteResponse, MutationMethod, Promisable, QueryMethod, Routes } from '../types.js';
1
+ import type { HattipHandler } from '@hattip/core';
2
+ import { ApplyMiddleware, chain, ExtractMiddleware, MiddlewareChain, MiddlewareTypes } from 'alien-middleware';
3
+ import type { Routes } from '../types.js';
4
+ import type { RouteRequestHandlerMap } from './types.js';
6
5
  export { chain };
7
- type EmptyMiddlewareChain<TPlatform = unknown> = MiddlewareChain<{
8
- initial: {
9
- env: {};
10
- properties: {};
11
- };
12
- current: {
13
- env: {};
14
- properties: {};
15
- };
16
- platform: TPlatform;
17
- }>;
18
6
  export type RouterConfig = {
19
7
  /**
20
8
  * Base path to prepend to all routes.
@@ -24,33 +12,6 @@ export type RouterConfig = {
24
12
  * ```
25
13
  */
26
14
  basePath?: string;
27
- /**
28
- * Routes to match.
29
- * @example
30
- * ```ts
31
- * // This namespace contains your `route()` declarations.
32
- * // Pass it to the `createRouter` function.
33
- * import * as routes from './routes'
34
- *
35
- * createRouter({ routes })({
36
- * // your route handlers...
37
- * })
38
- * ```
39
- */
40
- routes: Routes;
41
- /**
42
- * Middleware to apply to all routes.
43
- * @see https://github.com/alien-rpc/alien-middleware#quick-start
44
- * @example
45
- * ```ts
46
- * middlewares: chain().use(ctx => {
47
- * return {
48
- * db: postgres(ctx.env('POSTGRES_URL')),
49
- * }
50
- * }),
51
- * ```
52
- */
53
- middlewares?: MiddlewareChain;
54
15
  /**
55
16
  * Enable debugging features.
56
17
  * - When a handler throws an error, include its message in the response body.
@@ -67,37 +28,30 @@ export type RouterConfig = {
67
28
  cors?: {
68
29
  /**
69
30
  * If defined, requests must have an `Origin` header that is in this list.
31
+ *
32
+ * Origins may contain wildcards for protocol and subdomain. The protocol is
33
+ * optional and defaults to `https`.
34
+ *
35
+ * @example
36
+ * ```ts
37
+ * allowOrigins: ['example.net', 'https://*.example.com', '*://localhost:3000']
38
+ * ```
70
39
  */
71
40
  allowOrigins?: string[];
72
41
  };
73
42
  };
74
- interface CreateRouterConfig<TRoutes extends Routes, TMiddleware extends MiddlewareChain> extends RouterConfig {
75
- routes: TRoutes;
76
- middlewares?: TMiddleware;
43
+ export interface Router<T extends MiddlewareTypes = any> extends HattipHandler<T['platform']>, MiddlewareChain<T> {
44
+ /**
45
+ * Clone this router and add the given middleware to the end of the chain.
46
+ *
47
+ * @returns a new `Router` instance.
48
+ */
49
+ use<const TMiddleware extends ExtractMiddleware<this>>(middleware: TMiddleware): Router<ApplyMiddleware<this, TMiddleware>>;
50
+ /**
51
+ * Clone this router and add the given routes and handlers to the chain.
52
+ *
53
+ * @returns a new `Router` instance.
54
+ */
55
+ use<TRoutes extends Routes>(routes: TRoutes, handlers: RouteRequestHandlerMap<TRoutes, this>): Router<T>;
77
56
  }
78
- export declare function createRouter<TRoutes extends Routes, TMiddleware extends MiddlewareChain = EmptyMiddlewareChain>(config: CreateRouterConfig<TRoutes, TMiddleware>): (handlers: { [K in keyof TRoutes]: { [M in keyof TRoutes[K]["methods"]]: TRoutes[K]["methods"][M] extends infer T ? T extends TRoutes[K]["methods"][M] ? T extends QueryMethod ? (context: MiddlewareContext<TMiddleware> & {
79
- path: T extends {
80
- path: any;
81
- } ? z.infer<T["path"]> : Params<TRoutes[K]["path"]["source"]>;
82
- query: z.infer<T["query"]>;
83
- headers: z.infer<T["headers"]>;
84
- }) => Promisable<Response | InferRouteResponse<T>> : T extends MutationMethod ? (context: MiddlewareContext<TMiddleware> & {
85
- path: T extends {
86
- path: any;
87
- } ? z.infer<T["path"]> : Params<TRoutes[K]["path"]["source"]>;
88
- body: z.infer<T["body"]>;
89
- headers: z.infer<T["headers"]>;
90
- }) => Promisable<Response | InferRouteResponse<T>> : never : never : never; }; }) => import("alien-middleware").ApplyMiddleware<TMiddleware, (context: AdapterRequestContext<TMiddleware extends MiddlewareChain<infer T extends {
91
- initial: {
92
- env: object;
93
- properties: object;
94
- };
95
- current: {
96
- env: object;
97
- properties: object;
98
- };
99
- platform: unknown;
100
- }> ? T["platform"] : never> & {
101
- url?: URL | undefined;
102
- path?: {} | undefined;
103
- }) => Promise<Response | undefined>>;
57
+ export declare function createRouter<TEnv extends object = {}, TProperties extends object = {}, TPlatform = unknown>(config?: RouterConfig): Router<MiddlewareTypes<TEnv, TProperties, TPlatform>>;
@@ -1,86 +1,114 @@
1
1
  import { RoutePattern } from '@remix-run/route-pattern';
2
- import { chain, } from 'alien-middleware';
3
- import { mapValues } from '../common.js';
2
+ import { chain, MiddlewareChain, } from 'alien-middleware';
4
3
  import * as z from 'zod/mini';
4
+ import { mapValues } from '../common.js';
5
5
  export { chain };
6
- export function createRouter(config) {
7
- const keys = Object.keys(config.routes);
8
- const middlewares = config.middlewares ?? chain();
9
- const basePath = config.basePath?.replace(/\/?$/, '/');
10
- const patterns = mapValues(config.routes, ({ path }) => basePath ? new RoutePattern(path.source.replace(/^\/?/, basePath)) : path);
11
- return (handlers) => middlewares.use(async function (context) {
12
- const request = context.request;
13
- const url = (context.url ??= new URL(request.url));
14
- let method = request.method.toUpperCase();
15
- let isPreflight = false;
16
- if (method === 'OPTIONS') {
17
- method = request.headers.get('Access-Control-Request-Method') ?? 'GET';
18
- isPreflight = true;
19
- }
20
- for (let i = 0; i < keys.length; i++) {
21
- const route = config.routes[keys[i]].methods[method];
22
- if (!route) {
23
- continue;
6
+ // Internal prototype for the router instance.
7
+ class RouterObject extends MiddlewareChain {
8
+ config;
9
+ basePath;
10
+ allowOrigins;
11
+ constructor(config) {
12
+ super();
13
+ this.config = config;
14
+ this.basePath = config.basePath?.replace(/\/?$/, '/');
15
+ this.allowOrigins = config.cors?.allowOrigins?.map(createOriginPattern);
16
+ }
17
+ use(...args) {
18
+ const handler = args.length === 1 ? super.use(args[0]) : this.useRoutes(...args);
19
+ Object.setPrototypeOf(handler, this);
20
+ return handler;
21
+ }
22
+ /** @internal */
23
+ useRoutes(routes, handlers) {
24
+ const { config, basePath, allowOrigins } = this;
25
+ const keys = Object.keys(routes);
26
+ const patterns = mapValues(routes, ({ path }) => basePath ? new RoutePattern(path.source.replace(/^\/?/, basePath)) : path);
27
+ return super.use(async function (context) {
28
+ const request = context.request;
29
+ const origin = request.headers.get('Origin');
30
+ if (origin &&
31
+ allowOrigins &&
32
+ !allowOrigins.some(pattern => pattern.test(origin))) {
33
+ return new Response(null, { status: 403 });
24
34
  }
25
- const match = patterns[keys[i]].match(url);
26
- if (!match) {
27
- continue;
35
+ const url = (context.url ??= new URL(request.url));
36
+ let method = request.method.toUpperCase();
37
+ let isPreflight = false;
38
+ if (method === 'OPTIONS') {
39
+ method = request.headers.get('Access-Control-Request-Method') ?? 'GET';
40
+ isPreflight = true;
28
41
  }
29
- const handler = handlers[keys[i]][method];
30
- if (!handler) {
31
- if (config.debug) {
32
- throw new Error(`Handler not found for route: ${keys[i]} ${method}`);
42
+ for (let i = 0; i < keys.length; i++) {
43
+ const { methods } = routes[keys[i]];
44
+ const route = methods[method] || methods.ALL;
45
+ if (!route) {
46
+ continue;
33
47
  }
34
- continue;
35
- }
36
- if (isPreflight) {
37
- const origin = request.headers.get('Origin');
38
- const allowed = config.cors?.allowOrigins;
39
- if (allowed && !(origin && allowed.includes(origin))) {
40
- return new Response(null, { status: 403 });
48
+ const match = patterns[keys[i]].match(url);
49
+ if (!match) {
50
+ continue;
41
51
  }
42
- return new Response(null, {
43
- headers: {
44
- 'Access-Control-Allow-Origin': origin ?? '*',
45
- 'Access-Control-Allow-Methods': method,
46
- 'Access-Control-Allow-Headers': request.headers.get('Access-Control-Request-Headers') ?? '',
47
- },
48
- });
49
- }
50
- if (route.path) {
51
- const error = parsePathParams(context, enableStringParsing(route.path), match.params);
52
- if (error) {
53
- return httpClientError(error, 'Invalid path parameter', config);
52
+ const routeHandler = handlers[keys[i]][method];
53
+ if (!routeHandler) {
54
+ if (config.debug) {
55
+ throw new Error(`Handler not found for route: ${keys[i]} ${method}`);
56
+ }
57
+ continue;
54
58
  }
55
- }
56
- else {
57
- context.path = match.params;
58
- }
59
- if (route.headers) {
60
- const error = parseHeaders(context, enableStringParsing(route.headers));
61
- if (error) {
62
- return httpClientError(error, 'Invalid request headers', config);
59
+ if (isPreflight) {
60
+ return new Response(null, {
61
+ headers: {
62
+ 'Access-Control-Allow-Origin': origin ?? '*',
63
+ 'Access-Control-Allow-Methods': method,
64
+ 'Access-Control-Allow-Headers': request.headers.get('Access-Control-Request-Headers') ?? '',
65
+ },
66
+ });
63
67
  }
64
- }
65
- if (route.query) {
66
- const error = parseQueryString(context, enableStringParsing(route.query));
67
- if (error) {
68
- return httpClientError(error, 'Invalid query string', config);
68
+ if (origin) {
69
+ context.setHeader('Access-Control-Allow-Origin', origin);
69
70
  }
70
- }
71
- if (route.body) {
72
- const error = await parseRequestBody(context, route.body);
73
- if (error) {
74
- return httpClientError(error, 'Invalid request body', config);
71
+ if (route.path) {
72
+ const error = parsePathParams(context, enableStringParsing(route.path), match.params);
73
+ if (error) {
74
+ return httpClientError(error, 'Invalid path parameter', config);
75
+ }
75
76
  }
77
+ else {
78
+ context.path = match.params;
79
+ }
80
+ if (route.headers) {
81
+ const error = parseHeaders(context, enableStringParsing(route.headers));
82
+ if (error) {
83
+ return httpClientError(error, 'Invalid request headers', config);
84
+ }
85
+ }
86
+ if (route.query) {
87
+ const error = parseQueryString(context, enableStringParsing(route.query));
88
+ if (error) {
89
+ return httpClientError(error, 'Invalid query string', config);
90
+ }
91
+ }
92
+ if (route.body) {
93
+ const error = await parseRequestBody(context, route.body);
94
+ if (error) {
95
+ return httpClientError(error, 'Invalid request body', config);
96
+ }
97
+ }
98
+ const result = await routeHandler(context);
99
+ if (result instanceof Response) {
100
+ return result;
101
+ }
102
+ return Response.json(result);
76
103
  }
77
- const result = await handler(context);
78
- if (result instanceof Response) {
79
- return result;
80
- }
81
- return Response.json(result);
82
- }
83
- });
104
+ });
105
+ }
106
+ }
107
+ export function createRouter(config = {}) {
108
+ const router = new RouterObject(config);
109
+ const handler = router.toHandler();
110
+ Object.setPrototypeOf(handler, router);
111
+ return handler;
84
112
  }
85
113
  function httpClientError(error, message, config) {
86
114
  return Response.json({
@@ -128,6 +156,10 @@ const seen = new WeakMap();
128
156
  * value as a number or boolean.
129
157
  */
130
158
  function enableStringParsing(schema) {
159
+ if (schema.type === 'optional') {
160
+ const { def } = schema;
161
+ return z.optional(enableStringParsing(def.innerType));
162
+ }
131
163
  if (schema.type === 'number') {
132
164
  return z.pipe(z.transform(Number), schema);
133
165
  }
@@ -135,19 +167,43 @@ function enableStringParsing(schema) {
135
167
  return z.pipe(z.transform(toBooleanStrict), schema);
136
168
  }
137
169
  if (schema.type === 'object') {
138
- const cached = seen.get(schema);
139
- if (cached) {
140
- return cached;
170
+ const cachedSchema = seen.get(schema);
171
+ if (cachedSchema) {
172
+ return cachedSchema;
141
173
  }
142
- const modified = z.object(mapValues(schema.def.shape, enableStringParsing));
143
- seen.set(schema, modified);
144
- return modified;
174
+ const { def } = schema;
175
+ const newSchema = z.object(mapValues(def.shape, enableStringParsing));
176
+ seen.set(schema, newSchema);
177
+ return newSchema;
145
178
  }
146
179
  if (schema.type === 'array') {
147
- return z.array(enableStringParsing(schema.def.element));
180
+ const { def } = schema;
181
+ return z.array(enableStringParsing(def.element));
148
182
  }
149
183
  return schema;
150
184
  }
151
185
  function toBooleanStrict(value) {
152
186
  return value === 'true' || (value === 'false' ? false : value);
153
187
  }
188
+ class ExactPattern {
189
+ value;
190
+ constructor(value) {
191
+ this.value = value;
192
+ }
193
+ test(input) {
194
+ return input === this.value;
195
+ }
196
+ }
197
+ function createOriginPattern(origin) {
198
+ if (!origin.includes('//')) {
199
+ origin = `https://${origin}`;
200
+ }
201
+ if (origin.includes('*')) {
202
+ return new RegExp(`^${origin
203
+ .replace(/\./g, '\\.')
204
+ .replace(/\*:/g, '[^:]+:') // Wildcard protocol
205
+ .replace(/\*\./g, '([^/]+\\.)?') // Wildcard subdomain
206
+ }$`);
207
+ }
208
+ return new ExactPattern(origin);
209
+ }
@@ -0,0 +1,33 @@
1
+ import type { Params } from '@remix-run/route-pattern';
2
+ import type { AnyMiddlewareChain, MiddlewareChain, MiddlewareContext } from 'alien-middleware';
3
+ import type * as z from 'zod/mini';
4
+ import type { InferRouteResponse, MutationRouteSchema, Promisable, QueryRouteSchema, Routes } from '../types.js';
5
+ type RequestContext<TMiddleware extends AnyMiddlewareChain> = MiddlewareContext<TMiddleware>;
6
+ type RouteRequestHandler<TMiddleware extends AnyMiddlewareChain, TArgs extends object, TResult> = (context: RequestContext<TMiddleware> & TArgs) => Promisable<TResult | Response>;
7
+ type InferRouteRequestHandler<TMiddleware extends AnyMiddlewareChain, T, P extends string> = T extends QueryRouteSchema ? RouteRequestHandler<TMiddleware, {
8
+ path: T extends {
9
+ path: any;
10
+ } ? z.infer<T['path']> : Params<P>;
11
+ query: T extends {
12
+ query: any;
13
+ } ? z.infer<T['query']> : undefined;
14
+ headers: T extends {
15
+ headers: any;
16
+ } ? z.infer<T['headers']> : undefined;
17
+ }, InferRouteResponse<T>> : T extends MutationRouteSchema ? RouteRequestHandler<TMiddleware, {
18
+ path: T extends {
19
+ path: any;
20
+ } ? z.infer<T['path']> : Params<P>;
21
+ body: T extends {
22
+ body: any;
23
+ } ? z.infer<T['body']> : undefined;
24
+ headers: T extends {
25
+ headers: any;
26
+ } ? z.infer<T['headers']> : undefined;
27
+ }, InferRouteResponse<T>> : never;
28
+ export type RouteRequestHandlerMap<TRoutes extends Routes = Routes, TMiddleware extends AnyMiddlewareChain = MiddlewareChain> = {
29
+ [K in keyof TRoutes]: {
30
+ [M in keyof TRoutes[K]['methods']]: InferRouteRequestHandler<TMiddleware, TRoutes[K]['methods'][M], TRoutes[K]['path']['source']>;
31
+ };
32
+ };
33
+ export {};
@@ -0,0 +1 @@
1
+ export {};
package/dist/types.d.ts CHANGED
@@ -4,68 +4,81 @@ export type Promisable<T> = T | Promise<T>;
4
4
  export type Unchecked<T> = {
5
5
  __unchecked__: T;
6
6
  };
7
- export type QueryMethod = {
7
+ export type QueryRouteSchema = {
8
8
  path?: z.ZodMiniObject<any>;
9
9
  query?: z.ZodMiniObject<any>;
10
10
  body?: never;
11
11
  headers?: z.ZodMiniObject<any>;
12
- response: Unchecked<any>;
12
+ response?: Unchecked<any>;
13
13
  };
14
- export type MutationMethod = {
14
+ export type MutationRouteSchema = {
15
15
  path?: z.ZodMiniObject<any>;
16
16
  query?: never;
17
- body: z.ZodMiniType<any, any>;
17
+ body?: z.ZodMiniType<any, any>;
18
18
  headers?: z.ZodMiniObject<any>;
19
19
  response?: Unchecked<any>;
20
20
  };
21
- export type RouteMethods = {
22
- GET?: QueryMethod;
23
- POST?: MutationMethod;
24
- PUT?: MutationMethod;
25
- PATCH?: MutationMethod;
26
- DELETE?: MutationMethod;
21
+ export type RouteSchemaMap = {
22
+ GET?: QueryRouteSchema;
23
+ POST?: MutationRouteSchema;
24
+ PUT?: MutationRouteSchema;
25
+ PATCH?: MutationRouteSchema;
26
+ DELETE?: MutationRouteSchema;
27
+ ALL?: {
28
+ path?: z.ZodMiniObject<any>;
29
+ query?: z.ZodMiniObject<any>;
30
+ body?: never;
31
+ headers?: z.ZodMiniObject<any>;
32
+ response?: never;
33
+ };
27
34
  };
35
+ export type Method = string & keyof RouteSchemaMap;
36
+ export type RouteSchema = QueryRouteSchema | MutationRouteSchema;
28
37
  export type Routes = {
29
38
  [key: string]: {
30
39
  path: RoutePattern;
31
- methods: RouteMethods;
40
+ methods: RouteSchemaMap;
32
41
  };
33
42
  };
34
43
  declare class Any {
35
44
  private isAny;
36
45
  }
37
- type PathArgs<T> = T extends {
38
- path: infer TPath extends string;
39
- } ? Params<TPath> extends infer TParams ? {} extends TParams ? {
46
+ type PathArgs<T, P extends string> = T extends {
47
+ path: infer TPath;
48
+ } ? {} extends z.infer<TPath> ? {
49
+ path?: z.infer<TPath>;
50
+ } : {
51
+ path: z.infer<TPath>;
52
+ } : Params<P> extends infer TParams ? {} extends TParams ? {
40
53
  path?: TParams;
41
54
  } : {
42
55
  path: TParams;
43
- } : unknown : unknown;
44
- type QueryArgs<T> = T extends QueryMethod & {
56
+ } : unknown;
57
+ type QueryArgs<T> = T extends QueryRouteSchema & {
45
58
  query: infer TQuery;
46
59
  } ? {} extends z.infer<TQuery> ? {
47
60
  query?: z.infer<TQuery>;
48
61
  } : {
49
62
  query: z.infer<TQuery>;
50
63
  } : unknown;
51
- type MutationArgs<T> = T extends MutationMethod & {
64
+ type MutationArgs<T> = T extends MutationRouteSchema ? T extends {
52
65
  body: infer TBody;
53
66
  } ? {} extends z.infer<TBody> ? {
54
67
  body?: z.infer<TBody>;
55
68
  } : {
56
69
  body: z.infer<TBody>;
70
+ } : {
71
+ body?: unknown;
57
72
  } : unknown;
58
- export type RouteArgs<T extends QueryMethod | MutationMethod = any> = ([
59
- T
60
- ] extends [Any] ? {
73
+ export type RouteArgs<T extends RouteSchema = any, P extends string = string> = ([T] extends [Any] ? {
61
74
  query?: any;
62
75
  body?: any;
63
76
  path?: any;
64
- } : QueryArgs<T> & MutationArgs<T> & PathArgs<T>) & Omit<RequestInit, 'method' | 'body' | 'headers'> & {
77
+ } : QueryArgs<T> & MutationArgs<T> & PathArgs<T, P>) & Omit<RequestInit, 'method' | 'body' | 'headers'> & {
65
78
  headers?: Record<string, string | undefined>;
66
79
  };
67
80
  export type RouteRequest<TResult = any> = {
68
- route: QueryMethod | MutationMethod;
81
+ schema: RouteSchema;
69
82
  path: RoutePattern;
70
83
  method: string;
71
84
  args: RouteArgs;
@@ -74,12 +87,12 @@ export type RouteRequest<TResult = any> = {
74
87
  export type RouteResponse<TResult = any> = Response & {
75
88
  json(): Promise<TResult>;
76
89
  };
77
- export type InferRouteResponse<T extends QueryMethod | MutationMethod> = T extends {
90
+ export type InferRouteResponse<T extends RouteSchema> = T extends {
78
91
  response: Unchecked<infer TResponse>;
79
92
  } ? TResponse : void;
80
- export type RouteFunction<T extends QueryMethod | MutationMethod> = {
81
- (args: RouteArgs<T>): RouteRequest<InferRouteResponse<T>>;
82
- $args: RouteArgs<T>;
93
+ export type RouteRequestFactory<T extends RouteSchema, P extends string> = {
94
+ (...p: RouteArgs<T, P> extends infer TArgs ? {} extends TArgs ? [args?: TArgs] : [args: TArgs] : never): RouteRequest<InferRouteResponse<T>>;
95
+ $args: RouteArgs<T, P>;
83
96
  $response: InferRouteResponse<T>;
84
97
  };
85
98
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rouzer",
3
- "version": "1.0.0-beta.9",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -13,16 +13,21 @@
13
13
  },
14
14
  "devDependencies": {
15
15
  "@alloc/prettier-config": "^1.0.0",
16
+ "@hattip/adapter-test": "^0.0.49",
17
+ "@types/node": "^25.0.3",
16
18
  "@typescript/native-preview": "7.0.0-dev.20251208.1",
17
19
  "prettier": "^3.7.4",
20
+ "rouzer": "link:.",
18
21
  "tsc-lint": "^0.1.9",
19
22
  "typescript": "^5.9.3",
23
+ "vite": "^7.3.0",
24
+ "vitest": "^4.0.16",
20
25
  "zod": "^4.1.13"
21
26
  },
22
27
  "dependencies": {
23
28
  "@hattip/core": "^0.0.49",
24
29
  "@remix-run/route-pattern": "^0.15.3",
25
- "alien-middleware": "^0.10.2"
30
+ "alien-middleware": "^0.11.3"
26
31
  },
27
32
  "prettier": "@alloc/prettier-config",
28
33
  "license": "MIT",
@@ -35,6 +40,7 @@
35
40
  "!*.tsbuildinfo"
36
41
  ],
37
42
  "scripts": {
38
- "build": "tsgo -b tsconfig.json"
43
+ "build": "tsgo -b tsconfig.json",
44
+ "test": "vitest run"
39
45
  }
40
46
  }
package/readme.md CHANGED
@@ -26,8 +26,6 @@ export const helloRoute = route('hello/:name', {
26
26
  response: $type<{ message: string }>(),
27
27
  },
28
28
  })
29
-
30
- export const routes = { helloRoute }
31
29
  ```
32
30
 
33
31
  The following request parts can be validated with Zod:
@@ -39,6 +37,17 @@ The following request parts can be validated with Zod:
39
37
 
40
38
  Zod validation happens on both the server and client.
41
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
+
42
51
  ## Server router
43
52
 
44
53
  ```ts
@@ -53,10 +62,35 @@ const middlewares = chain().use(ctx => {
53
62
  })
54
63
 
55
64
  export const handler = createRouter({
56
- routes,
57
- middlewares,
58
65
  debug: process.env.NODE_ENV === 'development',
59
- })({
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, {
60
94
  helloRoute: {
61
95
  GET(ctx) {
62
96
  const message = `Hello, ${ctx.path.name}${ctx.query.excited ? '!' : '.'}`
@@ -66,6 +100,13 @@ export const handler = createRouter({
66
100
  })
67
101
  ```
68
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
+
69
110
  ## Client wrapper
70
111
 
71
112
  ```ts
@@ -86,8 +127,50 @@ const response = await client.request(
86
127
  const { message } = await response.json()
87
128
  ```
88
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
+
89
172
  ## Add an endpoint
90
173
 
91
- 1. Declare it in `routes.ts` with `route(...)` and `zod/mini` schemas.
92
- 2. Implement the handler in your router assembly with `createRouter(…)({ ... })`.
174
+ 1. Declare it in `routes.ts` with `route()` and `zod/mini` schemas.
175
+ 2. Implement the handler in your router assembly with `createRouter(…).use(routes, { })`.
93
176
  3. Call it from the client with the generated helper via `client.json` or `client.request`.