みどりのさるのエンジニア

ElectronアプリをMonorepo構成で構築

2023年01月06日

全体のコードやディレクトリ構成はhigeOhige/review-catを参照ください。

動機

久しぶりにリポジトリを見返した時にビルドの仕組みを思い出すのに思ったよりも時間がかかってしまったので、もう少しリポジトリのビルド管理を楽にしたいと思い解決策とモノリポ構成を試してみました。

変更前の問題点

変更前は次のようなディレクトリ構成になっており、メインプロセスとレンダラープロセスのコードをそれぞれelectron, renderer ディレクトリで管理している状態になっていました。

electronディレクトリのコードはesbuildでビルドをしており、rendererディレクトリのコードはviteでビルドをしているため、異なるビルド構成のプロジェクトが一つのルートで複数管理されている状態になっているため、非常に複雑な状態になっていました。

.
├── babel.config.js
├── build.js
├── electron
│   ├── app.ts
│   ├── assets
│   ├── preload.ts
│   └── src
├── esbuild.js
├── jest.config.js
├── package.json
├── renderer
│   ├── assets
│   ├── index.html
│   ├── public
│   └── src
├── tsconfig.electron.json
├── tsconfig.json
├── vite.config.ts
└── yarn.lock

また、electron/app.tsでHTMLファイルを参照する箇所のコードではビルドされたディレクトリ構成に依存する形でパスの指定がされているため、ビルドの出力先のディレクトリ構成が変更されるとアプリが正常に動作しなくなる危険性も存在しました。

// electron/app.ts
const indexUrl = isDevelopment
  ? 'http://localhost:3000/'
  : `file://${path.resolve(__dirname, './index.html')}`; // ビルドされたdistディレクトリ内のディレクトリ構成に強く依存

実際にビルドスクリプトを見るとdistディレクトリにビルドした成果物をマージするようにビルド構成が作られており、このビルドの仕組みと上記のコードが強く依存している状態になっています。

{
  "scripts": {
    "build": "yarn clean && yarn vite:build && yarn electron:build",
    "vite:build": "tsc && vite build",
    "electron:build": "node esbuild.js && yarn electron:copy && node build.js",
    "electron:copy": "cpx 'electron/assets/images/**' dist/assets/images",
  }
}

Monorepo構成に変更

packagesディレクトリを新たに作成して、その配下にメインプロセスのパッケージとしてmainディレクトリをレンダラープロセスのパッケージとしてwebディレクトリを新たに作成しました。

.
├── README.md
├── package.json
├── packages
│   ├── main
│   │   ├── assets
│   │   ├── build.js
│   │   ├── esbuild.js
│   │   ├── jest.config.js
│   │   ├── package.json
│   │   ├── src
│   │   └── tsconfig.json
│   └── web
│       ├── index.html
│       ├── jest.config.js
│       ├── package.json
│       ├── public
│       ├── src
│       ├── tsconfig.json
│       └── vite.config.ts
└── yarn.lock

モノリポ構成はYarn Workspacesで構築するために、package.jsonにworkspacesを新たに追加しています。

{
  "private": true,
  "workspaces": ["packages/*"]
}

レンダラープロセスのビルド

レンダラープロセスについてはvite.config.tsなどレンダラーだけに必要なファイル群をそのままwebディレクトリに移動しただけなので特別な変更は特にありませんが、Monorepo構成でパッケージとして定義するためにpackage.jsonのname属性にwebを指定している点と独立してビルドを考えられるのでnpmスクリプトの記述をスッキリさせています。

{
  "name": "web",
  "scripts": {
    "dev": "yarn vite",
    "build": "yarn clean && vite build",
    "clean": "rimraf dist"
  }
}

メインプロセスのビルド

レンダラープロセスのHTMLファイルの参照を外部パッケージ化したwebパッケージのHTMLファイルを参照するように変更しました。具体的にはrequire.resolveで動的にパスを指定するように変更しています。これにより上記で挙げていたHTMLファイルの参照がビルドされた成果物のディレクトリ構成に強く依存する問題を改善しています。

const indexUrl = isDevelopment
   ? 'http://localhost:3000/'
   : `file://${require.resolve('web/dist/index.html')}`;

package.jsonにはwebパッケージへの依存を新たに追記しています。

{
  "dependencies": {
    "web": "*"
  }
}

ビルドの依存関係を把握する

今回のMonorepo構成のビルドの依存関係を把握するために、electron-builderで生成されたアプリが最終的にどのようにパッケージングされているか中身を覗いてみます。

