Initial Commit
This commit is contained in:
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { drain, forEach, toWritable } from "./for-each.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { collect, toArray } from "./to-array.js";
|
||||
@@ -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.
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { toAsyncIterable } from "./to-async-iterable.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { bytesToString, toBytes } from "./to-bytes.js";
|
||||
@@ -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.
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user