みかづきブログ・カスタム

基本的にはちょちょいのほいです。

Yarn Workspacesを使って複数のNext.js + Electronプロジェクトを管理する 📁

これまでは、Yarn Workspacesの基本的な使い方Yarn Workspacesで複数のExpoプロジェクトを管理する方法 を記事にしてきました。

blog.kimizuka.org
blog.kimizuka.org

今回は、Yarn Workspacesを使って複数のNext.js + Electronプロジェクトを管理します。

リポジトリ

github.com

今回のソースコード一式はこちらの リポジトリ にまるまるアップしています。

Next.js + Electronのプロジェクトの始めかた

github.com

こちらの example を使えば楽々です。
ターミナルで、

yarn create next-app --example with-electron-typescript アプリ名

を実行するだけで、Next.js + Electron + TypeScriptのプロジェクトが作成できます。

Yarn WorkspacesでNext.js + Electronを管理する

しかし、Next.js + Electronを単純にYarn Workspacesで管理するとうまく実行できません。
「monorepo-next-electron」ディレクトリ配下で「next-electron-a」と「next-electron-b」を管理する前提で、順を追ってセットアップしていきます。

❶ package.jsonをつくる

yarn init -y monorepo-next-electron

で、package.jsonを作成し、下記のように編集します。

{
  "name": "monorepo-next-electron",
  "version": "1.0.0",
  "license": "MIT",
  "private": true,
  "workspaces": [
    "next-electron-a",
    "next-electron-b"
  ],
  "scripts": {
    "dev:a": "yarn workspace next-electron-a dev",
    "dev:b": "yarn workspace next-electron-b dev"
  }
}

❷ next-electron-a、next-electron-bを作成する

yarn create next-app --example with-electron-typescript next-electron-a
yarn create next-app --example with-electron-typescript next-electron-b

❸ next-electron-a/package.json と next-electron-b/package.json に「name」と「version」を追加する

nameとversionがないとエラーになるため追加します。

next-electron-a/package.json
{
  "name": "next-electron-a",
  "version": "1.0.0",
  "private": true,
  "main": "main/index.js",
  "productName": "ElectronTypescriptNext",
  "scripts": {
    "clean": "rimraf dist main renderer/out renderer/.next",
    "dev": "npm run build-electron && electron .",
    "build-renderer": "next build renderer && next export renderer",
    "build-electron": "tsc -p electron-src",
    "build": "npm run build-renderer && npm run build-electron",
    "pack-app": "npm run build && electron-builder --dir",
    "dist": "npm run build && electron-builder",
    "type-check": "tsc -p ./renderer/tsconfig.json && tsc -p ./electron-src/tsconfig.json"
  },
  "dependencies": {
    "electron-is-dev": "^1.1.0",
    "electron-next": "^3.1.5",
    "next": "^13.0.7",
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@types/node": "^14.14.6",
    "@types/react": "^16.9.9",
    "@types/react-dom": "^16.9.9",
    "electron": "^13",
    "electron-builder": "^23.0.3",
    "next": "latest",
    "rimraf": "^3.0.0",
    "typescript": "^4.0.5"
  },
  "build": {
    "asar": true,
    "files": [
      "main",
      "renderer/out"
    ]
  }
}
next-electron-b/package.json
{
  "name": "next-electron-b",
  "version": "1.0.0",
  "private": true,
  "main": "main/index.js",
  "productName": "ElectronTypescriptNext",
  "scripts": {
    "clean": "rimraf dist main renderer/out renderer/.next",
    "dev": "npm run build-electron && electron .",
    "build-renderer": "next build renderer && next export renderer",
    "build-electron": "tsc -p electron-src",
    "build": "npm run build-renderer && npm run build-electron",
    "pack-app": "npm run build && electron-builder --dir",
    "dist": "npm run build && electron-builder",
    "type-check": "tsc -p ./renderer/tsconfig.json && tsc -p ./electron-src/tsconfig.json"
  },
  "dependencies": {
    "electron-is-dev": "^1.1.0",
    "electron-next": "^3.1.5",
    "next": "^13.0.7",
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@types/node": "^14.14.6",
    "@types/react": "^16.9.9",
    "@types/react-dom": "^16.9.9",
    "electron": "^13",
    "electron-builder": "^23.0.3",
    "next": "latest",
    "rimraf": "^3.0.0",
    "typescript": "^4.0.5"
  },
  "build": {
    "asar": true,
    "files": [
      "main",
      "renderer/out"
    ]
  }
}

❹ next-electron-a と next-electron-b に next を追加する

yarn workspace next-electron-a add next
yarn workspace next-electron-b add next

❺ next-electron-a/electron-src/index.ts、next-electron-b/electron-src/index.tsを編集する

Yarn Workspacesを使うとパスが変わってくるので変更します。

next-electron-a/electron-src/index.ts
// Native
import { join } from 'path'
import { format } from 'url'

// Packages
import { BrowserWindow, app, ipcMain, IpcMainEvent } from 'electron'
import isDev from 'electron-is-dev'
import prepareNext from 'electron-next'

// Prepare the renderer once the app is ready
app.on('ready', async () => {
  await prepareNext('./next-electron-a/renderer') // パスを変更

  const mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      nodeIntegration: false,
      contextIsolation: false,
      preload: join(__dirname, 'preload.js'),
    },
  })

  const url = isDev
    ? 'http://localhost:8000/'
    : format({
        pathname: join(__dirname, '../renderer/out/index.html'),
        protocol: 'file:',
        slashes: true,
      })

  mainWindow.loadURL(url)
})

