Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,66 @@
# for-each
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides three stream sinks: `forEach` calls a user function for each chunk without accumulating results in memory, `toWritable` pipes a readable stream to a writable stream using the WHATWG `pipeTo` API, and `drain` reads and discards all chunks from a stream to completion.
These are the primary sinks for side-effectful consumption and plumbing: writing chunks to a file, sending them over a network, logging, or simply clearing a stream. They complement `toArray` and `toBytes` which materialise stream contents into memory.
## Features
- `forEach`: sequential, low-memory chunk processing
- `toWritable`: native WHATWG `pipeTo` with optional abort signal
- `drain`: discard all chunks efficiently
- AbortSignal support in `forEach` and `drain`
- Lock-safe: always releases reader lock
## Usage: Basic Example
```ts
import { forEach } from "@suaveplan/streams/sinks/for-each/for-each";
import { fromArray } from "@suaveplan/streams/readable/from-array/from-array";
let sum = 0;
await forEach(fromArray([1, 2, 3, 4, 5]), (n) => { sum += n; });
console.log(sum); // 15
```
## Usage: Advanced Example
```ts
import { forEach, toWritable, drain } from "@suaveplan/streams/sinks/for-each/for-each";
import { fromArray } from "@suaveplan/streams/readable/from-array/from-array";
// Pipe to a writable
const received: string[] = [];
const writable = new WritableStream<string>({
write(chunk) { received.push(chunk); }
});
await toWritable(fromArray(["a", "b", "c"]), writable);
console.log(received); // ["a", "b", "c"]
// Drain a stream (discard all chunks)
await drain(fromArray([1, 2, 3, 4, 5]));
```
## API Reference
### `forEach<T>(stream: ReadableStream<T>, fn: (chunk: T) => void | Promise<void>, signal?: AbortSignal): Promise<void>`
- `fn` — called for each chunk; may be async.
- `signal` — rejects with `StreamAbortedError` on abort.
### `toWritable<T>(stream: ReadableStream<T>, writable: WritableStream<T>, signal?: AbortSignal): Promise<void>`
Pipes `stream` to `writable` using `stream.pipeTo(writable)`. Propagates errors from both sides.
### `drain(stream: ReadableStream<unknown>, signal?: AbortSignal): Promise<void>`
Reads all chunks and discards them. Useful for exhausting a stream whose side effects are in the transforms.
## Implementation Notes
`forEach` uses the same abort-signal pattern as `toArray`: an event listener calls `reader.cancel()` to interrupt a pending `read()`, and the main loop checks `signal.aborted` before each read for pre-aborted signals. `toWritable` delegates entirely to `ReadableStream.pipeTo` which has native error propagation and abort support in the WHATWG spec. `drain` is a thin wrapper over `forEach` with a no-op callback — it exists as a named concept rather than requiring callers to know the pattern.

View File

