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を作る。公開しないパッケージなのでprivate
をtrue
にしておく。
{
"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
が自動的に追加される。
{
"name": "my-project",
"license": "UNLICENSED",
- "private": true
+ "private": true,
+ "workspaces": [
+ "packages/a",
+ "packages/b",
+ "packages/c"
+ ]
}
ちなみに、workspaces
フィールドは、次のようにワイルドカードで指定することもできます。
{
"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が生成される。
{
"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
を作っておくと便利。
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_modules
にsax
が追加される。
.
├── 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/a
のpackage.json
のdependencies
も変更される。
...
"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
が変更される。
"license": "UNLICENSED",
"dependencies": {
+ "@my-project/a": "^0.0.0",
"sax": "^1.2.4"
}
}
ワークスペースでシェルコマンドを実行する
任意のワークスペースでシェルコマンドを実行するにはnpx
を使います。その際、-w
オプションでワークスペース名、-c
オプションでシェルコマンドを指定します。
npx -w [ワークスペース名] -c [シェルコマンド]
たとえば、ワークスペース@my-project/a
でpwd
コマンドを実行する場合:
$ 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/a
にsort-package-json
コマンドを実行するには次のようにします:
npx -w @my-project/a 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/a
のbuild
を実行する場合は次のようにします。
{
"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
フィールドと実行順序
npx
やnpm run
で-ws
オプションを指定すると、全ワークスペースでコマンドが実行されます。その、実行順序は、ルートパッケージのpackage.jsonのworkspaces
の順番に依存しています。
たとえば、workspaces
フィールドが次のようにa,b,cの順の場合、
{
"workspaces": [
"packages/a",
"packages/b",
"packages/c"
]
}
コマンドもa,b,cの順で実行されます。
$ npx -ws -c 'basename $(pwd)'
a
b
c
一方、workspaces
フィールドがc,b,aの順のとき、
{
"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を頼ったほうがいいかもしれません。
ワークスペース間依存のアップデート
たとえば、次のような状況で、ワークスペース間の依存をどうアップデートしたらいいのでしょうか。
-
@my-project/b
が@my-project/a
のv0.0.0
に依存している -
@my-project/a
がv1.0.0
になった。 -
@my-project/b
も@my-project/a
のv1.0.0
に依存するようにしたい。
この場合、npm install
をし直します。
npm install @my-project/a@^1.0.0 -w @my-project/b
@my-project/b
のpackage.jsonも更新されます。
"dependencies": {
- "@my-project/a": "^0.0.0",
+ "@my-project/a": "^1.0.0",
"sax": "^1.2.4"
}
}
ワークスペースにおけるNPMパッケージのアップデート
たとえば、@my-project/c
がsax@^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
パッケージマネージャをnpmに限定する
パッケージマネージャはnpm以外にも、有名どころだとyarnがあります。npm workspacesを使う上では、パッケージマネージャはnpmに限定したいところです。また、チームで開発する場合は、知らずにyarnを使われてしまったりすることも避けなければなりません。個人開発においても、癖でyarnを使ってしまう失敗もありそうです。
npmに限定するには、ルートパッケージのNPMスクリプトにonly-allow
を追加します。
{
"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に未対応です。
代わりにmulti-semantic-releaseというものがあります。
さらにそれをフォークして、処理を並列実行できるようにした@qiwi/multi-semantic-releaseもあります。
ほかに、semantic-release-monorepoもあります。
超絶わかりやすいです