Open22

npm workspacesとモノレポ探検記

NPM 7で追加されたworkspaces機能を試す。

環境

❯❯❯ npx envinfo --binaries

  Binaries:
    Node: 16.13.2 - /private/var/tmp/fnm_multishells/5279_1643694868908/bin/node
    Yarn: 1.22.15 - ~/.local/share/npm/bin/yarn
    npm: 8.1.2 - /private/var/tmp/fnm_multishells/5279_1643694868908/bin/npm
    Watchman: 2022.01.24.00 - /usr/local/bin/watchman

用語

レポ(repo)

Gitのリポジトリ。

シングルレポ(single repo)

ベーシックなレポ。typescriptやeslintなどの開発ツールの設定もひとつだけで管理がかんたん。ただし、レポに対応するNPMパッケージは1個だけになるので、パッケージを部分的に使いたいユーザーもパッケージ全体をインストールしないといけない。

マルチレポ (multi-repo)

複数のGitレポジトリに別けた形態。シングルレポから再利用可能なモジュールをNPMパッケージとして独立させられるので、ユーザーは必要なパッケージだけをインストールできる。ただし、Gitリポジトリが分かれるため、開発ツールの統一的管理は面倒。

モノレポ(monorepo)

ひとつのGitリポジトリで複数のNPMパッケージを扱う形態。シングルレポの開発ツールの統一的管理の良さと、マルチレポの再利用可能なモジュールを切り出してリリースする点の両方を兼ね備える。一見完璧に見えるが、その分リポジトリの内容は複雑になるので、そこそこ運用知識が必要となる。

ルートパッケージ(root package)

モノレポにおけるトップレベルのNPMパッケージ。通常は、リポジトリのトップレベル。このパッケージはワークスペースを管理するパッケージ。NPMでは配布しない。ルートパッケージには、開発ツールの統一的設定や、CI設定が置かれ、テストフレームワークなどの開発ツールがインストールされる。

モノレポにはルートパッケージはひとつしか存在しない。

ワークスペース (workspaces)

モノレポにおけるNPMで配布するパッケージ。通常、/packagesディレクトリにワークスペースごとのディレクトリを作る。

単に「パッケージ」と言った場合は、ワークスペースを指すことがある。(本稿ではできるだけ「ワークスペース」と言うように心がける)

モノレポには複数のワークスペースが存在しうる。

巻き上げ (hoisting)

Nodeのモジュール解決で、自分のnode_modulesにモジュールが無いとき、親のnode_modules、その親のnode_modulesへとディレクトリをさかのぼってモジュールを探す仕組みがある。

たとえば、/a/b/c/index.jsでrequire("x")したとき、次の順でモジュールが見つかるまで探す。

  • /a/b/c/node_modules/x
  • /a/b/node_modules/x
  • /a/node_modules/x
  • /node_modules/x

この仕組みをモノレポでは活用することがある。

たとえば、packages/aとpackages/bどちらもjestを使いたいとき、それぞれにjestをインストールするのではなく、ルートパッケージにjestをひとつだけ入れておけばいい。

ルートパッケージを作る

ルートディレクトリにpackage.jsonを作る。公開しないパッケージなのでprivatetrueにしておく。

package.json
{
  "name": "my-project",
  "license": "UNLICENSED",
  "private": true
}

nameはなんでもいいが、一般公開されているNPMパッケージとかぶらないようにしておいたほうがいいだろう。

ワークスペースを作る

npm init-wオプションを付けて呼び出すとワークスペースが新規作成される。-wの引数はディレクトリ名。

npm init -w [ワークスペースのディレクトリ名]

スコープがある場合は、--scopeオプションもつける。

npm init --scope [スコープ名] -w [ワークスペースのディレクトリ名]

たとえば、packages/aディレクトリに@my-project/aというnameでワークスペースを作る場合、次のコマンドを実行する:

npm init --scope my-project -w packages/a

-wオプションを複数指定すれば、一気に複数のワークスペースを新規作成できる:

 npm init --scope my-project -w packages/b -w packages/c

ルートパッケージから見たファイル構造は次のようになる。

.
├── package.json
└── packages
    ├── a
    │   └── package.json
    ├── b
    │   └── package.json
    └── c
        └── package.json

npm init -wでワークスペースを作ると、ルートパッケージのpackage.jsonにworkspacesが自動的に追加される。

