Read OSS

Watch モード、onSuccess フック、そして開発フィードバックループ

中級

前提知識

  • 第 1〜3 記事を読了していること
  • chokidar および Node.js の fs イベントについて基本的な知識があること
  • 子プロセス管理(spawn、kill シグナル)を理解していること

Watch モード、onSuccess フック、そして開発フィードバックループ

一度しか実行できないバンドラーは、開発中にはほとんど役に立ちません。tsup の watch モードは、ビルドを継続的なフィードバックループに変えます。ファイルが変更されるとバンドルが再ビルドされ、開発サーバーが再起動されます。しかし、これをうまく機能させるには細心の注意が必要です。関連するファイルが変更されたときだけ再ビルドし、連続した保存操作でビルドが重複しないようにし、プラットフォームをまたいで子プロセスをきれいに管理しなければなりません。この記事では、それぞれの仕組みを詳しく見ていきましょう。

Watch モードへの移行:chokidar のセットアップと初回ビルド

Watch モードは mainTasks() の内部で有効化されます。最初の buildAll() が完了した後、451 行目startWatcher() が呼ばれます。この関数は、watch モードが有効なときだけ chokidar を遅延インポートします。

const startWatcher = async () => {
  if (!options.watch) return
  const { watch } = await import('chokidar')
  // ...
}

ウォッチャーは 378〜382 行目 で、適切な除外設定を付けて構成されます。

const ignored = [
  '**/{.git,node_modules}/**',
  options.outDir,
  ...customIgnores,
]

出力ディレクトリは常に監視対象から除外されます。これがないと、再ビルドがファイル変更イベントを引き起こし、無限ループに陥ってしまいます。

flowchart TD
    A["mainTasks()"] --> B["buildAll() — initial build"]
    B --> C["startWatcher()"]
    C --> D["import chokidar"]
    D --> E["chokidar.watch(paths, { ignored })"]
    E --> F["watcher.on('all', handler)"]
    F --> G{"File changed"}
    G -->|relevant file| H["debouncedBuildAll()"]
    G -->|irrelevant file| I["Skip"]
    H --> B

--watch をフラグとして渡した場合、監視パスはデフォルトで '.'(カレントディレクトリ)になります。文字列や配列で個別に指定することも可能です。

tsup --watch src --watch lib    # Watch specific directories
tsup --watch                     # Watch everything in "."

buildDependencies と esbuild メタファイルによるスマートな差分ビルド

すべてのファイル変更が再ビルドにつながるべきではありません。tsup が監視している最中に README を編集しても、再ビルドは必要ないはずです。tsup は、esbuild のメタファイルを使ってビルド中に参照したファイルを正確に追跡しています。

runEsbuild() の末尾で、すべての入力ファイルが共有の buildDependencies セットに追加されます。

if (result.metafile) {
  for (const file of Object.keys(result.metafile.inputs)) {
    buildDependencies.add(file)
  }
}

メタファイルの inputs オブジェクトには、esbuild がビルド中に読み込んだすべてのファイル(ソースファイル、インポートされたモジュール、解決された依存関係)が含まれます。このセットは、428〜435 行目 のウォッチャーの変更ハンドラーで参照されます。

if (options.watch === true) {
  if (file === 'package.json' && !buildDependencies.has(file)) {
    const currentHash = await getAllDepsHash(process.cwd())
    shouldSkipChange = currentHash === depsHash
    depsHash = currentHash
  } else if (!buildDependencies.has(file)) {
    shouldSkipChange = true
  }
}
sequenceDiagram
    participant FS as File System
    participant W as Watcher
    participant BD as buildDependencies Set
    participant BUILD as buildAll()

    Note over BD: After initial build:<br/>{src/index.ts, src/utils.ts, ...}
    FS->>W: Change: README.md
    W->>BD: Has "README.md"?
    BD-->>W: No
    W->>W: shouldSkipChange = true
    Note over W: Skip rebuild

    FS->>W: Change: src/utils.ts
    W->>BD: Has "src/utils.ts"?
    BD-->>W: Yes
    W->>BUILD: debouncedBuildAll()

カスタムの watch パス(例: --watch src)を指定した場合、そのパス内のすべての変更が再ビルドをトリガーします。buildDependencies のチェックが行われるのは、options.watch === true(デフォルトの boolean モード)のときだけです。

293〜296 行目 にも注目しましょう。ビルドが失敗した場合、直前の buildDependencies が復元され、次の再試行に向けてウォッチャーが引き続き正しいファイルを監視できるようになっています。

package.json の依存関係ハッシュ:依存変更時の選択的リビルド

package.json は特別な扱いを受けます。descriptionversion フィールドを編集しても再ビルドは不要ですが、新しい依存関係を追加したときは再ビルドが必要です(externals リストが変わる可能性があるからです)。

