Skip to content

Coding in the Fast Lane with ibazel

The alternating sound of ctrl+s and ctrl+r followed by a deep sigh fill my days working on EngFlow's Build and Test UI. I mean, centering divs is already frustrating, but having to glance back and forth from one screen to another while refreshing the browser adds insult to injury. It doesn't help that being your average frontend dev I usually work with no less than a few thousand monitors. How else would I be able to look at the application, the code, and the ever present Flexbox layout cheatsheet at the same time?

Example of a minimal frontend dev's workstation

Example of a minimal frontend dev's workstation

In the past I have had the pleasure of trying livereload and hot-reload with webpack, rollup and the like. Though neither offered the speed, power and flexibility of Bazel I still felt more productive thanks to livereloading alone. Luckily I was introduced to ibazel, and decided to embark on a journey to livereload-heaven.

Creating a simple full-stack application

For this tutorial we will be using express-js, a minimal web-framework for JavaScript. If you use a different language that's fine, as the ideas are the same across different programming stacks.

Let's start by creating an empty folder for the project. As with any other Node.js project, we will start by defining a minimal package.json with ibazel installed as a dev-dependency, some dependencies to write the server, and a simple npm-script to wrap ibazel:

package.json
{
  "name": "ibazel_livereload",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "start": "ibazel run server"
  },
  "author": "TheGrizzlyDev (https://github.com/TheGrizzlyDev)",
  "license": "ISC",
  "devDependencies": {
    "@bazel/ibazel": "^0.23.7"
  },
  "dependencies": {
    "express": "^4.18.2"
  }
}

The app itself is relatively straightforward:

  • hello_route.js with an API that tells us who to say hello to
  • index.html that queries the API and says hello
hello_route.js
1
2
3
module.exports = function (req, res) {
    res.send('World')
}
public/index.html
<html lang="en">
<head>
    <title>Hello ibazel!</title>
    <style>
        .center {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translateY(-50%) translateX(-50%);
        }
        #hello {
            font-weight: bold;
            font-size: 4em;
            color: green;
        }
    </style>
</head>
<body>
    <div class="center">
        <div id="hello"></div>
    </div>
    <script>
        function updateHello() {
            fetch('/who')
                .then(res => res.text())
                .then(who => document.getElementById('hello').innerText = `Hello ${who}!`)
        }
        updateHello()
        setInterval(updateHello, 100)
    </script>
</body>
</html>

In order to actually make this application work we are going to need an entrypoint that wires up the who-api and serves static content from the public folder. The entrypoint will be split into 2 separate files:

  • index.js responsible for creating the http server, starting it and eventually handling livereloading
  • server.js sets up the express application

By separating the server and the application, we can "reload" the application without having to restart the whole process, and thus achieve fast livereload.

index.js
1
2
3
4
5
6
7
8
9
const server = require('./server')
const port = process.env.PORT || 3000

const httpServer = require('http').createServer().listen(port, () => {
    console.log(`Server listening on port ${port}`)
})

const httpHandler = server.createHandler()
httpServer.on('request', httpHandler)
server.js
1
2
3
4
5
6
7
8
9
const express = require('express')
const helloRoute = require('./hello_route.js')

module.exports.createHandler = function() {
    const app = express()
    app.use(express.static('public'))
    app.get('/who', helloRoute)
    return app
}

At this point you can already run this locally (without using Bazel):

terminal
npm install
node index.js

A preview of our app

Bazel-ifying the application

To build this project using Bazel we will rely on Aspect's rules_js. We will also be using bzlmod, Bazel's new dependency manager. Setting it up requires mostly just a bunch of copypasta.

.bazelignore
node_modules
.bazelrc
common --enable_bzlmod
MODULE.bazel
bazel_dep(name = "aspect_rules_js", version = "1.32.1")

bazel_dep(name = "rules_nodejs", version = "5.8.2")
node = use_extension("@rules_nodejs//nodejs:extensions.bzl", "node")
node.toolchain(node_version = "16.14.2")

npm = use_extension("@aspect_rules_js//npm:extensions.bzl", "npm", dev_dependency = True)

npm.npm_translate_lock(
    name = "npm",
    pnpm_lock = "//:pnpm-lock.yaml",
    verify_node_modules_ignored = "//:.bazelignore",
)

use_repo(npm, "npm")

We also need to create an empty WORKSPACE so ibazel recognizes this project as a valid Bazel project.

Once we have set up the ruleset, we need to configure a js_binary target that takes all the JS files and the static content in the public folder, as well as set index.js as the binary's entrypoint:

BUILD.bazel
load("@npm//:defs.bzl", "npm_link_all_packages")
load("@aspect_rules_js//js:defs.bzl", "js_binary")

npm_link_all_packages()
js_binary(
    name = "server",
    data = [
        "//:node_modules/express",
    ] + glob(["*.js", "public/*"]),
    entry_point = "index.js",
)

Rules_js uses pnpm's lockfile as the source of JS dependencies that need to be fetched and installed by Bazel. We can use pnpm to generate and update the lockfile:

