rouzer 1.0.0-beta.8 → 1.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/dist/client/index.d.ts +43 -7
- package/dist/client/index.js +73 -49
- package/dist/route.d.ts +9 -3
- package/dist/route.js +8 -4
- package/dist/server/router.d.ts +35 -72
- package/dist/server/router.js +135 -60
- package/dist/server/types.d.ts +33 -0
- package/dist/server/types.js +1 -0
- package/dist/types.d.ts +39 -26
- package/package.json +11 -3
- package/readme.md +90 -7
package/dist/client/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
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.
|
|
@@ -22,15 +37,36 @@ export declare function createClient(config: {
|
|
|
22
37
|
/**
|
|
23
38
|
* Default headers to send with every request.
|
|
24
39
|
*/
|
|
25
|
-
headers?: Record<string, string
|
|
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
|
-
onJsonError?: (response: Response) => Promisable<Response
|
|
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 },
|
|
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>(
|
|
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>;
|
package/dist/client/index.js
CHANGED
|
@@ -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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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 {
|
|
2
|
+
import type { RouteRequestFactory, RouteSchema, RouteSchemaMap, Unchecked } from './types.js';
|
|
3
3
|
export declare function $type<T>(): Unchecked<T>;
|
|
4
|
-
export declare
|
|
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
|
-
} & {
|
|
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
|
|
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,
|
|
9
|
+
const createFetch = (method, schema) => (args = {}) => {
|
|
9
10
|
return {
|
|
10
|
-
|
|
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,
|
|
18
|
+
return Object.assign({ path, methods }, mapEntries(methods, (method, schema) => [
|
|
19
|
+
method,
|
|
20
|
+
createFetch(method, schema),
|
|
21
|
+
]));
|
|
18
22
|
}
|
package/dist/server/router.d.ts
CHANGED
|
@@ -1,20 +1,8 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
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.
|
|
@@ -61,34 +22,36 @@ export type RouterConfig = {
|
|
|
61
22
|
* ```
|
|
62
23
|
*/
|
|
63
24
|
debug?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* CORS configuration.
|
|
27
|
+
*/
|
|
28
|
+
cors?: {
|
|
29
|
+
/**
|
|
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
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
allowOrigins?: string[];
|
|
41
|
+
};
|
|
64
42
|
};
|
|
65
|
-
interface
|
|
66
|
-
|
|
67
|
-
|
|
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>;
|
|
68
56
|
}
|
|
69
|
-
export declare function createRouter
|
|
70
|
-
path: T extends {
|
|
71
|
-
path: any;
|
|
72
|
-
} ? z.infer<T["path"]> : Params<TRoutes[K]["path"]["source"]>;
|
|
73
|
-
query: z.infer<T["query"]>;
|
|
74
|
-
headers: z.infer<T["headers"]>;
|
|
75
|
-
}) => Promisable<Response | InferRouteResponse<T>> : T extends MutationMethod ? (context: MiddlewareContext<TMiddleware> & {
|
|
76
|
-
path: T extends {
|
|
77
|
-
path: any;
|
|
78
|
-
} ? z.infer<T["path"]> : Params<TRoutes[K]["path"]["source"]>;
|
|
79
|
-
body: z.infer<T["body"]>;
|
|
80
|
-
headers: z.infer<T["headers"]>;
|
|
81
|
-
}) => Promisable<Response | InferRouteResponse<T>> : never : never : never; }; }) => import("alien-middleware").ApplyMiddleware<TMiddleware, (context: AdapterRequestContext<TMiddleware extends MiddlewareChain<infer T extends {
|
|
82
|
-
initial: {
|
|
83
|
-
env: object;
|
|
84
|
-
properties: object;
|
|
85
|
-
};
|
|
86
|
-
current: {
|
|
87
|
-
env: object;
|
|
88
|
-
properties: object;
|
|
89
|
-
};
|
|
90
|
-
platform: unknown;
|
|
91
|
-
}> ? T["platform"] : never> & {
|
|
92
|
-
url?: URL;
|
|
93
|
-
path?: {};
|
|
94
|
-
}) => Promise<Response>>;
|
|
57
|
+
export declare function createRouter(config?: RouterConfig): Router;
|
package/dist/server/router.js
CHANGED
|
@@ -1,67 +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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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 });
|
|
19
34
|
}
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
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;
|
|
23
41
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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;
|
|
28
47
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const error = parsePathParams(context, enableStringParsing(route.path), match.params);
|
|
33
|
-
if (error) {
|
|
34
|
-
return httpClientError(error, 'Invalid path parameter', config);
|
|
48
|
+
const match = patterns[keys[i]].match(url);
|
|
49
|
+
if (!match) {
|
|
50
|
+
continue;
|
|
35
51
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (error) {
|
|
43
|
-
return httpClientError(error, 'Invalid request headers', 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;
|
|
44
58
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
+
});
|
|
50
67
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const error = await parseRequestBody(context, route.body);
|
|
54
|
-
if (error) {
|
|
55
|
-
return httpClientError(error, 'Invalid request body', config);
|
|
68
|
+
if (origin) {
|
|
69
|
+
context.setHeader('Access-Control-Allow-Origin', origin);
|
|
56
70
|
}
|
|
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
|
+
}
|
|
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);
|
|
57
103
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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;
|
|
65
112
|
}
|
|
66
113
|
function httpClientError(error, message, config) {
|
|
67
114
|
return Response.json({
|
|
@@ -109,6 +156,10 @@ const seen = new WeakMap();
|
|
|
109
156
|
* value as a number or boolean.
|
|
110
157
|
*/
|
|
111
158
|
function enableStringParsing(schema) {
|
|
159
|
+
if (schema.type === 'optional') {
|
|
160
|
+
const { def } = schema;
|
|
161
|
+
return z.optional(enableStringParsing(def.innerType));
|
|
162
|
+
}
|
|
112
163
|
if (schema.type === 'number') {
|
|
113
164
|
return z.pipe(z.transform(Number), schema);
|
|
114
165
|
}
|
|
@@ -116,19 +167,43 @@ function enableStringParsing(schema) {
|
|
|
116
167
|
return z.pipe(z.transform(toBooleanStrict), schema);
|
|
117
168
|
}
|
|
118
169
|
if (schema.type === 'object') {
|
|
119
|
-
const
|
|
120
|
-
if (
|
|
121
|
-
return
|
|
170
|
+
const cachedSchema = seen.get(schema);
|
|
171
|
+
if (cachedSchema) {
|
|
172
|
+
return cachedSchema;
|
|
122
173
|
}
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
174
|
+
const { def } = schema;
|
|
175
|
+
const newSchema = z.object(mapValues(def.shape, enableStringParsing));
|
|
176
|
+
seen.set(schema, newSchema);
|
|
177
|
+
return newSchema;
|
|
126
178
|
}
|
|
127
179
|
if (schema.type === 'array') {
|
|
128
|
-
|
|
180
|
+
const { def } = schema;
|
|
181
|
+
return z.array(enableStringParsing(def.element));
|
|
129
182
|
}
|
|
130
183
|
return schema;
|
|
131
184
|
}
|
|
132
185
|
function toBooleanStrict(value) {
|
|
133
186
|
return value === 'true' || (value === 'false' ? false : value);
|
|
134
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
|
|
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
|
|
12
|
+
response?: Unchecked<any>;
|
|
13
13
|
};
|
|
14
|
-
export type
|
|
14
|
+
export type MutationRouteSchema = {
|
|
15
15
|
path?: z.ZodMiniObject<any>;
|
|
16
16
|
query?: never;
|
|
17
|
-
body
|
|
17
|
+
body?: z.ZodMiniType<any, any>;
|
|
18
18
|
headers?: z.ZodMiniObject<any>;
|
|
19
19
|
response?: Unchecked<any>;
|
|
20
20
|
};
|
|
21
|
-
export type
|
|
22
|
-
GET?:
|
|
23
|
-
POST?:
|
|
24
|
-
PUT?:
|
|
25
|
-
PATCH?:
|
|
26
|
-
DELETE?:
|
|
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:
|
|
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
|
|
39
|
-
} ?
|
|
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
|
|
44
|
-
type QueryArgs<T> = T extends
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
90
|
+
export type InferRouteResponse<T extends RouteSchema> = T extends {
|
|
78
91
|
response: Unchecked<infer TResponse>;
|
|
79
92
|
} ? TResponse : void;
|
|
80
|
-
export type
|
|
81
|
-
(
|
|
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
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
@@ -13,14 +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:.",
|
|
21
|
+
"tsc-lint": "^0.1.9",
|
|
22
|
+
"typescript": "^5.9.3",
|
|
23
|
+
"vite": "^7.3.0",
|
|
24
|
+
"vitest": "^4.0.16",
|
|
18
25
|
"zod": "^4.1.13"
|
|
19
26
|
},
|
|
20
27
|
"dependencies": {
|
|
21
28
|
"@hattip/core": "^0.0.49",
|
|
22
29
|
"@remix-run/route-pattern": "^0.15.3",
|
|
23
|
-
"alien-middleware": "^0.
|
|
30
|
+
"alien-middleware": "^0.11.2"
|
|
24
31
|
},
|
|
25
32
|
"prettier": "@alloc/prettier-config",
|
|
26
33
|
"license": "MIT",
|
|
@@ -33,6 +40,7 @@
|
|
|
33
40
|
"!*.tsbuildinfo"
|
|
34
41
|
],
|
|
35
42
|
"scripts": {
|
|
36
|
-
"build": "tsgo -b tsconfig.json"
|
|
43
|
+
"build": "tsgo -b tsconfig.json",
|
|
44
|
+
"test": "vitest run"
|
|
37
45
|
}
|
|
38
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(
|
|
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`.
|