ClojureScript Libraries in 2020 part 2: Introducing target-bundle-libs

Wes Morgan
Wes Morgan

This post is a part 2 to this earlier post. You may want to start there if you haven't read it yet. But I will start by quickly restating the problem here, so it's not absolutely critical.

ClojureScript libraries w/ :target :bundle

The :target :bundle approach to building ClojureScript apps is really cool. At a very high level, it solves the "foreign libs" (i.e. Javascript that can't be processed by the Google Closure compiler; which is the vast majority of code you'll find on npmjs.com, for example) problem by dividing the process of building your app into two distinct steps:

  1. The CLJS compiler and Google Closure tools build and optimize your CLJS code. It also outputs JS code that third-party bundling tools can use to create something ready to run in a web browser.
  2. A JS bundler like webpack then reads the output and bundles your compiled and optimized code with your foreign JS libs. :target :bundle defines a new :bundle-cmd key you can put in your compiler options that will automatically invoke your bundler after the CLJS+Google Closure part of the pipeline is done.

The CLJS tooling doesn't do anything to manage your foreign JS deps. It expects you to do that separately. So, for example, you can just put your NPM deps in a top-level package.json file and install them with tools like npm or yarn.

As long as they're there when your :bundle-cmd is run, everything comes together and your app works.

Except...

If you have CLJS deps that themselves have foreign JS libs as deps (e.g. a CLJS library that gives an idiomatic Clojure interface on top of an NPM library), there's an important caveat. You cannot use :target :bundle to build these libraries (for e.g. running their own test suites). If you do you'll find that these transitive JS deps are missing when you try to build and run the top-level application.

At one level this makes sense. Think again about what :target :bundle does in the top-level app: It is entirely out of the business of managing nor bundling your foreign JS deps. It leaves that up to third-party tools. But those same tools don't know anything about CLJS deps (defined in your deps.edn file, for example), so they can't find transitive foreign JS deps. A CLJS library will be distributed in a JAR file from a Maven repo (e.g. Clojars), but the package.json file won't be in there.

The officially recommended approach for this is to include a src/deps.cljs file in your CLJS library that (re-)defines your foreign JS deps. This is an old feature in CLJS that predates :target :bundle. Since this is in the src dir, it will be included in your lib's JAR file. The ClojureScript compiler can then see these and (optionally) install those deps and include them in your bundle. The CLJSJS packages take advantage of this feature.

Is this OK?

Maybe. If you and your team are fine with defining foreign JS deps for libs in one way and for apps in another way (or duplicating them and manually keeping them in sync; which I really don't recommend) AND you have a bright line distinction between your app and library code, then by all means manage your JS deps the recommended way.

No sir, I don't like it

I personally find the status quo unsatisfying. For some context, I'm currently moving a semi-complex set of applications and libraries (some of which are CLJC hybrid Clojure / ClojureScript apps where the CLJS builds target browsers) from leiningen to tools-deps & target-bundle.

I wasn't happy shipping a solution to my team where the CLJ apps and libs all used the same basic tooling, processes, commands, etc. but CLJS apps were very different from the libs. Especially because the problem that needed to be solved felt simple, self-contained, and straightforward. Namely: I needed to get the JS deps info in package.json in the CLJS lib into a place where the CLJS tooling could read it in the JAR file it would later install. There was already a place I could put it: src/deps.cljs. So I wrote a tiny helper tool that reads package.json and writes out your dependencies as EDN into src/deps.cljs. It allows me to treat our libraries the same as our apps, and I like it so far.

It is called target-bundle-libs and you can find it here: https://github.com/cap10morgan/target-bundle-libs.

If you would like to try it in your CLJS libraries too, please let me know if you run into any issues or shortcomings. This is very much in "simplest thing that could possibly work" territory right now, and it definitely has bugs and omissions.