Readers, Writers, and Channels

js/streams.md

Fino models streaming as value delivery with back-pressure. A Reader<T> is the pull side and a Writer<T> is the acceptance side. The two endpoints are small facades over shared state; that state owns ordering, capacity, closure, and failure. Callers only use read(), write(), flush(), and close().

Every direct read returns an iterator-shaped result:

const result = await reader.read();
if (result.done) {
  // Clean end of stream.
} else {
  use(result.value);
}

This makes every T deliverable, including null and undefined, without an out-of-band sentinel. It is also the same shape used by for await, so direct pulls and async iteration express completion identically.

This separation matters for I/O. A socket receive path can own a private byte state and feed it from read(2) while exposing only its Reader<Uint8Array>. The transmit path can expose only a Writer<ArrayBuffer | ArrayBufferView> and let its private state drive write(2). A duplex socket composes one state in each direction. There is no synthetic public writer for incoming bytes and no synthetic public reader for outgoing bytes.

Typed channels

Channel<T> connects a Writer<T> to a Reader<T> with zero capacity. A write stays pending until a read accepts the value, so producer speed follows consumer demand. Reads and writes are paired in call order.

import { Channel } from 'fino:stream';

const channel = new Channel<string>();
const consumer = (async () => {
  for await (const value of channel.reader) console.log(value);
})();

await channel.writer.write('first');
await channel.writer.write('second');
await channel.writer.close();
await consumer;

Use UnboundedChannel<T> when the producer must be allowed to run ahead. Its linked buffer grows until the reader catches up, so it trades back-pressure for potentially unbounded memory use. It should be an explicit choice rather than the default channel behavior.

Transform values with async iterables

A channel delivers values; it does not transform one type into another. Transforms belong outside the delivery mechanism and compose naturally as async generators. Reader.from() turns the resulting async iterable back into a Reader when an endpoint API is useful.

import { Channel, Reader } from 'fino:stream';

async function* lengths(source: AsyncIterable<string>) {
  for await (const value of source) yield value.length;
}

const input = new Channel<string>();
const output: Reader<number> = Reader.from(lengths(input.reader));

Keeping transformation external means the same channel state can deliver any T, while mapping, filtering, decoding, framing, and protocol parsing remain ordinary composable JavaScript.

Byte readers and writers

BytesReader and BytesWriter specialize the generic endpoints with byte operations. A byte write does not imply one equally sized byte read. Readers can request a bounded chunk, exactly N bytes, one byte, or bytes through a delimiter; readInto(buffer) fills reusable caller storage and reports the number of bytes written in a ReadResult<number>. Writers accept any ArrayBuffer or view. The byte state preserves the byte sequence while satisfying those independently sized operations.

This is different from a typed Channel<T>, where each write delivers one value. Byte state treats input as a continuous ordered sequence.

BytesChannel is the zero-capacity byte form. It owns no byte storage. readInto(view) offers that exact view to the producer; the producer's reserve() waits for it and returns the same backing buffer, offset, and length. commit(n) completes the read with the filled prefix. The ordinary read(n) method is sugar over this mechanism: it allocates n bytes, offers that allocation to the producer, and returns a view ending at the committed length. A producer therefore cannot run ahead of demand on an unbuffered byte channel.

Buffered I/O state

Buffering is a state policy at an I/O boundary, not a transform-stream feature. BufferedBytesChannel owns one capacity-sized ArrayBuffer segment. UnboundedBytesChannel uses the same endpoint contract but may append more capacity-sized segments whenever its producer runs ahead. An input driver can fill these segments directly from a file descriptor and let protocol code consume differently sized views. Capacity is measured in bytes:

I/O drivers should fill storage reserved from their private state directly. For a buffered state, reserve() returns an entire capacity-sized segment and commit(n) publishes only its filled prefix. For an unbuffered state, reserve() instead waits for and returns the exact view offered by readInto(). These driver operations remain internal state mechanics rather than additional methods on the public Reader or Writer facade. Closing a state releases an uncommitted reservation without publishing it.

read(n) always returns stable caller-owned bytes. It allocates an n-byte destination, reads into it, and returns the filled prefix as the result value; later channel operations cannot mutate that result. readInto(view) instead borrows the caller's exact view only until its promise settles. It copies buffered bytes into that storage and can consume across multiple segments immediately, which is preferable when the caller manages an arena or buffer pool.

The transfer size should be large enough to amortize syscall overhead, while capacity limits total resident data so many concurrent connections can retain throughput. These are separate choices: transfer size controls each I/O attempt; capacity controls aggregate buffering and back-pressure.

Most protocol layers should therefore remain unbuffered async-iterable transforms over memory already admitted by the I/O state. Buffer once at the resource boundary, then compose pull-driven processing downstream.

Directional closure

writer.close() is clean completion. It rejects new writes, lets committed values or bytes drain in FIFO order, and only then completes; subsequent reads return { done: true, value: undefined }. writer.close(error) follows the same drain rule, then rejects the next read with that error instead of reporting clean completion. There is no separate failure method because an error still terminates the writer.

reader.close() is clean early cancellation by the consumer. Pending and future reads complete as done, while blocked and future producer operations reject with ChannelCancelledError. reader.close(error) uses the same directional cancellation but rejects pending reads and propagates that exact error to the writer. Buffered data that the reader abandons is discarded; any writer close still waiting for it to drain rejects with the cancellation.

An uncommitted reserved byte region is never data. Closing the writer releases it, and commit(0) publishes neither a value nor EOF; the read remains pending for a later non-empty commit or terminal close.