getAllDepsHash() は、すべての依存セクションのハッシュを計算します。

export async function getAllDepsHash(cwd: string) {
  const data = await loadPkg(cwd, true)  // clearCache: true
  return JSON.stringify({
    ...data.dependencies,
    ...data.peerDependencies,
    ...data.devDependencies,
  })
}

ハッシュは初回ビルドの前に計算されて保存されます。package.json が変更されると、新しいハッシュと保存済みのものが比較され、異なる場合のみ再ビルドが実行されます。clearCache: true フラグにより、joycon がキャッシュを使わずにファイルを再読み込みすることが保証されます。

flowchart TD
    A["package.json changed"] --> B["Compute new depsHash"]
    B --> C{"depsHash changed?"}
    C -->|yes| D["Rebuild — externals may have changed"]
    C -->|no| E["Skip — metadata-only change"]

debouncePromise:連続したファイル変更のまとめ処理

エディターでファイルを保存すると、ファイルシステムが連続して複数のイベント(write、rename、change)を発行することがあります。デバウンスなしでは、各イベントが新しいビルドを起動してしまい、ビルドが積み上がっていきます。

src/utils.tsdebouncePromise ユーティリティは、通常のデバウンスとは異なり、非同期処理を考慮した実装になっています。

export function debouncePromise<T extends unknown[]>(
  fn: (...args: T) => Promise<void>,
  delay: number,
  onError: (err: unknown) => void,
) {
  let timeout: ReturnType<typeof setTimeout> | undefined
  let promiseInFly: Promise<void> | undefined
  let callbackPending: (() => void) | undefined

  return function debounced(...args: Parameters<typeof fn>) {
    if (promiseInFly) {
      callbackPending = () => {
        debounced(...args)
        callbackPending = undefined
      }
    } else {
      if (timeout != null) clearTimeout(timeout)
      timeout = setTimeout(() => {
        timeout = undefined
        promiseInFly = fn(...args)
          .catch(onError)
          .finally(() => {
            promiseInFly = undefined
            if (callbackPending) callbackPending()
          })
      }, delay)
    }
  }
}
sequenceDiagram
    participant E as File Events
    participant D as debounced()
    participant B as buildAll()

    E->>D: Change event 1
    Note over D: Start 100ms timer
    E->>D: Change event 2 (50ms later)
    Note over D: Reset timer to 100ms
    E->>D: Change event 3 (80ms later)
    Note over D: Reset timer to 100ms
    Note over D: 100ms elapsed...
    D->>B: Start build
    E->>D: Change event 4 (during build)
    Note over D: Build in flight —<br/>store as pending callback
    B-->>D: Build complete
    D->>D: Execute pending callback
    Note over D: Start new 100ms timer

この実装には 3 つの状態があります。

  1. アイドル状態:新しいイベントが来るたびにタイマーをリセットします(通常のデバウンス動作)
  2. ビルド中:新しいイベントはペンディングコールバックとして保持され、現在のビルドが完了するまで新しいビルドは開始されません
  3. ペンディングありでビルド完了:ペンディングコールバックが実行され、新しいデバウンスサイクルが始まります

この仕組みにより、ビルドの重複実行を防いでいます。287 行目 のデフォルト遅延 100ms は、連続保存のような典型的なシナリオに十分対応できます。

ヒント: watch モードの再ビルドが頻繁すぎると感じたら、--ignore-watch を使って特定ディレクトリを除外しましょう。たとえば --ignore-watch test を指定すると、テストファイルの変更が再ビルドをトリガーしなくなります。指定したパターンはそのまま chokidar に渡されます。

onSuccess フック:コマンドとコールバック

watch モードでビルドが成功した後、多くの場合は何らかのアクションを実行したいはずです。開発サーバーを再起動したり、テストを実行したり、ファイルをコピーしたり。onSuccess オプションは 2 つの形式をサポートしています。

文字列コマンドtinyexec を使ってシェルプロセスとして起動されます。

if (typeof options.onSuccess === 'function') {
  onSuccessCleanup = await options.onSuccess()
} else {
  onSuccessProcess = exec(options.onSuccess, [], {
    nodeOptions: { shell: true, stdio: 'inherit' },
  })
}

関数コールバック はオプションのクリーンアップ関数を返すことができ、次の再ビルド前に呼び出されます。

ライフサイクル管理は 269〜281 行目doOnSuccessCleanup() が担当します。再ビルドのたびに、直前の onSuccess プロセスまたはクリーンアップ関数を先に完了させる必要があります。

stateDiagram-v2
    [*] --> Idle
    Idle --> Building: File change
    Building --> RunningOnSuccess: Build succeeds
    Building --> Idle: Build fails
    RunningOnSuccess --> KillingPrevious: File change
    KillingPrevious --> Building: Previous process killed
    RunningOnSuccess --> Idle: Process exits