package.json
 {
   "name": "my-project",
   "license": "UNLICENSED",
-  "private": true
+  "private": true,
+  "workspaces": [
+    "packages/a",
+    "packages/b",
+    "packages/c"
+  ]
 }

ちなみに、workspacesフィールドは、次のようにワイルドカードで指定することもできます。

package.json
{
  "workspaces": [
    "packages/*"
  ]
}

npm install

ルートパッケージでnpm installすると、ルートパッケージのnode_modulesからワークスペースのシンボリックリンクができる。巻き上げされた状態。

.
├── node_modules
│   └── @my-project
│       ├── a -> ../../packages/a
│       ├── b -> ../../packages/b
│       └── c -> ../../packages/c
├── package-lock.json
├── package.json
└── packages
    ├── a
    │   └── package.json
    ├── b
    │   └── package.json
    └── c
        └── package.json

この巻き上げによって、各ワークスペースから別のワークスペースを参照できるようになる。

同時に、ルートパッケージにpackage-lock.jsonが生成される。

package-lock.json
{
  "name": "my-project",
  "lockfileVersion": 2,
  "requires": true,
  "packages": {
    "": {
      "name": "my-project",
      "license": "UNLICENSED",
      "workspaces": [
        "packages/a",
        "packages/b",
        "packages/c"
      ]
    },
    "node_modules/@my-project/a": {
      "resolved": "packages/a",
      "link": true
    },
    "node_modules/@my-project/b": {
      "resolved": "packages/b",
      "link": true
    },
    "node_modules/@my-project/c": {
      "resolved": "packages/c",
      "link": true
    },
    "packages/a": {
      "name": "@my-project/a",
      "version": "0.0.0",
      "license": "UNLICENSED"
    },
    "packages/b": {
      "name": "@my-project/b",
      "version": "0.0.0",
      "license": "UNLICENSED"
    },
    "packages/c": {
      "name": "@my-project/c",
      "version": "0.0.0",
      "license": "UNLICENSED"
    }
  },
  "dependencies": {
    "@my-project/a": {
      "version": "file:packages/a"
    },
    "@my-project/b": {
      "version": "file:packages/b"
    },
    "@my-project/c": {
      "version": "file:packages/c"
    }
  }
}

.npmrc

モノレポではパッケージを新規作成することがしばしばある。npm initで毎回ユーザー名などを入力する手間がある。この手間を省くために、ルートパッケージに.npmrcを作っておくと便利。

.npmrc
init-author-email=support@example.com
init-author-name=My Organization
init-author-url=https://example.com/
init-license= UNLICENSED
init-version=0.0.0

この設定がある状態で、npm initするとこの設定が使われる。

ワークスペースにパッケージをインストールする

ワークスペースにパッケージをインストールする場合は、npm install-wオプションを付けて実行する。

npm install [パッケージ名] -w [ワークスペース名]

たとえば、パッケージsaxをワークスペース@my-project/aにインストールするには次のようにする。

npm install sax -w @my-project/a

これを実行すると、ルートパッケージのnode_modulessaxが追加される。

.
├── node_modules
│   ├── @my-project
│   │   ├── a -> ../../packages/a
│   │   ├── b -> ../../packages/b
│   │   └── c -> ../../packages/c
│   └── sax 🆕
├── package-lock.json
├── package.json
└── packages
    ├── a
    │   └── package.json
    ├── b
    │   └── package.json
    └── c
        └── package.json

同時に、ワークスペース@my-project/apackage.jsondependenciesも変更される。

packages/a/package.json
   ...
   "author": "My Organization <support@example.com> (https://example.com/)",
   "license": "UNLICENSED",
+  "dependencies": {
+    "sax": "^1.2.4"
+  }
 }

また、package-lock.jsonも更新される。

複数のワークスペースにパッケージをインストールする

-wオプションを複数指定することで、複数のワークスペースを選択して、一括でパッケージをインストールすることもできる。

npm install sax -w @my-project/a -w @my-project/b

全ワークスペースに一括してパッケージをインストールする

-wオプションの代わりに-wsオプションを指定すると、全ワークスペースに対して特定のパッケージがインストールできる。

npm install [パッケージ名] -ws

ワークスペースからパッケージをアンインストールする

特定のワークスペースからパッケージをアンインストールするのは、npm installと同様の形式で、npm uninstall-wオプションを指定して行う。

npm uninstall [パッケージ名] -w [ワークスペース名]

