This document describes the internal architecture and implementation of the
:js-runtime feature, which enables shadow-cljs to use alternative JavaScript
runtimes (currently Bun) for Node-family build targets. For user-facing
configuration, see js-runtime.md.
shadow-cljs already has a strong seam between build configuration and process
launch: repl-impl/node-repl* accepts node-command and node-args, runtime
registration is keyed on connected clients rather than Node-specific server
state, and Node-family targets inject the client runtime at configuration time.
Rather than introduce a first-class Bun runtime backend with its own
client/protocol path (high cost, large patch surface), the feature treats Bun as
a selectable JS host for the existing Node-family runtime path. The existing
shadow.cljs.devtools.client.node client runs unmodified under Bun; only the
executable and launch arguments change. If Bun-specific incompatibilities appear
later, a dedicated client namespace can be introduced without changing the
configuration model.
shadow.build.targets.sharedAll runtime-dependent behavior flows through a set of centralized helpers at the
bottom of shared.clj. Two other places read the :js-runtime key without
deciding anything from it: shadow.cljs.devtools.cli-opts/lift-js-runtime
copies --js-runtime into :config-merge, and repl-impl/node-repl* puts
its option into the build config it compiles.
| Function | Purpose |
|---|---|
node-family-target? | True when :target is :node-script or :node-test |
js-runtime | Returns the configured runtime keyword, defaulting to :node |
js-runtime-stdin-argv | Returns argv for piped stdin execution: ["node"] or ["bun" "run" "-"] |
js-runtime-file-argv | Returns argv for file execution: ["node" path] or ["bun" "run" path] |
warn-if-js-runtime-ignored | Prints a warning when :js-runtime is set on a non-node-family target; shadow.build/configure calls it once the config is valid |
The spec ::js-runtime is defined as keyword? in shadow.build.config and
included as an optional key in the base ::build spec (accepted by all
targets). It is also included in the :node-script and :node-test target
specs. On non-node-family targets, it is accepted but ignored with a warning.
shadow.cljs.devtools.server.repl-implnode-repl* creates a temporary :node-script build, compiles it, and pipes
the output to stdin of the selected runtime. The runtime argv comes from
js-runtime-stdin-argv:
["node"] — reads script from stdin["bun" "run" "-"] — bun run - accepts piped stdinBoth runtimes resolve require() paths relative to the working directory when
receiving piped input, which is why stdin piping works without an intermediate
file.
The REPL thread monitors the process and auto-restarts on crash until the worker stops.
shadow.build.targets.node-testautorun-test runs during the :flush stage when :autorun true is set. It
calls js-runtime-file-argv to get the argv, spawns a ProcessBuilder, pipes
stdout/stderr to the build logger, and waits for process exit. The exit code is
stored in build state.
shadow-cljs.edn (:js-runtime :bun)
│
▼
Build Supervisor ── creates ──▶ Worker (stores build-config)
│
┌───────────────────┼───────────────────┐
▼ ▼ ▼
Test Autorun Watched + REPL Standalone REPL
│ │ │
▼ ▼ ▼
js-runtime-file-argv compile writes the js-runtime-stdin-argv
│ output; the fork │
▼ starts nothing ▼
ProcessBuilder ┊ ProcessBuilder
["bun" "run" ┊ ["bun" "run" "-"]
"out/test.js"] ┊ │
▼ ▼
whatever started the Script piped to stdin
output (`bun out/x.js`, │
a launcher) │
│ │
┌───────────────────┴───────────────────┘
▼
Node client (client.node)
connects via WebSocket to
relay server
│
▼
Worker registers runtime
in :runtimes map
│
▼
REPL evals route to
connected runtime
The existing shadow.cljs.devtools.client.node client runs unmodified under
both Node and Bun. It connects via WebSocket to the relay server, sends a
:hello message with {:host :node}, and the relay maps it to the correct
worker by proc-id. REPL evaluation messages are then routed through the relay to
the connected runtime.
A process running the output stays alive through the client's own 1 s idle
interval: shadow.cljs.devtools.client.shared/init-runtime! sets it, and
stop-runtime! alone clears it. So a client that gave up retrying leaves the
process up and disconnected; it neither exits nor reconnects.
Each launch path has clear ownership:
| Path | Owner | Crash behavior | Shutdown |
|---|---|---|---|
| Standalone REPL | REPL thread in repl-impl | Auto-restart | Thread exit |
| Watched build | Whatever started the output | Its owner's choice | Its owner's choice |
| Test autorun | Synchronous in flush stage | N/A (waits for exit) | Process exits naturally |
Earlier fork releases started and stopped a process per watched build in the
worker, waited for it on eval, and reported its log when it had none. It was
built and removed: it cost most of the fork's diff against upstream and the
conflict surface of every upstream sync, so starting the output moved out of
the JVM, into whatever runs it. The design is
docs/specs/2026/2026-10/2026-10-28-drop-managed-runtime-design.org.
Projects that ran it keep its shadow-managed-runtime-<build>.cjs and .log
files in the cache root, .shadow-cljs/ by default. Nothing reads them any
more, and they can be deleted.
The feature currently supports:
:node-script, :node-test:node, :bunNot yet supported:
:esm target (uses a separate launch path)Test autorun launches the JS executable with no user-controlled arguments,
environment variables, or wrapper scripts. If use cases emerge that need
customizable launch (e.g., passing --inspect to Node, setting BUN_CONFIG_*
env vars, or wrapping the process in a profiler), js-runtime-file-argv would
need to accept additional options. node-repl already takes node-args.
Adding a new runtime (e.g., Deno) requires:
::js-runtime spec in shared.cljjs-runtime-stdin-argv and js-runtime-file-argvshadow.cljs.devtools.client.node unmodifiedCan you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |