Skip to content

Commit 16f8bd4

Browse files
committed
docs: show ndjson post stream cancellation
1 parent 1e4105b commit 16f8bd4

2 files changed

Lines changed: 59 additions & 6 deletions

File tree

examples/ndjson-stream.ts

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,34 @@ import type { HattipHandler } from '@hattip/core'
22
import { createClient, createRouter } from 'rouzer'
33
import * as http from 'rouzer/http'
44
import * as ndjson from 'rouzer/ndjson'
5+
import { z } from 'zod'
56

67
type Event = {
78
id: number
89
message: string
910
}
1011

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+
1122
export const events = http.get('events', {
1223
response: ndjson.$type<Event>(),
1324
})
1425

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 }
1633

1734
/**
1835
* Tiny Hattip adapter used only to keep this example self-contained. Real apps
@@ -46,6 +63,17 @@ async function collect<T>(source: AsyncIterable<T>) {
4663
return values
4764
}
4865

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+
4977
export async function runNdjsonStreamExample() {
5078
const handler = createRouter({
5179
basePath: 'api/',
@@ -55,6 +83,14 @@ export async function runNdjsonStreamExample() {
5583
yield { id: 1, message: 'ready' }
5684
yield { id: 2, message: 'done' }
5785
},
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+
},
5894
})
5995

6096
const client = createClient({
@@ -64,5 +100,16 @@ export async function runNdjsonStreamExample() {
64100
fetch: createLocalFetch(handler),
65101
})
66102

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 }
68115
}

test/examples.test.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,14 @@ test('typed error response example stays runnable', async () => {
3333
})
3434

3535
test('NDJSON stream example stays runnable', async () => {
36-
await expect(runNdjsonStreamExample()).resolves.toEqual([
37-
{ id: 1, message: 'ready' },
38-
{ id: 2, message: 'done' },
39-
])
36+
await expect(runNdjsonStreamExample()).resolves.toEqual({
37+
allEvents: [
38+
{ id: 1, message: 'ready' },
39+
{ id: 2, message: 'done' },
40+
],
41+
firstMatchingEvent: {
42+
id: 1,
43+
message: 'session.message for ses_123',
44+
},
45+
})
4046
})

0 commit comments

Comments
 (0)