rouzer 0.0.0 → 1.0.0-beta.10
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 +36 -0
- package/dist/client/index.js +55 -0
- package/dist/common.d.ts +47 -0
- package/dist/common.js +51 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -0
- package/dist/route.d.ts +7 -0
- package/dist/route.js +18 -0
- package/dist/server/router.d.ts +111 -0
- package/dist/server/router.js +175 -0
- package/dist/types.d.ts +85 -0
- package/dist/types.js +1 -0
- package/package.json +34 -3
- package/readme.md +93 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Promisable, RouteRequest } from '../types.js';
|
|
2
|
+
export declare function createClient(config: {
|
|
3
|
+
/**
|
|
4
|
+
* Base URL to use for all requests.
|
|
5
|
+
*/
|
|
6
|
+
baseURL: string;
|
|
7
|
+
/**
|
|
8
|
+
* Default headers to send with every request.
|
|
9
|
+
*/
|
|
10
|
+
headers?: Record<string, string>;
|
|
11
|
+
/**
|
|
12
|
+
* Custom handler for non-200 response to a `.json()` request. By default, the
|
|
13
|
+
* response is always parsed as JSON, regardless of the HTTP status code.
|
|
14
|
+
*/
|
|
15
|
+
onJsonError?: (response: Response) => Promisable<Response>;
|
|
16
|
+
}): {
|
|
17
|
+
config: {
|
|
18
|
+
/**
|
|
19
|
+
* Base URL to use for all requests.
|
|
20
|
+
*/
|
|
21
|
+
baseURL: string;
|
|
22
|
+
/**
|
|
23
|
+
* Default headers to send with every request.
|
|
24
|
+
*/
|
|
25
|
+
headers?: Record<string, string> | undefined;
|
|
26
|
+
/**
|
|
27
|
+
* Custom handler for non-200 response to a `.json()` request. By default, the
|
|
28
|
+
* response is always parsed as JSON, regardless of the HTTP status code.
|
|
29
|
+
*/
|
|
30
|
+
onJsonError?: ((response: Response) => Promisable<Response>) | undefined;
|
|
31
|
+
};
|
|
32
|
+
request<T extends RouteRequest>({ path: pathBuilder, method, args: { path, query, body, headers }, route, }: T): Promise<Response & {
|
|
33
|
+
json(): Promise<T["$result"]>;
|
|
34
|
+
}>;
|
|
35
|
+
json<T extends RouteRequest>(request: T): Promise<T["$result"]>;
|
|
36
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { shake } from '../common.js';
|
|
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');
|
|
31
|
+
}
|
|
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) {
|
|
50
|
+
return config.onJsonError(response);
|
|
51
|
+
}
|
|
52
|
+
return response.json();
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
package/dist/common.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Map over all the keys to create a new object.
|
|
3
|
+
*
|
|
4
|
+
* @see https://radashi.js.org/reference/object/mapEntries
|
|
5
|
+
* @example
|
|
6
|
+
* ```ts
|
|
7
|
+
* const a = { a: 1, b: 2, c: 3 }
|
|
8
|
+
* mapEntries(a, (key, value) => [value, key])
|
|
9
|
+
* // => { 1: 'a', 2: 'b', 3: 'c' }
|
|
10
|
+
* ```
|
|
11
|
+
* @version 12.1.0
|
|
12
|
+
*/
|
|
13
|
+
export declare function mapEntries<TKey extends string | number | symbol, TValue, TNewKey extends string | number | symbol, TNewValue>(obj: Record<TKey, TValue>, toEntry: (key: TKey, value: TValue) => [TNewKey, TNewValue]): Record<TNewKey, TNewValue>;
|
|
14
|
+
/**
|
|
15
|
+
* Removes (shakes out) undefined entries from an object. Optional
|
|
16
|
+
* second argument shakes out values by custom evaluation.
|
|
17
|
+
*
|
|
18
|
+
* Note that non-enumerable keys are never shaken out.
|
|
19
|
+
*
|
|
20
|
+
* @see https://radashi.js.org/reference/object/shake
|
|
21
|
+
* @example
|
|
22
|
+
* ```ts
|
|
23
|
+
* const a = { a: 1, b: undefined, c: 3 }
|
|
24
|
+
* shake(a)
|
|
25
|
+
* // => { a: 1, c: 3 }
|
|
26
|
+
* ```
|
|
27
|
+
* @version 12.1.0
|
|
28
|
+
*/
|
|
29
|
+
export declare function shake<T extends object>(obj: T): {
|
|
30
|
+
[K in keyof T]: Exclude<T[K], undefined>;
|
|
31
|
+
};
|
|
32
|
+
export declare function shake<T extends object>(obj: T, filter: ((value: unknown) => boolean) | undefined): T;
|
|
33
|
+
/**
|
|
34
|
+
* Map over all the keys to create a new object.
|
|
35
|
+
*
|
|
36
|
+
* @see https://radashi.js.org/reference/object/mapValues
|
|
37
|
+
* @example
|
|
38
|
+
* ```ts
|
|
39
|
+
* const a = { a: 1, b: 2, c: 3 }
|
|
40
|
+
* mapValues(a, (value, key) => value * 2)
|
|
41
|
+
* // => { a: 2, b: 4, c: 6 }
|
|
42
|
+
* ```
|
|
43
|
+
* @version 12.1.0
|
|
44
|
+
*/
|
|
45
|
+
export declare function mapValues<T extends object, U>(obj: T, mapFunc: (value: Required<T>[keyof T], key: keyof T) => U): {
|
|
46
|
+
[K in keyof T]: U;
|
|
47
|
+
};
|
package/dist/common.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Map over all the keys to create a new object.
|
|
3
|
+
*
|
|
4
|
+
* @see https://radashi.js.org/reference/object/mapEntries
|
|
5
|
+
* @example
|
|
6
|
+
* ```ts
|
|
7
|
+
* const a = { a: 1, b: 2, c: 3 }
|
|
8
|
+
* mapEntries(a, (key, value) => [value, key])
|
|
9
|
+
* // => { 1: 'a', 2: 'b', 3: 'c' }
|
|
10
|
+
* ```
|
|
11
|
+
* @version 12.1.0
|
|
12
|
+
*/
|
|
13
|
+
export function mapEntries(obj, toEntry) {
|
|
14
|
+
if (!obj) {
|
|
15
|
+
return {};
|
|
16
|
+
}
|
|
17
|
+
return Object.entries(obj).reduce((acc, [key, value]) => {
|
|
18
|
+
const [newKey, newValue] = toEntry(key, value);
|
|
19
|
+
acc[newKey] = newValue;
|
|
20
|
+
return acc;
|
|
21
|
+
}, {});
|
|
22
|
+
}
|
|
23
|
+
export function shake(obj, filter = value => value === undefined) {
|
|
24
|
+
if (!obj) {
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
return Object.keys(obj).reduce((acc, key) => {
|
|
28
|
+
if (!filter(obj[key])) {
|
|
29
|
+
acc[key] = obj[key];
|
|
30
|
+
}
|
|
31
|
+
return acc;
|
|
32
|
+
}, {});
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Map over all the keys to create a new object.
|
|
36
|
+
*
|
|
37
|
+
* @see https://radashi.js.org/reference/object/mapValues
|
|
38
|
+
* @example
|
|
39
|
+
* ```ts
|
|
40
|
+
* const a = { a: 1, b: 2, c: 3 }
|
|
41
|
+
* mapValues(a, (value, key) => value * 2)
|
|
42
|
+
* // => { a: 2, b: 4, c: 6 }
|
|
43
|
+
* ```
|
|
44
|
+
* @version 12.1.0
|
|
45
|
+
*/
|
|
46
|
+
export function mapValues(obj, mapFunc) {
|
|
47
|
+
return Object.keys(obj).reduce((acc, key) => {
|
|
48
|
+
acc[key] = mapFunc(obj[key], key);
|
|
49
|
+
return acc;
|
|
50
|
+
}, {});
|
|
51
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/route.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { RoutePattern } from '@remix-run/route-pattern';
|
|
2
|
+
import type { MutationMethod, QueryMethod, RouteFunction, RouteMethods, Unchecked } from './types.js';
|
|
3
|
+
export declare function $type<T>(): Unchecked<T>;
|
|
4
|
+
export declare function route<P extends string, T extends RouteMethods>(pattern: P, methods: T): {
|
|
5
|
+
path: RoutePattern<P>;
|
|
6
|
+
methods: T;
|
|
7
|
+
} & { [K in keyof T]: RouteFunction<Extract<T[K], MutationMethod | QueryMethod>>; };
|
package/dist/route.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { RoutePattern } from '@remix-run/route-pattern';
|
|
2
|
+
import { mapEntries } from './common.js';
|
|
3
|
+
export function $type() {
|
|
4
|
+
return null;
|
|
5
|
+
}
|
|
6
|
+
export function route(pattern, methods) {
|
|
7
|
+
const path = new RoutePattern(pattern);
|
|
8
|
+
const createFetch = (method, route) => (args) => {
|
|
9
|
+
return {
|
|
10
|
+
route,
|
|
11
|
+
path,
|
|
12
|
+
method,
|
|
13
|
+
args,
|
|
14
|
+
$result: undefined,
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
return Object.assign({ path, methods }, mapEntries(methods, (method, route) => [method, createFetch(method, route)]));
|
|
18
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
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';
|
|
6
|
+
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
|
+
export type RouterConfig = {
|
|
19
|
+
/**
|
|
20
|
+
* Base path to prepend to all routes.
|
|
21
|
+
* @example
|
|
22
|
+
* ```ts
|
|
23
|
+
* basePath: 'api/',
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
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
|
+
/**
|
|
55
|
+
* Enable debugging features.
|
|
56
|
+
* - When a handler throws an error, include its message in the response body.
|
|
57
|
+
* - Throw an error if a handler is not found for a route.
|
|
58
|
+
* @example
|
|
59
|
+
* ```ts
|
|
60
|
+
* debug: process.env.NODE_ENV !== 'production',
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
debug?: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* CORS configuration.
|
|
66
|
+
*/
|
|
67
|
+
cors?: {
|
|
68
|
+
/**
|
|
69
|
+
* If defined, requests must have an `Origin` header that is in this list.
|
|
70
|
+
*
|
|
71
|
+
* Origins may contain wildcards for protocol and subdomain. The protocol is
|
|
72
|
+
* optional and defaults to `https`.
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* ```ts
|
|
76
|
+
* allowOrigins: ['example.net', 'https://*.example.com', '*://localhost:3000']
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
allowOrigins?: string[];
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
interface CreateRouterConfig<TRoutes extends Routes, TMiddleware extends MiddlewareChain> extends RouterConfig {
|
|
83
|
+
routes: TRoutes;
|
|
84
|
+
middlewares?: TMiddleware;
|
|
85
|
+
}
|
|
86
|
+
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> & {
|
|
87
|
+
path: T extends {
|
|
88
|
+
path: any;
|
|
89
|
+
} ? z.infer<T["path"]> : Params<TRoutes[K]["path"]["source"]>;
|
|
90
|
+
query: z.infer<T["query"]>;
|
|
91
|
+
headers: z.infer<T["headers"]>;
|
|
92
|
+
}) => Promisable<Response | InferRouteResponse<T>> : T extends MutationMethod ? (context: MiddlewareContext<TMiddleware> & {
|
|
93
|
+
path: T extends {
|
|
94
|
+
path: any;
|
|
95
|
+
} ? z.infer<T["path"]> : Params<TRoutes[K]["path"]["source"]>;
|
|
96
|
+
body: z.infer<T["body"]>;
|
|
97
|
+
headers: z.infer<T["headers"]>;
|
|
98
|
+
}) => Promisable<Response | InferRouteResponse<T>> : never : never : never; }; }) => import("alien-middleware").ApplyMiddleware<TMiddleware, (context: AdapterRequestContext<TMiddleware extends MiddlewareChain<infer T extends {
|
|
99
|
+
initial: {
|
|
100
|
+
env: object;
|
|
101
|
+
properties: object;
|
|
102
|
+
};
|
|
103
|
+
current: {
|
|
104
|
+
env: object;
|
|
105
|
+
properties: object;
|
|
106
|
+
};
|
|
107
|
+
platform: unknown;
|
|
108
|
+
}> ? T["platform"] : never> & {
|
|
109
|
+
url?: URL | undefined;
|
|
110
|
+
path?: {} | undefined;
|
|
111
|
+
}) => Promise<Response | undefined>>;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { RoutePattern } from '@remix-run/route-pattern';
|
|
2
|
+
import { chain, } from 'alien-middleware';
|
|
3
|
+
import { mapValues } from '../common.js';
|
|
4
|
+
import * as z from 'zod/mini';
|
|
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
|
+
const allowOrigins = config.cors?.allowOrigins?.map(origin => {
|
|
12
|
+
if (!origin.includes('//')) {
|
|
13
|
+
origin = `https://${origin}`;
|
|
14
|
+
}
|
|
15
|
+
if (origin.includes('*')) {
|
|
16
|
+
return new RegExp(`^${origin
|
|
17
|
+
.replace(/\./g, '\\.')
|
|
18
|
+
.replace(/\*:/g, '[^:]+:') // Wildcard protocol
|
|
19
|
+
.replace(/\*\./g, '([^/.]+\\.)?') // Wildcard subdomain
|
|
20
|
+
}$`);
|
|
21
|
+
}
|
|
22
|
+
return new ExactPattern(origin);
|
|
23
|
+
});
|
|
24
|
+
return (handlers) => middlewares.use(async function (context) {
|
|
25
|
+
const request = context.request;
|
|
26
|
+
const url = (context.url ??= new URL(request.url));
|
|
27
|
+
let method = request.method.toUpperCase();
|
|
28
|
+
let isPreflight = false;
|
|
29
|
+
if (method === 'OPTIONS') {
|
|
30
|
+
method = request.headers.get('Access-Control-Request-Method') ?? 'GET';
|
|
31
|
+
isPreflight = true;
|
|
32
|
+
}
|
|
33
|
+
for (let i = 0; i < keys.length; i++) {
|
|
34
|
+
const route = config.routes[keys[i]].methods[method];
|
|
35
|
+
if (!route) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const match = patterns[keys[i]].match(url);
|
|
39
|
+
if (!match) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const handler = handlers[keys[i]][method];
|
|
43
|
+
if (!handler) {
|
|
44
|
+
if (config.debug) {
|
|
45
|
+
throw new Error(`Handler not found for route: ${keys[i]} ${method}`);
|
|
46
|
+
}
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (isPreflight) {
|
|
50
|
+
const origin = request.headers.get('Origin');
|
|
51
|
+
if (allowOrigins &&
|
|
52
|
+
!(origin && allowOrigins.some(pattern => pattern.test(origin)))) {
|
|
53
|
+
return new Response(null, { status: 403 });
|
|
54
|
+
}
|
|
55
|
+
return new Response(null, {
|
|
56
|
+
headers: {
|
|
57
|
+
'Access-Control-Allow-Origin': origin ?? '*',
|
|
58
|
+
'Access-Control-Allow-Methods': method,
|
|
59
|
+
'Access-Control-Allow-Headers': request.headers.get('Access-Control-Request-Headers') ?? '',
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
if (route.path) {
|
|
64
|
+
const error = parsePathParams(context, enableStringParsing(route.path), match.params);
|
|
65
|
+
if (error) {
|
|
66
|
+
return httpClientError(error, 'Invalid path parameter', config);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
context.path = match.params;
|
|
71
|
+
}
|
|
72
|
+
if (route.headers) {
|
|
73
|
+
const error = parseHeaders(context, enableStringParsing(route.headers));
|
|
74
|
+
if (error) {
|
|
75
|
+
return httpClientError(error, 'Invalid request headers', config);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (route.query) {
|
|
79
|
+
const error = parseQueryString(context, enableStringParsing(route.query));
|
|
80
|
+
if (error) {
|
|
81
|
+
return httpClientError(error, 'Invalid query string', config);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (route.body) {
|
|
85
|
+
const error = await parseRequestBody(context, route.body);
|
|
86
|
+
if (error) {
|
|
87
|
+
return httpClientError(error, 'Invalid request body', config);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const result = await handler(context);
|
|
91
|
+
if (result instanceof Response) {
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
return Response.json(result);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
function httpClientError(error, message, config) {
|
|
99
|
+
return Response.json({
|
|
100
|
+
...error,
|
|
101
|
+
message: config.debug ? `${message}: ${error.message}` : message,
|
|
102
|
+
}, { status: 400 });
|
|
103
|
+
}
|
|
104
|
+
function parsePathParams(context, schema, params) {
|
|
105
|
+
const result = schema.safeParse(params);
|
|
106
|
+
if (!result.success) {
|
|
107
|
+
return result.error;
|
|
108
|
+
}
|
|
109
|
+
context.path = result.data;
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
function parseHeaders(context, schema) {
|
|
113
|
+
const headers = Object.fromEntries(context.request.headers);
|
|
114
|
+
const result = schema.safeParse(headers);
|
|
115
|
+
if (!result.success) {
|
|
116
|
+
return result.error;
|
|
117
|
+
}
|
|
118
|
+
context.headers = result.data;
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
function parseQueryString(context, schema) {
|
|
122
|
+
const result = schema.safeParse(Object.fromEntries(context.url.searchParams));
|
|
123
|
+
if (!result.success) {
|
|
124
|
+
return result.error;
|
|
125
|
+
}
|
|
126
|
+
context.query = result.data;
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
async function parseRequestBody(context, schema) {
|
|
130
|
+
const result = await context.request.json().then(body => schema.safeParse(body), error => ({ success: false, error }));
|
|
131
|
+
if (!result.success) {
|
|
132
|
+
return result.error;
|
|
133
|
+
}
|
|
134
|
+
context.body = result.data;
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
const seen = new WeakMap();
|
|
138
|
+
/**
|
|
139
|
+
* Traverse object and array schemas, finding schemas that expect a number or
|
|
140
|
+
* boolean, and replace those schemas with a new schema that parses the input
|
|
141
|
+
* value as a number or boolean.
|
|
142
|
+
*/
|
|
143
|
+
function enableStringParsing(schema) {
|
|
144
|
+
if (schema.type === 'number') {
|
|
145
|
+
return z.pipe(z.transform(Number), schema);
|
|
146
|
+
}
|
|
147
|
+
if (schema.type === 'boolean') {
|
|
148
|
+
return z.pipe(z.transform(toBooleanStrict), schema);
|
|
149
|
+
}
|
|
150
|
+
if (schema.type === 'object') {
|
|
151
|
+
const cached = seen.get(schema);
|
|
152
|
+
if (cached) {
|
|
153
|
+
return cached;
|
|
154
|
+
}
|
|
155
|
+
const modified = z.object(mapValues(schema.def.shape, enableStringParsing));
|
|
156
|
+
seen.set(schema, modified);
|
|
157
|
+
return modified;
|
|
158
|
+
}
|
|
159
|
+
if (schema.type === 'array') {
|
|
160
|
+
return z.array(enableStringParsing(schema.def.element));
|
|
161
|
+
}
|
|
162
|
+
return schema;
|
|
163
|
+
}
|
|
164
|
+
function toBooleanStrict(value) {
|
|
165
|
+
return value === 'true' || (value === 'false' ? false : value);
|
|
166
|
+
}
|
|
167
|
+
class ExactPattern {
|
|
168
|
+
value;
|
|
169
|
+
constructor(value) {
|
|
170
|
+
this.value = value;
|
|
171
|
+
}
|
|
172
|
+
test(input) {
|
|
173
|
+
return input === this.value;
|
|
174
|
+
}
|
|
175
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Params, RoutePattern } from '@remix-run/route-pattern';
|
|
2
|
+
import * as z from 'zod/mini';
|
|
3
|
+
export type Promisable<T> = T | Promise<T>;
|
|
4
|
+
export type Unchecked<T> = {
|
|
5
|
+
__unchecked__: T;
|
|
6
|
+
};
|
|
7
|
+
export type QueryMethod = {
|
|
8
|
+
path?: z.ZodMiniObject<any>;
|
|
9
|
+
query?: z.ZodMiniObject<any>;
|
|
10
|
+
body?: never;
|
|
11
|
+
headers?: z.ZodMiniObject<any>;
|
|
12
|
+
response: Unchecked<any>;
|
|
13
|
+
};
|
|
14
|
+
export type MutationMethod = {
|
|
15
|
+
path?: z.ZodMiniObject<any>;
|
|
16
|
+
query?: never;
|
|
17
|
+
body: z.ZodMiniType<any, any>;
|
|
18
|
+
headers?: z.ZodMiniObject<any>;
|
|
19
|
+
response?: Unchecked<any>;
|
|
20
|
+
};
|
|
21
|
+
export type RouteMethods = {
|
|
22
|
+
GET?: QueryMethod;
|
|
23
|
+
POST?: MutationMethod;
|
|
24
|
+
PUT?: MutationMethod;
|
|
25
|
+
PATCH?: MutationMethod;
|
|
26
|
+
DELETE?: MutationMethod;
|
|
27
|
+
};
|
|
28
|
+
export type Routes = {
|
|
29
|
+
[key: string]: {
|
|
30
|
+
path: RoutePattern;
|
|
31
|
+
methods: RouteMethods;
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
declare class Any {
|
|
35
|
+
private isAny;
|
|
36
|
+
}
|
|
37
|
+
type PathArgs<T> = T extends {
|
|
38
|
+
path: infer TPath extends string;
|
|
39
|
+
} ? Params<TPath> extends infer TParams ? {} extends TParams ? {
|
|
40
|
+
path?: TParams;
|
|
41
|
+
} : {
|
|
42
|
+
path: TParams;
|
|
43
|
+
} : unknown : unknown;
|
|
44
|
+
type QueryArgs<T> = T extends QueryMethod & {
|
|
45
|
+
query: infer TQuery;
|
|
46
|
+
} ? {} extends z.infer<TQuery> ? {
|
|
47
|
+
query?: z.infer<TQuery>;
|
|
48
|
+
} : {
|
|
49
|
+
query: z.infer<TQuery>;
|
|
50
|
+
} : unknown;
|
|
51
|
+
type MutationArgs<T> = T extends MutationMethod & {
|
|
52
|
+
body: infer TBody;
|
|
53
|
+
} ? {} extends z.infer<TBody> ? {
|
|
54
|
+
body?: z.infer<TBody>;
|
|
55
|
+
} : {
|
|
56
|
+
body: z.infer<TBody>;
|
|
57
|
+
} : unknown;
|
|
58
|
+
export type RouteArgs<T extends QueryMethod | MutationMethod = any> = ([
|
|
59
|
+
T
|
|
60
|
+
] extends [Any] ? {
|
|
61
|
+
query?: any;
|
|
62
|
+
body?: any;
|
|
63
|
+
path?: any;
|
|
64
|
+
} : QueryArgs<T> & MutationArgs<T> & PathArgs<T>) & Omit<RequestInit, 'method' | 'body' | 'headers'> & {
|
|
65
|
+
headers?: Record<string, string | undefined>;
|
|
66
|
+
};
|
|
67
|
+
export type RouteRequest<TResult = any> = {
|
|
68
|
+
route: QueryMethod | MutationMethod;
|
|
69
|
+
path: RoutePattern;
|
|
70
|
+
method: string;
|
|
71
|
+
args: RouteArgs;
|
|
72
|
+
$result: TResult;
|
|
73
|
+
};
|
|
74
|
+
export type RouteResponse<TResult = any> = Response & {
|
|
75
|
+
json(): Promise<TResult>;
|
|
76
|
+
};
|
|
77
|
+
export type InferRouteResponse<T extends QueryMethod | MutationMethod> = T extends {
|
|
78
|
+
response: Unchecked<infer TResponse>;
|
|
79
|
+
} ? TResponse : void;
|
|
80
|
+
export type RouteFunction<T extends QueryMethod | MutationMethod> = {
|
|
81
|
+
(args: RouteArgs<T>): RouteRequest<InferRouteResponse<T>>;
|
|
82
|
+
$args: RouteArgs<T>;
|
|
83
|
+
$response: InferRouteResponse<T>;
|
|
84
|
+
};
|
|
85
|
+
export {};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,9 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rouzer",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "1.0.0-beta.10",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"import": "./dist/index.js"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"peerDependencies": {
|
|
12
|
+
"zod": ">=4"
|
|
13
|
+
},
|
|
4
14
|
"devDependencies": {
|
|
5
15
|
"@alloc/prettier-config": "^1.0.0",
|
|
6
16
|
"@typescript/native-preview": "7.0.0-dev.20251208.1",
|
|
7
|
-
"prettier": "^3.7.4"
|
|
17
|
+
"prettier": "^3.7.4",
|
|
18
|
+
"tsc-lint": "^0.1.9",
|
|
19
|
+
"typescript": "^5.9.3",
|
|
20
|
+
"zod": "^4.1.13"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@hattip/core": "^0.0.49",
|
|
24
|
+
"@remix-run/route-pattern": "^0.15.3",
|
|
25
|
+
"alien-middleware": "^0.10.2"
|
|
26
|
+
},
|
|
27
|
+
"prettier": "@alloc/prettier-config",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "https://github.com/alloc/rouzer.git"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"dist",
|
|
35
|
+
"!*.tsbuildinfo"
|
|
36
|
+
],
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsgo -b tsconfig.json"
|
|
8
39
|
}
|
|
9
|
-
}
|
|
40
|
+
}
|
package/readme.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# rouzer
|
|
2
|
+
|
|
3
|
+
Type-safe routes shared by your server and client, powered by `zod/mini` (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/mini'
|
|
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
|
+
export const routes = { helloRoute }
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The following request parts can be validated with Zod:
|
|
34
|
+
|
|
35
|
+
- `path`
|
|
36
|
+
- `query`
|
|
37
|
+
- `body`
|
|
38
|
+
- `headers`
|
|
39
|
+
|
|
40
|
+
Zod validation happens on both the server and client.
|
|
41
|
+
|
|
42
|
+
## Server router
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import { chain, createRouter } from 'rouzer'
|
|
46
|
+
import { routes } from './routes'
|
|
47
|
+
|
|
48
|
+
const middlewares = chain().use(ctx => {
|
|
49
|
+
// An example middleware. For more info, see https://github.com/alien-rpc/alien-middleware#readme
|
|
50
|
+
return {
|
|
51
|
+
db: postgres(ctx.env('POSTGRES_URL')),
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
export const handler = createRouter({
|
|
56
|
+
routes,
|
|
57
|
+
middlewares,
|
|
58
|
+
debug: process.env.NODE_ENV === 'development',
|
|
59
|
+
})({
|
|
60
|
+
helloRoute: {
|
|
61
|
+
GET(ctx) {
|
|
62
|
+
const message = `Hello, ${ctx.path.name}${ctx.query.excited ? '!' : '.'}`
|
|
63
|
+
return { message }
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Client wrapper
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import { createClient } from 'rouzer'
|
|
73
|
+
import { helloRoute } from './routes'
|
|
74
|
+
|
|
75
|
+
const client = createClient({ baseURL: '/api/' })
|
|
76
|
+
|
|
77
|
+
const { message } = await client.json(
|
|
78
|
+
helloRoute.GET({ path: { name: 'world' }, query: { excited: true } })
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
// If you want the Response object, use `client.request` instead.
|
|
82
|
+
const response = await client.request(
|
|
83
|
+
helloRoute.GET({ path: { name: 'world' } })
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
const { message } = await response.json()
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Add an endpoint
|
|
90
|
+
|
|
91
|
+
1. Declare it in `routes.ts` with `route(...)` and `zod/mini` schemas.
|
|
92
|
+
2. Implement the handler in your router assembly with `createRouter(…)({ ... })`.
|
|
93
|
+
3. Call it from the client with the generated helper via `client.json` or `client.request`.
|