Within the final week, I’ve been chargeable for migrating a fairly large (300k+ strains of JS code) venture from Webpack 4 to esbuild. In our Rails venture, we have been utilizing Webpacker to combine our JS stack with the principle utility. For the final months, we have been scuffling with very lengthy builds and in addition have been locked into Webpack 4 as Webpacker turned a deprecated library. When a number of Webpack 4 dependencies obtained their CVE, we determined it was time to modify to another bundler.
After brief investigation, we determined to make use of esbuild to organize our JS entries. And as time has handed and we now have lastly mentioned goodbye to IE 11, there was additionally a possibility to enhance the stack by switching to ECMAScript modules – nearly 95% of net customers can perceive them and there’s no level in not utilizing them at the moment. And few years after we checked out our JS stack for the final time, it turned out that we don’t want Babel and a few polyfills anymore as most options we’re utilizing are already inbuilt fashionable browsers.
On this put up, I’m describing and discussing the present configuration of our JavaScript setup.
esbuild configuration
Our present esbuild choices appear like this:
const glob = require("glob");
const esbuild = require("esbuild");
const {lessLoader} = require("esbuild-plugin-less");
const isProduction = ["production", "staging"].contains(
course of.env.WEBPACK_ENV
);
const config = {
entryPoints: glob.sync("app/javascript/packs/*.js"),
bundle: true,
assetNames: "[name]-[hash].digested",
chunkNames: "[name]-[hash].digested",
logLevel: "information",
outdir: "app/property/builds",
publicPath: `${course of.env.CDN_HOST ?? ""}/property`,
plugins: [lessLoader({javascriptEnabled: true})],
tsconfig: "tsconfig.json",
format: "esm",
splitting: true,
inject: ["./react-shim.js"],
mainFields: ["browser", "module", "main"],
loader: {
".js": "jsx",
".locale.json": "file",
".json": "json",
".png": "file",
".jpeg": "file",
".jpg": "file",
".svg": "file",
},
outline: "improvement"),
VERSION: JSON.stringify(course of.env.IMAGE_TAG ,
incremental: course of.argv.contains("--watch"),
sourcemap: true,
minify: isProduction,
metafile: true,
goal: ["safari12", "ios12", "chrome92", "firefox88"],
};
Let’s begin from entryPoints
listing – as we have been utilizing Webpacker, we had a fairly customary location for our entries (packs) – app/javascript/packs
. I didn’t need to listing all entries that we have been creating (and bear in mind so as to add any new entries manually) so I used the glob
package deal to generate the listing of all information matching the given sample. This manner I can add any new entries to app/javascript/packs
listing and they are going to be mechanically constructed on the subsequent run. We do need to inline all imported dependencies into created entries so we’re utilizing bundle: true
setting.
The subsequent two settings: assetNames
and chunkNames
are set to correctly deal with the asset pipeline managed by sprockets and forestall digesting chunks and property twice. Observe the -[hash].digested
half – that is required so as to not digest generated information as soon as once more in sprockets. With out these settings, all dynamic imports or imports of information dealt with by the file loader gained’t work after the property compilation.
As we’re constructing multiple entry, the outdir
property is required. It’s set to app/property/builds
in order that we are able to combine it with the Rails asset pipeline later.
In most of tutorials I discovered, the publicPath
was set to property
. However this setting gained’t work correctly when splitting
possibility is enabled – imported chunks could have a relative path in order that they gained’t work on most pages. This can even not work with any CDN. We’re prepending the publicPath
with the CDN host set by the env variable in order that we don’t must serve property by means of the Rails app server (CDN_HOST
is the same as config.asset_host
worth).
We’re utilizing one plugin for LESS help – we’re going to maneuver all CSS out of the JS stack, however not all stylesheets have been moved out but and a few of them are utilizing LESS. This plugin will probably be eliminated quickly.
With Webpacker, we have been utilizing a resolve alias. It allowed us to not use relative paths in imports, we might use import foo from "~/foo"
as an alternative. esbuild doesn’t help such aliases by default, nevertheless it has help for tsconfig.json
information. The tsconfig
possibility permits specifying the trail for the tsconfig.json
file. This works additionally if you’re not utilizing TypeScript in your utility. Our tsconfig.json
file seems like this:
{
"compilerOptions": {
"goal": "es6",
"baseUrl": ".",
"paths": {
"~/*": [
"./app/javascript/src/*"
]
}
},
"embrace": [
"app/javascript/src/**/*"
],
}
compilerOptions.path
object permits us to protect imports from Webpacker and resolve the ~/
prefix correctly.
The subsequent choices (format
& splitting
) allow ESM format and help for chunk splitting with dynamic imports. As we’re utilizing dynamic imports and have a number of lazy-loaded pages in our single-page utility, we would have liked this selection to optimize the scale of JS information wanted for the preliminary render. To correctly deal with React elements we would have liked a shim that’s injected into each file generated by esbuild (inject
possibility). Our react-shim.js
file could be very easy and appears like this:
import * as React from "react";
export {React};
We additionally wanted to vary the default mainFields
setting as one or two libraries we have been utilizing weren’t exporting code for the browser appropriately. That resulted in errors throughout the construct as node packages weren’t obtainable. You may seek advice from the mainFields
documentation to learn extra about this.
There may be one other story with loaders. In our venture, we’re utilizing i18next with customized backend to deal with i18n. We retailer translations in JSON information and cargo them by way of dynamic imports. To make it work with digested property we would have liked to make use of the file loader in order that we are able to get the digested URL in our i18n init module. However after utilizing simply {".json": "file"}
loader, it turned out that we began getting different errors from certainly one of our dependencies. After a quick investigation, it turned out that requiring certainly one of our dependencies have a aspect impact of importing a JSON file. Consequently we couldn’t simply use the file loader for all JSON information. We ended up with utilizing .locale.json
suffix for our translation information and utilizing the file loader just for .locale.json
extension whereas leaving the json
loader for .json
suffix. We don’t use .jsx
extension so we simply enabled JSX loader for all .js
information and left the file loader for photographs.
With Webpack, we have been utilizing the outline plugin to inject env variables into the code. With esbuild, we don’t want any plugins to deal with that as there’s already a outline
choice to deal with that.
The final choices are used principally for improvement mode or manufacturing optimization. The incremental
possibility permits us to hurry up rebuilding property within the improvement atmosphere. We’re not utilizing the built-in watch
setting because it didn’t work with our setup. We determined to make use of a customized file watcher that’s utilizing chokidar
package deal to observe our JS listing and rebuild entries after detecting any adjustments:
/* ... */
const fs = require("fs");
const config = { /* see above */ };
if (course of.argv.contains("--watch")) {
(async () => {
const outcome = await esbuild.construct(config);
chokidar.watch("./app/javascript/**/*.js").on("all", async (occasion, path) => {
if (occasion === "change") {
console.log(`[esbuild] Rebuilding ${path}`);
console.time("[esbuild] Carried out");
await outcome.rebuild();
console.timeEnd("[esbuild] Carried out");
}
});
})();
} else {
const outcome = await esbuild.construct(config);
fs.writeFileSync(
path.be a part of(__dirname, "metafile.json"),
JSON.stringify(outcome.metafile)
);
}
In the intervening time we’re producing the supply maps in all environments and minifying the code for manufacturing environments. The metafile is saved in metafile.json
when the file watcher is just not used.
The final possibility: goal
specifies the goal atmosphere for the generated JS code.
We retailer our esbuild config within the esbuild.config.js
file and use the next scripts in our package deal.json
file to construct/rebuild all JS information:
{
"scripts": {
"construct:js": "node esbuild.config.js",
"watch:js": "node esbuild.config.js --watch",
}
}
Rails integration
After inspecting jsbundling-rails
sources we now have determined to not use that library and simply add one rake process that’s doing the identical as that gem. Integrating esbuild with Rails could be very simple and the one factor that’s wanted is to be sure to add this line to your app/property/config/manifest.js
file:
You also needs to improve a number of Rake duties to ensure your esbuild property are generated earlier than the asset precompilation. We’re utilizing this code to make it work:
namespace :javascript do
desc "Construct your JavaScript bundle"
process :construct do
system "yarn set up" or increase
system "yarn run construct:js" or increase
finish
desc "Take away JavaScript builds"
process :clobber do
rm_rf Dir["app/assets/builds/**/[^.]*.{js,js.map}"], verbose: false
finish
finish
Rake::Process["assets:precompile"].improve(["javascript:build"])
Rake::Process["test:prepare"].improve(["javascript:build"])
Rake::Process["assets:clobber"].improve(["javascript:clobber"])
Now every time you run rails property:precompile
the Yarn package deal supervisor will set up all dependencies after which construct your JS information into app/property/construct
. If you wish to set the file watcher whereas growing your app, you’ll be able to simply run yarn run watch:js
in your terminal and begin your dev server.
Potential enhancements
This configuration works completely for us in the meanwhile and resulted in smaller and quicker builds. With Webpack we would have liked from 6 to fifteen minutes to construct our property, relying on the load. With esbuild, we are able to construct minified property in lower than a minute. And it’s even quicker in improvement – the preliminary construct with the file watcher enabled takes lower than 5 seconds on my 2019 iMac and rebuilds are even quicker. Nonetheless, there are nonetheless some issues we’re going to enhance sooner or later. When growing your JS information regionally, if you happen to change information which might be utilized in many different modules typically, the variety of information generated in app/property/builds
might develop very quick. That may make your Rails server very sluggish on the primary request. I made an esbuild cleanup plugin to resolve this problem.
One other problem is with the app/property/builds
itself. Should you begin the watch:js
script and there could be any error throughout the rebuild, esbuild course of will probably be killed however the property listing gained’t be cleaned so your property will nonetheless be served by Rails and also you would possibly marvel why you’ll be able to’t see adjustments in your modules after refreshing the web page. With the intention to repair this we’ll most likely want one other course of that can handle esbuild course of state and restart it or at the very least ship a notification on error. If you know the way to resolve this problem, be happy to share your data.