Monorepo/Turborepo解説

# はじめに
最近 Node.js の開発プロジェクトに Monorepo/Turborepoを導入して開発・運用してみたところ、かなりソースコードが整理され、開発効率が上がりました。
開発を進めていく中で迷った点や、自分なりにベストプラクティスを模索したので共有させていただきます。

# Monorepo(モノレポ)とは
Monorepo とは、複数のプロジェクトを 1 つのリポジトリで管理することです。
ある程度規模の大きい開発の場合、Node.js のパッケージを複数準備したくなるシーンが多々あります。

これまでは2つのアプローチをトライしたことがあります

## 完全分離
```bash
polyrepo/
├── client/          # クライアントアプリケーションのリポジトリ
│   ├── client-app/   # クライアントアプリケーションのルート
│   │   ├── package.json
│   │   ├── ...
│   │   └── src/
│   │      └── utils/ # client の utils
│   │         └── ...
│   └── ...          # その他設定ファイルなど
├── server/          # サーバーアプリケーションのリポジトリ
│   ├── server-app/   # サーバーアプリケーションのルート
│   │   ├── package.json
│   │   ├── ...
│   │   └── src/
│   │      └── utils/ # server の utils
│   │         └── ...
│   └── ...          # その他設定ファイルなど
└── batch/          # バッチアプリケーションのリポジトリ
    ├── batch-app/   # バッチアプリケーションのルート
    │   ├── package.json
    │   ├── ...
    │   └── src/
    │      └── utils/ # batch の utils
    │         └── ...
    └── ...          # その他設定ファイルなど
```

## 無理やり統合
```bash
singlepackage/
├── package.json
├── packages/
│   ├── client-app/
│   │   ├── src/
│   │   │   └── utils/
│   │   │       └── ...
│   │   └── ...
│   ├── server-app/
│   │   ├── src/
│   │   │   └── utils/
│   │   │       └── ...
│   │   └── ...
│   └── batch-app/
│       ├── src/
│       │   └── utils/
│       │       └── ...
│       └── ...
├── ...
```

これらの方法のデメリットとしては、
* コードが共有できない（しづらい）
* フォルダの行き来する必要がある
* package.jsonにすべての依存パッケージを記述するので、個別のアプリ（client-app等）が依存しているパッケージを特定することができない

などがあります。
Monorepoは上記二つの中間的なソリューションになります。

## Monorepo(workspace機能)
```
monorepo/
├── apps/
│   ├── client/
│   │   ├── src/
│   │   │   └── ...
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── server/
│   │   ├── src/
│   │   │   └── ...
│   │   ├── package.json
│   │   └── tsconfig.json
│   └── batch/
│       ├── src/
│       │   └── ...
│       ├── package.json
│       └── tsconfig.json
├── packages/
│   └── utils/
│       ├── src/
│       │   └── ... # 全アプリ共通のユーティリティを定義できる
│       ├── package.json
│       └── tsconfig.json
├── package.json
├── tsconfig.json
```

`package.json`はアプリケーションと内部パッケージという単位で作成しますが、ルートディレクトリで`npm install`すればルートディレクトリの`node_modules`に全ての依存パッケージが格納されるので、作業ディレクトリを行き来する必要がないです。
アプリケーションは複数`apps/`配下に作成できますし、アプリケーション間で共通利用したいものは内部パッケージとして`packages/`配下に作成することができます。
※フォルダの構成はプロジェクトに合わせて自由に設定することが可能です

`apps`の例
- client/server: WEBアプリのフロントとバック
- batch: バッチ処理
- admin/dashboard: 管理画面・ダッシュボード
- docs/storybook: ドキュメント

`packages` の例

- utils: 標準的な共通関数
- services: DB 操作等のサービス
- shared: クライアントとサーバーで共有するコード
- ui: UI コンポーネント
- plugin: Vite のカスタムプラグイン

例えば`frontend`と`admin`のアプリケーションで共通で使えるような UI コンポーネントを作成できるなど、小さな単位でパッケージを作成し、それをアプリケーションで利用することができます。

これらのアプリ、内部パッケージそれぞれに対して`package.json`が存在し、依存するパッケージを定義することができます。

# Turborepo とは

`workspace`機能は各種パッケージマネージャー（npm/yarn/pnpm）でサポートされていますが、それらをさらに拡張したライブラリが`Turborepo`です。

https://turbo.build/repo/docs

Turborepoを使うことで以下が実現できます

- `client`/`server`の`npm run dev`をワンコマンドで並列実行
- アプリケーションと内部パッケージの依存関係を定義し、必要に応じて順番にビルドする
- すべてのアプリ・内部パッケージの`test`/`check-types`を一括実行
- ビルド内容のキャッシュし、変更がないパッケージについてはスキップ
- デプロイ先に応じて必要なパッケージのみを抽出（turbo prune）


