既存のNext.jsプロジェクトをTurborepoに移行する方法

## はじめに

https://turbo.build/

Next.jsを使った案件に以前関わっていました。そのプロジェクトは、Next.jsでフロントエンドを構築しつつも、Next.js内のあるReactコンポーネントを利用したNode.jsを実行するスクリプトがありました。Node.jsを実行するスクリプトは、Next.jsで動かすフロントエンドとは別に、Lambdaで動かすだけのスクリプトで、しかしあるReactコンポーネントを使いたいがために、Next.js内にディレクトリを作って管理していました。
（ここではNext.jsを使ったフロントエンドを**Web側**、Node.jsのスクリプトを**Node側**と表現することにします。）

これの何が嫌かというと、
* Web側とNode側でコンテナを分けていても、両方の不要なコードが入ってくる
* 片方にだけパッケージをインストールしたいみたいなことができない

この2点が嫌でした。無駄なコードが増えて、容量も両方とも大きくなっていました。Node側で欲しいコードはWeb側のコードのほんの一部なのにも関わらず、結局すべてをNode側のコンテナにデプロイしていました。必要なパッケージもほんの一部だけなのに、Web側に必要なパッケージも全部Node側にインストールされていました。

そこで、[Turborepo](https://turbo.build/)を使用して、このひとまとまりになっているコードを整理して、モノレポ化することにしました。

この記事ではTurborepo移行でつまづいた点をいくつか書いていきます。同じくTurborepo移行しようとしている人に役立てばなと思います。

## Turborepoのコードをよく観察する
Turborepoなんて使ったことがなかったので、とにかくドキュメントや記事を参考に、調べまくりました。最初は、そのプロジェクト上にTurborepoをインストールしてプロジェクトを再編成しましたが、よくわからなくなったため、別のところにTurborepoのプロジェクトを立てて、どうやるのかよく観察しました。
```
npx create-turbo@latest
```
を実行して、Next.jsのプロジェクトを観察し続けました。
```
├── README.md
├── apps
│   ├── docs
│   └── web
├── package.json
├── packages
│   ├── eslint-config
│   ├── typescript-config
│   └── ui
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
└── turbo.json
```
こちらが、Turborepoの初期のディレクトリ構成です。これだったら、Web側のコードとNode側のコードをapps内でディレクトリ分けして、共通するコンポーネントやメソッドをpackagesにまとめれば良さそうに思いました。実際に、大半の処理はそれで済みました。`turbo.json`や`package.json`の中身も、見よう見まねで書きました。

## 型情報共有に苦戦
packagesの中の共通コンポーネントの型をどう他のところと共有したらいいのか、とても苦労しました。最初は`tsc`や`tsup`を利用してtypescriptのコードをビルドしていました。しかし、このやり方だと変更があるたびにビルドを行わなければなりません。
しかし、ちゃんと対処方法が公式のドキュメントに書いてありました。

https://turbo.build/repo/docs/guides/tools/typescript

なるほど、わざわざビルドを行わなくても、package.jsonの中のexportsに書けばいいみたいです。
以下は`packages/ui`内のpackage.jsonです。
```package.json
{
   "scripts": {
    "build": "tsc",
    "dev": "tsc --watch"
  },
  "exports": {
    "./button": {
      "types": "./src/button.ts"
      "default": "./dist/button.js",
    },
    "./input": {
      "types": "./src/input.ts"
      "default": "./dist/input.js",
    }
  }
}
```
こうすれば、型情報やモジュールも共有できるようになりました。特に他には追加設定がないです。
なんだ、簡単でしたね。devでwatchしておけば、ホットリロードも効くので開発中にすぐに変更が反映されます。

## ビルドに苦労
Web側はこれで、特に問題なく動かすことができました。しかし、Node側はさらに色々と苦労しました。Node.jsなので、
* TypeScriptをちゃんとコンパイルしないと実行できない
* ちゃんとpackage.jsonやtsconfigを設定しないとcommon jsになってしまいes moduleで記述できない

という点がずっと開発中悩まされていました。ちゃんと設定できれば、そんなに難しいことでもなかったです。すべてのtsconfigで共通で使うものに
```tsconfig.json
 {
    "target": "ESNext",
    "moduleResolution": "Bundler"
 }
 ```
この2箇所をこのように設定してあげると、両方でちゃんと動くようになりました。

## ビルド時に拡張子をつける
TypeScriptのimport文では`.ts`の拡張子は省略できましたが、ES Moduleのimport文では省略できません。さらに、TypeScriptでは、例えば`Button/index.tsx`みたいなディレクトリ構成になっているとき、
```tsx
import Button from "Button"
```
と、`index`を省略できましたが、ES Moduleの場合省略できません。この2つをクリアするために自分がやったことは
* `index`は省略しない `from "Button/index"`と書く
* ビルド時に、`.js`の拡張子をつける
です。拡張子をつけるのは、それようのscriptを書きました。
```addJsExtention.js
const fs = require('fs')
const path = require('path')

const targetDir = './dist'
const targetExt = '.js'

/**
 * .js拡張子付与スクリプト
 * @param {*} filePath
 */
function addJsExtension(filePath) {
  const data = fs.readFileSync(filePath, 'utf8')
  const result = data.replace(
    /(from\s+['"])(\..*?)(['"])/g,
    (match, p1, p2, p3) => {
      if (p2.endsWith(targetExt)) {
        return match
      }
      const newImport = `${p1}${p2}${targetExt}${p3}`
      return newImport
    },
  )
  fs.writeFileSync(filePath, result, 'utf8')
}

function processDir(dir) {
  fs.readdirSync(dir).forEach((file) => {
    const filePath = path.join(dir, file)
    if (fs.lstatSync(filePath).isDirectory()) {
      processDir(filePath)
    } else if (filePath.endsWith('.js')) {
      addJsExtension(filePath)
    }
  })
}

processDir(targetDir)
```
これは自分で思いついたスクリプトでなく、GitHub Copilotに書かせたものです。これをビルド時実行するようにします。
```package.json
{
  "script": {
     "build": "tsc && node addJsExtention.cjs"
    }
}
```
これで、Node.jsで実行した際に問題なくスクリプトを実行できました。`bun`など、typescriptのコードをそのまま実行できるようなものであれば、こういった処理は不要です。まだ`bun`で書いたスクリプトをLambdaで本番運用するには怖かったのでやめました。

## Dockerでビルドする
こちらにも具体的にやり方が書いてあります。これをほぼコピペでうまくいきます。しかし、それぞれのコードが何を実行しているのか、ある程度は知っておきましょう。

https://turbo.build/repo/docs/guides/tools/docker

Web側とNode側をそれぞれDockerでビルドしてデプロイしたいときにも、そこまで難しくはありません。
まず、Next.jsはstandaloneモードでoutputします。
```next.config.js
{
   output: 'standalone'
}
```
standaloneモードに関しては、こちらの記事が詳しいです。

https://zenn.dev/team_zenn/articles/nextjs-standalone-mode-cloudrun

これで、Dockerで無事にDeployすることができます。

## まとめ
既存のNext.jsプロジェクトをTurborepoに移行する際には
* Turborepoのサンプルをよく観察する。ドキュメントを読み込む
* 何を分けるか、共通するものは何かを整理しておき、ディレクトリを移行する
* tsconfigやpackage.jsonを適切に設定する

これらが肝になってきます。移行作業をやっていて感じたのは、Turborepoはドキュメントがかなり色々と書いてあるので、困ったらドキュメントを見に行くことが大切です。それでも上手い感じの情報がなければ、自分でスクリプトを書いたりして対処するのも手です。
モノレポ化して、プロジェクトがとても見通しよくなりました。Turborepoも移行作業がそこまで難しくないので、ぜひモノレポ化検討の際はTurborepoを使ってみてください。