@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 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
+ [![npm version](https://img.shields.io/npm/v/@nextrush/stream.svg)](https://www.npmjs.com/package/@nextrush/stream)
6
+ [![downloads](https://img.shields.io/npm/dm/@nextrush/stream.svg)](https://www.npmjs.com/package/@nextrush/stream)
7
+ [![bundle size](https://img.shields.io/bundlephobia/minzip/@nextrush/stream.svg)](https://bundlephobia.com/package/@nextrush/stream)
8
+ [![types](https://img.shields.io/npm/types/@nextrush/stream.svg)](https://www.npmjs.com/package/@nextrush/stream)
9
+ [![ESM only](https://img.shields.io/badge/module-ESM--only-blue.svg)](https://nodejs.org/api/esm.html)
10
+ [![license](https://img.shields.io/npm/l/@nextrush/stream.svg)](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)
@@ -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 };