Read OSS

tsupのプラグインシステム:ビルド後のトランスフォームと組み込みプラグイン

上級

前提知識

  • 記事3(esbuildパイプライン)の完了
  • CJS/ESM相互運用パターンの理解
  • ツリーシェイキングセクションのためのRollup bundle APIの基本知識

tsupのプラグインシステム:ビルド後のトランスフォームと組み込みプラグイン

記事3で確認したように、esbuildはwrite: falseパターンによってメモリ上に出力ファイルを保持します。tsupのプラグインシステムはこのメモリ上のチャンクに対して動作し、esbuildだけでは対応できない変換処理を担います。具体的には、CJSのコード分割、二次的なツリーシェイキング、ES5へのダウンレベリング、Terserによるminificationなどがその対象です。本記事ではプラグインAPIを詳しく解説し、各組み込みプラグインがどのような課題を解決しているかを順に見ていきます。

Plugin型とライフサイクルフック

Plugin型には4つのライフサイクルフックが定義されています:

export type Plugin = {
  name: string
  esbuildOptions?: ModifyEsbuildOptions
  buildStart?: BuildStart
  renderChunk?: RenderChunk
  buildEnd?: BuildEnd
}
フック タイミング 同期/非同期 用途
esbuildOptions esbuild実行前 同期 esbuildの設定変更(例:targetの上書き)
buildStart esbuild設定後、ビルド前 非同期 セットアップ処理、ディレクトリのクリーン
renderChunk esbuild完了後、出力チャンクごと 非同期 コードの変換、ソースマップの調整
buildEnd すべてのファイルがディスクに書き込まれた後 非同期 サイズのレポート、ファイルパーミッションの設定

各フックはPluginContextにバインドされたthisを受け取り、現在のformatsplittingフラグ、全options、そしてloggerにアクセスできます。このコンテキストはPluginContainerによって、フックが実行される前にセットされます。

最も強力なフックはrenderChunkです。チャンクのコード文字列と、ファイルパス・ソースマップ・エントリーポイント情報・エクスポートリスト・インポートメタデータを含むChunkInfoオブジェクトを受け取ります。チャンクを変換する場合は{ code, map }を返し、何も変更しない場合はundefinedまたはnullを返します。

buildAll()でのプラグインの組み立てと順序

プラグインチェーンはsrc/index.ts内のbuildAll()で組み立てられます:

const pluginContainer = new PluginContainer([
  shebang(),
  ...(options.plugins || []),
  treeShakingPlugin({ treeshake: options.treeshake, ... }),
  cjsSplitting(),
  cjsInterop(),
  swcTarget(),
  sizeReporter(),
  terserPlugin({ minifyOptions: options.minify, ... }),
])
flowchart TD
    A["1. shebang<br/>Detect #! lines, set mode 0o755"] --> B["2. User plugins<br/>Custom transforms"]
    B --> C["3. treeShaking<br/>Rollup secondary pass"]
    C --> D["4. cjsSplitting<br/>Sucrase ESM→CJS conversion"]
    D --> E["5. cjsInterop<br/>module.exports = exports.default"]
    E --> F["6. swcTarget<br/>ES5/ES3 downleveling"]
    F --> G["7. sizeReporter<br/>Log output sizes"]
    G --> H["8. terser<br/>Minification (last!)"]

この順序には明確な意図があります:

  1. shebangを先頭に:他のトランスフォームが先頭行を変更する前に#!を検出するため
  2. ユーザープラグインを早めに:ユーザーコードが最大限のコントロールを持てるように
  3. ツリーシェイキングはCJS変換の前に:RollupはESMに対して動作するため、CJS分割が有効な場合にesbuildがESMで出力するタイミングに合わせる
  4. CJS分割はツリーシェイキングの後に:デッドコードの除去が終わってからESM→CJSに変換する
  5. CJS interopは分割の後に:CJS出力に対してmodule.exportsを修正する
  6. SWC targetはフォーマット変換の後に:最終的なコードの形にダウンレベリングを適用する
  7. sizeReporterは末尾近くに:トランスフォーム後、minification前のサイズを計測する
  8. Terserは絶対に最後に:minificationは最終的なコードに対して実行しなければならない

shebangプラグイン:CLIファイルのパーミッション保持

shebangプラグインはチェーンの中で最もシンプルですが、実際の問題を解決しています:

export const shebang = (): Plugin => {
  return {
    name: 'shebang',
    renderChunk(_, info) {
      if (
        info.type === 'chunk' &&
        /\.(cjs|js|mjs)$/.test(info.path) &&
        info.code.startsWith('#!')
      ) {
        info.mode = 0o755
      }
    },
  }
}

