nREPL: make the built-in server middleware-extensible

The built-in nREPL stays minimal (clone/describe/eval/load-file/close) but now
composes a middleware stack so a library can add the heavier features (sessions,
interruptible-eval, completion, lookup) without bloating core.

- A middleware is (fn [handler] (fn [request] ...)); request carries :reply (a
  thread-safe send that adds id/session) plus the wire fields. List them in
  deps.edn :nrepl/middleware (symbols -> a middleware fn or a vector of them);
  jolt.nrepl composes them over the built-in handler.
- Public seam: respond, evaluate, register-ops!, new-session, err-msg.
- Per-connection send lock so middleware replying from other threads don't
  interleave bytes. describe advertises built-in + registered ops.

deps.clj surfaces :nrepl/middleware; jolt.main passes it to the server. Built-in
behavior unchanged when no middleware is declared. Runtime, no re-mint.
This commit is contained in:
Yogthos 2026-06-22 15:37:14 -04:00
parent d33277c0b2
commit 185b4fd3ca
3 changed files with 130 additions and 73 deletions

View file

@ -124,7 +124,10 @@
{:roots (vec (distinct (concat project-roots dep-roots))) {:roots (vec (distinct (concat project-roots dep-roots)))
:main-opts main-opts :main-opts main-opts
:tasks (:tasks edn) :tasks (:tasks edn)
:natives (vec (distinct (concat (:jolt/native edn) dep-natives)))}))) :natives (vec (distinct (concat (:jolt/native edn) dep-natives)))
;; nREPL middleware a library contributes (jolt.nrepl composes them over its
;; built-in handler) — symbols resolving to a middleware fn or a vector of them.
:nrepl-middleware (:nrepl/middleware edn)})))
(defn has-deps-edn? [project-dir] (defn has-deps-edn? [project-dir]
(file-exists? (str project-dir "/deps.edn"))) (file-exists? (str project-dir "/deps.edn")))

View file