## 実装例＋解説
ここからは以下に公開した実装例を参考に解説、紹介していきます。
https://github.com/yoshida-valuesccg/turborepo-sample
本記事で使用した環境は以下の通りです：
```
エディタ: Visual Studio Code
npm: v10.9.0
Node.js: v22.12.0
TypeScript: v5.7.2
Turborepo: v2
フレームワーク: （solid-js / tRPC）※あんまり関係なし
デプロイ先: ECS / Lambda
```

## フォルダ構成

まずフォルダ構成は以下の通りです。
基本にならって、`apps/`と `packages/`に分けて管理しています。

```
.
├── apps
│   ├── batch-hello-world
│   │   ├── package.json
│   │   ├── package.runtime.json
│   │   ├── src
│   │   │   └── index.ts
│   │   ├── tsconfig.json
│   │   └── vite.config.ts
│   ├── web-client
│   │   ├── index.html
│   │   ├── package.json
│   │   ├── postcss.config.js
│   │   ├── src
│   │   │   ├── App.tsx
│   │   │   ├── index.tsx
│   │   │   └── trpc.ts
│   │   ├── tailwind.config.ts
│   │   ├── tsconfig.json
│   │   └── vite.config.ts
│   └── web-server
│       ├── package.json
│       ├── package.runtime.json
│       ├── scripts
│       │   └── dev.ts
│       ├── src
│       │   ├── index.ts
│       │   └── trpc.ts
│       ├── tsconfig.json
│       └── vite.config.ts
├── dockerfiles
│   ├── Dockerfile.hello-world
│   └── Dockerfile.web
├── package-lock.json
├── package.json
├── packages
│   ├── env
│   │   ├── env.d.ts
│   │   └── package.json
│   ├── env-plugin
│   │   ├── package.json
│   │   ├── src
│   │   │   └── index.ts
│   │   └── tsconfig.json
│   ├── runtime-package-plugin
│   │   ├── package.json
│   │   ├── src
│   │   │   └── index.ts
│   │   └── tsconfig.json
│   ├── services
│   │   ├── package.json
│   │   ├── src
│   │   │   └── user
│   │   │       └── index.ts
│   │   ├── tsconfig.json
│   │   └── vite.config.ts
│   ├── shared
│   │   ├── package.json
│   │   ├── src
│   │   │   └── user
│   │   │       └── index.ts
│   │   ├── tsconfig.json
│   │   └── vite.config.ts
│   ├── typescript-config
│   │   ├── base.json
│   │   ├── package.json
│   │   └── solidjs.json
│   └── ui
│       ├── package.json
│       ├── src
│       │   └── Button.tsx
│       ├── tsconfig.json
│       └── vite.config.ts
├── scripts
│   ├── build-hello-world.sh
│   └── build-web.sh
└── turbo.json
```

## ルート package.json

ルートの package.json の書き方のポイントは以下です。

- まず全体の名称を決める。ここでは`repo`としています
- `workspaces` には、apps/と packages/を指定します
  - フォルダの構成を変更する場合には内部パッケージを配置するフォルダを指定します
- `dependencies` / `devDependencies`は最小限に抑える
  - `turbo`はマスト
- 利用しているpackageManager を指定する

```json
{
  "name": "repo",
  "version": "1.0.0",
  "packageManager": "npm@10.9.2",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "dev": "turbo run dev",
    "build": "turbo run build",
    "check-types": "turbo run check-types"
  },
  "private": true,
  "devDependencies": {
    "@types/node": "^22.10.2",
    "turbo": "^2.3.3",
    "typescript": "^5.7.2"
  },
  "workspaces": ["apps/**/*", "packages/**/*"],
  "volta": {
    "node": "22.12.0"
  }
}
```

## ワークスペース内のpackage.json

各ワークスペースには `package.json` が必要です。
ポイントは以下です。

- `name` は `@repo/` で始まるようにしています
- `private: true` にしています（内部パッケージなので）
- `type: module` にすることで ES Modules を使えるようにしています
- `exports` には、他のパッケージから参照されるファイルを指定しています
  - 後述しますが、これは`tsconfig`の`moduleResolution`が`nodeNext`になっているためです。
- 他のパッケージに依存する場合には必ず、`dependencies`/`devDependencies`に追加します
  - これにより後述する`turbo.json`で依存関係を解決できます
  - バージョンの指定は"\*"

