Read OSS

tsup アーキテクチャ概観:コードベースを読み解く

中級

前提知識

  • TypeScript と Node.js のモジュールシステムに関する基本的な知識
  • バンドラーの役割(エントリーポイント、出力フォーマット、externals など)についての概要理解

tsup アーキテクチャ概観:コードベースを読み解く

tsup は「esbuild を基盤としたゼロコンフィグの TypeScript バンドラー」を標榜しており、多くのユーザーにとってはそれだけで十分な説明です。しかし内部では、esbuild・Rollup・TypeScript コンパイラ・SWC・Sucrase・Terser を精緻に連携させたパイプラインが動いています。それぞれのツールが最も得意とする場面で使われるよう、注意深く設計されているのです。この記事では、コードベース全体をマッピングします。バグ修正のコントリビューションを考えている方も、プラグインを作りたい方も、あるいは自分のライブラリが .ts から配布可能なアーティファクトへとどのように変換されるのか純粋に興味がある方も、自信を持ってコードを読み進められるようになるはずです。

リポジトリ構成の全体像

tsup のリポジトリはコンパクトです。src/ 配下のおよそ 20 ファイルがすべての処理を担っており、esbuild プラグイン・tsup プラグイン・Rollup を使った DTS 生成のサブディレクトリに整理されています。

パス 役割
src/index.ts プログラマティック API:build()defineConfig()normalizeOptions()
src/cli-default.ts tsup バイナリのエントリーポイント
src/cli-node.ts tsup-node バイナリのエントリーポイント
src/cli-main.ts cac を使った CLI 引数のパース
src/esbuild/ esbuild のオーケストレーションと esbuild プラグイン(external、postcss、svelte、swc など)
src/plugins/ tsup のポストビルドプラグイン(shebang、cjs-splitting、tree-shaking、terser など)
src/rollup.ts --dts 向けの rollup-plugin-dts を実行するワーカースレッド
src/rollup/ 型解決用のカスタム Rollup プラグイン
src/tsc.ts --experimental-dts 向けのプログラマティック TypeScript コンパイラ
src/api-extractor.ts --experimental-dts 向けの API Extractor 連携
src/exports.ts experimental DTS における re-export 文のフォーマット
assets/ バンドルに注入される CJS・ESM シムファイル
test/ Vitest を使った統合テスト
graph TD
    subgraph "Source Tree"
        CLI["src/cli-*.ts<br/>CLI entry points"]
        IDX["src/index.ts<br/>build() orchestrator"]
        ESB["src/esbuild/<br/>esbuild plugins"]
        PLG["src/plugins/<br/>tsup plugins"]
        ROL["src/rollup.ts<br/>DTS worker"]
        TSC["src/tsc.ts<br/>TypeScript compiler"]
        API["src/api-extractor.ts<br/>API Extractor"]
    end
    CLI --> IDX
    IDX --> ESB
    IDX --> PLG
    IDX --> ROL
    IDX --> TSC
    TSC --> API

assets/ ディレクトリには assets/cjs_shims.jsassets/esm_shims.js の 2 ファイルだけが置かれています。CJS 環境での import.meta.url や ESM 環境での __dirname/__filename といった、フォーマットをまたぐグローバル変数をポリフィルするものです。詳細は Article 3 で取り上げます。

3 つのエントリーポイント:tsup・tsup-node・build()

tsup は 2 つの CLI コマンドと 1 つのプログラマティック API を提供しています。CLI のファイルは驚くほど薄く、それぞれ 10 行未満です。

src/cli-default.tstsup バイナリの実体です。

#!/usr/bin/env node
import { handleError } from './errors'
import { main } from './cli-main'

main().catch(handleError)

一方、src/cli-node.tstsup-node で、プリセットのオプションが 1 つ追加されています。

#!/usr/bin/env node
import { handleError } from './errors'
import { main } from './cli-main'

main({
  skipNodeModulesBundle: true,
}).catch(handleError)

唯一の違いは skipNodeModulesBundle: true です。これにより、node_modules 内のすべてのモジュールをバンドルに取り込まず esbuild が外部化するようになります。ライブラリではなく Node.js アプリケーションに適したデフォルト設定です。

