rouzer 3.1.0 → 4.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/README.md +57 -10
- package/dist/client/index.d.ts +19 -23
- package/dist/client/index.js +44 -24
- package/dist/common.d.ts +11 -1
- package/dist/http.d.ts +0 -3
- package/dist/http.js +4 -9
- package/dist/response-map.d.ts +10 -0
- package/dist/response-map.js +32 -0
- package/dist/response.d.ts +16 -5
- package/dist/response.js +4 -6
- package/dist/server/router.js +53 -3
- package/dist/type.d.ts +33 -4
- package/dist/type.js +32 -3
- package/dist/types/args.d.ts +1 -1
- package/dist/types/handler.d.ts +54 -4
- package/dist/types/index.d.ts +0 -1
- package/dist/types/infer.d.ts +3 -8
- package/dist/types/response.d.ts +51 -11
- package/dist/types/schema.d.ts +15 -4
- package/docs/context.md +133 -54
- package/examples/error-responses.ts +98 -0
- package/package.json +2 -1
- package/dist/types/request.d.ts +0 -35
- package/dist/types/request.js +0 -1
package/README.md
CHANGED
|
@@ -2,18 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
Rouzer lets you declare an HTTP route tree once and share its TypeScript types
|
|
4
4
|
and Zod validation between a Hattip-compatible server and a typed fetch client.
|
|
5
|
+
The client is always created from that route tree.
|
|
5
6
|
|
|
6
7
|
## What it does
|
|
7
8
|
|
|
8
9
|
A Rouzer HTTP route tree defines URL patterns, named actions, method schemas, and
|
|
9
|
-
optional JSON or newline-delimited JSON response types once, then reuses
|
|
10
|
-
contract to:
|
|
10
|
+
optional JSON, error, or newline-delimited JSON response types once, then reuses
|
|
11
|
+
that contract to:
|
|
11
12
|
|
|
12
13
|
- validate client arguments before `fetch`
|
|
13
14
|
- match and validate server requests before handlers run
|
|
14
15
|
- type handler context from path, query/body, headers, and middleware
|
|
15
16
|
- attach typed client action functions such as `client.profiles.get(...)`
|
|
16
|
-
- parse typed JSON responses and
|
|
17
|
+
- parse typed JSON responses, declared error responses, and NDJSON streams
|
|
17
18
|
|
|
18
19
|
Rouzer optimizes for shared TypeScript route modules over language-agnostic API
|
|
19
20
|
schemas or generated SDKs.
|
|
@@ -24,6 +25,8 @@ Use Rouzer if:
|
|
|
24
25
|
|
|
25
26
|
- your server and client can import the same TypeScript route tree
|
|
26
27
|
- you want Zod request validation on both sides of an HTTP boundary
|
|
28
|
+
- response data is validated at data/client boundaries, not by re-checking every
|
|
29
|
+
handler return
|
|
27
30
|
- a Hattip-compatible handler fits your server runtime
|
|
28
31
|
- you prefer named resource/action functions over a generated client class
|
|
29
32
|
|
|
@@ -31,8 +34,8 @@ Consider something else if:
|
|
|
31
34
|
|
|
32
35
|
- you need OpenAPI-first workflows, schema files, or generated clients for other
|
|
33
36
|
languages
|
|
34
|
-
- you
|
|
35
|
-
`
|
|
37
|
+
- you want the router to validate every response body at the server boundary;
|
|
38
|
+
`$type<T>()`, `$error<T>()`, and `ndjson.$type<T>()` are type contracts
|
|
36
39
|
- you want a framework that owns controllers, data loading, rendering, and
|
|
37
40
|
deployment adapters
|
|
38
41
|
- you cannot use ESM or Zod v4+
|
|
@@ -43,7 +46,7 @@ Consider something else if:
|
|
|
43
46
|
- Zod v4 or newer
|
|
44
47
|
- a Hattip adapter when using `createRouter(...)`
|
|
45
48
|
- a Fetch API implementation when using `createClient(...)`
|
|
46
|
-
- an absolute `baseURL` for generated client URLs
|
|
49
|
+
- an absolute `baseURL` and shared `routes` tree for generated client URLs
|
|
47
50
|
|
|
48
51
|
## Installation
|
|
49
52
|
|
|
@@ -55,7 +58,7 @@ Import the primary API from the root package and declare routes through the HTTP
|
|
|
55
58
|
subpath:
|
|
56
59
|
|
|
57
60
|
```ts
|
|
58
|
-
import { $type, chain, createClient, createRouter } from 'rouzer'
|
|
61
|
+
import { $error, $type, chain, createClient, createRouter } from 'rouzer'
|
|
59
62
|
import * as http from 'rouzer/http'
|
|
60
63
|
```
|
|
61
64
|
|
|
@@ -99,9 +102,52 @@ const { message } = await client.hello({
|
|
|
99
102
|
})
|
|
100
103
|
```
|
|
101
104
|
|
|
102
|
-
`handler` can be mounted with any Hattip adapter.
|
|
103
|
-
route arguments before `fetch`; server handlers validate matched path,
|
|
104
|
-
headers, and JSON bodies before your handler runs.
|
|
105
|
+
`handler` can be mounted with any Hattip adapter. Generated client action calls
|
|
106
|
+
validate route arguments before `fetch`; server handlers validate matched path,
|
|
107
|
+
query, headers, and JSON bodies before your handler runs.
|
|
108
|
+
|
|
109
|
+
### Typed status responses
|
|
110
|
+
|
|
111
|
+
Use a response map when client code needs declared error statuses as data instead
|
|
112
|
+
of exceptions.
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
import { $error, $type, createClient, createRouter } from 'rouzer'
|
|
116
|
+
import * as http from 'rouzer/http'
|
|
117
|
+
|
|
118
|
+
type User = { id: string; name: string }
|
|
119
|
+
type NotFound = { code: 'NOT_FOUND'; message: string }
|
|
120
|
+
|
|
121
|
+
export const getUser = http.get('users/:id', {
|
|
122
|
+
response: {
|
|
123
|
+
200: $type<User>(),
|
|
124
|
+
404: $error<NotFound>(),
|
|
125
|
+
},
|
|
126
|
+
})
|
|
127
|
+
export const routes = { getUser }
|
|
128
|
+
|
|
129
|
+
createRouter().use(routes, {
|
|
130
|
+
getUser(ctx) {
|
|
131
|
+
if (ctx.path.id === 'missing') {
|
|
132
|
+
return ctx.error(404, {
|
|
133
|
+
code: 'NOT_FOUND',
|
|
134
|
+
message: 'User not found',
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
return { id: ctx.path.id, name: 'Ada' }
|
|
138
|
+
},
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
const client = createClient({
|
|
142
|
+
baseURL: 'https://example.com/api/',
|
|
143
|
+
routes,
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
const [error, user, status] = await client.getUser({ path: { id: '42' } })
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Success entries resolve as `[null, value, status]`; declared error entries
|
|
150
|
+
resolve as `[error, null, status]`.
|
|
105
151
|
|
|
106
152
|
### NDJSON response streams
|
|
107
153
|
|
|
@@ -141,6 +187,7 @@ for await (const event of await client.events()) {
|
|
|
141
187
|
|
|
142
188
|
- [Concepts, API selection, and v2->v3 migration notes](docs/context.md)
|
|
143
189
|
- [Runnable shared-route example](examples/basic-usage.ts)
|
|
190
|
+
- [Runnable typed error response example](examples/error-responses.ts)
|
|
144
191
|
- [Runnable NDJSON response-stream example](examples/ndjson-stream.ts)
|
|
145
192
|
- Generated declarations in the published package provide the exact signatures
|
|
146
193
|
for every public export, including the `rouzer/http` and `rouzer/ndjson`
|
package/dist/client/index.d.ts
CHANGED
|
@@ -2,7 +2,6 @@ import { Promisable } from '../common.js';
|
|
|
2
2
|
import type { HttpAction, HttpResource, HttpRouteTree } from '../http.js';
|
|
3
3
|
import { type ClientResponsePlugin } from '../response.js';
|
|
4
4
|
import type { RouteArgs } from '../types/args.js';
|
|
5
|
-
import type { RouteRequest } from '../types/request.js';
|
|
6
5
|
import type { InferRouteResponse } from '../types/response.js';
|
|
7
6
|
import type { RouteSchema } from '../types/schema.js';
|
|
8
7
|
/** Client type inferred from an HTTP route tree passed to `createClient`. */
|
|
@@ -10,9 +9,8 @@ export type RouzerClient<TRoutes extends HttpRouteTree = Record<string, never>>
|
|
|
10
9
|
/**
|
|
11
10
|
* Create a typed fetch client for an HTTP route tree.
|
|
12
11
|
*
|
|
13
|
-
* @remarks The returned client
|
|
14
|
-
*
|
|
15
|
-
* tree and attaches direct action functions such as `client.users.list(...)`.
|
|
12
|
+
* @remarks The returned client mirrors the resource tree and attaches direct
|
|
13
|
+
* action functions such as `client.users.list(...)`.
|
|
16
14
|
*/
|
|
17
15
|
export declare function createClient<TRoutes extends HttpRouteTree = Record<string, never>>(config: {
|
|
18
16
|
/**
|
|
@@ -38,22 +36,22 @@ export declare function createClient<TRoutes extends HttpRouteTree = Record<stri
|
|
|
38
36
|
* await client.users.list({ query: { page: 1 } })
|
|
39
37
|
* ```
|
|
40
38
|
*/
|
|
41
|
-
routes
|
|
39
|
+
routes: TRoutes;
|
|
42
40
|
/** Response codec plugins used by generated action functions. */
|
|
43
41
|
plugins?: readonly ClientResponsePlugin[];
|
|
44
42
|
/**
|
|
45
|
-
* Custom handler for non-2xx responses from
|
|
46
|
-
*
|
|
43
|
+
* Custom handler for non-2xx responses from generated client action
|
|
44
|
+
* functions.
|
|
47
45
|
*
|
|
48
|
-
* @remarks When provided, the return value is returned from the
|
|
49
|
-
*
|
|
50
|
-
*
|
|
46
|
+
* @remarks When provided, the return value is returned from the client action
|
|
47
|
+
* as-is; Rouzer does not automatically parse a `Response` returned by this
|
|
48
|
+
* hook.
|
|
51
49
|
*/
|
|
52
50
|
onJsonError?: (response: Response) => Promisable<unknown>;
|
|
53
51
|
/** Custom `fetch` implementation to use for requests. */
|
|
54
52
|
fetch?: typeof globalThis.fetch;
|
|
55
53
|
}): ClientTree<TRoutes, ""> & {
|
|
56
|
-
|
|
54
|
+
clientConfig: {
|
|
57
55
|
/**
|
|
58
56
|
* Absolute base URL used for generated request URLs.
|
|
59
57
|
*
|
|
@@ -77,25 +75,21 @@ export declare function createClient<TRoutes extends HttpRouteTree = Record<stri
|
|
|
77
75
|
* await client.users.list({ query: { page: 1 } })
|
|
78
76
|
* ```
|
|
79
77
|
*/
|
|
80
|
-
routes
|
|
78
|
+
routes: TRoutes;
|
|
81
79
|
/** Response codec plugins used by generated action functions. */
|
|
82
80
|
plugins?: readonly ClientResponsePlugin[];
|
|
83
81
|
/**
|
|
84
|
-
* Custom handler for non-2xx responses from
|
|
85
|
-
*
|
|
82
|
+
* Custom handler for non-2xx responses from generated client action
|
|
83
|
+
* functions.
|
|
86
84
|
*
|
|
87
|
-
* @remarks When provided, the return value is returned from the
|
|
88
|
-
*
|
|
89
|
-
*
|
|
85
|
+
* @remarks When provided, the return value is returned from the client action
|
|
86
|
+
* as-is; Rouzer does not automatically parse a `Response` returned by this
|
|
87
|
+
* hook.
|
|
90
88
|
*/
|
|
91
89
|
onJsonError?: (response: Response) => Promisable<unknown>;
|
|
92
90
|
/** Custom `fetch` implementation to use for requests. */
|
|
93
91
|
fetch?: typeof globalThis.fetch;
|
|
94
92
|
};
|
|
95
|
-
request: <T extends RouteRequest>({ path: pathBuilder, method, args, schema, }: T) => Promise<Response & {
|
|
96
|
-
json(): Promise<T['$result']>;
|
|
97
|
-
}>;
|
|
98
|
-
json: <T extends RouteRequest>(props: T) => Promise<T['$result']>;
|
|
99
93
|
};
|
|
100
94
|
type Join<A extends string, B extends string> = A extends '' ? B : B extends '' ? A : `${A}/${B}`;
|
|
101
95
|
/** Client object shape produced from an HTTP route tree. */
|
|
@@ -106,8 +100,10 @@ export type ClientTree<T extends HttpRouteTree, TPrefix extends string = ''> = {
|
|
|
106
100
|
* Client action function attached for each HTTP action leaf.
|
|
107
101
|
*
|
|
108
102
|
* @remarks Actions whose schema has `response: $type<T>()` return parsed JSON
|
|
109
|
-
* as `T`. Actions whose schema has a
|
|
110
|
-
*
|
|
103
|
+
* as `T`. Actions whose schema has a status-keyed response map return a tuple
|
|
104
|
+
* union of `[null, value, status]` success entries and `[error, null, status]`
|
|
105
|
+
* error entries. Actions whose schema has a plugin response marker return the
|
|
106
|
+
* plugin's client result type. Actions without a response marker return the raw
|
|
111
107
|
* `Response`.
|
|
112
108
|
*/
|
|
113
109
|
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 {
|
package/dist/client/index.js
CHANGED
|
@@ -2,21 +2,19 @@ import { RoutePattern } from '@remix-run/route-pattern';
|
|
|
2
2
|
import { createHref } from '@remix-run/route-pattern/href';
|
|
3
3
|
import { shake } from '../common.js';
|
|
4
4
|
import { createResponsePluginMap, getResponsePluginMarkerId, } from '../response.js';
|
|
5
|
+
import { getResponseMapPluginIds, isErrorMarker, isResponseMap, } from '../response-map.js';
|
|
5
6
|
/**
|
|
6
7
|
* Create a typed fetch client for an HTTP route tree.
|
|
7
8
|
*
|
|
8
|
-
* @remarks The returned client
|
|
9
|
-
*
|
|
10
|
-
* tree and attaches direct action functions such as `client.users.list(...)`.
|
|
9
|
+
* @remarks The returned client mirrors the resource tree and attaches direct
|
|
10
|
+
* action functions such as `client.users.list(...)`.
|
|
11
11
|
*/
|
|
12
12
|
export function createClient(config) {
|
|
13
13
|
const baseURL = config.baseURL.replace(/\/?$/, '/');
|
|
14
14
|
const defaultHeaders = config.headers && shake(config.headers);
|
|
15
15
|
const fetch = config.fetch ?? globalThis.fetch;
|
|
16
16
|
const responsePlugins = createResponsePluginMap(config.plugins, 'client response');
|
|
17
|
-
|
|
18
|
-
validateClientResponsePlugins(config.routes, responsePlugins);
|
|
19
|
-
}
|
|
17
|
+
validateClientResponsePlugins(config.routes, responsePlugins);
|
|
20
18
|
async function request({ path: pathBuilder, method, args, schema, }) {
|
|
21
19
|
let { path, query, body, headers, ...init } = args;
|
|
22
20
|
if (schema.path) {
|
|
@@ -60,26 +58,48 @@ export function createClient(config) {
|
|
|
60
58
|
headers: (headers ?? defaultHeaders),
|
|
61
59
|
});
|
|
62
60
|
}
|
|
63
|
-
async function json(props) {
|
|
64
|
-
const response = await request(props);
|
|
65
|
-
if (!response.ok) {
|
|
66
|
-
return handleResponseError(response, props);
|
|
67
|
-
}
|
|
68
|
-
return response.json();
|
|
69
|
-
}
|
|
70
61
|
async function response(props) {
|
|
71
62
|
const httpResponse = await request(props);
|
|
63
|
+
const responseSchema = props.schema.response;
|
|
64
|
+
// Handle status-keyed response maps
|
|
65
|
+
if (isResponseMap(responseSchema)) {
|
|
66
|
+
const status = httpResponse.status;
|
|
67
|
+
if (status in responseSchema) {
|
|
68
|
+
const marker = responseSchema[status];
|
|
69
|
+
if (isErrorMarker(marker)) {
|
|
70
|
+
return [await httpResponse.json(), null, status];
|
|
71
|
+
}
|
|
72
|
+
const pluginId = getResponsePluginMarkerId(marker);
|
|
73
|
+
if (pluginId) {
|
|
74
|
+
const plugin = responsePlugins.get(pluginId);
|
|
75
|
+
if (!plugin) {
|
|
76
|
+
throw missingClientResponsePlugin(pluginId);
|
|
77
|
+
}
|
|
78
|
+
return [
|
|
79
|
+
null,
|
|
80
|
+
await plugin.decode(httpResponse, {
|
|
81
|
+
marker: marker,
|
|
82
|
+
request: props,
|
|
83
|
+
}),
|
|
84
|
+
status,
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
return [null, await httpResponse.json(), status];
|
|
88
|
+
}
|
|
89
|
+
// Undeclared status — reject
|
|
90
|
+
return handleResponseError(httpResponse, props);
|
|
91
|
+
}
|
|
72
92
|
if (!httpResponse.ok) {
|
|
73
93
|
return handleResponseError(httpResponse, props);
|
|
74
94
|
}
|
|
75
|
-
const pluginId = getResponsePluginMarkerId(
|
|
95
|
+
const pluginId = getResponsePluginMarkerId(responseSchema);
|
|
76
96
|
if (pluginId) {
|
|
77
97
|
const plugin = responsePlugins.get(pluginId);
|
|
78
98
|
if (!plugin) {
|
|
79
99
|
throw missingClientResponsePlugin(pluginId);
|
|
80
100
|
}
|
|
81
101
|
return plugin.decode(httpResponse, {
|
|
82
|
-
marker:
|
|
102
|
+
marker: responseSchema,
|
|
83
103
|
request: props,
|
|
84
104
|
});
|
|
85
105
|
}
|
|
@@ -97,12 +117,8 @@ export function createClient(config) {
|
|
|
97
117
|
throw error;
|
|
98
118
|
}
|
|
99
119
|
return {
|
|
100
|
-
...(config.routes
|
|
101
|
-
|
|
102
|
-
: null),
|
|
103
|
-
config,
|
|
104
|
-
request,
|
|
105
|
-
json,
|
|
120
|
+
...connectTree(config.routes, '', request, response),
|
|
121
|
+
clientConfig: config,
|
|
106
122
|
};
|
|
107
123
|
}
|
|
108
124
|
function connectTree(tree, prefix, request, response) {
|
|
@@ -133,9 +149,13 @@ function validateClientResponsePlugins(tree, plugins) {
|
|
|
133
149
|
validateClientResponsePlugins(node.children, plugins);
|
|
134
150
|
}
|
|
135
151
|
else {
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
152
|
+
const pluginIds = isResponseMap(node.schema.response)
|
|
153
|
+
? getResponseMapPluginIds(node.schema.response)
|
|
154
|
+
: [getResponsePluginMarkerId(node.schema.response)].filter(pluginId => pluginId !== undefined);
|
|
155
|
+
for (const pluginId of pluginIds) {
|
|
156
|
+
if (!plugins.has(pluginId)) {
|
|
157
|
+
throw missingClientResponsePlugin(pluginId);
|
|
158
|
+
}
|
|
139
159
|
}
|
|
140
160
|
}
|
|
141
161
|
}
|
package/dist/common.d.ts
CHANGED
|
@@ -6,9 +6,19 @@ export type Promisable<T> = T | Promise<T>;
|
|
|
6
6
|
* @remarks Consumers usually use `$type<T>()` instead of constructing this type
|
|
7
7
|
* directly.
|
|
8
8
|
*/
|
|
9
|
-
export type Unchecked<T> = {
|
|
9
|
+
export type Unchecked<T> = Record<number, unknown> & {
|
|
10
10
|
__unchecked__: T;
|
|
11
11
|
};
|
|
12
|
+
/**
|
|
13
|
+
* Compile-time-only marker used by `$error<T>()` to carry a declared error
|
|
14
|
+
* response type through route declarations.
|
|
15
|
+
*
|
|
16
|
+
* @remarks Consumers usually use `$error<T>()` instead of constructing this
|
|
17
|
+
* type directly.
|
|
18
|
+
*/
|
|
19
|
+
export type UncheckedError<T> = {
|
|
20
|
+
__uncheckedError__: T;
|
|
21
|
+
};
|
|
12
22
|
/**
|
|
13
23
|
* Map over all the keys to create a new object.
|
|
14
24
|
*
|
package/dist/http.d.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { RoutePattern } from '@remix-run/route-pattern';
|
|
2
|
-
import type { RouteRequestFactory } from './types/request.js';
|
|
3
2
|
import type { RouteSchema } from './types/schema.js';
|
|
4
3
|
/** HTTP methods supported by Rouzer action declarations. */
|
|
5
4
|
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
@@ -18,8 +17,6 @@ export type HttpAction<P extends string = string, T extends RouteSchema = RouteS
|
|
|
18
17
|
method: M;
|
|
19
18
|
/** Request validation and optional response type schema. */
|
|
20
19
|
schema: T;
|
|
21
|
-
/** Low-level request descriptor factory for this action. */
|
|
22
|
-
request: RouteRequestFactory<T, P>;
|
|
23
20
|
};
|
|
24
21
|
/**
|
|
25
22
|
* Path-scoped namespace in an HTTP route tree.
|
package/dist/http.js
CHANGED
|
@@ -30,14 +30,9 @@ function deleteAction(pathOrSchema, schema) {
|
|
|
30
30
|
}
|
|
31
31
|
export { deleteAction as delete };
|
|
32
32
|
function action(method, pathOrSchema, schema) {
|
|
33
|
-
const path = typeof pathOrSchema === 'string'
|
|
33
|
+
const path = typeof pathOrSchema === 'string'
|
|
34
|
+
? RoutePattern.parse(pathOrSchema)
|
|
35
|
+
: undefined;
|
|
34
36
|
schema ??= typeof pathOrSchema === 'string' ? {} : pathOrSchema;
|
|
35
|
-
|
|
36
|
-
schema,
|
|
37
|
-
path: path ?? RoutePattern.parse(''),
|
|
38
|
-
method,
|
|
39
|
-
args,
|
|
40
|
-
$result: undefined,
|
|
41
|
-
}));
|
|
42
|
-
return { kind: 'action', path, method, schema, request };
|
|
37
|
+
return { kind: 'action', path, method, schema };
|
|
43
38
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { RouteResponseMap, RouteSchema } from './types/schema.js';
|
|
2
|
+
/** Return true when the response schema is a status-keyed response map. */
|
|
3
|
+
export declare function isResponseMap(response: RouteSchema['response']): response is RouteResponseMap;
|
|
4
|
+
/** Return true when the marker represents a declared error response. */
|
|
5
|
+
export declare function isErrorMarker(marker: unknown): boolean;
|
|
6
|
+
/** Return true when the marker represents a success response. */
|
|
7
|
+
export declare function isSuccessMarker(marker: unknown): boolean;
|
|
8
|
+
/** Find the default success status for a direct handler result. */
|
|
9
|
+
export declare function getResponseMapPluginIds(responseMap: RouteResponseMap): string[];
|
|
10
|
+
export declare function getDefaultSuccessStatus(responseMap: RouteResponseMap): number;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { getResponsePluginMarkerId, responsePluginMarker, } from './response.js';
|
|
2
|
+
import { $error } from './type.js';
|
|
3
|
+
/** Return true when the response schema is a status-keyed response map. */
|
|
4
|
+
export function isResponseMap(response) {
|
|
5
|
+
return (typeof response === 'object' &&
|
|
6
|
+
response !== null &&
|
|
7
|
+
!(responsePluginMarker in response));
|
|
8
|
+
}
|
|
9
|
+
/** Return true when the marker represents a declared error response. */
|
|
10
|
+
export function isErrorMarker(marker) {
|
|
11
|
+
return marker === $error.symbol;
|
|
12
|
+
}
|
|
13
|
+
/** Return true when the marker represents a success response. */
|
|
14
|
+
export function isSuccessMarker(marker) {
|
|
15
|
+
return marker !== undefined && !isErrorMarker(marker);
|
|
16
|
+
}
|
|
17
|
+
/** Find the default success status for a direct handler result. */
|
|
18
|
+
export function getResponseMapPluginIds(responseMap) {
|
|
19
|
+
return Object.values(responseMap).flatMap(marker => {
|
|
20
|
+
const pluginId = getResponsePluginMarkerId(marker);
|
|
21
|
+
return pluginId ? [pluginId] : [];
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
export function getDefaultSuccessStatus(responseMap) {
|
|
25
|
+
for (const key of Object.keys(responseMap)) {
|
|
26
|
+
const marker = responseMap[Number(key)];
|
|
27
|
+
if (isSuccessMarker(marker)) {
|
|
28
|
+
return Number(key);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return 200;
|
|
32
|
+
}
|
package/dist/response.d.ts
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import type { Promisable } from './common.js';
|
|
2
|
-
import type {
|
|
2
|
+
import type { RoutePattern } from '@remix-run/route-pattern';
|
|
3
|
+
import type { RouteArgs } from './types/args.js';
|
|
4
|
+
import type { RouteSchema } from './types/schema.js';
|
|
3
5
|
/** Runtime key carried by response plugin markers. */
|
|
4
|
-
export declare const
|
|
6
|
+
export declare const responsePluginMarker: unique symbol;
|
|
5
7
|
/**
|
|
6
8
|
* Compile-time response marker handled by a client/router response plugin pair.
|
|
7
9
|
*
|
|
8
10
|
* @remarks `TClient` is the value returned by generated client action
|
|
9
11
|
* functions. `TRouter` is the non-`Response` value accepted from route handlers.
|
|
12
|
+
* Plugin markers may be used directly as an action response or as success
|
|
13
|
+
* entries in a status-keyed response map.
|
|
10
14
|
*/
|
|
11
|
-
export type ResponsePluginMarker<TClient, TRouter = TClient, TId extends string = string> = {
|
|
12
|
-
readonly [
|
|
15
|
+
export type ResponsePluginMarker<TClient, TRouter = TClient, TId extends string = string> = Record<number, unknown> & {
|
|
16
|
+
readonly [responsePluginMarker]: {
|
|
13
17
|
readonly id: TId;
|
|
14
18
|
readonly client: TClient;
|
|
15
19
|
readonly router: TRouter;
|
|
@@ -22,9 +26,16 @@ export type ClientResponsePlugin = {
|
|
|
22
26
|
/** Decode a successful `Response` into the client action result. */
|
|
23
27
|
decode(response: Response, context: {
|
|
24
28
|
marker: ResponsePluginMarker<any, any>;
|
|
25
|
-
request:
|
|
29
|
+
request: ClientResponsePluginRequest;
|
|
26
30
|
}): Promisable<unknown>;
|
|
27
31
|
};
|
|
32
|
+
/** Request metadata passed to client response plugins. */
|
|
33
|
+
export type ClientResponsePluginRequest = {
|
|
34
|
+
schema: RouteSchema;
|
|
35
|
+
path: RoutePattern;
|
|
36
|
+
method: string;
|
|
37
|
+
args: RouteArgs;
|
|
38
|
+
};
|
|
28
39
|
/** Router-side response plugin used by `createRouter({ plugins })`. */
|
|
29
40
|
export type RouterResponsePlugin = {
|
|
30
41
|
/** Stable response codec id matched against route response markers. */
|
package/dist/response.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/** Runtime key carried by response plugin markers. */
|
|
2
|
-
export const
|
|
2
|
+
export const responsePluginMarker = Symbol.for('rouzer.response-plugin');
|
|
3
3
|
/** Create a response marker for a response plugin. */
|
|
4
4
|
export function createResponsePluginMarker(id) {
|
|
5
5
|
return {
|
|
6
|
-
[
|
|
6
|
+
[responsePluginMarker]: {
|
|
7
7
|
id,
|
|
8
8
|
client: undefined,
|
|
9
9
|
router: undefined,
|
|
@@ -13,14 +13,12 @@ export function createResponsePluginMarker(id) {
|
|
|
13
13
|
/** Get the response plugin id from a plugin marker, if present. */
|
|
14
14
|
export function getResponsePluginMarkerId(value) {
|
|
15
15
|
return isResponsePluginMarker(value)
|
|
16
|
-
? value[
|
|
16
|
+
? value[responsePluginMarker].id
|
|
17
17
|
: undefined;
|
|
18
18
|
}
|
|
19
19
|
/** Return true when a route response marker is handled by a response plugin. */
|
|
20
20
|
export function isResponsePluginMarker(value) {
|
|
21
|
-
return (typeof value === 'object' &&
|
|
22
|
-
value !== null &&
|
|
23
|
-
responsePluginMarkerSymbol in value);
|
|
21
|
+
return (typeof value === 'object' && value !== null && responsePluginMarker in value);
|
|
24
22
|
}
|
|
25
23
|
/** Create a plugin lookup map and reject duplicate plugin ids. */
|
|
26
24
|
export function createResponsePluginMap(plugins = [], label = 'response') {
|
package/dist/server/router.js
CHANGED
|
@@ -4,6 +4,7 @@ import { chain, MiddlewareChain, } from 'alien-middleware';
|
|
|
4
4
|
import * as z from 'zod';
|
|
5
5
|
import { mapValues } from '../common.js';
|
|
6
6
|
import { createResponsePluginMap, getResponsePluginMarkerId, } from '../response.js';
|
|
7
|
+
import { getDefaultSuccessStatus, getResponseMapPluginIds, isErrorMarker, isResponseMap, } from '../response-map.js';
|
|
7
8
|
export { chain };
|
|
8
9
|
// Internal prototype for the router instance.
|
|
9
10
|
class RouterObject extends MiddlewareChain {
|
|
@@ -109,6 +110,11 @@ class RouterObject extends MiddlewareChain {
|
|
|
109
110
|
return httpClientError(error, 'Invalid request body', config);
|
|
110
111
|
}
|
|
111
112
|
}
|
|
113
|
+
if (isResponseMap(schema.response)) {
|
|
114
|
+
;
|
|
115
|
+
context.error = createResponseHelper(schema.response, request, responsePlugins, true);
|
|
116
|
+
context.success = createResponseHelper(schema.response, request, responsePlugins, false);
|
|
117
|
+
}
|
|
112
118
|
const result = await handler(context);
|
|
113
119
|
addDebugHeaders?.(context, route);
|
|
114
120
|
if (result instanceof Response) {
|
|
@@ -125,6 +131,10 @@ class RouterObject extends MiddlewareChain {
|
|
|
125
131
|
request,
|
|
126
132
|
});
|
|
127
133
|
}
|
|
134
|
+
if (isResponseMap(schema.response)) {
|
|
135
|
+
const status = getDefaultSuccessStatus(schema.response);
|
|
136
|
+
return encodeResponseMapResult(schema.response, status, result, request, responsePlugins);
|
|
137
|
+
}
|
|
128
138
|
return Response.json(result);
|
|
129
139
|
}
|
|
130
140
|
});
|
|
@@ -169,9 +179,13 @@ function flattenRoutes(tree, handlers, prefix, debug) {
|
|
|
169
179
|
}
|
|
170
180
|
function validateRouterResponsePlugins(routes, plugins) {
|
|
171
181
|
for (const route of routes) {
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
182
|
+
const pluginIds = isResponseMap(route.schema.response)
|
|
183
|
+
? getResponseMapPluginIds(route.schema.response)
|
|
184
|
+
: [getResponsePluginMarkerId(route.schema.response)].filter(pluginId => pluginId !== undefined);
|
|
185
|
+
for (const pluginId of pluginIds) {
|
|
186
|
+
if (!plugins.has(pluginId)) {
|
|
187
|
+
throw missingRouterResponsePlugin(pluginId);
|
|
188
|
+
}
|
|
175
189
|
}
|
|
176
190
|
}
|
|
177
191
|
}
|
|
@@ -278,3 +292,39 @@ function createOriginPattern(origin) {
|
|
|
278
292
|
}
|
|
279
293
|
return new ExactPattern(origin);
|
|
280
294
|
}
|
|
295
|
+
/** Create `ctx.error(status, body)` or `ctx.success(status, body)`. */
|
|
296
|
+
function createResponseHelper(responseMap, request, responsePlugins, error) {
|
|
297
|
+
return (status, body) => {
|
|
298
|
+
const marker = responseMap[status];
|
|
299
|
+
if (!marker || isErrorMarker(marker) !== error) {
|
|
300
|
+
throw new Error(`Undeclared ${error ? 'error' : 'success'} response status: ${status}`);
|
|
301
|
+
}
|
|
302
|
+
return encodeResponseMapResult(responseMap, status, body, request, responsePlugins);
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
async function encodeResponseMapResult(responseMap, status, value, request, responsePlugins) {
|
|
306
|
+
const marker = responseMap[status];
|
|
307
|
+
if (!marker) {
|
|
308
|
+
throw new Error(`Undeclared response status: ${status}`);
|
|
309
|
+
}
|
|
310
|
+
if (isErrorMarker(marker)) {
|
|
311
|
+
return Response.json(value, { status });
|
|
312
|
+
}
|
|
313
|
+
const pluginId = getResponsePluginMarkerId(marker);
|
|
314
|
+
if (!pluginId) {
|
|
315
|
+
return Response.json(value, { status });
|
|
316
|
+
}
|
|
317
|
+
const plugin = responsePlugins.get(pluginId);
|
|
318
|
+
if (!plugin) {
|
|
319
|
+
throw missingRouterResponsePlugin(pluginId);
|
|
320
|
+
}
|
|
321
|
+
const response = await plugin.encode(value, {
|
|
322
|
+
marker: marker,
|
|
323
|
+
request,
|
|
324
|
+
});
|
|
325
|
+
return new Response(response.body, {
|
|
326
|
+
status,
|
|
327
|
+
statusText: response.statusText,
|
|
328
|
+
headers: response.headers,
|
|
329
|
+
});
|
|
330
|
+
}
|
package/dist/type.d.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import type { Unchecked } from './common.js';
|
|
1
|
+
import type { Unchecked, UncheckedError } from './common.js';
|
|
2
2
|
/**
|
|
3
3
|
* Create a compile-time-only marker for an action's JSON response payload type.
|
|
4
4
|
*
|
|
5
|
-
* @remarks `$type<T>()` does not
|
|
6
|
-
* server handler return values and client action
|
|
7
|
-
* whose responses are expected to be JSON.
|
|
5
|
+
* @remarks `$type<T>()` does not validate handler return values at the server
|
|
6
|
+
* boundary. It lets Rouzer type server handler return values and client action
|
|
7
|
+
* functions for HTTP actions whose responses are expected to be JSON. Use it
|
|
8
|
+
* directly as `response` for one JSON success shape, or as a success entry in a
|
|
9
|
+
* status-keyed response map. Validate response data where it enters your server
|
|
10
|
+
* or client code when runtime integrity is required.
|
|
8
11
|
*
|
|
9
12
|
* @example
|
|
10
13
|
* ```ts
|
|
@@ -20,3 +23,29 @@ export declare function $type<T>(): Unchecked<T>;
|
|
|
20
23
|
export declare namespace $type {
|
|
21
24
|
var symbol: symbol;
|
|
22
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Create a compile-time-only marker for a declared error response type.
|
|
28
|
+
*
|
|
29
|
+
* @remarks `$error<T>()` marks a non-success response branch in a status-keyed
|
|
30
|
+
* response map. It is a type contract, not a runtime validator. On the server,
|
|
31
|
+
* handlers use `ctx.error(status, body)` to return declared errors. On the
|
|
32
|
+
* client, declared error responses resolve as `[error, null, status]` tuple
|
|
33
|
+
* entries instead of rejecting the promise.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```ts
|
|
37
|
+
* import { $type, $error } from 'rouzer'
|
|
38
|
+
* import * as http from 'rouzer/http'
|
|
39
|
+
*
|
|
40
|
+
* const getUser = http.get('users/:id', {
|
|
41
|
+
* response: {
|
|
42
|
+
* 200: $type<User>(),
|
|
43
|
+
* 404: $error<{ code: string; message: string }>(),
|
|
44
|
+
* },
|
|
45
|
+
* })
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export declare function $error<T>(): UncheckedError<T>;
|
|
49
|
+
export declare namespace $error {
|
|
50
|
+
var symbol: symbol;
|
|
51
|
+
}
|