```json
{
    "name": "@repo/web-server",
    "version": "1.0.0",
    "private": true,
    "type": "module",
    "scripts": {
        "test": "echo \"Error: no test specified\" && exit 1",
        "dev": "tsx ./scripts/dev.ts",
        "build": "vite build",
        "check-types": "tsc --noEmit"
    },
    "exports": {
        ".": "./src/index.ts"
    },
    "dependencies": {
       ...
    },
    "devDependencies": {
        "@repo/env-plugin": "*",
        "@repo/runtime-package-plugin": "*",
        "@repo/typescript-config": "*",
        ...
    }
}

```

## tsconfig.json

各ワークスペースに必ず`tsconfig.json`を設定します。
ただ独立した設定を持たせると管理が煩雑になるので、`packages/typescript-config/`というのを作成しています。
この中で `base.json` と `solidjs.json` を作成し、状況に応じたものを拡張して利用しています。

```json
{
  "extends": "@repo/typescript-config/base.json",
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}
```

### moduleResoution: Nodenext

また`moduleResolution`は`nodeNext` に設定しています。
これにより以下の注意点が必要です。

- ローカルファイルの参照は必ず拡張子（.js/.jsx）をつける

```tsx
import { Button } from "./Button.jsx";
```

- 外部から参照する場合は、`package.json`の`exports`に明示的に出力するファイルをしていする

```json|package.json
{
  "exports": {
    ".": "./src/index.ts",
    "./Button": "./src/Button.tsx"
  }
}
```

これにより、以下のようにインポートが可能になります。

```tsx
import { Button } from "@repo/ui/Button";
```

## 内部パッケージのコンパイル手段

https://turbo.build/repo/docs/core-concepts/internal-packages

内部パッケージはいずれも、アプリケーション（`app/`）または他の内部パッケージ（`packages/`）から参照されることを前提としています。
つまり最終的には、全てのパッケージはアプリケーションに組み込まれることとなります。

内部パッケージを作成する際は、必要に応じて２種類のコンパイル方式を選択できます。

### JIT（Just-In-Time）パッケージ

内部パッケージの中でコンパイルを行わず、`TypeScript`として扱われ、アプリケーションのビルド時にコンパイルを行う方式です。
この方式では、ビルドの必要がないので`package.json`に`build`スクリプトを設定してはいけません。

私のサンプルだと、`@repo/ui`や`@repo/services` は JIT パッケージにあたります。

### Compiled パッケージ

内部パッケージでコンパイルを行い、アプリケーションにはビルド済みの JavaScript ＋型定義ファイルを出力する方法です。
何かしらの事情で参照したいアプリケーションが外部パッケージをコンパイル出来ない場合などに使用します。
この方式を利用する場合は、

- `package.json`の`main`に`dist/index.js`を指定する
- `package.json`の`scripts`に`build`を設定する

私のサンプルだと、`@repo/runtime-package-plugin` は Compiled パッケージにあたります。
このパッケージは、アプリケーションの`vite.config.ts`で参照されているため、ビルド時にコンパイルされている必要があったのでこのようにしました。
他にも何かしらの都合で、内部パッケージの中でビルドしてしまった方が都合の良い場合に使用します。

後述しますが、`turborepo`の設定により、アプリケーションのビルド時に依存する内部パッケージのビルドを事前に実行するようできるので、いずれの方式を選択肢てもさほど手間は変わりません。

### アプリケーション（apps/）のビルド

（これは個人的にそうしているだけではありますが、）
アプリケーションのビルドはサーバーサイドであれ、必ず`vite`を使用しています。
`vite`の設定を工夫し、以下が出力されるようにしています。

- index.js
  - トランスパイル済み
  - 内部パッケージバンドル済み
  - 外部パッケージは未バンドル（node_modules から参照できる状態）
  - 環境変数解決済み（process.env.xxx 置換済み）
- package.runtime.json
  - 実行時に必要なパッケージ（内部パッケージの依存も含む）のみを記載したpackage.json

こうすることで、どんな実行環境であれ上記2ファイルがあれば実行できるようにしています。
Tips の方に記述しましたが、上記を実現するための viteのプラグイン（runtime-package-plugin） を自作しています。

## turbo.json

https://turbo.build/repo/docs/reference/configuration

turborepo を利用することで、アプリケーションの開発環境立ち上げ（dev）やビルド（build）が簡単に行えます。

まず以下を実行することで、全ての内部パッケージの`dev`スクリプトを実行できます。

