@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
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nextrush/stream - StreamController
|
|
3
|
+
*
|
|
4
|
+
* The single internal component that owns streaming lifecycle: abort tracking,
|
|
5
|
+
* enqueue, cooperative backpressure, source normalization, and close/cleanup.
|
|
6
|
+
* The protocol writers ({@link TextWriter}/{@link SSEWriter}/{@link NDJSONWriter})
|
|
7
|
+
* are thin formatting wrappers over this — they never touch lifecycle directly.
|
|
8
|
+
*
|
|
9
|
+
* See docs/RFC/request-data/003-stream.md §5.
|
|
10
|
+
*
|
|
11
|
+
* @packageDocumentation
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { StreamAbortedError } from './errors';
|
|
15
|
+
|
|
16
|
+
/** Shared encoder — avoids per-call allocation. */
|
|
17
|
+
const TEXT_ENCODER = new TextEncoder();
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Owns the underlying `ReadableStream` controller and all streaming lifecycle.
|
|
21
|
+
*
|
|
22
|
+
* @remarks
|
|
23
|
+
* One instance per streaming response, shared by exactly one writer.
|
|
24
|
+
*/
|
|
25
|
+
export class StreamController {
|
|
26
|
+
/** Fires when the client disconnects. */
|
|
27
|
+
readonly signal: AbortSignal;
|
|
28
|
+
|
|
29
|
+
private _rsController: ReadableStreamDefaultController<Uint8Array> | null = null;
|
|
30
|
+
private _pullResolve: (() => void) | null = null;
|
|
31
|
+
private _abortCallbacks: (() => void)[] = [];
|
|
32
|
+
private _closed = false;
|
|
33
|
+
|
|
34
|
+
constructor(signal: AbortSignal) {
|
|
35
|
+
this.signal = signal;
|
|
36
|
+
if (!signal.aborted) {
|
|
37
|
+
signal.addEventListener('abort', this._onAbort, { once: true });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** `true` once the client has disconnected. */
|
|
42
|
+
get aborted(): boolean {
|
|
43
|
+
return this.signal.aborted;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @internal Wire the underlying `ReadableStream` controller. Called once from
|
|
48
|
+
* the stream's `start()`.
|
|
49
|
+
*/
|
|
50
|
+
attach(controller: ReadableStreamDefaultController<Uint8Array>): void {
|
|
51
|
+
this._rsController = controller;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @internal Release a pending backpressure wait. Called from the stream's
|
|
56
|
+
* `pull()` when the consumer is ready for more data.
|
|
57
|
+
*/
|
|
58
|
+
onPull(): void {
|
|
59
|
+
this._resolvePull();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Register a cleanup callback invoked once when the client disconnects.
|
|
64
|
+
* Invoked immediately if already aborted.
|
|
65
|
+
*/
|
|
66
|
+
onAbort(fn: () => void): void {
|
|
67
|
+
if (this.aborted) {
|
|
68
|
+
fn();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
this._abortCallbacks.push(fn);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Enqueue raw bytes, applying cooperative backpressure.
|
|
76
|
+
*
|
|
77
|
+
* @throws StreamAbortedError if the client has disconnected.
|
|
78
|
+
*/
|
|
79
|
+
async enqueue(chunk: Uint8Array): Promise<void> {
|
|
80
|
+
if (this.aborted) throw new StreamAbortedError();
|
|
81
|
+
const controller = this._rsController;
|
|
82
|
+
if (!controller) {
|
|
83
|
+
throw new Error('StreamController is not attached to a stream.');
|
|
84
|
+
}
|
|
85
|
+
controller.enqueue(chunk);
|
|
86
|
+
// Backpressure: if the consumer's buffer is full, wait until the next pull().
|
|
87
|
+
if ((controller.desiredSize ?? 1) <= 0) {
|
|
88
|
+
await this._waitForPull();
|
|
89
|
+
// Re-check via the signal directly: the client may have disconnected
|
|
90
|
+
// while we were parked on backpressure.
|
|
91
|
+
if (this.signal.aborted) throw new StreamAbortedError();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Encode a UTF-8 string and enqueue it. */
|
|
96
|
+
enqueueText(text: string): Promise<void> {
|
|
97
|
+
return this.enqueue(TEXT_ENCODER.encode(text));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Normalize any accepted source shape to a single async-iterator.
|
|
102
|
+
*
|
|
103
|
+
* @remarks
|
|
104
|
+
* The one and only place that branches on source type. `AsyncIterable`
|
|
105
|
+
* (including Node `Readable`, which implements `Symbol.asyncIterator`) is used
|
|
106
|
+
* directly; a bare Web `ReadableStream` is adapted via its reader.
|
|
107
|
+
*/
|
|
108
|
+
normalize<T>(source: AsyncIterable<T> | ReadableStream<T>): AsyncIterator<T> {
|
|
109
|
+
if (Symbol.asyncIterator in source) {
|
|
110
|
+
return (source as AsyncIterable<T>)[Symbol.asyncIterator]();
|
|
111
|
+
}
|
|
112
|
+
const reader = (source as ReadableStream<T>).getReader();
|
|
113
|
+
return {
|
|
114
|
+
async next(): Promise<IteratorResult<T>> {
|
|
115
|
+
const { done, value } = await reader.read();
|
|
116
|
+
return done
|
|
117
|
+
? { done: true, value: undefined as never }
|
|
118
|
+
: { done: false, value };
|
|
119
|
+
},
|
|
120
|
+
async return(): Promise<IteratorResult<T>> {
|
|
121
|
+
await reader.cancel();
|
|
122
|
+
return { done: true, value: undefined as never };
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Close the underlying stream cleanly. Idempotent. */
|
|
128
|
+
close(): void {
|
|
129
|
+
if (this._closed) return;
|
|
130
|
+
this._closed = true;
|
|
131
|
+
this.signal.removeEventListener('abort', this._onAbort);
|
|
132
|
+
this._resolvePull();
|
|
133
|
+
if (this._rsController) {
|
|
134
|
+
try {
|
|
135
|
+
this._rsController.close();
|
|
136
|
+
} catch {
|
|
137
|
+
// Already closed or errored by the consumer — nothing to do.
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Error the underlying stream. Idempotent. */
|
|
143
|
+
error(err: unknown): void {
|
|
144
|
+
if (this._closed) return;
|
|
145
|
+
this._closed = true;
|
|
146
|
+
this.signal.removeEventListener('abort', this._onAbort);
|
|
147
|
+
this._resolvePull();
|
|
148
|
+
if (this._rsController) {
|
|
149
|
+
try {
|
|
150
|
+
this._rsController.error(err);
|
|
151
|
+
} catch {
|
|
152
|
+
// Already closed or errored — nothing to do.
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private _onAbort = (): void => {
|
|
158
|
+
// Unblock any writer waiting on backpressure so it observes the abort.
|
|
159
|
+
this._resolvePull();
|
|
160
|
+
const callbacks = this._abortCallbacks;
|
|
161
|
+
this._abortCallbacks = [];
|
|
162
|
+
for (const cb of callbacks) {
|
|
163
|
+
try {
|
|
164
|
+
cb();
|
|
165
|
+
} catch {
|
|
166
|
+
// Cleanup callbacks must not break the abort path.
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
private _resolvePull(): void {
|
|
172
|
+
const resolve = this._pullResolve;
|
|
173
|
+
if (resolve) {
|
|
174
|
+
this._pullResolve = null;
|
|
175
|
+
resolve();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private _waitForPull(): Promise<void> {
|
|
180
|
+
return new Promise<void>((resolve) => {
|
|
181
|
+
this._pullResolve = resolve;
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
package/src/writers.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nextrush/stream - Protocol writers
|
|
3
|
+
*
|
|
4
|
+
* Thin formatting wrappers over {@link StreamController}. Each writer differs
|
|
5
|
+
* only in how `write()` encodes its protocol's native unit and how `consume()`
|
|
6
|
+
* maps a raw chunk. All lifecycle (abort, backpressure, close) lives in the
|
|
7
|
+
* controller — not here.
|
|
8
|
+
*
|
|
9
|
+
* See docs/RFC/request-data/003-stream.md §5, §7.
|
|
10
|
+
*
|
|
11
|
+
* @packageDocumentation
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
NDJSONStreamWriter,
|
|
16
|
+
SSEEvent,
|
|
17
|
+
SSEStreamWriter,
|
|
18
|
+
StreamSource,
|
|
19
|
+
TextStreamWriter,
|
|
20
|
+
} from '@nextrush/types';
|
|
21
|
+
import { formatSSE } from './sse-format';
|
|
22
|
+
import type { StreamController } from './stream-controller';
|
|
23
|
+
|
|
24
|
+
/** Decodes byte chunks to text for protocols whose payload is textual (SSE). */
|
|
25
|
+
const TEXT_DECODER = new TextDecoder();
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Shared base: exposes the controller's abort surface and drives `consume()`.
|
|
29
|
+
*
|
|
30
|
+
* @typeParam T - The unit type each source chunk is mapped to before `write()`.
|
|
31
|
+
*/
|
|
32
|
+
abstract class BaseWriter<T> {
|
|
33
|
+
constructor(protected readonly controller: StreamController) {}
|
|
34
|
+
|
|
35
|
+
get aborted(): boolean {
|
|
36
|
+
return this.controller.aborted;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get signal(): AbortSignal {
|
|
40
|
+
return this.controller.signal;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
onAbort(fn: () => void): void {
|
|
44
|
+
this.controller.onAbort(fn);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Protocol-specific write of one native unit. */
|
|
48
|
+
abstract write(value: T): Promise<void>;
|
|
49
|
+
|
|
50
|
+
/** Map one raw source chunk to this protocol's native unit. */
|
|
51
|
+
protected abstract mapChunk(chunk: unknown): T;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Consume an existing producer into this response. Single normalization path;
|
|
55
|
+
* stops and throws `StreamAbortedError` if the client disconnects mid-consume.
|
|
56
|
+
*/
|
|
57
|
+
async consume(source: StreamSource<unknown>): Promise<void> {
|
|
58
|
+
const iterator = this.controller.normalize(source);
|
|
59
|
+
try {
|
|
60
|
+
for (;;) {
|
|
61
|
+
const result: IteratorResult<unknown> = await iterator.next();
|
|
62
|
+
if (result.done) return;
|
|
63
|
+
await this.write(this.mapChunk(result.value));
|
|
64
|
+
}
|
|
65
|
+
} finally {
|
|
66
|
+
await iterator.return?.(undefined);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Raw text/byte writer for `ctx.stream()`. */
|
|
72
|
+
export class TextWriter
|
|
73
|
+
extends BaseWriter<string | Uint8Array>
|
|
74
|
+
implements TextStreamWriter
|
|
75
|
+
{
|
|
76
|
+
write(chunk: string | Uint8Array): Promise<void> {
|
|
77
|
+
return typeof chunk === 'string'
|
|
78
|
+
? this.controller.enqueueText(chunk)
|
|
79
|
+
: this.controller.enqueue(chunk);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
protected mapChunk(chunk: unknown): string | Uint8Array {
|
|
83
|
+
return chunk as string | Uint8Array;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Server-Sent Events writer for `ctx.sse()`. */
|
|
88
|
+
export class SSEWriter extends BaseWriter<SSEEvent> implements SSEStreamWriter {
|
|
89
|
+
write(event: SSEEvent): Promise<void> {
|
|
90
|
+
return this.controller.enqueueText(formatSSE(event));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
protected mapChunk(chunk: unknown): SSEEvent {
|
|
94
|
+
// A consumed producer yields raw text/bytes; wrap each as an SSE `data` event.
|
|
95
|
+
const data = chunk instanceof Uint8Array ? TEXT_DECODER.decode(chunk) : (chunk as string);
|
|
96
|
+
return { data };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Newline-delimited JSON writer for `ctx.ndjson()`. */
|
|
101
|
+
export class NDJSONWriter extends BaseWriter<unknown> implements NDJSONStreamWriter {
|
|
102
|
+
write(value: unknown): Promise<void> {
|
|
103
|
+
return this.controller.enqueueText(`${JSON.stringify(value)}\n`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
protected mapChunk(chunk: unknown): unknown {
|
|
107
|
+
return chunk;
|
|
108
|
+
}
|
|
109
|
+
}
|