最初にElectronアプリはビルドされたリソースをasar形式でパッケージングするために、asarを解凍するためのコマンドをインストールします。

$ npm install -g asar
$ asar -V
v3.2.0

生成されたアプリにパッケージングされたasarファイルを解凍します。

$ asar extract app/mac-arm64/ReviewCat.app/Contents/Resources/app.asar extracted

中身は次のようになっており、メインプロセスでビルドされたapp.jspreload.jsdistディレクトリに配置されており、レンダラープロセスのwebパッケージがnode_modules/webに配置されていることが分かります。

extracted/
├── dist
│   ├── app.js
│   └── preload.js
├── node_modules
│   └── web
└── package.json

このようにレンダラープロセスを外部パッケージ化してrequire.resolvenode_modules/web/dist/index.htmlのHTMLファイルをメインプロセスが参照することで、ビルド後のディレクトリ構成に依存せずにHTMLファイルを参照できるようになりました。

これで、Monorepo構成でメインプロセスとレンダラープロセスを独立してパッケージとして管理することでビルドの依存関係が非常にシンプルになったのがイメージできました。

tsconfigをパッケージとして管理

Monorepoでパッケージを独立させたことで、tsconfig.jsonが各パッケージで重複して管理されるになってしまいました。Turborepoのexamples/basicを参考にして、次のようにtsconfigを管理するパッケージを新たに追加して共通化しました。

.
├── README.md
├── package.json
├── packages
│   ├── main
│   │   └── tsconfig.json
│   ├── tsconfig
│   │   ├── base.json
│   │   └── package.json
│   └── web
│       └── tsconfig.json
└── yarn.lock

packages/tsconfig/base.jsonに共通の設定を記述して、packages/mainではこのパッケージで管理している定義を継承するようにtsconfig.jsonを記述しています。

// packages/main/package.json
{
  "devDependencies": {
    "tsconfig": "*"
  }
}
// packages/main/tsconfig.json
{
  "extends": "tsconfig/base.json"
}

ESLintの設定をパッケージとして管理

ESLintも利用するプラグインや設定ファイルを一つのパッケージで管理します。

.
├── README.md
├── package.json
├── packages
│   ├── eslint-config-custom
│   │   ├── index.js
│   │   └── package.json
│   ├── main
│   │   └── .eslintrc
│   └── web
│       └── .eslintrc
└── yarn.lock

tsconfigと同様にpackages/eslint-config-custom/index.jsに共通の設定を記述して、packages/mainではこのパッケージで管理している定義を.eslintrcで継承するようにしています。

// packages/main/package.json
{
  "devDependencies": {
    "eslint-config-custom": "*"
  }
}
// packages/main/.eslintrc
{
  "extends": ["custom"]
}

全体のビルドとアプリのパッケージング

最後に全体としてのビルド構成です。プロジェクトルートのpackage.jsonで各パッケージのビルドを管理するnpmスクリプトを記述しました。

ビルドについてはyarn workspaces run buildだとpackages/tsconfigなどのパッケージでビルドスクリプトが存在せずにエラーになるため、各ワークスペースを指定する形になっています。
また、コードのビルド自体は独立しているためxxx & yyyみたいな形式で並列ビルドしても問題無いのですが、何故か正常にプロセスが終了しない問題に遭遇したので、直列でビルドする形になっています。ここら辺はTurborepoを導入していい感じにしたいです。

{
  "scripts": {
    "build": "yarn workspace web build && yarn workspace main build",
    "package": "yarn workspace main package",
  }
}

Codecovへカバレッジレポートを送信

Monorepo構成にしたことで自動テストのカバレッジが複数のディレクトリで管理されるようになったので、ここも対応をしておく必要がありました。CodecovではFlags機能を使うことで一つのプロジェクトで複数のカバレッジをまとめて管理できます。

GitHub Actionsのワークフローを次のように新しく定義しました。この書き方が最適な書き方かはかなり怪しいです。

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      # (省略)
      - run: yarn test:coverage
      - name: Upload main coverage to Codecov
        uses: codecov/codecov-action@v3
        with:
          token: ${{ secrets.CODECOV_TOKEN }}
          flags: main
          directory: packages/main
      - name: Upload web coverage to Codecov
        uses: codecov/codecov-action@v3
        with:
          token: ${{ secrets.CODECOV_TOKEN }}
          flags: web
          directory: packages/web

Monorepo構成に変更してみて

目的としていたメインプロセスとレンダラープロセスのビルドを独立して管理できるようになったので、ElectronアプリとMonorepo構成はかなり相性が良いのではないかなと思っています。