Migrating from Webpacker to esbuild
Why?
With the retirement of Webpacker we needed to find an alternative.
So what is Webpacker and why do we need it? Webpacker is a gem that makes it easy for Rails developers to bundle JavaScript assets. Basically it is a thin layer over Webpack (Or that was the original intention). Which is how the README describes it.
Webpacker makes it easy to use the JavaScript pre-processor and bundler webpack 4.x.x+ to manage application-like JavaScript in Rails. It coexists with the asset pipeline, as the primary purpose for webpack is app-like JavaScript, not images, CSS, or even JavaScript Sprinkles (that all continues to live in app/assets).
However, it is possible to use Webpacker for CSS, images and fonts assets as well, in which case you may not even need the asset pipeline. This is mostly relevant when exclusively using component-based JavaScript frameworks.
Given that Webpacker has been retired and the fact that there are few developers who know how to work with it, it felt like an opportune time to revisit how to package our JavaScript assets.
When considering a replacement for Webpacker we had the following non-functional requirements:
- Reduce the complexity of our current JavaScript Packaging
- Packaging Javascript Assets should be easy for Developers to configure and understand
- Potentially allow the JavaScript packaging to be extracted from the rails app
- Address the slowness in our current build process
- Should have a similar dev experience to Webpacker (e.g. recompilation and reloading in the browser on file changes)
- The changeover should be relatively seamless - with as little code changes as possible
- Ease our eventual transition of our JavaScript assets to TypeScript
What do we replace Webpacker with?
So with the non-functional requirements in mind, we considered the following options
1. Continue using Webpacker
Pros
- Nothing needs to be done we can
keep calm and carry on
Cons
- We are delaying the fact we need to migrate to another solution. Eventually with Rails 7 we will need to revisit this again
- The assets packaging will still be slow
- Does nothing to address the complexity - nor the siloed knowledge on JavaScript packaging
2. Migrate to Shakapacker (a fork of Webpacker)
Pros
- Relatively easy transition process
- Developer experience is similar to the way everything works now
Cons
- Developers will need to understand Webpack
- Does little to address the speed issues - from the README
Check out 6.1.1+ for SWC and esbuild-loader support! They are faster than Babel!
- Doesn't allow us to extract the JavaScript packaging from the Rails application
- Does nothing to address the complexity - nor the siloed knowledge on JavaScript packaging
3. Move to jsbundling-rails and cssbundling-rails
As per the README in the jsbundling-rails repo:
Use esbuild, rollup.js, or Webpack to bundle your JavaScript, then deliver it via the asset pipeline in Rails. This gem provides installers to get you going with the bundler of your choice in a new Rails application, and a convention to use app/assets/builds to hold your bundled output as artifacts that are not checked into source control (the installer adds this directory to .gitignore by default).
It does not dictate what tool to use for the bundling.
Pros
- Allows us to use TypeScript easily
- Building assets will be faster
- Configuration is more approachable to non frontend developers - (Don't need to understand Webpack)
Cons
- Packaging scripts would need to written
Sprocketsgem needs to be added and configuredWebpackerhelpers need to migrated to standardsprocketasset helpersWebpackaliases in our css using~are not supported in standardsassesbuildis still relatively earlier in its development not version 1 yet
The Decision
After considering the options listed above, options 1 and 2 do little in reducing the complexity that webpacker introduced. We felt that option 3 would be a more future proof and maintainable solution.
We chose Sass to bundle the CSS, and for our JavaScript bundling tool, we chose to use esbuild for the following reasons:
- It's fast
- Relatively small build API
- Wide community usage
How did we migrate?
We have answered the Why? and What?, now on to the trickiest question How?
Whilst jsbundling-rails does provide instructions on how to switch from Webpacker, the document only covers the migration to webpack based asset compilation. This document did provide us with some insight on how to proceed with the migration.
Our migration process
Note: some of this process was taken from the original migration instructions linked above.
-
Setup
jsbundling-rails- Install
jsbundling-rails - Run
./bin/rails javascript:install:esbuild
This is where we hit our first issue - the install script expects a sprockets manifest to exist.
if (sprockets_manifest_path = Rails.root.join("app/assets/config/manifest.js")).exist? append_to_file sprockets_manifest_path, %(//= link_tree ../builds\n) endWell this is going to be fun - our rails app doesn't even have sprockets.
- Install
-
Setup
sprockets- Install
sprockets-rails - Add configuration for
productiontestanddevelopment - Create the manifest file for sprockets
app/assets/config/manifest.js- include the
jsbundling-railsbuild output by adding//= link_tree ../builds
- include the
- Install
-
Setup
cssbundling-rails- Install
cssbundling-rails - Run
./bin/rails css:install:sass
running
yarn run build:cssThis is where we hit our second problem: our
Sassfiles were using~prefixed imports e.g.@forward ~@bugcrowd/bc-flair/. Insass-loader < 11.0.0, this was the way to tell the plugin to resolve sass files in thenode_modulesfolder. This is still supported but is deprecated. The original usecase for the~prefix was to simplify sass imports without having to write long relative paths.So how did resolve this? We wrote a script using the Sass JavaScript API that would process all the Sass files and call out to a custom importer to process the
~imports.For the importer, we reverse engineered an esbuild Sass plugin that supported
~prefixed imports./** * The Sass syntax for a given file * * https://sass-lang.com/documentation/syntax */ function fileSyntax(filename) { if (filename.endsWith('.scss')) { return 'scss' } else if (filename.endsWith('.css')) { return 'css' } else { return 'indented' } } /** * Custom SASS importer to process @import statements with "~" prefix * */ const importer = { load(canonicalUrl) { const pathname = fileURLToPath(canonicalUrl) const contents = fs.readFileSync(pathname, 'utf8') return { contents: contents.replace(/(url\(['"]?)(~)/g, `$1`), syntax: fileSyntax(pathname), } }, canonicalize(url) { const baseDir = dirname('.') if (resolved.hasOwnProperty(url)) { return pathToFileURL(resolved[url]) } let filename = null if (url.startsWith('~@')) { // @forward '~@bugcrowd/bc-flair/dist/main'; filename = decodeURI(url.slice(1)) try { requireOptions.paths[0] = baseDir filename = require.resolve(filename, requireOptions) } catch (ignored) { filename = resolveRelativeImport('./node_modules', filename) } } else if (url.startsWith('~')) { // @use '~css/modules/organization-roster' const { 0: alias } = url.split(sep) filename = resolveRelativeImport(aliases[alias], url.replace(alias, '.')) } else if (url.startsWith('file://')) { filename = resolveImport(fileURLToPath(url)) } return filename == null ? filename : pathToFileURL(filename) }, }And usage of importer
const result = sass.compile(source, { loadPaths, importers: [importer], style: minify ? 'compressed' : 'expanded', }) - Install
-
Replace Webpacker pack tags
# Webpacker tag # Sprockets tag javascript_pack_tag = javascript_include_tag stylesheet_pack_tag = stylesheet_link_tag -
Deploy and test
Issues
The migration to jsbundling-rails and cssbundling-rails was relatively straightforward. However, we did run across some minor issues during the migration:
-
Assets weren't compiled when running tests. Reading through the documentation for
jsbundling-railsprovides an explanation of why they aren't being be generated:If your testing library of choice does not define a test:prepare Rake task, ensure that your test suite runs javascript:build to bundle JavaScript before testing commences.
RSpec-rails does not provide a
test:prepareRake task. After little bit more googling we found a relevant post on how to ensure assets are precompiled with RSpec Stack Overflow link. Once we added the script, it just needed to be called before all RSpec testsRSpec.configure do |config| config.before(:suite) do MaintainTestAssets.maintain! end end -
Some images were not being displayed in React components. This was due to the way
esbuildprocesses external images. Roughly the process is:- Create a digest for the image
- Copy the file with the digest appended e.g.
app/assets/builds/slack-CJBP27SE.svg - Reference the image by the new name - so the compiled JavaScript would have reference like this
x(()=>{o0n='/assets/slack-CJBP27SE.digested.svg'})After esbuild has completed compilation - sprockets will process the
app/assets/buildsfolder. During processing of this folder,Sprocketswill fingerprint each file and copy it to the assets folder with the fingerprint appended. So we would end up with a filename ofslack-CJBP27SE-ed7f62b217d969163106eb3bdd9ce7fbcab95813affe1274145398edf6850c23.svg. Once again the internet had a solution, we just had to getesbuildto append the file withdigestedto preventSprocketsfrom adding its own digest. Which was as easy as adding--asset-names=[name]-[hash].digested. -
No
HMR(Hot module Replace) -Webpackerprovided us with a nice toolwebpack-dev-serverto auto reload the page as you update the source. Sadly,esbuildis not planning to add support for HMR see. This article provided an alterative.
Conclusion
To give you an some idea on the complexity and scale of the migration from webpacker, our codebase has:
- 1727 JavaScript files
- 107 scss files
- 106 usages of
asset_pack_path - 11 usages of
javascript_pack_tag - 20 usages of
stylesheet_pack_tag
Was the migration worth the effort?
Here are some benefits we can see:
- The build process is easier for us to maintain
- Our eventual transition to
TypeScriptwill be an easier endeavour - No longer need
babelplugins for compilation - No longer ship
es5code - Potentially, we could bundle the
JavaScriptandCSSoutside of the Rails application
I think the best way to sum it up is our builds are now Blazingly™ fast