@@ -0,0 +1,151 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { StreamAbortedError } from "../../errors/stream-error.js";
import { fromArray } from "../../readable/from-array/from-array.js";
import { drain, forEach, toWritable } from "./for-each.js";
describe("forEach", () => {
it("calls fn for each chunk", { timeout: 5000 }, async () => {
const seen: number[] = [];
await forEach(fromArray([1, 2, 3]), (n) => {
seen.push(n);
});
expect(seen).toEqual([1, 2, 3]);
});
it("awaits async fn", { timeout: 5000 }, async () => {
const seen: number[] = [];
await forEach(fromArray([1, 2, 3]), async (n) => {
await Promise.resolve();
seen.push(n);
});
expect(seen).toEqual([1, 2, 3]);
});
it("throws StreamAbortedError on pre-aborted signal", {
timeout: 5000,
}, async () => {
const signal = AbortSignal.abort(new Error("stop"));
await expect(
forEach(fromArray([1, 2, 3]), () => {}, signal)
).rejects.toBeInstanceOf(StreamAbortedError);
});
it("handles empty stream", { timeout: 5000 }, async () => {
const seen: number[] = [];
await forEach(fromArray<number>([]), (n) => seen.push(n));
expect(seen).toEqual([]);
});
it("releases lock on error", { timeout: 5000 }, async () => {
const stream = new ReadableStream({
start(controller) {
controller.error(new Error("boom"));
},
});
await expect(forEach(stream, () => {})).rejects.toThrow("boom");
expect(stream.locked).toBe(false);
});
it("triggers onAbort listener mid-stream (pre-first-read)", {
timeout: 30000,
}, async () => {
const ac = new AbortController();
const stream = new ReadableStream<number>({
pull() {
return new Promise(() => {});
},
});
const promise = forEach(stream, () => {}, ac.signal);
await new Promise((r) => setTimeout(r, 20));
ac.abort(new Error("mid-forEach abort"));
await expect(promise).rejects.toBeInstanceOf(StreamAbortedError);
});
it("post-read abort check fires after first chunk", {
timeout: 30000,
}, async () => {
const ac = new AbortController();
let pullCount = 0;
const stream = new ReadableStream<number>({
pull(controller) {
pullCount++;
if (pullCount === 1) {
controller.enqueue(1);
return;
}
return new Promise(() => {});
},
});
const promise = forEach(stream, () => {}, ac.signal);
await new Promise((r) => setTimeout(r, 30));
ac.abort(new Error("abort-after-first-chunk"));
await expect(promise).rejects.toBeInstanceOf(StreamAbortedError);
});
it("catch block re-wraps plain stream error when signal is aborted", {
timeout: 30000,
}, async () => {
const ac = new AbortController();
let streamController: ReadableStreamDefaultController<number>;
const stream = new ReadableStream<number>({
start(c) {
streamController = c;
},
});
const promise = forEach(stream, () => {}, ac.signal);
await new Promise((r) => setTimeout(r, 10));
streamController!.error(new Error("stream-error"));
ac.abort(new Error("abort-reason"));
await expect(promise).rejects.toBeInstanceOf(StreamAbortedError);
});
});
describe("toWritable", () => {
it("pipes chunks to writable", { timeout: 5000 }, async () => {
const received: number[] = [];
const writable = new WritableStream<number>({
write(chunk) {
received.push(chunk);
},
});
await toWritable(fromArray([1, 2, 3]), writable);
expect(received).toEqual([1, 2, 3]);
});
it("accepts optional signal and uses pipeTo with signal option", {
timeout: 5000,
}, async () => {
const received: number[] = [];
const writable = new WritableStream<number>({
write(chunk) {
received.push(chunk);
},
});
const ac = new AbortController();
await toWritable(fromArray([1, 2]), writable, ac.signal);
expect(received).toEqual([1, 2]);
});
});
describe("drain", () => {
it("drains stream without returning values", {
timeout: 5000,
}, async () => {
let count = 0;
const stream = new ReadableStream<number>({
start(controller) {
for (let i = 0; i < 5; i++) {
count++;
controller.enqueue(i);
}
controller.close();
},
});
await drain(stream);
expect(count).toBe(5);
});
it("resolves on empty stream", { timeout: 5000 }, async () => {
await expect(drain(fromArray([]))).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,70 @@
import { StreamAbortedError } from "../../errors/stream-error.js";
/**
* Iterates over all chunks in a ReadableStream, calling `fn` for each one.
* Rejects with StreamAbortedError when the signal fires.
*/
export async function forEach<T>(
stream: ReadableStream<T>,
fn: (chunk: T) => void | Promise<void>,
signal?: AbortSignal
): Promise<void> {
const reader = stream.getReader();
const onAbort = signal
? () => {
reader.cancel(signal.reason).catch(() => {});
}
: undefined;
if (onAbort && signal) {
signal.addEventListener("abort", onAbort, { once: true });
}
try {
while (true) {
if (signal?.aborted) {
throw new StreamAbortedError({ cause: signal.reason });
}
const { value, done } = await reader.read();
// Re-check after awaiting — abort may have fired during the read
if (signal?.aborted) {
throw new StreamAbortedError({ cause: signal.reason });
}
if (done) break;
await fn(value);
}
} catch (err) {
if (signal?.aborted && !(err instanceof StreamAbortedError)) {
throw new StreamAbortedError({ cause: err });
}
throw err;
} finally {
if (onAbort && signal) {
signal.removeEventListener("abort", onAbort);
}
reader.releaseLock();
}
}
/**
* Pipes a ReadableStream to a WritableStream.
* Uses WHATWG pipeTo under the hood.
*/
export async function toWritable<T>(
stream: ReadableStream<T>,
writable: WritableStream<T>,
signal?: AbortSignal
): Promise<void> {
await stream.pipeTo(writable, signal ? { signal } : {});
}
/**
* Drains a ReadableStream to completion, discarding all chunks.
*/
export async function drain(
stream: ReadableStream<unknown>,
signal?: AbortSignal
): Promise<void> {
await forEach(stream, () => {}, signal);
}

View File

@@ -0,0 +1 @@
export { drain, forEach, toWritable } from "./for-each.js";

View File

@@ -0,0 +1 @@
export { collect, toArray } from "./to-array.js";

View File

@@ -0,0 +1,73 @@
# to-array
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`toArray` (aliased as `collect`) is the primary sink for materialising a `ReadableStream<T>` into an in-memory array. It reads chunks sequentially until the stream closes and returns all chunks in order. An optional `AbortSignal` allows the consumer to cancel the operation mid-flight: when the signal fires, the reader is cancelled and the promise rejects with `StreamAbortedError`.
This is the most common way to consume a stream in tests and batch pipelines. For large streams, prefer `forEach` (which avoids accumulating the full result) or streaming into a `WritableStream`.
## Features
- Materialises a stream into a `T[]`
- AbortSignal support with `StreamAbortedError` on cancellation
- Lock-safe: always releases the reader lock in `finally`
- `collect` is an alias for `toArray` (both are exported)
## Usage: Basic Example
```ts
import { fromArray } from "@suaveplan/streams/readable/from-array/from-array";
import { toArray } from "@suaveplan/streams/sinks/to-array/to-array";
const result = await toArray(fromArray([1, 2, 3]));
console.log(result); // [1, 2, 3]
```
## Usage: Advanced Example
```ts
import { fromArray } from "@suaveplan/streams/readable/from-array/from-array";
import { mapStream } from "@suaveplan/streams/transforms/map-stream/map-stream";
import { toArray, collect } from "@suaveplan/streams/sinks/to-array/to-array";
// With abort
const ac = new AbortController();
setTimeout(() => ac.abort(), 50);
try {
const result = await toArray(
fromArray(new Array(1_000_000).fill(0)),
ac.signal
);
console.log(result.length);
} catch (err) {
// StreamAbortedError
console.log("cancelled");
}
// collect alias
const result2 = await collect(fromArray(["a", "b", "c"]));
console.log(result2); // ["a", "b", "c"]
```
## API Reference
### `toArray<T>(stream: ReadableStream<T>, signal?: AbortSignal): Promise<T[]>`
**Parameters**:
- `stream` — the readable stream to consume.
- `signal` — optional `AbortSignal`; rejects with `StreamAbortedError` on abort.
**Returns**: `Promise<T[]>` resolving to all chunks in order.
**Throws**: `StreamAbortedError` when the signal fires; re-throws stream errors unchanged.
### `collect<T>` — alias for `toArray`
## Implementation Notes
The abort signal listener calls `reader.cancel()` asynchronously to interrupt a pending `reader.read()`. The main loop checks `signal.aborted` before each read call to handle pre-aborted signals. The `finally` block always calls `reader.releaseLock()` — even if the stream errors or the abort fires — to prevent the stream from becoming permanently locked and unusable.

View File

@@ -0,0 +1,133 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { StreamAbortedError } from "../../errors/stream-error.js";
import { fromArray } from "../../readable/from-array/from-array.js";
import { collect, toArray } from "./to-array.js";
describe("toArray", () => {
it("collects all items from a stream", { timeout: 5000 }, async () => {
const result = await toArray(fromArray([1, 2, 3]));
expect(result).toEqual([1, 2, 3]);
});
it("returns empty array for empty stream", { timeout: 5000 }, async () => {
const result = await toArray(fromArray([]));
expect(result).toEqual([]);
});
it("throws StreamAbortedError on aborted signal", {
timeout: 5000,
}, async () => {
const signal = AbortSignal.abort(new Error("cancelled"));
await expect(
toArray(fromArray([1, 2, 3]), signal)
).rejects.toBeInstanceOf(StreamAbortedError);
});
it("collects before abort fires", { timeout: 5000 }, async () => {
// A stream that never aborts should collect normally
const ac = new AbortController();
const result = await toArray(fromArray([1, 2, 3]), ac.signal);
expect(result).toEqual([1, 2, 3]);
});
it("releases reader lock even on error", { timeout: 5000 }, async () => {
// A stream that errors immediately
const stream = new ReadableStream({
start(controller) {
controller.enqueue(1);
controller.error(new Error("fail"));
},
});
await expect(toArray(stream)).rejects.toThrow("fail");
// Stream should be in errored state and not locked
expect(stream.locked).toBe(false);
});
it("triggers onAbort listener and cancels reader (pre-first-read)", {
timeout: 30000,
}, async () => {
const ac = new AbortController();
// Stream that hangs forever on first pull
const stream = new ReadableStream<number>({
pull() {
return new Promise(() => {}); // never resolves
},
});
const promise = toArray(stream, ac.signal);
await new Promise((r) => setTimeout(r, 20));
ac.abort(new Error("mid-stream abort"));
await expect(promise).rejects.toBeInstanceOf(StreamAbortedError);
});
it("post-read abort check fires after cancel resolves read", {
timeout: 30000,
}, async () => {
const ac = new AbortController();
let pullCount = 0;
// Stream: first pull returns a chunk immediately; second pull hangs
const stream = new ReadableStream<number>({
pull(controller) {
pullCount++;
if (pullCount === 1) {
controller.enqueue(42);
return;
}
// second pull hangs — abort fires here
return new Promise(() => {});
},
});
const promise = toArray(stream, ac.signal);
// wait for first chunk to be consumed and second pull to hang
await new Promise((r) => setTimeout(r, 30));
ac.abort(new Error("abort-after-first-chunk"));
await expect(promise).rejects.toBeInstanceOf(StreamAbortedError);
});
it("re-wraps non-StreamAbortedError on abort", {
timeout: 30000,
}, async () => {
const ac = new AbortController();
// Stream that hangs on read
const stream = new ReadableStream<number>({
start(controller) {
controller.enqueue(1);
},
pull() {
return new Promise(() => {});
},
});
const promise = toArray(stream, ac.signal);
await new Promise((r) => setTimeout(r, 10));
ac.abort(new Error("abort-reason"));
await expect(promise).rejects.toBeInstanceOf(StreamAbortedError);
});
it("catch block re-wraps plain stream error when signal is aborted", {
timeout: 30000,
}, async () => {
// Scenario: stream is errored BEFORE abort, so reader.read() rejects with a
// plain Error. Then signal is aborted. The catch block sees signal.aborted=true
// and a non-StreamAbortedError, so it wraps it as StreamAbortedError.
const ac = new AbortController();
let streamController: ReadableStreamDefaultController<number>;
const stream = new ReadableStream<number>({
start(c) {
streamController = c;
},
});
const promise = toArray(stream, ac.signal);
await new Promise((r) => setTimeout(r, 10));
// Error stream first (no abort yet) — this rejects the pending reader.read()
streamController!.error(new Error("stream-error"));
// Abort signal after — onAbort fires but cancel on an errored stream is a no-op
ac.abort(new Error("abort-reason"));
await expect(promise).rejects.toBeInstanceOf(StreamAbortedError);
});
});
describe("collect", () => {
it("is an alias for toArray", { timeout: 5000 }, async () => {
const result = await collect(fromArray(["a", "b"]));
expect(result).toEqual(["a", "b"]);
});
});

View File

@@ -0,0 +1,52 @@
import { StreamAbortedError } from "../../errors/stream-error.js";
/**
* Collects all chunks from a ReadableStream into an array.
* Cancels and rejects with StreamAbortedError when the signal fires.
*/
export async function toArray<T>(
stream: ReadableStream<T>,
signal?: AbortSignal
): Promise<T[]> {
const results: T[] = [];
const reader = stream.getReader();
const onAbort = signal
? () => {
reader.cancel(signal.reason).catch(() => {});
}
: undefined;
if (onAbort && signal) {
signal.addEventListener("abort", onAbort, { once: true });
}
try {
while (true) {
if (signal?.aborted) {
throw new StreamAbortedError({ cause: signal.reason });
}
const { value, done } = await reader.read();
// Re-check after awaiting — the abort may have fired during the read
if (signal?.aborted) {
throw new StreamAbortedError({ cause: signal.reason });
}
if (done) break;
results.push(value);
}
return results;
} catch (err) {
if (signal?.aborted && !(err instanceof StreamAbortedError)) {
throw new StreamAbortedError({ cause: err });
}
throw err;
} finally {
if (onAbort && signal) {
signal.removeEventListener("abort", onAbort);
}
reader.releaseLock();
}
}
/** Alias for toArray — collects all chunks from a stream into an array. */
export const collect = toArray;

View File

@@ -0,0 +1 @@
export { toAsyncIterable } from "./to-async-iterable.js";

View File

@@ -0,0 +1,94 @@
# toAsyncIterable
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`toAsyncIterable` adapts a `ReadableStream<T>` into a standard
`AsyncIterable<T>`, letting consumers use ergonomic `for await ... of` loops
to drain a stream rather than driving the WHATWG reader API by hand. While
modern Node and recent browser engines expose `ReadableStream[Symbol.asyncIterator]`
natively, the streams package ships its own implementation so that:
- behaviour is identical across runtimes regardless of native support;
- abort semantics are pinned — aborts always surface as `StreamAbortedError`;
- early-break cleanup is guaranteed (the underlying reader is cancelled, the
lock is released, and abort listeners are removed) so upstream producers
can promptly free resources.
## Features
- `for await ... of` support for any `ReadableStream<T>`.
- Optional `AbortSignal` input — abort throws `StreamAbortedError`.
- Cancels the underlying reader on early-break (via the iterator's
`return()` hook).
- Idempotent cleanup — releasing an already-released lock is swallowed.
- Generic over `T`; works equally for `Uint8Array` byte streams and
arbitrary object streams.
## Usage: Basic Example
```ts
import { toAsyncIterable } from "@suaveplan/streams/sinks/to-async-iterable/to-async-iterable";
for await (const chunk of toAsyncIterable(stream)) {
console.log(chunk);
}
```
## Usage: Advanced Example
```ts
import { toAsyncIterable } from "@suaveplan/streams/sinks/to-async-iterable/to-async-iterable";
import { StreamAbortedError } from "@suaveplan/streams/errors/stream-error";
async function consumeWithDeadline<T>(
stream: ReadableStream<T>,
deadlineMs: number,
onItem: (item: T) => Promise<void>
): Promise<void> {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(new Error("deadline")), deadlineMs);
try {
for await (const item of toAsyncIterable(stream, ac.signal)) {
await onItem(item);
}
} catch (err) {
if (err instanceof StreamAbortedError) {
console.warn("hit deadline before stream completed");
return;
}
throw err;
} finally {
clearTimeout(timer);
}
}
```
## API Reference
### `toAsyncIterable<T>(stream, signal?): AsyncIterable<T>`
- **`stream: ReadableStream<T>`** — source stream to adapt.
- **`signal?: AbortSignal`** — optional cancellation token. Aborting causes
the active or next `next()` call to reject with `StreamAbortedError` and
cancels the underlying reader.
- **Returns** — an `AsyncIterable<T>` that yields chunks until the stream
closes or aborts.
- **Throws** — `StreamAbortedError` on abort; otherwise propagates upstream
stream errors verbatim.
## Implementation Notes
- The iterator implements `return()` so `break`/`throw` inside the
`for await` loop both trigger reader cancel + lock release. Without this,
upstream producers would keep filling buffers for chunks no consumer will
ever read.
- Both pre-iteration abort and mid-`read()` abort paths produce
`StreamAbortedError`. The mid-read path uses the same `aborted` flag plus
a defensive re-check after `read()` resolves to handle the race where the
signal fires after the read promise was already in flight.
- Abort listeners are registered with `{ once: true }` and explicitly
removed in cleanup — long-lived signals shared across many iterators do
not accumulate listeners.

View File

@@ -0,0 +1,169 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { StreamAbortedError } from "../../errors/stream-error.js";
import { toAsyncIterable } from "./to-async-iterable.js";
function fromArray<T>(items: T[]): ReadableStream<T> {
return new ReadableStream<T>({
start(c) {
for (const it of items) c.enqueue(it);
c.close();
},
});
}
describe("toAsyncIterable", () => {
it("yields all items from the underlying stream", {
timeout: 5000,
}, async () => {
const out: number[] = [];
for await (const n of toAsyncIterable(fromArray([1, 2, 3]))) {
out.push(n);
}
expect(out).toEqual([1, 2, 3]);
});
it("explicit iterator.return() releases the reader and reports done", {
timeout: 5000,
}, async () => {
const iterable = toAsyncIterable(fromArray([1, 2, 3, 4, 5]));
const it = iterable[Symbol.asyncIterator]();
const first = await it.next();
expect(first.value).toBe(1);
const closed = await it.return!();
expect(closed.done).toBe(true);
});
it("iterator.return() swallows cancel errors via the inner .catch", {
timeout: 5000,
}, async () => {
const stream = new ReadableStream<number>({
start(c) {
c.enqueue(1);
},
cancel() {
throw new Error("cancel-throw");
},
});
const iterable = toAsyncIterable(stream);
const it = iterable[Symbol.asyncIterator]();
const first = await it.next();
expect(first.value).toBe(1);
const closed = await it.return!();
expect(closed.done).toBe(true);
});
it("breaking out of a for-await loop invokes iterator.return()", {
timeout: 5000,
}, async () => {
const out: number[] = [];
for await (const n of toAsyncIterable(fromArray([1, 2, 3, 4, 5]))) {
out.push(n);
if (n === 2) break;
}
expect(out).toEqual([1, 2]);
});
it("yields nothing for an empty stream", { timeout: 5000 }, async () => {
const out: number[] = [];
for await (const n of toAsyncIterable(fromArray<number>([]))) {
out.push(n);
}
expect(out).toEqual([]);
});
it("throws StreamAbortedError when signal aborts before iteration", {
timeout: 5000,
}, async () => {
const ac = new AbortController();
ac.abort(new Error("nope"));
const it = toAsyncIterable(fromArray([1, 2, 3]), ac.signal)[
Symbol.asyncIterator
]();
await expect(it.next()).rejects.toBeInstanceOf(StreamAbortedError);
});
it("throws StreamAbortedError when signal aborts mid-iteration", {
timeout: 5000,
}, async () => {
const ac = new AbortController();
const stream = new ReadableStream<number>({
start(c) {
c.enqueue(1);
// never closes
},
});
const it = toAsyncIterable(stream, ac.signal)[Symbol.asyncIterator]();
const first = await it.next();
expect(first.value).toBe(1);
queueMicrotask(() => ac.abort(new Error("stop")));
await expect(it.next()).rejects.toBeInstanceOf(StreamAbortedError);
});
it("early break cancels the upstream stream", {
timeout: 5000,
}, async () => {
let cancelled = false;
const stream = new ReadableStream<number>({
start(c) {
c.enqueue(1);
c.enqueue(2);
},
cancel() {
cancelled = true;
},
});
for await (const n of toAsyncIterable(stream)) {
if (n === 1) break;
}
expect(cancelled).toBe(true);
});
it("abort during in-flight read causes StreamAbortedError (aborted=true catch branch)", {
timeout: 5000,
}, async () => {
// Exercises lines 65-67: aborted=true inside catch, err is not StreamAbortedError,
// so it gets wrapped into StreamAbortedError with signal.reason as cause.
const ac = new AbortController();
const reason = new Error("abort-reason");
let firstPullDone = false;
const stream = new ReadableStream<number>({
pull(controller) {
if (!firstPullDone) {
firstPullDone = true;
controller.enqueue(1);
}
// subsequent pulls hang — abort cancels the reader mid-read
},
});
const iter = toAsyncIterable(stream, ac.signal)[Symbol.asyncIterator]();
const first = await iter.next();
expect(first.value).toBe(1);
// Start the next read, then abort while it is in-flight
const nextPromise = iter.next();
ac.abort(reason);
await expect(nextPromise).rejects.toBeInstanceOf(StreamAbortedError);
});
it("stream error propagates as-is when not aborted (line 70 rethrow path)", {
timeout: 5000,
}, async () => {
// Exercises line 70: aborted=false, err is not StreamAbortedError — rethrown directly.
// Use pull() to error lazily (after first chunk) to avoid unhandled-rejection
// noise from controller.error() in start() before a reader is attached.
const boom = new Error("stream-error");
let pulled = 0;
const stream = new ReadableStream<number>({
pull(controller) {
pulled++;
if (pulled === 1) {
controller.enqueue(1);
} else {
controller.error(boom);
}
},
});
const iter = toAsyncIterable(stream)[Symbol.asyncIterator]();
await iter.next(); // consume the enqueued 1
await expect(iter.next()).rejects.toBe(boom);
});
});

View File

@@ -0,0 +1,80 @@
import { StreamAbortedError } from "../../errors/stream-error.js";
/**
* Adapts a `ReadableStream<T>` into an `AsyncIterable<T>`. Yields chunks until
* the stream closes or the optional `signal` aborts (rejecting iteration with
* {@link StreamAbortedError}).
*
* The adapter cancels the underlying reader on abort so upstream producers can
* release resources promptly.
*/
export function toAsyncIterable<T>(
stream: ReadableStream<T>,
signal?: AbortSignal
): AsyncIterable<T> {
return {
[Symbol.asyncIterator](): AsyncIterator<T> {
const reader = stream.getReader();
let aborted = false;
const onAbort = (): void => {
aborted = true;
void reader.cancel(signal?.reason);
};
if (signal) {
if (signal.aborted) {
aborted = true;
void reader.cancel(signal.reason);
} else {
signal.addEventListener("abort", onAbort, { once: true });
}
}
const cleanup = (): void => {
signal?.removeEventListener("abort", onAbort);
try {
reader.releaseLock();
} catch {
// Lock may already be released after cancel — safe to ignore.
}
};
return {
async next(): Promise<IteratorResult<T>> {
if (aborted) {
cleanup();
throw new StreamAbortedError({
cause: signal?.reason,
});
}
try {
const { value, done } = await reader.read();
if (aborted) {
cleanup();
throw new StreamAbortedError({
cause: signal?.reason,
});
}
if (done) {
cleanup();
return { value: undefined, done: true };
}
return { value: value as T, done: false };
} catch (err) {
cleanup();
throw err;
}
},
async return(): Promise<IteratorResult<T>> {
signal?.removeEventListener("abort", onAbort);
await reader.cancel().catch(() => {});
try {
reader.releaseLock();
} catch {
// Lock may already be released — safe to ignore.
}
return { value: undefined, done: true };
},
};
},
};
}

View File

@@ -0,0 +1 @@
export { bytesToString, toBytes } from "./to-bytes.js";

View File

@@ -0,0 +1,67 @@
# to-bytes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`toBytes` and `bytesToString` are binary sinks for `ReadableStream<Uint8Array>`. `toBytes` reads all chunks from the stream and concatenates them into a single `Uint8Array` using a single allocation (collect chunks, compute total size, write all at once). `bytesToString` is a convenience wrapper that decodes the result via `TextDecoder`.
These sinks are the counterparts to `fromBytes` and are used when a pipeline produces binary data that needs to be materialised into memory — for example, the final step after `compressStream` or `encodeTextStream`.
## Features
- `toBytes`: single-allocation concatenation of all stream chunks
- `bytesToString`: `toBytes` followed by `TextDecoder` with configurable encoding
- AbortSignal support with `StreamAbortedError` on cancellation
- Lock-safe: always releases reader lock in `finally`
## Usage: Basic Example
```ts
import { fromBytes } from "@suaveplan/streams/readable/from-bytes/from-bytes";
import { toBytes } from "@suaveplan/streams/sinks/to-bytes/to-bytes";
const data = new Uint8Array([1, 2, 3, 4, 5]);
const result = await toBytes(fromBytes(data, 2));
console.log(Array.from(result)); // [1, 2, 3, 4, 5]
```
## Usage: Advanced Example
```ts
import { fromArray } from "@suaveplan/streams/readable/from-array/from-array";
import { encodeTextStream } from "@suaveplan/streams/transforms/text-codec/text-codec";
import { toBytes, bytesToString } from "@suaveplan/streams/sinks/to-bytes/to-bytes";
const bytes = await toBytes(
fromArray(["hello", " world"]).pipeThrough(encodeTextStream())
);
console.log(bytes.byteLength); // 11
// Or directly as string
const text = await bytesToString(
fromArray(["hello", " world"]).pipeThrough(encodeTextStream())
);
console.log(text); // "hello world"
```
## API Reference
### `toBytes(stream: ReadableStream<Uint8Array>, signal?: AbortSignal): Promise<Uint8Array>`
- `stream` — byte stream to consume.
- `signal` — optional `AbortSignal`; rejects with `StreamAbortedError` on abort.
**Returns**: `Promise<Uint8Array>` — all bytes concatenated into a single buffer.
### `bytesToString(stream: ReadableStream<Uint8Array>, encoding?: string): Promise<string>`
- `stream` — byte stream to decode.
- `encoding` — IANA encoding label (default `"utf-8"`).
**Returns**: `Promise<string>`.
## Implementation Notes
Chunks are collected into an array first, then the total byte length is computed, then a single `Uint8Array` is allocated and each chunk is copied in with `set`. This is more efficient than repeatedly concatenating buffers (which would require O(N²) total copy work). The lock is always released in `finally`, even on error.

View File

@@ -0,0 +1,116 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { StreamAbortedError } from "../../errors/stream-error.js";
import { fromArray } from "../../readable/from-array/from-array.js";
import { fromBytes } from "../../readable/from-bytes/from-bytes.js";
import { encodeTextStream } from "../../transforms/text-codec/text-codec.js";
import { bytesToString, toBytes } from "./to-bytes.js";
describe("toBytes", () => {
it("concatenates chunks into single buffer", {
timeout: 5000,
}, async () => {
const data = new Uint8Array([1, 2, 3, 4, 5]);
const result = await toBytes(fromBytes(data, 2));
expect(Array.from(result)).toEqual([1, 2, 3, 4, 5]);
});
it("returns empty Uint8Array for empty stream", {
timeout: 5000,
}, async () => {
const result = await toBytes(fromBytes(new Uint8Array(0)));
expect(result.byteLength).toBe(0);
});
it("throws StreamAbortedError on aborted signal", {
timeout: 5000,
}, async () => {
const signal = AbortSignal.abort(new Error("stop"));
await expect(
toBytes(fromBytes(new Uint8Array(100), 10), signal)
).rejects.toBeInstanceOf(StreamAbortedError);
});
it("single-allocation output equals multi-chunk input", {
timeout: 5000,
}, async () => {
const a = new Uint8Array([0, 1, 2]);
const b = new Uint8Array([3, 4, 5]);
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(a);
controller.enqueue(b);
controller.close();
},
});
const result = await toBytes(stream);
expect(Array.from(result)).toEqual([0, 1, 2, 3, 4, 5]);
});
it("triggers onAbort listener mid-stream", {
timeout: 30000,
}, async () => {
const ac = new AbortController();
// Stream that hangs forever
const stream = new ReadableStream<Uint8Array>({
pull() {
return new Promise(() => {}); // never resolves
},
});
const promise = toBytes(stream, ac.signal);
await new Promise((r) => setTimeout(r, 20));
ac.abort(new Error("mid-stream abort"));
await expect(promise).rejects.toBeInstanceOf(StreamAbortedError);
});
it("post-read abort check fires after first chunk", {
timeout: 30000,
}, async () => {
const ac = new AbortController();
let pullCount = 0;
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
pullCount++;
if (pullCount === 1) {
controller.enqueue(new Uint8Array([1]));
return;
}
return new Promise(() => {});
},
});
const promise = toBytes(stream, ac.signal);
await new Promise((r) => setTimeout(r, 30));
ac.abort(new Error("abort-after-first-chunk"));
await expect(promise).rejects.toBeInstanceOf(StreamAbortedError);
});
it("catch block re-wraps plain stream error when signal is aborted", {
timeout: 30000,
}, async () => {
const ac = new AbortController();
let streamController: ReadableStreamDefaultController<Uint8Array>;
const stream = new ReadableStream<Uint8Array>({
start(c) {
streamController = c;
},
});
const promise = toBytes(stream, ac.signal);
await new Promise((r) => setTimeout(r, 10));
streamController!.error(new Error("stream-error"));
ac.abort(new Error("abort-reason"));
await expect(promise).rejects.toBeInstanceOf(StreamAbortedError);
});
});
describe("bytesToString", () => {
it("decodes UTF-8 stream to string", { timeout: 5000 }, async () => {
const text = await bytesToString(
fromArray(["hello", " world"]).pipeThrough(encodeTextStream())
);
expect(text).toBe("hello world");
});
it("handles empty stream", { timeout: 5000 }, async () => {
const text = await bytesToString(fromBytes(new Uint8Array(0)));
expect(text).toBe("");
});
});

