Where parallels cross

Interesting bits of life

An easier way to try Cljs libraries with shadow-cljs ob-clojure and cider

In my previous blog, I showed how to inject libraries in a Cider session started via ob-clojure. So I thought to provide the same for ClojureScript (Cljs). With Cljs we need to do something similar, but with the additional step of setting up a REPL for Cider to work with.

When working with Cljs you may be targeting different platforms (e.g., Node.js, the browser, or even mobile). I had a great experience using shadow-cljs for compiling Cljs to one of these, so I picked that tool for starting the volatile REPL I need. I say volatile because in my note-taking/min-exploration session I am aiming to avoiding the distraction of setting up a Cljs project from scratch.

The use case looks like this in an Org Mode file:

#+begin_src clojurescript :deps '(("io.github.ruffnext/cljs-http" "0.1.47")) :shadowcljs-type "browser-repl"
(ns example.core
  (:require-macros [cljs.core.async.macros :refer [go]])
  (:require [cljs-http.client :as http]
     [cljs.core.async :refer [<!]]))

(def result (atom {}))
(go (let [response (<! (http/get "https://api.github.com/users"
                                 {:with-credentials? false
                                  :query-params {"since" 135}}))]
      (swap! result #(conj %
                           [:status (:status response)]
                           [:login (map :login (:body response))]))))

@result
#+end_src

#+RESULTS:
| #'example.core/result                                                                                                              |
| #object[cljs.core.async.impl.channels.ManyToManyChannel]                                                                           |
| {:status 200, :login ("simonjefford" "josh" ...)} |

In the above block you may see that I successfully run some example code for the cljs-http library via an Org Mode source block. (Hurrah!)

May be also useful to see this in action:

If you are an user of Org Babel, you may also recognize that the :deps and shadowcljs-type headers don't come by default. My hack uses these to setup the shadow-cljs REPL correctly: in the example, I target the browser and I declare the dependency I need.

The code for this to work consists in an Emacs advice which runs around ob-eval:clojure, which is the function Org Mode calls when you try to run a Cljs code block.

The aim is to start a shadow-cljs REPL and then let ob-clojure start Cider. I put an if statement to avoid starting the REPL multiple times: that seemed to work in my case, but is not battle-tested.

All in all this is the hack:

(defun my/cider-run-projectless-cljs-repl (&optional repl-type deps)
  "Start a projectless cljs REPL running on REPL-TYPE with DEPS.
DEPS needs to be something like '((\"foo/bar\" \"0.0.1\") (\"foo/baz\" \"0.0.2\"))"
  (when (and
         ;; shadow-cljs is available
         (executable-find "shadow-cljs")
         ;; check if cider has a shadow session running for this dir
         (not (--any
               (and (s-contains-p "cider-repl" (buffer-name it))
                    (s-contains-p "cljs" (buffer-name it))
                    (s-contains-p (file-name-base (directory-file-name
                                                   default-directory))
                                  (buffer-name it)))
               (buffer-list)))
         ;; not in a cljs project
         (not (--any (or (s-ends-with-p ".edn" it)
                         (s-ends-with-p ".clj" it))
                     (directory-files
                      (or
                       (ignore-errors (project-root (project-current)))
                       default-directory)))))
    (let* ((repl-type (or
                       repl-type
                       (completing-read
                        "Shadow-cljs REPL type:"
                        '("browser-repl" "node-repl")
                        nil
                        t)))
           (deps-as-vectors
            (concat "["
                    (s-join
                     "\n"
                     (--map (concat "[" (nth 0 it) " \"" (nth 1 it) "\"]")
                            deps))
                    "]")))
      (with-temp-file "shadow-cljs.edn"
        (insert (concat
                 "{:dependencies "
                 deps-as-vectors
                 "}")))
      (async-shell-command
       (concat "shadow-cljs " repl-type))
      ;; in a bit get rid of the temporary shadow-cljs.edn
      (run-with-idle-timer
       10
       nil
       (lambda () (shell-command "rm shadow-cljs.edn")))
      repl-type)))

(defun my/setup-shadow-cljs-project-if-possible (orig-fun &rest args)
  (if-let* ((headers (nth 1 args))
            (_ (equal "cljs" (alist-get :target headers)))
            (cider-default-cljs-repl 'shadow)
            (cider-shadow-default-options
             (my/cider-run-projectless-cljs-repl (alist-get :shadowcljs-type headers)
                                                 (alist-get :deps headers))))
      (progn
        (while (not (car-safe (nrepl-extract-ports (cider--file-path "."))))
          (message "Waiting for shadow-cljs to connect...")
          (sleep-for 1))
        (cider-connect-cljs (list
                             :host "localhost"
                             :port (car (nrepl-extract-ports (cider--file-path ".")))
                             :project-dir (concat "~/" (file-relative-name "." "~"))
                             :cljs-repl-type 'shadow))
        (apply orig-fun args))
    (apply orig-fun args)))

(advice-add 'ob-clojure-eval-with-cider :around #'my/setup-shadow-cljs-project-if-possible)

Note that this code creates a shadow-cljs.edn configuration file to declare the dependencies. Starting a REPL comes to shadow-cljs browser-repl or shadow-cljs node-repl, which are default build ids that shadow-cljs provides by default. I also added a timer that deletes the file after 10 seconds of inactivity, because I am pretty sure I would forget to cleanup things after my note taking.

I put these ob-clojure extensions in this little GitHub project to make it easier to load for people.

Happy literate hacking!