@nextrush/stream 1.0.0-beta.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/LICENSE +21 -0
- package/README.md +387 -0
- package/dist/index.d.ts +201 -0
- package/dist/index.js +339 -0
- package/dist/index.js.map +1 -0
- package/package.json +65 -0
- package/src/__tests__/public-surface.test.ts +40 -0
- package/src/__tests__/stream.test.ts +445 -0
- package/src/errors.ts +29 -0
- package/src/index.ts +35 -0
- package/src/run.ts +122 -0
- package/src/sse-format.ts +54 -0
- package/src/stream-controller.ts +184 -0
- package/src/writers.ts +109 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tanzim (NextRush)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
# @nextrush/stream
|
|
2
|
+
|
|
3
|
+
> Runtime-agnostic response streaming for NextRush - text, Server-Sent Events, and NDJSON. Built for AI/agentic apps, works for any chunked response.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@nextrush/stream)
|
|
6
|
+
[](https://www.npmjs.com/package/@nextrush/stream)
|
|
7
|
+
[](https://bundlephobia.com/package/@nextrush/stream)
|
|
8
|
+
[](https://www.npmjs.com/package/@nextrush/stream)
|
|
9
|
+
[](https://nodejs.org/api/esm.html)
|
|
10
|
+
[](https://github.com/0xTanzim/nextRush/blob/main/LICENSE)
|
|
11
|
+
|
|
12
|
+
| | |
|
|
13
|
+
| --- | --- |
|
|
14
|
+
| **Purpose** | Implement `ctx.stream()` / `ctx.sse()` / `ctx.ndjson()` once, in a runtime-agnostic core, so every platform adapter gets identical chunked-response behavior |
|
|
15
|
+
| **Package type** | Middleware/registrar (a shared runtime layer consumed by every platform adapter, not something you `app.use()` directly) |
|
|
16
|
+
| **Status** | Stable |
|
|
17
|
+
| **Included in `nextrush`?** | Yes -- re-exported. `ctx.stream()` / `ctx.sse()` / `ctx.ndjson()` work out of the box with `nextrush`; install this package directly only for `StreamController` / `StreamAbortedError` / `formatSSE` advanced integrations |
|
|
18
|
+
| **Support tier** | Public -- middleware/registrar (stable) -- see [ADR-0005](https://github.com/0xTanzim/nextRush/blob/main/docs/adr/ADR-0005-package-tiers-sealed-surface-deprecation.md) |
|
|
19
|
+
| **Maintenance** | Active |
|
|
20
|
+
| **Runtime** | Universal -- Node, Bun, Deno, Edge (zero `node:` imports; built only on Web-standard `ReadableStream` / `AbortSignal`) |
|
|
21
|
+
| **Requires** | Node >=22, ESM-only, TypeScript >=5.x |
|
|
22
|
+
| **Introduced** | v3.1.0 |
|
|
23
|
+
|
|
24
|
+
## Highlights
|
|
25
|
+
|
|
26
|
+
- Zero runtime dependencies -- the only listed dependency is `@nextrush/types` (workspace, types only, erased at build)
|
|
27
|
+
- ESM-only, tree-shakable, side-effect-free (`sideEffects: false`)
|
|
28
|
+
- Fully typed, strict TypeScript, zero `any`
|
|
29
|
+
- One shared lifecycle (`StreamController`) drives Node's eager pump and Bun/Deno/Edge's lazy `Response` body identically -- the same handler runs unmodified on all four
|
|
30
|
+
|
|
31
|
+
<details>
|
|
32
|
+
<summary><strong>Table of contents</strong></summary>
|
|
33
|
+
|
|
34
|
+
[The problem](#the-problem) . [When to use](#when-to-use) . [Installation](#installation) . [Quick start](#quick-start) . [Capabilities](#capabilities) . [Mental model](#mental-model) . [Common tasks](#common-tasks) . [API overview](#api-overview) . [Options](#options) . [Compatibility](#compatibility) . [Troubleshooting](#troubleshooting) . [FAQ](#faq) . [Package relationships](#package-relationships) . [Architecture](#architecture) . [Resources](#resources)
|
|
35
|
+
|
|
36
|
+
</details>
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## The problem
|
|
41
|
+
|
|
42
|
+
LLM responses don't arrive as a value -- they arrive as a sequence of tokens over time. Streaming them correctly means solving four problems at once, and most hand-rolled implementations get at least one wrong:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
// TODAY, without this package -- looks fine, has real gaps:
|
|
46
|
+
app.get('/chat', async (ctx) => {
|
|
47
|
+
ctx.raw.res.setHeader('Content-Type', 'text/event-stream'); // bypasses the Context API
|
|
48
|
+
for await (const chunk of completion) {
|
|
49
|
+
ctx.raw.res.write(`data: ${chunk}\n\n`); // no multi-line escaping, no field injection guard
|
|
50
|
+
}
|
|
51
|
+
// nothing stops the upstream LLM call when the client closes the tab --
|
|
52
|
+
// it keeps running and keeps costing money for tokens nobody will read
|
|
53
|
+
ctx.raw.res.end();
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
- **Manual SSE framing is subtle and error-prone.** Multi-line `data:` fields need per-line escaping, `event:`/`id:`/`retry:` fields must precede `data:`, and the terminating blank line is commonly omitted -- all silent failures the browser's `EventSource` parser won't explain.
|
|
58
|
+
- **Streaming code is not portable across runtimes.** Node's `ServerResponse.write()` and the Fetch `Response`/`ReadableStream` model used by Bun, Deno, and edge runtimes are fundamentally different APIs. A handler written for one doesn't run on the other without a rewrite.
|
|
59
|
+
- **Cancellation is usually missing entirely.** When a user closes a chat tab mid-response, nothing tells the handler to stop. The upstream LLM call keeps running.
|
|
60
|
+
- **Bypassing the Context API breaks the framework's own contract.** Without a streaming primitive, the only escape hatch is raw response access -- defeating the point of a unified request/response API.
|
|
61
|
+
|
|
62
|
+
## When to use
|
|
63
|
+
|
|
64
|
+
**Use `@nextrush/stream` if:**
|
|
65
|
+
|
|
66
|
+
- Yes: You're streaming LLM/agent output token-by-token to a browser (`ctx.sse()`) or another service (`ctx.ndjson()`)
|
|
67
|
+
- Yes: You need real cancellation -- the upstream call should stop the instant the client disconnects
|
|
68
|
+
- Yes: You want the same handler code to run unmodified on Node, Bun, Deno, and edge runtimes
|
|
69
|
+
|
|
70
|
+
**Reach for something else if:**
|
|
71
|
+
|
|
72
|
+
- No: You need bidirectional communication (client -> server messages, not just server -> client) -- use [`@nextrush/websocket`](../extensions/websocket)
|
|
73
|
+
- No: You just need to send a complete response in one shot -- use `ctx.json()` / `ctx.send()`, not a streaming writer
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Installation
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
pnpm add @nextrush/stream
|
|
81
|
+
# npm i @nextrush/stream . yarn add @nextrush/stream . bun add @nextrush/stream
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
> [!NOTE]
|
|
85
|
+
> Already using `nextrush`? This is included -- `ctx.stream()`, `ctx.sse()`, and `ctx.ndjson()`
|
|
86
|
+
> are wired by every platform adapter automatically. Install `@nextrush/stream` directly only to
|
|
87
|
+
> import `StreamController`, `StreamAbortedError`, or `formatSSE` for advanced integrations.
|
|
88
|
+
|
|
89
|
+
## Quick start
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
import { createApp, listen } from 'nextrush';
|
|
93
|
+
|
|
94
|
+
const app = createApp();
|
|
95
|
+
|
|
96
|
+
app.get('/progress', async (ctx) => {
|
|
97
|
+
await ctx.stream(async (writer) => {
|
|
98
|
+
await writer.write('Loading...\n');
|
|
99
|
+
await writer.write('Processing...\n');
|
|
100
|
+
await writer.write('Done.\n');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
listen(app, 8080);
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
No options, no headers to set, no content type to remember. The connection closes automatically when the callback returns.
|
|
108
|
+
|
|
109
|
+
## Capabilities
|
|
110
|
+
|
|
111
|
+
**Capabilities**
|
|
112
|
+
- **Three protocol-specific entry points** -- `ctx.stream()` (text/bytes), `ctx.sse()` (Server-Sent Events), `ctx.ndjson()` (newline-delimited JSON), each with a writer that speaks exactly one wire format
|
|
113
|
+
- **Real cancellation** -- `writer.signal` fires the instant the client disconnects; wire it into any AI SDK's abort option and the upstream call actually stops
|
|
114
|
+
- **Loud failure on write-after-abort** -- throws `StreamAbortedError` rather than silently dropping data
|
|
115
|
+
- **Source consumption** -- `writer.consume(source)` adapts an existing `AsyncIterable` or Web `ReadableStream` (including a Node `Readable`, which satisfies `AsyncIterable`) in one call
|
|
116
|
+
|
|
117
|
+
**Developer experience**
|
|
118
|
+
- Writer-callback API -- no options bag to read before the first working handler
|
|
119
|
+
- Fully typed, zero `any`
|
|
120
|
+
- Tree-shakable, side-effect-free
|
|
121
|
+
|
|
122
|
+
## Mental model
|
|
123
|
+
|
|
124
|
+
`ctx.stream()` / `ctx.sse()` / `ctx.ndjson()` all follow the same shape: pass a callback, receive a protocol-specific writer, write until done. The connection closes automatically when the callback resolves -- nothing to remember, nothing to configure first.
|
|
125
|
+
|
|
126
|
+
```text
|
|
127
|
+
ctx.sse(async writer => ...) --> StreamController --> writer.write() / writer.consume()
|
|
128
|
+
| |
|
|
129
|
+
| v
|
|
130
|
+
| formatSSE() / encode()
|
|
131
|
+
| |
|
|
132
|
+
v v
|
|
133
|
+
client disconnects ctx.sendStream()
|
|
134
|
+
(writer.signal fires) |
|
|
135
|
+
| Node: eager pump
|
|
136
|
+
v Bun/Deno/Edge: Response body
|
|
137
|
+
StreamAbortedError
|
|
138
|
+
(thrown from write()/consume())
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
**Rule:** `StreamController` owns cancellation, backpressure, and source normalization exactly once. `TextWriter`, `SSEWriter`, and `NDJSONWriter` are thin formatters on top of it -- each knows one wire format and nothing else.
|
|
142
|
+
|
|
143
|
+
> [!TIP]
|
|
144
|
+
> The full request lifecycle (Mermaid) is in [`ARCHITECTURE.md`](./ARCHITECTURE.md).
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## Common tasks
|
|
149
|
+
|
|
150
|
+
### Stream Server-Sent Events to an LLM chat UI
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
app.post('/chat', async (ctx) => {
|
|
154
|
+
await ctx.sse(async (writer) => {
|
|
155
|
+
const completion = await openai.chat.completions.create(
|
|
156
|
+
{ model: 'gpt-5', messages: ctx.body as ChatMessage[], stream: true },
|
|
157
|
+
{ signal: writer.signal } // abort OpenAI the instant the client disconnects
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
for await (const chunk of completion) {
|
|
161
|
+
const token = chunk.choices[0]?.delta?.content;
|
|
162
|
+
if (token) await writer.write({ data: token });
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`ctx.sse()` sets `Content-Type: text/event-stream` and `Cache-Control: no-cache`, and formats every event to spec -- multi-line `data:` escaping, `event:`/`id:`/`retry:` fields, the terminating blank line.
|
|
169
|
+
|
|
170
|
+
### Stream structured agent traces as NDJSON
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
app.post('/agent/trace', async (ctx) => {
|
|
174
|
+
await ctx.ndjson(async (writer) => {
|
|
175
|
+
await writer.write({ type: 'tool_call', name: 'search', args: { query: '...' } });
|
|
176
|
+
const result = await runTool('search', { query: '...' });
|
|
177
|
+
await writer.write({ type: 'tool_result', result });
|
|
178
|
+
await writer.write({ type: 'final_answer', text: '...' });
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
For structured agent traces and tool-call logs where SSE framing isn't needed -- server-to-server pipelines, CLI consumers.
|
|
184
|
+
|
|
185
|
+
### Consume an existing AsyncIterable or ReadableStream
|
|
186
|
+
|
|
187
|
+
AI SDKs and database cursors already hand you an `AsyncIterable` or `ReadableStream`. `writer.consume()` adapts it in one call -- no manual loop, no manual type-checking.
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
// LangChain - model.stream() already returns an AsyncIterable
|
|
191
|
+
app.post('/agent', async (ctx) => {
|
|
192
|
+
await ctx.sse(async (writer) => {
|
|
193
|
+
const model = new ChatOpenAI({ model: 'gpt-5', streaming: true });
|
|
194
|
+
const stream = await model.stream(ctx.body as string, { signal: writer.signal });
|
|
195
|
+
await writer.consume(stream);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// Vercel AI SDK
|
|
200
|
+
app.post('/ai', async (ctx) => {
|
|
201
|
+
await ctx.sse(async (writer) => {
|
|
202
|
+
const result = streamText({
|
|
203
|
+
model: openai('gpt-5'),
|
|
204
|
+
prompt: (ctx.body as { prompt: string }).prompt,
|
|
205
|
+
abortSignal: writer.signal,
|
|
206
|
+
});
|
|
207
|
+
await writer.consume(result.textStream);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
For `ctx.sse()`, each consumed chunk is automatically wrapped as `{ data: chunk }`. `writer.consume(source)` accepts `StreamSource<T>` -- `AsyncIterable<T> | ReadableStream<T>` -- normalized internally to one code path and never branched on in application code. A Node `Readable` is accepted through the `AsyncIterable` branch (it has implemented `Symbol.asyncIterator` natively since Node 10); it is not a distinct third member of the union.
|
|
213
|
+
|
|
214
|
+
### Wire real cancellation into an upstream call
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
await ctx.sse(async (writer) => {
|
|
218
|
+
writer.onAbort(() => console.log('client disconnected - upstream cancelled'));
|
|
219
|
+
|
|
220
|
+
for await (const chunk of completion) {
|
|
221
|
+
if (writer.aborted) break; // optional early exit for expensive work
|
|
222
|
+
await writer.write({ data: chunk }); // throws StreamAbortedError once aborted
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Every writer exposes real cancellation backed by an `AbortSignal`. Writing after disconnect **throws** `StreamAbortedError` -- it does not silently no-op. The framework catches it at the `ctx.stream()` / `ctx.sse()` / `ctx.ndjson()` boundary and closes cleanly: it is never logged as an error and never re-thrown to the caller.
|
|
228
|
+
|
|
229
|
+
### Handle errors inside a streaming handler
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
await ctx.sse(async (writer) => {
|
|
233
|
+
try {
|
|
234
|
+
await runAgent(writer);
|
|
235
|
+
} catch (error) {
|
|
236
|
+
await writer.write({ event: 'error', data: (error as Error).message });
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
There is one error-handling model -- `try`/`catch` -- not a second callback to learn. Once a stream has started, headers are already on the wire: a global error-handling middleware can observe and log an error that propagates out of a streaming handler, but it cannot rewrite a response that has already begun. Write a final error event inside the callback if the client needs to see it.
|
|
242
|
+
|
|
243
|
+
## API overview
|
|
244
|
+
|
|
245
|
+
The sealed public surface (ADR-0005).
|
|
246
|
+
|
|
247
|
+
| Export | Signature | Since | Stability | Description |
|
|
248
|
+
| ------ | --------- | ----- | --------- | ----------- |
|
|
249
|
+
| `StreamController` | `class` | 3.1.0 | Stable | Owns abort/backpressure/enqueue/close lifecycle. Consumed by platform adapters to implement `ctx.stream()`/`ctx.sse()`/`ctx.ndjson()`; most applications never construct one directly. |
|
|
250
|
+
| `StreamAbortedError` | `class extends Error` | 3.1.0 | Stable | Thrown by a writer's `write()`/`consume()` once the client has disconnected. |
|
|
251
|
+
| `formatSSE` | `(event: SSEEvent) => string` | 3.1.0 | Stable | Formats one `SSEEvent` to its `text/event-stream` wire representation. |
|
|
252
|
+
| `runTextStream` | `(ctx, run) => Promise<void>` | 3.1.0 | Stable | Implements `ctx.stream()`. |
|
|
253
|
+
| `runSSEStream` | `(ctx, run) => Promise<void>` | 3.1.0 | Stable | Implements `ctx.sse()`. |
|
|
254
|
+
| `runNDJSONStream` | `(ctx, run) => Promise<void>` | 3.1.0 | Stable | Implements `ctx.ndjson()`. |
|
|
255
|
+
| `TextWriter` | `class implements TextStreamWriter` | 3.1.0 | Stable | Raw text/byte writer. |
|
|
256
|
+
| `SSEWriter` | `class implements SSEStreamWriter` | 3.1.0 | Stable | Server-Sent Events writer. |
|
|
257
|
+
| `NDJSONWriter` | `class implements NDJSONStreamWriter` | 3.1.0 | Stable | Newline-delimited JSON writer. |
|
|
258
|
+
| `type BaseStreamWriter` | `{ aborted, signal, onAbort(fn) }` | 3.1.0 | Stable | Capabilities shared by every writer, regardless of protocol. |
|
|
259
|
+
| `type TextStreamWriter` | `extends BaseStreamWriter` | 3.1.0 | Stable | Adds `write(chunk: string \| Uint8Array)` and `consume(source: StreamSource<string \| Uint8Array>)`. |
|
|
260
|
+
| `type SSEStreamWriter` | `extends BaseStreamWriter` | 3.1.0 | Stable | Adds `write(event: SSEEvent)` and `consume(source: StreamSource<string \| Uint8Array>)`. |
|
|
261
|
+
| `type NDJSONStreamWriter` | `extends BaseStreamWriter` | 3.1.0 | Stable | Adds `write(value: unknown)` and `consume(source: StreamSource<unknown>)`. |
|
|
262
|
+
| `type SSEEvent` | `{ data, event?, id?, retry? }` | 3.1.0 | Stable | One Server-Sent Event. |
|
|
263
|
+
| `type StreamSource<T>` | `AsyncIterable<T> \| ReadableStream<T>` | 3.1.0 | Stable | Source shapes `consume()` accepts. |
|
|
264
|
+
| `type StreamRun<W>` | `(writer: W) => Promise<void>` | 3.1.0 | Stable | The callback shape passed to `ctx.stream()`/`ctx.sse()`/`ctx.ndjson()`. |
|
|
265
|
+
|
|
266
|
+
> [!IMPORTANT]
|
|
267
|
+
> `consume()` is declared on each concrete writer interface (`TextStreamWriter`, `SSEStreamWriter`,
|
|
268
|
+
> `NDJSONStreamWriter`), not on `BaseStreamWriter` -- the base interface only carries
|
|
269
|
+
> `aborted`/`signal`/`onAbort`. There is no `NodeJS.ReadableStream` member in `StreamSource<T>`;
|
|
270
|
+
> it is exactly the two-member union `AsyncIterable<T> | ReadableStream<T>`.
|
|
271
|
+
|
|
272
|
+
Most applications never construct `StreamController` or the writer classes directly -- they exist so platform adapters can build `ctx.stream()` / `ctx.sse()` / `ctx.ndjson()` without duplicating lifecycle logic. Set custom headers (e.g. a different `Content-Type`) with `ctx.set(...)` before calling the streaming method -- there is no options bag on the streaming methods themselves.
|
|
273
|
+
|
|
274
|
+
## Options
|
|
275
|
+
|
|
276
|
+
No configuration -- `ctx.stream()` / `ctx.sse()` / `ctx.ndjson()` each take one callback and nothing else. Content-Type, cache headers, and wire-format framing are fixed per protocol; use `ctx.set(...)` before the call for anything else.
|
|
277
|
+
|
|
278
|
+
## Compatibility
|
|
279
|
+
|
|
280
|
+
**Requirements**
|
|
281
|
+
|
|
282
|
+
| Requirement | Version |
|
|
283
|
+
| ----------- | ------- |
|
|
284
|
+
| NextRush | 3.x |
|
|
285
|
+
| Node.js | >=22 |
|
|
286
|
+
| TypeScript | >=5.x |
|
|
287
|
+
|
|
288
|
+
**Runtimes**
|
|
289
|
+
|
|
290
|
+
| Runtime | Supported | Notes |
|
|
291
|
+
| ------- | --------- | ----- |
|
|
292
|
+
| Node.js >=22 | Yes | Eager pump -- `ctx.sendStream()` writes directly to `ServerResponse` with backpressure |
|
|
293
|
+
| Bun / Deno / Edge | Yes / Yes / Yes | Native `Response` body -- the runtime drains the stream; `ctx.sendStream()` is a one-line body assignment on each |
|
|
294
|
+
|
|
295
|
+
The public writer API is identical across all four runtimes -- only the internal transport primitive (`ctx.sendStream()`) differs per adapter, and application code never touches it directly. Verified with real integration tests against Node's HTTP server, and against real `Bun.serve` / `Deno.serve` instances producing byte-identical output.
|
|
296
|
+
|
|
297
|
+
**Integration**
|
|
298
|
+
- **Peer dependencies:** none
|
|
299
|
+
- **Works with:** any AI SDK exposing an `AsyncIterable` or an abort-signal option (OpenAI, LangChain, Vercel AI SDK -- see [Common tasks](#common-tasks))
|
|
300
|
+
- **Incompatible with:** none
|
|
301
|
+
|
|
302
|
+
> [!IMPORTANT]
|
|
303
|
+
> NextRush is **ESM-only, permanently** -- no CommonJS build. On Node >=22, CommonJS consumers
|
|
304
|
+
> can `require()` this ESM package natively. See the
|
|
305
|
+
> [Module Format Policy](https://github.com/0xTanzim/nextRush#module-format-policy).
|
|
306
|
+
|
|
307
|
+
---
|
|
308
|
+
|
|
309
|
+
## Troubleshooting
|
|
310
|
+
|
|
311
|
+
<details>
|
|
312
|
+
<summary><strong>The upstream LLM call keeps running after the client disconnects</strong></summary>
|
|
313
|
+
|
|
314
|
+
**Cause:** `writer.signal` was never passed into the upstream SDK's own abort option -- a disconnected client still leaves the LLM request running server-side unless something tells it to stop. **Fix:** always pass `{ signal: writer.signal }` (or the SDK's equivalent) to any long-running upstream call inside a streaming handler.
|
|
315
|
+
|
|
316
|
+
```ts
|
|
317
|
+
await ctx.sse(async (writer) => {
|
|
318
|
+
const completion = await openai.chat.completions.create(
|
|
319
|
+
{ model: 'gpt-5', messages, stream: true },
|
|
320
|
+
{ signal: writer.signal } // <-- this line
|
|
321
|
+
);
|
|
322
|
+
});
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
</details>
|
|
326
|
+
|
|
327
|
+
<details>
|
|
328
|
+
<summary><strong>`StreamAbortedError` appears to swallow a real bug</strong></summary>
|
|
329
|
+
|
|
330
|
+
**Cause:** `StreamAbortedError` is a control-flow signal, not an application error -- it is thrown deliberately when a write happens after the client has disconnected, and the framework catches it at the `ctx.stream()`/`ctx.sse()`/`ctx.ndjson()` boundary without logging or re-throwing. **Fix:** if you need to distinguish "cancelled" from "something broke," catch `StreamAbortedError` explicitly inside your callback; a genuine bug throws a different error type and propagates normally.
|
|
331
|
+
|
|
332
|
+
</details>
|
|
333
|
+
|
|
334
|
+
<details>
|
|
335
|
+
<summary><strong>A response never completes, or hangs on the client</strong></summary>
|
|
336
|
+
|
|
337
|
+
**Cause:** most commonly, the callback passed to `ctx.stream()`/`ctx.sse()`/`ctx.ndjson()` never resolves -- the connection closes exactly when that callback returns. **Fix:** confirm every code path inside the callback (including error paths) either returns or throws; there is no separate "end the stream" call to remember.
|
|
338
|
+
|
|
339
|
+
</details>
|
|
340
|
+
|
|
341
|
+
## FAQ
|
|
342
|
+
|
|
343
|
+
**Can I use this without `nextrush`?**
|
|
344
|
+
Yes -- install `@nextrush/stream` directly and call `runTextStream()` / `runSSEStream()` / `runNDJSONStream()` against any object satisfying the minimal `StreamCapableContext` shape (`signal`, `set()`, `sendStream()`). Most applications should use `ctx.stream()`/`ctx.sse()`/`ctx.ndjson()` through `nextrush` instead.
|
|
345
|
+
|
|
346
|
+
**Why ESM-only?**
|
|
347
|
+
See the [Module Format Policy](https://github.com/0xTanzim/nextRush#module-format-policy).
|
|
348
|
+
|
|
349
|
+
**Does it work on Bun / Deno / Edge?**
|
|
350
|
+
Yes -- all four runtimes are wired and verified byte-identical (see [Compatibility](#compatibility)). The package itself has zero runtime-specific code; each adapter supplies its own `ctx.sendStream()`.
|
|
351
|
+
|
|
352
|
+
**Does this handle WebSocket-style bidirectional streaming?**
|
|
353
|
+
No -- this package is strictly server-to-client. For bidirectional communication, see [`@nextrush/websocket`](../extensions/websocket).
|
|
354
|
+
|
|
355
|
+
---
|
|
356
|
+
|
|
357
|
+
## Package relationships
|
|
358
|
+
|
|
359
|
+
```text
|
|
360
|
+
depends on @nextrush/types
|
|
361
|
+
@nextrush/stream ------------------->
|
|
362
|
+
consumed by adapter-node / adapter-bun / adapter-deno / adapter-edge
|
|
363
|
+
distinct from @nextrush/websocket (bidirectional, not server-to-client only)
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
- **Depends on:** [`@nextrush/types`](../types) -- `BaseStreamWriter`/`StreamSource`/`SSEEvent`/`StreamRun` contracts, types only
|
|
367
|
+
- **Consumed by:** every platform adapter (`adapter-node`, `adapter-bun`, `adapter-deno`, `adapter-edge`) -- each implements `ctx.sendStream()` and wires `runTextStream`/`runSSEStream`/`runNDJSONStream` into `Context`
|
|
368
|
+
- **Distinct from:** [`@nextrush/websocket`](../extensions/websocket) -- bidirectional streaming; this package is server-to-client only
|
|
369
|
+
- **Alternative:** none within NextRush for chunked HTTP responses
|
|
370
|
+
|
|
371
|
+
## Architecture
|
|
372
|
+
|
|
373
|
+
Maintaining or contributing to this package? The internal design -- `StreamController`'s
|
|
374
|
+
lifecycle, backpressure, source normalization, and cross-runtime wiring (with diagrams) -- is in
|
|
375
|
+
**[`ARCHITECTURE.md`](./ARCHITECTURE.md)**. Design history:
|
|
376
|
+
[`docs/RFC/request-data/003-stream.md`](../../docs/RFC/request-data/003-stream.md).
|
|
377
|
+
|
|
378
|
+
## Resources
|
|
379
|
+
|
|
380
|
+
- **Learn** -- [Documentation](https://0xtanzim.github.io/nextRush/docs) . [Architecture](./ARCHITECTURE.md) . [RFCs](https://github.com/0xTanzim/nextRush/tree/main/docs/RFC)
|
|
381
|
+
- **Changelog** -- [CHANGELOG.md](./CHANGELOG.md)
|
|
382
|
+
- **Report an issue** -- [GitHub Issues](https://github.com/0xTanzim/nextRush/issues)
|
|
383
|
+
- **Contribute** -- [CONTRIBUTING.md](https://github.com/0xTanzim/nextRush/blob/main/CONTRIBUTING.md)
|
|
384
|
+
|
|
385
|
+
---
|
|
386
|
+
|
|
387
|
+
MIT (c) [Tanzim Hossain](https://github.com/0xTanzim)
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { SSEEvent, StreamRun, NDJSONStreamWriter, SSEStreamWriter, TextStreamWriter, StreamSource } from '@nextrush/types';
|
|
2
|
+
export { BaseStreamWriter, NDJSONStreamWriter, SSEEvent, SSEStreamWriter, StreamRun, StreamSource, TextStreamWriter } from '@nextrush/types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @nextrush/stream - Errors
|
|
6
|
+
*
|
|
7
|
+
* @packageDocumentation
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Thrown by a writer's `write()`/`consume()` once the client has disconnected.
|
|
11
|
+
*
|
|
12
|
+
* @remarks
|
|
13
|
+
* This is a control-flow signal, not an HTTP error — the client is already gone,
|
|
14
|
+
* so nothing is sent to them. `ctx.stream()`/`ctx.sse()`/`ctx.ndjson()` catch it
|
|
15
|
+
* at the top-level boundary and treat it as a clean, expected shutdown: it is
|
|
16
|
+
* never logged as a failure and never re-thrown to the caller.
|
|
17
|
+
*
|
|
18
|
+
* A handler that wants to distinguish "I was cancelled" from "something broke"
|
|
19
|
+
* can catch this explicitly; a handler that does nothing special still cannot
|
|
20
|
+
* produce a silently-corrupted response, because the throw happens before any
|
|
21
|
+
* partial write.
|
|
22
|
+
*/
|
|
23
|
+
declare class StreamAbortedError extends Error {
|
|
24
|
+
readonly name = "StreamAbortedError";
|
|
25
|
+
constructor();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @nextrush/stream - Server-Sent Events wire formatting
|
|
30
|
+
*
|
|
31
|
+
* @packageDocumentation
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Format a single {@link SSEEvent} into its `text/event-stream` wire representation.
|
|
36
|
+
*
|
|
37
|
+
* @remarks
|
|
38
|
+
* Handles every framing detail so handlers never touch it:
|
|
39
|
+
* - `data`: objects are `JSON.stringify`'d; strings are sent verbatim.
|
|
40
|
+
* - Multi-line `data` is split into one `data:` line per line, per the SSE spec
|
|
41
|
+
* (a raw `\n` inside a single `data:` field would corrupt the stream).
|
|
42
|
+
* - Optional `event:`, `id:`, and `retry:` fields precede the data lines.
|
|
43
|
+
* - The event is terminated by a blank line (`\n\n`).
|
|
44
|
+
*
|
|
45
|
+
* Carriage returns and newlines are stripped from `event`/`id` to prevent
|
|
46
|
+
* field injection (a `\n` in `id` would otherwise start a new field/event).
|
|
47
|
+
*
|
|
48
|
+
* @param event - The event to format.
|
|
49
|
+
* @returns The event as an SSE wire-format string.
|
|
50
|
+
*/
|
|
51
|
+
declare function formatSSE(event: SSEEvent): string;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @nextrush/stream - StreamController
|
|
55
|
+
*
|
|
56
|
+
* The single internal component that owns streaming lifecycle: abort tracking,
|
|
57
|
+
* enqueue, cooperative backpressure, source normalization, and close/cleanup.
|
|
58
|
+
* The protocol writers ({@link TextWriter}/{@link SSEWriter}/{@link NDJSONWriter})
|
|
59
|
+
* are thin formatting wrappers over this — they never touch lifecycle directly.
|
|
60
|
+
*
|
|
61
|
+
* See docs/RFC/request-data/003-stream.md §5.
|
|
62
|
+
*
|
|
63
|
+
* @packageDocumentation
|
|
64
|
+
*/
|
|
65
|
+
/**
|
|
66
|
+
* Owns the underlying `ReadableStream` controller and all streaming lifecycle.
|
|
67
|
+
*
|
|
68
|
+
* @remarks
|
|
69
|
+
* One instance per streaming response, shared by exactly one writer.
|
|
70
|
+
*/
|
|
71
|
+
declare class StreamController {
|
|
72
|
+
/** Fires when the client disconnects. */
|
|
73
|
+
readonly signal: AbortSignal;
|
|
74
|
+
private _rsController;
|
|
75
|
+
private _pullResolve;
|
|
76
|
+
private _abortCallbacks;
|
|
77
|
+
private _closed;
|
|
78
|
+
constructor(signal: AbortSignal);
|
|
79
|
+
/** `true` once the client has disconnected. */
|
|
80
|
+
get aborted(): boolean;
|
|
81
|
+
/**
|
|
82
|
+
* @internal Wire the underlying `ReadableStream` controller. Called once from
|
|
83
|
+
* the stream's `start()`.
|
|
84
|
+
*/
|
|
85
|
+
attach(controller: ReadableStreamDefaultController<Uint8Array>): void;
|
|
86
|
+
/**
|
|
87
|
+
* @internal Release a pending backpressure wait. Called from the stream's
|
|
88
|
+
* `pull()` when the consumer is ready for more data.
|
|
89
|
+
*/
|
|
90
|
+
onPull(): void;
|
|
91
|
+
/**
|
|
92
|
+
* Register a cleanup callback invoked once when the client disconnects.
|
|
93
|
+
* Invoked immediately if already aborted.
|
|
94
|
+
*/
|
|
95
|
+
onAbort(fn: () => void): void;
|
|
96
|
+
/**
|
|
97
|
+
* Enqueue raw bytes, applying cooperative backpressure.
|
|
98
|
+
*
|
|
99
|
+
* @throws StreamAbortedError if the client has disconnected.
|
|
100
|
+
*/
|
|
101
|
+
enqueue(chunk: Uint8Array): Promise<void>;
|
|
102
|
+
/** Encode a UTF-8 string and enqueue it. */
|
|
103
|
+
enqueueText(text: string): Promise<void>;
|
|
104
|
+
/**
|
|
105
|
+
* Normalize any accepted source shape to a single async-iterator.
|
|
106
|
+
*
|
|
107
|
+
* @remarks
|
|
108
|
+
* The one and only place that branches on source type. `AsyncIterable`
|
|
109
|
+
* (including Node `Readable`, which implements `Symbol.asyncIterator`) is used
|
|
110
|
+
* directly; a bare Web `ReadableStream` is adapted via its reader.
|
|
111
|
+
*/
|
|
112
|
+
normalize<T>(source: AsyncIterable<T> | ReadableStream<T>): AsyncIterator<T>;
|
|
113
|
+
/** Close the underlying stream cleanly. Idempotent. */
|
|
114
|
+
close(): void;
|
|
115
|
+
/** Error the underlying stream. Idempotent. */
|
|
116
|
+
error(err: unknown): void;
|
|
117
|
+
private _onAbort;
|
|
118
|
+
private _resolvePull;
|
|
119
|
+
private _waitForPull;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @nextrush/stream - Run orchestration
|
|
124
|
+
*
|
|
125
|
+
* Wires a protocol writer to a Web `ReadableStream` and ships it through the
|
|
126
|
+
* adapter's `ctx.sendStream()` primitive. Runtime-agnostic: identical code path
|
|
127
|
+
* on Node (eager pump) and Bun/Deno/Edge (lazy Response body).
|
|
128
|
+
*
|
|
129
|
+
* See docs/RFC/request-data/003-stream.md §5, §6.
|
|
130
|
+
*
|
|
131
|
+
* @packageDocumentation
|
|
132
|
+
*/
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Minimal Context surface `@nextrush/stream` needs. The concrete adapter
|
|
136
|
+
* `Context` satisfies this structurally; kept narrow so unit tests can supply a
|
|
137
|
+
* lightweight fake.
|
|
138
|
+
*/
|
|
139
|
+
interface StreamCapableContext {
|
|
140
|
+
readonly signal: AbortSignal;
|
|
141
|
+
set(field: string, value: string | number | string[]): void;
|
|
142
|
+
sendStream(source: ReadableStream<Uint8Array>): Promise<void>;
|
|
143
|
+
}
|
|
144
|
+
/** Implements `ctx.stream()`. */
|
|
145
|
+
declare function runTextStream(ctx: StreamCapableContext, run: StreamRun<TextStreamWriter>): Promise<void>;
|
|
146
|
+
/** Implements `ctx.sse()`. */
|
|
147
|
+
declare function runSSEStream(ctx: StreamCapableContext, run: StreamRun<SSEStreamWriter>): Promise<void>;
|
|
148
|
+
/** Implements `ctx.ndjson()`. */
|
|
149
|
+
declare function runNDJSONStream(ctx: StreamCapableContext, run: StreamRun<NDJSONStreamWriter>): Promise<void>;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @nextrush/stream - Protocol writers
|
|
153
|
+
*
|
|
154
|
+
* Thin formatting wrappers over {@link StreamController}. Each writer differs
|
|
155
|
+
* only in how `write()` encodes its protocol's native unit and how `consume()`
|
|
156
|
+
* maps a raw chunk. All lifecycle (abort, backpressure, close) lives in the
|
|
157
|
+
* controller — not here.
|
|
158
|
+
*
|
|
159
|
+
* See docs/RFC/request-data/003-stream.md §5, §7.
|
|
160
|
+
*
|
|
161
|
+
* @packageDocumentation
|
|
162
|
+
*/
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Shared base: exposes the controller's abort surface and drives `consume()`.
|
|
166
|
+
*
|
|
167
|
+
* @typeParam T - The unit type each source chunk is mapped to before `write()`.
|
|
168
|
+
*/
|
|
169
|
+
declare abstract class BaseWriter<T> {
|
|
170
|
+
protected readonly controller: StreamController;
|
|
171
|
+
constructor(controller: StreamController);
|
|
172
|
+
get aborted(): boolean;
|
|
173
|
+
get signal(): AbortSignal;
|
|
174
|
+
onAbort(fn: () => void): void;
|
|
175
|
+
/** Protocol-specific write of one native unit. */
|
|
176
|
+
abstract write(value: T): Promise<void>;
|
|
177
|
+
/** Map one raw source chunk to this protocol's native unit. */
|
|
178
|
+
protected abstract mapChunk(chunk: unknown): T;
|
|
179
|
+
/**
|
|
180
|
+
* Consume an existing producer into this response. Single normalization path;
|
|
181
|
+
* stops and throws `StreamAbortedError` if the client disconnects mid-consume.
|
|
182
|
+
*/
|
|
183
|
+
consume(source: StreamSource<unknown>): Promise<void>;
|
|
184
|
+
}
|
|
185
|
+
/** Raw text/byte writer for `ctx.stream()`. */
|
|
186
|
+
declare class TextWriter extends BaseWriter<string | Uint8Array> implements TextStreamWriter {
|
|
187
|
+
write(chunk: string | Uint8Array): Promise<void>;
|
|
188
|
+
protected mapChunk(chunk: unknown): string | Uint8Array;
|
|
189
|
+
}
|
|
190
|
+
/** Server-Sent Events writer for `ctx.sse()`. */
|
|
191
|
+
declare class SSEWriter extends BaseWriter<SSEEvent> implements SSEStreamWriter {
|
|
192
|
+
write(event: SSEEvent): Promise<void>;
|
|
193
|
+
protected mapChunk(chunk: unknown): SSEEvent;
|
|
194
|
+
}
|
|
195
|
+
/** Newline-delimited JSON writer for `ctx.ndjson()`. */
|
|
196
|
+
declare class NDJSONWriter extends BaseWriter<unknown> implements NDJSONStreamWriter {
|
|
197
|
+
write(value: unknown): Promise<void>;
|
|
198
|
+
protected mapChunk(chunk: unknown): unknown;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export { NDJSONWriter, SSEWriter, StreamAbortedError, type StreamCapableContext, StreamController, TextWriter, formatSSE, runNDJSONStream, runSSEStream, runTextStream };
|