@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/dist/index.js ADDED
@@ -0,0 +1,339 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
3
+
4
+ // src/errors.ts
5
+ var StreamAbortedError = class _StreamAbortedError extends Error {
6
+ static {
7
+ __name(this, "StreamAbortedError");
8
+ }
9
+ name = "StreamAbortedError";
10
+ constructor() {
11
+ super("Cannot write to stream: client has disconnected.");
12
+ Object.setPrototypeOf(this, _StreamAbortedError.prototype);
13
+ }
14
+ };
15
+
16
+ // src/sse-format.ts
17
+ function formatSSE(event) {
18
+ let out = "";
19
+ if (event.event !== void 0) {
20
+ out += `event: ${sanitizeField(event.event)}
21
+ `;
22
+ }
23
+ if (event.id !== void 0) {
24
+ out += `id: ${sanitizeField(event.id)}
25
+ `;
26
+ }
27
+ if (event.retry !== void 0) {
28
+ out += `retry: ${String(Math.trunc(event.retry))}
29
+ `;
30
+ }
31
+ const data = typeof event.data === "string" ? event.data : JSON.stringify(event.data);
32
+ for (const line of data.split("\n")) {
33
+ out += `data: ${line.replace(/\r$/, "")}
34
+ `;
35
+ }
36
+ out += "\n";
37
+ return out;
38
+ }
39
+ __name(formatSSE, "formatSSE");
40
+ function sanitizeField(value) {
41
+ return value.replace(/[\r\n]/g, "");
42
+ }
43
+ __name(sanitizeField, "sanitizeField");
44
+
45
+ // src/stream-controller.ts
46
+ var TEXT_ENCODER = new TextEncoder();
47
+ var StreamController = class {
48
+ static {
49
+ __name(this, "StreamController");
50
+ }
51
+ /** Fires when the client disconnects. */
52
+ signal;
53
+ _rsController = null;
54
+ _pullResolve = null;
55
+ _abortCallbacks = [];
56
+ _closed = false;
57
+ constructor(signal) {
58
+ this.signal = signal;
59
+ if (!signal.aborted) {
60
+ signal.addEventListener("abort", this._onAbort, {
61
+ once: true
62
+ });
63
+ }
64
+ }
65
+ /** `true` once the client has disconnected. */
66
+ get aborted() {
67
+ return this.signal.aborted;
68
+ }
69
+ /**
70
+ * @internal Wire the underlying `ReadableStream` controller. Called once from
71
+ * the stream's `start()`.
72
+ */
73
+ attach(controller) {
74
+ this._rsController = controller;
75
+ }
76
+ /**
77
+ * @internal Release a pending backpressure wait. Called from the stream's
78
+ * `pull()` when the consumer is ready for more data.
79
+ */
80
+ onPull() {
81
+ this._resolvePull();
82
+ }
83
+ /**
84
+ * Register a cleanup callback invoked once when the client disconnects.
85
+ * Invoked immediately if already aborted.
86
+ */
87
+ onAbort(fn) {
88
+ if (this.aborted) {
89
+ fn();
90
+ return;
91
+ }
92
+ this._abortCallbacks.push(fn);
93
+ }
94
+ /**
95
+ * Enqueue raw bytes, applying cooperative backpressure.
96
+ *
97
+ * @throws StreamAbortedError if the client has disconnected.
98
+ */
99
+ async enqueue(chunk) {
100
+ if (this.aborted) throw new StreamAbortedError();
101
+ const controller = this._rsController;
102
+ if (!controller) {
103
+ throw new Error("StreamController is not attached to a stream.");
104
+ }
105
+ controller.enqueue(chunk);
106
+ if ((controller.desiredSize ?? 1) <= 0) {
107
+ await this._waitForPull();
108
+ if (this.signal.aborted) throw new StreamAbortedError();
109
+ }
110
+ }
111
+ /** Encode a UTF-8 string and enqueue it. */
112
+ enqueueText(text) {
113
+ return this.enqueue(TEXT_ENCODER.encode(text));
114
+ }
115
+ /**
116
+ * Normalize any accepted source shape to a single async-iterator.
117
+ *
118
+ * @remarks
119
+ * The one and only place that branches on source type. `AsyncIterable`
120
+ * (including Node `Readable`, which implements `Symbol.asyncIterator`) is used
121
+ * directly; a bare Web `ReadableStream` is adapted via its reader.
122
+ */
123
+ normalize(source) {
124
+ if (Symbol.asyncIterator in source) {
125
+ return source[Symbol.asyncIterator]();
126
+ }
127
+ const reader = source.getReader();
128
+ return {
129
+ async next() {
130
+ const { done, value } = await reader.read();
131
+ return done ? {
132
+ done: true,
133
+ value: void 0
134
+ } : {
135
+ done: false,
136
+ value
137
+ };
138
+ },
139
+ async return() {
140
+ await reader.cancel();
141
+ return {
142
+ done: true,
143
+ value: void 0
144
+ };
145
+ }
146
+ };
147
+ }
148
+ /** Close the underlying stream cleanly. Idempotent. */
149
+ close() {
150
+ if (this._closed) return;
151
+ this._closed = true;
152
+ this.signal.removeEventListener("abort", this._onAbort);
153
+ this._resolvePull();
154
+ if (this._rsController) {
155
+ try {
156
+ this._rsController.close();
157
+ } catch {
158
+ }
159
+ }
160
+ }
161
+ /** Error the underlying stream. Idempotent. */
162
+ error(err) {
163
+ if (this._closed) return;
164
+ this._closed = true;
165
+ this.signal.removeEventListener("abort", this._onAbort);
166
+ this._resolvePull();
167
+ if (this._rsController) {
168
+ try {
169
+ this._rsController.error(err);
170
+ } catch {
171
+ }
172
+ }
173
+ }
174
+ _onAbort = /* @__PURE__ */ __name(() => {
175
+ this._resolvePull();
176
+ const callbacks = this._abortCallbacks;
177
+ this._abortCallbacks = [];
178
+ for (const cb of callbacks) {
179
+ try {
180
+ cb();
181
+ } catch {
182
+ }
183
+ }
184
+ }, "_onAbort");
185
+ _resolvePull() {
186
+ const resolve = this._pullResolve;
187
+ if (resolve) {
188
+ this._pullResolve = null;
189
+ resolve();
190
+ }
191
+ }
192
+ _waitForPull() {
193
+ return new Promise((resolve) => {
194
+ this._pullResolve = resolve;
195
+ });
196
+ }
197
+ };
198
+
199
+ // src/writers.ts
200
+ var TEXT_DECODER = new TextDecoder();
201
+ var BaseWriter = class BaseWriter2 {
202
+ static {
203
+ __name(this, "BaseWriter");
204
+ }
205
+ controller;
206
+ constructor(controller) {
207
+ this.controller = controller;
208
+ }
209
+ get aborted() {
210
+ return this.controller.aborted;
211
+ }
212
+ get signal() {
213
+ return this.controller.signal;
214
+ }
215
+ onAbort(fn) {
216
+ this.controller.onAbort(fn);
217
+ }
218
+ /**
219
+ * Consume an existing producer into this response. Single normalization path;
220
+ * stops and throws `StreamAbortedError` if the client disconnects mid-consume.
221
+ */
222
+ async consume(source) {
223
+ const iterator = this.controller.normalize(source);
224
+ try {
225
+ for (; ; ) {
226
+ const result = await iterator.next();
227
+ if (result.done) return;
228
+ await this.write(this.mapChunk(result.value));
229
+ }
230
+ } finally {
231
+ await iterator.return?.(void 0);
232
+ }
233
+ }
234
+ };
235
+ var TextWriter = class extends BaseWriter {
236
+ static {
237
+ __name(this, "TextWriter");
238
+ }
239
+ write(chunk) {
240
+ return typeof chunk === "string" ? this.controller.enqueueText(chunk) : this.controller.enqueue(chunk);
241
+ }
242
+ mapChunk(chunk) {
243
+ return chunk;
244
+ }
245
+ };
246
+ var SSEWriter = class extends BaseWriter {
247
+ static {
248
+ __name(this, "SSEWriter");
249
+ }
250
+ write(event) {
251
+ return this.controller.enqueueText(formatSSE(event));
252
+ }
253
+ mapChunk(chunk) {
254
+ const data = chunk instanceof Uint8Array ? TEXT_DECODER.decode(chunk) : chunk;
255
+ return {
256
+ data
257
+ };
258
+ }
259
+ };
260
+ var NDJSONWriter = class extends BaseWriter {
261
+ static {
262
+ __name(this, "NDJSONWriter");
263
+ }
264
+ write(value) {
265
+ return this.controller.enqueueText(`${JSON.stringify(value)}
266
+ `);
267
+ }
268
+ mapChunk(chunk) {
269
+ return chunk;
270
+ }
271
+ };
272
+
273
+ // src/run.ts
274
+ var CONTENT_TYPE = {
275
+ text: "text/plain; charset=utf-8",
276
+ sse: "text/event-stream; charset=utf-8",
277
+ ndjson: "application/x-ndjson; charset=utf-8"
278
+ };
279
+ function runStream(ctx, contentType, makeWriter, run, extraHeaders) {
280
+ ctx.set("Content-Type", contentType);
281
+ if (extraHeaders) {
282
+ for (const [field, value] of Object.entries(extraHeaders)) {
283
+ ctx.set(field, value);
284
+ }
285
+ }
286
+ const controller = new StreamController(ctx.signal);
287
+ const readable = new ReadableStream({
288
+ start(rsController) {
289
+ controller.attach(rsController);
290
+ const writer = makeWriter(controller);
291
+ void (async () => {
292
+ try {
293
+ await run(writer);
294
+ controller.close();
295
+ } catch (err) {
296
+ if (err instanceof StreamAbortedError) {
297
+ controller.close();
298
+ } else {
299
+ controller.error(err);
300
+ }
301
+ }
302
+ })();
303
+ },
304
+ pull() {
305
+ controller.onPull();
306
+ },
307
+ cancel() {
308
+ controller.onPull();
309
+ }
310
+ });
311
+ return ctx.sendStream(readable);
312
+ }
313
+ __name(runStream, "runStream");
314
+ function runTextStream(ctx, run) {
315
+ return runStream(ctx, CONTENT_TYPE.text, (c) => new TextWriter(c), run);
316
+ }
317
+ __name(runTextStream, "runTextStream");
318
+ function runSSEStream(ctx, run) {
319
+ return runStream(ctx, CONTENT_TYPE.sse, (c) => new SSEWriter(c), run, {
320
+ "Cache-Control": "no-cache"
321
+ });
322
+ }
323
+ __name(runSSEStream, "runSSEStream");
324
+ function runNDJSONStream(ctx, run) {
325
+ return runStream(ctx, CONTENT_TYPE.ndjson, (c) => new NDJSONWriter(c), run);
326
+ }
327
+ __name(runNDJSONStream, "runNDJSONStream");
328
+ export {
329
+ NDJSONWriter,
330
+ SSEWriter,
331
+ StreamAbortedError,
332
+ StreamController,
333
+ TextWriter,
334
+ formatSSE,
335
+ runNDJSONStream,
336
+ runSSEStream,
337
+ runTextStream
338
+ };
339
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/errors.ts","../src/sse-format.ts","../src/stream-controller.ts","../src/writers.ts","../src/run.ts"],"sourcesContent":["/**\n * @nextrush/stream - Errors\n *\n * @packageDocumentation\n */\n\n/**\n * Thrown by a writer's `write()`/`consume()` once the client has disconnected.\n *\n * @remarks\n * This is a control-flow signal, not an HTTP error — the client is already gone,\n * so nothing is sent to them. `ctx.stream()`/`ctx.sse()`/`ctx.ndjson()` catch it\n * at the top-level boundary and treat it as a clean, expected shutdown: it is\n * never logged as a failure and never re-thrown to the caller.\n *\n * A handler that wants to distinguish \"I was cancelled\" from \"something broke\"\n * can catch this explicitly; a handler that does nothing special still cannot\n * produce a silently-corrupted response, because the throw happens before any\n * partial write.\n */\nexport class StreamAbortedError extends Error {\n override readonly name = 'StreamAbortedError';\n\n constructor() {\n super('Cannot write to stream: client has disconnected.');\n // Restore prototype chain for reliable `instanceof` after transpilation.\n Object.setPrototypeOf(this, StreamAbortedError.prototype);\n }\n}\n","/**\n * @nextrush/stream - Server-Sent Events wire formatting\n *\n * @packageDocumentation\n */\n\nimport type { SSEEvent } from '@nextrush/types';\n\n/**\n * Format a single {@link SSEEvent} into its `text/event-stream` wire representation.\n *\n * @remarks\n * Handles every framing detail so handlers never touch it:\n * - `data`: objects are `JSON.stringify`'d; strings are sent verbatim.\n * - Multi-line `data` is split into one `data:` line per line, per the SSE spec\n * (a raw `\\n` inside a single `data:` field would corrupt the stream).\n * - Optional `event:`, `id:`, and `retry:` fields precede the data lines.\n * - The event is terminated by a blank line (`\\n\\n`).\n *\n * Carriage returns and newlines are stripped from `event`/`id` to prevent\n * field injection (a `\\n` in `id` would otherwise start a new field/event).\n *\n * @param event - The event to format.\n * @returns The event as an SSE wire-format string.\n */\nexport function formatSSE(event: SSEEvent): string {\n let out = '';\n\n if (event.event !== undefined) {\n out += `event: ${sanitizeField(event.event)}\\n`;\n }\n if (event.id !== undefined) {\n out += `id: ${sanitizeField(event.id)}\\n`;\n }\n if (event.retry !== undefined) {\n out += `retry: ${String(Math.trunc(event.retry))}\\n`;\n }\n\n const data = typeof event.data === 'string' ? event.data : JSON.stringify(event.data);\n // Per SSE spec, each line of the payload is its own `data:` field.\n // Split on \\n; also strip any \\r so CRLF sources don't leak a stray \\r.\n for (const line of data.split('\\n')) {\n out += `data: ${line.replace(/\\r$/, '')}\\n`;\n }\n\n // Blank line terminates the event.\n out += '\\n';\n return out;\n}\n\n/** Strip CR/LF so a field value cannot inject additional SSE fields or events. */\nfunction sanitizeField(value: string): string {\n return value.replace(/[\\r\\n]/g, '');\n}\n","/**\n * @nextrush/stream - StreamController\n *\n * The single internal component that owns streaming lifecycle: abort tracking,\n * enqueue, cooperative backpressure, source normalization, and close/cleanup.\n * The protocol writers ({@link TextWriter}/{@link SSEWriter}/{@link NDJSONWriter})\n * are thin formatting wrappers over this — they never touch lifecycle directly.\n *\n * See docs/RFC/request-data/003-stream.md §5.\n *\n * @packageDocumentation\n */\n\nimport { StreamAbortedError } from './errors';\n\n/** Shared encoder — avoids per-call allocation. */\nconst TEXT_ENCODER = new TextEncoder();\n\n/**\n * Owns the underlying `ReadableStream` controller and all streaming lifecycle.\n *\n * @remarks\n * One instance per streaming response, shared by exactly one writer.\n */\nexport class StreamController {\n /** Fires when the client disconnects. */\n readonly signal: AbortSignal;\n\n private _rsController: ReadableStreamDefaultController<Uint8Array> | null = null;\n private _pullResolve: (() => void) | null = null;\n private _abortCallbacks: (() => void)[] = [];\n private _closed = false;\n\n constructor(signal: AbortSignal) {\n this.signal = signal;\n if (!signal.aborted) {\n signal.addEventListener('abort', this._onAbort, { once: true });\n }\n }\n\n /** `true` once the client has disconnected. */\n get aborted(): boolean {\n return this.signal.aborted;\n }\n\n /**\n * @internal Wire the underlying `ReadableStream` controller. Called once from\n * the stream's `start()`.\n */\n attach(controller: ReadableStreamDefaultController<Uint8Array>): void {\n this._rsController = controller;\n }\n\n /**\n * @internal Release a pending backpressure wait. Called from the stream's\n * `pull()` when the consumer is ready for more data.\n */\n onPull(): void {\n this._resolvePull();\n }\n\n /**\n * Register a cleanup callback invoked once when the client disconnects.\n * Invoked immediately if already aborted.\n */\n onAbort(fn: () => void): void {\n if (this.aborted) {\n fn();\n return;\n }\n this._abortCallbacks.push(fn);\n }\n\n /**\n * Enqueue raw bytes, applying cooperative backpressure.\n *\n * @throws StreamAbortedError if the client has disconnected.\n */\n async enqueue(chunk: Uint8Array): Promise<void> {\n if (this.aborted) throw new StreamAbortedError();\n const controller = this._rsController;\n if (!controller) {\n throw new Error('StreamController is not attached to a stream.');\n }\n controller.enqueue(chunk);\n // Backpressure: if the consumer's buffer is full, wait until the next pull().\n if ((controller.desiredSize ?? 1) <= 0) {\n await this._waitForPull();\n // Re-check via the signal directly: the client may have disconnected\n // while we were parked on backpressure.\n if (this.signal.aborted) throw new StreamAbortedError();\n }\n }\n\n /** Encode a UTF-8 string and enqueue it. */\n enqueueText(text: string): Promise<void> {\n return this.enqueue(TEXT_ENCODER.encode(text));\n }\n\n /**\n * Normalize any accepted source shape to a single async-iterator.\n *\n * @remarks\n * The one and only place that branches on source type. `AsyncIterable`\n * (including Node `Readable`, which implements `Symbol.asyncIterator`) is used\n * directly; a bare Web `ReadableStream` is adapted via its reader.\n */\n normalize<T>(source: AsyncIterable<T> | ReadableStream<T>): AsyncIterator<T> {\n if (Symbol.asyncIterator in source) {\n return (source as AsyncIterable<T>)[Symbol.asyncIterator]();\n }\n const reader = (source as ReadableStream<T>).getReader();\n return {\n async next(): Promise<IteratorResult<T>> {\n const { done, value } = await reader.read();\n return done\n ? { done: true, value: undefined as never }\n : { done: false, value };\n },\n async return(): Promise<IteratorResult<T>> {\n await reader.cancel();\n return { done: true, value: undefined as never };\n },\n };\n }\n\n /** Close the underlying stream cleanly. Idempotent. */\n close(): void {\n if (this._closed) return;\n this._closed = true;\n this.signal.removeEventListener('abort', this._onAbort);\n this._resolvePull();\n if (this._rsController) {\n try {\n this._rsController.close();\n } catch {\n // Already closed or errored by the consumer — nothing to do.\n }\n }\n }\n\n /** Error the underlying stream. Idempotent. */\n error(err: unknown): void {\n if (this._closed) return;\n this._closed = true;\n this.signal.removeEventListener('abort', this._onAbort);\n this._resolvePull();\n if (this._rsController) {\n try {\n this._rsController.error(err);\n } catch {\n // Already closed or errored — nothing to do.\n }\n }\n }\n\n private _onAbort = (): void => {\n // Unblock any writer waiting on backpressure so it observes the abort.\n this._resolvePull();\n const callbacks = this._abortCallbacks;\n this._abortCallbacks = [];\n for (const cb of callbacks) {\n try {\n cb();\n } catch {\n // Cleanup callbacks must not break the abort path.\n }\n }\n };\n\n private _resolvePull(): void {\n const resolve = this._pullResolve;\n if (resolve) {\n this._pullResolve = null;\n resolve();\n }\n }\n\n private _waitForPull(): Promise<void> {\n return new Promise<void>((resolve) => {\n this._pullResolve = resolve;\n });\n }\n}\n","/**\n * @nextrush/stream - Protocol writers\n *\n * Thin formatting wrappers over {@link StreamController}. Each writer differs\n * only in how `write()` encodes its protocol's native unit and how `consume()`\n * maps a raw chunk. All lifecycle (abort, backpressure, close) lives in the\n * controller — not here.\n *\n * See docs/RFC/request-data/003-stream.md §5, §7.\n *\n * @packageDocumentation\n */\n\nimport type {\n NDJSONStreamWriter,\n SSEEvent,\n SSEStreamWriter,\n StreamSource,\n TextStreamWriter,\n} from '@nextrush/types';\nimport { formatSSE } from './sse-format';\nimport type { StreamController } from './stream-controller';\n\n/** Decodes byte chunks to text for protocols whose payload is textual (SSE). */\nconst TEXT_DECODER = new TextDecoder();\n\n/**\n * Shared base: exposes the controller's abort surface and drives `consume()`.\n *\n * @typeParam T - The unit type each source chunk is mapped to before `write()`.\n */\nabstract class BaseWriter<T> {\n constructor(protected readonly controller: StreamController) {}\n\n get aborted(): boolean {\n return this.controller.aborted;\n }\n\n get signal(): AbortSignal {\n return this.controller.signal;\n }\n\n onAbort(fn: () => void): void {\n this.controller.onAbort(fn);\n }\n\n /** Protocol-specific write of one native unit. */\n abstract write(value: T): Promise<void>;\n\n /** Map one raw source chunk to this protocol's native unit. */\n protected abstract mapChunk(chunk: unknown): T;\n\n /**\n * Consume an existing producer into this response. Single normalization path;\n * stops and throws `StreamAbortedError` if the client disconnects mid-consume.\n */\n async consume(source: StreamSource<unknown>): Promise<void> {\n const iterator = this.controller.normalize(source);\n try {\n for (;;) {\n const result: IteratorResult<unknown> = await iterator.next();\n if (result.done) return;\n await this.write(this.mapChunk(result.value));\n }\n } finally {\n await iterator.return?.(undefined);\n }\n }\n}\n\n/** Raw text/byte writer for `ctx.stream()`. */\nexport class TextWriter\n extends BaseWriter<string | Uint8Array>\n implements TextStreamWriter\n{\n write(chunk: string | Uint8Array): Promise<void> {\n return typeof chunk === 'string'\n ? this.controller.enqueueText(chunk)\n : this.controller.enqueue(chunk);\n }\n\n protected mapChunk(chunk: unknown): string | Uint8Array {\n return chunk as string | Uint8Array;\n }\n}\n\n/** Server-Sent Events writer for `ctx.sse()`. */\nexport class SSEWriter extends BaseWriter<SSEEvent> implements SSEStreamWriter {\n write(event: SSEEvent): Promise<void> {\n return this.controller.enqueueText(formatSSE(event));\n }\n\n protected mapChunk(chunk: unknown): SSEEvent {\n // A consumed producer yields raw text/bytes; wrap each as an SSE `data` event.\n const data = chunk instanceof Uint8Array ? TEXT_DECODER.decode(chunk) : (chunk as string);\n return { data };\n }\n}\n\n/** Newline-delimited JSON writer for `ctx.ndjson()`. */\nexport class NDJSONWriter extends BaseWriter<unknown> implements NDJSONStreamWriter {\n write(value: unknown): Promise<void> {\n return this.controller.enqueueText(`${JSON.stringify(value)}\\n`);\n }\n\n protected mapChunk(chunk: unknown): unknown {\n return chunk;\n }\n}\n","/**\n * @nextrush/stream - Run orchestration\n *\n * Wires a protocol writer to a Web `ReadableStream` and ships it through the\n * adapter's `ctx.sendStream()` primitive. Runtime-agnostic: identical code path\n * on Node (eager pump) and Bun/Deno/Edge (lazy Response body).\n *\n * See docs/RFC/request-data/003-stream.md §5, §6.\n *\n * @packageDocumentation\n */\n\nimport type {\n NDJSONStreamWriter,\n SSEStreamWriter,\n StreamRun,\n TextStreamWriter,\n} from '@nextrush/types';\nimport { StreamAbortedError } from './errors';\nimport { StreamController } from './stream-controller';\nimport { NDJSONWriter, SSEWriter, TextWriter } from './writers';\n\n/**\n * Minimal Context surface `@nextrush/stream` needs. The concrete adapter\n * `Context` satisfies this structurally; kept narrow so unit tests can supply a\n * lightweight fake.\n */\nexport interface StreamCapableContext {\n readonly signal: AbortSignal;\n set(field: string, value: string | number | string[]): void;\n sendStream(source: ReadableStream<Uint8Array>): Promise<void>;\n}\n\nconst CONTENT_TYPE = {\n text: 'text/plain; charset=utf-8',\n sse: 'text/event-stream; charset=utf-8',\n ndjson: 'application/x-ndjson; charset=utf-8',\n} as const;\n\n/**\n * Core streaming loop shared by all three protocols.\n *\n * @remarks\n * The callback runs in a **detached** task launched from the stream's `start()`,\n * intentionally not awaited there: awaiting it would block `pull()` from ever\n * being called, deadlocking backpressure on lazy (web) runtimes. Backpressure is\n * instead relieved cooperatively via `pull()` → `controller.onPull()`.\n */\nfunction runStream<W>(\n ctx: StreamCapableContext,\n contentType: string,\n makeWriter: (controller: StreamController) => W,\n run: (writer: W) => Promise<void>,\n extraHeaders?: Record<string, string>,\n): Promise<void> {\n ctx.set('Content-Type', contentType);\n if (extraHeaders) {\n for (const [field, value] of Object.entries(extraHeaders)) {\n ctx.set(field, value);\n }\n }\n\n const controller = new StreamController(ctx.signal);\n\n const readable = new ReadableStream<Uint8Array>({\n start(rsController): void {\n controller.attach(rsController);\n const writer = makeWriter(controller);\n // Detached on purpose — see function remarks.\n void (async (): Promise<void> => {\n try {\n await run(writer);\n controller.close();\n } catch (err) {\n if (err instanceof StreamAbortedError) {\n // Client disconnected — expected, close cleanly and swallow.\n controller.close();\n } else {\n // Real error: surface it to the stream. On Node this rejects the\n // pump (ctx.sendStream); on web it errors the Response body stream.\n controller.error(err);\n }\n }\n })();\n },\n pull(): void {\n controller.onPull();\n },\n cancel(): void {\n // Consumer cancelled the read side — release any pending backpressure wait.\n controller.onPull();\n },\n });\n\n return ctx.sendStream(readable);\n}\n\n/** Implements `ctx.stream()`. */\nexport function runTextStream(\n ctx: StreamCapableContext,\n run: StreamRun<TextStreamWriter>,\n): Promise<void> {\n return runStream(ctx, CONTENT_TYPE.text, (c) => new TextWriter(c), run);\n}\n\n/** Implements `ctx.sse()`. */\nexport function runSSEStream(\n ctx: StreamCapableContext,\n run: StreamRun<SSEStreamWriter>,\n): Promise<void> {\n return runStream(ctx, CONTENT_TYPE.sse, (c) => new SSEWriter(c), run, {\n 'Cache-Control': 'no-cache',\n });\n}\n\n/** Implements `ctx.ndjson()`. */\nexport function runNDJSONStream(\n ctx: StreamCapableContext,\n run: StreamRun<NDJSONStreamWriter>,\n): Promise<void> {\n return runStream(ctx, CONTENT_TYPE.ndjson, (c) => new NDJSONWriter(c), run);\n}\n"],"mappings":"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","names":["StreamAbortedError","Error","name","Object","setPrototypeOf","prototype","formatSSE","event","out","undefined","sanitizeField","id","retry","String","Math","trunc","data","JSON","stringify","line","split","replace","value","TEXT_ENCODER","TextEncoder","StreamController","signal","_rsController","_pullResolve","_abortCallbacks","_closed","aborted","addEventListener","_onAbort","once","attach","controller","onPull","_resolvePull","onAbort","fn","push","enqueue","chunk","StreamAbortedError","Error","desiredSize","_waitForPull","enqueueText","text","encode","normalize","source","Symbol","asyncIterator","reader","getReader","next","done","value","read","undefined","return","cancel","close","removeEventListener","error","err","callbacks","cb","resolve","Promise","TEXT_DECODER","TextDecoder","BaseWriter","controller","aborted","signal","onAbort","fn","consume","source","iterator","normalize","result","next","done","write","mapChunk","value","return","undefined","TextWriter","chunk","enqueueText","enqueue","SSEWriter","event","formatSSE","data","Uint8Array","decode","NDJSONWriter","JSON","stringify","CONTENT_TYPE","text","sse","ndjson","runStream","ctx","contentType","makeWriter","run","extraHeaders","set","field","value","Object","entries","controller","StreamController","signal","readable","ReadableStream","start","rsController","attach","writer","close","err","StreamAbortedError","error","pull","onPull","cancel","sendStream","runTextStream","c","TextWriter","runSSEStream","SSEWriter","runNDJSONStream","NDJSONWriter"]}
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@nextrush/stream",
3
+ "version": "1.0.0-beta.0",
4
+ "description": "Runtime-agnostic response streaming (text/SSE/NDJSON) for NextRush — built for AI/agentic apps",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "src",
18
+ "README.md"
19
+ ],
20
+ "dependencies": {
21
+ "@nextrush/types": "4.0.0-beta.0"
22
+ },
23
+ "devDependencies": {
24
+ "tsup": "^8.5.1",
25
+ "typescript": "^6.0.3"
26
+ },
27
+ "keywords": [
28
+ "nextrush",
29
+ "stream",
30
+ "sse",
31
+ "server-sent-events",
32
+ "ndjson",
33
+ "ai",
34
+ "streaming",
35
+ "http"
36
+ ],
37
+ "license": "MIT",
38
+ "engines": {
39
+ "node": ">=22.0.0"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "homepage": "https://github.com/0xTanzim/nextRush/tree/main/packages/stream#readme",
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/0xTanzim/nextRush.git",
48
+ "directory": "packages/stream"
49
+ },
50
+ "author": {
51
+ "name": "Tanzim Hossain",
52
+ "email": "tanzimhossain2@gmail.com",
53
+ "url": "https://github.com/0xTanzim"
54
+ },
55
+ "sideEffects": false,
56
+ "scripts": {
57
+ "build": "tsup",
58
+ "dev": "tsup --watch",
59
+ "test": "vitest run",
60
+ "test:watch": "vitest",
61
+ "typecheck": "tsc --noEmit",
62
+ "lint": "eslint src --ignore-pattern '**/__tests__/**'",
63
+ "clean": "rm -rf dist"
64
+ }
65
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @nextrush/stream - Public API surface test
3
+ *
4
+ * Locks the exported symbol set from `src/index.ts`. If this test fails, the
5
+ * public API has changed. Intentional changes require an explicit update to
6
+ * the expected list below, plus a changeset for a published package.
7
+ */
8
+ import { describe, expect, expectTypeOf, it } from 'vitest';
9
+ import * as streamApi from '../index';
10
+ import type { BaseStreamWriter, NDJSONStreamWriter, SSEEvent, SSEStreamWriter, StreamCapableContext, StreamRun, StreamSource, TextStreamWriter } from '../index';
11
+
12
+ describe('Public API surface (runtime exports)', () => {
13
+ it('exports exactly the intended runtime symbols', () => {
14
+ const actualExports = Object.keys(streamApi).sort();
15
+
16
+ // SEALED: intentional public runtime API surface.
17
+ const expectedRuntime = [
18
+ 'StreamAbortedError',
19
+ 'formatSSE',
20
+ 'StreamController',
21
+ 'runNDJSONStream',
22
+ 'runSSEStream',
23
+ 'runTextStream',
24
+ 'NDJSONWriter',
25
+ 'SSEWriter',
26
+ 'TextWriter',
27
+ ].sort();
28
+
29
+ expect(actualExports).toEqual(expectedRuntime);
30
+ });
31
+ });
32
+
33
+ describe('Public API surface (type-only exports)', () => {
34
+ it('the type-only surface stays importable from the barrel', () => {
35
+ // Compile-time only: removing/renaming any of these in src/index.ts fails
36
+ // this file to type-check.
37
+ type Surface = [StreamCapableContext, BaseStreamWriter, NDJSONStreamWriter, SSEEvent, SSEStreamWriter, StreamRun, StreamSource, TextStreamWriter];
38
+ expectTypeOf<Surface>().not.toBeNever();
39
+ });
40
+ });