@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.
@@ -0,0 +1,445 @@
1
+ /**
2
+ * @nextrush/stream - Tests
3
+ *
4
+ * Exercises the full runtime-agnostic streaming path through a mock context that
5
+ * consumes the ReadableStream like a real web runtime (driving pull/backpressure),
6
+ * plus focused unit tests for SSE formatting, normalization, and abort behavior.
7
+ */
8
+
9
+ import { describe, expect, it, vi } from 'vitest';
10
+ import { StreamAbortedError } from '../errors';
11
+ import {
12
+ runNDJSONStream,
13
+ runSSEStream,
14
+ runTextStream,
15
+ type StreamCapableContext,
16
+ } from '../run';
17
+ import { formatSSE } from '../sse-format';
18
+ import { StreamController } from '../stream-controller';
19
+
20
+ /** A mock context that consumes the stream to completion, like a web runtime. */
21
+ function createMockCtx(): StreamCapableContext & {
22
+ readonly headers: Record<string, string>;
23
+ collected(): Promise<string>;
24
+ abort(): void;
25
+ } {
26
+ const ac = new AbortController();
27
+ const headers: Record<string, string> = {};
28
+ let collectedPromise: Promise<Uint8Array[]> = Promise.resolve([]);
29
+
30
+ return {
31
+ signal: ac.signal,
32
+ headers,
33
+ set(field, value) {
34
+ headers[field] = String(value);
35
+ },
36
+ sendStream(rs) {
37
+ collectedPromise = (async () => {
38
+ const reader = rs.getReader();
39
+ const chunks: Uint8Array[] = [];
40
+ for (;;) {
41
+ const { done, value } = await reader.read();
42
+ if (done) break;
43
+ chunks.push(value);
44
+ }
45
+ return chunks;
46
+ })();
47
+ return collectedPromise.then(() => undefined);
48
+ },
49
+ async collected() {
50
+ const chunks = await collectedPromise;
51
+ return Buffer.concat(chunks.map((c) => Buffer.from(c))).toString('utf8');
52
+ },
53
+ abort() {
54
+ ac.abort();
55
+ },
56
+ };
57
+ }
58
+
59
+ describe('formatSSE', () => {
60
+ it('formats a minimal data-only event', () => {
61
+ expect(formatSSE({ data: 'hello' })).toBe('data: hello\n\n');
62
+ });
63
+
64
+ it('JSON-serializes non-string data', () => {
65
+ expect(formatSSE({ data: { a: 1 } })).toBe('data: {"a":1}\n\n');
66
+ });
67
+
68
+ it('emits event/id/retry fields before data', () => {
69
+ expect(formatSSE({ data: 'x', event: 'token', id: '7', retry: 1000 })).toBe(
70
+ 'event: token\nid: 7\nretry: 1000\ndata: x\n\n',
71
+ );
72
+ });
73
+
74
+ it('splits multi-line data into one data: field per line', () => {
75
+ expect(formatSSE({ data: 'a\nb' })).toBe('data: a\ndata: b\n\n');
76
+ });
77
+
78
+ it('strips CR/LF from event and id to prevent field injection', () => {
79
+ expect(formatSSE({ data: 'x', event: 'a\nb', id: 'c\rd' })).toBe(
80
+ 'event: ab\nid: cd\ndata: x\n\n',
81
+ );
82
+ });
83
+
84
+ it('truncates a fractional retry to an integer', () => {
85
+ expect(formatSSE({ data: 'x', retry: 1500.9 })).toContain('retry: 1500\n');
86
+ });
87
+ });
88
+
89
+ describe('StreamController.normalize', () => {
90
+ it('adapts a Web ReadableStream to an async iterator', async () => {
91
+ const controller = new StreamController(new AbortController().signal);
92
+ const rs = new ReadableStream<string>({
93
+ start(c) {
94
+ c.enqueue('a');
95
+ c.enqueue('b');
96
+ c.close();
97
+ },
98
+ });
99
+ const it = controller.normalize(rs);
100
+ expect((await it.next()).value).toBe('a');
101
+ expect((await it.next()).value).toBe('b');
102
+ expect((await it.next()).done).toBe(true);
103
+ });
104
+
105
+ it('uses an AsyncIterable directly', async () => {
106
+ const controller = new StreamController(new AbortController().signal);
107
+ async function* gen() {
108
+ yield 1;
109
+ yield 2;
110
+ }
111
+ const it = controller.normalize(gen());
112
+ expect((await it.next()).value).toBe(1);
113
+ expect((await it.next()).value).toBe(2);
114
+ expect((await it.next()).done).toBe(true);
115
+ });
116
+ });
117
+
118
+ describe('ctx.stream() — text', () => {
119
+ it('writes text chunks in order', async () => {
120
+ const ctx = createMockCtx();
121
+ await runTextStream(ctx, async (w) => {
122
+ await w.write('Hello, ');
123
+ await w.write('World');
124
+ });
125
+ expect(await ctx.collected()).toBe('Hello, World');
126
+ expect(ctx.headers['Content-Type']).toBe('text/plain; charset=utf-8');
127
+ });
128
+
129
+ it('writes raw bytes', async () => {
130
+ const ctx = createMockCtx();
131
+ await runTextStream(ctx, async (w) => {
132
+ await w.write(new Uint8Array([0x68, 0x69])); // "hi"
133
+ });
134
+ expect(await ctx.collected()).toBe('hi');
135
+ });
136
+
137
+ it('consumes an async iterable', async () => {
138
+ const ctx = createMockCtx();
139
+ async function* gen() {
140
+ yield 'a';
141
+ yield 'b';
142
+ yield 'c';
143
+ }
144
+ await runTextStream(ctx, async (w) => {
145
+ await w.consume(gen());
146
+ });
147
+ expect(await ctx.collected()).toBe('abc');
148
+ });
149
+
150
+ it('consumes a Web ReadableStream', async () => {
151
+ const ctx = createMockCtx();
152
+ const src = new ReadableStream<string>({
153
+ start(c) {
154
+ c.enqueue('x');
155
+ c.enqueue('y');
156
+ c.close();
157
+ },
158
+ });
159
+ await runTextStream(ctx, async (w) => {
160
+ await w.consume(src);
161
+ });
162
+ expect(await ctx.collected()).toBe('xy');
163
+ });
164
+ });
165
+
166
+ describe('ctx.sse() — Server-Sent Events', () => {
167
+ it('frames events and sets the event-stream content type', async () => {
168
+ const ctx = createMockCtx();
169
+ await runSSEStream(ctx, async (w) => {
170
+ await w.write({ data: 'one' });
171
+ await w.write({ data: 'two', event: 'token' });
172
+ });
173
+ expect(await ctx.collected()).toBe('data: one\n\nevent: token\ndata: two\n\n');
174
+ expect(ctx.headers['Content-Type']).toBe('text/event-stream; charset=utf-8');
175
+ expect(ctx.headers['Cache-Control']).toBe('no-cache');
176
+ });
177
+
178
+ it('wraps consumed chunks as data events', async () => {
179
+ const ctx = createMockCtx();
180
+ async function* tokens() {
181
+ yield 'a';
182
+ yield 'b';
183
+ }
184
+ await runSSEStream(ctx, async (w) => {
185
+ await w.consume(tokens());
186
+ });
187
+ expect(await ctx.collected()).toBe('data: a\n\ndata: b\n\n');
188
+ });
189
+ });
190
+
191
+ describe('ctx.ndjson() — newline-delimited JSON', () => {
192
+ it('emits one JSON object per line', async () => {
193
+ const ctx = createMockCtx();
194
+ await runNDJSONStream(ctx, async (w) => {
195
+ await w.write({ step: 1 });
196
+ await w.write({ step: 2 });
197
+ });
198
+ expect(await ctx.collected()).toBe('{"step":1}\n{"step":2}\n');
199
+ expect(ctx.headers['Content-Type']).toBe('application/x-ndjson; charset=utf-8');
200
+ });
201
+ });
202
+
203
+ describe('cancellation', () => {
204
+ it('write after abort throws StreamAbortedError, swallowed by the run boundary', async () => {
205
+ const ctx = createMockCtx();
206
+ let caught: unknown;
207
+ const onAbortSpy = vi.fn();
208
+
209
+ await runTextStream(ctx, async (w) => {
210
+ w.onAbort(onAbortSpy);
211
+ await w.write('before');
212
+ ctx.abort();
213
+ try {
214
+ await w.write('after');
215
+ } catch (err) {
216
+ caught = err;
217
+ throw err; // propagate — run boundary must swallow StreamAbortedError
218
+ }
219
+ });
220
+
221
+ expect(caught).toBeInstanceOf(StreamAbortedError);
222
+ expect(onAbortSpy).toHaveBeenCalledTimes(1);
223
+ // "before" may or may not have flushed depending on timing, but the stream
224
+ // must have closed cleanly (no throw out of runTextStream).
225
+ });
226
+
227
+ it('onAbort fires immediately if already aborted', () => {
228
+ const ac = new AbortController();
229
+ ac.abort();
230
+ const controller = new StreamController(ac.signal);
231
+ const spy = vi.fn();
232
+ controller.onAbort(spy);
233
+ expect(spy).toHaveBeenCalledTimes(1);
234
+ expect(controller.aborted).toBe(true);
235
+ });
236
+
237
+ it('enqueue throws StreamAbortedError when already aborted', async () => {
238
+ const ac = new AbortController();
239
+ ac.abort();
240
+ const controller = new StreamController(ac.signal);
241
+ await expect(controller.enqueue(new Uint8Array([1]))).rejects.toBeInstanceOf(
242
+ StreamAbortedError,
243
+ );
244
+ });
245
+ });
246
+
247
+ describe('error propagation', () => {
248
+ it('a non-abort error thrown in the callback surfaces to the stream consumer', async () => {
249
+ const ctx = createMockCtx();
250
+ const boom = new Error('boom');
251
+ await expect(
252
+ runTextStream(ctx, async (w) => {
253
+ await w.write('partial');
254
+ throw boom;
255
+ }),
256
+ ).rejects.toThrow('boom');
257
+ });
258
+ });
259
+
260
+ describe('writer abort surface', () => {
261
+ it('exposes signal and aborted on the writer', async () => {
262
+ const ctx = createMockCtx();
263
+ let seenSignalIsAbortSignal = false;
264
+ let seenAbortedBefore = true;
265
+ await runTextStream(ctx, async (w) => {
266
+ seenSignalIsAbortSignal = w.signal instanceof AbortSignal;
267
+ seenAbortedBefore = w.aborted;
268
+ await w.write('x');
269
+ });
270
+ expect(seenSignalIsAbortSignal).toBe(true);
271
+ expect(seenAbortedBefore).toBe(false);
272
+ expect(await ctx.collected()).toBe('x');
273
+ });
274
+ });
275
+
276
+ describe('consume byte sources', () => {
277
+ it('sse consume decodes Uint8Array chunks to data events', async () => {
278
+ const ctx = createMockCtx();
279
+ const enc = new TextEncoder();
280
+ async function* bytes() {
281
+ yield enc.encode('a');
282
+ yield enc.encode('b');
283
+ }
284
+ await runSSEStream(ctx, async (w) => {
285
+ await w.consume(bytes());
286
+ });
287
+ expect(await ctx.collected()).toBe('data: a\n\ndata: b\n\n');
288
+ });
289
+
290
+ it('ndjson consume emits one line per value', async () => {
291
+ const ctx = createMockCtx();
292
+ async function* values() {
293
+ yield { a: 1 };
294
+ yield { b: 2 };
295
+ }
296
+ await runNDJSONStream(ctx, async (w) => {
297
+ await w.consume(values());
298
+ });
299
+ expect(await ctx.collected()).toBe('{"a":1}\n{"b":2}\n');
300
+ });
301
+ });
302
+
303
+ describe('StreamController lifecycle', () => {
304
+ function attachedController() {
305
+ const sc = new StreamController(new AbortController().signal);
306
+ let rsc!: ReadableStreamDefaultController<Uint8Array>;
307
+ // Constructing the stream synchronously invokes start(), capturing the controller.
308
+ void new ReadableStream<Uint8Array>({
309
+ start(c) {
310
+ rsc = c;
311
+ },
312
+ });
313
+ sc.attach(rsc);
314
+ return sc;
315
+ }
316
+
317
+ it('error() then close() is idempotent (close is a no-op after error)', () => {
318
+ const sc = attachedController();
319
+ expect(() => sc.error(new Error('x'))).not.toThrow();
320
+ expect(() => sc.close()).not.toThrow(); // already closed via error()
321
+ });
322
+
323
+ it('close() twice is idempotent', () => {
324
+ const sc = attachedController();
325
+ expect(() => sc.close()).not.toThrow();
326
+ expect(() => sc.close()).not.toThrow();
327
+ });
328
+
329
+ it('enqueue on an unattached controller throws a clear error', async () => {
330
+ const sc = new StreamController(new AbortController().signal);
331
+ await expect(sc.enqueue(new Uint8Array([1]))).rejects.toThrow(/not attached/);
332
+ });
333
+
334
+ it('close() and error() on an unattached controller are safe no-ops', () => {
335
+ const sc1 = new StreamController(new AbortController().signal);
336
+ expect(() => sc1.close()).not.toThrow();
337
+ const sc2 = new StreamController(new AbortController().signal);
338
+ expect(() => sc2.error(new Error('x'))).not.toThrow();
339
+ });
340
+
341
+ it('normalize() reader adapter cancels the source on return()', async () => {
342
+ const sc = new StreamController(new AbortController().signal);
343
+ let cancelled = false;
344
+ const rs = new ReadableStream<string>({
345
+ start(c) {
346
+ c.enqueue('a');
347
+ },
348
+ cancel() {
349
+ cancelled = true;
350
+ },
351
+ });
352
+ const it = sc.normalize(rs);
353
+ expect((await it.next()).value).toBe('a');
354
+ const ret = await it.return?.(undefined as never);
355
+ expect(ret?.done).toBe(true);
356
+ expect(cancelled).toBe(true);
357
+ });
358
+ });
359
+
360
+ describe('consumer cancellation', () => {
361
+ it('releases a backpressure wait when the consumer cancels the read side', async () => {
362
+ const ac = new AbortController();
363
+ let cancelled = false;
364
+ const ctx: StreamCapableContext = {
365
+ signal: ac.signal,
366
+ set() {
367
+ /* no-op */
368
+ },
369
+ sendStream(rs) {
370
+ return (async () => {
371
+ const reader = rs.getReader();
372
+ await reader.read(); // pull first chunk → start()/pull()
373
+ await reader.cancel(); // → run.ts cancel() → controller.onPull()
374
+ cancelled = true;
375
+ })();
376
+ },
377
+ };
378
+
379
+ await runTextStream(ctx, async (w) => {
380
+ await w.write('a');
381
+ try {
382
+ await w.write('b'); // may throw once the stream is cancelled
383
+ } catch {
384
+ /* expected after cancel */
385
+ }
386
+ });
387
+
388
+ expect(cancelled).toBe(true);
389
+ });
390
+ });
391
+
392
+ describe('edge cases', () => {
393
+ it('an empty stream (no writes) closes cleanly with an empty body', async () => {
394
+ const ctx = createMockCtx();
395
+ await runTextStream(ctx, async () => {
396
+ // writes nothing
397
+ });
398
+ expect(await ctx.collected()).toBe('');
399
+ });
400
+
401
+ it('a client already gone before streaming starts: first write throws and is swallowed', async () => {
402
+ const ctx = createMockCtx();
403
+ ctx.abort(); // client disconnected before the handler streams anything
404
+ let threw = false;
405
+ await runTextStream(ctx, async (w) => {
406
+ try {
407
+ await w.write('never delivered');
408
+ } catch {
409
+ threw = true;
410
+ throw new StreamAbortedError();
411
+ }
412
+ });
413
+ expect(threw).toBe(true);
414
+ expect(await ctx.collected()).toBe('');
415
+ });
416
+
417
+ it('a source that throws mid-iteration propagates the error to the consumer', async () => {
418
+ const ctx = createMockCtx();
419
+ async function* faulty() {
420
+ yield 'ok';
421
+ throw new Error('source failed');
422
+ }
423
+ await expect(
424
+ runTextStream(ctx, async (w) => {
425
+ await w.consume(faulty());
426
+ }),
427
+ ).rejects.toThrow('source failed');
428
+ });
429
+
430
+ it('empty-string SSE data still frames a valid event', async () => {
431
+ const ctx = createMockCtx();
432
+ await runSSEStream(ctx, async (w) => {
433
+ await w.write({ data: '' });
434
+ });
435
+ expect(await ctx.collected()).toBe('data: \n\n');
436
+ });
437
+
438
+ it('SSE retry:0 is emitted (not treated as absent)', async () => {
439
+ const ctx = createMockCtx();
440
+ await runSSEStream(ctx, async (w) => {
441
+ await w.write({ data: 'x', retry: 0 });
442
+ });
443
+ expect(await ctx.collected()).toBe('retry: 0\ndata: x\n\n');
444
+ });
445
+ });
package/src/errors.ts ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * @nextrush/stream - Errors
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+
7
+ /**
8
+ * Thrown by a writer's `write()`/`consume()` once the client has disconnected.
9
+ *
10
+ * @remarks
11
+ * This is a control-flow signal, not an HTTP error — the client is already gone,
12
+ * so nothing is sent to them. `ctx.stream()`/`ctx.sse()`/`ctx.ndjson()` catch it
13
+ * at the top-level boundary and treat it as a clean, expected shutdown: it is
14
+ * never logged as a failure and never re-thrown to the caller.
15
+ *
16
+ * A handler that wants to distinguish "I was cancelled" from "something broke"
17
+ * can catch this explicitly; a handler that does nothing special still cannot
18
+ * produce a silently-corrupted response, because the throw happens before any
19
+ * partial write.
20
+ */
21
+ export class StreamAbortedError extends Error {
22
+ override readonly name = 'StreamAbortedError';
23
+
24
+ constructor() {
25
+ super('Cannot write to stream: client has disconnected.');
26
+ // Restore prototype chain for reliable `instanceof` after transpilation.
27
+ Object.setPrototypeOf(this, StreamAbortedError.prototype);
28
+ }
29
+ }
package/src/index.ts ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @nextrush/stream — Runtime-agnostic response streaming for NextRush.
3
+ *
4
+ * Provides `ctx.stream()` / `ctx.sse()` / `ctx.ndjson()` implementations wired by
5
+ * the platform adapters, plus the `StreamController` foundation and
6
+ * `StreamAbortedError`. Built for AI/agentic apps (LLM token streaming) but works
7
+ * for any chunked response (CSV export, progress logs, structured traces).
8
+ *
9
+ * See docs/RFC/request-data/003-stream.md.
10
+ *
11
+ * @packageDocumentation
12
+ * @module @nextrush/stream
13
+ */
14
+
15
+ export { StreamAbortedError } from './errors';
16
+ export { formatSSE } from './sse-format';
17
+ export { StreamController } from './stream-controller';
18
+ export {
19
+ runNDJSONStream,
20
+ runSSEStream,
21
+ runTextStream,
22
+ type StreamCapableContext,
23
+ } from './run';
24
+ export { NDJSONWriter, SSEWriter, TextWriter } from './writers';
25
+
26
+ // Re-export the writer contracts for convenience (canonical home: @nextrush/types).
27
+ export type {
28
+ BaseStreamWriter,
29
+ NDJSONStreamWriter,
30
+ SSEEvent,
31
+ SSEStreamWriter,
32
+ StreamRun,
33
+ StreamSource,
34
+ TextStreamWriter,
35
+ } from '@nextrush/types';
package/src/run.ts ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * @nextrush/stream - Run orchestration
3
+ *
4
+ * Wires a protocol writer to a Web `ReadableStream` and ships it through the
5
+ * adapter's `ctx.sendStream()` primitive. Runtime-agnostic: identical code path
6
+ * on Node (eager pump) and Bun/Deno/Edge (lazy Response body).
7
+ *
8
+ * See docs/RFC/request-data/003-stream.md §5, §6.
9
+ *
10
+ * @packageDocumentation
11
+ */
12
+
13
+ import type {
14
+ NDJSONStreamWriter,
15
+ SSEStreamWriter,
16
+ StreamRun,
17
+ TextStreamWriter,
18
+ } from '@nextrush/types';
19
+ import { StreamAbortedError } from './errors';
20
+ import { StreamController } from './stream-controller';
21
+ import { NDJSONWriter, SSEWriter, TextWriter } from './writers';
22
+
23
+ /**
24
+ * Minimal Context surface `@nextrush/stream` needs. The concrete adapter
25
+ * `Context` satisfies this structurally; kept narrow so unit tests can supply a
26
+ * lightweight fake.
27
+ */
28
+ export interface StreamCapableContext {
29
+ readonly signal: AbortSignal;
30
+ set(field: string, value: string | number | string[]): void;
31
+ sendStream(source: ReadableStream<Uint8Array>): Promise<void>;
32
+ }
33
+
34
+ const CONTENT_TYPE = {
35
+ text: 'text/plain; charset=utf-8',
36
+ sse: 'text/event-stream; charset=utf-8',
37
+ ndjson: 'application/x-ndjson; charset=utf-8',
38
+ } as const;
39
+
40
+ /**
41
+ * Core streaming loop shared by all three protocols.
42
+ *
43
+ * @remarks
44
+ * The callback runs in a **detached** task launched from the stream's `start()`,
45
+ * intentionally not awaited there: awaiting it would block `pull()` from ever
46
+ * being called, deadlocking backpressure on lazy (web) runtimes. Backpressure is
47
+ * instead relieved cooperatively via `pull()` → `controller.onPull()`.
48
+ */
49
+ function runStream<W>(
50
+ ctx: StreamCapableContext,
51
+ contentType: string,
52
+ makeWriter: (controller: StreamController) => W,
53
+ run: (writer: W) => Promise<void>,
54
+ extraHeaders?: Record<string, string>,
55
+ ): Promise<void> {
56
+ ctx.set('Content-Type', contentType);
57
+ if (extraHeaders) {
58
+ for (const [field, value] of Object.entries(extraHeaders)) {
59
+ ctx.set(field, value);
60
+ }
61
+ }
62
+
63
+ const controller = new StreamController(ctx.signal);
64
+
65
+ const readable = new ReadableStream<Uint8Array>({
66
+ start(rsController): void {
67
+ controller.attach(rsController);
68
+ const writer = makeWriter(controller);
69
+ // Detached on purpose — see function remarks.
70
+ void (async (): Promise<void> => {
71
+ try {
72
+ await run(writer);
73
+ controller.close();
74
+ } catch (err) {
75
+ if (err instanceof StreamAbortedError) {
76
+ // Client disconnected — expected, close cleanly and swallow.
77
+ controller.close();
78
+ } else {
79
+ // Real error: surface it to the stream. On Node this rejects the
80
+ // pump (ctx.sendStream); on web it errors the Response body stream.
81
+ controller.error(err);
82
+ }
83
+ }
84
+ })();
85
+ },
86
+ pull(): void {
87
+ controller.onPull();
88
+ },
89
+ cancel(): void {
90
+ // Consumer cancelled the read side — release any pending backpressure wait.
91
+ controller.onPull();
92
+ },
93
+ });
94
+
95
+ return ctx.sendStream(readable);
96
+ }
97
+
98
+ /** Implements `ctx.stream()`. */
99
+ export function runTextStream(
100
+ ctx: StreamCapableContext,
101
+ run: StreamRun<TextStreamWriter>,
102
+ ): Promise<void> {
103
+ return runStream(ctx, CONTENT_TYPE.text, (c) => new TextWriter(c), run);
104
+ }
105
+
106
+ /** Implements `ctx.sse()`. */
107
+ export function runSSEStream(
108
+ ctx: StreamCapableContext,
109
+ run: StreamRun<SSEStreamWriter>,
110
+ ): Promise<void> {
111
+ return runStream(ctx, CONTENT_TYPE.sse, (c) => new SSEWriter(c), run, {
112
+ 'Cache-Control': 'no-cache',
113
+ });
114
+ }
115
+
116
+ /** Implements `ctx.ndjson()`. */
117
+ export function runNDJSONStream(
118
+ ctx: StreamCapableContext,
119
+ run: StreamRun<NDJSONStreamWriter>,
120
+ ): Promise<void> {
121
+ return runStream(ctx, CONTENT_TYPE.ndjson, (c) => new NDJSONWriter(c), run);
122
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * @nextrush/stream - Server-Sent Events wire formatting
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+
7
+ import type { SSEEvent } from '@nextrush/types';
8
+
9
+ /**
10
+ * Format a single {@link SSEEvent} into its `text/event-stream` wire representation.
11
+ *
12
+ * @remarks
13
+ * Handles every framing detail so handlers never touch it:
14
+ * - `data`: objects are `JSON.stringify`'d; strings are sent verbatim.
15
+ * - Multi-line `data` is split into one `data:` line per line, per the SSE spec
16
+ * (a raw `\n` inside a single `data:` field would corrupt the stream).
17
+ * - Optional `event:`, `id:`, and `retry:` fields precede the data lines.
18
+ * - The event is terminated by a blank line (`\n\n`).
19
+ *
20
+ * Carriage returns and newlines are stripped from `event`/`id` to prevent
21
+ * field injection (a `\n` in `id` would otherwise start a new field/event).
22
+ *
23
+ * @param event - The event to format.
24
+ * @returns The event as an SSE wire-format string.
25
+ */
26
+ export function formatSSE(event: SSEEvent): string {
27
+ let out = '';
28
+
29
+ if (event.event !== undefined) {
30
+ out += `event: ${sanitizeField(event.event)}\n`;
31
+ }
32
+ if (event.id !== undefined) {
33
+ out += `id: ${sanitizeField(event.id)}\n`;
34
+ }
35
+ if (event.retry !== undefined) {
36
+ out += `retry: ${String(Math.trunc(event.retry))}\n`;
37
+ }
38
+
39
+ const data = typeof event.data === 'string' ? event.data : JSON.stringify(event.data);
40
+ // Per SSE spec, each line of the payload is its own `data:` field.
41
+ // Split on \n; also strip any \r so CRLF sources don't leak a stray \r.
42
+ for (const line of data.split('\n')) {
43
+ out += `data: ${line.replace(/\r$/, '')}\n`;
44
+ }
45
+
46
+ // Blank line terminates the event.
47
+ out += '\n';
48
+ return out;
49
+ }
50
+
51
+ /** Strip CR/LF so a field value cannot inject additional SSE fields or events. */
52
+ function sanitizeField(value: string): string {
53
+ return value.replace(/[\r\n]/g, '');
54
+ }