npm v7 で追加された workspace 機能の使い方について紹介します。
記事中で使用している npm のバージョンは v7.22.0 です。
workspace 機能とは
yarn workspace のような機能です。
単一のルートパッケージから複数のパッケージを workspace として管理することができます。
つまり、次のような monorepo を管理するための機能です。
.
├── package.json
└── packages
├── a
│ └── package.json
└── b
└── package.json
workspace 機能を使うことによって、package-a
, package-b
のような複数のパッケージをトップレベルの npm プロジェクト (トップレベルの package.json
) から管理・操作することができます。
ちなみに、このような monorepo 管理を支援するツールとしては lerna が有名ですが、2021年 2 月の v4.0.0 リリース以降、開発があまり活発ではないようです(2021 年 8 月時点)。
今後は「脱 lerna」が進んで yarn workspace や npm workspace のみで monorepo を管理する方が主流になるかもしれません。
workspace を作る
まずはルートとなるトップレベルの npm プロジェクトを作成します。
$ mkdir sample
$ cd sample
$ npm init
ルートの npm プロジェクトは workspace の管理用なので公開されることはありません。
生成された package.json
を編集して private フィールド を true にしておくと良いでしょう。
{
"name": "sample",
// ...
"private": true
}
workspace を追加するには -w
オプションをつけて npm init
を呼び出します。
$ npm init -w packages/a
上記の場合、packages/a
ディレクトリが新しい workspace として追加されます。
packages/a/package.json
が作成されたことが確認できるはずです。
$ tree
.
├── package.json
└── packages
└── a
└── package.json
また、ルートの package.json
には workspaces フィールド が自動で追加されています。
{
"name": "sample",
"version": "1.0.0",
// ...
"private": true,
"workspaces": [
"packages/a"
]
}
npm init -w
は「workspace となるディレクトリの作成」、「workspace 用の package.json
の生成」、「ルートの package.json
の workspace
フィールドの更新」を行うだけなので手動でこれらの作業を行っても構いません。
もう 1 つ workspace を追加してみましょう。
$ npm init -w packages/b
$ tree
.
├── package.json
└── packages
├── a
│ └── package.json
└── b
└── package.json
ルートの package.json
は次のようになるはずです。
{
"name": "sample",
"version": "1.0.0",
// ...
"private": true,
"workspaces": [
"packages/a",
"packages/b"
]
}
ちなみに workspaces
フィールドはパターンも受け付けるので、上記のようなディレクトリ構成の場合はパターンに書き換えてしまった方がすっきりします。
{
"name": "sample",
"version": "1.0.0",
// ...
"private": true,
"workspaces": [
"packages/*"
]
}
これで workspace を使う準備ができました。
npm install
を実行してみましょう。
$ npm install
生成された node_modules
以下に、先ほど作成した 2 つの workspace のシンボリックリンクが作成されます。
$ tree node_modules/
node_modules/
├── a -> ../packages/a
└── b -> ../packages/b
これによって、各 workspace をプロジェクト内から参照できるようになるという仕組みです。
試しに workspace b
から workspace a
を参照してみましょう。
workspace a
側で適当に文字列を export します。
module.exports = "workspace a"
workspace b
側に module a
を参照するコードを作成し、実行してみます。
const a = require("a");
console.log(a);
$ node packages/b/index.js
workspace a
workspace a
が workspace b
から参照できることが確認できました。
依存パッケージを追加する
(npm workspace に限った話ではありませんが) monorepo では一般的に次のように依存パッケージを管理します。
- 開発用の依存パッケージ (
devDependencies
) はルートパッケージのpackage.json
で管理する - サブパッケージのアプリケーションコードが参照する依存パッケージはサブパッケージ内の
package.json
で管理する
このようにすることで、複数パッケージの開発に使うツールのバージョンや設定を単一のルート設定で共通化しつつ、各サブパッケージの依存関係は個々に管理することができます。
また、開発用の依存パッケージをルート側にのみインストールすることでディスクスペースの節約にもなります。
これを踏まえて依存パッケージを追加してみましょう。
eslint
のような lint ツールは開発用の依存パッケージなので単純にルート側で npm install
を行います。
$ npm install --save-dev eslint
workspace のコードが依存するパッケージは、次のように -w
をつけて ルート側 で npm install
を実行して追加します。
$ npm install -w packages/a --save node-fetch
こうすることにより、workspace が依存するパッケージもルートの package-lock.json
によって管理されるようになります。
これにより新規に clone したリポジトリでセットアップを行う場合でも、ルート側で npm install
を実行するだけで全ての workspace の依存パッケージを取得することができます。
また、他の workspace が依存している同一のパッケージと競合せずにバージョン解決できる場合はルート側の node_modules
にインストールされ、workspace 間で共有されます(hoisting)。
バージョンが競合する場合は workspace 内の node_modules
にインストールされる仕組みです。
次のように workspace 内で単純に npm install
を実行してはいけないことに注意してください。
$ cd packages/a
$ npm install --save node-fetch
この方法だと packages/a
が workspace ではなく単一のパッケージとみなされてしまうため、新規に packages/a/package-lock.json
が生成され、ルートの package-lock.json
で管理することができなくなってしまいます。
workspace で定義された npm script を実行する
特定の workspace で定義された npm script は、ルート側で npm run
に -w
オプションをつけて workspace を指定することで実行することができます。
例えば次のように workspace a
に print
という npm script を定義したとします。
{
"name": "a",
"version": "1.0.0",
// ...
"scripts": {
"print": "echo \"workspace a\""
}
}
この npm script はルート側から次のように実行できます。
$ npm run print -w packages/a
> a@1.0.0 print
> echo "workspace a"
workspace a
また、--workspaces
オプションをつけて npm run
を実行すると全ての workspace で定義された npm script を一括で実行することができます。
次のように workspace b
にも print
を定義し、
{
"name": "b",
"version": "1.0.0",
// ...
"scripts": {
"print": "echo \"workspace b\""
}
}
--workspaces
をつけて実行すると、
$ npm run --workspaces print
> a@1.0.0 print
> echo "workspace a"
workspace a
> b@1.0.0 print
> echo "workspace b"
workspace b
全ての workspace の print
が実行されました。
例えば、次のようにルートの package.json
に各 workspace の build
を一括で実行する npm script を定義するといった使い方が便利です。
{
"name": "sample",
"version": "1.0.0",
// ...
"workspaces": [
"packages/*"
],
"scripts": {
"build": "npm run build --workspaces"
}
}
実行対象の npm script が定義されていない workspace があるとエラーになるので注意してください。
--if-present
オプションをつけるとこのようなケースでもエラーにならずに実行することができます。
$ npm run print --workspaces --if-present
> a@1.0.0 print
> echo "workspace a"
workspace a
また、npm v7 では npm script から呼び出したコマンドが直近の node_modules/.bin
以下に存在しない場合、さらに上位のディレクトリの node_modules
からコマンドを探して実行するようになっています。
これにより、workspace 側で定義した npm script はルートの node_modules
にインストールされたコマンドを呼び出すことができます。
例えば次のような場合、workspace a
の依存関係には eslint
は含まれていないのですがルート側の依存関係には含まれているため workspace a
で定義されている npm script lint
から eslint
を呼び出すことができます。
{
"name": "sample",
"version": "1.0.0",
// ...
"workspaces": [
"packages/*"
],
"devDependencies": {
"eslint": "^7.32.0"
}
}
{
"name": "a",
"version": "1.0.0",
// ...
"scripts": {
"print": "echo \"workspace a\"",
"lint": "eslint ."
},
"dependencies": {
"node-fetch": "^2.6.1"
}
}
workspace の node_modules/.bin
にコマンドが存在する場合はそちらが優先されるため、workspace 単位でツールのバージョンを使い分けることもできます。
{
"name": "b",
"version": "1.0.0",
// ...
"scripts": {
"lint": "eslint ."
},
"devDependencies": {
"eslint": "^6.8.0"
}
}
$ npm run -w packages/b lint
そのほかの操作
npm outdated
や npm publish
など、多くの npm サブコマンドに workspace サポートが追加されています。
詳細は各コマンドの公式ドキュメントを確認してください。
npm workspace による monorepo 運用の Tips
依存関係の落とし穴に対処する
npm workspace で管理しているサブパッケージを npm として公開したい場合は、workspace 側の依存関係に注意しないと思わぬ落とし穴に引っかかることがあります。
例えば package-a
を npm workspace で管理しているとします。
この package-a
は node-fetch
に依存しているものとします。
{
"name": "package-a",
"version": "1.0.0",
// ...
"dependencies": {
"node-fetch": "^2.6.1"
}
}
npm install を実行すると node-fetch
はルート側の node_modules
にインストールされます。
次のような状態です。
.
├── node_modules
│ ├── node-fetch
│ └── package-a -> ../package-a
├── package-a
│ └── package.json
├── package-lock.json
└── package.json
ここに、新たに node-fetch
に依存した別のパッケージ package-b
を workspace として追加しましたが、package-b
の package.json
には node-fetch
を記載し忘れてしまいました。
{
"name": "package-b",
"version": "1.0.0"
}
.
├── node_modules
│ ├── node-fetch
│ ├── package-a -> ../package-a
│ └── package-b -> ../package-b
├── package-a
│ └── package.json
├── package-b
│ └── package.json
├── package-lock.json
└── package.json
問題なのはこのケースにおいて、すでにルートの node_modules
に node-fetch
が存在するため package-b
から node-fetch
を参照できてしまうという点です。
この場合、ローカルでのテストや lint は成功しますが、npm として公開した package-b
の依存関係からは node-fetch
が不足しているためユーザー側では正常に動作しなくなってしまいます。
よくやりがちなミスであるにも関わらず致命的なバグを引き起こし、CI でも検出できないという非常に厄介な問題です。
対策の 1 つとして、depcheck
というツールを CI で実行するという方法があります。
depcheck
はコードの実際の依存関係を検出し、package.json
に記述された依存関係の過不足をチェックしてくれるツールです。
CI で全ての workspace に対して depcheck
を実行するようにすれば、依存関係の追加漏れを検出することができ前述の問題を回避することができます。
各 workspace の package.json を lint する
管理する workspace が多くなると、それだけ package.json
が増えていくことになります。
多数の package.json
を作成・管理しているとフィールドの追加忘れや更新漏れが発生しがちです。
npm-package-json-lint
というツールを CI で実行するようにしておくとこういったミスをかなり防ぐことができます。
次のような設定ファイルで package.json
が満たすべきルールを定めて lint することができます。
{
"rules": {
"require-description": "error",
"require-engines": "error",
"require-license": "error",
"require-name": "error",
"require-version": "error",
"description-format": [
"error",
{
"requireCapitalFirstLetter": true,
"requireEndingPeriod": true
}
],
"name-format": "error",
"version-format": "error"
}
}
この例では、description
, engines
, license
, name
, version
を必須フィールドとし、description
, name
, version
が正しいフォーマットであることをチェックするようにしています。
便利なツールですが、autofix 機能が未実装なのが玉に瑕です。
Dependabot で依存パッケージを自動更新する
Dependabot を使う場合、ルートの package.json
のみを対象に設定を作成してあげれば、各 workspace の package.json
に記載された依存関係も更新してくれるようになります。
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "daily"
TypeScript を使う
あまり詳しくは触れませんが、npm workspace で TypeScript を使う場合は Project References を使うと良いでしょう。