Live reloading with Ruby on Rails and esbuild

As you may have heard by now, Rails 7 comes out of the box with importmap-rails and the mighty Webpacker is no longer the default for new Rails applications.

For those who aren’t ready to switch to import maps and don’t want to use Webpacker now that it is no longer a Rails default, jsbundling-rails was created. This gem adds the option to use webpack, rollup, or esbuild to bundle JavaScript while using the asset pipeline to deliver the bundled files.

Of the three JavaScript bundling options, the Rails community seems to be most interested in using esbuild, which aims to bring about a “new era of build tool performance” and offers extremely fast build times and enough features for most users’ needs.

Using esbuild with Rails, via jsbundling-rails is very simple, especially in a new Rails 7 application; however, the default esbuild configuration is missing a few quality of life features. Most important among these missing features is live reloading. Out of the box, each time you change a file, you need to refresh the page to see your changes.

Once you’ve gotten used to live reloading (or its fancier cousin, Hot Module Replacement), losing it is tough.

Today, esbuild doesn’t support HMR, but with some effort it is possible to configure esbuild to support live reloading via automatic page refreshing, and that’s what we’re going to do today.

We’ll start from a fresh Rails 7 install and then modify esbuild to support live reloading when JavaScript, CSS, and HTML files change.

Before we get started, please note that this very much an experiment that hasn’t been battle-tested. I’m hoping that this is a nice jumping off point for discussion and improvements. YMMV.

With that disclaimer out of the way, let’s get started!

Application setup

We’ll start by creating a new Rails 7 application.

If you aren’t already using Rails 7 for new Rails applications locally, this article can help you get your local environment ready.

Once your rails new command is ready for Rails 7, from your terminal:

rails new live_esbuild -j esbuild
cd live_esbuild
rails db:create
rails g controller Home index

Here we created a new Rails application set to use jsbundling-rails with esbuild and then generated a controller we’ll use to verify that the esbuild configuration works.

Booting up

In addition to installing esbuild for us, jsbundling-rails creates a few files that simplify starting the server and building assets for development. It also changes how you’ll boot up your Rails app locally.

Rather than using rails s, you’ll use bin/dev. bin/dev uses foreman to run multiple start up scripts, via Procfile.dev. We’ll make a change to the Procfile.dev later, but for now just know that when you’re ready to boot up your app, use bin/dev to make sure your assets are built properly.

Configure esbuild for live reloading

To enable live reloading, we’ll start by creating an esbuild config file. From your terminal:

touch esbuild-dev.config.js

To make things a bit more consumable, we’ll first enable live reloading for JavaScript files only, leaving CSS and HTML changes to wait for manual page refreshes.

We’ll add reloading for views and CSS next, but we’ll start simpler.

To enable live reloading on JavaScript changes, update esbuild-dev.config.js like this:

#!/usr/bin/env node

const path = require('path')
const http = require('http')

const watch = process.argv.includes('--watch')
const clients = []

const watchOptions = {
  onRebuild: (error, result) => {
    if (error) {
      console.error('Build failed:', error)
    } else {
      console.log('Build succeeded')
      clients.forEach((res) => res.write('data: update\n\n'))
      clients.length = 0
    }
  }
}

require("esbuild").build({
  entryPoints: ["application.js"],
  bundle: true,
  outdir: path.join(process.cwd(), "app/assets/builds"),
  absWorkingDir: path.join(process.cwd(), "app/javascript"),
  watch: watch && watchOptions,
  banner: {
    js: ' (() => new EventSource("http://localhost:8082").onmessage = () => location.reload())();',
  },
}).catch(() => process.exit(1));

http.createServer((req, res) => {
  return clients.push(
    res.writeHead(200, {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      "Access-Control-Allow-Origin": "*",
      Connection: "keep-alive",
    }),
  );
}).listen(8082);

There’s a lot going on here, let’s walk through it a section at a time:

const path = require('path')
const http = require('http')

const watch = process.argv.includes('--watch')
let clients = []

First we require packages and define a few variables, easy so far, right?

Next, watchOptions:

const watchOptions = {
  onRebuild: (error, result) => {
    if (error) {
      console.error('Build failed:', error)
    } else {
      console.log('Build succeeded')
      clients.forEach((res) => res.write('data: update\n\n'))
      clients.length = 0
    }
  }
}

watchOptions will be passed to esbuild to define what happens each time an esbuild rebuild is triggered.

When there’s an error, we output the error, otherwise, we output a success message and then use res.write to send data out to each client.

Finally, clients.length = 0 empties the clients array to prepare it for the next rebuild.

require("esbuild").build({
  entryPoints: ["application.js"],
  bundle: true,
  outdir: path.join(process.cwd(), "app/assets/builds"),
  absWorkingDir: path.join(process.cwd(), "app/javascript"),
  watch: watch && watchOptions,
  banner: {
    js: ' (() => new EventSource("http://localhost:8082").onmessage = () => location.reload())();',
  },
}).catch(() => process.exit(1));

This section defines the esbuild build command, passing in the options we need to make our (JavaScript only) live reload work.

The important options are the watch option, which takes the watch and watchOptions variables we defined earlier and banner.

esbuild’s banner option allows us to prepend arbitrary code to the JavaScript file built by esbuild. In this case, we insert an EventSource that fires location.reload() each time a message is received from localhost:8082.

Inserting the EventSource banner and sending a new request from 8082 each time rebuild runs is what enables live reloading for JavaScript files to work. Without the EventSource and the local request sent on each rebuild, we would need to refresh the page manually to see changes in our JavaScript files.

