rouzer 5.2.0 → 5.2.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.
- package/README.md +6 -0
- package/dist/ndjson.d.ts +10 -3
- package/dist/ndjson.js +183 -64
- package/docs/context.md +6 -0
- package/examples/ndjson-stream.ts +49 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -237,6 +237,12 @@ for await (const event of await client.events()) {
|
|
|
237
237
|
}
|
|
238
238
|
```
|
|
239
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
|
+
|
|
240
246
|
## Documentation
|
|
241
247
|
|
|
242
248
|
- [Concepts, API selection, v5 client input notes, and migration notes](docs/context.md)
|
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>, { signal, ...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,28 +47,12 @@ 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) {
|
|
51
|
-
|
|
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
|
-
});
|
|
54
|
+
export function encodeNdjson(source, options = {}) {
|
|
55
|
+
return new ReadableStream(new NdjsonEncoder(source, options));
|
|
70
56
|
}
|
|
71
57
|
/**
|
|
72
58
|
* Decode a newline-delimited JSON byte stream.
|
|
@@ -75,39 +61,175 @@ export function encodeNdjson(source) {
|
|
|
75
61
|
* are accepted, and a final line does not need a trailing newline. Malformed
|
|
76
62
|
* lines throw a `SyntaxError` that includes the 1-based line number.
|
|
77
63
|
*/
|
|
78
|
-
export
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
64
|
+
export function decodeNdjson(stream) {
|
|
65
|
+
return new NdjsonDecoder(stream);
|
|
66
|
+
}
|
|
67
|
+
class NdjsonEncoder {
|
|
68
|
+
options;
|
|
69
|
+
iterator;
|
|
70
|
+
encoder = new TextEncoder();
|
|
71
|
+
cancelled = false;
|
|
72
|
+
cleanup;
|
|
73
|
+
abortHandler;
|
|
74
|
+
constructor(source, options) {
|
|
75
|
+
this.options = options;
|
|
76
|
+
this.iterator = getAsyncIterator(source);
|
|
77
|
+
}
|
|
78
|
+
start(controller) {
|
|
79
|
+
const { signal } = this.options;
|
|
80
|
+
if (signal) {
|
|
81
|
+
this.abortHandler = () => {
|
|
82
|
+
void this.cancel(signal.reason).catch(() => { });
|
|
83
|
+
try {
|
|
84
|
+
controller.close();
|
|
85
|
+
}
|
|
86
|
+
catch { }
|
|
87
|
+
};
|
|
88
|
+
if (signal.aborted) {
|
|
89
|
+
this.abortHandler();
|
|
91
90
|
}
|
|
92
|
-
|
|
93
|
-
|
|
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);
|
|
91
|
+
else {
|
|
92
|
+
signal.addEventListener('abort', this.abortHandler, { once: true });
|
|
99
93
|
}
|
|
100
94
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
95
|
+
}
|
|
96
|
+
async pull(controller) {
|
|
97
|
+
if (this.cancelled) {
|
|
98
|
+
controller.close();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const { done, value } = await this.iterator.next();
|
|
102
|
+
if (this.cancelled) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (done) {
|
|
106
|
+
this.removeAbortHandler();
|
|
107
|
+
controller.close();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const line = JSON.stringify(value);
|
|
111
|
+
if (line === undefined) {
|
|
112
|
+
throw new TypeError('NDJSON items must serialize to a JSON text; received undefined');
|
|
113
|
+
}
|
|
114
|
+
controller.enqueue(this.encoder.encode(`${line}\n`));
|
|
115
|
+
}
|
|
116
|
+
async cancel(reason) {
|
|
117
|
+
if (!this.cancelled) {
|
|
118
|
+
this.cancelled = true;
|
|
119
|
+
this.removeAbortHandler();
|
|
120
|
+
this.cleanup ??= Promise.resolve(this.iterator.return?.(reason)).then(() => { });
|
|
121
|
+
}
|
|
122
|
+
await this.cleanup;
|
|
123
|
+
}
|
|
124
|
+
removeAbortHandler() {
|
|
125
|
+
const { signal } = this.options;
|
|
126
|
+
if (signal && this.abortHandler) {
|
|
127
|
+
signal.removeEventListener('abort', this.abortHandler);
|
|
128
|
+
this.abortHandler = undefined;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
class NdjsonDecoder {
|
|
133
|
+
reader;
|
|
134
|
+
decoder = new TextDecoder();
|
|
135
|
+
buffer = '';
|
|
136
|
+
lineNumber = 0;
|
|
137
|
+
closed = false;
|
|
138
|
+
doneReading = false;
|
|
139
|
+
readerReleased = false;
|
|
140
|
+
constructor(stream) {
|
|
141
|
+
this.reader = stream.getReader();
|
|
142
|
+
}
|
|
143
|
+
[Symbol.asyncIterator]() {
|
|
144
|
+
return new NdjsonAsyncIterator(this);
|
|
145
|
+
}
|
|
146
|
+
releaseReader() {
|
|
147
|
+
if (!this.readerReleased) {
|
|
148
|
+
this.readerReleased = true;
|
|
149
|
+
this.reader.releaseLock();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
async cancelReader(reason) {
|
|
153
|
+
if (!this.doneReading) {
|
|
154
|
+
await this.reader.cancel(reason).catch(() => { });
|
|
104
155
|
}
|
|
156
|
+
this.releaseReader();
|
|
105
157
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
158
|
+
async parseNextLine(line) {
|
|
159
|
+
try {
|
|
160
|
+
this.lineNumber += 1;
|
|
161
|
+
return {
|
|
162
|
+
done: false,
|
|
163
|
+
value: parseNdjsonLine(stripCarriageReturn(line), this.lineNumber),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
this.closed = true;
|
|
168
|
+
await this.cancelReader(error);
|
|
169
|
+
throw error;
|
|
109
170
|
}
|
|
110
|
-
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
class NdjsonAsyncIterator {
|
|
174
|
+
decoder;
|
|
175
|
+
closed = false;
|
|
176
|
+
constructor(decoder) {
|
|
177
|
+
this.decoder = decoder;
|
|
178
|
+
}
|
|
179
|
+
async next() {
|
|
180
|
+
if (this.closed || this.decoder.closed) {
|
|
181
|
+
return { done: true, value: undefined };
|
|
182
|
+
}
|
|
183
|
+
while (true) {
|
|
184
|
+
const newlineIndex = this.decoder.buffer.indexOf('\n');
|
|
185
|
+
if (newlineIndex !== -1) {
|
|
186
|
+
const line = this.decoder.buffer.slice(0, newlineIndex);
|
|
187
|
+
this.decoder.buffer = this.decoder.buffer.slice(newlineIndex + 1);
|
|
188
|
+
return this.decoder.parseNextLine(line);
|
|
189
|
+
}
|
|
190
|
+
if (this.decoder.doneReading) {
|
|
191
|
+
this.close();
|
|
192
|
+
this.decoder.releaseReader();
|
|
193
|
+
if (this.decoder.buffer.length > 0) {
|
|
194
|
+
const line = this.decoder.buffer;
|
|
195
|
+
this.decoder.buffer = '';
|
|
196
|
+
return this.decoder.parseNextLine(line);
|
|
197
|
+
}
|
|
198
|
+
return { done: true, value: undefined };
|
|
199
|
+
}
|
|
200
|
+
let chunk;
|
|
201
|
+
try {
|
|
202
|
+
chunk = await this.decoder.reader.read();
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
this.close();
|
|
206
|
+
this.decoder.releaseReader();
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
if (this.closed || this.decoder.closed) {
|
|
210
|
+
return { done: true, value: undefined };
|
|
211
|
+
}
|
|
212
|
+
if (chunk.done) {
|
|
213
|
+
this.decoder.buffer += this.decoder.decoder.decode();
|
|
214
|
+
this.decoder.doneReading = true;
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
this.decoder.buffer += this.decoder.decoder.decode(chunk.value, {
|
|
218
|
+
stream: true,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
async return(reason) {
|
|
224
|
+
if (!this.closed) {
|
|
225
|
+
this.close();
|
|
226
|
+
await this.decoder.cancelReader(reason);
|
|
227
|
+
}
|
|
228
|
+
return { done: true, value: undefined };
|
|
229
|
+
}
|
|
230
|
+
close() {
|
|
231
|
+
this.closed = true;
|
|
232
|
+
this.decoder.closed = true;
|
|
111
233
|
}
|
|
112
234
|
}
|
|
113
235
|
/**
|
|
@@ -117,31 +239,28 @@ export async function* decodeNdjson(stream) {
|
|
|
117
239
|
* `content-type: application/x-ndjson; charset=utf-8` unless the caller supplies
|
|
118
240
|
* a content type in `init.headers`.
|
|
119
241
|
*/
|
|
120
|
-
export function ndjsonResponse(source, init = {}) {
|
|
242
|
+
export function ndjsonResponse(source, { signal, ...init } = {}) {
|
|
121
243
|
const headers = new Headers(init.headers);
|
|
122
244
|
if (!headers.has('content-type')) {
|
|
123
245
|
headers.set('content-type', 'application/x-ndjson; charset=utf-8');
|
|
124
246
|
}
|
|
125
|
-
return new Response(encodeNdjson(source), {
|
|
247
|
+
return new Response(encodeNdjson(source, { signal }), {
|
|
126
248
|
...init,
|
|
127
249
|
headers,
|
|
128
250
|
});
|
|
129
251
|
}
|
|
130
252
|
function getAsyncIterator(source) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
return asyncIterator;
|
|
253
|
+
if (Symbol.asyncIterator in source) {
|
|
254
|
+
return source[Symbol.asyncIterator]();
|
|
134
255
|
}
|
|
135
|
-
|
|
136
|
-
|
|
256
|
+
if (Symbol.iterator in source) {
|
|
257
|
+
const iterator = source[Symbol.iterator]();
|
|
137
258
|
return {
|
|
138
|
-
next()
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
return { done: true, value: undefined };
|
|
144
|
-
},
|
|
259
|
+
next: async (value) => iterator.next(value),
|
|
260
|
+
return: iterator.return
|
|
261
|
+
? async (value) => iterator.return(value)
|
|
262
|
+
: undefined,
|
|
263
|
+
throw: iterator.throw ? async (error) => iterator.throw(error) : undefined,
|
|
145
264
|
};
|
|
146
265
|
}
|
|
147
266
|
throw new TypeError('NDJSON source must be iterable');
|
package/docs/context.md
CHANGED
|
@@ -424,6 +424,12 @@ Rouzer's decoder accepts `\n` and `\r\n`, handles UTF-8 chunk boundaries, and
|
|
|
424
424
|
throws a `SyntaxError` with a line number for malformed JSON. If a consumer stops
|
|
425
425
|
reading early, the response body is cancelled.
|
|
426
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
|
+
|
|
427
433
|
Rouzer does not convert handler or generator failures into extra NDJSON items. If
|
|
428
434
|
an async generator throws after the response starts, the response stream errors
|
|
429
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,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rouzer",
|
|
3
|
-
"version": "5.2.
|
|
3
|
+
"version": "5.2.2",
|
|
4
4
|
"packageManager": "pnpm@11.5.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"@hattip/core": "^0.0.49",
|
|
44
|
-
"@remix-run/route-pattern": "^0.
|
|
44
|
+
"@remix-run/route-pattern": "^0.22.1",
|
|
45
45
|
"alien-middleware": "^0.11.6"
|
|
46
46
|
},
|
|
47
47
|
"prettier": "@alloc/prettier-config",
|