Read OSS

esbuildビルドパイプライン:オーケストレーション、プラグイン、出力処理

上級

前提知識

  • 記事1〜2の読了
  • esbuild Plugin API(onResolve・onLoad・onEndフック)の実務的な理解
  • CJSとESMのモジュールセマンティクスの理解
  • ソースマップの基礎知識

esbuildビルドパイプライン:オーケストレーション、プラグイン、出力処理

tsupの本質は、TypeScriptソースとesbuildをつなぐブリッジです。ただし、そのブリッジの内部構造は決してシンプルとは言えません。runEsbuild()NormalizedOptions をもとに完全なesbuild設定を組み立て、出力をメモリ上に保持しながらビルドを実行します。その結果をディスクへ書き出す前にプラグインパイプラインへ流し込みます。この記事では、その一連のプロセスをひとつずつ追っていきます。

esbuildの設定:自動外部化とフォーマット選択

runEsbuild() が最初に行うのは、何を外部化すべきかの判定です。77〜83行目では、package.json から本番依存関係を読み込み、正規表現パターンに変換しています。

const deps = await getProductionDeps(process.cwd())
const external = [
  ...deps.map((dep) => new RegExp(`^${dep}($|\/|\\)`)),
  ...(await generateExternal(options.external || [])),
]

^lodash($|\/|\\) というパターンは、lodash 本体だけでなく lodash/get のような深いインポートにも対応しています。getProductionDeps()dependenciespeerDependencies を合算します。ライブラリが本番依存関係をバンドルすべきでないという前提は、至極正当です。

もうひとつ見落としがちな重要な挙動が、164〜165行目のフォーマット上書きです。

format:
  (format === 'cjs' && splitting) || options.treeshake ? 'esm' : format,

CJS出力かつコードスプリッティングが有効な場合、またはツリーシェイキングが有効な場合、esbuildの実際のビルドフォーマットはESMになります。esbuildはCJS向けのコードスプリッティングをネイティブサポートしていないためです。ESM出力はその後、cjsSplitting プラグイン(記事4で詳述)によってCJSに変換されます。ツリーシェイキングプラグインも同様に、esbuildのESM出力に対してRollupを走らせ、最終的なCJSを生成します。

指定フォーマット Splitting Treeshake esbuildの実際のフォーマット
cjs false false cjs
cjs true false esm(cjsSplittingプラグインが変換)
cjs 任意 true esm(ツリーシェイキングプラグインが変換)
esm 任意 任意 esm
iife N/A 任意 iife

externalPlugin:skipNodeModulesBundleとnoExternal

package.json から生成した正規表現パターンをそのまま使うには、カスタムのesbuildプラグインが必要です。esbuild組み込みの external オプションは正規表現をサポートしていないからです。externalPluginには、動作モードが2つあります。

flowchart TD
    A["Import resolved"] --> B{"skipNodeModulesBundle?"}
    B -->|yes| C{"Matches tsconfig paths?"}
    C -->|yes| D["Bundle it — let esbuild resolve"]
    C -->|no| E{"Matches noExternal?"}
    E -->|yes| D
    E -->|no| F{"Matches explicit external?"}
    F -->|yes| G["Mark external"]
    F -->|no| H{"Is non-relative import?<br/>(looks like node_module)"}
    H -->|yes| G
    H -->|no| D
    B -->|no| I{"Matches noExternal?"}
    I -->|yes| D
    I -->|no| J{"Matches external?"}
    J -->|yes| G
    J -->|no| D

通常モードtsup が使用)では、明示的に指定した externalnoExternal のパターンだけを照合します。skipNodeModulesBundleモードtsup-node が使用)では、相対パスや絶対パスに見えないインポートはすべて外部化されます。ただし noExternal やtsconfigのパスエイリアスに一致する場合は例外です。

5行目の正規表現が、モジュールインポートの判定ロジックです。

const NON_NODE_MODULE_RE = /^[A-Z]:[/\\]|^\.{0,2}\/|^\.{1,2}$/

このパターンにマッチしないインポートパス(相対パス・絶対パス・ドライブレターのどれでもないもの)は、node_moduleとみなして外部化されます。

組み込みesbuildプラグインの全体像

tsupは121〜150行目で6つのesbuildプラグインを登録しています。それぞれがesbuildの機能的な空白を埋める役割を担っています。

