記事一覧に戻る
pnpm devとの戦いの歴史
- モノレポ開発環境の試行錯誤

pnpm devとの戦いの歴史 - モノレポ開発環境の試行錯誤

Turborepo + pnpmモノレポで快適な開発体験を実現するまでの6つのバージョンと、それぞれで直面した問題と解決策を振り返る。


モノレポで開発していると、「pnpm dev一発で全部動く」という理想と現実のギャップに苦しむことがあります。

このプロジェクトでも、快適な開発体験を実現するまでに6回の大きな変更がありました。それぞれで直面した問題と、どう解決したかを振り返ってみます。

構成のおさらい

まず、プロジェクトの構成を簡単に説明します:

papercal/
├── apps/
│   ├── api/          # Hono API
│   └── web/          # React SPA (Vite)
├── packages/
│   ├── domain/       # エンティティ、値オブジェクト
│   ├── usecase/      # ビジネスロジック
│   ├── infrastructure/  # DB実装
│   ├── routes/       # API定義 → openapi.json生成
│   └── client/       # orvalで生成したAPIクライアント
└── turbo.json

依存関係は domain ← usecase ← infrastructure/routes ← apps という一方向です。

ビルドツール

  • API(apps/api)Honoフレームワーク。開発時はtsxでTypeScriptを直接実行
  • Web(apps/web)ViteでReact SPAをビルド。開発時はVite Dev Serverが高速なHMRを提供
  • packages/*(共有パッケージ)tsup(内部でesbuildを使用)でTypeScriptをバンドル。型定義はtscで生成

Turborepoの役割

Turborepoはモノレポのタスク実行を管理するツールです。turbo.jsonで各タスクの依存関係を定義すると、依存するパッケージのタスクを先に実行してくれます。

// turbo.json(初期)
{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}

^build^は「依存パッケージのbuildを先に実行する」という意味です。例えば@papercal/api@papercal/usecaseに依存している場合、pnpm build --filter=@papercal/apiを実行すると:

  1. @papercal/domainをbuild(usecaseの依存)
  2. @papercal/usecaseをbuild
  3. @papercal/apiをbuild

という順序で実行されます。さらにTurborepoはビルド結果をキャッシュするので、変更のないパッケージは再ビルドされません。

v1: シンプルなsrc/index.ts直接参照

やったこと:各パッケージのpackage.jsonmaintypessrc/index.tsに向ける。ビルドなしで動く理想的な構成。

{
  "main": "src/index.ts",
  "types": "src/index.ts"
}

この時点ではturbo.jsondevタスクにはビルド依存がなく、シンプルに各アプリを起動するだけでした。

良かった点

  • ビルド不要で即座に変更が反映される
  • Go to Definitionでソースに直接ジャンプできる

問題:drizzle-kitがESMのみのパッケージを解釈できなかった。drizzle-kitは内部でCommonJS環境として動作するため、ESMで書かれたパッケージを直接importできず、マイグレーション生成が動かなくなった。

これはdrizzle-kit側の既知の問題で、drizzle-team/drizzle-orm#2853などで報告されている。

v2: dist/index.jsをexportで指定

やったこと:tsupでビルドしてdist/index.jsをexportするように変更。

{
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "types": "./dist/index.d.ts"
    }
  }
}

良かった点

  • drizzle-kitが動くようになった
  • CJS/ESM両対応の標準的な構成

問題

  1. pnpm devでpackages側のコードを変更しても、appsに反映されない。毎回pnpm buildし直す必要があった
  2. Go to Definitionがトランスパイル後のdist/index.jsに飛んでしまい、実際のソースコードを見れない

開発体験が著しく悪化。

v3: declarationMapでGo to Definitionを修正

やったこと:tsupのdts生成をやめて、tsc --emitDeclarationOnlytsc-aliasを使うように変更。declarationMap: true.d.ts.mapファイルを生成。

{
  "scripts": {
    "build": "pnpm generate && tsup && tsc --emitDeclarationOnly && tsc-alias"
  }
}
// tsconfig.json
{
  "compilerOptions": {
    "declaration": true,
    "declarationMap": true
  }
}

良かった点

  • Go to Definitionでdist/*.d.tsからsrc/*.tsにジャンプできるようになった
  • VSCodeでの開発体験が復活

残った問題:packagesの変更がdev時に自動反映されない問題は未解決。

v4: nodemonで並列watch

やったこと:API側にnodemonを導入し、packagesのソース変更を検知して自動リロードするように。

// apps/api/nodemon.json
{
  "watch": [
    "src",
    "../../packages/*/src"
  ],
  "ext": "ts",
  "exec": "turbo build --filter=@papercal/api^... && tsx src/index.ts"
}

各パッケージにもdevスクリプトを追加:

