Livereload + esbuild

Getting CSS (and JS) live reloading to work in a esbuild / Rails project is a bit of a hassle, but the following seems to work decently well.

We assume that you already use a standard "esbuild in Rails" setup, and have an esbuild watcher running that picks up your source code in app/assets and compiles to public/assets; if not change the paths below accordingly.

Basic idea

We will

  • use the guard-livereload gem as the livereload server (which send updates to the browser),
  • use the livereload-js npm package in the browser to connect to the livereload server,
  • not use rack-livereload any more as this is unnecessarily complex and does not work with many CSP policies.

We will configure guard-livereload to watch the generated files in public/assets. Inconveniently, esbuild always rewrites all files in public/assets on any change, triggering full page loads on any change, even CSS changes. To fix this, we employ a little hack, see the Guardfile below.

Livereload server

Add the guard-livereload gem to the :development section of your Gemfile. Create a Guardfile like this

# Speed up Guard by allowlisting specific directories
directories %w[app config public spec]

allowed_js_reload_window = 10 # seconds
last_js_change = Time.at(0)

guard 'livereload', apply_css_live: true, host: '127.0.0.1' do
  watch(%r(^public/assets/esbuild_error_development\.txt$))

  # Livereload + ESbuild is not the best combo
  # We want
  # - to reload CSS files without doing a full page reload
  # - do a full page reload for JS changes
  #
  # Unfortunately, every time anything changes in app/assets, ESbuild will rewrite all files
  # in public/assets. On the other hand, we have to watch these files, otherwise we do not
  # know when ESbuild is done.
  #
  # As a compromise we do the following:
  # - when a JS file in **app/asset** changes, we remember the timestamp.
  # - when a CSS file in **public/asset** changes, we instruct live-reload to reload it.
  # - when a JS file in **public/asset** changes, we instruct live-reload to reload the page *only if*
  #   a JS change in **app/assets** occured within th `allowed_js_reload_window`.

  watch(%r(^public(/assets/.*\.css)$)) do |match|
    # Any generated CSS changed, send its name to livereload-js
    match[1]
  end

  watch(%r(^app/assets/.*\.js$)) do |_match|
    # Any source Javascript changed. Assume the next changes in public/assets refer to JS.
    last_js_change = Time.now
    nil
  end

  watch(%r(^public/assets/.*\.js$)) do
    # Any generated JS changed. This might be because of a JS or CSS change!
    if Time.now < last_js_change + allowed_js_reload_window
      'all-js' # anything that is not a .css file will trigger a full page load
    end
  end

  # All of these will trigger full page loads.
  watch(%r(^app/views/.+\.(html|erb|haml)$))
  watch(%r(^app/(?:controllers|helpers|inputs)/.+\.rb))
  watch(%r(^config/locales/.+\.yml))
end

Running guard or guard -P liveload will start your livereload server. It will log when it triggers a reload, and when a browser connects.

Livereload client

Add the livereload-js npm package. Then, in your esbuild config, add an entrypoint for dev.js and add the following files:

// app/assets/dev.js
import './dev/livereload'
// app/assets/dev/livereload/index.js
import './settings'
import 'livereload-js/dist/livereload'
// app/assets/dev/livereload/settings.js
window.LiveReloadOptions = {
  host: 'localhost',
  port: 35729,
}

Finally, require this new entrypoint in your layout with

- if Rails.env.development?
  = javascript_include_tag 'dev', nonce: true

(skip the nonce, if you do not have a CSP configured)

Debugging the integration

You can add #LR-verbose to your URL to get debugging output from the livereload-js script.

Tobias Kraze 4 months ago