rouzer 3.0.1 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -5
- package/dist/client/index.d.ts +20 -9
- package/dist/client/index.js +55 -13
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/ndjson.d.ts +54 -0
- package/dist/ndjson.js +161 -0
- package/dist/response.d.ts +47 -0
- package/dist/response.js +35 -0
- package/dist/server/router.d.ts +5 -2
- package/dist/server/router.js +31 -5
- package/dist/type.d.ts +1 -1
- package/dist/types/handler.d.ts +3 -3
- package/dist/types/request.d.ts +1 -1
- package/dist/types/response.d.ts +10 -2
- package/dist/types/schema.d.ts +11 -6
- package/docs/context.md +116 -25
- package/examples/ndjson-stream.ts +68 -0
- package/package.json +6 -2
- package/dist/internal.d.ts +0 -17
- package/dist/internal.js +0 -1
- package/dist/server/types.d.ts +0 -42
- package/dist/server/types.js +0 -1
- package/dist/types.d.ts +0 -140
- package/dist/types.js +0 -1
package/README.md
CHANGED
|
@@ -6,12 +6,14 @@ and Zod validation between a Hattip-compatible server and a typed fetch client.
|
|
|
6
6
|
## What it does
|
|
7
7
|
|
|
8
8
|
A Rouzer HTTP route tree defines URL patterns, named actions, method schemas, and
|
|
9
|
-
optional response types once, then reuses that
|
|
9
|
+
optional JSON or newline-delimited JSON response types once, then reuses that
|
|
10
|
+
contract to:
|
|
10
11
|
|
|
11
12
|
- validate client arguments before `fetch`
|
|
12
13
|
- match and validate server requests before handlers run
|
|
13
14
|
- type handler context from path, query/body, headers, and middleware
|
|
14
15
|
- attach typed client action functions such as `client.profiles.get(...)`
|
|
16
|
+
- parse typed JSON responses and typed NDJSON response streams
|
|
15
17
|
|
|
16
18
|
Rouzer optimizes for shared TypeScript route modules over language-agnostic API
|
|
17
19
|
schemas or generated SDKs.
|
|
@@ -29,8 +31,8 @@ Consider something else if:
|
|
|
29
31
|
|
|
30
32
|
- you need OpenAPI-first workflows, schema files, or generated clients for other
|
|
31
33
|
languages
|
|
32
|
-
- you need runtime response-body validation; `response: $type<T>()`
|
|
33
|
-
compile-time only
|
|
34
|
+
- you need runtime response-body validation; `response: $type<T>()` and
|
|
35
|
+
`response: ndjson.$type<T>()` are compile-time only
|
|
34
36
|
- you want a framework that owns controllers, data loading, rendering, and
|
|
35
37
|
deployment adapters
|
|
36
38
|
- you cannot use ESM or Zod v4+
|
|
@@ -101,10 +103,46 @@ const { message } = await client.hello({
|
|
|
101
103
|
route arguments before `fetch`; server handlers validate matched path, query,
|
|
102
104
|
headers, and JSON bodies before your handler runs.
|
|
103
105
|
|
|
106
|
+
### NDJSON response streams
|
|
107
|
+
|
|
108
|
+
Use `response: ndjson.$type<T>()` for endpoints that stream
|
|
109
|
+
newline-delimited JSON. Add `ndjson.routerPlugin` to the router and
|
|
110
|
+
`ndjson.clientPlugin` to the client. Handlers return an `Iterable<T>` or
|
|
111
|
+
`AsyncIterable<T>`; Rouzer wraps it in an `application/x-ndjson` response.
|
|
112
|
+
Client action functions resolve to an `AsyncIterable<T>`.
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
import { createClient, createRouter } from 'rouzer'
|
|
116
|
+
import * as http from 'rouzer/http'
|
|
117
|
+
import * as ndjson from 'rouzer/ndjson'
|
|
118
|
+
|
|
119
|
+
export const events = http.get('events', {
|
|
120
|
+
response: ndjson.$type<{ id: number; message: string }>(),
|
|
121
|
+
})
|
|
122
|
+
export const routes = { events }
|
|
123
|
+
|
|
124
|
+
createRouter({ plugins: [ndjson.routerPlugin] }).use(routes, {
|
|
125
|
+
async *events() {
|
|
126
|
+
yield { id: 1, message: 'ready' }
|
|
127
|
+
},
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
const client = createClient({
|
|
131
|
+
baseURL: 'https://example.com/api/',
|
|
132
|
+
routes,
|
|
133
|
+
plugins: [ndjson.clientPlugin],
|
|
134
|
+
})
|
|
135
|
+
for await (const event of await client.events()) {
|
|
136
|
+
console.log(event.message)
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
104
140
|
## Documentation
|
|
105
141
|
|
|
106
|
-
- [Concepts, API selection, and v2
|
|
142
|
+
- [Concepts, API selection, and v2->v3 migration notes](docs/context.md)
|
|
107
143
|
- [Runnable shared-route example](examples/basic-usage.ts)
|
|
144
|
+
- [Runnable NDJSON response-stream example](examples/ndjson-stream.ts)
|
|
108
145
|
- Generated declarations in the published package provide the exact signatures
|
|
109
|
-
for every public export, including the `rouzer/http`
|
|
146
|
+
for every public export, including the `rouzer/http` and `rouzer/ndjson`
|
|
147
|
+
subpaths.
|
|
110
148
|
- Public TSDoc in `src/` owns symbol-level behavior and option details.
|
package/dist/client/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Promisable } from '../common.js';
|
|
2
2
|
import type { HttpAction, HttpResource, HttpRouteTree } from '../http.js';
|
|
3
|
+
import { type ClientResponsePlugin } from '../response.js';
|
|
3
4
|
import type { RouteArgs } from '../types/args.js';
|
|
4
5
|
import type { RouteRequest } from '../types/request.js';
|
|
5
6
|
import type { InferRouteResponse } from '../types/response.js';
|
|
@@ -38,13 +39,17 @@ export declare function createClient<TRoutes extends HttpRouteTree = Record<stri
|
|
|
38
39
|
* ```
|
|
39
40
|
*/
|
|
40
41
|
routes?: TRoutes;
|
|
42
|
+
/** Response codec plugins used by generated action functions. */
|
|
43
|
+
plugins?: readonly ClientResponsePlugin[];
|
|
41
44
|
/**
|
|
42
|
-
* Custom handler for non-2xx responses from `.json()
|
|
45
|
+
* Custom handler for non-2xx responses from `.json()` and generated response
|
|
46
|
+
* helpers.
|
|
43
47
|
*
|
|
44
|
-
* @remarks When provided, the return value is returned from
|
|
45
|
-
* Rouzer does not automatically parse a `Response` returned by
|
|
48
|
+
* @remarks When provided, the return value is returned from the response
|
|
49
|
+
* helper as-is; Rouzer does not automatically parse a `Response` returned by
|
|
50
|
+
* this hook.
|
|
46
51
|
*/
|
|
47
|
-
onJsonError?: (response: Response) => Promisable<
|
|
52
|
+
onJsonError?: (response: Response) => Promisable<unknown>;
|
|
48
53
|
/** Custom `fetch` implementation to use for requests. */
|
|
49
54
|
fetch?: typeof globalThis.fetch;
|
|
50
55
|
}): ClientTree<TRoutes, ""> & {
|
|
@@ -73,13 +78,17 @@ export declare function createClient<TRoutes extends HttpRouteTree = Record<stri
|
|
|
73
78
|
* ```
|
|
74
79
|
*/
|
|
75
80
|
routes?: TRoutes;
|
|
81
|
+
/** Response codec plugins used by generated action functions. */
|
|
82
|
+
plugins?: readonly ClientResponsePlugin[];
|
|
76
83
|
/**
|
|
77
|
-
* Custom handler for non-2xx responses from `.json()
|
|
84
|
+
* Custom handler for non-2xx responses from `.json()` and generated response
|
|
85
|
+
* helpers.
|
|
78
86
|
*
|
|
79
|
-
* @remarks When provided, the return value is returned from
|
|
80
|
-
* Rouzer does not automatically parse a `Response` returned by
|
|
87
|
+
* @remarks When provided, the return value is returned from the response
|
|
88
|
+
* helper as-is; Rouzer does not automatically parse a `Response` returned by
|
|
89
|
+
* this hook.
|
|
81
90
|
*/
|
|
82
|
-
onJsonError?: (response: Response) => Promisable<
|
|
91
|
+
onJsonError?: (response: Response) => Promisable<unknown>;
|
|
83
92
|
/** Custom `fetch` implementation to use for requests. */
|
|
84
93
|
fetch?: typeof globalThis.fetch;
|
|
85
94
|
};
|
|
@@ -97,7 +106,9 @@ export type ClientTree<T extends HttpRouteTree, TPrefix extends string = ''> = {
|
|
|
97
106
|
* Client action function attached for each HTTP action leaf.
|
|
98
107
|
*
|
|
99
108
|
* @remarks Actions whose schema has `response: $type<T>()` return parsed JSON
|
|
100
|
-
* as `T`. Actions
|
|
109
|
+
* as `T`. Actions whose schema has a plugin response marker return the plugin's
|
|
110
|
+
* client result type. Actions without a response marker return the raw
|
|
111
|
+
* `Response`.
|
|
101
112
|
*/
|
|
102
113
|
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 {
|
|
103
114
|
response: any;
|
package/dist/client/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
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
|
+
import { createResponsePluginMap, getResponsePluginMarkerId, } from '../response.js';
|
|
4
5
|
/**
|
|
5
6
|
* Create a typed fetch client for an HTTP route tree.
|
|
6
7
|
*
|
|
@@ -12,6 +13,10 @@ export function createClient(config) {
|
|
|
12
13
|
const baseURL = config.baseURL.replace(/\/?$/, '/');
|
|
13
14
|
const defaultHeaders = config.headers && shake(config.headers);
|
|
14
15
|
const fetch = config.fetch ?? globalThis.fetch;
|
|
16
|
+
const responsePlugins = createResponsePluginMap(config.plugins, 'client response');
|
|
17
|
+
if (config.routes) {
|
|
18
|
+
validateClientResponsePlugins(config.routes, responsePlugins);
|
|
19
|
+
}
|
|
15
20
|
async function request({ path: pathBuilder, method, args, schema, }) {
|
|
16
21
|
let { path, query, body, headers, ...init } = args;
|
|
17
22
|
if (schema.path) {
|
|
@@ -58,37 +63,58 @@ export function createClient(config) {
|
|
|
58
63
|
async function json(props) {
|
|
59
64
|
const response = await request(props);
|
|
60
65
|
if (!response.ok) {
|
|
61
|
-
|
|
62
|
-
return config.onJsonError(response);
|
|
63
|
-
}
|
|
64
|
-
const error = new Error(`Request to ${props.method} ${createHref(props.path, props.args.path)} failed with status ${response.status}`);
|
|
65
|
-
const contentType = response.headers.get('content-type');
|
|
66
|
-
if (contentType?.includes('application/json')) {
|
|
67
|
-
Object.assign(error, await response.json());
|
|
68
|
-
}
|
|
69
|
-
throw error;
|
|
66
|
+
return handleResponseError(response, props);
|
|
70
67
|
}
|
|
71
68
|
return response.json();
|
|
72
69
|
}
|
|
70
|
+
async function response(props) {
|
|
71
|
+
const httpResponse = await request(props);
|
|
72
|
+
if (!httpResponse.ok) {
|
|
73
|
+
return handleResponseError(httpResponse, props);
|
|
74
|
+
}
|
|
75
|
+
const pluginId = getResponsePluginMarkerId(props.schema.response);
|
|
76
|
+
if (pluginId) {
|
|
77
|
+
const plugin = responsePlugins.get(pluginId);
|
|
78
|
+
if (!plugin) {
|
|
79
|
+
throw missingClientResponsePlugin(pluginId);
|
|
80
|
+
}
|
|
81
|
+
return plugin.decode(httpResponse, {
|
|
82
|
+
marker: props.schema.response,
|
|
83
|
+
request: props,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return httpResponse.json();
|
|
87
|
+
}
|
|
88
|
+
async function handleResponseError(response, props) {
|
|
89
|
+
if (config.onJsonError) {
|
|
90
|
+
return config.onJsonError(response);
|
|
91
|
+
}
|
|
92
|
+
const error = new Error(`Request to ${props.method} ${createHref(props.path, props.args.path)} failed with status ${response.status}`);
|
|
93
|
+
const contentType = response.headers.get('content-type');
|
|
94
|
+
if (contentType?.includes('application/json')) {
|
|
95
|
+
Object.assign(error, await response.json());
|
|
96
|
+
}
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
73
99
|
return {
|
|
74
100
|
...(config.routes
|
|
75
|
-
? connectTree(config.routes, '', request,
|
|
101
|
+
? connectTree(config.routes, '', request, response)
|
|
76
102
|
: null),
|
|
77
103
|
config,
|
|
78
104
|
request,
|
|
79
105
|
json,
|
|
80
106
|
};
|
|
81
107
|
}
|
|
82
|
-
function connectTree(tree, prefix, request,
|
|
108
|
+
function connectTree(tree, prefix, request, response) {
|
|
83
109
|
return Object.fromEntries(Object.entries(tree).map(([key, node]) => {
|
|
84
110
|
if (node.kind === 'resource') {
|
|
85
111
|
return [
|
|
86
112
|
key,
|
|
87
|
-
connectTree(node.children, joinPaths(prefix, node.path.source), request,
|
|
113
|
+
connectTree(node.children, joinPaths(prefix, node.path.source), request, response),
|
|
88
114
|
];
|
|
89
115
|
}
|
|
90
116
|
const path = RoutePattern.parse(joinPaths(prefix, node.path?.source ?? ''));
|
|
91
|
-
const fetch = node.schema.response ?
|
|
117
|
+
const fetch = node.schema.response ? response : request;
|
|
92
118
|
return [
|
|
93
119
|
key,
|
|
94
120
|
(args = {}) => fetch({
|
|
@@ -101,6 +127,22 @@ function connectTree(tree, prefix, request, json) {
|
|
|
101
127
|
];
|
|
102
128
|
}));
|
|
103
129
|
}
|
|
130
|
+
function validateClientResponsePlugins(tree, plugins) {
|
|
131
|
+
for (const node of Object.values(tree)) {
|
|
132
|
+
if (node.kind === 'resource') {
|
|
133
|
+
validateClientResponsePlugins(node.children, plugins);
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
const pluginId = getResponsePluginMarkerId(node.schema.response);
|
|
137
|
+
if (pluginId && !plugins.has(pluginId)) {
|
|
138
|
+
throw missingClientResponsePlugin(pluginId);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function missingClientResponsePlugin(pluginId) {
|
|
144
|
+
return new Error(`Missing client response plugin for ${pluginId}`);
|
|
145
|
+
}
|
|
104
146
|
function joinPaths(left, right) {
|
|
105
147
|
return [left, right].filter(Boolean).join('/').replace(/\/+/g, '/');
|
|
106
148
|
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/ndjson.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { type ClientResponsePlugin, type ResponsePluginMarker, type RouterResponsePlugin } from './response.js';
|
|
2
|
+
declare const codecId = "rouzer/ndjson";
|
|
3
|
+
/** Values accepted by Rouzer's NDJSON response encoder. */
|
|
4
|
+
export type NdjsonSource<T = unknown> = Iterable<T> | AsyncIterable<T>;
|
|
5
|
+
/**
|
|
6
|
+
* Create a compile-time marker for newline-delimited JSON response items.
|
|
7
|
+
*
|
|
8
|
+
* @remarks The returned marker is handled by `clientPlugin` in clients and
|
|
9
|
+
* `routerPlugin` in routers. Generated client action functions resolve to an
|
|
10
|
+
* `AsyncIterable<T>`, while route handlers may return either an `Iterable<T>`
|
|
11
|
+
* or an `AsyncIterable<T>`. Rouzer does not validate streamed items at runtime.
|
|
12
|
+
*/
|
|
13
|
+
export declare function $type<T>(): ResponsePluginMarker<AsyncIterable<T>, NdjsonSource<T>, typeof codecId>;
|
|
14
|
+
/**
|
|
15
|
+
* Client plugin that decodes successful NDJSON responses.
|
|
16
|
+
*
|
|
17
|
+
* @remarks Register this plugin with `createClient({ plugins })` when the route
|
|
18
|
+
* tree contains `response: ndjson.$type<T>()` markers.
|
|
19
|
+
*/
|
|
20
|
+
export declare const clientPlugin: ClientResponsePlugin;
|
|
21
|
+
/**
|
|
22
|
+
* Router plugin that encodes handler results as NDJSON responses.
|
|
23
|
+
*
|
|
24
|
+
* @remarks Register this plugin with `createRouter({ plugins })` when the route
|
|
25
|
+
* tree contains `response: ndjson.$type<T>()` markers. Handler or generator
|
|
26
|
+
* errors are not encoded as NDJSON items; model application-level errors in the
|
|
27
|
+
* item type when clients should receive them as data.
|
|
28
|
+
*/
|
|
29
|
+
export declare const routerPlugin: RouterResponsePlugin;
|
|
30
|
+
/**
|
|
31
|
+
* Encode an iterable of values as a newline-delimited JSON byte stream.
|
|
32
|
+
*
|
|
33
|
+
* @remarks Each yielded value is serialized with `JSON.stringify` and followed
|
|
34
|
+
* by `\n`. Values that cannot be represented as a JSON text, such as
|
|
35
|
+
* `undefined`, cause the stream to error when read.
|
|
36
|
+
*/
|
|
37
|
+
export declare function encodeNdjson(source: NdjsonSource): ReadableStream<Uint8Array>;
|
|
38
|
+
/**
|
|
39
|
+
* Decode a newline-delimited JSON byte stream.
|
|
40
|
+
*
|
|
41
|
+
* @remarks UTF-8 chunks may split JSON lines. Both `\n` and `\r\n` line endings
|
|
42
|
+
* are accepted, and a final line does not need a trailing newline. Malformed
|
|
43
|
+
* lines throw a `SyntaxError` that includes the 1-based line number.
|
|
44
|
+
*/
|
|
45
|
+
export declare function decodeNdjson<T = unknown>(stream: ReadableStream<Uint8Array>): AsyncIterable<T>;
|
|
46
|
+
/**
|
|
47
|
+
* Create a `Response` whose body is encoded as newline-delimited JSON.
|
|
48
|
+
*
|
|
49
|
+
* @remarks The response defaults to
|
|
50
|
+
* `content-type: application/x-ndjson; charset=utf-8` unless the caller supplies
|
|
51
|
+
* a content type in `init.headers`.
|
|
52
|
+
*/
|
|
53
|
+
export declare function ndjsonResponse<T>(source: NdjsonSource<T>, init?: ResponseInit): Response;
|
|
54
|
+
export {};
|
package/dist/ndjson.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { createResponsePluginMarker, } from './response.js';
|
|
2
|
+
const codecId = 'rouzer/ndjson';
|
|
3
|
+
/**
|
|
4
|
+
* Create a compile-time marker for newline-delimited JSON response items.
|
|
5
|
+
*
|
|
6
|
+
* @remarks The returned marker is handled by `clientPlugin` in clients and
|
|
7
|
+
* `routerPlugin` in routers. Generated client action functions resolve to an
|
|
8
|
+
* `AsyncIterable<T>`, while route handlers may return either an `Iterable<T>`
|
|
9
|
+
* or an `AsyncIterable<T>`. Rouzer does not validate streamed items at runtime.
|
|
10
|
+
*/
|
|
11
|
+
export function $type() {
|
|
12
|
+
return createResponsePluginMarker(codecId);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Client plugin that decodes successful NDJSON responses.
|
|
16
|
+
*
|
|
17
|
+
* @remarks Register this plugin with `createClient({ plugins })` when the route
|
|
18
|
+
* tree contains `response: ndjson.$type<T>()` markers.
|
|
19
|
+
*/
|
|
20
|
+
export const clientPlugin = {
|
|
21
|
+
id: codecId,
|
|
22
|
+
decode(response) {
|
|
23
|
+
if (!response.body) {
|
|
24
|
+
throw new Error('NDJSON response has no body');
|
|
25
|
+
}
|
|
26
|
+
return decodeNdjson(response.body);
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Router plugin that encodes handler results as NDJSON responses.
|
|
31
|
+
*
|
|
32
|
+
* @remarks Register this plugin with `createRouter({ plugins })` when the route
|
|
33
|
+
* tree contains `response: ndjson.$type<T>()` markers. Handler or generator
|
|
34
|
+
* errors are not encoded as NDJSON items; model application-level errors in the
|
|
35
|
+
* item type when clients should receive them as data.
|
|
36
|
+
*/
|
|
37
|
+
export const routerPlugin = {
|
|
38
|
+
id: codecId,
|
|
39
|
+
encode(value) {
|
|
40
|
+
return ndjsonResponse(value);
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Encode an iterable of values as a newline-delimited JSON byte stream.
|
|
45
|
+
*
|
|
46
|
+
* @remarks Each yielded value is serialized with `JSON.stringify` and followed
|
|
47
|
+
* by `\n`. Values that cannot be represented as a JSON text, such as
|
|
48
|
+
* `undefined`, cause the stream to error when read.
|
|
49
|
+
*/
|
|
50
|
+
export function encodeNdjson(source) {
|
|
51
|
+
const iterator = getAsyncIterator(source);
|
|
52
|
+
const encoder = new TextEncoder();
|
|
53
|
+
return new ReadableStream({
|
|
54
|
+
async pull(controller) {
|
|
55
|
+
const { done, value } = await iterator.next();
|
|
56
|
+
if (done) {
|
|
57
|
+
controller.close();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const line = JSON.stringify(value);
|
|
61
|
+
if (line === undefined) {
|
|
62
|
+
throw new TypeError('NDJSON items must serialize to a JSON text; received undefined');
|
|
63
|
+
}
|
|
64
|
+
controller.enqueue(encoder.encode(`${line}\n`));
|
|
65
|
+
},
|
|
66
|
+
async cancel(reason) {
|
|
67
|
+
await iterator.return?.(reason);
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Decode a newline-delimited JSON byte stream.
|
|
73
|
+
*
|
|
74
|
+
* @remarks UTF-8 chunks may split JSON lines. Both `\n` and `\r\n` line endings
|
|
75
|
+
* are accepted, and a final line does not need a trailing newline. Malformed
|
|
76
|
+
* lines throw a `SyntaxError` that includes the 1-based line number.
|
|
77
|
+
*/
|
|
78
|
+
export async function* decodeNdjson(stream) {
|
|
79
|
+
const reader = stream.getReader();
|
|
80
|
+
const decoder = new TextDecoder();
|
|
81
|
+
let buffer = '';
|
|
82
|
+
let lineNumber = 0;
|
|
83
|
+
let doneReading = false;
|
|
84
|
+
try {
|
|
85
|
+
while (true) {
|
|
86
|
+
const { done, value } = await reader.read();
|
|
87
|
+
if (done) {
|
|
88
|
+
buffer += decoder.decode();
|
|
89
|
+
doneReading = true;
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
buffer += decoder.decode(value, { stream: true });
|
|
93
|
+
let newlineIndex;
|
|
94
|
+
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
|
|
95
|
+
const line = stripCarriageReturn(buffer.slice(0, newlineIndex));
|
|
96
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
97
|
+
lineNumber += 1;
|
|
98
|
+
yield parseNdjsonLine(line, lineNumber);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (buffer.length > 0) {
|
|
102
|
+
lineNumber += 1;
|
|
103
|
+
yield parseNdjsonLine(stripCarriageReturn(buffer), lineNumber);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
if (!doneReading) {
|
|
108
|
+
await reader.cancel().catch(() => { });
|
|
109
|
+
}
|
|
110
|
+
reader.releaseLock();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Create a `Response` whose body is encoded as newline-delimited JSON.
|
|
115
|
+
*
|
|
116
|
+
* @remarks The response defaults to
|
|
117
|
+
* `content-type: application/x-ndjson; charset=utf-8` unless the caller supplies
|
|
118
|
+
* a content type in `init.headers`.
|
|
119
|
+
*/
|
|
120
|
+
export function ndjsonResponse(source, init = {}) {
|
|
121
|
+
const headers = new Headers(init.headers);
|
|
122
|
+
if (!headers.has('content-type')) {
|
|
123
|
+
headers.set('content-type', 'application/x-ndjson; charset=utf-8');
|
|
124
|
+
}
|
|
125
|
+
return new Response(encodeNdjson(source), {
|
|
126
|
+
...init,
|
|
127
|
+
headers,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
function getAsyncIterator(source) {
|
|
131
|
+
const asyncIterator = source[Symbol.asyncIterator]?.();
|
|
132
|
+
if (asyncIterator) {
|
|
133
|
+
return asyncIterator;
|
|
134
|
+
}
|
|
135
|
+
const iterator = source[Symbol.iterator]?.();
|
|
136
|
+
if (iterator) {
|
|
137
|
+
return {
|
|
138
|
+
next() {
|
|
139
|
+
return Promise.resolve(iterator.next());
|
|
140
|
+
},
|
|
141
|
+
async return() {
|
|
142
|
+
iterator.return?.();
|
|
143
|
+
return { done: true, value: undefined };
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
throw new TypeError('NDJSON source must be iterable');
|
|
148
|
+
}
|
|
149
|
+
function stripCarriageReturn(line) {
|
|
150
|
+
return line.endsWith('\r') ? line.slice(0, -1) : line;
|
|
151
|
+
}
|
|
152
|
+
function parseNdjsonLine(line, lineNumber) {
|
|
153
|
+
try {
|
|
154
|
+
return JSON.parse(line);
|
|
155
|
+
}
|
|
156
|
+
catch (cause) {
|
|
157
|
+
const error = new SyntaxError(`Invalid NDJSON at line ${lineNumber}: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
158
|
+
error.cause = cause;
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Promisable } from './common.js';
|
|
2
|
+
import type { RouteRequest } from './types/request.js';
|
|
3
|
+
/** Runtime key carried by response plugin markers. */
|
|
4
|
+
export declare const responsePluginMarkerSymbol: unique symbol;
|
|
5
|
+
/**
|
|
6
|
+
* Compile-time response marker handled by a client/router response plugin pair.
|
|
7
|
+
*
|
|
8
|
+
* @remarks `TClient` is the value returned by generated client action
|
|
9
|
+
* functions. `TRouter` is the non-`Response` value accepted from route handlers.
|
|
10
|
+
*/
|
|
11
|
+
export type ResponsePluginMarker<TClient, TRouter = TClient, TId extends string = string> = {
|
|
12
|
+
readonly [responsePluginMarkerSymbol]: {
|
|
13
|
+
readonly id: TId;
|
|
14
|
+
readonly client: TClient;
|
|
15
|
+
readonly router: TRouter;
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
/** Client-side response plugin used by `createClient({ plugins })`. */
|
|
19
|
+
export type ClientResponsePlugin = {
|
|
20
|
+
/** Stable response codec id matched against route response markers. */
|
|
21
|
+
readonly id: string;
|
|
22
|
+
/** Decode a successful `Response` into the client action result. */
|
|
23
|
+
decode(response: Response, context: {
|
|
24
|
+
marker: ResponsePluginMarker<any, any>;
|
|
25
|
+
request: RouteRequest;
|
|
26
|
+
}): Promisable<unknown>;
|
|
27
|
+
};
|
|
28
|
+
/** Router-side response plugin used by `createRouter({ plugins })`. */
|
|
29
|
+
export type RouterResponsePlugin = {
|
|
30
|
+
/** Stable response codec id matched against route response markers. */
|
|
31
|
+
readonly id: string;
|
|
32
|
+
/** Encode a handler result into the HTTP response. */
|
|
33
|
+
encode(value: unknown, context: {
|
|
34
|
+
marker: ResponsePluginMarker<any, any>;
|
|
35
|
+
request: Request;
|
|
36
|
+
}): Promisable<Response>;
|
|
37
|
+
};
|
|
38
|
+
/** Create a response marker for a response plugin. */
|
|
39
|
+
export declare function createResponsePluginMarker<TClient, TRouter = TClient, const TId extends string = string>(id: TId): ResponsePluginMarker<TClient, TRouter, TId>;
|
|
40
|
+
/** Get the response plugin id from a plugin marker, if present. */
|
|
41
|
+
export declare function getResponsePluginMarkerId(value: unknown): string | undefined;
|
|
42
|
+
/** Return true when a route response marker is handled by a response plugin. */
|
|
43
|
+
export declare function isResponsePluginMarker(value: unknown): value is ResponsePluginMarker<unknown, unknown>;
|
|
44
|
+
/** Create a plugin lookup map and reject duplicate plugin ids. */
|
|
45
|
+
export declare function createResponsePluginMap<TPlugin extends {
|
|
46
|
+
readonly id: string;
|
|
47
|
+
}>(plugins?: readonly TPlugin[], label?: string): Map<string, TPlugin>;
|
package/dist/response.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Runtime key carried by response plugin markers. */
|
|
2
|
+
export const responsePluginMarkerSymbol = Symbol.for('rouzer.response-plugin');
|
|
3
|
+
/** Create a response marker for a response plugin. */
|
|
4
|
+
export function createResponsePluginMarker(id) {
|
|
5
|
+
return {
|
|
6
|
+
[responsePluginMarkerSymbol]: {
|
|
7
|
+
id,
|
|
8
|
+
client: undefined,
|
|
9
|
+
router: undefined,
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/** Get the response plugin id from a plugin marker, if present. */
|
|
14
|
+
export function getResponsePluginMarkerId(value) {
|
|
15
|
+
return isResponsePluginMarker(value)
|
|
16
|
+
? value[responsePluginMarkerSymbol].id
|
|
17
|
+
: undefined;
|
|
18
|
+
}
|
|
19
|
+
/** Return true when a route response marker is handled by a response plugin. */
|
|
20
|
+
export function isResponsePluginMarker(value) {
|
|
21
|
+
return (typeof value === 'object' &&
|
|
22
|
+
value !== null &&
|
|
23
|
+
responsePluginMarkerSymbol in value);
|
|
24
|
+
}
|
|
25
|
+
/** Create a plugin lookup map and reject duplicate plugin ids. */
|
|
26
|
+
export function createResponsePluginMap(plugins = [], label = 'response') {
|
|
27
|
+
const map = new Map();
|
|
28
|
+
for (const plugin of plugins) {
|
|
29
|
+
if (map.has(plugin.id)) {
|
|
30
|
+
throw new Error(`Duplicate ${label} plugin: ${plugin.id}`);
|
|
31
|
+
}
|
|
32
|
+
map.set(plugin.id, plugin);
|
|
33
|
+
}
|
|
34
|
+
return map;
|
|
35
|
+
}
|
package/dist/server/router.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { HattipHandler } from '@hattip/core';
|
|
2
2
|
import { ApplyMiddleware, chain, ExtractMiddleware, MiddlewareChain, MiddlewareTypes } from 'alien-middleware';
|
|
3
3
|
import type { HttpRouteTree } from '../http.js';
|
|
4
|
+
import { type RouterResponsePlugin } from '../response.js';
|
|
4
5
|
import type { RouteRequestHandlerMap } from '../types/server.js';
|
|
5
6
|
export { chain };
|
|
6
7
|
/** Configuration for `createRouter`. */
|
|
@@ -25,6 +26,8 @@ export type RouterConfig = {
|
|
|
25
26
|
* and logs missing route handlers to the console.
|
|
26
27
|
*/
|
|
27
28
|
debug?: boolean;
|
|
29
|
+
/** Response codec plugins used for route handler results. */
|
|
30
|
+
plugins?: readonly RouterResponsePlugin[];
|
|
28
31
|
/** CORS configuration for requests with an `Origin` header. */
|
|
29
32
|
cors?: {
|
|
30
33
|
/**
|
|
@@ -67,8 +70,8 @@ export interface Router<T extends MiddlewareTypes = any> extends HattipHandler<T
|
|
|
67
70
|
/**
|
|
68
71
|
* Create a Rouzer router that can be mounted by any Hattip adapter.
|
|
69
72
|
*
|
|
70
|
-
* @param config Optional router configuration for base path, debug behavior,
|
|
71
|
-
* CORS origin restrictions.
|
|
73
|
+
* @param config Optional router configuration for base path, debug behavior,
|
|
74
|
+
* response plugins, and CORS origin restrictions.
|
|
72
75
|
* @returns A Hattip-compatible handler with `.use(...)` methods for middleware
|
|
73
76
|
* and route registration.
|
|
74
77
|
*/
|
package/dist/server/router.js
CHANGED
|
@@ -3,15 +3,18 @@ import { createMatcher } from '@remix-run/route-pattern/match';
|
|
|
3
3
|
import { chain, MiddlewareChain, } from 'alien-middleware';
|
|
4
4
|
import * as z from 'zod';
|
|
5
5
|
import { mapValues } from '../common.js';
|
|
6
|
+
import { createResponsePluginMap, getResponsePluginMarkerId, } from '../response.js';
|
|
6
7
|
export { chain };
|
|
7
8
|
// Internal prototype for the router instance.
|
|
8
9
|
class RouterObject extends MiddlewareChain {
|
|
9
10
|
config;
|
|
10
11
|
basePath;
|
|
12
|
+
responsePlugins;
|
|
11
13
|
constructor(config) {
|
|
12
14
|
super();
|
|
13
15
|
this.config = config;
|
|
14
16
|
this.basePath = config.basePath?.replace(/\/?$/, '/');
|
|
17
|
+
this.responsePlugins = createResponsePluginMap(config.plugins, 'router response');
|
|
15
18
|
const allowOrigins = config.cors?.allowOrigins?.map(createOriginPattern);
|
|
16
19
|
if (allowOrigins) {
|
|
17
20
|
super.use(((ctx) => {
|
|
@@ -31,14 +34,15 @@ class RouterObject extends MiddlewareChain {
|
|
|
31
34
|
}
|
|
32
35
|
/** @internal */
|
|
33
36
|
useRoutes(routeSchemas, handlers) {
|
|
34
|
-
const { config, basePath } = this;
|
|
37
|
+
const { config, basePath, responsePlugins } = this;
|
|
35
38
|
const routes = flattenRoutes(routeSchemas, handlers, basePath ?? '', config.debug);
|
|
39
|
+
validateRouterResponsePlugins(routes, responsePlugins);
|
|
36
40
|
const addDebugHeaders = config.debug
|
|
37
41
|
? (context, route) => {
|
|
38
42
|
context.setHeader('X-Route-Name', route.name);
|
|
39
43
|
}
|
|
40
44
|
: null;
|
|
41
|
-
return super.use(
|
|
45
|
+
return super.use(async function (context) {
|
|
42
46
|
const request = context.request;
|
|
43
47
|
const origin = request.headers.get('Origin');
|
|
44
48
|
const url = (context.url ??= new URL(request.url));
|
|
@@ -110,16 +114,27 @@ class RouterObject extends MiddlewareChain {
|
|
|
110
114
|
if (result instanceof Response) {
|
|
111
115
|
return result;
|
|
112
116
|
}
|
|
117
|
+
const pluginId = getResponsePluginMarkerId(schema.response);
|
|
118
|
+
if (pluginId) {
|
|
119
|
+
const plugin = responsePlugins.get(pluginId);
|
|
120
|
+
if (!plugin) {
|
|
121
|
+
throw missingRouterResponsePlugin(pluginId);
|
|
122
|
+
}
|
|
123
|
+
return plugin.encode(result, {
|
|
124
|
+
marker: schema.response,
|
|
125
|
+
request,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
113
128
|
return Response.json(result);
|
|
114
129
|
}
|
|
115
|
-
})
|
|
130
|
+
});
|
|
116
131
|
}
|
|
117
132
|
}
|
|
118
133
|
/**
|
|
119
134
|
* Create a Rouzer router that can be mounted by any Hattip adapter.
|
|
120
135
|
*
|
|
121
|
-
* @param config Optional router configuration for base path, debug behavior,
|
|
122
|
-
* CORS origin restrictions.
|
|
136
|
+
* @param config Optional router configuration for base path, debug behavior,
|
|
137
|
+
* response plugins, and CORS origin restrictions.
|
|
123
138
|
* @returns A Hattip-compatible handler with `.use(...)` methods for middleware
|
|
124
139
|
* and route registration.
|
|
125
140
|
*/
|
|
@@ -152,6 +167,17 @@ function flattenRoutes(tree, handlers, prefix, debug) {
|
|
|
152
167
|
}
|
|
153
168
|
return routes;
|
|
154
169
|
}
|
|
170
|
+
function validateRouterResponsePlugins(routes, plugins) {
|
|
171
|
+
for (const route of routes) {
|
|
172
|
+
const pluginId = getResponsePluginMarkerId(route.schema.response);
|
|
173
|
+
if (pluginId && !plugins.has(pluginId)) {
|
|
174
|
+
throw missingRouterResponsePlugin(pluginId);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function missingRouterResponsePlugin(pluginId) {
|
|
179
|
+
return new Error(`Missing router response plugin for ${pluginId}`);
|
|
180
|
+
}
|
|
155
181
|
function joinPaths(left, right) {
|
|
156
182
|
return [left, right].filter(Boolean).join('/').replace(/\/+/g, '/');
|
|
157
183
|
}
|