Tune up your esbuild config with plugins and cleanup your assets directory
… and check why 5600+ Rails engineers read also this
In my previous post, I wrote about migrating a large JS project from Webpack to esbuild. I mentioned that the process is not fully optimal as old files from previous builds are kept in the app/assets/builds
directory. This is not a problem when you’re not using the splitting
option with hashed chunk names. But if you want to split your bundle into more smaller chunks and lazy-load them, you will end up with multiple small JS files with digested names that may change often in your app/assets/builds
directory. That might result in slow initial requests after starting the Rails server. I spent some time learning about esbuild plugins and it turned out that a solution for this issue is very simple.
Esbuild plugin system allows running on-end callbacks when a build ends. This is a perfect place to hook our cleanup task that will remove all files from previous builds that are no longer needed. The initial version of the plugin may look like this:
const fs = require("fs");
const path = require("path");
const glob = require("glob");
const esbuild = require("esbuild");
function cleanup({pattern = "*"}) {
return {
name: "esbuild:cleanup",
setup(build) {
const options = build.initialOptions;
build.onEnd(async (result) => {
const safelist = new Set(Object.keys(result.metafile.outputs));
await glob(path.join(options.outdir, pattern), (err, files) => {
files.forEach((path) => {
if (!safelist.has(path))
fs.unlink(path);
});
});
});
},
};
}
esbuild.build({
// some config,
metafile: true,
plugins: [cleanup()]
})
In the onEnd
callback, we’re using the metafile property to get the list of all output entries created during the build and add it to the safelist
set. Then we’re using the glob
package to list all files in our output directory. I’m reading the outdir
setting from the initial build configuration and appending the *
pattern as I don’t have any subdirectories in the output directory. If you have another setup, you might use a different pattern (or just provide the pattern on your own, without using the build options). When the list is ready, we can iterate over it and remove all files that are not present in our safelist
set.
What about preserving some files?
The proposed plugin might be enough for simple use cases where you just use a single esbuild build and do not put any other files in the app/assets/builds
directory. But let’s assume you’re using Tailwind CSS to prepare your stylesheets and you’re also putting them in your app/assets/builds
directory. Or you have multiple esbuild builds because you prepare assets in different formats (e.g. ESM and IIFE for older browsers). In such scenario, you’ll need some safelist to ensure you won’t remove files generated by other tools or in other builds.
Extending the initial plugin is also easy - we just need to pass a safelist when constructing the plugin:
function cleanup({pattern = "*", safelist = []}) {
return {
name: "esbuild:cleanup",
setup(build) {
const options = build.initialOptions;
const safelistSet = new Set(safelist);
build.onEnd(async (result) => {
Object.keys(result.metafile.outputs).forEach((path) =>
safelistSet.add(path)
);
await glob(path.join(options.outdir, pattern), (err, files) => {
files.forEach((path) => {
if (!safelistSet.has(path))
fs.unlink(path);
});
});
});
},
};
}
esbuild.build({
// some config,
metafile: true,
plugins: [cleanup(["app/assets/builds/style.css"])]
})
Now we’ll keep all files from the build and also preserve files from the safelist. If you’re using more than one esbuild build in your setup, you can add the cleanup plugin to the last build and retrieve all files generated in previous builds from the metafile:
const iifeBuild = await esbuild.build(iifeConfig); // remember about setting metafile to true
const esmBuild = await esbuild.build({
// ...
metafile: true,
plugins: [cleanup(Object.keys(iifeBuild.metafile.outputs))]
})
Adding some logging
The plugin is now ready but as it’s most useful in development when incremental builds are enabled, we can add some logging to see what’s going on during the cleanup and also prevent from running the plugin in environments that are not correctly configured (e.g. when metafile is not available). You can also use make use of console.time
method to track how long the cleanup takes. Here’s my proposal for the plugin with the safelist and logging:
function cleanup({pattern = "*", safelist = [], debug = false}) {
return {
name: "esbuild:cleanup",
setup(build) {
const options = build.initialOptions;
if (!options.outdir) {
console.log("[esbuild cleanup] Not outdir configured - skipping the cleanup");
return;
}
if (!options.metafile) {
console.log("[esbuild cleanup] Metafile is not enabled - skipping the cleanup");
return;
}
const safelistSet = new Set(safelist);
build.onEnd(async (result) => {
try {
console.time("[esbuild cleanup] Cleaning up old assets");
Object.keys(result.metafile.outputs).forEach((path) =>
safelistSet.add(path)
);
await glob(path.join(options.outdir, pattern), (err, files) => {
files.forEach((path) => {
if (!safelistSet.has(path))
fs.unlink(
path,
(err) =>
debug &&
console.log(
err
? "[esbuild cleanup] " + err
: "[esbuild cleanup] Removed old file: " + path
)
);
});
});
} finally {
console.timeEnd("[esbuild cleanup] Cleaning up old assets");
}
});
},
};
}
The esbuild plugin system is brand new and is still actively developed. This means the API might be changed in the future so before you use this code in your project, make sure it’s still compatible with your esbuild version - I was using esbuild 0.14.54.