npm workspace の使い方

npm v7 で追加された workspace 機能の使い方について紹介します。
記事中で使用している npm のバージョンは [v7.22.0](https://github.com/npm/cli/releases/tag/v7.22.0) です。

## workspace 機能とは

[yarn workspace](https://classic.yarnpkg.com/en/docs/workspaces/) のような機能です。
単一のルートパッケージから複数のパッケージを workspace として管理することができます。

つまり、次のような monorepo を管理するための機能です。

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

workspace 機能を使うことによって、`package-a`, `package-b` のような複数のパッケージをトップレベルの npm プロジェクト (トップレベルの `package.json`) から管理・操作することができます。

ちなみに、このような monorepo 管理を支援するツールとしては [lerna](https://github.com/lerna/lerna) が有名ですが、2021年 2 月の [v4.0.0](https://github.com/lerna/lerna/releases/tag/v4.0.0) リリース以降、開発があまり活発ではないようです(2021 年 8 月時点)。
今後は「脱 lerna」が進んで yarn workspace や npm workspace のみで monorepo を管理する方が主流になるかもしれません。

## workspace を作る

まずはルートとなるトップレベルの npm プロジェクトを作成します。

```console
$ mkdir sample
$ cd sample
$ npm init
```

ルートの npm プロジェクトは workspace の管理用なので公開されることはありません。
生成された `package.json` を編集して [private フィールド](https://docs.npmjs.com/cli/v7/configuring-npm/package-json#private) を true にしておくと良いでしょう。

```jsonc:package.json
{
  "name": "sample",
  // ...
  "private": true
}
```

workspace を追加するには `-w` オプションをつけて `npm init` を呼び出します。

```console
$ npm init -w packages/a
```

上記の場合、`packages/a` ディレクトリが新しい workspace として追加されます。
`packages/a/package.json` が作成されたことが確認できるはずです。

```console
$ tree
.
├── package.json
└── packages
    └── a
        └── package.json
```

また、ルートの `package.json` には [workspaces フィールド](https://docs.npmjs.com/cli/v7/configuring-npm/package-json#workspaces) が自動で追加されています。

```jsonc:package.json
{
  "name": "sample",
  "version": "1.0.0",
  // ...
  "private": true,
  "workspaces": [
    "packages/a"
  ]
}
```

`npm init -w` は「workspace となるディレクトリの作成」、「workspace 用の `package.json` の生成」、「ルートの `package.json` の `workspace` フィールドの更新」を行うだけなので手動でこれらの作業を行っても構いません。

もう 1 つ workspace を追加してみましょう。

```console
$ npm init -w packages/b
```

```console
$ tree
.
├── package.json
└── packages
    ├── a
    │   └── package.json
    └── b
        └── package.json
```

ルートの `package.json` は次のようになるはずです。

```jsonc:package.json
{
  "name": "sample",
  "version": "1.0.0",
  // ...
  "private": true,
  "workspaces": [
    "packages/a",
    "packages/b"
  ]
}
```

ちなみに `workspaces` フィールドはパターンも受け付けるので、上記のようなディレクトリ構成の場合はパターンに書き換えてしまった方がすっきりします。

```jsonc:package.json
{
  "name": "sample",
  "version": "1.0.0",
  // ...
  "private": true,
  "workspaces": [
    "packages/*"
  ]
}
```

これで workspace を使う準備ができました。
`npm install` を実行してみましょう。

```console
$ npm install
```

生成された `node_modules` 以下に、先ほど作成した 2 つの workspace のシンボリックリンクが作成されます。

```console
$ tree node_modules/
node_modules/
├── a -> ../packages/a
└── b -> ../packages/b
```

これによって、各 workspace をプロジェクト内から参照できるようになるという仕組みです。

試しに workspace `b` から workspace `a` を参照してみましょう。

workspace `a` 側で適当に文字列を export します。

```javascript:packages/a/index.js
module.exports = "workspace a"
```

workspace `b` 側に module `a` を参照するコードを作成し、実行してみます。

```javascript:packages/a/index.js
const a = require("a");
console.log(a);
```

```console
$ node packages/b/index.js
workspace a
```

workspace `a` が workspace `b` から参照できることが確認できました。

## 依存パッケージを追加する

(npm workspace に限った話ではありませんが) monorepo では一般的に次のように依存パッケージを管理します。

- 開発用の依存パッケージ (`devDependencies`) はルートパッケージの `package.json` で管理する
- サブパッケージのアプリケーションコードが参照する依存パッケージはサブパッケージ内の `package.json` で管理する

このようにすることで、複数パッケージの開発に使うツールのバージョンや設定を単一のルート設定で共通化しつつ、各サブパッケージの依存関係は個々に管理することができます。
また、開発用の依存パッケージをルート側にのみインストールすることでディスクスペースの節約にもなります。

これを踏まえて依存パッケージを追加してみましょう。

`eslint` のような lint ツールは開発用の依存パッケージなので単純にルート側で `npm install` を行います。

```console
$ npm install --save-dev eslint
```

workspace のコードが依存するパッケージは、次のように `-w` をつけて __ルート側__ で `npm install` を実行して追加します。

```console
$ 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` を実行してはいけないことに注意してください。

```console
$ 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 を定義したとします。

```jsonc:packages/a/package.json
{
  "name": "a",
  "version": "1.0.0",
  // ...
  "scripts": {
    "print": "echo \"workspace a\""
  }
}
```

この npm script はルート側から次のように実行できます。

```console
$ 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` を定義し、

```jsonc:packages/b/package.json
{
  "name": "b",
  "version": "1.0.0",
  // ...
  "scripts": {
    "print": "echo \"workspace b\""
  }
}
```

`--workspaces` をつけて実行すると、

```console
$ 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 を定義するといった使い方が便利です。

```jsonc:package.json
{
  "name": "sample",
  "version": "1.0.0",
  // ...
  "workspaces": [
    "packages/*"
  ],
  "scripts": {
    "build": "npm run build --workspaces"
  }
}
```

実行対象の npm script が定義されていない workspace があるとエラーになるので注意してください。
`--if-present` オプションをつけるとこのようなケースでもエラーにならずに実行することができます。

```console
$ 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` を呼び出すことができます。

```jsonc:package.json
{
  "name": "sample",
  "version": "1.0.0",
  // ...
  "workspaces": [
    "packages/*"
  ],
  "devDependencies": {
    "eslint": "^7.32.0"
  }
}

```

```jsonc:packages/a/package.json
{
  "name": "a",
  "version": "1.0.0",
  // ...
  "scripts": {
    "print": "echo \"workspace a\"",
    "lint": "eslint ."
  },
  "dependencies": {
    "node-fetch": "^2.6.1"
  }
}
```

workspace の `node_modules/.bin` にコマンドが存在する場合はそちらが優先されるため、workspace 単位でツールのバージョンを使い分けることもできます。

```jsonc:packages/b/package.json
{
  "name": "b",
  "version": "1.0.0",
  // ...
  "scripts": {
    "lint": "eslint ."
  },
  "devDependencies": {
    "eslint": "^6.8.0"
  }
}
```

```console
$ npm run -w packages/b lint　
```

## そのほかの操作

`npm outdated` や `npm publish` など、多くの npm サブコマンドに workspace サポートが追加されています。
詳細は各コマンドの公式ドキュメントを確認してください。

https://docs.npmjs.com/cli/v7/commands

## npm workspace による monorepo 運用の Tips

### 依存関係の落とし穴に対処する

npm workspace で管理しているサブパッケージを npm として公開したい場合は、workspace 側の依存関係に注意しないと思わぬ落とし穴に引っかかることがあります。

例えば `package-a` を npm workspace で管理しているとします。
この `package-a` は `node-fetch` に依存しているものとします。

```jsonc:package-a/package.json
{
  "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` を記載し忘れてしまいました。

```jsonc:package-b/package.json
{
  "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 で実行するという方法があります。

https://github.com/depcheck/depcheck

`depcheck` はコードの実際の依存関係を検出し、`package.json` に記述された依存関係の過不足をチェックしてくれるツールです。
CI で全ての workspace に対して `depcheck` を実行するようにすれば、依存関係の追加漏れを検出することができ前述の問題を回避することができます。

### 各 workspace の package.json を lint する

管理する workspace が多くなると、それだけ `package.json` が増えていくことになります。
多数の `package.json` を作成・管理しているとフィールドの追加忘れや更新漏れが発生しがちです。

`npm-package-json-lint` というツールを CI で実行するようにしておくとこういったミスをかなり防ぐことができます。

https://github.com/tclindner/npm-package-json-lint

次のような設定ファイルで `package.json` が満たすべきルールを定めて lint することができます。

```jsonc:.npmpackagejsonlintrc.json
{
  "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 機能が未実装なのが玉に瑕です。

https://github.com/tclindner/npm-package-json-lint/issues/70

### Dependabot で依存パッケージを自動更新する

[Dependabot](https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/about-dependabot-version-updates) を使う場合、ルートの `package.json` のみを対象に設定を作成してあげれば、各 workspace の `package.json` に記載された依存関係も更新してくれるようになります。

```yaml:.github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "daily"
```

### TypeScript を使う

あまり詳しくは触れませんが、npm workspace で TypeScript を使う場合は Project References を使うと良いでしょう。

https://www.typescriptlang.org/docs/handbook/project-references.html