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 を使うと良いでしょう。