Files
suaveplan/packages/foundation/flow/streams/src/primitives/duplex/duplex.md
2026-05-27 23:47:53 +01:00

6.3 KiB

BaseDuplexStream

(no submodules)

Purpose

BaseDuplexStream is the primitive for bidirectional WHATWG streams. It bundles a ReadableStream and a WritableStream whose source and sink are supplied by the caller, with sensible queuing-strategy defaults for byte vs. object mode. It exists as the foundation for higher-level primitives (NetworkStreamImpl for WebRTC/WebSocket peers, future WorkerStream for transferable-buffer transport, etc.) and as a clean boundary for any consumer that needs "write to one end, read from the other" semantics without re-implementing the WHATWG plumbing.

The two sides are independent by design — a write to writable does NOT echo back on readable unless the supplied source is wired to do so. This decoupling is what makes the primitive reusable for echoing flows (test doubles), proxy flows (network bridges), and asymmetric flows (encode-on-write, decode-on-read).

Features

  • WHATWG-native ReadableStream + WritableStream exposed as readable / writable properties
  • Caller-supplied StreamSource<R> (start / pull / cancel) and StreamSink<W> (start / write / abort / close)
  • Default queuing strategy chooses between count-based (objectMode: true) and byte-size based (default) automatically
  • highWaterMark is forwarded to both sides
  • cancel(reason) on readable propagates reason to the source's cancel callback verbatim
  • abort(reason) on writable propagates to the sink's abort callback
  • All four sink hooks (start, write, abort, close) and three source hooks (start, pull, cancel) are optional — callers supply only what they need
  • createDuplexStream(source, sink, options?) factory for inline usage

Usage: Basic Example

import { createDuplexStream } from "@suaveplan/streams/primitives/duplex/duplex";

let pending: number | null = null;
const stream = createDuplexStream<number, number>(
    {
        pull(controller) {
            if (pending !== null) {
                controller.enqueue(pending);
                pending = null;
            }
        },
    },
    {
        write(chunk) {
            // Echo the doubled value back to the readable side
            pending = chunk * 2;
        },
    }
);

const writer = stream.writable.getWriter();
await writer.write(7);
await writer.close();

const reader = stream.readable.getReader();
console.log((await reader.read()).value); // 14

Usage: Advanced Example

import { BaseDuplexStream } from "@suaveplan/streams/primitives/duplex/duplex";
import type {
    StreamSink,
    StreamSource,
    DuplexStreamOptions,
} from "@suaveplan/streams/primitives/duplex/duplex.types";

interface Message {
    seq: number;
    payload: Uint8Array;
}

function createPeerStream(
    onIncoming: (m: Message) => void
): BaseDuplexStream<Message, Message> {
    const outgoing: Message[] = [];
    let pullController:
        | ReadableStreamDefaultController<Message>
        | undefined;

    const source: StreamSource<Message> = {
        start(controller) {
            pullController = controller;
        },
        pull(controller) {
            const next = outgoing.shift();
            if (next !== undefined) controller.enqueue(next);
        },
        cancel(reason) {
            outgoing.length = 0;
            pullController = undefined;
            console.warn("peer stream cancelled", reason);
        },
    };

    const sink: StreamSink<Message> = {
        write(chunk) {
            onIncoming(chunk);
        },
        close() {
            pullController?.close();
        },
        abort(reason) {
            pullController?.error(reason);
        },
    };

    const options: DuplexStreamOptions = {
        objectMode: true,
        highWaterMark: 32,
    };

    return new BaseDuplexStream(source, sink, options);
}

API Reference

BaseDuplexStream<R, W>

constructor(source: StreamSource<R>, sink: StreamSink<W>, options?: DuplexStreamOptions)

Builds the bidirectional stream. Every callback on source and sink is optional.

  • options.objectMode — true selects count-based queuing; false (default) selects byte-size queuing where each chunk's byteLength (or 1 if absent) counts toward highWaterMark.
  • options.highWaterMark — applied to both sides when set; left to WHATWG defaults when omitted.

readable: ReadableStream<R>

The readable side, suitable for pipeThrough / pipeTo composition.

writable: WritableStream<W>

The writable side. Use .getWriter() for direct control or .pipeTo from another readable.

createDuplexStream<R, W>(source, sink, options?): DuplexStream<R, W>

Inline factory equivalent to new BaseDuplexStream(...).

Types

  • StreamSource<R> — { start?, pull?, cancel? } with WHATWG controller signatures
  • StreamSink<W> — { start?, write?, abort?, close? } with WHATWG controller signatures
  • DuplexStreamOptions — { objectMode?, highWaterMark? }
  • DuplexStream<R, W> — { readonly readable, readonly writable } interface

Implementation Notes

The constructor builds the UnderlyingSource and UnderlyingSink shapes by conditionally copying only the callbacks that exist on the caller-supplied objects. This avoids the WHATWG quirk where supplying start: undefined is materially different from omitting start, and it keeps the underlying-source object minimal so spec-compliant runtimes can apply their fast-path optimisations.

The size function lives outside the conditional copy because it's strategy-level, not source-level: a byteLength-aware function is installed when objectMode is false, and a default count-based strategy is installed when it is true. The chunk-size lookup uses optional chaining and falls back to 1 for non-byte chunks ({ byteLength?: number } ?? 1), which is the same approach used by node:stream and the WHATWG reference implementation.

createDuplexStream is a thin alias rather than a method on the class because the JS-style "create-some-stream" naming reads more idiomatically inside pipelines than new BaseDuplexStream(...). The two have identical behaviour and runtime cost.

This primitive is intentionally minimal — it does not own a benchmark suite, an abort surface beyond the standard WHATWG plumbing, or an event surface. Higher-level primitives (NetworkStreamImpl) layer those concerns on top via composition.