シェルコマンドの場合、直前のプロセスの終了には killProcess() を使います。これは tree-kill パッケージのラッパーです。

const killProcess = ({ pid, signal }: { pid: number; signal: KILL_SIGNAL }) =>
  new Promise<void>((resolve, reject) => {
    kill(pid, signal, (err) => {
      if (err && !isTaskkillCmdProcessNotFoundError(err)) return reject(err)
      resolve()
    })
  })

isTaskkillCmdProcessNotFoundError は Windows 特有のエッジケースに対処しています。tree-kill は Windows で taskkill コマンドを使いますが、対象プロセスがすでに終了している場合に終了コード 128 を返します。このチェックがないと、onSuccess プロセスが自然終了した後の再ビルドのたびにエラーが発生してしまいます。

KILL_SIGNAL--killSignal で設定可能)のデフォルトは SIGTERM で、グレースフルシャットダウンを可能にします。onSuccess プロセスが SIGTERM を処理しない場合は、即時終了のために SIGKILL を指定してください。

テスト基盤:エンドツーエンドの検証

tsup のインテグレーションテストは、これらすべての仕組みがどのように連携するかを示す実践的な例です。test/utils.ts のテストハーネスは、一時的なフィクスチャプロジェクトを作成し、事前にビルドされた tsup バイナリをそこに対して実行します。

const bin = path.resolve(__dirname, '../dist/cli-default.js')

export async function run(
  title: string,
  files: { [name: string]: string },
  options: { entry?: string[]; flags?: string[]; env?: Record<string, string> } = {},
) {
  const testDir = path.resolve(cacheDir, filenamify(title))
  // Write fixture files
  await Promise.all(
    Object.keys(files).map(async (name) => {
      const filePath = path.resolve(testDir, name)
      await fsp.mkdir(path.dirname(filePath), { recursive: true })
      return fsp.writeFile(filePath, files[name], 'utf8')
    }),
  )
  // Run tsup
  const processPromise = exec(bin, [...entry, ...(options.flags || [])], {
    nodeOptions: { cwd: testDir, env: { ...process.env, ...options.env } },
  })
  // ...
}

各テストは .cache/<test-name>/ にファイルを書き込み、dist/cli-default.js をサブプロセスとして実行し、出力を検証します。これは真のエンドツーエンドテストであり、CLI 引数のパース、設定ファイルの読み込み、esbuild パイプライン、plugin による変換、ファイル出力まで、すべての処理を通して検証します。

flowchart LR
    A["Test: write fixture files<br/>to .cache/test-name/"] --> B["exec(dist/cli-default.js,<br/>flags, { cwd: testDir })"]
    B --> C["tsup runs in subprocess"]
    C --> D["Read dist/ output files"]
    D --> E["Assert on file contents,<br/>output file list, logs"]

このテスト設計では、テスト実行前に tsup がビルド済みである必要があります。package.json のスクリプトはこれを強制しています:"test": "pnpm run build && pnpm run test-only"。これは意図的な設計です。テストは生のソースではなく、公開されるアーティファクトを検証します。ビルドプロセス自体にバグがあれば、テストがそれを捉えられます。

ヒント: tsup にコントリビュートする際は、pnpm test-only の前に必ず pnpm build を実行しましょう。プロジェクトを再ビルドするまで、テストスイートはソースへの変更を反映しません。

シリーズのまとめ

この 6 本の記事を通じて、tsup のビルドライフサイクル全体を追ってきました。

  1. アーキテクチャ:3 つのエントリーポイントが build() に集約され、JS タスクと DTS タスクを並列にディスパッチする
  2. 設定管理:joycon が設定ファイルを探索し、bundle-require が TypeScript 設定を実行し、normalizeOptions() がすべてを厳密な型に解決する
  3. esbuild パイプライン:自動 externalize、ファイルレベルの変換を担う 6 つの esbuild plugin、インメモリ出力のための write: false
  4. tsup plugin:慎重に順序付けられた 7 つの組み込みポストビルド plugin — shebang → tree-shaking → CJS splitting → CJS interop → SWC target → サイズレポーター → terser
  5. DTS 生成:2 つの戦略 — Worker スレッドで動作する Rollup ベースの --dts と、tsc + API Extractor を使う --experimental-dts
  6. Watch モード:メタファイルトラッキングによるスマートな差分ビルド、デバウンスビルド、クロスプラットフォームなプロセス管理

tsup の設計哲学は「実用的な組み合わせ」です。各処理に最適なツールを使います。esbuild でスピードを、Rollup でツリーシェイキングを、SWC でデコレーターメタデータを、TypeScript で型を担当させます。クリーンなインメモリパイプラインでそれらを橋渡しした結果、デフォルトで高速に動作し必要なときは拡張できるバンドラーが生まれました。