-wsオプションを指定すれば、全ワークスペースから特定のパッケージのアンインストールも可能。

npm uninstall [パッケージ名] -ws

そういえばnpm installに--saveってつけないの?

昔のnpmは--saveオプションをつけないとpackage.jsonのdependeciesにパッケージを追加してもらえなかった。

npm 5からは--saveを付けなくても、デフォルトでdependeciesを追加するようになった。そのため、workspacesが使えるnpm 7以降の場合は、--saveを省略して問題ない。

ワークスペースをワークスペースにインストールする

ローカルのワークスペース間で依存関係を持たせるには、npm installを次のようにする。

npm install [ワークスペース名] -w [ワークスペース名]

たとえば、@my-project/a@my-project/bにインストールする場合は次のようになる。

npm install @my-project/a -w @my-project/b

ワークスペースへの依存が追加されると、package.jsonは次のようにdependenciesが変更される。

packages/b/package.json
   "license": "UNLICENSED",
   "dependencies": {
+    "@my-project/a": "^0.0.0",
     "sax": "^1.2.4"
   }
 }

ワークスペースでシェルコマンドを実行する

任意のワークスペースでシェルコマンドを実行するにはnpxを使います。その際、-wオプションでワークスペース名、-cオプションでシェルコマンドを指定します。

npx -w [ワークスペース名] -c [シェルコマンド]

たとえば、ワークスペース@my-project/apwdコマンドを実行する場合:

$ npx -w @my-project/a -c pwd
/Volumes/v/suinplayground/monorepo-with-npm/packages/a

-wオプションは複数指定することもできます:

$ npx -w @my-project/a -w @my-project/b -c pwd
/Volumes/v/suinplayground/monorepo-with-npm/packages/a
/Volumes/v/suinplayground/monorepo-with-npm/packages/b

-wsオプションを使えば、全ワークスペースに対してシェルコマンドが実行できます:

$ npx -ws -c pwd
/Volumes/v/suinplayground/monorepo-with-npm/packages/a
/Volumes/v/suinplayground/monorepo-with-npm/packages/b
/Volumes/v/suinplayground/monorepo-with-npm/packages/c

シェルコマンドに引数がある場合は、-cオプションの値はシングルクォーテーションで囲む必要があります。

$ npx -ws -c 'basename $(pwd)'
a
b
c

ワークスペースでnpmコマンドを実行する

任意のワークスペースでnpmコマンドを実行する場合もnpxを使います。その際、-wオプションでワークスペース名を指定します。

npx -w [ワークスペース名] [npmパッケージ名]

たとえば、ワークスペース@my-project/asort-package-jsonコマンドを実行するには次のようにします:

npx -w @my-project/a sort-package-json

https://www.npmjs.com/package/sort-package-json

次のように、-wオプションを複数指定すると、複数のワークスペースでnpmコマンドが実行できます。

npx -w @my-project/a -w @my-project/b sort-package-json

さらに、-wsオプションを指定すると、全ワークスペースでnpmコマンドが実行されます。

npx -ws sort-package-json

ワークスペースでnpmスクリプトを実行する

npm runでも-wオプションを指定すると任意のワークスペースのnpmスクリプトが実行できます。

npm run -w [ワークスペース名] [スクリプト名]

たとえば、@my-project/abuildを実行する場合は次のようにします。

packages/a/package.json
{
  "name": "@my-project/a",
  ...
  "scripts": {
    "build": "# build @my-project/a" // これを実行したい
  },
  ...
}
$ npm run build -w @my-project/a

> @my-project/a@0.0.0 build
> # build @my-project/a

-wオプションを複数指定すれば、複数のワークスペースのスクリプトが実行できます。

npm run build -w @my-project/a -w @my-project/b

> @my-project/a@0.0.0 build
> # build @my-project/a


> @my-project/b@0.0.0 build
> # build @my-project/b

-wsオプションを指定した場合、全ワークスペースのスクリプトが実行されます。

$ npm run build -ws

> @my-project/a@0.0.0 build
> # build @my-project/a


> @my-project/b@0.0.0 build
> # build @my-project/b


> @my-project/c@0.0.0 build
> # build @my-project/c

ワークスペースによってはスクリプトが無い場合があります。そういった場合は、--if-presentオプションを付けて実行します。

npm run test -ws --if-present

-wsオプションとpackage.jsonのworkspacesフィールドと実行順序

