Live reloading with Ruby on Rails and esbuild

Posted on

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
Enter fullscreen mode

Exit fullscreen mode

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
Enter fullscreen mode

Exit fullscreen mode

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: updatenn'))
      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);
Enter fullscreen mode

Exit fullscreen mode

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 = []
Enter fullscreen mode

Exit fullscreen mode

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: updatenn'))
      clients.length = 0
    }
  }
}
Enter fullscreen mode

Exit fullscreen mode

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));
Enter fullscreen mode

Exit fullscreen mode

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);
Enter fullscreen mode

Exit fullscreen mode

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"
}
Enter fullscreen mode

Exit fullscreen mode

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
Enter fullscreen mode

Exit fullscreen mode

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>
Enter fullscreen mode

Exit fullscreen mode

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?"
  }
}
Enter fullscreen mode

Exit fullscreen mode

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:

Leave a Reply

Your email address will not be published.