Skip to content

Cross runtime notes

Eugene Lazutkin edited this page Jun 2, 2026 · 4 revisions

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.

Forcing the Node backend

By default each runtime uses its own backend — Deno.Command on Deno, Bun.spawn on Bun, and node:child_process on Node. You can force every runtime to spawn through the Node backend instead (so Bun and Deno run node:child_process on their Node compatibility layer), which is handy to sidestep a runtime-specific quirk — for example, Bun intermittently drops the final chunk of a child process's piped output when it is read as a Web Stream, while the Node backend (consumed through Node streams) delivers it reliably.

This swaps only the spawn mechanism. The runtime that executes your scripts and the way dollar-shell re-launches the current runtime (currentExecPath / runFileArgs / cwd) stay native — a forced child of Bun or Deno is still launched as bun run … / deno run …, never a bare node. It is the spawn backend that changes, not the runtime.

DSH_FORCE_NODE environment variable (recommended)

Set DSH_FORCE_NODE to any value except '', 0, or false:

DSH_FORCE_NODE=1 node app.js

dollar-shell's env option defaults to process.env, so spawned child processes inherit the variable. The environment variable therefore forces the Node backend for the whole process tree — this process and every dollar-shell child it spawns. For a build script or a wrapper that shells out to other dollar-shell programs, that tree-wide consistency is usually what you want.

globalThis.DSH_FORCE_NODE (force only this process)

If you want to force the backend for only the current process — without the variable leaking into the children it spawns — set the in-process flag instead. It is process-local and never crosses the spawn boundary:

globalThis.DSH_FORCE_NODE = true;
const {$} = await import('dollar-shell'); // dynamic import: the flag must be set before the module loads

await $`bun run worker.js`; // worker.js does NOT inherit the flag; its dollar-shell stays on Bun

The backend is selected once, when the module is first imported, so the flag has to be set before the import — which means a dynamic import(), since a static import is evaluated before any of your code runs. (Setting process.env.DSH_FORCE_NODE in-process and deleting it again after the import works too, but the globalThis flag is cleaner and, on Deno, needs no --allow-env.)

Notes:

  • Bun and Deno must support the node:child_process / node:stream APIs the Node backend relies on — they do, through their Node compatibility layers. Process spawning still needs the usual permissions (e.g. Deno's --allow-run).
  • On Deno, reading DSH_FORCE_NODE requires --allow-env (the read is permission-guarded, so a plain import never prompts); writing process.env / Deno.env.set in-process also needs --allow-env, whereas the globalThis flag needs no permission.
  • Forcing the Node backend on Deno also turns sp.stdout into a regular (non-byte) ReadableStream, so BYOB readers stop working there — the Node backend carries the same BYOB limitation as Node itself.

Clone this wiki locally