http.createServer((req, res) => {
  return clients.push(
    res.writeHead(200, {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      "Access-Control-Allow-Origin": "*",
      Connection: "keep-alive",
    }),
  );
}).listen(8082);

This section at the end of the file simply starts up a local web server using node’s http module.

With the esbuild file updated, we need to update package.json to use the new config file:

"scripts": {
  "build": "esbuild app/javascript/*.* --bundle --outdir=app/assets/builds",
  "start": "node esbuild-dev.config.js"
}

Here we updated the scripts section of package.json to add a new start script that uses our new config file. We’ve left build as-is since build will be used on production deployments where our live reloading isn’t needed.

Next, update Procfile.dev to use the start script:

web: bin/rails server -p 3000
js: yarn start --watch

Finally, let’s make sure our JavaScript reloading works. Update app/views/home/index.html.erb to connect the default hello Stimulus controller:

<h1 data-controller="hello">Home#index</h1>
<p>Find me in app/views/home/index.html.erb</p>

Now boot up the app with bin/dev and head to http://localhost:3000/home/index.

Then open up app/javascript/hello_controller.js and make a change to the connect method, maybe something like this:

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  connect() {
    this.element.textContent = "Hello Peter. What's happening?"
  }
}

If all has gone well, you should see the new Hello Peter header on the page, replacing the Hello World header.

If all you want is JavaScript live reloading, feel free to stop here. If you want live reloading for your HTML and CSS files, that’s where we’re heading next.

HTML and CSS live reloading

esbuild helpfully watches our JavaScript files and rebuilds every time they change. It doesn’t know anything about non-JS files, and so we’ll need to branch out a bit to get full live reloading in place.

Our basic approach will be to scrap esbuild’s watch mechanism and replace it with our own file system monitoring that triggers rebuilds and pushes updates over the local server when needed.

To start, we’re going to use chokidar to watch our file system for changes, so that we can reload when we update a view or a CSS file, not just JavaScript files.

Install chokidar from your terminal with:

yarn add chokidar -D

With chokidar installed, we’ll update esbuild-dev.config.js again, like this:

#!/usr/bin/env node

const path = require('path')
const chokidar = require('chokidar')
const http = require('http')

const clients = []

http.createServer((req, res) => {
  return clients.push(
    res.writeHead(200, {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      "Access-Control-Allow-Origin": "*",
      Connection: "keep-alive",
    }),
  );
}).listen(8082);

async function builder() {
  let result = await require("esbuild").build({
    entryPoints: ["application.js"],
    bundle: true,
    outdir: path.join(process.cwd(), "app/assets/builds"),
    absWorkingDir: path.join(process.cwd(), "app/javascript"),
    incremental: true,
    banner: {
      js: ' (() => new EventSource("http://localhost:8082").onmessage = () => location.reload())();',
    },
  })
  chokidar.watch(["./app/javascript/**/*.js", "./app/views/**/*.html.erb", "./app/assets/stylesheets/*.css"]).on('all', (event, path) => {
    if (path.includes("javascript")) {
      result.rebuild()
    }
    clients.forEach((res) => res.write('data: update\n\n'))
    clients.length = 0
  });
}
builder()

Again, lots going on here. Let’s step through the important bits.

const chokidar = require('chokidar')

First, we require chokidar, which we need to setup file system watching. Starting easy again.

Next, we setup the build task:

async function builder() {
  let result = await require("esbuild").build({
    // snip unchanged options
    incremental: true,
  })
  chokidar.watch(["./app/javascript/**/*.js", "./app/views/**/*.html.erb", "./app/assets/stylesheets/*.css"]).on('all', (event, path) => {
    if (path.includes("javascript")) {
      result.rebuild()
    }
    clients.forEach((res) => res.write('data: update\n\n'))
    clients.length = 0
  });
}

Here we’ve moved the build setup into an async function that assigns result to build.

We also added the incremental flag to the builder, which makes repeated builds (which we’ll be doing) more efficient.

The watch option was removed since we no longer want esbuild to watch for changes on rebuild on its own.

Next, we setup chokidar to watch files in the javascript, views, and stylesheets directories. When a change is detected, we check the path to see if the file was a javascript file. If it was, we manually trigger a rebuild of our JavaScript.

Finally, we send a request out from our local server, notifying the browser that it should reload the current page.

With these changes in place, stop the server if it is running and then bin/dev again. Open up or refresh http://localhost:3000/home/index, make changes to index.html.erb and application.css and see that those changes trigger page reloads and that updating hello_controller.js still triggers a reload.

A screen recording of a user with a browser window and a code editor open, side-by-side. As the user makes changes in their code editor, the browser automatically refreshes and reflects the changes made in the editor.

Wrapping up

Today we created an esbuild config file that enables live reloading (but not HMR) for our jsbundling-rails powered Rails application. As I mentioned at the beginning of this article, this is very much an experiment and this configuration has not been tested on an application of any meaningful size. You can find the finished code for this example application on Github.

I’m certain that there are better routes out there to the same end result, and I’d love to hear from others on pitfalls to watch out for and ways to improve my approach.

While researching this problem, I leaned heavily on previous examples of esbuild configs. In particular, the examples found at these two links were very helpful in getting live reload to a functional state:

If you, like me, are a Rails developer that needs to learn more about bundling and bundlers, a great starting point is this deep dive into the world of bundlers. If you’re intested in full HMR without any speed loss, and you’re willing to break out of the standard Rails offerings, you might enjoy vite-ruby.

Finally, if you’re using esbuild with Rails and Stimulus, you’ll probably find the esbuild-rails plugin from Chris Oliver useful.

That’s all for today. As always - thanks for reading!