```
turbo run dev
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272803/3d4e0674-fa15-744a-5bd0-66f91b105f95.png)


特定のアプリケーションのみを実行する場合には、以下のように実行します。

```
turbo run @repo/web-server#dev
```

`turbo.json`を定義することで、各コマンドについて詳細に設定を定義することが可能です。

- `persistent` は、コマンドが終了した後もプロセスを維持するかどうかを指定します
- `cache` は、コマンドの結果をキャッシュするかどうかを指定します
- `dependsOn` は、他のコマンドが実行される前に実行されるコマンドを指定します
- `outputs`は、build コマンド等の出力先の配列を指定します。（こちらの結果がキャッシュされます）

アプリケーションが内部の Compiled パッケージに依存している場合、`dev`スクリプト実行前に内部パッケージのビルドを行う必要があります。
これは`turbo.json`で設定することができます。

```json
{
    "$schema": "https://turbo.build/schema.json",
    "ui": "tui",
    "tasks": {
        "dev": {
            "persistent": true,
            "cache": false,
            "dependsOn": ["^build"]
        },
        ...
    }
}
```

上記のようにすることで、`dev`コマンドを実行する前には、**そのパッケージが依存している内部パッケージ**の`build`スクリプトを実行するようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272803/1d38daaf-d6eb-aab9-0040-33f636526e39.png)

## 開発方法

### npm install

アプリケーション・内部パッケージで新しくパッケージを追加したい場合は、npm の workspace 機能を利用して追加します。

```bash
npm install -S dayjs -w @repo/web-server
```

## デプロイ方法（ECS）

例えば ECS にデプロイする際は、以下のような Dockerfile からビルドすることが可能です。

```Dockerfile
FROM node:22 AS builder

WORKDIR /usr/app

COPY . .

RUN npm i -g turbo
RUN turbo prune @repo/client @repo/server
RUN cp ./.env.production ./out

WORKDIR /usr/app/out

RUN npm i
RUN npm run build

FROM node:22 AS runtime

WORKDIR /usr/app

ENV TZ=Asia/Tokyo

EXPOSE 80

RUN mkdir ./public
COPY --from=builder /usr/app/out/apps/web-client/dist/ ./public/

COPY --from=builder /usr/app/out/apps/web-server/dist/ ./
COPY --from=builder /usr/app/out/apps/web-server/package.runtime.json ./package.json

RUN npm i --omit=dev

CMD ["node", "index.js"]
```

ポイントとしては、

- `turbo prune` で不要なパッケージを削除する
  - https://turbo.build/repo/docs/reference/prune
- `npm run build`(または`turbo run build`)で`index.js`と`package.runtime.json`を出力する
- runtime の方で `npm install`し、エントリーポイント（`node index.js`）を設定する

turbo.json で依存関係を解決しているため、`turbo prune`で不要なパッケージを削除することができ、ビルドのコマンドも`npm run build`一文で実行できます。
（内部的には、`@repo/env-plugin`などの内部パッケージのビルドも行われています。）

# Tips

## vite-runtime-package-plugin

デプロイをシンプルにするため、`vite`で内部パッケージの依存ライブラリも`external`として扱い、実行に必要なパッケージのみをまとめて出力するプラグインを作成しました。
これにより、内部パッケージだけがバンドルされ、外部モジュールは`node_modules`から参照されるようになった`index.js`と`package.runtime.json`を出力することができます。
非常に便利だと思うので、是非参考にしてみてください。

https://github.com/yoshida-valuesccg/turborepo-sample/blob/master/packages/runtime-package-plugin/src/index.ts

## client vs. server

注意点として、うっかりサーバーサイドのみで使われるパッケージをクライアント側のアプリケーションから参照してしまうと、ビルド時にエラーが発生します。
内部パッケージの中でも、サーバーサイドのみで使われるパッケージ、クライアントサイドでも利用可能なパッケージを分けて管理することが重要です。
私のサンプルでは、`@repo/services`はサーバーサイドのみで利用されるパッケージ、`@repo/shared`はクライアントサイドでも利用可能なパッケージとしています。

# 導入してみて

- どう管理するのがべスプラなのか、模索するのに時間がかかった
- マイクロサービスを分離しつつ、依存関係も明確に出来るので効率よくきれいに開発が進められるようにはなった
- `node_modules`がひとつに統合されているので、どこかの`package.json`の`dependencies`に記述されていれば全体にインストールされてまう
    - それによって`package.json`の記述漏れがあっても発見しにくい
    - 全体でライブラリのバージョンを統一しないといけない

## 使っていない機能
* `turborepo`はクラウドキャッシュにも対応しており、ビルドなどのさらなる高速化が期待できるらしいです
    * ビルド速度がそこまで気にならなかったので未検証

# 参考

* https://docs.npmjs.com/cli/v11/using-npm/workspaces
* https://turbo.build/
* https://blog.cloudsmith.co.jp/2023/06/374/