View File

@@ -0,0 +1,69 @@
import { StreamAbortedError } from "../../errors/stream-error.js";
/**
* Collects all Uint8Array chunks from a stream and concatenates them
* into a single Uint8Array. No intermediate copies beyond the final buffer.
*/
export async function toBytes(
stream: ReadableStream<Uint8Array>,
signal?: AbortSignal
): Promise<Uint8Array> {
const chunks: Uint8Array[] = [];
let totalLength = 0;
const reader = stream.getReader();
const onAbort = signal
? () => {
reader.cancel(signal.reason).catch(() => {});
}
: undefined;
if (onAbort && signal) {
signal.addEventListener("abort", onAbort, { once: true });
}
try {
while (true) {
if (signal?.aborted) {
throw new StreamAbortedError({ cause: signal.reason });
}
const { value, done } = await reader.read();
// Re-check after awaiting — the abort may have fired during the read
if (signal?.aborted) {
throw new StreamAbortedError({ cause: signal.reason });
}
if (done) break;
chunks.push(value);
totalLength += value.byteLength;
}
// Single allocation for the result
const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.byteLength;
}
return result;
} catch (err) {
if (signal?.aborted && !(err instanceof StreamAbortedError)) {
throw new StreamAbortedError({ cause: err });
}
throw err;
} finally {
if (onAbort && signal) {
signal.removeEventListener("abort", onAbort);
}
reader.releaseLock();
}
}
/**
* Collects a Uint8Array stream and decodes it to a string.
*/
export async function bytesToString(
stream: ReadableStream<Uint8Array>,
encoding = "utf-8"
): Promise<string> {
const bytes = await toBytes(stream);
return new TextDecoder(encoding).decode(bytes);
}