Bazel is incompatible with JavaScript
Bazel is an open source fork of Googleâs internal tooling, Blaze, that Iâve had the misfortune of fighting with for the past year. It promises a mouthwatering smorgasboard of:
- Incremental builds
- Build caching
- Remote build caching
- Support for any programming language, any codebase, and any possible deploy target
The reviews are great. It has a passionate fanbase. Itâs well-maintained and supported.
But its promises fall completely flat for the JS ecosystem, to the point Iâve developed the strong opinion it shouldnât touch ANY JavaScript, TypeScript, or Node.js at all. And itâs not just me that thinks this; people like Ben Lesh and companies like Vercel also feel itâs incompatible. But Iâll explain why I am pleading for folks to ensure that never the twain shall meet.
TL;DR
âBlah, blah; I donât use Bazel and donât care. I found this Googling and just want to know what I should use.â Use Turborepo + pnpm. Turborepo is designed for how JS works from the ground-up, and does an absolutely lovely job at delivering on all of Bazelâs promises for the JS ecosystem. Though I wonât mention Turborepo again in this blog post, just know for every criticism levied against Bazel, I canât say the same for Turborepo.
âWhat about Nx?â I have no clue. I havenât tried it. This is about my hatred for Bazel, not about my passion for cached build systems.
Anyways, with that out of the wayâŚ
Inputs and outputs
Letâs back up a bit and lay some groundwork. The underlying principle of skipping work is determinism. Or more specifically, the idea that if the inputs donât change, the outputs shouldnât, therefore, no reason to rebuild (assuming itâs a pure system).
Bazelâand any other build system for that matterâoperates off this principle of âidentify the inputs, and you can determine whether or not a rebuild is necessary.â Fair enough. However, how it determines that is not only time-consuming; itâs philosophically opposed to how JS works.
Problem 1: I wonât do what you tell me
Bazel uses a proprietary syntax called Starlark that is based on Python. All packages require writing Starlark for config. I donât really mind it (better than MAKEFILEs), but it can be⌠a lot. Hereâs a simple example:
# BUILD
load("@aspect_bazel_lib//lib:copy_to_directory.bzl", "copy_to_directory")
load("@aspect_bazel_lib//lib:write_source_files.bzl", "write_source_files")
load("@aspect_rules_js//npm:defs.bzl", "npm_package")
load("@aspect_rules_ts//npm:defs.bzl", "ts_project")
load("@npm//:defs.bzl", "npm_link_all_packages")
# 1. Bazel will pretend like the entire `node_modules` folder doesnât exist,
# unless we do work telling it about these files
npm_link_all_packages(name = "node_modules")
# 2. This tries to run `tsc` but itâs way worse, and it will forget half your
# settings
ts_project(
name = "ts",
# 2a. Youâll need to redeclare your `includes` from tsconfig
srcs = glob(
[
"src/**/*.cjs",
"src/**/*.cts",
"src/**/*.js",
"src/**/*.mjs",
"src/**/*.mts",
"src/**/*.ts",
"src/**/*.tsx",
"**/*.json",
]
),
transpiler = "tsc",
# 2b. Youâll also need to redeclare many TSConfig settings, even if theyâre
# already in tsconfig.json
declaration = True,
tsconfig = "tsconfig.json",
# 2c. Youâll need to redeclare all your package.json dependencies, too
deps = [
":node_modules/@types",
":node_modules/date-fns",
":node_modules/lodash-es",
":node_modules/react",
":node_modules/react-dom",
],
)
# 3. Youâll need to actually tell Bazel you want it to write SOMETHING to disk
copy_to_directory(
name = "dist",
srcs = [":ts"],
replace_prefixes = {
# But due to limitations in Bazel, it will write it IN THE WRONG DIRECTORY
# so we have to hack it by rewriting paths back to where they should have
# been in the first place
"src/": "/",
},
)
# 4. Oh also did we mention where Bazel writes files to disk isnât in your
# project? Youâll need even more code to tell Bazel to move the files it
# wrote back into the project
write_source_files(
name = "build_dist",
diff_test = False,
files = {"dist": "dist"],
visibility = ["//visibility:public"],
)
# 5. ALSO if youâre using a monorepo it will pretend like local packages donât
# exist, so weâll have to do even more work just to create a broken system
# for locally-linked packages
npm_package(
name = "npm_package",
srcs = [":ts"],
visibiliity = ["//visibility:public"],
)
You donât have to read or understand any of that, but some of the commentary will explain whatâs happening. I mainly wanted to give a realistic example of what every package in a monorepo requires at a minimum.
Putting complexity and learning curve aside, the damning design flaw is Bazel requires all this work and configuration so it can run commands differently. Different environment, different processes. Even using a forked version of Node.js where it can hijack node:fs if it wants to. The issue isnât that Starlark requires a little repetition from package.json
and tsconfig.json
. Itâs the issue of running different underlying processes entirely, that donât map 1:1 with package.json
and tsconfig.json
, masquerading as repetition. This, as you fear, results in different (or missing) output, different errors, and a different end result than just using Node.js. If you have ever worked with a bundler before, you know how scary it is dealing with the uncertainty of a complex thing ending up in a different shape than what you had predicted.
âItâs just a knowledge issue! This can be configured to work the same as Node.â Some might say. No, no it canât. If that were the case, then Bazel wouldnât need the Starlark files, rules, macros, and thousands of lines of code wrapping the original processes. All the information already exists to build the project outside Starlark, but thereâs a reason thatâs being discarded (and Bazel devs, if youâre reading this and want to prove me wrong, I love being wrong! Nothing would make me happier than if JS projects could be built with mere npm scripts and no other config).
If different outputs wasnât enough, it also results in the slow death of the local dev setup. Whenânot ifâBazel disagrees with Node, CI wins (because Bazel is what runs in CI). After all, you have to ship, and short-term we can sacrifice a little DX. âWeâll fix it later,â you say. Slowly but surely, more and more IDE extensions stop working as Bazel âwinsâ more and more disagreements with Node and the native toolchain, until the entire thing is unusable outside of Bazel. All because Bazel refuses to just run npm scripts directly.
Problem 2: the hermetic upside-down
Bazel has a concept of hermeticity where it tries to isolate the inputs and create as âpureâ a build as possible. In this isolated environment, nothing gets loaded you donât explicitly let in. This is useful in general, and not completely unlike working with containers. But where this hermetic layer throws a wrench into everything is in its inability to mirror your working projectâit will subtly transform things in the process. It doesnât respect other things happening in the monorepo and other local packages. And you have to so much work transforming things in-and-out of that hermetic layer, youâre 99.9% likely to make a simple error that blows everything up (or worseâsilently blows everything up).
As a simple example, I want to go back to just part of the Starlark config earlier:
# 3. Youâll need to actually tell Bazel you want it to write _something_ to disk
copy_to_directory(
name = "dist",
srcs = [":ts"],
replace_prefixes = {
# But due to limitations in Bazel, it will write it IN THE WRONG DIRECTORY
# so we have to hack it by rewriting paths back to where they should have
# been in the first place
"src/": "/",
},
)
# 4. Oh also did we mention where Bazel writes files to disk isnât in your
# project? Youâll need even more code to tell Bazel to move the files it
# wrote back into the project
write_source_files(
name = "build_dist",
diff_test = False,
files = {"dist": "dist"],
visibility = ["//visibility:public"],
)
After you even get Bazel producing something remotely similar to what you want, you have to use not one but TWO macros to copy the files back to the original source directory where they should have been in the first place. Notice that replace_prefixes
lineâit will actually build files to /dist/src
rather than /dist
(see point #1; I wonât restate it), so you have to fix all the mistakes it made in rebuilding those underlying Node processes from scratch. In a scary shadow dimension filled with monsters.
When we look at Bazel in the context of, say, a C++ app itâs more forgivable. Building a single binary only has one output thatâs easy to calculate. It doesnât need access to the whole file system. And Iâm not arguing at all that Node.js doesnât have any design flaws. But Node.js is what it is, and it is realistic to expect tens of thousands of files go into the input, and the output could be dozens or hundreds of files of varying extensions and types. The output is deterministic, but is so complex only the bundler is capable of calculating. And the output varies wildly by project type (Node.js app, frontend app, JS library, CLI, and Electron app will all produce different outputs from one another). Bazel is ill-equipped to deal with this scale of number of files, yet itâs designed to be a closed system, so it gets stuck in an unusable limbo between not being a bundler, but also not deferring file handling to the underlying Node.js processes and doing a piss-poor job of half-managing everything.
Problem 3: top-down builds
This is a short-but-sweet complaint: Bazel should be using bottom-up builds rather than top-down. What I mean is: for a Bazel monorepo, you have one WORKSPACE. One version of rules for the entire monorepo, one version of Bazel. Top-down. However, this fundamentally disagrees with how Node.js works: each package in a monorepo can have different dependencies at different versions. Some projects are newer, and have upgraded dependencies from older projects. Each handles its own needs, and itâs responsible for building itself without concern for the larger system. Bottom-up. In any large codebase, not all packages get equal maintenance, and often lag behind others in their dependencies (which isnât a bad thingâtheyâve stabilized). The Node.js ecosystem can handle this just fine.
But not so with Bazelâbecause you only have one rules_js
that acts on the entire project, all packages are bound to the limitations and flaws of the rule at that version. Even just getting it working requires an incredible effort. And once itâs working, often updating a single dependency in the monorepo can be enough to break it. You wind up in a tenuous state where any update to rules_js
may break everything. And so it is with any individual package update. Now teams that may be responsible for updating their packages canât, if updating their dependency breaks Bazel. All this could be avoided if Bazel would just defer responsibility to individual packages to manage building themselves.
Summary
At the end of the day, a build system just wonât work for all languages, and thatâs OK. Bazel was designed for a different usecase than JS. And itâs natural that Bazel tried to solve a problem for everyone, especially with the growing popularity and maturation of JS. And I applaud all the lovely contributors and maintainers that are making an effort at making Bazel better! But that doesnât mean Iâd touch Bazel with a 10 foot pole for handling JS right now.
But rather than end on a sour note and be a downer, I know a natural question is âwell if all that is true, then what would make Bazel compatible with JS?â And to give a more concrete answer than âmake it like Turbopackâ (OK, I liedâI did end up mentioning it again), only 3 changes would be required:
- Bazel only runs npm scripts. The entire Node.js world operates off npm scripts. Everything meaningful happens in npm scriptsâbuilding, linting, testing. All the problems of bottom-up building, working local dev setup, and reliability are all handled by Bazel deferring toâŚÂ the thing that already existed and is already working as expected.
- Bazel automatically writes files to source. Any build step in a Node.js package already has all the information it needs to put the files in its proper place. In fact, most of the time this is also statically set in the
package.json
under the main or exports fields, and sometimes even files. Node.js ALWAYS requires files to exist in the source tree, so Bazel needs to just start assuming that. - No TS rules. To ensure better interop with the Node ecosystem at large, there shouldnât be such a thing as
rules_ts
(separate fromrules_js
). If Bazel only ran Node.js/npm/pnpm commands, TS is just another package like any other. This would automatically improve support across the board for all Node.js projects because Bazel would be deferring builds to the things that should be building in the first place.
Will all these changes happen? Who can say. The Bazel project and its devs doesnât owe me, or anyone else for that matter, anything. And these changes would probably disrupt all the things itâs doing properly. But until these changes are made, Iâll personally be using anything but Bazel for JS. And you should too.