-
-
Notifications
You must be signed in to change notification settings - Fork 0
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 viadenoland/setup-deno@v2at 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.
For sp.stdout / sp.stderr (the ReadableStream you get when options.stdout: 'pipe' / stderr: 'pipe'):
-
for await (const chunk of sp.stdout) { ... }—Symbol.asyncIteratoris present on all three. -
sp.stdout.getReader().read()— chunks come back asUint8Arraybacked by a regularArrayBuffer(notSharedArrayBuffer) on every runtime. -
sp.stdout.pipeTo(writableStream)— backpressure-honouring pipe to anyWritableStream. -
sp.stdout.pipeThrough(transformStream)— chains through aTransformStream(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 aftercancel(), exit code is platform-dependent — typically1on Unix from SIGPIPE). -
await new Response(sp.stdout).text()/.arrayBuffer()/.bytes()— the standardResponsebody convenience methods all work.
For sp.stdin (the WritableStream you get when options.stdin: 'pipe'):
-
sp.stdin.getWriter().write(chunk)—chunkmay beUint8Array/ArrayBuffer/string(anyBufferSource). -
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.closedrejects with the samereasonvalue, and the child sees EOF and exits.
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.
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.
Set DSH_FORCE_NODE to any value except '', 0, or false:
DSH_FORCE_NODE=1 node app.jsdollar-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.
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 BunThe 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:streamAPIs 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_NODErequires--allow-env(the read is permission-guarded, so a plain import never prompts); writingprocess.env/Deno.env.setin-process also needs--allow-env, whereas theglobalThisflag needs no permission. - Forcing the Node backend on Deno also turns
sp.stdoutinto a regular (non-byte)ReadableStream, so BYOB readers stop working there — the Node backend carries the same BYOB limitation as Node itself.