Files
suaveplan/packages/foundation/flow/streams/src/pipeline/concat/concat.ts
2026-05-27 23:47:53 +01:00

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.
},
});
}