rouzer 5.2.0 → 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 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>, 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 iterator.return?.(reason);
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
- ...init,
171
+ return new Response(encodeNdjson(source, { signal }), {
172
+ ...responseInit,
127
173
  headers,
128
174
  });
129
175
  }
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
- export const routes = { events }
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
- return collect(await client.events())
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.0",
3
+ "version": "5.2.1",
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.21.1",
44
+ "@remix-run/route-pattern": "^0.22.1",
45
45
  "alien-middleware": "^0.11.6"
46
46
  },
47
47
  "prettier": "@alloc/prettier-config",