Live reloading with Ruby on Rails and esbuild
Tags: rails, esbuild, javascript, tutorial
Published: 2021-11-06 16:24:00

As you may have heard by now, Rails 7 comes out of the box with [importmap-rails](https://github.com/rails/importmap-rails) and the mighty [Webpacker](https://github.com/rails/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](https://github.com/rails/jsbundling-rails) was created. This gem adds the option to use [webpack](https://webpack.js.org/), [rollup](https://rollupjs.org/guide/en/), or [esbuild](https://esbuild.github.io/) 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](https://webpack.js.org/concepts/hot-module-replacement/)), losing it is tough.

Today, esbuild [doesn’t support HMR](https://github.com/evanw/esbuild/issues/645#issuecomment-755215007), 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!
<!--more-->
## 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](https://www.aloucaslabs.com/miniposts/using-a-specific-rails-version-when-you-generate-a-new-rails-app-with-rails-new-command) 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](https://github.com/ddollar/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:

```javascript
#!/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:

```javascript
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`:

```javascript
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.

```javascript
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](https://esbuild.github.io/api/#build-api) we need to make our (JavaScript only) live reload work.

The important options are the [watch option](https://esbuild.github.io/api/#watch), which takes the `watch` and `watchOptions` variables we defined earlier and `banner`.

esbuild’s [banner option](https://esbuild.github.io/api/#banner) allows us to prepend arbitrary code to the JavaScript file built by esbuild. In this case, we insert an [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) that fires `location.reload()` each time a [message is received](https://developer.mozilla.org/en-US/docs/Web/API/EventSource/onmessage) 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.

```javascript
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](https://nodejs.dev/learn/the-nodejs-http-module).

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

```json
"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:

```html
<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](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:

```javascript
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](https://github.com/paulmillr/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:

```javascript
#!/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.

```javascript
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:

```javascript
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](https://esbuild.github.io/api/#incremental) 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](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.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/a9y5zgkn3xubtwocy43e.gif)

## 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](https://github.com/DavidColby/esbuild-live-reload).

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:
* This [example esbuild config](https://github.com/rails/jsbundling-rails/issues/40#issuecomment-939514493), from an issue on the jsbundling-rails Github repo
* This [discussion](https://github.com/evanw/esbuild/issues/802) on the esbuild Github repo

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](https://dev.to/paramagicdev/frontend-bundler-braindump-10fj) 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](https://vite-ruby.netlify.app/).

Finally, if you're using esbuild with Rails and Stimulus, you'll probably find the [esbuild-rails plugin](https://github.com/excid3/esbuild-rails) from Chris Oliver useful.

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