98 lines
3.3 KiB
TypeScript
98 lines
3.3 KiB
TypeScript
import { StreamPipeFailedError } from "../../errors/stream-error.js";
|
|
|
|
/**
|
|
* Concatenates multiple ReadableStreams into one, sequentially.
|
|
*
|
|
* Each input stream is drained to completion before the next stream is pulled
|
|
* from. Output ordering is therefore deterministic and identical to the input
|
|
* order — unlike {@link merge}, which is race-based.
|
|
*
|
|
* Errors from any input stream halt the concatenation immediately and are
|
|
* propagated wrapped in a {@link StreamPipeFailedError}; subsequent streams
|
|
* are never read. Output cancellation cancels the currently-reading source
|
|
* and releases any unread sources without pulling from them.
|
|
*/
|
|
export function concat<T>(streams: ReadableStream<T>[]): ReadableStream<T> {
|
|
if (streams.length === 0) {
|
|
return new ReadableStream({
|
|
start(c) {
|
|
c.close();
|
|
},
|
|
});
|
|
}
|
|
if (streams.length === 1) {
|
|
return streams[0]!;
|
|
}
|
|
|
|
const sources = [...streams];
|
|
let index = 0;
|
|
let currentReader: ReadableStreamDefaultReader<T> | undefined;
|
|
let cancelled = false;
|
|
|
|
function acquireReader(): ReadableStreamDefaultReader<T> | undefined {
|
|
if (currentReader) return currentReader;
|
|
if (index >= sources.length) return undefined;
|
|
currentReader = sources[index]!.getReader();
|
|
return currentReader;
|
|
}
|
|
|
|
return new ReadableStream<T>({
|
|
async pull(controller) {
|
|
while (!cancelled) {
|
|
const reader = acquireReader();
|
|
if (!reader) {
|
|
controller.close();
|
|
return;
|
|
}
|
|
let result: ReadableStreamReadResult<T>;
|
|
try {
|
|
result = await reader.read();
|
|
} catch (cause) {
|
|
controller.error(
|
|
new StreamPipeFailedError({
|
|
cause,
|
|
message: "concat: input stream errored",
|
|
})
|
|
);
|
|
// Release the failed reader; do not touch unread sources.
|
|
try {
|
|
reader.releaseLock();
|
|
} catch {
|
|
// ignore — reader may already be released
|
|
}
|
|
currentReader = undefined;
|
|
return;
|
|
}
|
|
|
|
if (result.done) {
|
|
try {
|
|
reader.releaseLock();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
currentReader = undefined;
|
|
index++;
|
|
// Loop to acquire next reader (or close if exhausted).
|
|
continue;
|
|
}
|
|
|
|
controller.enqueue(result.value);
|
|
return;
|
|
}
|
|
},
|
|
async cancel(reason) {
|
|
cancelled = true;
|
|
const reader = currentReader;
|
|
currentReader = undefined;
|
|
try {
|
|
await reader?.cancel(reason);
|
|
} catch {
|
|
// best-effort cancellation
|
|
}
|
|
// Subsequent un-acquired sources are intentionally NOT pulled or
|
|
// cancelled — concat consumes them lazily, so unread sources were
|
|
// never attached and remain owned by the caller.
|
|
},
|
|
});
|
|
}
|