In my previous post I outlined my REPL based workflow for CLJ as well as CLJS. In this post I want to dive deeper into how you can extend/customize it to make it your own.

The gist of it all is having a dedicated src/dev/repl.clj file. This file serves as the entrypoint to start your development setup, so this is where everything related should go. It captures the essential start/stop cycle to quickly get your workflow going and restarting it if necessary.

Quick note: There is no rule that this needs to be in this file, or even that everyone working in a team needs to use the same file. Each team member could have their own file. This is all just Clojure code and there are basically no limits.

There are a couple common questions that come up in shadow-cljs discussions every so often. I’ll give some examples of stuff I have done or recommendations I made in the past. I used to recommend npm-run-all in the past, but have pretty much eliminated all my own uses in favor of this.

Example 1: Building CSS

Probably the most common question is how to process CSS in a CLJ/CLJS environment. Especially people from the JS side of the world often come with the expectation that the build tool (i.e. shadow-cljs) would take care of it. shadow-cljs does not support processing CSS, and likely never will. IMHO it doesn’t need to, you can just as well build something yourself.

I’ll use the code as a reference that builds the CSS for the shadow-cljs UI itself. You can find in its src/dev/repl.clj.

(defonce css-watch-ref (atom nil))

(defn start []
  ;; ...

  (build/css-release)

  (reset! css-watch-ref
    (fs-watch/start
      {}
      [(io/file "src" "main")]
      ["cljs" "cljc" "clj"]
      (fn [updates]
        (try
          (build/css-release)
          (catch Exception e
            (prn [:css-failed e])))
        )))

  ::started)

(defn stop []
  (when-some [css-watch @css-watch-ref]
    (fs-watch/stop css-watch))
  
  ::stopped)

So, when I start my workflow, it calls the build/css-release function. Which is just a defn in another namespace. This happens to be using shadow-css, but for the purposes of this post this isn’t relevant. It could be anything you can do in Clojure.

shadow-css does not have a built-in watcher, so in the next bit I’m using the fs-watch namespace from shadow-cljs. Basically it watches the src/main folder for changes in cljs,cljc,clj files, given that shadow-css generates CSS from Clojure (css ...) forms. When fs-watch detects changes it calls the provided function. In this case I don’t care about which file was updated and just rebuild the whole CSS. This takes about 50-100ms, so optimizing this further wasn’t necessary, although I did in the past and you absolutely can.

fs-watch/start returns the watcher instance, which I’m storing in the css-watch-ref atom. The stop function will properly shut this down, to avoid ending up with this watch running multiple times.

When it is time to make an actual release I will just call the build/css-release as part of that process. That is the reason this is a dedicated function in a different namespace, I don’t want to be reaching into the repl ns for release related things, although technically there is nothing wrong with doing that. Just personal preference I guess.

Example 2: Running other Tasks

Well, this is just a repeat of the above. The pattern is always the same. Maybe you are trying to run tailwindcss instead? This has its own watch mode, so you can skip the fs-watch entirely. Clojure and the JVM have many different ways of running external processes. java.lang.ProcessBuilder is always available and quite easy to use from CLJ. The latest Clojure 1.12 release added a new clojure.java.process namespace, which might just suit your needs too, and is also just wrapper around ProcessBuilder.

;; with ns (:import [java.lang ProcessBuilder ProcessBuilder$Redirect Process]) added
(defn css-watch []
  (-> ["npx" "tailwindcss" "-i" "./src/css/index.css" "-o" "public/css/main.css" "--watch"]
      (ProcessBuilder.)
      (.redirectError ProcessBuilder$Redirect/INHERIT)
      (.redirectOutput ProcessBuilder$Redirect/INHERIT)
      (.start)))

So, this starts the tailwindcss command (example taken directly from tailwind docs). Those .redirectError/.redirectOutput calls make sure the output of the process isn’t lost and instead is written to the stderr/stdout of the current JVM process. We do not want to wait for this process to exit, since it just keeps running and watching the files. Integrating this into our start function then becomes

  (reset! css-watch-ref
    (build/css-watch))

The css-watch function returns a java.lang.Process handle, which we can later use to kill the process in our stop function.

(defn stop []
  (when-some [css-watch @css-watch-ref]
    (.destroyForcibly css-watch)
    (reset! css-watch-ref nil))
  
  ::stopped)

You could then build your own build/css-release function, that uses the same mechanism but skips the watch. Or just run it directly from your shell instead.

Things to be aware of

Unlimited Power!

It is quite possible to break your entire JVM or just leaking a lot of resources all over the place. Make sure you actually always properly clean up after yourself and do not just ignore this. Shutting down the JVM entirely will usually clean up, but I always consider this the “nuclear” option. I rely on stop to clean up everything, since actually starting the JVM is quite expensive I want to avoid doing it. I often have my dev process running for weeks, and pretty much the only reason to ever restart it is when I need to change my dependencies.

Also make sure you actually see the produced output. The above tailwind process for example. I would want to see this output in case tailwind is showing me an error. Depending on how you start your setup this may not be visible by default. If running this over nREPL for example it won’t show up in the REPL. I actually prefer that, so I’ll always have this visible in a dedicated Terminal window on my side monitor.

Those are the two main reasons I personally do not like the “jack-in” process of most editors. My JVM process lives longer than my editor, and, at least in the past, some of the output wasn’t always visible by default. Could be that is a non-issue these days, just make sure to check.