rouzer 0.0.0 → 1.0.0-beta.2

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.
@@ -0,0 +1,5 @@
1
+ {
2
+ "editor.formatOnSave": true,
3
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
4
+ "rewrap.wrappingColumn": 80
5
+ }
package/package.json CHANGED
@@ -1,9 +1,33 @@
1
1
  {
2
2
  "name": "rouzer",
3
- "version": "0.0.0",
3
+ "version": "1.0.0-beta.2",
4
+ "exports": {
5
+ ".": {
6
+ "types": "./dist/index.d.ts",
7
+ "import": "./dist/index.js"
8
+ }
9
+ },
10
+ "peerDependencies": {
11
+ "zod": ">=4"
12
+ },
4
13
  "devDependencies": {
5
14
  "@alloc/prettier-config": "^1.0.0",
6
15
  "@typescript/native-preview": "7.0.0-dev.20251208.1",
7
- "prettier": "^3.7.4"
16
+ "prettier": "^3.7.4",
17
+ "zod": "^4.1.13"
18
+ },
19
+ "dependencies": {
20
+ "@hattip/core": "^0.0.49",
21
+ "@remix-run/route-pattern": "^0.15.3",
22
+ "alien-middleware": "^0.10.2"
23
+ },
24
+ "prettier": "@alloc/prettier-config",
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/alloc/rouzer.git"
29
+ },
30
+ "scripts": {
31
+ "build": "tsgo -b tsconfig.json"
8
32
  }
