nREPL and ClojureScript: Demystifying Piggieback
I can’t carry it for you… but I can carry you!
– Samwise Gamgee, on the virtues of piggyback
If you’ve ever fired up a ClojureScript REPL from your editor and it just worked, there’s a decent chance Piggieback was quietly doing the heavy lifting behind the scenes. It’s one of those libraries that’s been around forever, that everyone in the CIDER world depends on, and that almost nobody actually understands. For years I counted myself firmly in the “almost nobody” camp.
That changed recently. I finally had to do some serious work on Piggieback myself, and to my mild horror I realized I’d forgotten most of how it works internally. So I did what I always do when I need to understand something properly - I dug in, refactored it into a shape I could actually reason about, and then wrote it all down. This article is that write-up.
A little backstory
Piggieback was created by Chas Emerick back in August 2012. The very first commit is dated August 10th, 2012, which makes the project a few weeks shy of fourteen years old as I write this. That’s a very long time in software years.
The name is a pun, of course - Piggieback rides piggyback on top of an existing nREPL server. Why “piggie” and not “piggy”? That’s one of life’s great unsolved mysteries. Only Chas knows, and he’s not telling.1
The problem it set out to solve was very concrete. nREPL talks to a Clojure runtime. ClojureScript, meanwhile, compiles to JavaScript and runs somewhere else entirely - a browser tab, a Node process, back in the day even Rhino or Nashorn running inside the same JVM. So how do you get your editor, which only knows how to talk nREPL to a JVM, to evaluate ClojureScript in some far-flung JavaScript runtime? Piggieback’s answer was pretty clever: don’t build a new protocol, don’t build a new server, just hijack the existing nREPL session and quietly reroute its evaluation to ClojureScript.
The project changed hands a couple of times over the years. It started life as
com.cemerick/piggieback, deployed to Maven Central. When Chas stepped back it
moved under the CIDER umbrella and became cider/piggieback on Clojars, and
eventually landed in the nREPL org where it lives
today. We kept the cider group id to avoid breaking everyone’s deps.edn for
no good reason.2
Historically I wasn’t the one driving Piggieback’s development. That credit goes to a few other people - Chas himself, of course, but also Bruce Hauman (of Figwheel fame, who shaped a lot of the evaluation machinery) and Michael Griffiths (who did a ton of work on printing and nREPL integration). For a long time I was happy to let Piggieback tick along in the background while smarter people than me handled its guts.
But times change. These days CIDER and the rest of its supporting projects, Piggieback very much included, have exactly two active maintainers: me and Alex Yakushev. When you’re down to a crew that small, “I’ll let someone else understand the ClojureScript internals” stops being a viable strategy. So I sat down and learned. Eventually we all have to, I guess. :D
Which brings me to a small confession: neither Alex nor I is an actual ClojureScript expert. We’re Clojure folks who wandered into the cljs internals out of necessity, and we muddle through. So if you are one, and you’ve got a bit of time and goodwill to spare, help with the development of Piggieback (and CIDER more broadly) would be most appreciated. There’s plenty of interesting work to go around!
Two languages, one server
Here’s the thing that still makes me smile about Piggieback’s design: a single nREPL server can host both Clojure and ClojureScript evaluation at the same time, on a per-session basis. One editor connection can be running Clojure, another can be running ClojureScript, and the server doesn’t blink.
And the client doesn’t have to do anything special either. There’s no
:env :cljs parameter to pass, no dedicated cljs-eval op to call. You keep
sending plain old eval messages with your session id, exactly as you would for
Clojure. Piggieback figures out the rest.
The way you “enter” ClojureScript is by evaluating a single form in your session:
(require 'cljs.repl.node)
(cider.piggieback/cljs-repl (cljs.repl.node/repl-env))
;; To quit, type: :cljs/quit
From that point on, every eval and load-file in that session is
ClojureScript, until you send :cljs/quit to switch back. Meanwhile your
colleague’s session on the same server is still merrily evaluating Clojure. It’s
a bit like flipping a switch that only affects your own room in a shared house.
This is what I mean by session-based dispatch, and it’s the conceptual heart of the whole thing:
nREPL server (one JVM)
+-----------------------------------------------------------+
| |
| session A --eval--> Clojure |
| |
| session B --eval--> ClojureScript --> Node runtime |
| |
| session C --eval--> ClojureScript --> browser tab |
| |
+-----------------------------------------------------------+
How it actually works
These aren’t the droids you’re looking for.
– Obi-Wan Kenobi, nREPL middleware
Let’s peel back a layer. Piggieback is, first and foremost, an nREPL middleware - a function that wraps the nREPL handler and gets a crack at every message before (or instead of) the default machinery.
Its middleware, wrap-cljs-repl, does something pretty simple: for each incoming
message, it checks whether the session currently has an active ClojureScript
REPL. If it does and the op is eval or load-file, Piggieback handles the
message itself. Otherwise it passes the message straight through to the normal
Clojure handling. Distilled to its essence, it looks like this:
(defn wrap-cljs-repl [handler]
(fn [{:keys [session op] :as msg}]
(if (and (@session #'*cljs-repl-env*) ; are we in cljs mode?
(#{"eval" "load-file"} op)) ; and is this an eval?
(piggieback-eval msg) ; yes: hijack it
(handler msg)))) ; no: business as usual
The *cljs-repl-env* dynamic var stored in the session is the linchpin. Its
presence is the signal “this session is doing ClojureScript”. It’s also what
other middleware, like cider-nrepl, look for to know they’re dealing with a cljs
session - more on that in a bit.
Here’s the bigger picture, showing where Piggieback sits in the stack:
editor JVM: nREPL server
+---------+ nREPL protocol +-------------------------------------------+
| CIDER, |<--(bencode/edn)--->| transport + session |
| Calva, | | | |
| vim, | | v middleware stack |
| ... | | wrap-print > wrap-cljs-repl > eval ... |
+---------+ | | |
| (eval/load-file, only when the |
| session has *cljs-repl-env* set) |
| v |
| cljs.repl (IJavaScriptEnv) |
| | |
+--------------------+----------------------+
v
Node process / browser tab / ...
The clever bit is that Piggieback is written in Clojure and runs on the JVM,
yet it drives evaluation in ClojureScript. It does this through ClojureScript’s
own Clojure-facing API - specifically the cljs.repl/IJavaScriptEnv protocol,
which every ClojureScript REPL environment implements:
(defprotocol IJavaScriptEnv
(-setup [repl-env opts] "initialize the environment")
(-evaluate [repl-env filename line js] "evaluate a javascript string")
(-load [repl-env provides url] "load code at url into the environment")
(-tear-down [repl-env] "dispose of the environment"))
So the flow, roughly, is this. Your ClojureScript form comes in as text.
Piggieback reads it with the ClojureScript reader, the ClojureScript compiler
turns it into JavaScript, and -evaluate ships that JavaScript off to the
runtime (Node, browser, whatever). The result comes back as a string, Piggieback
reads it and sends it to your editor as an ordinary nREPL :value. Your editor
has no idea a whole JavaScript runtime was involved.
Starting the REPL vs. evaluating in it
There are actually two distinct code paths inside Piggieback, and understanding the split explains a lot of its quirks.
Setup is the fiddly one. To bootstrap a ClojureScript REPL you need to load
cljs.core, set up the analyzer, wire up the compiler environment, and require a
bunch of REPL helpers into cljs.user. Rather than reimplement all that,
Piggieback leans on ClojureScript’s own cljs.repl/repl* - it briefly drives the
real REPL loop, feeds it a single namespace-require form, and then bails out.
There’s a funny wrinkle here. cljs.repl/repl* insists on tearing the
environment down when its loop exits, which would immediately kill the Node
process we just launched. Piggieback’s workaround is to wrap your repl-env in a
delegating environment whose -tear-down is a deliberate no-op, forwarding
every other method to the real thing:
(deftype DelegatingReplEnv [repl-env]
cljs.repl/IJavaScriptEnv
(-setup [_ opts] (cljs.repl/-setup repl-env opts))
(-evaluate [_ filename line js] (cljs.repl/-evaluate repl-env filename line js))
(-load [_ provides url] (cljs.repl/-load repl-env provides url))
(-tear-down [_]) ; <- the whole point: swallow the tear-down
;; ... plus map-like access, delegated to repl-env ...
)
Steady-state evaluation is the other path, and it’s the one your editor hits on
every eval. Here Piggieback skips the REPL loop entirely and calls
cljs.repl/evaluate-form directly, reusing the compiler environment and repl-env
it squirreled away in the session during setup. One form in, one printed value
out.
(cljs-repl node-env) (eval "(+ 1 1)")
| |
v v
drive repl* once read cljs form
(delegating env) |
| compile to JS
-setup: launch Node |
| -evaluate --> Node --> "2"
stash repl-env + |
compiler-env in session read back, send :value
The compiler environment, and why your tools love it
Now for my favourite part, because it’s the bit that makes ClojureScript tooling actually good rather than merely functional.
When Piggieback sets up a REPL, it stashes the ClojureScript compiler environment in the session, under a well-known var:
;; in the session atom, keyed by the var itself:
{#'cider.piggieback/*cljs-compiler-env* <the compiler env atom>
#'cider.piggieback/*cljs-repl-env* <the (delegating) repl-env>}
That compiler environment is a treasure trove. It holds the analyzer’s full picture of your program - every namespace, every var, their arglists, docstrings, source locations, the works. And because Piggieback parks it somewhere stable and predictable, other middleware can reach in and grab it.
That’s exactly what cider-nrepl does. When you ask CIDER for completion candidates, or arglists, or “jump to definition” in a ClojureScript buffer, cider-nrepl pulls the compiler env out of the session:
;; cider-nrepl, roughly:
(get @session #'cider.piggieback/*cljs-compiler-env*)
…and then runs static analysis against it. No extra round-trip to the browser,
no runtime reflection, just the compiler’s own knowledge sitting right there for
the taking. It’s a nice example of how a small, stable contract between two
libraries enables a whole class of features neither could build alone. Every time
autocompletion works in a .cljs buffer, this little handshake is why.
Which environments does it support?
Because Piggieback talks to the generic cljs.repl/IJavaScriptEnv protocol, it
works with pretty much any standard ClojureScript REPL environment. These days
that mostly means Node.js (cljs.repl.node) - the workhorse for non-browser
development, scripts, and, importantly, test suites; this is what Piggieback’s own
tests run against. The other big one is the browser environment
(cljs.repl.browser), which evaluates in a real browser tab via a little
websocket dance and is great for actual web development. Beyond those there’s
GraalJS and anything else that happens to ship an IJavaScriptEnv
implementation.
A couple of historical footnotes are worth a mention too. The very first versions of Piggieback supported Rhino (dropped in 0.3.0) and later Nashorn, both in-JVM JavaScript engines that have since faded from relevance. The tests were moved off Nashorn and onto Node years ago.
There’s one category Piggieback can’t help with, though: self-hosted ClojureScript, like Lumo or Planck.3 Piggieback is fundamentally a Clojure program leaning on ClojureScript’s Clojure API. If there’s no JVM in the picture at all, there’s nothing for it to hook into. For those you want a native ClojureScript nREPL implementation like nrepl-cljs instead.
The limitations
Piggieback’s simplicity is its greatest strength, but simplicity always has a price. Let me be honest about the sharp edges.
The most annoying one is that you can only run one Node REPL per JVM.
ClojureScript’s cljs.repl.node keys some of its internal state on the thread
name, which collapses nREPL’s worker threads together, so two Node REPLs in the
same JVM end up clobbering each other. This one’s upstream, not Piggieback’s
fault, but it’s a real ceiling.
Then there’s the fact that you can’t interrupt a running evaluation. A
ClojureScript form runs in the JavaScript runtime, on a thread Piggieback doesn’t
control, and there’s no portable way to interrupt code that’s already executing
over there. So a runaway expression means restarting the runtime - Clojure’s
interrupt op simply doesn’t apply here.
Multi-form evaluation is limited too. For performance reasons Piggieback doesn’t spin up a fresh REPL for each evaluation, which means that if you send several top-level forms in a single message, only the first one gets evaluated. Rarely an issue in practice, but a sharp edge nonetheless.
And finally, Piggieback is coupled to ClojureScript’s compiler internals. There’s
no public “evaluate this cljs form and hand me the result” API, so it has to reach
into cljs.analyzer, cljs.env, and the non-public bits of cljs.repl. It
works because ClojureScript changes slowly, but it’s inherently a bit fragile.
None of these are dealbreakers for the things Piggieback is good at. But they do point at why it’s not the only game in town.
The other approach: shadow-cljs
If you’ve done any serious ClojureScript app development in the last several years, you’ve almost certainly used shadow-cljs, Thomas Heller’s build tool. And here’s a fact that surprises a lot of people: shadow-cljs does not use Piggieback. It ships its own nREPL middleware.
The philosophical difference is worth spelling out. Piggieback is a thin bridge
to the official cljs.repl environments; it assumes very little and owns very
little. shadow-cljs takes the opposite stance - it owns the entire build and
runtime lifecycle (watching your files, hot-reloading code, managing multiple
runtimes, tracking which build is which), and its nREPL integration is a natural
extension of all that machinery. When you eval a form in a shadow-cljs setup it
routes to whichever runtime is attached to your build, with hot-reload and the
rest already in play.
So which is “better”? That’s the wrong question, in my opinion. They’re solving overlapping but quite different problems, and the right choice depends almost entirely on what kind of ClojureScript environment you’re working in. If you’re building a web app with a modern toolchain, shadow-cljs’s integrated approach is probably a better fit and a nicer experience overall - the hot-reload story alone is worth a lot. If you’re on a plain deps.edn or Leiningen project, poking at a Node REPL, or building tooling that targets the official ClojureScript REPL, Piggieback’s minimalism is exactly what you want. The flip side of shadow-cljs owning everything is that you have to buy into its world; the flip side of Piggieback owning almost nothing is that ceiling of one Node REPL per JVM. Both are entirely valid trade-offs. I use both, depending on the day.
What we’ve been up to lately
Piggieback had drifted into maintenance mode for a while, so a big chunk of my recent work was simply modernizing it - part of the same recent burst of energy that’s producing CIDER 2.0 - and then fixing a pile of long-standing bugs now that I actually understood the thing.
On the correctness front, the recent 0.6.x and 0.7.0 releases sorted out a batch
of old issues: ClojureScript output going missing after reconnecting to a
session (#111), evaluation
breaking after a REPL setup error
(#62), garbled printing of
unknown tagged literals
(#120), and a cryptic error
when cljs-repl was invoked outside a session
(#124). On top of that,
load-file now evaluates the source that’s actually in your editor buffer,
unsaved changes and all, instead of silently re-reading the file from disk - which
is how nREPL has always behaved for Clojure. Closing a session now tears down its
ClojureScript REPL too, so you stop leaking Node processes when you disconnect
without a polite :cljs/quit. And describe finally advertises ClojureScript
status, so tools can detect that a session is in cljs mode straight from the
protocol instead of having to guess.
There was also a serious bit of internal restructuring. The old three-file
load/in-ns contraption is gone, all the ClojureScript coupling now lives
behind one clean namespace, and the runtime code generation for the delegating env
was replaced with a single, comprehensible type. The public contract didn’t change
one bit; the insides are just far less scary now. If you want the gory details, I
wrote both an
architecture document
and a roadmap
while I was in there - partly for future contributors, partly for future me, who
will inevitably forget all of this again.
What’s next?
A few things I’ve been ruminating on for the future. Piggieback still drives
cljs.repl/repl* for the initial bootstrap, which is the source of most of its
remaining awkwardness. In principle we could replicate just the setup we need and
unify the two eval paths into one, but that trades one kind of coupling for
another, so I’ve deferred it until the benefit clearly outweighs the risk.
There are also a couple of things I’d love to see fixed upstream in
ClojureScript itself, because they’re not really Piggieback’s problems: the
thread-name state in cljs.repl.node that caps us at one Node REPL per JVM, and
the way the Node env captures *out* at setup time. Fixing those upstream would
help everyone, shadow-cljs included. And if those pieces land, better
multi-runtime support becomes a real possibility. None of it is urgent, though.
Piggieback works, and works well, for what it does.
Epilogue
If there’s one thing I hope you take away from this, it’s that Piggieback is not
as scary as its reputation suggests. Underneath the intimidating “it evaluates
ClojureScript over nREPL” description is a genuinely simple idea - hijack a
session, delegate to cljs.repl, stash the compiler env where tools can find it -
with a bit of cleverness sprinkled on top. It’s a great example of how a small,
sharp design can endure for well over a decade with its core intact.
For years the community kept expecting native nREPL servers running on self-hosted ClojureScript to show up and make Piggieback obsolete. Those never really materialized. Piggieback, meanwhile, is still here, still doing its job, still quietly powering ClojureScript REPLs in editors everywhere. It’s clearly not the only game in town anymore - shadow-cljs’s approach is a better fit for a lot of people, and that’s great. But there’s something to be said for a little library that picked a simple design back in 2012 and rode it all the way to today.
That’s from me, folks! Keep hacking!
-
The official project FAQ’s answer to “Why ‘piggieback’ instead of ‘piggyback’?” is, and I quote, “That’s one of life’s greatest mysteries. Only Chas can answer that one.” I’ve decided to preserve the mystery. ↩
-
This is also why the main namespace is
cider.piggiebackrather thannrepl.piggieback. Renaming it would break every existing config for zero real benefit. Boring stability beats exciting churn. ↩ -
The self-hosted ClojureScript scene has gone rather quiet, by the way. Lumo was archived back in 2022 and Planck hasn’t cut a release since 2024. These days the “Clojure without a JVM” itch mostly gets scratched by SCI-based tools like nbb and scittle - a different beast (an interpreter, not the self-hosted compiler), but very much alive. Which makes that native ClojureScript nREPL server I keep pining for feel further away than ever. ↩