1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Monorepo/Turborepo解説

Last updated at Posted at 2024-12-27

はじめに

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

Monorepo(モノレポ)とは

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

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

完全分離

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
    │         └── ...
    └── ...          # その他設定ファイルなど

無理やり統合

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 のカスタムプラグイン

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

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

Turborepo とは

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

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

  • client/servernpm 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 を指定する
{
  "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 には、他のパッケージから参照されるファイルを指定しています
    • 後述しますが、これはtsconfigmoduleResolutionnodeNextになっているためです。
  • 他のパッケージに依存する場合には必ず、dependencies/devDependenciesに追加します
    • これにより後述するturbo.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.jsonsolidjs.json を作成し、状況に応じたものを拡張して利用しています。

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

moduleResoution: Nodenext

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

  • ローカルファイルの参照は必ず拡張子(.js/.jsx)をつける
import { Button } from "./Button.jsx";
  • 外部から参照する場合は、package.jsonexportsに明示的に出力するファイルをしていする
json|package.json
{
  "exports": {
    ".": "./src/index.ts",
    "./Button": "./src/Button.tsx"
  }
}

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

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

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

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

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

JIT(Just-In-Time)パッケージ

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

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

Compiled パッケージ

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

  • package.jsonmaindist/index.jsを指定する
  • package.jsonscriptsbuildを設定する

私のサンプルだと、@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

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

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

turbo run dev

image.png

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

turbo run @repo/web-server#dev

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

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

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

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

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

image.png

開発方法

npm install

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

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

デプロイ方法(ECS)

例えば ECS にデプロイする際は、以下のような 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 で不要なパッケージを削除する
  • npm run build(またはturbo run build)でindex.jspackage.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.jspackage.runtime.jsonを出力することができます。
非常に便利だと思うので、是非参考にしてみてください。

client vs. server

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

導入してみて

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

使っていない機能

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

参考

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?