Yarn workspaces から Lerna に移行した

Yarn workspaces から Lerna に移行した時の知見です。
やや書きかけ項目です。

# 前提

とあるサービスを提供しており、モノレポで運用している。使用スタックは主に API の Ruby on Rails で GraphQL の API サーバーを構築し、そのフロントエンドを React/ReactNative で作成している。

もともとは別々のリポジトリだったが、提供サービスが増えてくるに従い、下記の問題点が出てきた。

- CI/CD パイプラインを一々作成する必要があり、反映にも手間がかかる
- GraphQL の型定義やユーティリティ、細かいところでは Git の Hook など、再利用したいものがある
- GitHub への招待などが手間になる

そこで、この記事を書く半年前くらいにリポジトリを統合し、モノレポになった。

Rails と それ以外 (TypeScript) でフォルダを分割し、 TypeScript 側は手軽にモノレポをマネジメントできそうな Yarn workspaces を選択した。その時はこれで苦しむことを知らない。

当初のディレクトリ構成は、下記。

```zsh
.
|-- api/                    # Rails API
|-- docker-compose.yml
|-- package.json            # 開発用のパッケージ (prettier など）
|-- packages/               # TypeScript clients
|    |-- app                # アプリ (React Native)
|    |    |-- src/
|    |    `-- package.json
|    |-- console            # 管理画面 (React Native Web)
|    `-- components         # 共有コンポーネント
|-- prettier.config.js
|-- private/                 # AWS のトークンなど、秘匿ファイル。git-crypt で暗号化
|-- tsconfig.json           # 使いまわしている
|-- tslint.json
`-- yarn.lock
```


まとめると、

- サーバーサイドは主に Ruby on Rails
- iOS/Android アプリ 及びそれに付随する Web サービス（管理画面など）を提供している
  - iOS/Android アプリは React Native を使用
  - Web サービスには React Native Web を使用
  - React Native で作成されたコンポーネントを、一部 Web でも使用している

が、上記の構成では限界を感じたため、 Lerna に移行することにした。

# Lerna 移行の動機

React Native で Yarn workspaces は、 Yarn の hoist (巻き上げ）機能により、非常に使いにくいことが分かってきた。

致命的なのは、 `react-native-cli` は、 React Native における `index.js` （ `AppRegistry.registerComponent` する場所） の **相対パス** で `./node_modules/react-native/cli.js` を見に行くため、巻き上げた先のパスを設定する必要がある。

巻き上げられると、 Root の `node_modules` を見に行くため、ビルド時に `../../node_modules/react-native/cli.js` に設定する必要がある。つまり、 Xcode をいじる必要が出てくる。

また、 Root から実行する際は下記のスクリプトが必要になってくる。

```js
// @see https://github.com/facebook/react-native/issues/25822#issuecomment-531009417

process.chdir('./packages/nupp1-fit')

var cli = require('@react-native-community/cli')
cli.run()
```


ここまでは、まだ設定すれば問題無いが、 **場合によって** 巻き上げられないパッケージがあるので、他のパッケージを更新した際に、 React Native がビルドされるかを確認する必要がある。

特に React Native 0.60.0 からの `autolink` 機能は、 Cocoapod でインストールした時に、暗黙的に `./node_modules` 以下を探索するので、`../../node_modules` に巻き上げられると使えなくなってしまう。

では Yarn の `nohoist` 機能を使えばよいではないか、という意見がありそうだが、それもうまくいくとは限らない。調べた限りでは、下記のような動作をする。

- `nohoist` 機能は、動作しないことがある。
    - https://stackoverflow.com/questions/56675874/nohoist-with-workspaces-still-hoisting
    - https://github.com/yarnpkg/yarn/issues/6412
- 何が巻き上げられるかが不明瞭。多分ロジックを追えば分かるが、各ライブラリの依存関係を調べる必要があり、果てしない。
- 同一のパッケージ/バージョンがあると巻き上げられるらしい。
    - バージョンが異なる場合、各パッケージの `node_modules` に保存される。
- キャッシュが効かないことがある → CIの低速化
- Symbolic link (.bin/**) が壊れることがある。 (`semver` など）
    - 多分、多数のパッケージが依存しているパッケージで、バージョンによって `bin` が違う場合、発生する。
- 各パッケージでインストールした後、 Root でインストールすると、依存関係を変更してないのに、再度インストールが走る。

この問題を調査していたところ、 Lerna はデフォルトで巻き上げをしない（正確にはするが、 Root の package.json にある場合のみ）ので、これを使って解決できそうな気がした。

# 実作業・困ったこと

基本的には、こちらのリポジトリを参考にしながら、作業を進めていった。

https://github.com/erickjth/react-native-web-monorepo-lerna


## コマンドが複雑化する

Lerna を入れて、ローカルのパッケージ同士を package.json に記述して依存させると、 `yarn add` 等は **一切できなくなる** 。

また cross-env や webpack-dev-server など、開発時のみ必要になる一部の devDependencies は Root の package.json のみに記述していたので（これは要改善）、各パッケージ配下では実行できない。

毎回 `yarn lerna exec --ignore @my/types --scope @my/package tsc` などするのも面倒だったので、 `Makefile` を作成し、その中に npm scripts に相当するスクリプトを記述することにした。

抜粋するとこんな感じ。

```makefile
SHELL := /bin/bash
LERNA_OPTION := --stream --parallel --no-bail --ignore @my/types --ignore @my/js-project
NODE_BIN := ./node_modules/.bin

