In my previous post, I wrote about [migrating a large JS project from Webpack to esbuild](https://blog.arkency.com/how-i-migrated-a-rails-app-from-webpack-to-esbuild-and-got-smaller-and-faster-js-builds/). 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`](https://esbuild.github.io/api/#splitting) option with hashed [chunk names](https://esbuild.github.io/api/#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](https://esbuild.github.io/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:

```js
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](https://tailwindcss.com) 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 <abbr title="Immediately invoked function expression">IIFE</abbr> 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:

```js
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:

```js
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](https://esbuild.github.io/api/#incremental) 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`](https://developer.mozilla.org/en-US/docs/Web/API/console/time) method to track how long the cleanup takes. Here's my proposal for the plugin with the safelist and logging:

```js
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.