esbuildは#!/usr/bin/env nodeで始まるファイルを処理する際、シバンを出力にそのまま保持します。このプラグインはそれを検出してinfo.mode = 0o755を設定します。これはUnixの実行可能パーミッションビットです。このmodeはディスクへの書き込み時にoutputFile()によって適用され、ビルド後のCLIスクリプトを手動でchmodすることなく即座に実行可能な状態にします。

このプラグインは新しいcode/mapペアを返すのではなく、infoオブジェクトを直接変更している点に注目してください。ChunkInfomodeプロパティはまさにこのために設計されており、buildFinished()のファイル書き込みステップで参照されます。

cjsSplittingプラグイン:esbuildのCJS分割制限の回避

これはtsupの中でも特に巧妙な回避策の一つです。esbuildはCommonJS出力に対してコード分割をサポートしていません。この問題の解決策は、ESM(分割有効)でビルドし、その後にCJSへ変換するというアプローチです。

記事3で確認したように、runEsbuild()はCJS分割が要求された場合にフォーマットを'esm'に上書きします。cjsSplittingプラグインはその後、Sucraseを使って各ESMチャンクをCJSに変換します:

async renderChunk(code, info) {
  if (
    !this.splitting ||
    this.options.treeshake ||  // handled by rollup instead
    this.format !== 'cjs' ||
    info.type !== 'chunk' ||
    !/\.(js|cjs)$/.test(info.path)
  ) {
    return
  }

  const { transform } = await import('sucrase')
  const result = transform(code, {
    filePath: info.path,
    transforms: ['imports'],
    sourceMapOptions: this.options.sourcemap
      ? { compiledFilename: info.path }
      : undefined,
  })

  return { code: result.code, map: result.sourceMap }
}
flowchart LR
    A["Source files"] -->|"format: 'cjs', splitting: true"| B["esbuild builds as ESM<br/>(with splitting)"]
    B --> C["ESM chunks with<br/>import/export syntax"]
    C -->|"cjsSplitting plugin"| D["CJS chunks with<br/>require()/exports"]
    D --> E["disk"]

フルパーサーではなくSucraseを選んでいるのは、その処理速度の速さゆえです。Sucraseはimport/export文をrequire()/module.exportsに変換するだけで、それ以外には一切手を加えません。transforms: ['imports']オプションがまさにこの動作を指定しています。

this.options.treeshakeのガード条件も重要です。ツリーシェイキングが有効な場合、treeShakingプラグイン内でRollupがESM→CJSの変換を担うため、cjsSplittingはその処理をスキップして二重変換を防ぎます。

cjsInteropプラグイン:デフォルトエクスポートの互換性

CJSのコンシューマーがconst lib = require('my-lib')とした場合、通常はデフォルトエクスポートを直接受け取ることを期待します。しかし、esbuildのCJS出力はデフォルトエクスポートをexports.defaultに配置します。cjsInteropプラグインはこのギャップを埋めます:

renderChunk(code, info) {
  if (
    !this.options.cjsInterop ||
    this.format !== 'cjs' ||
    info.type !== 'chunk' ||
    !/\.(js|cjs)$/.test(info.path) ||
    !info.entryPoint ||
    info.exports?.length !== 1 ||
    info.exports[0] !== 'default'
  ) {
    return
  }

  return {
    code: `${code}\nmodule.exports = exports.default;\n`,
    map: info.map,
  }
}

このプラグインはmodule.exports = exports.default;を末尾に追記しますが、それはチャンクがデフォルトエクスポートのみを持つエントリーポイントである場合に限られます。info.exports?.length !== 1 || info.exports[0] !== 'default'というガード条件により、名前付きエクスポートを持つモジュールでmodule.exportsが上書きされるのを防いでいます。

ヒント: 単一のデフォルトエクスポートを持つライブラリをビルドし、CJSのコンシューマーがrequire()で利用する場合は--cjsInteropを有効にしましょう。ただし注意が必要です。モジュールに名前付きエクスポートも含まれる場合、module.exports = exports.defaultによってそれらが隠れてしまうため、このフラグは使用すべきではありません。

treeShakingプラグイン:Rollupによる二次パス

esbuildのツリーシェイキングはほとんどのケースをカバーしますが、複雑な再エクスポートパターンではデッドコードが残ることがあります。treeShakingPluginはRollupの優れたツリーシェイキングをポストプロセスとして活用します:

sequenceDiagram
    participant ESB as esbuild output
    participant RP as Rollup (virtual)
    participant OUT as Final output

    ESB->>RP: Feed chunk code as virtual module
    Note over RP: resolveId: only resolve self<br/>load: return esbuild's code
    RP->>RP: Tree-shake with<br/>preserveEntrySignatures: 'exports-only'
    RP->>RP: Generate output in target format
    RP-->>OUT: Smaller code + map

ポイントとなるのは26〜37行目のvirtualモジュールのセットアップです:

plugins: [
  {
    name: 'tsup',
    resolveId(source) {
      if (source === info.path) return source
      return false  // externalize everything else
    },
    load(id) {
      if (id === info.path) return { code, map: info.map }
    },
  },
],

このvirtualプラグインはチャンク自身のパスのみを既知のモジュールとして扱い、それ以外はすべてreturn falseで外部化します。これにより、RollupはImportを解決しようとせず、チャンク内のコードのみをツリーシェイクします。Rollupに渡すtreeshakeオプションには、真偽値、プリセット文字列('recommended''smallest''safest')、あるいはRollupのTreeshakingOptionsオブジェクトをそのまま指定できます。

swcTargetプラグイン:ES5/ES3へのダウンレベリング

esbuildのES5サポートは不完全で、一部のクラス関連構文を変換できません。swcTargetプラグインはこれに対応します:

esbuildOptions(options) {
  if (
    typeof options.target === 'string' &&
    TARGETS.includes(options.target as any)  // ['es5', 'es3']
  ) {
    target = options.target as any
    options.target = 'es2020'  // Override esbuild to target modern JS
    enabled = true
  }
},

esbuildOptionsフックでes5またはes3ターゲットを検出すると、それをes2020置き換えます。esbuildはモダンなJavaScriptをビルドし、renderChunkでSWCのtransformが完全なダウンレベリングを担います:

const result = await swc.transform(code, {
  filename: info.path,
  sourceMaps: this.options.sourcemap,
  jsc: {
    target,  // 'es5' or 'es3'
    parser: { syntax: 'ecmascript' },
  },
  module: {
    type: this.format === 'cjs' ? 'commonjs' : 'es6',
  },
})

これは実用的な設計です。esbuildが高速に処理の95%をこなし、SWCがレガシーターゲット向けにesbuildでは対応できないエッジケースを補います。

terserプラグイン:ポストプロセスとしてのMinification

terserPluginはチェーンの最後に実行されます。この順序は重要で、他のトランスフォームの前にminificationを行うと、後続の処理が格段に難しくなり、結果も悪化します。

このプラグインはminifyが文字列'terser'に設定されている場合のみ動作します(真偽値trueの場合はesbuild組み込みのminifierが使われます):

if (minifyOptions !== 'terser' || !/\.(cjs|js|mjs)$/.test(info.path))
  return

フォーマットに応じたTerserオプションを自動的に適用します:

if (format === 'esm') {
  defaultOptions.module = true
} else if (!(format === 'iife' && globalName !== undefined)) {
  defaultOptions.toplevel = true
}

ESM出力にはmodule: true(ESM固有の最適化を有効化)を設定します。CJSとglobalNameなしのIIFEにはtoplevel: true(トップレベル宣言のマングリングを許可)を設定します。globalNameありのIIFEはグローバル変数名を保持するためにtoplevelをスキップします。

SWCプラグインと同様に、TerserはlocalRequire()で読み込まれます。オプションの依存関係のため、別途インストールが必要です。インストール方法はエラーメッセージで案内されます:

if (!terser) {
  throw new PrettyError(
    'terser is required for terser minification. Please install it with `npm install terser -D`',
  )
}

sizeReporterプラグイン:出力サイズの表示

sizeReporterbuildEndプラグインで、すべてのファイルがディスクに書き込まれた後に実行されます:

buildEnd({ writtenFiles }) {
  reportSize(
    this.logger,
    this.format,
    writtenFiles.reduce((res, file) => {
      return { ...res, [file.name]: file.size }
    }, {}),
  )
}

writtenFiles配列からファイル名とサイズを収集し、reportSizeに処理を委譲してコンソールに整形して出力します。prettyBytesで人間が読みやすいサイズ表示を行い、ファイル名を揃えてパディングします。シンプルながら、外部ツールなしにバンドルサイズを即座に確認できる便利な仕組みです。

ヒント: このプラグインチェーンは独自のプラグインを構築する際の良いモデルになります。ライセンスヘッダーの挿入、importのパッチ、カスタムバリデーションなど、独自のポストプロセスが必要な場合は、Plugin型をrenderChunkフックで実装し、tsupの設定ファイルのplugins配列に追加しましょう。追加したプラグインはshebangと組み込みのトランスフォームプラグインの間で実行されます。

次回予告

これでtsupのプラグインアーキテクチャの両層をカバーできました。記事5ではPromise.all([dtsTask(), mainTasks()])のもう一方の処理、すなわち型宣言ファイルの生成パイプラインを取り上げます。2つの戦略を比較します。一つはWorkerスレッドとrollup-plugin-dtsを使用するRollupベースの--dtsパス、もう一つはTypeScript compiler APIとAPI Extractorを使用する--experimental-dtsパスです。