flowchart LR
    A["tsup CLI"] -->|"main()"| C["cli-main.ts"]
    B["tsup-node CLI"] -->|"main({skipNodeModulesBundle: true})"| C
    C -->|"dynamic import('.')"| D["build()"]
    D --> E["esbuild pipeline"]
    D --> F["DTS pipeline"]

実際の CLI ロジックは src/cli-main.ts に集約されており、cac を使っておよそ 30 個のフラグを定義しています。104 行目に重要なパフォーマンス最適化があります。build().action() コールバックの中で動的な import('.') によってロードされているのです。

.action(async (files: string[], flags) => {
    const { build } = await import('.')
    // ...
    await build(options)
})

これにより、tsup --helptsup --version を実行しても、esbuild・Rollup・TypeScript といったビルドパイプライン全体が読み込まれることはありません。cac はアクションを起動することなくヘルプやバージョン表示を処理できるため、CLI の起動が高速に保たれます。

Tips: package.jsonbin フィールドはコンパイル済みの出力ファイルに直接マッピングされています。"tsup": "dist/cli-default.js""tsup-node": "dist/cli-node.js" です。tsup は自分自身をビルドするのにも tsup を使っており、そのビルドコマンドは tsup src/cli-*.ts src/index.ts src/rollup.ts --clean --splitting です。

build() オーケストレーターと並列タスク設計

src/index.tsbuild() 関数は tsup の心臓部です。設定ファイルのロード、オプションの正規化を行い、その後 2 つの独立したタスクツリーを並列で実行します。

flowchart TD
    B["build(_options)"] --> CL["Load config file"]
    CL --> NM["normalizeOptions()"]
    NM --> PA["Promise.all()"]
    PA --> DTS["dtsTask()"]
    PA --> MAIN["mainTasks()"]
    DTS --> EDTS{"experimentalDts?"}
    EDTS -->|yes| TSC["tsc + API Extractor"]
    EDTS -->|no| RDTS{"dts?"}
    RDTS -->|yes| WORKER["Worker thread<br/>Rollup + rollup-plugin-dts"]
    MAIN --> BA["buildAll()"]
    BA --> FMT1["runEsbuild(format: 'cjs')"]
    BA --> FMT2["runEsbuild(format: 'esm')"]
    BA --> OS["onSuccess hook"]

455 行目 の並列処理はシンプルですが重要な設計です。

await Promise.all([dtsTask(), mainTasks()])

JavaScript のバンドル処理と型定義ファイルの生成は完全に切り離されています。JS パイプラインは速度を優先して esbuild を使い、DTS パイプラインは Rollup(rollup-plugin-dts を使用)か TypeScript コンパイラ(API Extractor を使用)のいずれかを使います。両者が共有するのは正規化されたオプションだけです。ただし、関数はスレッド境界をまたぐことができないため、ワーカースレッドに送る前にオプションをシリアライズしておく必要があります。

mainTasks() の内部では、buildAll() 関数が 312〜347 行目 の別の Promise.all() を通じて各出力フォーマットに対して runEsbuild() を並列実行します。cjsesm を同時にビルドする場合、それぞれ独自の PluginContainer インスタンスを持つ 2 つの esbuild が同時に動作します。

二層構造のプラグインアーキテクチャ:概要

tsup にはビルドの異なるフェーズで動作する、明確に分離された 2 つのプラグイン層があります。Article 3・4 で詳細を見ていく前に、この分離を理解しておくことが重要です。

flowchart TD
    subgraph "Layer 1: esbuild Plugins"
        direction TB
        EP1["external.ts<br/>Resolve externals"]
        EP2["node-protocol.ts<br/>Strip node: prefix"]
        EP3["postcss.ts<br/>CSS processing"]
        EP4["swc.ts<br/>Decorator metadata"]
        EP5["svelte.ts<br/>Compile .svelte"]
        EP6["native-node-module.ts<br/>.node binary handling"]
    end
    subgraph "Layer 2: tsup Plugins"
        direction TB
        TP1["shebang<br/>CLI permissions"]
        TP2["tree-shaking<br/>Rollup pass"]
        TP3["cjs-splitting<br/>ESM→CJS via Sucrase"]
        TP4["cjs-interop<br/>Default export compat"]
        TP5["swc-target<br/>ES5/ES3 downlevel"]
        TP6["terser<br/>Minification"]
        TP7["size-reporter<br/>Output stats"]
    end
    SRC["Source files"] --> EP1
    EP6 --> OUT["esbuild outputFiles<br/>(in memory)"]
    OUT --> TP1
    TP7 --> DISK["Written to disk"]