npxnpm run-wsオプションを指定すると、全ワークスペースでコマンドが実行されます。その、実行順序は、ルートパッケージのpackage.jsonのworkspacesの順番に依存しています。

たとえば、workspacesフィールドが次のようにa,b,cの順の場合、

package.json
{
  "workspaces": [
    "packages/a",
    "packages/b",
    "packages/c"
  ]
}

コマンドもa,b,cの順で実行されます。

$ npx -ws -c 'basename $(pwd)'
a
b
c

一方、workspacesフィールドがc,b,aの順のとき、

package.json
{
  "workspaces": [
    "packages/c",
    "packages/b",
    "packages/a"
  ]
}

コマンドもこの順のとおり、c,b,aの順で実行されます。

$ npx -ws -c 'basename $(pwd)'
c
b
a

workspacesフィールドがpackages/*のようなワイルドカードになってくると、コマンドの実行順はおそらくファイルシステムの順序に依存するのではないかと思われます(要調査)。

いずれにせよ肝心なことは、-wsオプションは各ワークスペースの依存グラフを解析して、依存の根幹ワークスペースから順にやってくれるわけではないということです。

そのため、依存関係が重要になってくるビルドタスクなどは、-wsオプションではうまく扱えない可能生があります。

-wsオプションはワークスペース間の順番が関係ないときだけに使ったほうが良さそうです。

依存関係を考慮した順番で何かしたいときは、lernaやturborepoを頼ったほうがいいかもしれません。

https://lerna.js.org/

https://turborepo.org/

ワークスペース間依存のアップデート

たとえば、次のような状況で、ワークスペース間の依存をどうアップデートしたらいいのでしょうか。

  • @my-project/b@my-project/av0.0.0に依存している
  • @my-project/av1.0.0になった。
  • @my-project/b@my-project/av1.0.0に依存するようにしたい。

この場合、npm installをし直します。

npm install @my-project/a@^1.0.0 -w @my-project/b

@my-project/bのpackage.jsonも更新されます。

packages/b/package.json
   "dependencies": {
-    "@my-project/a": "^0.0.0",
+    "@my-project/a": "^1.0.0",
     "sax": "^1.2.4"
   }
 }

ワークスペースにおけるNPMパッケージのアップデート

たとえば、@my-project/csax@^0.6に依存していて、sax@latest(最新版)にアップデートしたい場合どうするか?

まず、普通にnpm installをすればいい。

npm install sax@latest -w @my-project/c

この方法は、どのNPMパッケージにアップデートがあるか分かっている場合に使える。

分かってない場合は、npm-upgradeを使う。

npx -w @my-project/c npm-upgrade

# その後に
npm install

https://www.npmjs.com/package/npm-upgrade

パッケージマネージャをnpmに限定する

パッケージマネージャはnpm以外にも、有名どころだとyarnがあります。npm workspacesを使う上では、パッケージマネージャはnpmに限定したいところです。また、チームで開発する場合は、知らずにyarnを使われてしまったりすることも避けなければなりません。個人開発においても、癖でyarnを使ってしまう失敗もありそうです。

npmに限定するには、ルートパッケージのNPMスクリプトにonly-allowを追加します。

package.json
 {
   "name": "my-project",
   "license": "UNLICENSED",
   "private": true,
   "workspaces": [
     "packages/c",
     "packages/b",
     "packages/a"
   ],
   "scripts": {
+    "preinstall": "npx only-allow npm"
   }
 }

これをセットしておくと、yarn installをしたときに処理が中断されるようになります。

npm workspacesとsemantic-release

semantic-releaseはconventional commitに則ってコミットされたリポジトリにおいて、セマンティックバージョンの発番からCHANGELOG.mdの更新、npmレジストリへのpublishまでを自動化してくれる超便利ツールです。

残念ながら、sematic-releaseはnpm workspacesに未対応です。

https://github.com/semantic-release/semantic-release/issues/1688

代わりにmulti-semantic-releaseというものがあります。

https://www.npmjs.com/package/multi-semantic-release

さらにそれをフォークして、処理を並列実行できるようにした@qiwi/multi-semantic-releaseもあります。

https://www.npmjs.com/package/@qiwi/multi-semantic-release

ほかに、semantic-release-monorepoもあります。

https://www.npmjs.com/package/semantic-release-monorepo
ログインするとコメントできます