Middleware

Middleware are higher-order functions that accept a handler and return a new handler that may compose additional functionality onto or around the original. For example, some middleware that handles a hypothetical "time?" :op by replying with the local time on the server:

(require
 '[nrepl.misc :refer (response-for)]
 '[nrepl.transport :as t])

(defn current-time
  [h]
  (fn [{:keys [op transport] :as msg}]
    (if (= "time?" op)
      (t/send transport (response-for msg :status :done :time (System/currentTimeMillis)))
      (h msg))))

A little silly, but this pattern should be familiar to you if you have implemented Ring middleware before. Nearly all of the same patterns and expectations associated with Ring middleware should be applicable to nREPL middleware.

All of nREPL’s provided default functionality is implemented in terms of middleware, even foundational bits like session and eval support. This default middleware "stack" aims to match and exceed the functionality offered by the standard Clojure REPL, and is available at nrepl.server/default-middlewares. Concretely, it consists of a number of middleware functions' vars that are implicitly merged with any user-specified middleware provided to nrepl.server/default-handler. To understand how that implicit merge works, we’ll first need to talk about middleware "descriptors".

(See this documentation listing for details as to the operations implemented by nREPL’s default middleware stack, what each operation expects in request messages, and what they emit for responses.)

Middleware descriptors and nREPL server configuration

It is generally the case that most users of nREPL will expect some minimal REPL functionality to always be available: evaluation (and the ability to interrupt evaluations), sessions, file loading, and so on. However, as with all middleware, the order in which nREPL middleware is applied to a base handler is significant; e.g., the session middleware’s handler must look up a user’s session and add it to the message map before delegating to the handler it wraps (so that e.g. evaluation middleware can use that session data to stand up the user’s dynamic evaluation context). If middleware were "just" functions, then any customization of an nREPL middleware stack would need to explicitly repeat all of the defaults, except for the edge cases where middleware is to be appended or prepended to the default stack.

To eliminate this tedium, the vars holding nREPL middleware functions may have a descriptor applied to them to specify certain constraints in how that middleware is applied. For example, the descriptor for the nrepl.middleware.session/add-stdin middleware is set thusly:

(set-descriptor! #'add-stdin
  {:requires #{#'session}
   :expects #{"eval"}
   :handles {"stdin"
             {:doc "Add content from the value of \"stdin\" to *in* in the current session."
              :requires {"stdin" "Content to add to *in*."}
              :optional {}
              :returns {"status" "A status of \"need-input\" will be sent if a session's *in* requires content in order to satisfy an attempted read operation."}}}})

Middleware descriptors are implemented as a map in var metadata under a :nrepl.middleware/descriptor key. Each descriptor can contain any of three entries:

  • :requires, a set containing strings or vars identifying other middleware that must be applied at a higher level than the middleware being described. Var references indicate an implementation detail dependency; string values indicate a dependency on any middleware that handles the specified :op.

  • :expects, the same as :requires, except the referenced middleware must exist in the final stack at a lower level than the middleware being described.

Another way to think of :expects and :requires would be before and after. Middleware you’re expecting should have already been applied by the time the middleware that expects it gets applied, and middleware that’s required should be applied afterwards. We’ll expand on this in the paragraphs to come.
  • :handles, a map that documents the operations implemented by the middleware. Each entry in this map must have as its key the string value of the handled :op and a value that contains any of four entries:

    • :doc, a human-readable docstring for the middleware

    • :requires, a map of slots that the handled operation must find in request messages with the indicated :op

    • :optional, a map of slots that the handled operation may utilize from the request messages with the indicated :op

    • :returns, a map of slots that may be found in messages sent in response to handling the indicated :op

The values in the :handles map are used to support the "describe" operation, which provides "a machine- and human-readable directory and documentation for the operations supported by an nREPL endpoint" (see nrepl.impl.docs/generate-ops-info and the results of lein with-profile +maint run nrepl.impl.docs here).

There’s also lein with-profile +maint run nrepl.impl.docs --output md if you’d like to generate an ops listing in Markdown format.

The :requires and :expects entries control the order in which middleware is applied to a base handler. In the add-stdin example above, that middleware will be applied after any middleware that handles the "eval" operation, but before the nrepl.middleware.session/session middleware. In the case of add-stdin, this ensures that incoming messages hit the session middleware (thus ensuring that the user’s dynamic scope — including in — has been added to the message) before the add-stdin’s handler sees them, so that it may append the provided `stdin content to the buffer underlying in. Additionally, add-stdin must be "above" any eval middleware, as it takes responsibility for calling clojure.main/skip-if-eol on in prior to each evaluation (in order to ensure functional parity with Clojure’s default stream-based REPL implementation).

The specific contents of a middleware’s descriptor depends entirely on its objectives: which operations it is to implement/define, how it is to modify incoming request messages, and which higher- and lower-level middlewares are to aid in accomplishing its aims.

nREPL uses the dependency information in descriptors in order to produce a linearization of a set of middleware; this linearization is exposed by nrepl.middleware/linearize-middleware-stack, which is implicitly used by nrepl.server/default-handler to combine the default stack of middleware with any additional provided middleware vars. The primary contribution of default-handler is to use nrepl.server/unknown-op as the base handler; this ensures that unhandled messages will always produce a response message with an :unknown-op :status. Any handlers otherwise created (e.g. via direct usage of linearize-middleware-stack to obtain a ordered sequence of middleware vars) should do the same, or use a similar alternative base handler.

Sessions

There are two types of sessions: ephemeral sessions and long-lived sessions (or registered sessions).

When a message arrives without a session id, an ephemeral session is created and assigned to it. Ephemeral sessions are bound to the processing of a single message.

The only way to create a long-lived session is to clone an existing session (even an ephemeral one). Cloning a session creates a new session that initially shares the dynamic bindings of the original session.

Sessions (long-lived ones since they are the useful ones) serialize evaluations and make dynamic bindings available for inspection to other ops.

All other ops have no serialization guarantee (they are serialized in the current implementation since they run on the server IO thread). However evals run on a dedicated thread so a running eval can’t block another op.

Sessions become even more useful when different nREPL extensions start taking advantage of them. debug-repl uses sessions to store information about the current breakpoint, allowing debugging of two things separately. piggieback uses sessions to allow host a ClojureScript REPL alongside an existing Clojure one.

Pretty Printing

Pretty-printing support was added in nREPL 0.5 and the API is still considered experimental.

nREPL includes a pr-values middleware to print the results of evaluated forms as strings for returning to the client. By default, this will use either print-dup or print-method to match the standard Clojure print behavior. To customize this you can pass a custom :printer as a symbol along with the message. If present, this will be resolved to a function and used to print the value instead.

This enables using libraries like puget to pretty-print the evaluation results automatically.

{:op :eval
 :code "(+ 1 1)"
 :printer 'my.custom/print-value
 :print-options {:print-width 120}}

A note for client authors - passing strings will work just fine as well (using the bencode transport you can’t pass keywords and symbols as anyways).

{"op" "eval"
 "code" "(+ 1 1)"
 "printer" "my.custom/print-value"
 "print-options" {"print-width" 120}}

nREPL will take of converting the keys of the print-options map to keywords, so they’d work as expected with their respective printers.

The requirement for the printer function are the following:

  • It should take one or two arguments - a value to print, and an optional print options map.

  • It should return the printed value as a string. This means you can’t use directly functions like clojure.pprint/pprint, as they output the printed value instead of returning it as a string.

  • The printer function should have no side-effects.

  • In the absence of print options it should ideally respect Clojure’s print dynamic vars - e.g. *print-length*, *print-right-margin*, etc.

Great examples of functions that honour the API contract are zprint.core/zprint-str and puget.printer/pprint-str.