// Quit the app once all windows are closed
app.on('window-all-closed', app.quit)

// listen the channel `message` and resend the received message to the renderer process
ipcMain.on('message', (event: IpcMainEvent, message: any) => {
  console.log(message)
  setTimeout(() => event.sender.send('message', 'hi from electron'), 500)
})
next-electron-b/electron-src/index.ts
// Native
import { join } from 'path'
import { format } from 'url'

// Packages
import { BrowserWindow, app, ipcMain, IpcMainEvent } from 'electron'
import isDev from 'electron-is-dev'
import prepareNext from 'electron-next'

// Prepare the renderer once the app is ready
app.on('ready', async () => {
  await prepareNext('./next-electron-b/renderer') // パスを変更

  const mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      nodeIntegration: false,
      contextIsolation: false,
      preload: join(__dirname, 'preload.js'),
    },
  })

  const url = isDev
    ? 'http://localhost:8000/'
    : format({
        pathname: join(__dirname, '../renderer/out/index.html'),
        protocol: 'file:',
        slashes: true,
      })

  mainWindow.loadURL(url)
})

// Quit the app once all windows are closed
app.on('window-all-closed', app.quit)

// listen the channel `message` and resend the received message to the renderer process
ipcMain.on('message', (event: IpcMainEvent, message: any) => {
  console.log(message)
  setTimeout(() => event.sender.send('message', 'hi from electron'), 500)
})

これにて準備完了です。

あとは、

yarn dev:a
yarn dev:b

で、「next-electron-a」、「next-electron-b」が起動します。


distにも対応する

ここまでで、開発には困らなくなったのですが、
このままだと、

yarn workspace next-electron-a dist
yarn workspace next-electron-b dist

を実行した際に、

Cannot compute electron version from installed node modules - none of the possible electron modules are installed and version ("^13") is not fixed in project.
See https://github.com/electron-userland/electron-builder/issues/3984#issuecomment-504968246

というエラーが出ます。
色々試した結果、

"electron": "^13"

を、

"electron": "13"

に修正することでbuildできるようになることがわかりました。
ただ、buildが成功しても、アプリを起動するとwebpackが生成したJSファイルがことごとくリンクが切れています。
こちらも色々試した結果、devとdistでprepareNextに渡すpathを変更する必要があることがわかったので、isDevでpathを切り替えるように修正しました。

修正後

next-electron-a/electron-src/index.ts
// Native
import { join } from 'path'
import { format } from 'url'

// Packages
import { BrowserWindow, app, ipcMain, IpcMainEvent } from 'electron'
import isDev from 'electron-is-dev'
import prepareNext from 'electron-next'

// Prepare the renderer once the app is ready
app.on('ready', async () => {
  await prepareNext(isDev ? './next-electron-a/renderer' : './renderer') // devのときはパスを変更

  const mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      nodeIntegration: false,
      contextIsolation: false,
      preload: join(__dirname, 'preload.js'),
    },
  })

  const url = isDev
    ? 'http://localhost:8000/'
    : format({
        pathname: join(__dirname, '../renderer/out/index.html'),
        protocol: 'file:',
        slashes: true,
      })

  mainWindow.loadURL(url)
})

// Quit the app once all windows are closed
app.on('window-all-closed', app.quit)

// listen the channel `message` and resend the received message to the renderer process
ipcMain.on('message', (event: IpcMainEvent, message: any) => {
  console.log(message)
  setTimeout(() => event.sender.send('message', 'hi from electron'), 500)
})
next-electron-b/electron-src/index.ts
// Native
import { join } from 'path'
import { format } from 'url'

// Packages
import { BrowserWindow, app, ipcMain, IpcMainEvent } from 'electron'
import isDev from 'electron-is-dev'
import prepareNext from 'electron-next'

// Prepare the renderer once the app is ready
app.on('ready', async () => {
  await prepareNext(isDev ? './next-electron-b/renderer' : './renderer') // devのときはパスを変更

  const mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      nodeIntegration: false,
      contextIsolation: false,
      preload: join(__dirname, 'preload.js'),
    },
  })

  const url = isDev
    ? 'http://localhost:8000/'
    : format({
        pathname: join(__dirname, '../renderer/out/index.html'),
        protocol: 'file:',
        slashes: true,
      })

  mainWindow.loadURL(url)
})

// Quit the app once all windows are closed
app.on('window-all-closed', app.quit)

// listen the channel `message` and resend the received message to the renderer process
ipcMain.on('message', (event: IpcMainEvent, message: any) => {
  console.log(message)
  setTimeout(() => event.sender.send('message', 'hi from electron'), 500)
})
package.json
{
  "name": "monorepo-next-electron",
  "version": "1.0.0",
  "license": "MIT",
  "private": true,
  "workspaces": [
    "next-electron-a",
    "next-electron-b"
  ],
  "scripts": {
    "dev:a": "yarn workspace next-electron-a dev",
    "dist:a": "yarn workspace next-electron-a dist",
    "dev:b": "yarn workspace next-electron-b dev"
    "dist:b": "yarn workspace next-electron-b dist",
  }
}

これにて、無事にdevもdistも実行できるようになりました。