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.

You might also like