update package.json
diff --git a/test/package.json b/test/package.json
index 9c0318a..6391b77 100644
--- a/test/package.json
+++ b/test/package.json
@@ -4,12 +4,14 @@
     "description": "",
     "main": "index.js",
     "scripts": {
-        "start": "ibazel run server"
+        "start": "ibazel run server",
+        "update-lockfile": "pnpm install --lockfile-only"
     },
     "author": "TheGrizzlyDev (https://github.com/TheGrizzlyDev)",
     "license": "ISC",
     "devDependencies": {
-        "@bazel/ibazel": "^0.23.7"
+        "@bazel/ibazel": "^0.23.7",
+        "pnpm": "^8.7.1"
     },
     "dependencies": {
         "express": "^4.18.2",

After a quick pnpm install creating and updating dependencies becomes as simple as running a npm script pnpm run update-lockfile.

Similarly, running this app with ibazel is just a npm script npm run start, but sadly even if this already reruns the build on code changes, it will not automatically reload the page, and instead it will restart the nodejs server and require you to manually refresh the page on your browser.

Setting up livereload with ibazel

Ibazel queries Bazel to know what files to observe for the target(s) being built, watches them for changes and when changes occur it re-triggers the build. This sounds relatively straightforward, but it isn't enough to handle livereload yet!

To implement livereload we'll need to implement the following:

  1. Keep the bazel run process alive during builds
  2. Notify the process when a build is completed
  3. Serve and inject livereload.js in index.html

The first 2 points are achieved through the tags ibazel_live_reload, which keeps the bazel-run-process running, and ibazel_notify_changes, which sends messages to a process' stdin.

BUILD.bazel
diff --git a/BUILD.bazel b/BUILD.bazel
index 6974914..f487e8b 100644
--- a/BUILD.bazel
+++ b/BUILD.bazel
@@ -7,6 +7,11 @@ js_binary(
     name = "server",
     data = [
         "//:node_modules/express",
+        "//:node_modules/string-replace-middleware",
     ] + glob(["*.js", "public/*"]),
     entry_point = "index.js",
+    tags = [
+        "ibazel_live_reload",
+        "ibazel_notify_changes",
+    ],
 )

In particular, we want to reload the application when stdin receives the string IBAZEL_BUILD_COMPLETED SUCCESS, which indicates a successful build. Other events are shown in the source code.

Finally, when setting the tag ibazel_live_reload, ibazel will automatically start a livereload server for you and indicate its location in the environment variable IBAZEL_LIVERELOAD_URL. We can now inject this url in a script tag by using string-replace-middleware, a middleware for Express.js that replaces a string in a response with another string. In this case it allows us to do something like this replace('<head>', `<head><script src="${ibazelLivereloadUrl}"></script>`).

update package.json
diff --git a/package.json b/package.json
index 76069b1..3c84828 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,7 @@
     "@bazel/ibazel": "^0.23.7"
   },
   "dependencies": {
-    "express": "^4.18.2"
+    "express": "^4.18.2",
+    "string-replace-middleware": "^1.0.2"
   }
 }
update BUILD.bazel
diff --git a/BUILD.bazel b/BUILD.bazel
index 402a1eb..f487e8b 100644
--- a/BUILD.bazel
+++ b/BUILD.bazel
@@ -7,6 +7,7 @@ js_binary(
     name = "server",
     data = [
         "//:node_modules/express",
+        "//:node_modules/string-replace-middleware",
     ] + glob(["*.js", "public/*"]),
     entry_point = "index.js",
     tags = [

After updating both BUILD.bazel and package.json via npm run update-lockfile we can now use the string-replace middleware. One last thing before we wrap this up! Though not related to ibazel, we need to make Node.js' require mechanism aware of the changes in a build.

For that we need to take two things into account:

  • require has to be rerun on each rebuild for the code we want to livereload on the server. This can be implemented by moving const helloRoute = require('./hello_route.js') from outside the function createHandler in server.js to inside of it, so each and every time the function is called, the module will be reloaded.
  • require.cache caches modules and thus prevents subsequent invocations of require from reloading a module from file-system, where Bazel builds it. This attribute is a simple dictionary and we can just remove each element upon reload.

The final code for server.js and index.js looks like this:

index.js
const ibazelLivereloadUrl = process.env.IBAZEL_LIVERELOAD_URL
const httpServer = require('http').createServer().listen(3000)
var httpHandler;

async function reloadServer() {
    // clean-up require's cache on live reload so that the server is forced to reload
    // the newly built code from the file-system.
    Object.keys(require.cache).forEach(function(key) { delete require.cache[key] })

    // if an handler has already been registered before, then remove the previous one
    // and replce it with the newly imported one.
    if (httpHandler) {
        httpServer.removeListener('request', httpHandler)
    }

    server = require('./server.js')
    httpHandler = server.createHandler(ibazelLivereloadUrl)
    httpServer.on('request', httpHandler)
}

reloadServer()

if (ibazelLivereloadUrl) {
    const readline = require('readline')
    const rl = readline.createInterface({
        input: process.stdin,
        output: process.stdout,
        terminal: false
    });
    rl.on('line', async (line) => {
        if (line === "IBAZEL_BUILD_COMPLETED SUCCESS") {
            await reloadServer()
        }
    });
}
server.js
const express = require('express')
const { stringReplace } = require('string-replace-middleware')

module.exports.createHandler = function(ibazelLivereloadUrl) {
    const helloRoute = require('./hello_route.js')
    const app = express()
    if (ibazelLivereloadUrl) {
        app.use(stringReplace({
            '<head>': `<head><script src="${ibazelLivereloadUrl}"></script>`
        }))
    }
    app.use(express.static('public'))
    app.get('/who', helloRoute)
    return app
}

You can finally run npm run start and enjoy watching your page reload whenever you change the code!

Changing code causes the app to livereload

And that is how you get Express.js, Bazel, and livereload to play nice together using ibazel. Even if you're using a different JavaScript framework or language, the Bazel parts remain mostly unchanged.

ibazel does not end with livereload though and can be used to automate lots of Bazel related tasks. You can read more about it on ibazel's Github project.