Layer 1 のプラグインは esbuild のビルド処理中に動作します。esbuild の onResolveonLoad フックを使って、個々のファイルがバンドルに組み込まれる前に変換を行います。externals の解決、CSS の処理、Svelte のコンパイル、デコレーターメタデータの出力などが担当です。

Layer 2 のプラグインは esbuild が完了した後、メモリ上に保持された出力チャンク全体に対して動作します。renderChunkbuildEnd といったフックを持つ tsup の Plugin インターフェースを実装したもので、CJS スプリッティング、ツリーシェイキング、ミニファイ、ファイルパーミッションの設定などがここで行われます。

この仕組みを実現する重要なパターンが、esbuild の設定に含まれる write: false です。これにより esbuild はすべての出力をディスクに書き込まず、インメモリの Uint8Array バッファとして返します。tsup のプラグイン層が最終的なファイル書き出しの前にすべてを変換できるのはこのためです。

Options と NormalizedOptions:型の使い分け

tsup は src/options.ts に定義された 2 つの型を通じて、ユーザー向けの設定と内部状態をきれいに分離しています。

Options 型(103〜262 行目)はユーザーが指定できる設定を表しており、約 40 個のフィールドがすべてオプショナルです。デフォルト値は後から適用されるため、すべて省略可能になっています。

export type Options = {
  name?: string
  entry?: Entry
  target?: Target | Target[]
  format?: Format[] | Format   // Note: can be a single string
  dts?: boolean | string | DtsConfig  // Polymorphic
  // ... ~35 more optional fields
}

NormalizedOptions 型(269〜279 行目)は、以降のすべてのコードが実際に扱う型です。

export type NormalizedOptions = Omit<
  MarkRequired<Options, 'entry' | 'outDir'>,
  'dts' | 'experimentalDts' | 'format'
> & {
  dts?: DtsConfig
  experimentalDts?: NormalizedExperimentalDtsConfig
  tsconfigResolvePaths: Record<string, string[]>
  tsconfigDecoratorMetadata?: boolean
  format: Format[]
}
classDiagram
    class Options {
        +entry?: Entry
        +outDir?: string
        +format?: Format[] | Format
        +dts?: boolean | string | DtsConfig
        +target?: Target | Target[]
        ... ~35 more optional fields
    }
    class NormalizedOptions {
        +entry: Entry ⟵ required
        +outDir: string ⟵ required
        +format: Format[] ⟵ always array
        +dts?: DtsConfig ⟵ normalized
        +tsconfigResolvePaths: Record
        +tsconfigDecoratorMetadata?: boolean
    }
    Options --> NormalizedOptions : normalizeOptions()

ts-essentialsMarkRequired ユーティリティにより、entryoutDir の存在が型レベルで保証されます。dts フィールドは入力時の 3 形式(boolean | string | DtsConfig)が DtsConfig | undefined に統一されます。また format は常に Format[] の配列型となり、裸の文字列が来ることはありません。

この設計によって、normalizeOptions() より後のすべての関数は防御的なチェックを書かなくても入力の型を信頼できます。小さな設計上の判断ですが、バグ全体のクラスをひとつ排除できるのです。

Tips: tsup にコントリビュートする際、build() が起動した後に実行されるコードを書くなら、必ず NormalizedOptions を使いましょう。Options 型は設定ファイル・CLI パース・defineConfig 関数といったユーザー向けのインターフェースに限定されています。

次回予告

コードベース全体の地図を手に入れたところで、Article 2 では設定パイプラインにフォーカスします。tsup が 7 つのサポートフォーマットをまたいで設定ファイルを検出する方法を解説します。また、bundle-require がセットアップ不要で TypeScript の設定ファイルを読み込む仕組みも取り上げます。さらに normalizeOptions() がグロブ・tsconfig パス・出力拡張子を解決し、今回見てきた NormalizedOptions オブジェクトに仕上げるまでの流れを詳しく見ていきます。