Liking cljdoc? Tell your friends :D

JS Runtime Architecture

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.

Design Rationale

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.

Components

Shared Helpers — shadow.build.targets.shared

All 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.

FunctionPurpose
node-family-target?True when :target is :node-script or :node-test
js-runtimeReturns the configured runtime keyword, defaulting to :node
js-runtime-stdin-argvReturns argv for piped stdin execution: ["node"] or ["bun" "run" "-"]
js-runtime-file-argvReturns argv for file execution: ["node" path] or ["bun" "run" path]
warn-if-js-runtime-ignoredPrints 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.

Standalone Node REPL — shadow.cljs.devtools.server.repl-impl

node-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: ["node"] — reads script from stdin
  • Bun: ["bun" "run" "-"] — bun run - accepts piped stdin

Both 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.

Test Autorun — shadow.build.targets.node-test

autorun-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.

Data Flow

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

Runtime Connection

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.

Process Ownership

Each launch path has clear ownership:

PathOwnerCrash behaviorShutdown
Standalone REPLREPL thread in repl-implAuto-restartThread exit
Watched buildWhatever started the outputIts owner's choiceIts owner's choice
Test autorunSynchronous in flush stageN/A (waits for exit)Process exits naturally

Rejected: a managed runtime

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.

Scope and Limitations

The feature currently supports:

  • Targets: :node-script, :node-test
  • Runtimes: :node, :bun

Not yet supported:

  • :esm target (uses a separate launch path)
  • Bun-specific client optimizations

Future Considerations

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.

Extension Points

Adding a new runtime (e.g., Deno) requires:

  1. Add the keyword to the ::js-runtime spec in shared.clj
  2. Add cases to js-runtime-stdin-argv and js-runtime-file-argv
  3. Verify the runtime can host shadow.cljs.devtools.client.node unmodified
  4. If not, introduce a dedicated client namespace

Can you improve this documentation?Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
←Move to previous article
→Move to next article
Ctrl+/Jump to the search field
× close