プラグイン ファイル 役割
nodeProtocolPlugin node-protocol.ts 古いNode.jsランタイム向けに、インポートから node: プレフィックスを除去する(例:node:pathpath
externalPlugin external.ts 正規表現ベースの外部解決(前節で解説)
swcPlugin swc.ts tsconfig で emitDecoratorMetadata が有効な場合、すべての .ts/.js ファイルに対してSWCを実行しデコレータメタデータを出力する
nativeNodeModulesPlugin native-node-module.ts .node バイナリアドオンを解決し、require() ラッパーを生成する
postcssPlugin postcss.ts CSSファイルを読み込み、設定があればPostCSS変換を適用し、必要に応じてCSSをJSとして注入する
sveltePlugin svelte.ts Svelteコンパイラを使って .svelte ファイルをコンパイルし、TypeScriptの前処理も行う

中でもSWCプラグインは特筆に値します。esbuildはTypeScriptの emitDecoratorMetadata をサポートしていません。しかしNestJSやTypeORMといったフレームワークはこの機能を必要とします。tsconfigDecoratorMetadata が有効な場合、swcPlugin はすべてのTypeScriptファイルを onLoad でインターセプトします。そして decoratorMetadata: true を指定してSWCの transformFile を実行し、その結果をesbuildに返します。

const jsc: JscConfig = {
  parser: {
    syntax: isTs ? 'typescript' : 'ecmascript',
    decorators: true,
  },
  transform: {
    legacyDecorator: true,
    decoratorMetadata: true,
  },
  keepClassNames: true,
  target: 'es2022',
}

keepClassNames: truetarget: 'es2022' が強制されている点に注目してください。クラス名をリフレクションメタデータのために保持しつつ、esbuildが問題なく処理できる程度にモダンな出力を保証するためです。

Tip: SWC・PostCSS・Svelteの各プラグインは、いずれも utils.tslocalRequire() を使ってパッケージをユーザーのプロジェクトディレクトリ基準で解決します。そのため、ユーザーはその機能を実際に使うときだけオプショナルな依存関係をインストールすればよく、tsupはimport時点でクラッシュしません。

write:falseパターンとメモリ内出力

tsupの設定の中でもっとも重要なesbuildオプションは、234行目の一行です。

write: false,

write: false を指定すると、esbuildはすべての出力ファイルを OutputFile[] オブジェクトとしてメモリ上に返します。各オブジェクトは pathtextcontents(Uint8Array)を持ちます。ディスクへの書き込みは一切行われません。

sequenceDiagram
    participant RE as runEsbuild()
    participant EB as esbuild
    participant PC as PluginContainer
    participant FS as File System

    RE->>EB: esbuild({ write: false, ... })
    EB-->>RE: { outputFiles: OutputFile[], metafile }
    RE->>PC: buildFinished({ outputFiles, metafile })
    PC->>PC: Filter out .map files
    PC->>PC: Classify as ChunkInfo or AssetInfo
    loop For each chunk
        PC->>PC: Run renderChunk() through all plugins
        PC->>PC: Merge source maps if plugin returned map
    end
    PC->>FS: outputFile() — write to disk
    PC->>PC: Call buildEnd() on all plugins

このパターンはtsupのプラグイン層全体の前提条件です。これがなければ、CJS出力の変換、二次的なツリーシェイキング、Terserによるminification、shebangの修正といった処理を差し込む機会がありません。トレードオフとしてすべての出力が同時にメモリ上に保持されますが、ライブラリのビルドにおいてこれが問題になることはほとんどないでしょう。

PluginContainer.buildFinished():出力処理パイプライン

esbuildの処理が完了すると、次に pluginContainer.buildFinished() が主導権を握り、ビルド後の全パイプラインを取り仕切ります。

まず131〜149行目で出力ファイルが分類されます。ソースマップファイルは除外され、親ファイルと紐付けられます。JSファイルとCSSファイルはコードを文字列として持つ ChunkInfo オブジェクトになり、それ以外はバイト列を持つ AssetInfo になります。

続いて、各チャンクに対してすべてのプラグインの renderChunk フックが順番に呼び出されます。

for (const plugin of this.plugins) {
  if (info.type === 'chunk' && plugin.renderChunk) {
    const result = await plugin.renderChunk.call(
      this.getContext(),
      info.code,
      info,
    )
    if (result) {
      info.code = result.code
      // source map merging...
    }
  }
}
flowchart LR
    IN["esbuild output chunk"] --> S["shebang"]
    S --> U["user plugins"]
    U --> TS["tree-shaking"]
    TS --> CS["cjs-splitting"]
    CS --> CI["cjs-interop"]
    CI --> SW["swc-target"]
    SW --> SR["size-reporter"]
    SR --> TR["terser"]
    TR --> OUT["Final code + map"]

順序には意味があります。shebangは #! 行を検出するために最初に実行される必要があります。ツリーシェイキングとCJSスプリッティングは、Terserによるminificationより前に完了していなければなりません。サイズレポーターは最終的なサイズに近い値を計測するために、後段で実行されます。

複数の変換パスにまたがるソースマップのマージ

プラグインが codemap の両方を返した場合、PluginContainerは新しいソースマップを既存のものとマージする必要があります。この処理は164〜176行目で行われます。

if (result.map) {
  const originalConsumer = await new SourceMapConsumer(
    parseSourceMap(info.map),
  )
  const newConsumer = await new SourceMapConsumer(
    parseSourceMap(result.map),
  )
  const generator = SourceMapGenerator.fromSourceMap(newConsumer)
  generator.applySourceMap(originalConsumer, info.path)
  info.map = generator.toJSON()
  originalConsumer.destroy()
  newConsumer.destroy()
}
sequenceDiagram
    participant O as Original Map<br/>(esbuild → source)
    participant N as New Map<br/>(plugin → esbuild output)
    participant G as SourceMapGenerator

    Note over G: Start from new map
    G->>G: fromSourceMap(newConsumer)
    G->>G: applySourceMap(originalConsumer)
    Note over G: Composed map now traces<br/>plugin output → original source

重い処理を担うのは source-map ライブラリの applySourceMap メソッドです。これはプラグイン出力の位置情報を、esbuildが生成したマップを通じて元のソース位置まで遡ってマッピングします。このマップ合成がなければ、デバッグ時に元のTypeScriptではなく変換後のコードの位置が表示されてしまいます。

全プラグインの処理が完了したら、最終的なコードとソースマップが outputFile() を通じてディスクに書き出されます。その後、書き出されたファイルのメタデータとともに各プラグインの buildEnd フックが呼び出されます。サイズレポーターが実際に動くのはここです。

CJSとESMのシム注入

--shims が有効な場合、tsupはesbuildの inject オプションを使ってポリフィルファイルを注入します。CJSシム assets/cjs_shims.js は、CJSコンテキストで import.meta.url を提供します。

const getImportMetaUrl = () =>
  typeof document === "undefined"
    ? new URL(`file:${__filename}`).href
    : (document.currentScript && document.currentScript.tagName.toUpperCase() === 'SCRIPT')
      ? document.currentScript.src
      : new URL("main.js", document.baseURI).href;

export const importMetaUrl = /* @__PURE__ */ getImportMetaUrl()

関数でラップしているのは理由があります。1〜4行目のコメントにある通り、esbuildには import.meta.urlconst として直接エクスポートすると、不要なフォーマットでも常に注入してしまうバグがあります。関数でラップすることで、この問題を回避しています。

ESMシム assets/esm_shims.js はその逆で、import.meta.url を使ってESMコンテキストで __dirname__filename を提供します。

import path from 'node:path'
import { fileURLToPath } from 'node:url'

const getFilename = () => fileURLToPath(import.meta.url)
const getDirname = () => path.dirname(getFilename())

export const __dirname = /* @__PURE__ */ getDirname()
export const __filename = /* @__PURE__ */ getFilename()

どちらも /* @__PURE__ */ アノテーションが付いているので、未使用の場合はツリーシェイカーが除去できます。注入の有無はフォーマットと shims オプションの組み合わせによって決まり、runEsbuild()220〜228行目で制御されています。

Tip: ESM出力で ReferenceError: __dirname is not defined が発生したり、CJS出力で import.meta.url is not available というエラーが出た場合は、--shims を有効にするのが解決策です。tsupが適切なポリフィルを自動で注入してくれます。

次回予告

esbuildの出力が ChunkInfo オブジェクトとしてメモリ上に揃ったところで、記事4ではtsupの組み込みプラグイン全体を詳しく見ていきます。shebangを処理してファイルのパーミッションを設定するところから始まり、CJSスプリッティングのうまい回避策を経て、パイプラインの末端でTerserがminificationを行うまで、一通り追っていきましょう。