@ -114,12 +114,15 @@
(defn- nrepl [more] (defn- nrepl [more]
;; resolve the project (deps on the roots, native libs loaded), then start the ;; resolve the project (deps on the roots, native libs loaded), then start the
;; nREPL server so an editor can connect and (require '[some.lib]) live. ;; nREPL server so an editor can connect and (require '[some.lib]) live. A
(try (apply-project! (deps/resolve-project (project-dir))) (catch :default _ nil)) ;; library's middleware (deps.edn :nrepl/middleware) is composed over the
;; built-in handler — sessions / interruptible-eval / completion etc.
(let [resolved (deps/resolve-project (project-dir))]
(apply-project! resolved)
(let [port (or (some-> (first (filter #(not (str/starts-with? % "-")) more)) parse-long) (let [port (or (some-> (first (filter #(not (str/starts-with? % "-")) more)) parse-long)
(parse-long (or (jolt.host/getenv "JOLT_NREPL_PORT") "7888")))] (parse-long (or (jolt.host/getenv "JOLT_NREPL_PORT") "7888")))]
(require 'jolt.nrepl) (require 'jolt.nrepl)
((resolve 'jolt.nrepl/start) port))) ((resolve 'jolt.nrepl/start) port (:nrepl-middleware resolved)))))
(defn- usage [] (defn- usage []
(println "usage: jolt <command> [args]") (println "usage: jolt <command> [args]")

View file

@ -1,10 +1,19 @@
(ns jolt.nrepl (ns jolt.nrepl
"A minimal nREPL server for jolt, so an editor (CIDER / Calva / Cursive) can "A minimal, extensible nREPL server for jolt, so an editor (CIDER / Calva /
connect and develop a project live. Speaks bencode over a loopback TCP socket Cursive) can connect and develop a project live. Speaks bencode over a loopback
bound through jolt.ffi; supports the ops a client needs to connect and eval: TCP socket bound through jolt.ffi. Built in: clone, describe, eval, load-file,
clone, describe, eval, load-file, close. The project's deps are already on the close enough to connect and eval, with the project's deps on the roots and
source roots and its native libs loaded (jolt.main applies the project before native libs loaded (jolt.main applies the project first), so (require '[lib])
starting the server), so (require '[some.lib]) works at the REPL. works.
EXTENSIBLE: a library can add the heavier nREPL features (sessions,
interruptible-eval, completion, lookup) as MIDDLEWARE without bloating core. A
middleware is `(fn [handler] (fn [request] ...))`; list them in deps.edn under
:nrepl/middleware (symbols resolving to a middleware fn, or to a vector of them)
and jolt.nrepl composes them over the built-in handler. The request is the
decoded bencode map (string keys: \"op\" \"code\" \"ns\" \"id\" \"session\" )
plus :reply a thread-safe (fn [response-map]) that adds id/session and sends.
Public seam for middleware: respond, evaluate, register-ops!, new-session.
Writes .nrepl-port in the project dir so editors auto-detect the port." Writes .nrepl-port in the project dir so editors auto-detect the port."
(:require [clojure.string :as str] (:require [clojure.string :as str]
@ -109,80 +118,122 @@
(when (<= end (count s)) [(subs s start end) end])))) (when (<= end (count s)) [(subs s start end) end]))))
:else nil)))) :else nil))))
;; --- eval ------------------------------------------------------------------ ;; --- public seam for middleware --------------------------------------------
(def ^:private session-counter (atom 0)) (def ^:private session-counter (atom 0))
(defn- new-session [] (str "jolt-" (swap! session-counter inc))) (defn new-session
"A fresh session id (middleware that implements sessions uses this)."
[] (str "jolt-" (swap! session-counter inc)))
(defn- err-msg [e] (defn respond
"Send a response map for `request` (id/session added, then bencoded)."
[request m] ((:reply request) m))
(defn err-msg
"Best-effort message for any thrown value (ex-info, or a raw Chez condition
ex-message is nil for those, so fall back to the host condition text)."
[e]
(or (ex-message e) (or (ex-message e)
(try ((resolve 'jolt.host/condition-message) e) (catch :default _ nil)) (try ((resolve 'jolt.host/condition-message) e) (catch :default _ nil))
(pr-str e))) (pr-str e)))
(defn- eval-in-ns [ns-str code] (defn evaluate
;; evaluate in the requested ns if it's loaded (CIDER sends :ns with each eval). "Evaluate `code` (optionally in loaded ns `ns-str`), capturing *out*. Returns
;; in-ns — not (binding [*ns* ..]) — sets the ns load-string resolves against on {:value .. :out .. :ns .. :err ..}. in-ns not (binding [*ns* ..]) sets the
;; jolt; it persists for the next eval (fine for a serial editor session). ns load-string resolves against on jolt. Reusable by eval middleware."
(when (and ns-str (not (str/blank? ns-str)) (find-ns (symbol ns-str))) [code ns-str]
(in-ns (symbol ns-str)))
(load-string code))
(defn- do-eval [code ns-str]
;; eval the code, capturing *out*; return {:value .. :out .. :ns .. :err ..}.
(let [result (atom nil) err (atom nil) (let [result (atom nil) err (atom nil)
out (with-out-str out (with-out-str
(try (reset! result (eval-in-ns ns-str (wire-> code))) (try (when (and ns-str (not (str/blank? ns-str)) (find-ns (symbol ns-str)))
(in-ns (symbol ns-str)))
(reset! result (load-string code))
(catch :default e (reset! err (err-msg e)))))] (catch :default e (reset! err (err-msg e)))))]
{:value (when (nil? @err) (pr-str @result)) {:value (when (nil? @err) (pr-str @result))
:out out :out out
:ns (str (ns-name *ns*)) :ns (str (ns-name *ns*))
:err @err})) :err @err}))
(defn- handle-msg [fd msg] ;; ops middleware advertise via describe (built-ins + any a library registers).
(let [op (get msg "op") (def ^:private extra-ops (atom #{}))
id (get msg "id") (defn register-ops!
session (or (get msg "session") "none") "Register op name(s) so `describe` advertises them. Call at middleware load."
base (fn [m] (cond-> m id (assoc "id" id) session (assoc "session" session))) [& ops] (swap! extra-ops into (map name ops)))
reply (fn [m] (send-str fd (bencode (base m))))]
(cond
(= op "clone") (reply {"new-session" (new-session) "status" ["done"]})
(= op "close") (reply {"status" ["session-closed" "done"]})
(= op "describe") (reply {"status" ["done"]
"versions" {"jolt-nrepl" {"major" 0 "minor" 1}}
"ops" {"clone" {} "close" {} "describe" {} "eval" {} "load-file" {}}})
(or (= op "eval") (= op "load-file"))
(let [code (if (= op "load-file") (get msg "file") (get msg "code"))
{:keys [value out ns err]} (do-eval code (get msg "ns"))]
(when (seq out) (reply {"out" out}))
(if err
(do (reply {"err" (str err "\n")}) (reply {"ex" (str err) "status" ["eval-error" "done"]}))
(reply {"value" value "ns" ns "status" ["done"]})))
:else (reply {"status" ["done" "unknown-op"]}))))
(defn- handle-conn [fd] ;; --- built-in handler ------------------------------------------------------
(defn- built-in-handler [request]
(let [op (get request "op")]
(cond
(= op "clone") (respond request {"new-session" (new-session) "status" ["done"]})
(= op "close") (respond request {"status" ["session-closed" "done"]})
(= op "describe") (respond request {"status" ["done"]
"versions" {"jolt-nrepl" {"major" 0 "minor" 1}}
"ops" (zipmap (into #{"clone" "close" "describe" "eval" "load-file"}
@extra-ops)
(repeat {}))})
(or (= op "eval") (= op "load-file"))
(let [code (wire-> (if (= op "load-file") (get request "file") (get request "code")))
{:keys [value out ns err]} (evaluate code (get request "ns"))]
(when (seq out) (respond request {"out" out}))
(if err
(do (respond request {"err" (str err "\n")})
(respond request {"ex" (str err) "status" ["eval-error" "done"]}))
(respond request {"value" value "ns" ns "status" ["done"]})))
:else (respond request {"status" ["done" "unknown-op"]}))))
;; --- middleware composition ------------------------------------------------
;; resolve deps.edn :nrepl/middleware symbols to middleware fns. An entry may
;; resolve to a single (fn [handler] handler') or to a vector of them (so a
;; library can export one `default-middleware` var).
(defn- resolve-middleware [syms]
(vec (mapcat
(fn [sym]
(require (symbol (namespace sym)))
(let [v (deref (resolve sym))]
(if (sequential? v) (map #(if (var? %) (deref %) %) v) [v])))
syms)))
(defn- build-handler [middleware]
;; first listed middleware is outermost.
(reduce (fn [h mw] (mw h)) built-in-handler (reverse middleware)))
(defn- handle-conn [fd handler]
;; one send lock per connection: eval/session middleware reply from other
;; threads, so sends must not interleave.
(let [lock (Object.)
reply-for (fn [msg]
(let [id (get msg "id") session (or (get msg "session") "none")]
(fn [m]
(locking lock
(send-str fd (bencode (cond-> m id (assoc "id" id)
session (assoc "session" session))))))))]
(loop [buf ""] (loop [buf ""]
(let [chunk (recv-str fd)] (let [chunk (recv-str fd)]
(if (nil? chunk) (if (nil? chunk)
(c-close fd) (c-close fd)
(let [buf (str buf chunk) (let [rest-buf (loop [b (str buf chunk)]
;; drain every complete message in the buffer
rest-buf (loop [b buf]
(let [r (bdecode b 0)] (let [r (bdecode b 0)]
(if (nil? r) b (if (nil? r) b
(do (when (map? (first r)) (handle-msg fd (first r))) (do (when (map? (first r))
(let [msg (first r)]
(try (handler (assoc msg :reply (reply-for msg)))
(catch :default e (println "nrepl handler error:" (err-msg e))))))
(recur (subs b (second r)))))))] (recur (subs b (second r)))))))]
(recur rest-buf)))))) (recur rest-buf)))))))
(defn start (defn start
"Start the nREPL server on `port` (0 = an OS-assigned port is not supported here; "Start the nREPL server on `port` (a concrete port; loopback only). `middleware`
pass a concrete port). Writes .nrepl-port. Blocks accepting connections." is a vector of deps.edn :nrepl/middleware symbols to compose over the built-in
[port] handler. Writes .nrepl-port. Blocks accepting connections."
(let [fd (listen-socket port)] ([port] (start port nil))
([port middleware]
(let [handler (build-handler (resolve-middleware (or middleware [])))
fd (listen-socket port)]
(try (spit ".nrepl-port" (str port)) (catch :default _ nil)) (try (spit ".nrepl-port" (str port)) (catch :default _ nil))
(println (str "nREPL server started on port " port " (127.0.0.1) — .nrepl-port written")) (println (str "nREPL server started on port " port " (127.0.0.1) — .nrepl-port written"))
(when (seq middleware) (println (str ";; middleware: " (str/join " " middleware))))
(println ";; connect your editor; ^C to stop") (println ";; connect your editor; ^C to stop")
(loop [] (loop []
(let [conn (c-accept fd ffi/null ffi/null)] (let [conn (c-accept fd ffi/null ffi/null)]
(when (>= conn 0) (when (>= conn 0)
(future (try (handle-conn conn) (future (try (handle-conn conn handler)
(catch :default e (println "nrepl conn error:" (ex-message e)) (c-close conn))))) (catch :default e (println "nrepl conn error:" (err-msg e)) (c-close conn)))))
(recur))))) (recur))))))