rouzer 5.1.1 → 5.2.1
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 +28 -0
- package/dist/client/index.d.ts +43 -0
- package/dist/client/index.js +111 -10
- package/dist/ndjson.d.ts +10 -3
- package/dist/ndjson.js +53 -7
- package/docs/context.md +54 -0
- package/examples/ndjson-stream.ts +49 -2
- package/package.json +11 -9
package/README.md
CHANGED
|
@@ -181,6 +181,28 @@ await client.upload(file, { headers: { 'content-type': file.type } })
|
|
|
181
181
|
Server handlers for raw-body routes read from `ctx.request` directly with Fetch
|
|
182
182
|
APIs such as `arrayBuffer()`, `blob()`, `formData()`, or `text()`.
|
|
183
183
|
|
|
184
|
+
### Client lifecycle hooks
|
|
185
|
+
|
|
186
|
+
Pass `clientHook` to observe generated client action calls without wrapping the
|
|
187
|
+
client tree:
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
const client = createClient({
|
|
191
|
+
baseURL: 'https://example.com/api/',
|
|
192
|
+
routes,
|
|
193
|
+
clientHook(event) {
|
|
194
|
+
if (event.type === 'request.success') {
|
|
195
|
+
console.log(event.routeName, event.durationMs)
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
})
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Rouzer emits `request.start` before client-side validation, then
|
|
202
|
+
`request.success` when the action resolves or `request.error` when it rejects.
|
|
203
|
+
Terminal events include the parsed response or thrown error plus `durationMs`.
|
|
204
|
+
Hook errors are swallowed.
|
|
205
|
+
|
|
184
206
|
### NDJSON response streams
|
|
185
207
|
|
|
186
208
|
Use `response: ndjson.$type<T>()` for endpoints that stream
|
|
@@ -215,6 +237,12 @@ for await (const event of await client.events()) {
|
|
|
215
237
|
}
|
|
216
238
|
```
|
|
217
239
|
|
|
240
|
+
If a client aborts the request signal or stops iteration early by breaking from
|
|
241
|
+
`for await` or calling the iterator's `return()`, Rouzer cancels the response
|
|
242
|
+
body and calls the server source iterator's `return()`. Sources that wait for
|
|
243
|
+
future events should make those waits abort-aware when they need cleanup to run
|
|
244
|
+
while an awaited operation is still pending.
|
|
245
|
+
|
|
218
246
|
## Documentation
|
|
219
247
|
|
|
220
248
|
- [Concepts, API selection, v5 client input notes, and migration notes](docs/context.md)
|
package/dist/client/index.d.ts
CHANGED
|
@@ -5,6 +5,37 @@ import type { RouteFetchOptions, RouteInput, RouteOptions } from '../types/args.
|
|
|
5
5
|
import type { RawBodySchema } from '../types/schema.js';
|
|
6
6
|
import type { InferRouteResponse } from '../types/response.js';
|
|
7
7
|
import type { RouteSchema } from '../types/schema.js';
|
|
8
|
+
/** Lifecycle event emitted by generated client action functions. */
|
|
9
|
+
export type RouzerClientHookEvent = {
|
|
10
|
+
type: 'request.start';
|
|
11
|
+
opId: string;
|
|
12
|
+
routeName: string;
|
|
13
|
+
method: string;
|
|
14
|
+
pathPattern: string;
|
|
15
|
+
payload: unknown;
|
|
16
|
+
} | {
|
|
17
|
+
type: 'request.success';
|
|
18
|
+
opId: string;
|
|
19
|
+
routeName: string;
|
|
20
|
+
method: string;
|
|
21
|
+
pathPattern: string;
|
|
22
|
+
payload: unknown;
|
|
23
|
+
response: unknown;
|
|
24
|
+
status?: number;
|
|
25
|
+
durationMs: number;
|
|
26
|
+
} | {
|
|
27
|
+
type: 'request.error';
|
|
28
|
+
opId: string;
|
|
29
|
+
routeName: string;
|
|
30
|
+
method: string;
|
|
31
|
+
pathPattern: string;
|
|
32
|
+
payload: unknown;
|
|
33
|
+
error: unknown;
|
|
34
|
+
status?: number;
|
|
35
|
+
durationMs: number;
|
|
36
|
+
};
|
|
37
|
+
/** Best-effort observer for generated client action lifecycles. */
|
|
38
|
+
export type RouzerClientHook = (event: RouzerClientHookEvent) => void;
|
|
8
39
|
/** Client type inferred from an HTTP route tree passed to `createClient`. */
|
|
9
40
|
export type RouzerClient<TRoutes extends HttpRouteTree = Record<string, never>> = ReturnType<typeof createClient<TRoutes>>;
|
|
10
41
|
/**
|
|
@@ -51,6 +82,12 @@ export declare function createClient<TRoutes extends HttpRouteTree = Record<stri
|
|
|
51
82
|
onJsonError?: (response: Response) => Promisable<unknown>;
|
|
52
83
|
/** Custom `fetch` implementation to use for requests. */
|
|
53
84
|
fetch?: typeof globalThis.fetch;
|
|
85
|
+
/**
|
|
86
|
+
* Best-effort lifecycle observer for generated client action calls.
|
|
87
|
+
*
|
|
88
|
+
* @remarks Hook errors are swallowed and never change request behavior.
|
|
89
|
+
*/
|
|
90
|
+
clientHook?: RouzerClientHook;
|
|
54
91
|
}): ClientTree<TRoutes, ""> & {
|
|
55
92
|
clientConfig: {
|
|
56
93
|
/**
|
|
@@ -90,6 +127,12 @@ export declare function createClient<TRoutes extends HttpRouteTree = Record<stri
|
|
|
90
127
|
onJsonError?: (response: Response) => Promisable<unknown>;
|
|
91
128
|
/** Custom `fetch` implementation to use for requests. */
|
|
92
129
|
fetch?: typeof globalThis.fetch;
|
|
130
|
+
/**
|
|
131
|
+
* Best-effort lifecycle observer for generated client action calls.
|
|
132
|
+
*
|
|
133
|
+
* @remarks Hook errors are swallowed and never change request behavior.
|
|
134
|
+
*/
|
|
135
|
+
clientHook?: RouzerClientHook;
|
|
93
136
|
};
|
|
94
137
|
};
|
|
95
138
|
type Join<A extends string, B extends string> = A extends '' ? B : B extends '' ? A : `${A}/${B}`;
|
package/dist/client/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { shake } from '../common.js';
|
|
|
4
4
|
import { isRawBodySchema, } from '../http.js';
|
|
5
5
|
import { getResponseMapPluginIds, isErrorMarker, isResponseMap, } from '../response-map.js';
|
|
6
6
|
import { createResponsePluginMap, getResponsePluginMarkerId, } from '../response.js';
|
|
7
|
+
let nextClientOpId = 0;
|
|
7
8
|
/**
|
|
8
9
|
* Create a typed fetch client for an HTTP route tree.
|
|
9
10
|
*
|
|
@@ -16,7 +17,10 @@ export function createClient(config) {
|
|
|
16
17
|
const fetch = config.fetch ?? globalThis.fetch;
|
|
17
18
|
const responsePlugins = createResponsePluginMap(config.plugins, 'client response');
|
|
18
19
|
validateClientResponsePlugins(config.routes, responsePlugins);
|
|
19
|
-
async function plainRequest(
|
|
20
|
+
async function plainRequest(props) {
|
|
21
|
+
const { path: pathPattern, method, input = {}, schema } = props;
|
|
22
|
+
const { body: rawBody, ...options } = props.options ?? {};
|
|
23
|
+
let { headers, ...init } = options;
|
|
20
24
|
const path = schema.path
|
|
21
25
|
? schema.path.parse(pickObjectSchemaFields(schema.path, input))
|
|
22
26
|
: input;
|
|
@@ -48,7 +52,7 @@ export function createClient(config) {
|
|
|
48
52
|
if (schema.headers) {
|
|
49
53
|
headers = schema.headers.parse(headers);
|
|
50
54
|
}
|
|
51
|
-
return fetch(url, {
|
|
55
|
+
return (await fetch(url, {
|
|
52
56
|
...init,
|
|
53
57
|
method,
|
|
54
58
|
body: isRawBodySchema(schema.body)
|
|
@@ -57,10 +61,9 @@ export function createClient(config) {
|
|
|
57
61
|
? JSON.stringify(body)
|
|
58
62
|
: undefined,
|
|
59
63
|
headers: (headers ?? defaultHeaders),
|
|
60
|
-
});
|
|
64
|
+
}));
|
|
61
65
|
}
|
|
62
|
-
async function
|
|
63
|
-
const response = await plainRequest(props);
|
|
66
|
+
async function parseResponse(response, props) {
|
|
64
67
|
const responseSchema = props.schema.response;
|
|
65
68
|
// Handle status-keyed response maps
|
|
66
69
|
if (isResponseMap(responseSchema)) {
|
|
@@ -106,6 +109,25 @@ export function createClient(config) {
|
|
|
106
109
|
}
|
|
107
110
|
return response.json();
|
|
108
111
|
}
|
|
112
|
+
async function plainClientRequest(props) {
|
|
113
|
+
const response = await plainRequest(props);
|
|
114
|
+
return {
|
|
115
|
+
value: response,
|
|
116
|
+
status: response.status,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
async function parsedClientRequest(props) {
|
|
120
|
+
const response = await plainRequest(props);
|
|
121
|
+
try {
|
|
122
|
+
return {
|
|
123
|
+
value: await parseResponse(response, props),
|
|
124
|
+
status: response.status,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
throw new ClientRequestFailure(error, response.status);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
109
131
|
async function handleResponseError(response, props) {
|
|
110
132
|
if (config.onJsonError) {
|
|
111
133
|
return config.onJsonError(response);
|
|
@@ -118,39 +140,118 @@ export function createClient(config) {
|
|
|
118
140
|
throw error;
|
|
119
141
|
}
|
|
120
142
|
return {
|
|
121
|
-
...connectTree(config.routes, '',
|
|
143
|
+
...connectTree(config.routes, '', '', plainClientRequest, parsedClientRequest, config.clientHook),
|
|
122
144
|
clientConfig: config,
|
|
123
145
|
};
|
|
124
146
|
}
|
|
125
|
-
|
|
147
|
+
class ClientRequestFailure {
|
|
148
|
+
error;
|
|
149
|
+
status;
|
|
150
|
+
constructor(error, status) {
|
|
151
|
+
this.error = error;
|
|
152
|
+
this.status = status;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function connectTree(tree, prefix, namePrefix, plainRequest, parsedRequest, clientHook) {
|
|
126
156
|
return Object.fromEntries(Object.entries(tree).map(([key, node]) => {
|
|
127
157
|
if (node.kind === 'resource') {
|
|
128
158
|
return [
|
|
129
159
|
key,
|
|
130
|
-
connectTree(node.children, joinPaths(prefix, node.path.source), plainRequest, parsedRequest),
|
|
160
|
+
connectTree(node.children, joinPaths(prefix, node.path.source), joinNames(namePrefix, key), plainRequest, parsedRequest, clientHook),
|
|
131
161
|
];
|
|
132
162
|
}
|
|
133
163
|
const path = RoutePattern.parse(joinPaths(prefix, node.path?.source ?? ''));
|
|
134
164
|
const fetch = node.schema.response ? parsedRequest : plainRequest;
|
|
165
|
+
const routeName = joinNames(namePrefix, key);
|
|
135
166
|
return [
|
|
136
167
|
key,
|
|
137
168
|
(input, options) => {
|
|
169
|
+
const payload = input;
|
|
138
170
|
if (isRawBodySchema(node.schema.body) && !hasRouteInput(node, path)) {
|
|
139
171
|
options = { ...options, body: input };
|
|
140
172
|
input = undefined;
|
|
141
173
|
}
|
|
142
|
-
return
|
|
174
|
+
return runClientRequest({
|
|
143
175
|
schema: node.schema,
|
|
144
176
|
path,
|
|
177
|
+
routeName,
|
|
145
178
|
method: node.method,
|
|
146
179
|
input,
|
|
180
|
+
payload,
|
|
147
181
|
options,
|
|
148
182
|
$result: undefined,
|
|
149
|
-
});
|
|
183
|
+
}, fetch, clientHook);
|
|
150
184
|
},
|
|
151
185
|
];
|
|
152
186
|
}));
|
|
153
187
|
}
|
|
188
|
+
async function runClientRequest(request, fetch, clientHook) {
|
|
189
|
+
if (!clientHook) {
|
|
190
|
+
try {
|
|
191
|
+
return (await fetch(request)).value;
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
throw getClientRequestFailure(error)?.error ?? error;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const opId = createClientOpId();
|
|
198
|
+
const startTime = Date.now();
|
|
199
|
+
const baseEvent = {
|
|
200
|
+
opId,
|
|
201
|
+
routeName: request.routeName,
|
|
202
|
+
method: request.method,
|
|
203
|
+
pathPattern: request.path.source,
|
|
204
|
+
payload: request.payload,
|
|
205
|
+
};
|
|
206
|
+
emitClientHook(clientHook, {
|
|
207
|
+
type: 'request.start',
|
|
208
|
+
...baseEvent,
|
|
209
|
+
});
|
|
210
|
+
try {
|
|
211
|
+
const result = await fetch(request);
|
|
212
|
+
emitClientHook(clientHook, {
|
|
213
|
+
type: 'request.success',
|
|
214
|
+
...baseEvent,
|
|
215
|
+
response: result.value,
|
|
216
|
+
...clientRequestStatus(result.status),
|
|
217
|
+
durationMs: Date.now() - startTime,
|
|
218
|
+
});
|
|
219
|
+
return result.value;
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
const failure = getClientRequestFailure(error);
|
|
223
|
+
const eventError = failure ? failure.error : error;
|
|
224
|
+
emitClientHook(clientHook, {
|
|
225
|
+
type: 'request.error',
|
|
226
|
+
...baseEvent,
|
|
227
|
+
error: eventError,
|
|
228
|
+
...clientRequestStatus(failure?.status),
|
|
229
|
+
durationMs: Date.now() - startTime,
|
|
230
|
+
});
|
|
231
|
+
throw eventError;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
function emitClientHook(clientHook, event) {
|
|
235
|
+
try {
|
|
236
|
+
clientHook(event);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// Lifecycle hooks are observability-only and must not affect requests.
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function createClientOpId() {
|
|
243
|
+
nextClientOpId += 1;
|
|
244
|
+
return `rouzer:${Date.now().toString(36)}:${nextClientOpId.toString(36)}`;
|
|
245
|
+
}
|
|
246
|
+
function getClientRequestFailure(error) {
|
|
247
|
+
return error instanceof ClientRequestFailure ? error : undefined;
|
|
248
|
+
}
|
|
249
|
+
function clientRequestStatus(status) {
|
|
250
|
+
return status === undefined ? {} : { status };
|
|
251
|
+
}
|
|
252
|
+
function joinNames(left, right) {
|
|
253
|
+
return [left, right].filter(Boolean).join('.');
|
|
254
|
+
}
|
|
154
255
|
function validateClientResponsePlugins(tree, plugins) {
|
|
155
256
|
for (const node of Object.values(tree)) {
|
|
156
257
|
if (node.kind === 'resource') {
|
package/dist/ndjson.d.ts
CHANGED
|
@@ -2,6 +2,11 @@ import { type ClientResponsePlugin, type ResponsePluginMarker, type RouterRespon
|
|
|
2
2
|
declare const codecId = "rouzer/ndjson";
|
|
3
3
|
/** Values accepted by Rouzer's NDJSON response encoder. */
|
|
4
4
|
export type NdjsonSource<T = unknown> = Iterable<T> | AsyncIterable<T>;
|
|
5
|
+
/** Options for Rouzer's NDJSON response encoder. */
|
|
6
|
+
export type NdjsonEncodeOptions = {
|
|
7
|
+
/** Signal whose abort cancels the source iterator and closes the stream. */
|
|
8
|
+
signal?: AbortSignal;
|
|
9
|
+
};
|
|
5
10
|
/**
|
|
6
11
|
* Create a compile-time marker for newline-delimited JSON response items.
|
|
7
12
|
*
|
|
@@ -32,9 +37,11 @@ export declare const routerPlugin: RouterResponsePlugin;
|
|
|
32
37
|
*
|
|
33
38
|
* @remarks Each yielded value is serialized with `JSON.stringify` and followed
|
|
34
39
|
* by `\n`. Values that cannot be represented as a JSON text, such as
|
|
35
|
-
* `undefined`, cause the stream to error when read.
|
|
40
|
+
* `undefined`, cause the stream to error when read. When `options.signal`
|
|
41
|
+
* aborts, the source iterator's `return()` method is called and the stream is
|
|
42
|
+
* closed.
|
|
36
43
|
*/
|
|
37
|
-
export declare function encodeNdjson(source: NdjsonSource): ReadableStream<Uint8Array>;
|
|
44
|
+
export declare function encodeNdjson(source: NdjsonSource, options?: NdjsonEncodeOptions): ReadableStream<Uint8Array>;
|
|
38
45
|
/**
|
|
39
46
|
* Decode a newline-delimited JSON byte stream.
|
|
40
47
|
*
|
|
@@ -50,5 +57,5 @@ export declare function decodeNdjson<T = unknown>(stream: ReadableStream<Uint8Ar
|
|
|
50
57
|
* `content-type: application/x-ndjson; charset=utf-8` unless the caller supplies
|
|
51
58
|
* a content type in `init.headers`.
|
|
52
59
|
*/
|
|
53
|
-
export declare function ndjsonResponse<T>(source: NdjsonSource<T>, init?: ResponseInit): Response;
|
|
60
|
+
export declare function ndjsonResponse<T>(source: NdjsonSource<T>, init?: ResponseInit & NdjsonEncodeOptions): Response;
|
|
54
61
|
export {};
|
package/dist/ndjson.js
CHANGED
|
@@ -36,8 +36,10 @@ export const clientPlugin = {
|
|
|
36
36
|
*/
|
|
37
37
|
export const routerPlugin = {
|
|
38
38
|
id: codecId,
|
|
39
|
-
encode(value) {
|
|
40
|
-
return ndjsonResponse(value
|
|
39
|
+
encode(value, { request }) {
|
|
40
|
+
return ndjsonResponse(value, {
|
|
41
|
+
signal: request.signal,
|
|
42
|
+
});
|
|
41
43
|
},
|
|
42
44
|
};
|
|
43
45
|
/**
|
|
@@ -45,15 +47,58 @@ export const routerPlugin = {
|
|
|
45
47
|
*
|
|
46
48
|
* @remarks Each yielded value is serialized with `JSON.stringify` and followed
|
|
47
49
|
* by `\n`. Values that cannot be represented as a JSON text, such as
|
|
48
|
-
* `undefined`, cause the stream to error when read.
|
|
50
|
+
* `undefined`, cause the stream to error when read. When `options.signal`
|
|
51
|
+
* aborts, the source iterator's `return()` method is called and the stream is
|
|
52
|
+
* closed.
|
|
49
53
|
*/
|
|
50
|
-
export function encodeNdjson(source) {
|
|
54
|
+
export function encodeNdjson(source, options = {}) {
|
|
51
55
|
const iterator = getAsyncIterator(source);
|
|
52
56
|
const encoder = new TextEncoder();
|
|
57
|
+
const { signal } = options;
|
|
58
|
+
let cancelled = false;
|
|
59
|
+
let cleanup;
|
|
60
|
+
let abortHandler;
|
|
61
|
+
function removeAbortHandler() {
|
|
62
|
+
if (signal && abortHandler) {
|
|
63
|
+
signal.removeEventListener('abort', abortHandler);
|
|
64
|
+
abortHandler = undefined;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function cancelIterator(reason) {
|
|
68
|
+
cancelled = true;
|
|
69
|
+
removeAbortHandler();
|
|
70
|
+
cleanup ??= Promise.resolve(iterator.return?.(reason)).then(() => { });
|
|
71
|
+
return cleanup;
|
|
72
|
+
}
|
|
53
73
|
return new ReadableStream({
|
|
74
|
+
start(controller) {
|
|
75
|
+
if (!signal) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
abortHandler = () => {
|
|
79
|
+
void cancelIterator(signal.reason).catch(() => { });
|
|
80
|
+
try {
|
|
81
|
+
controller.close();
|
|
82
|
+
}
|
|
83
|
+
catch { }
|
|
84
|
+
};
|
|
85
|
+
if (signal.aborted) {
|
|
86
|
+
abortHandler();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
signal.addEventListener('abort', abortHandler, { once: true });
|
|
90
|
+
},
|
|
54
91
|
async pull(controller) {
|
|
92
|
+
if (cancelled) {
|
|
93
|
+
controller.close();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
55
96
|
const { done, value } = await iterator.next();
|
|
97
|
+
if (cancelled) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
56
100
|
if (done) {
|
|
101
|
+
removeAbortHandler();
|
|
57
102
|
controller.close();
|
|
58
103
|
return;
|
|
59
104
|
}
|
|
@@ -64,7 +109,7 @@ export function encodeNdjson(source) {
|
|
|
64
109
|
controller.enqueue(encoder.encode(`${line}\n`));
|
|
65
110
|
},
|
|
66
111
|
async cancel(reason) {
|
|
67
|
-
await
|
|
112
|
+
await cancelIterator(reason);
|
|
68
113
|
},
|
|
69
114
|
});
|
|
70
115
|
}
|
|
@@ -118,12 +163,13 @@ export async function* decodeNdjson(stream) {
|
|
|
118
163
|
* a content type in `init.headers`.
|
|
119
164
|
*/
|
|
120
165
|
export function ndjsonResponse(source, init = {}) {
|
|
166
|
+
const { signal, ...responseInit } = init;
|
|
121
167
|
const headers = new Headers(init.headers);
|
|
122
168
|
if (!headers.has('content-type')) {
|
|
123
169
|
headers.set('content-type', 'application/x-ndjson; charset=utf-8');
|
|
124
170
|
}
|
|
125
|
-
return new Response(encodeNdjson(source), {
|
|
126
|
-
...
|
|
171
|
+
return new Response(encodeNdjson(source, { signal }), {
|
|
172
|
+
...responseInit,
|
|
127
173
|
headers,
|
|
128
174
|
});
|
|
129
175
|
}
|
package/docs/context.md
CHANGED
|
@@ -242,6 +242,54 @@ top, and a custom `fetch` implementation can be supplied for tests or non-browse
|
|
|
242
242
|
runtimes. The returned client exposes the original options as `clientConfig`, so
|
|
243
243
|
route actions named `config` remain available as `client.config(...)`.
|
|
244
244
|
|
|
245
|
+
### Client lifecycle hooks
|
|
246
|
+
|
|
247
|
+
`createClient({ clientHook })` observes generated client action calls without
|
|
248
|
+
wrapping the returned client tree:
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
const client = createClient({
|
|
252
|
+
baseURL: 'https://example.com/api/',
|
|
253
|
+
routes,
|
|
254
|
+
clientHook(event) {
|
|
255
|
+
if (event.type === 'request.success') {
|
|
256
|
+
console.log({
|
|
257
|
+
opId: event.opId,
|
|
258
|
+
routeName: event.routeName,
|
|
259
|
+
durationMs: event.durationMs,
|
|
260
|
+
})
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
})
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Rouzer emits:
|
|
267
|
+
|
|
268
|
+
- `request.start` before client-side validation
|
|
269
|
+
- `request.success` when the generated action resolves
|
|
270
|
+
- `request.error` when the generated action rejects
|
|
271
|
+
|
|
272
|
+
Each event includes an opaque per-call `opId`, the generated client route name
|
|
273
|
+
such as `session.create`, the HTTP method, the joined route path pattern, and
|
|
274
|
+
the generated action's first argument as `payload`. Terminal events include
|
|
275
|
+
`durationMs`, either the resolved `response` or thrown `error`, and `status` when
|
|
276
|
+
an HTTP response was received.
|
|
277
|
+
|
|
278
|
+
`request.error` covers client validation failures, transport failures,
|
|
279
|
+
undeclared HTTP errors, JSON parsing failures, response plugin decode failures,
|
|
280
|
+
and rejected `onJsonError` handlers. A declared error response returned as data
|
|
281
|
+
from a response map is a successful generated action and emits
|
|
282
|
+
`request.success`.
|
|
283
|
+
|
|
284
|
+
Lifecycle hooks are best-effort observability only. If `clientHook` throws,
|
|
285
|
+
Rouzer swallows that hook error and preserves the generated action's original
|
|
286
|
+
behavior.
|
|
287
|
+
|
|
288
|
+
For response plugins that return streams, such as NDJSON, `request.success` is
|
|
289
|
+
emitted when the generated action resolves to the stream object. Errors that
|
|
290
|
+
happen later while the caller consumes the stream are outside the first lifecycle
|
|
291
|
+
hook surface.
|
|
292
|
+
|
|
245
293
|
## Lifecycle
|
|
246
294
|
|
|
247
295
|
1. Define shared HTTP actions/resources with `rouzer/http` and Zod schemas.
|
|
@@ -376,6 +424,12 @@ Rouzer's decoder accepts `\n` and `\r\n`, handles UTF-8 chunk boundaries, and
|
|
|
376
424
|
throws a `SyntaxError` with a line number for malformed JSON. If a consumer stops
|
|
377
425
|
reading early, the response body is cancelled.
|
|
378
426
|
|
|
427
|
+
If a client aborts the request signal or stops iteration early by breaking from
|
|
428
|
+
`for await` or calling the iterator's `return()`, Rouzer cancels the response
|
|
429
|
+
body and calls the server source iterator's `return()`. Sources that wait for
|
|
430
|
+
future events should make those waits abort-aware when they need cleanup to run
|
|
431
|
+
while an awaited operation is still pending.
|
|
432
|
+
|
|
379
433
|
Rouzer does not convert handler or generator failures into extra NDJSON items. If
|
|
380
434
|
an async generator throws after the response starts, the response stream errors
|
|
381
435
|
and the client's `for await` loop throws. Model application-level stream errors
|
|
@@ -2,17 +2,34 @@ import type { HattipHandler } from '@hattip/core'
|
|
|
2
2
|
import { createClient, createRouter } from 'rouzer'
|
|
3
3
|
import * as http from 'rouzer/http'
|
|
4
4
|
import * as ndjson from 'rouzer/ndjson'
|
|
5
|
+
import { z } from 'zod'
|
|
5
6
|
|
|
6
7
|
type Event = {
|
|
7
8
|
id: number
|
|
8
9
|
message: string
|
|
9
10
|
}
|
|
10
11
|
|
|
12
|
+
const EventFilter = z.object({
|
|
13
|
+
names: z.array(z.string()),
|
|
14
|
+
where: z.array(
|
|
15
|
+
z.object({
|
|
16
|
+
path: z.string(),
|
|
17
|
+
equals: z.string(),
|
|
18
|
+
})
|
|
19
|
+
),
|
|
20
|
+
})
|
|
21
|
+
|
|
11
22
|
export const events = http.get('events', {
|
|
12
23
|
response: ndjson.$type<Event>(),
|
|
13
24
|
})
|
|
14
25
|
|
|
15
|
-
|
|
26
|
+
// NDJSON responses work for POST routes with ordinary JSON body schemas too.
|
|
27
|
+
export const stream = http.post('events/stream', {
|
|
28
|
+
body: EventFilter,
|
|
29
|
+
response: ndjson.$type<Event>(),
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
export const routes = { events, stream }
|
|
16
33
|
|
|
17
34
|
/**
|
|
18
35
|
* Tiny Hattip adapter used only to keep this example self-contained. Real apps
|
|
@@ -46,6 +63,17 @@ async function collect<T>(source: AsyncIterable<T>) {
|
|
|
46
63
|
return values
|
|
47
64
|
}
|
|
48
65
|
|
|
66
|
+
async function readFirst<T>(source: AsyncIterable<T>) {
|
|
67
|
+
const iterator = source[Symbol.asyncIterator]()
|
|
68
|
+
try {
|
|
69
|
+
return (await iterator.next()).value
|
|
70
|
+
} finally {
|
|
71
|
+
// Closing the client iterator cancels the response body. For Rouzer NDJSON
|
|
72
|
+
// routes, that cancellation reaches the server source iterator's return().
|
|
73
|
+
await iterator.return?.()
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
49
77
|
export async function runNdjsonStreamExample() {
|
|
50
78
|
const handler = createRouter({
|
|
51
79
|
basePath: 'api/',
|
|
@@ -55,6 +83,14 @@ export async function runNdjsonStreamExample() {
|
|
|
55
83
|
yield { id: 1, message: 'ready' }
|
|
56
84
|
yield { id: 2, message: 'done' }
|
|
57
85
|
},
|
|
86
|
+
async *stream({ body }) {
|
|
87
|
+
// The POST body was parsed and validated before the stream starts.
|
|
88
|
+
yield {
|
|
89
|
+
id: 1,
|
|
90
|
+
message: `${body.names[0]} for ${body.where[0]?.equals}`,
|
|
91
|
+
}
|
|
92
|
+
yield { id: 2, message: 'done' }
|
|
93
|
+
},
|
|
58
94
|
})
|
|
59
95
|
|
|
60
96
|
const client = createClient({
|
|
@@ -64,5 +100,16 @@ export async function runNdjsonStreamExample() {
|
|
|
64
100
|
fetch: createLocalFetch(handler),
|
|
65
101
|
})
|
|
66
102
|
|
|
67
|
-
|
|
103
|
+
const allEvents = await collect(await client.events())
|
|
104
|
+
|
|
105
|
+
// This call sends a JSON body, receives an AsyncIterable, and then stops after
|
|
106
|
+
// one event. Request signals can also be used to cancel long-lived streams.
|
|
107
|
+
const firstMatchingEvent = await readFirst(
|
|
108
|
+
await client.stream({
|
|
109
|
+
names: ['session.message'],
|
|
110
|
+
where: [{ path: 'id', equals: 'ses_123' }],
|
|
111
|
+
})
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
return { allEvents, firstMatchingEvent }
|
|
68
115
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rouzer",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.2.1",
|
|
4
|
+
"packageManager": "pnpm@11.5.1",
|
|
4
5
|
"type": "module",
|
|
5
6
|
"exports": {
|
|
6
7
|
".": {
|
|
@@ -16,6 +17,12 @@
|
|
|
16
17
|
"import": "./dist/ndjson.js"
|
|
17
18
|
}
|
|
18
19
|
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "rm -rf dist && tsgo -b tsconfig.json",
|
|
22
|
+
"format": "prettier --write src test",
|
|
23
|
+
"test": "vitest run",
|
|
24
|
+
"prepublishOnly": "pnpm build"
|
|
25
|
+
},
|
|
19
26
|
"peerDependencies": {
|
|
20
27
|
"zod": ">=4"
|
|
21
28
|
},
|
|
@@ -34,7 +41,7 @@
|
|
|
34
41
|
},
|
|
35
42
|
"dependencies": {
|
|
36
43
|
"@hattip/core": "^0.0.49",
|
|
37
|
-
"@remix-run/route-pattern": "^0.
|
|
44
|
+
"@remix-run/route-pattern": "^0.22.1",
|
|
38
45
|
"alien-middleware": "^0.11.6"
|
|
39
46
|
},
|
|
40
47
|
"prettier": "@alloc/prettier-config",
|
|
@@ -49,10 +56,5 @@
|
|
|
49
56
|
"examples",
|
|
50
57
|
"README.md",
|
|
51
58
|
"!*.tsbuildinfo"
|
|
52
|
-
]
|
|
53
|
-
|
|
54
|
-
"build": "rm -rf dist && tsgo -b tsconfig.json",
|
|
55
|
-
"format": "prettier --write src test",
|
|
56
|
-
"test": "vitest run"
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
+
]
|
|
60
|
+
}
|