export PATH := $(NODE_BIN):$(PATH)

tsc:                                           # Execute `tsc` in each scripts
	lerna exec $(LERNA_OPTION) tsc
```

参考: https://qiita.com/Hoishin/items/0e9b4ebee45e3f8cdc29

## React Native CLI 

React Natvie CLI に Lerna が生成する Symbolic Link を追わせるためには、 `metro.config.js` を少々いじる必要がある。
React Native 0.62.0-RC3 で利用するときは、下記のような設定が必要になる。

`resolver.watchFolders` と `extraNodeModules` の設定がポイント。

参考

- https://github.com/facebook/metro/issues/1#issuecomment-527863738
- https://github.com/facebook/metro/issues/7#issuecomment-356616505


```js
const path = require('path')
const { getDefaultConfig } = require('metro-config')

function getProjectModuleDir(m) {
  return path.resolve(__dirname, `node_modules/${m}`)
}

// To allow importing peerDependency from other packages
const modulesResolvedInProject = [
  '@babel/runtime',
  '@react-native-firebase/app',
  '@react-native-firebase/analytics',
  '@react-native-community/push-notification-ios',
  '@react-native-community/async-storage',
  'react',
  'react-is',
  'react-native',
  'react-native-appsflyer',
  'react-native-device-info',
  'react-native-picker',
  'react-native-keyboard-aware-scroll-view',
  'react-native-google-places-autocomplete',
  'react-native-keyboard-spacer',
  'react-native-linear-gradient',
  'react-native-paper',
  'react-native-svg',
  'react-native-vector-icons',
  'react-redux',
  'react-native-repro',
]

const extraNodeModules = modulesResolvedInProject.reduce((acc, m) => {
  return { ...acc, [m]: path.resolve(__dirname, `node_modules/${m}`) }
}, {})

module.exports = (async () => {
  const {
    resolver: { sourceExts, assetExts },
  } = await getDefaultConfig()
  return {
    transformer: {
      babelTransformerPath: require.resolve('react-native-svg-transformer'),
    },
    watchFolders: [
      // To allow finding files outside mobile
      path.resolve(__dirname, '..'),
    ],
    resolver: {
      assetExts: assetExts.filter(ext => ext !== 'svg'),
      sourceExts: [...sourceExts, 'svg'],
      extraNodeModules,
    },
    maxWorkers: 2,
  }
})()
```

# 総評

## 良かったこと

- きちんと依存関係を各リポジトリの `package.json` に記述しないと、 Symlink が設定されない。
    - Yarn workspaces は何となくでも簡単に動いてしまったので、これは逆に良かった。
- Lerna bootstrap の方が `yarn install` より速い（体感）
- 今後、 Docker Image の作成をする時に、苦労しなそう
    - 依存関係は、各パッケージでちゃんとインストールしないといけないため
- 各パッケージ以下でコマンドを並列実行できるようになった
    - それ以前は、 `wsrun` というものを使っていた

## 手間取ったこと

- セットアップ手順が複雑化。 `yarn install && yarn lerna bootstrap && yarn postinstall`
    - 上記の `Makefile` で記述したため、解決
- CI の書き換えなど