Skip to content

Cross runtime notes

Eugene Lazutkin edited this page May 4, 2026 · 1 revision

Cross-runtime notes

dollar-shell exposes the same Subprocess shape across Node, Bun, and Deno: stdin is a WritableStream, stdout and stderr are ReadableStreams. For everyday use the three runtimes are interchangeable.

This page documents the small differences that do leak through, so you're not surprised by them.

Note on freshness. The findings below were verified on 2026-05-04 against the runtime versions in CI at the time (Node 20/22/24/25, Bun via oven-sh/setup-bun@v2, Deno via denoland/setup-deno@v2 at v2.x). Web Streams support is actively progressing in all three runtimes, so the asymmetry below may shrink over time. If you hit a difference that contradicts this page, the page is wrong — please open an issue with your runtime version.

What works the same on Node, Bun, and Deno

For sp.stdout / sp.stderr (the ReadableStream you get when options.stdout: 'pipe' / stderr: 'pipe'):

  • for await (const chunk of sp.stdout) { ... }Symbol.asyncIterator is present on all three.
  • sp.stdout.getReader().read() — chunks come back as Uint8Array backed by a regular ArrayBuffer (not SharedArrayBuffer) on every runtime.
  • sp.stdout.pipeTo(writableStream) — backpressure-honouring pipe to any WritableStream.
  • sp.stdout.pipeThrough(transformStream) — chains through a TransformStream (e.g., TextDecoderStream).
  • sp.stdout.tee() — returns two independent readers from the same source.
  • sp.stdout.cancel() — cancels reading and propagates to the child process. The child sees a broken pipe (typical exit ~17 ms after cancel(), exit code is platform-dependent — typically 1 on Unix from SIGPIPE).
  • await new Response(sp.stdout).text() / .arrayBuffer() / .bytes() — the standard Response body convenience methods all work.

For sp.stdin (the WritableStream you get when options.stdin: 'pipe'):

  • sp.stdin.getWriter().write(chunk)chunk may be Uint8Array / ArrayBuffer / string (any BufferSource).
  • writer.close() — graceful close, child sees EOF on its stdin.
  • writer.abort(reason) — abrupt close with an associated reason. The reason is preserved: await writer.closed rejects with the same reason value, and the child sees EOF and exits.

The one place runtimes diverge: BYOB readers

Bring-Your-Own-Buffer (BYOB) readers let advanced consumers pre-allocate a buffer and read into it without an intermediate copy. They are an optional optimization on top of the basic ReadableStream API, and only available on byte-stream ReadableStreams (those backed by a ReadableByteStreamController).

Runtime sp.stdout.getReader({mode: 'byob'})
Node ❌ Throws — Node's Readable.toWeb() produces a regular (non-byte) ReadableStream.
Bun ❌ Throws — Bun's subprocess.stdout is also a regular ReadableStream.
Deno ✅ Works — Deno.Command(...).spawn().stdout is a byte-stream ReadableStream.

If you write code that uses getReader({mode: 'byob'}) and intends to be cross-runtime, avoid relying on it — feature-detect with try / catch around getReader({mode: 'byob'}) and fall back to the default reader when it throws. For the overwhelming majority of use cases (stream consumption, pipelines, tee, pipeTo, Response(...).text()), the default reader is what you want anyway.

This is the only Web Streams divergence currently known. If you find another, please open an issue.

Clone this wiki locally