{
  "scripts": {
    "dev": "tsup --watch"
  }
}

良かった点

  • API側はpackagesの変更で自動リロードされるようになった
  • turboのキャッシュにより、変更していないパッケージは再ビルドされない

残った問題

  • Web側(Vite)ではまだうまく動かない
  • 初回pnpm dev時にdistがない状態でエラーになることがある

Web側の複雑さ:コード生成チェーン

ここでWeb側がAPIより難しい理由を説明しておきます。

API側は単純で、packagesをimportして使うだけです。packagesが変更されたら再ビルドして再起動すれば動きます。

一方、Web側にはコード生成のチェーンがあります:

routes変更 → openapi.json生成 → orvalでクライアントコード生成 → Webで使用

具体的には:

  1. @papercal/routesでAPIエンドポイントを変更
  2. routes#generateタスクでopenapi.jsonを生成
  3. @papercal/clientgenerateタスクでorvalがOpenAPIからReact QueryフックとZodスキーマを自動生成
  4. @papercal/clientをtsupでバンドル
  5. @papercal/webがそのクライアントをimport

この5段階のパイプラインがdev時にも動く必要があります。しかも、それぞれが前の段階の完了を待ってから実行されないといけません。

v5: dev起動前にbuildを実行

やったことturbo.jsondevタスクが依存パッケージのbuildに依存するように変更。tsupのclean: false設定で、watchモードでdistを削除しないように。

// turbo.json
{
  "tasks": {
    "dev": {
      "dependsOn": ["^build"],  // 依存パッケージのbuildを先に実行
      "cache": false,
      "persistent": true
    }
  }
}

^buildにより、pnpm devを実行すると:

  1. まず依存パッケージ(domain → usecase → …)が順番にbuildされる
  2. 全てのdistが揃った状態でdev(watch)モードが起動
// tsup.config.ts
export default defineConfig({
  clean: false,  // devモードでdist/を削除しない
  // ...
});

clean: falseは重要で、これがないとtsupがwatchモードで再ビルドする際にdist/を一度削除してしまう。削除中に他のパッケージがimportしようとするとエラーになる。

良かった点

  • 初回pnpm devでも確実にdistがある状態で起動
  • API側は安定して動作するようになった

問題:Web側で謎のエラーが発生。createCalendarBodyClassNameMaxという定数が、なぜかcreateCalendarBodyClassNameMaxOneという名前で参照される。画面が白くなって止まってしまう。

v6: orvalとtsupの競合を解消

v5でAPI側は安定したものの、Web側では依然として問題が残っていました。前述のコード生成チェーンが正しく動いていなかったのです。

問題の原因@papercal/clientパッケージで、orval(OpenAPIからクライアントコード生成)とtsup(バンドル)が並列実行されていた。

// 問題のあったスクリプト
{
  "dev": "concurrently \"tsup --watch\" \"orval --watch\""
}

一見効率的に見えるこの構成が問題でした。

orvalがコードを生成している最中にtsupがビルドを開始すると、不完全なファイルを読み込んでしまう。esbuild(tsupの内部)は変数名の衝突を避けるためにリネームを行うが、中途半端な状態でビルドされると、生成されるはずだった変数名と異なる名前になってしまう。

例えば、orvalがcreateCalendarBodyClassNameMaxという定数を生成する途中でtsupがビルドすると、esbuildが「同名の変数がある」と誤認識してcreateCalendarBodyClassNameMaxOneにリネームしてしまう。

解決策1:カスタムwatchスクリプト

concurrentlyでの並列実行をやめて、Node.jsのカスタムスクリプトで順次実行するように変更しました:

// scripts/dev-watch.mjs
async function build() {
  console.log("[dev-watch] Running orval...");
  await runCommand("pnpm", ["generate"]);  // 完了を待つ

  console.log("[dev-watch] Running tsup...");
  await runCommand("pnpm", ["exec", "tsup"]);  // その後にビルド
}

// openapi.jsonの変更を監視
watch(openapiPath, (eventType) => {
  if (eventType === "change") {
    build();  // 変更があったら順次実行
  }
});

これにより、openapi.jsonが更新されたときに:

  1. まずorvalがクライアントコードを完全に生成
  2. 生成完了後にtsupがバンドル

という順序が保証されます。

解決策2:ViteでのHMR問題対策

もう一つの問題がありました。@papercal/clientのdistが更新されたとき、ViteはHMR(Hot Module Replacement)でモジュールを差し替えようとしますが、React Queryのフックは内部で状態を持っているため、モジュールが差し替わると不整合が起きてエラーになります。

そこで、Viteのカスタムプラグインで@papercal/clientの変更時はHMRではなくフルリロードを強制するようにしました:

// vite.config.ts
function papercalClientReload(): Plugin {
  return {
    name: "papercal-client-reload",
    configureServer(server) {
      const clientDistPath = resolve(__dirname, "../../packages/client/dist");
      server.watcher.on("change", (file) => {
        if (file.startsWith(clientDistPath)) {
          server.ws.send({ type: "full-reload" });
        }
      });
    },
  };
}

フルリロードはHMRより遅いですが、確実に動作します。APIの変更頻度を考えると許容範囲でした。

学んだこと

1. 並列実行の罠

「速くなるから並列化しよう」は危険。ファイル生成→読み込みの依存関係がある場合、順次実行が必要。

2. ビルドツールチェーンの理解

tsup、esbuild、Vite、それぞれがどのタイミングで何をするか理解していないと、謎のエラーに苦しむ。

3. HMRの限界

React Queryのフックなど、モジュールの参照を内部で保持しているライブラリは、HMRでモジュールが差し替わると不整合が起きる。外部パッケージの変更はフルリロードが安全。

4. 段階的な改善

一気に完璧な構成を目指すのではなく、問題が起きたら直す。6回の変更を経て、ようやく安定した開発環境になった。

補足:なぜsrc/index.ts直接参照に戻せないのか

v1で断念した「src/index.ts直接参照」について、後から検証してみました。drizzle-kitの問題は最新版(0.31.8)ではtsx経由で実行すれば回避できることがわかりました。しかし、仮にdrizzle-kitの問題を解決しても、src直接参照には戻せない別の根本的な理由がありました。

パスエイリアスの壁

このプロジェクトでは、各パッケージ内で@/というパスエイリアスを使っています:

// packages/domain/src/index.ts
export * from "@/entities";
export * from "@/values";
export * from "@/errors";
// packages/domain/tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

これが問題になります。

TypeScriptのモジュール解決の限界

apps/apiから@papercal/domainをimportするとき、TypeScriptは以下のように動作します:

  1. @papercal/domainpackage.jsonを見る
  2. exportsフィールドからsrc/index.tsを発見
  3. src/index.tsを読み込む
  4. export * from "@/entities"を見つける
  5. @/entitiesを解決しようとするが、domainのtsconfig.jsonは参照されない
  6. エラー:Cannot find module '@/entities'

TypeScriptのmoduleResolution: "bundler"package.jsonexportsを解決してくれますが、参照先パッケージのtsconfig.jsonのpathsまでは見てくれないのです。

tsxも同じ問題を抱えている

開発時に使っているtsxも同様です。tsxは実行元のディレクトリのtsconfig.jsonを参照しますが、importした先のパッケージのtsconfig.jsonは見ません。

# apps/apiから実行
pnpm exec tsx src/index.ts
# → @papercal/domainをimport
# → domainのsrc/index.tsを読む
# → "@/entities"が解決できずエラー

解決策はあるのか

いくつかの選択肢があります:

  1. パスエイリアスを廃止して相対パスに書き換える

    • 全パッケージで@/entities./entitiesに変更
    • 大規模な変更になるが、最もシンプルな解決策
  2. TypeScript Project Referencesを使う

    • 全パッケージのtsconfig.jsonにcomposite: trueを設定
    • パッケージ間の依存関係をreferencesで明示的に定義
    • 正統派だが設定が複雑
  3. 現状維持(distを参照)

    • tsupでビルドしたdist/を参照する現在の方式を維持
    • パスエイリアスはビルド時にtsc-aliasで解決済み

現時点では3を選択しています。Turborepoのキャッシュにより、変更のないパッケージは再ビルドされないので、実際のビルド時間は最小限に抑えられています。

現在の状態

今はこんな感じで動いています:

pnpm dev
# → 全パッケージがwatch状態で起動
# → packagesを変更するとAPI/Webが自動リロード
# → openapi.jsonが更新されるとclientが再生成→Webがフルリロード

それでも起動に失敗するとき

正直に言うと、これだけやっても稀にpnpm devが起動に失敗することがあります。特にorigin/mainをマージした後など、依存関係が大きく変わったタイミングで発生しがちです。

そんなときは潔くクリーンインストールしています:

rm -rf node_modules apps/*/node_modules packages/*/node_modules
pnpm install
pnpm build
pnpm dev

pnpmのnode_modulesにはsymlinkしか入っていないので、削除しても再インストールは速いです。実体は.pnpm-storeにキャッシュされているので、ネットワークアクセスもほぼ発生しません。

稀にしか起きない問題に対して完璧な解決策を追求するより、「困ったらクリーンインストール」という運用でカバーする方が現実的でした。

おわりに

完璧とは言えないけど、ストレスなく開発できる状態にはなりました。モノレポの開発環境構築は地道な試行錯誤の積み重ねですね。