Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: esbuildコンフィグをプラグインで改善してアセットディレクトリをクリーンアップする(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

本記事は以下の記事の続きです。

Rails: Webpackをesbuildに移行してJSのビルドを縮小・高速化(翻訳)

Rails: esbuildコンフィグをプラグインで改善してアセットディレクトリをクリーンアップする(翻訳)

前回の記事では、巨大なJavaScriptプロジェクトをWebpackからesbuildに移行する話を書きました。その際、app/assets/builds/ディレクトリに従来のビルドの古いファイルが残っているため移行の最適化が不十分であるという点に触れました。

これは、ハッシュ形式のchunk namesを用いるsplittingオプションを使わなければ問題になりません。しかし、バンドルを小さなチャンクに小分けして遅延読み込みしたい場合は、ダイジェスト化されたファイル名が頻繁に変更される小さなJavaScriptファイルがapp/assets/builds/ディレクトリの下に大量に発生してしまいます。そうなると、Railsサーバー起動後の最初のリクエストが遅くなる可能性があります。

esbuildのプラグインについてしばらく調べてみたところ、この解決方法はきわめてシンプルであることが判明しました。

esbuildのプラグインシステムでは、ビルド終了時に呼び出されるonEndコールバックも実行できます。これは、従来のビルドから不要なファイルをすべて削除するクリーンアップタスクをフックするのにうってつけの場所です。

プラグインの初期バージョンは以下のような感じになります。

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({
  // 他のコンフィグの続き
  metafile: true,
  plugins: [cleanup()]
})

このonEndコールバックでは、ビルド中に生成されたすべての出力エントリをmetafileプロパティで取得してsafelistセットに追加します。次に、globパッケージを用いて出力ディレクトリ(outdir)内にあるすべてのファイルをリストアップします。outdirセットの内容は初期ビルドのコンフィグから読み込みます。私たちのセットアップではoutdir内にサブディレクトリがないので*パターンを追加していますが、セットアップがこれと異なる場合は別のパターンを使ってもよいでしょう(あるいはビルドオプションを使わずに独自のパターンを指定してもよいでしょう)。リストを準備できたら、リスト項目をイテレートしてsafelist設定に存在しないファイルをすべて削除します。

削除したくないファイルがある場合は?

上で提案したプラグインは、利用するesbuildのビルドが1個だけでapp/assets/builds/ディレクトリ内にほかのファイルがないようなシンプルなケースならおそらく十分でしょう。

しかし、たとえばTailwind CSSでスタイルシートを用意してapp/assets/builds/ディレクトリにTailwindのファイルも置いている場合が考えられます。あるいは、アセットを複数のフォーマットで用意している(ESMや古いブラウザ用のIIFEなど)という理由でesbuildのビルドを複数使っている場合も考えられます。このようなシナリオでは、他のツールやビルドで生成されたファイルを削除しないように何らかのセーフリストが必要になるでしょう。

上述の初期プラグインは拡張も簡単です。プラグインをnewするときにsafelistを渡すだけでできます。

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"])]
})

これで、ビルドファイルを維持しながらsafelistのファイルも保護されるようになります。自分のセットアップでesbuildのビルドを複数使う場合は、最後のビルドにcleanupプラグインを追加し、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))]
})

ログ出力機能の追加

これでプラグインは準備が整いましたが、esbuildのインクリメンタルビルドを有効にしておくと開発で非常に便利なので、ここにログ出力機能を追加してクリーンアップ中の動作を確認できるようにします。また、コンフィグが誤っている環境(metafileが利用できないなど)でプラグインが実行されないようにします。

console.timeメソッドを使えば、クリーンアップに要した時間を追跡できます。以下はプラグインにsafelistやログ出力機能を追加する私からの提案です。

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");
        }
      });
    },
  };
}

esbuildのプラグインシステムはまだ新しく、現在も開発が活発に進められています。つまり、このAPIは今後変更される可能性があるので、上のコードを自分のプロジェクトで使う場合は、esbuildのバージョンと互換性があるかどうかを確認してからにしてください。なお私はesbuild 0.14.54を使っています。

お知らせ: 5600人以上のRailsエンジニアが購読しているメールマガジン

元記事末尾のフォームに登録いただくと、Arkencyのベテランプログラマーによる磨き抜かれた洞察や知恵を結晶したメールマガジンを配信いたします。

私たちは皆さんのメールボックスにスパムを決して送信することのないよう最大限に配慮しています。フォームを送信いただくと確認メールが届きます。

関連記事

Rails: Webpackをesbuildに移行してJSのビルドを縮小・高速化(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。