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+WritableStreamexposed asreadable/writableproperties - Caller-supplied
StreamSource<R>(start / pull / cancel) andStreamSink<W>(start / write / abort / close) - Default queuing strategy chooses between count-based (
objectMode: true) and byte-size based (default) automatically highWaterMarkis forwarded to both sidescancel(reason)onreadablepropagatesreasonto the source'scancelcallback verbatimabort(reason)onwritablepropagates to the sink'sabortcallback- 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—trueselects count-based queuing;false(default) selects byte-size queuing where each chunk'sbyteLength(or 1 if absent) counts towardhighWaterMark.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 signaturesStreamSink<W>—{ start?, write?, abort?, close? }with WHATWG controller signaturesDuplexStreamOptions—{ 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.