9
- }
33
+ }
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.params.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`.
@@ -0,0 +1,70 @@
1
+ import { shake } from '../common'
2
+ import type { RouteRequest } from '../types'
3
+
4
+ export function createClient(config: {
5
+ /**
6
+ * Base URL to use for all requests.
7
+ */
8
+ baseURL: string
9
+ /**
10
+ * Default headers to send with every request.
11
+ */
12
+ headers?: Record<string, string>
13
+ /**
14
+ * Custom handler for non-200 response to a `.json()` request. By default, the
15
+ * response is always parsed as JSON, regardless of the HTTP status code.
16
+ */
17
+ onJsonError?: (response: Response) => Response
18
+ }) {
19
+ return {
20
+ config,
21
+ request<T extends RouteRequest>({
22
+ pathPattern,
23
+ method,
24
+ args: { path, query, body, headers },
25
+ route,
26
+ }: T) {
27
+ if (route.path) {
28
+ path = route.path.parse(path)
29
+ }
30
+
31
+ const url = new URL(pathPattern.href(path), config.baseURL)
32
+
33
+ if (route.query) {
34
+ query = route.query.parse(query ?? {})
35
+ url.search = new URLSearchParams(query).toString()
36
+ } else if (query) {
37
+ throw new Error('Unexpected query parameters')
38
+ }
39
+ if (route.body) {
40
+ body = route.body.parse(body !== undefined ? body : {})
41
+ } else if (body !== undefined) {
42
+ throw new Error('Unexpected body')
43
+ }
44
+
45
+ if (config.headers || headers) {
46
+ headers = {
47
+ ...config.headers,
48
+ ...(headers && shake(headers)),
49
+ }
50
+ }
51
+
52
+ if (route.headers) {
53
+ headers = route.headers.parse(headers) as any
54
+ }
55
+
56
+ return fetch(url, {
57
+ method,
58
+ body: body !== undefined ? JSON.stringify(body) : undefined,
59
+ headers,
60
+ }) as Promise<Response & { json(): Promise<T['$result']> }>
61
+ },
62
+ async json<T extends RouteRequest>(request: T): Promise<T['$result']> {
63
+ const response = await this.request(request)
64
+ if (!response.ok && config.onJsonError) {
65
+ return config.onJsonError(response)
66
+ }
67
+ return response.json()
68
+ },
69
+ }
70
+ }
package/src/common.ts ADDED
@@ -0,0 +1,99 @@
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<
14
+ TKey extends string | number | symbol,
15
+ TValue,
16
+ TNewKey extends string | number | symbol,
17
+ TNewValue,
18
+ >(
19
+ obj: Record<TKey, TValue>,
20
+ toEntry: (key: TKey, value: TValue) => [TNewKey, TNewValue]
21
+ ): Record<TNewKey, TNewValue> {
22
+ if (!obj) {
23
+ return {} as Record<TNewKey, TNewValue>
24
+ }
25
+ return Object.entries(obj).reduce(
26
+ (acc, [key, value]) => {
27
+ const [newKey, newValue] = toEntry(key as TKey, value as TValue)
28
+ acc[newKey] = newValue
29
+ return acc
30
+ },
31
+ {} as Record<TNewKey, TNewValue>
32
+ )
33
+ }
34
+
35
+ /**
36
+ * Removes (shakes out) undefined entries from an object. Optional
37
+ * second argument shakes out values by custom evaluation.
38
+ *
39
+ * Note that non-enumerable keys are never shaken out.
40
+ *
41
+ * @see https://radashi.js.org/reference/object/shake
42
+ * @example
43
+ * ```ts
44
+ * const a = { a: 1, b: undefined, c: 3 }
45
+ * shake(a)
46
+ * // => { a: 1, c: 3 }
47
+ * ```
48
+ * @version 12.1.0
49
+ */
50
+ export function shake<T extends object>(
51
+ obj: T
52
+ ): {
53
+ [K in keyof T]: Exclude<T[K], undefined>
54
+ }
55
+
56
+ export function shake<T extends object>(
57
+ obj: T,
58
+ filter: ((value: unknown) => boolean) | undefined
59
+ ): T
60
+
61
+ export function shake<T extends object>(
62
+ obj: T,
63
+ filter: (value: unknown) => boolean = value => value === undefined
64
+ ): T {
65
+ if (!obj) {
66
+ return {} as T
67
+ }
68
+ return (Object.keys(obj) as (keyof T)[]).reduce((acc, key) => {
69
+ if (!filter(obj[key])) {
70
+ acc[key] = obj[key]
71
+ }
72
+ return acc
73
+ }, {} as T)
74
+ }
75
+
76
+ /**
77
+ * Map over all the keys to create a new object.
78
+ *
79
+ * @see https://radashi.js.org/reference/object/mapValues
80
+ * @example
81
+ * ```ts
82
+ * const a = { a: 1, b: 2, c: 3 }
83
+ * mapValues(a, (value, key) => value * 2)
84
+ * // => { a: 2, b: 4, c: 6 }
85
+ * ```
86
+ * @version 12.1.0
87
+ */
88
+ export function mapValues<T extends object, U>(
89
+ obj: T,
90
+ mapFunc: (value: Required<T>[keyof T], key: keyof T) => U
91
+ ): { [K in keyof T]: U } {
92
+ return (Object.keys(obj) as (keyof T)[]).reduce(
93
+ (acc, key) => {
94
+ acc[key] = mapFunc(obj[key], key)
95
+ return acc
96
+ },
97
+ {} as { [K in keyof T]: U }
98
+ )
99
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export * from './route'
2
+ export * from './server/router'
3
+ export * from './client/index'
4
+ export type * from './types'
5
+
6
+ export * from 'alien-middleware'
package/src/route.ts ADDED
@@ -0,0 +1,44 @@
1
+ import { RoutePattern } from '@remix-run/route-pattern'
2
+ import { mapEntries } from './common'
3
+ import type {
4
+ MutationRoute,
5
+ QueryRoute,
6
+ RouteArgs,
7
+ RouteFunction,
8
+ RouteRequest,
9
+ Routes,
10
+ Unchecked,
11
+ } from './types'
12
+
13
+ export function $type<T>() {
14
+ return null as unknown as Unchecked<T>
15
+ }
16
+
17
+ export function route<P extends string, T extends Routes>(path: P, routes: T) {
18
+ const pathPattern = new RoutePattern(path)
19
+ const createFetch =
20
+ (method: string, route: QueryRoute | MutationRoute) =>
21
+ (args: RouteArgs): RouteRequest => {
22
+ return {
23
+ route,
24
+ pathPattern,
25
+ method,
26
+ args,
27
+ $result: undefined!,
28
+ }
29
+ }
30
+
31
+ return Object.assign(
32
+ { path, pathPattern, routes },
33
+ mapEntries(
34
+ routes as Record<string, QueryRoute | MutationRoute>,
35
+ (method, route) => [method, createFetch(method, route)]
36
+ )
37
+ ) as unknown as {
38
+ path: P
39
+ pathPattern: RoutePattern
40
+ routes: T
41
+ } & {
42
+ [K in keyof T]: RouteFunction<Extract<T[K], QueryRoute | MutationRoute>>
43
+ }
44
+ }
@@ -0,0 +1,248 @@
1
+ import type { AdapterRequestContext } from '@hattip/core'
2
+ import { RoutePattern, type Params } from '@remix-run/route-pattern'
3
+ import {
4
+ chain,
5
+ MiddlewareChain,
6
+ type MiddlewareContext,
7
+ } from 'alien-middleware'
8
+ import { mapValues } from '../common'
9
+ import * as z from 'zod/mini'
10
+ import type {
11
+ InferRouteResponse,
12
+ MutationRoute,
13
+ Promisable,
14
+ QueryRoute,
15
+ Routes,
16
+ } from '../types'
17
+
18
+ export { chain }
19
+
20
+ type EmptyMiddlewareChain<TPlatform = unknown> = MiddlewareChain<{
21
+ initial: {
22
+ env: {}
23
+ properties: {}
24
+ }
25
+ current: {
26
+ env: {}
27
+ properties: {}
28
+ }
29
+ platform: TPlatform
30
+ }>
31
+
32
+ export type RouterConfig<
33
+ TRoutes extends Record<string, { path: string; routes: Routes }> = any,
34
+ TMiddleware extends MiddlewareChain = any,
35
+ > = {
36
+ routes: TRoutes
37
+ middlewares?: TMiddleware
38
+ debug?: boolean
39
+ }
40
+
41
+ export function createRouter<
42
+ TRoutes extends Record<string, { path: string; routes: Routes }>,
43
+ TMiddleware extends MiddlewareChain = EmptyMiddlewareChain,
44
+ >(config: { routes: TRoutes; middlewares?: TMiddleware; debug?: boolean }) {
45
+ const keys = Object.keys(config.routes)
46
+ const middlewares = config.middlewares ?? (chain() as TMiddleware)
47
+ const patterns = mapValues(
48
+ config.routes,
49
+ ({ path }) => new RoutePattern(path)
50
+ )
51
+
52
+ type RequestContext = MiddlewareContext<TMiddleware>
53
+
54
+ type RequestHandler<TArgs extends object, TResult> = (
55
+ context: RequestContext & TArgs
56
+ ) => Promisable<TResult | Response>
57
+
58
+ type InferRequestHandler<T, P extends string> = T extends QueryRoute
59
+ ? RequestHandler<
60
+ {
61
+ query: z.infer<T['query']>
62
+ params: Params<P>
63
+ headers: z.infer<T['headers']>
64
+ },
65
+ InferRouteResponse<T>
66
+ >
67
+ : T extends MutationRoute
68
+ ? RequestHandler<
69
+ {
70
+ body: z.infer<T['body']>
71
+ params: Params<P>
72
+ headers: z.infer<T['headers']>
73
+ },
74
+ InferRouteResponse<T>
75
+ >
76
+ : never
77
+
78
+ type RequestHandlers = {
79
+ [K in keyof TRoutes]: {
80
+ [M in keyof TRoutes[K]['routes']]: InferRequestHandler<
81
+ TRoutes[K]['routes'][M],
82
+ TRoutes[K]['path']
83
+ >
84
+ }
85
+ }
86
+
87
+ type TPlatform =
88
+ TMiddleware extends MiddlewareChain<infer T> ? T['platform'] : never
89
+
90
+ return (handlers: RequestHandlers) =>
91
+ middlewares.use(async function (
92
+ context: AdapterRequestContext<TPlatform> & {
93
+ url?: URL
94
+ params?: {}
95
+ }
96
+ ) {
97
+ const request = context.request as Request
98
+ const method = request.method.toUpperCase() as keyof Routes
99
+ const url: URL = (context.url ??= new URL(request.url))
100
+
101
+ for (let i = 0; i < keys.length; i++) {
102
+ const pattern = patterns[keys[i]]
103
+
104
+ const match = pattern.match(url)
105
+ if (!match) {
106
+ continue
107
+ }
108
+
109
+ const route = config.routes[keys[i]].routes[method]
110
+ if (!route) {
111
+ continue
112
+ }
113
+
114
+ if (route.headers) {
115
+ const error = parseHeaders(
116
+ context,
117
+ enableStringParsing(route.headers)
118
+ )
119
+ if (error) {
120
+ return httpClientError(error, 'Invalid request headers', config)
121
+ }
122
+ }
123
+
124
+ if (route.query) {
125
+ const error = parseQueryString(
126
+ context,
127
+ enableStringParsing(route.query)
128
+ )
129
+ if (error) {
130
+ return httpClientError(error, 'Invalid query string', config)
131
+ }
132
+ }
133
+
134
+ if (route.body) {
135
+ const error = await parseRequestBody(context, route.body)
136
+ if (error) {
137
+ return httpClientError(error, 'Invalid request body', config)
138
+ }
139
+ }
140
+
141
+ const handler = handlers[keys[i]][method]
142
+ if (!handler) {
143
+ continue
144
+ }
145
+
146
+ context.params = match.params
147
+
148
+ const result = await handler(context as any)
149
+ if (result instanceof Response) {
150
+ return result
151
+ }
152
+ return Response.json(result)
153
+ }
154
+ })
155
+ }
156
+
157
+ function httpClientError(
158
+ error: any,
159
+ message: string,
160
+ config: { debug?: boolean }
161
+ ) {
162
+ return Response.json(
163
+ {
164
+ ...error,
165
+ message: config.debug ? `${message}: ${error.message}` : message,
166
+ },
167
+ { status: 400 }
168
+ )
169
+ }
170
+
171
+ function parseHeaders(
172
+ context: AdapterRequestContext & { headers?: {} },
173
+ schema: z.ZodMiniType<any, any>
174
+ ) {
175
+ const headers = Object.fromEntries(context.request.headers as any)
176
+ const result = schema.safeParse(headers)
177
+ if (!result.success) {
178
+ return result.error
179
+ }
180
+ context.headers = result.data
181
+ return null
182
+ }
183
+
184
+ function parseQueryString(
185
+ context: AdapterRequestContext & { url?: URL; query?: {} },
186
+ schema: z.ZodMiniType<any, any>
187
+ ) {
188
+ const result = schema.safeParse(
189
+ Object.fromEntries(context.url!.searchParams as any)
190
+ )
191
+ if (!result.success) {
192
+ return result.error
193
+ }
194
+ context.query = result.data
195
+ return null
196
+ }
197
+
198
+ async function parseRequestBody(
199
+ context: AdapterRequestContext & { body?: {} },
200
+ schema: z.ZodMiniType<any, any>
201
+ ) {
202
+ const result = await context.request.json().then(
203
+ body => schema.safeParse(body),
204
+ error => ({ success: false, error }) as const
205
+ )
206
+ if (!result.success) {
207
+ return result.error
208
+ }
209
+ context.body = result.data
210
+ return null
211
+ }
212
+
213
+ const seen = new WeakMap<z.ZodMiniType<any, any>, z.ZodMiniType<any, any>>()
214
+
215
+ /**
216
+ * Traverse object and array schemas, finding schemas that expect a number or
217
+ * boolean, and replace those schemas with a new schema that parses the input
218
+ * value as a number or boolean.
219
+ */
220
+ function enableStringParsing(schema: z.ZodMiniType<any, any>): typeof schema {
221
+ if (schema.type === 'number') {
222
+ return z.pipe(z.transform(Number), schema)
223
+ }
224
+ if (schema.type === 'boolean') {
225
+ return z.pipe(z.transform(toBooleanStrict), schema)
226
+ }
227
+ if (schema.type === 'object') {
228
+ const cached = seen.get(schema)
229
+ if (cached) {
230
+ return cached
231
+ }
232
+ const modified = z.object(
233
+ mapValues((schema as z.ZodMiniObject<any>).def.shape, enableStringParsing)
234
+ )
235
+ seen.set(schema, modified)
236
+ return modified
237
+ }
238
+ if (schema.type === 'array') {
239
+ return z.array(
240
+ enableStringParsing((schema as z.ZodMiniArray<any>).def.element)
241
+ )
242
+ }
243
+ return schema
244
+ }
245
+
246
+ function toBooleanStrict(value: string) {
247
+ return value === 'true' || (value === 'false' ? false : value)
248
+ }
package/src/types.ts ADDED
@@ -0,0 +1,89 @@
1
+ import { Params, RoutePattern } from '@remix-run/route-pattern'
2
+ import * as z from 'zod/mini'
3
+
4
+ export type Promisable<T> = T | Promise<T>
5
+
6
+ export type Unchecked<T> = { __unchecked__: T }
7
+
8
+ export type QueryRoute = {
9
+ path?: z.ZodMiniObject<any>
10
+ query?: z.ZodMiniObject<any>
11
+ body?: never
12
+ headers?: z.ZodMiniObject<any>
13
+ response: Unchecked<any>
14
+ }
15
+
16
+ export type MutationRoute = {
17
+ path?: z.ZodMiniObject<any>
18
+ query?: never
19
+ body: z.ZodMiniType<any, any>
20
+ headers?: z.ZodMiniObject<any>
21
+ response?: Unchecked<any>
22
+ }
23
+
24
+ export type Routes = {
25
+ GET?: QueryRoute
26
+ POST?: MutationRoute
27
+ PUT?: MutationRoute
28
+ PATCH?: MutationRoute
29
+ DELETE?: MutationRoute
30
+ }
31
+
32
+ declare class Any {
33
+ private isAny: true
34
+ }
35
+
36
+ type PathArgs<T> = T extends { path: infer TPath extends string }
37
+ ? Params<TPath> extends infer TParams
38
+ ? {} extends TParams
39
+ ? { path?: TParams }
40
+ : { path: TParams }
41
+ : unknown
42
+ : unknown
43
+
44
+ type QueryArgs<T> = T extends QueryRoute & { query: infer TQuery }
45
+ ? {} extends z.infer<TQuery>
46
+ ? { query?: z.infer<TQuery> }
47
+ : { query: z.infer<TQuery> }
48
+ : unknown
49
+
50
+ type MutationArgs<T> = T extends MutationRoute & { body: infer TBody }
51
+ ? {} extends z.infer<TBody>
52
+ ? { body?: z.infer<TBody> }
53
+ : { body: z.infer<TBody> }
54
+ : unknown
55
+
56
+ export type RouteArgs<T extends QueryRoute | MutationRoute = any> = ([
57
+ T,
58
+ ] extends [Any]
59
+ ? { query?: any; body?: any; path?: any }
60
+ : QueryArgs<T> & MutationArgs<T> & PathArgs<T>) &
61
+ Omit<RequestInit, 'method' | 'body' | 'headers'> & {
62
+ headers?: Record<string, string | undefined>
63
+ }
64
+
65
+ export type RouteRequest<TResult = any> = {
66
+ route: QueryRoute | MutationRoute
67
+ pathPattern: RoutePattern
68
+ method: string
69
+ args: RouteArgs
70
+ $result: TResult
71
+ }
72
+
73
+ export type RouteResponse<TResult = any> = Response & {
74
+ json(): Promise<TResult>
75
+ }
76
+
77
+ export type InferRouteResponse<T extends QueryRoute | MutationRoute> =
78
+ T extends {
79
+ response: Unchecked<infer TResponse>
80
+ }
81
+ ? TResponse
82
+ : void
83
+
84
+ export type RouteFunction<T extends QueryRoute | MutationRoute> = {
85
+ (args: RouteArgs<T>): RouteRequest<InferRouteResponse<T>>
86
+
87
+ $args: RouteArgs<T>
88
+ $response: InferRouteResponse<T>
89
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "compilerOptions": {
3
+ "outDir": "dist",
4
+ "declaration": true,
5
+ "module": "nodenext",
6
+ "moduleResolution": "nodenext",
7
+ "lib": ["dom", "es2019"],
8
+ "target": "esnext"
9
+ }
10
+ }