This document explains how Piggieback works internally: how it sits inside an nREPL server, how it routes ClojureScript evaluation, and the mechanisms it uses to bridge two runtimes that were never designed to meet. It complements the shorter "Design" section in the README, which covers the user-facing behaviour and rationale.
If you are here to plan changes rather than understand the current code, see the roadmap.
Piggieback is a single, focused thing: an nREPL middleware that lets a normal
nREPL server evaluate ClojureScript in the official cljs.repl REPL
environments (node, browser, graaljs, ...) without a separate build tool. It is
written in Clojure and runs on the JVM. It drives ClojureScript evaluation
through ClojureScript's Clojure-facing compiler API, primarily the
cljs.repl/IJavaScriptEnv protocol.
What it is not: a build tool, a hot-reload system, or a replacement for
shadow-cljs / figwheel-main (both of which ship their own nREPL story and do not
use Piggieback). The clearest way to think about Piggieback today is as the thin,
correct adapter between nREPL and stock cljs.repl environments, plus CIDER's
built-in default for "just give me a cljs REPL."
A single nREPL server can host both Clojure and ClojureScript evaluation at the
same time, on a per-session basis. Whether a given eval runs as Clojure or as
ClojureScript is decided entirely by session state, not by anything the client
puts in the message. A client "enters" ClojureScript by evaluating
(cider.piggieback/cljs-repl <repl-env>) in a session; from then on, that
session's eval and load-file ops are handled by Piggieback until the client
sends :cljs/quit.
flowchart TB
client["nREPL client<br/>(CIDER, Calva, ...)"]
subgraph jvm["JVM - nREPL server"]
direction TB
session["Transport + Session<br/>(holds the dynamic-var state)"]
subgraph stack["Middleware stack"]
direction TB
wprint["wrap-print"]
piggie["wrap-cljs-repl"]
ieval["interruptible-eval"]
end
subgraph pb["Piggieback (Clojure)"]
direction TB
route["evaluate / load-file"]
doeval["do-eval"]
core["read-cljs-string + eval-cljs"]
deleg["Delegating repl-env<br/>(swallows -tear-down)"]
end
subgraph cljs["ClojureScript compiler - Clojure API"]
direction TB
ef["cljs.repl/evaluate-form"]
env["IJavaScriptEnv impl<br/>(node / browser / ...)"]
end
end
runtime["JS runtime<br/>(node subprocess / browser)"]
client <--> session
session --> wprint --> piggie
piggie -->|"eval/load-file when<br/>session has *cljs-repl-env*"| route
piggie -.->|"everything else"| ieval
route --> doeval --> core --> ef
ef --> deleg --> env
env <-->|"-evaluate + async output pump"| runtime
The key seam is wrap-cljs-repl: for every message it inspects the session. If
the session has an active *cljs-repl-env* and the op is eval or load-file,
it hijacks the message and runs Piggieback's ClojureScript evaluation. Otherwise
it passes the message straight through to the rest of the stack (ordinary
Clojure handling).
Piggieback operates at the nREPL session level. Clients do not pass an
:env :cljs parameter or call a dedicated op; they just keep using eval with a
session that has a ClojureScript REPL active. This is what makes a mixed
Clojure/ClojureScript server transparent to tooling. The price is that
ClojureScript-ness is implicit in the eval flow: clients don't flag a message as
cljs. To let tooling detect it from the protocol rather than out of band,
Piggieback contributes its per-session status (whether a cljs REPL is active, and
which repl-env) to the describe response's :aux map via a :describe-fn.
(See roadmap item M3.)
ClojureScript REPL state lives in a handful of dynamic vars interned in the
cider.piggieback namespace (they are public, since other middleware read them
out of the session by name):
| Var | Holds |
|---|---|
*cljs-repl-env* | the active (delegating) repl-env; also the "are we in cljs?" flag |
*cljs-compiler-env* | the ClojureScript compiler environment (analyzer/compiler state) |
*cljs-repl-options* | the merged repl options |
*cljs-warnings* / *cljs-warning-handlers* | analyzer warning configuration |
*original-clj-ns* | the Clojure namespace to restore on :cljs/quit |
*cljs-out-target* / *cljs-err-target* | atoms repointed at the current message's output (see Output forwarding) |
These are not root-bound. nREPL's session middleware establishes per-message
thread bindings for the vars stored in the session atom, and merges any set!
changes back into the session atom when the message completes. So the flow is:
cljs-repl does set! on these vars (which works because the bindings exist for
the duration of the message), and wrap-cljs-repl seeds the session atom with
them up front so the bindings are present. This is also why cljs-repl fails
fast if it is called outside a session: with no thread binding, set! would
throw a cryptic root-binding error (issue #124).
Piggieback has no hard dependency on ClojureScript, so tools can load it unconditionally. There are two namespaces:
cider.piggieback - the always-loadable public face. It requires only nREPL,
holds the session-state dynamic vars (the part other middleware read by name),
and exposes the public API as thin delegators.cider.piggieback.cljs - the implementation, which requires the ClojureScript
compiler and holds the handlers. It is loaded lazily, on first use, and only
when ClojureScript is on the classpath.cider.piggieback checks for ClojureScript by trying to require cljs.repl
directly (not the implementation namespace, which would trigger a load cycle
since the implementation requires cider.piggieback back). When ClojureScript is
present, the public functions resolve their counterparts in
cider.piggieback.cljs via requiring-resolve on first call; when it is absent,
wrap-cljs-repl is a no-op and cljs-repl throws a clear "did you forget a
dependency?" error (roadmap item S1).
sequenceDiagram
autonumber
participant C as nREPL client
participant W as wrap-cljs-repl
participant IE as interruptible-eval
participant PB as cljs-repl
participant R as cljs.repl/repl*
participant E as repl-env (node)
participant JS as JS runtime
C->>W: eval "(cljs-repl (node/repl-env))"
Note over W: session has no *cljs-repl-env*<br/>so this is a normal Clojure eval
W->>IE: pass through
IE->>PB: invoke cljs-repl
PB->>PB: wrap repl-env in a delegating env,<br/>build default-compiler-env up front
PB->>R: setup-repl (ns-require form + " :cljs/quit")
R->>E: -setup
E->>JS: launch runtime
R-->>PB: repl* loop exits on :cljs/quit
PB->>PB: set! dynamic vars, swap session ns to cljs.user
PB-->>C: "To quit, type: :cljs/quit"
Setup is the one place Piggieback drives cljs.repl/repl* (the full REPL loop)
rather than evaluating forms itself. It feeds the loop a single namespace-require
form plus :cljs/quit, with no-op :prompt / :need-prompt / :init / :print
callbacks. Afterwards Piggieback reads the state it needs back directly: the
compiler env is the one it created and handed in, and the namespace is always
cljs.user (the setup form switches into it), so no :print side-channel is
needed. The compiler env is created up front and captured unconditionally, so that
evaluation still works even if the setup eval errors before :print runs (issue
#62).
sequenceDiagram
autonumber
participant C as nREPL client
participant W as wrap-cljs-repl
participant EV as evaluate / do-eval
participant RD as read-cljs-string
participant EF as cljs.repl/evaluate-form
participant E as repl-env
participant JS as JS runtime
C->>W: eval "(+ 1 1)" (Piggieback session)
Note over W: session has *cljs-repl-env*
W->>EV: route to evaluate, enqueue on the session executor
EV->>EV: with-bindings (compiler-env, repl-env, warnings),<br/>repoint forwarding writers at this msg's *out*/*err*
EV->>RD: read form (cljs data readers + ns alias map)
RD-->>EV: form
EV->>EF: eval-cljs (wrapped for *1/*2/*3 and pretty-printing)
EF->>E: -evaluate
E->>JS: run compiled JS
JS-->>E: result (printed string)
Note over E,JS: a separate output-pump thread writes<br/>stdout/stderr asynchronously, via the<br/>forwarding writer, to this message's *out*
E-->>EF: result
EF-->>EV: result
EV->>EV: track current cljs ns back into the session
EV-->>C: :value, :ns
Ongoing evaluation does not go through repl*. It calls
cljs.repl/evaluate-form directly, reusing the compiler env and repl-env stored
in the session. The result comes back as a printed string, which Piggieback
re-reads with an EDN reader (using UnknownTaggedLiteral as the default tag
handler so unknown tagged literals round-trip) before sending it as :value.
Note the two distinct evaluation paths (setup via repl*, steady-state via
evaluate-form). Fully unifying them onto one path was considered (roadmap item
B1) but deferred: it would mean replicating repl*'s version-sensitive setup,
trading one coupling for another. Instead the worst of the setup warts were
removed (the :print side-channel, and the per-class codegen delegator described
below), keeping repl* for the setup it does correctly.
stateDiagram-v2
[*] --> Clojure: session created
Clojure --> ClojureScript: cljs-repl invoked (sets *cljs-repl-env*)
ClojureScript --> ClojureScript: eval / load-file routed to Piggieback
ClojureScript --> Clojure: ":cljs/quit" (-tear-down, vars reset)
ClojureScript --> [*]: session closed (-tear-down via :close hook)
Clojure --> [*]: session closed
note right of ClojureScript
Closing the session while a
cljs REPL is active also tears
the runtime down, via the
session's :close meta hook
(roadmap C1).
end note
Teardown happens on an explicit :cljs/quit and also when the session is closed
while a cljs REPL is active. Since nREPL's session middleware handles the close
op itself and never delegates it to Piggieback, the latter is wired by composing
a teardown into the session's :close metadata fn (the one
nrepl.middleware.session/close-session invokes) when the REPL starts. This
keeps a client that closes its session (or exits) without :cljs/quit from
leaking the JavaScript runtime (roadmap item C1).
Note this covers session close, not a silently dropped TCP connection: nREPL sessions deliberately outlive their connection (so you can reconnect, as the output-routing test exercises), so a dropped connection alone does not close the session or trigger teardown.
These are the non-obvious pieces that make the bridge work.
cljs.repl/repl* calls -tear-down on the repl-env when its loop exits, which
(because setup appends :cljs/quit) would tear the env down immediately after
setup. To prevent that, Piggieback wraps the real repl-env in a single
DelegatingReplEnv type whose -tear-down is a no-op and which forwards every
other IJavaScriptEnv method (and map-like access) to the wrapped env.
The cljs.repl error-formatting protocols (IParseError, IGetError,
IParseStacktrace, IPrintStacktrace, IReplEnvOptions) are optional - a given
env implements only some (node and the browser env implement different subsets) -
and cljs.repl guards each call site with satisfies?. The delegator implements
all of them, delegating to the wrapped env when it supports the protocol and
otherwise falling back to the same default cljs.repl would use, so it mirrors
each env's behaviour per instance. This replaced an earlier scheme that generated
a delegating type per repl-env class at runtime via eval (roadmap item B1).
ClojureScript repl envs such as the node env run an asynchronous output pump on
their own thread and capture *out* once, at setup time, via bound-fn. Under
nREPL, *out* is rebound per message, so a captured *out* would keep sending
all later output to the message that started the REPL (and that output would
vanish once that connection closed). Piggieback hands the env a
forwarding-writer: a java.io.Writer that delegates to whatever writer is
currently held in an atom (*cljs-out-target* / *cljs-err-target*). On each
evaluation, do-eval repoints those atoms at the current message's output. The
writer flushes rather than closes on close, because the underlying per-message
writers are owned by nREPL.
The current ClojureScript namespace (cljs.analyzer/*cljs-ns*) is stored in the
session atom and updated after each eval, so that in-ns and namespace switches
persist across messages and are reported back as :ns. The reader is configured
with the analyzer's resolve-symbol, the cljs data readers, and an alias map
reconstructed from the current namespace's :requires / :require-macros, so
that alias-qualified keywords and reader conditionals read correctly.
*1 *2 *3 *e and pretty-printingBefore evaluation, the form is wrapped (pprint-repl-wrap-fn) so that the result
rolls the REPL history vars (*1/*2/*3), captures exceptions into *e, and
optionally pretty-prints. The choice between plain printing and pretty-printing
currently keys off the name of the requested print function. ns/require/import
forms are passed through unwrapped.
ClojureScript REPL special functions (cljs.repl/default-special-fns, merged
with any from the repl options) are dispatched directly rather than evaluated, so
things like load-file, in-ns, and require behave like REPL specials.
The load-file op evaluates the source sent in the message (its :file), using
cljs.repl/load-stream to read and evaluate every top-level form against the
active repl-env, with the analyzer namespace restored afterwards. This loads the
client's buffer content, including unsaved changes, matching Clojure nREPL
semantics. If a message arrives without :file content, Piggieback falls back to
the cljs load-file special function, which reads from disk (roadmap item C2).
These are limitations that are either upstream or architectural; each maps to a roadmap item.
cljs.repl.node keys its results/output state by
thread name, which collapses nREPL's worker threads together. Two node REPLs in
one JVM clobber each other. Upstream; roadmap U1 (issues #105, #88).*out* capture. The forwarding-writer machinery exists only to compensate
for the node env capturing *out* at setup. The clean fix is upstream; roadmap
U2.cljs.repl per
message (see the README Design section).Piggieback supports a range of nREPL versions (1.0 through current). The
differences are handled by runtime feature detection (for example resolving
interruptible-eval/evaluator to decide how to bind per-message state, and
resolving replying-PrintWriter for print middleware). These checks live in one
place, the cider.piggieback.compat namespace (roadmap item M1).
Piggieback also couples tightly to ClojureScript compiler internals
(cljs.analyzer, cljs.env, cljs.closure, cljs.tagged-literals, and the
non-public parts of cljs.repl). This is largely unavoidable given there is no
public "evaluate a cljs form in this env and hand me the result" API, but it is
fragile, so it is confined to a single namespace, cider.piggieback.cljs, which
holds all of it; the public cider.piggieback namespace reaches none of the
compiler directly (roadmap item M2).
Can 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 |