ESM + Typescript + Jest の monorepo を Lerna と Rollup で npm に上がるまで
某音ゲーの譜面フォーマットの入出力をずっと前から npm に上げたかったけど、殆どのソフトが多分 parse の部分しか必要としないので、最初から monorepo で分けたかったけど、まじで monorepo 周りの設定なり準備なりがめんどくさすぎるし、ツールがたくさんなのにそれぞれに caveat があって、チュートリアルを漁ってもなかなか自分の要求を満たしたものが見つからなかったので、大変でしたね……まぁ、何日かかってか分からない位、頑張ったわけですが、やっと気持ちのいいセットに落ち着いたわけです。ちなみに私が作ったパッケージはこれですね……
https://www.npmjs.com/package/sus-io
閑話休題、実際にやっていきましょう!
目標
- 型チェックありきで安全に書きたい!(Typescript)
- import/export を縦横無尽に使いたい!(ESM)
- 複数のパッケージを同じレポジトリに入れたい!(Lerna による monorepo)
- 自動的にテストできるようにしたい!(Jest)
- ブラウザーでも、Node.js でも、Module としても利用できるようにしたい!(Rollup)
- パッケージのサイズを最小化したい!(Rollup)
- パッケージたちを無事 npm に上げて、皆が簡単に install できるようにしたい!(Lerna)
TL;DR
最後を除いて基本的手順通りにコミットしているので、わかる人は直接コミット履歴を見た方が早いかもしれません
事前準備
- Node.js + npm, git 当たり前ですね
- pnpm 別に他のでもいいんですが、早いのでこれ使います
- VSCode 使っていますが、特になんでもいいです
モノレポを作る
Lerna を使う
インストール
まず、新しいフォルダを作ります。名前は何でもいいんですが、ここでは monorepo-example
にしておきます。モノレポの特徴が表せるやつがいいかもですね。
それから先ほど作った monorepo-example
のフォルダを shell で開きます。これから基本的に場所の移動はしないので、全てのコマンドの cwd がこのフォルダと思ってもらって結構です。
次に、Lerna をインストールします。Lerna に CLI があるので、自動的にファイル類を生成してくれるから、以下のコマンドを実行します。
npx lerna init
(図にはないのですが、初めて実行する時は、ダウンロードする必要があるので、y を入れて許可しましょう。)
すると、package.json
、lerna.json
という設定ファイル、および packages
のフォルダが生成されます。
{
"name": "root",
"private": true,
"devDependencies": {
"lerna": "^4.0.0"
}
}
root パッケージ自体は publish する必要がないので、private
が true になって、いざ間違えて npm publish
しちゃった時も阻止してくれます。name
も何でもいいです。
{
"packages": [
"packages/*"
],
"version": "0.0.0"
}
これは Lerna の設定ファイルです。packages
の設定は、デフォルトでは packages の中のパッケージのみが認識されますが、ここで例えば apps/*
を追加したら apps の中のパッケージも認識されるようになります。version
は今のバージョンですね。
固定モードと独立モード
Lerna には、固定モード(Fixed/Locked)と独立モード(Independant)があります。固定モードは、全てのパッケージが同じバージョンになるようになります。それに対して、独立モードは個別でバージョンを管理できる。独立モードはめんどくさそうなので本稿では踏み込みません。(独立モードは lerna.json
の version
をindependent
に替えるとできます。)
plus
パッケージを追加する
普通に packages にパッケージを追加しましょう。試しに、plus
を追加してみます。
{
"name": "@mkpoli-arithmetic/plus",
"version": "0.0.0",
"type": "module",
"main": "src/index.js"
}
main
はパッケージの名前で import された時のエントリーポイントです。つまり、外から、node_modules にあるこのパッケージを、その名前である @mkpoli-arithmetic/plus
で以下のように直接 import する時に、main
に記載されているものに辿り着きます。
import { plus } from '@mkpoli-arithmetic/plus' // = from '@mkpoli-arithmetic/plus/src/index.js'
function plus(a, b) {
return a + b
}
export { plus }
う~ん、これで確かに追加できたけど、本当に動くかどうかわからないよね……そこで、そのパッケージを利用しているアプリを作りましょう!
app
パッケージを追加する
同じ要領で、以下のファイルを追加します。
{
"name": "@mkpoli-arithmetic/app",
"private": true,
"version": "0.0.0",
"type": "module",
"main": "src/index.js",
"dependencies": {
"@mkpoli-arithmetic/plus": "^0.0.0"
}
}
アプリの中で、@mkpoli-arithmetic/plus
を使いたいので、dependencies
の中にいれます。アプリは npm に上げても意味がないので、private
をオンにします。
import { plus } from '@mkpoli-arithmetic/plus'
console.log(plus(1, 2))
実行してみる
node ./packages/app
しかし、以上のコマンドを直接実行するとエラーが起きます。なぜなら、Node.js は packages/app/node_modules
フォルダから import
の対象を探しているけど、node_modules がまだ存在しないので、当然 ERR_MODULE_NOT_FOUND
エラーが出ます。
この時、Lerna の bootstrap 機能が助けてくれます。
npx lerna bootstrap
以上の命令を実行すると、packages/app/node_modules
の中に @mkpoli-arithmetic/plus
パッケージの内容がリンクされます。これで、packages/plus
に何か変化しても自動的に packages/app/node_modules/@mkpoli-arithmetic/plus
として反映されます。
node ./packages/app
これでもう一度実行すると、ちゃんと 3
と教えてくれます。
.gitignore
ちなみに、.gitignore がまだないので、node_modules も git のレポに入っちゃいます。それが嫌なので .gitignore 作って入れます。
node_modules/
minus
パッケージを追加する
今度は、加法ができたけど、減法もできるようにしたい!ということで、同じ要領で、減法も追加します。しかし減法ってさぁ、a - b って、a + (-b) じゃないですか?ということは、減法を書くのに、加法も利用できますよね!ということで、依頼関係は以下の図の通りです。
{
"name": "@mkpoli-arithmetic/minus",
"version": "0.0.0",
"type": "module",
"main": "src/index.js",
"dependencies": {
"@mkpoli-arithmetic/plus": "^0.0.0"
}
}
{
"name": "@mkpoli-arithmetic/minus",
"version": "0.0.0",
"type": "module",
"main": "src/index.js",
"dependencies": {
"@mkpoli-arithmetic/plus": "^0.0.0"
}
}
@@ -5,6 +5,7 @@
"type": "module",
"main": "src/index.js",
"dependencies": {
- "@mkpoli-arithmetic/plus": "^0.0.0"
+ "@mkpoli-arithmetic/plus": "^0.0.0",
+ "@mkpoli-arithmetic/minus": "^0.0.0"
}
}
@@ -1,3 +1,5 @@
import { plus } from '@mkpoli-arithmetic/plus'
+import { minus } from '@mkpoli-arithmetic/minus'
-console.log(plus(1, 2))
+console.log('1 + 2 =', plus(1, 2))
+console.log('3 - 2 =', minus(3, 2))
実行してみます
npx lerna bootstrap
node packages/app
すると、以下のように正常に出力されるはずです。
1 + 2 = 3
3 - 2 = 1
これで、ちゃんとした monorepo の作成に成功しました!
Typescript を使う
折角計算のパッケージができたのに、万が一誰かが数字じゃないものを入れちゃ大変なことになるので……
インストール
Typescript を使うには、まず typescript tslib を devDependencies にインストールする必要があります。Lerna では、パッケージたちに共通する devDependencies を設置することができます。なので、直接 root パッケージにインストールします。
pnpm i -D typescript tslib
Javascript を Typescript に書き換える
--- packages/plus/src/index.js
+++ packages/plus/src/index.ts
@@ -1,4 +1,4 @@
-function plus(a, b) {
+function plus(a: number, b: number): number{
return a + b
}
--- packages/minus/src/index.js
+++ packages/minus/src/index.ts
@@ -1,6 +1,6 @@
import { plus } from '@mkpoli-arithmetic/plus'
-function minus(a, b) {
+function minus(a: number, b: number): number {
return plus(a, -b)
}
設定ファイル
Typescript は結局のところ Javascript とは別言語なので、Typescript のライブラリを Javascript で使いたいなら、最終的には Javascript にコンパイルする必要があります。Typescript のソースファイル packages/**/src/**/*.ts
をコンパイルしたものを package/**/dist/**/*.js
に格納したいので、以下のように設定ファイルを書きます。
{
"compileOnSave": false,
"compilerOptions": {
"declaration": true,
"strict": true,
"target": "es6",
"moduleResolution": "node"
},
"exclude": ["node_modules", "dist"]
}
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
},
"include": ["src/**/*.ts"]
}
main を再指定する
前の段階では、packages/**/src/index.js
しかなかったため、packages/**/package.json
の main
項目で src/index.js
と指定していたが、今度は本当のエントリーポイントが packages/**/dist/index.js
となっているので、以下のように再指定します。
@@ -2,5 +2,5 @@
"name": "@mkpoli-arithmetic/plus",
"version": "0.0.0",
"type": "module",
- "main": "src/index.js"
+ "main": "dist/index.js"
}
@@ -2,7 +2,7 @@
"name": "@mkpoli-arithmetic/minus",
"version": "0.0.0",
"type": "module",
- "main": "src/index.js",
+ "main": "dist/index.js",
"dependencies": {
"@mkpoli-arithmetic/plus": "^0.0.0"
}
@@ -3,7 +3,7 @@
"private": true,
"version": "0.0.0",
"type": "module",
- "main": "src/index.js",
+ "main": "dist/index.js",
"dependencies": {
"@mkpoli-arithmetic/plus": "^0.0.0",
"@mkpoli-arithmetic/minus": "^0.0.0"
コンパイルする
lerna exec -- tsc
このコマンドを実行すると、コンパイルされた .js
/ .d.ts
ファイルが packages/**/dist/
に現れるはずです。lerna exec --
とは全てのパッケージに同じコマンドを実行する機能です。
初めてコンパイルする時は大丈夫そうなのですが、何かファイル構造の変更があったりすると、前にコンパイルしたものが残ってしまって汚いので、次回のコンパイル前に、先にアーティファクトを消しておきたい。そして、Wimdows では rm -rf が効かないので、どこでも綺麗にしてくれるものが欲しい!というときにもってこいのが、rimraf です。まずそれをpnpm i -D rimraf
でインストールしてから、以下のように全てのパッケージにおいて実行させます。させてからまた、前述のようにコンパイルすればよし!
lerna exec -- rimraf dist
流れをまとめる
毎回全く違う長いコマンドを打つのは流石に間違いやすいので、scripts
にコンパイルまでの流れを簡単にしたい!
@@ -1,7 +1,16 @@
{
"name": "root",
"private": true,
+ "scripts": {
+ "bootstrap": "lerna bootstrap",
+ "clean": "lerna exec -- rimraf dist/",
+ "build": "lerna exec -- tsc",
+ "start": "node packages/app"
+ },
"devDependencies": {
- "lerna": "^4.0.0"
+ "lerna": "^4.0.0",
+ "rimraf": "^3.0.2",
+ "tslib": "^2.3.1",
+ "typescript": "^4.5.5"
}
}
以上のようにスクリプトを追加したら、今度は新しい環境でも、古い環境でも、順に実行すれば、簡単にビルドしてくれる。
pnpm run bootstrap
pnpm run clean
pnpm run build
pnpm run start
Jest を使う
確かに app
では実際に E2E テスト的なことができるけれど、自分で実行しなければならないし、カバーできないエッジケースもあるだろうし、なんなら急に TDD したくなるかもしれない。それを簡単にやってくれるのが Jest です。シンプルに言えば、ユニットテストですね。
インストール
まず同じくインストールします〜
pnpm i -D jest
これで普通の .spec.js
でテストを行うことができますが、今は Typescript のプロジェクトなので、以下のような特別設定が必要です。
Typescript 対応設定
Jest はデフォルトで Typescript に対応していないので、対応用のものをインストールします。
pnpm i -D ts-jest @types/jest
それから packages/**/tsconfig.spec.ts
や packages/**/jest.config.js
を書きます。
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["jest", "node"]
},
"include": ["**/*.test.ts", "**/*.spec.ts", "**/*.d.ts"]
}
module.exports = {
displayName: 'plus',
globals: {
'ts-jest': {
tsconfig: '<rootDir>/tsconfig.spec.json',
useESM: true,
},
},
transform: {
'^.+\\.[tj]s$': 'ts-jest',
},
extensionsToTreatAsEsm: ['.ts'],
coverageDirectory: '../../coverage/packages/plus',
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
},
};
module.exports = {
displayName: 'minus',
globals: {
'ts-jest': {
tsconfig: '<rootDir>/tsconfig.spec.json',
useESM: true,
},
},
transform: {
'^.+\\.[tj]s$': 'ts-jest',
},
extensionsToTreatAsEsm: ['.ts'],
coverageDirectory: '../../coverage/packages/minus',
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
},
};
テストケースを書く
import { plus } from './index'
it('should return the sum of two numbers', () => {
expect(plus(1, 2)).toBe(3)
expect(plus(2, 1)).toBe(3)
expect(plus(2, 2)).toBe(4)
})
import { minus } from './index'
it('should return the difference of two numbers', () => {
expect(minus(1, 2)).toBe(-1)
expect(minus(2, 1)).toBe(1)
expect(minus(2, 2)).toBe(0)
})
テストを実行する
まず以下のようにスクリプトを追加します。
@@ -3,13 +3,17 @@
"private": true,
"scripts": {
"bootstrap": "lerna bootstrap",
+ "test": "lerna exec -- jest --passWithNoTests",
"clean": "lerna exec -- rimraf dist/",
"build": "lerna exec -- tsc",
"start": "node packages/app"
},
"devDependencies": {
+ "@types/jest": "^27.4.0",
+ "jest": "^27.5.1",
"lerna": "^4.0.0",
"rimraf": "^3.0.2",
+ "ts-jest": "^27.1.3",
"tslib": "^2.3.1",
"typescript": "^4.5.5"
}
それからテストを実行します。
pnpm run test
すると、このようにテストが実行され、成功します。
これで Jest によるテストを完璧にできました。
Rollup を使う
以上のステップで、十分自己満足ができたけれど、他の人にも簡単にこのパッケージたちを使えるように、色々工夫していきます。
インストールする
Rollup 本体及びプラグインをインストールします。
pnpm i -D rollup
pnpm i -D @rollup/plugin-node-resolve rollup-plugin-ts rollup-plugin-terser
設定する
-
@rollup/plugin-node-resolve
はパスの解析をやってくれるので、他の npm モジュールをインポートする時には必要ですね -
rollup-plugin-ts
は Typescript と統合してくれます。公式の@rollup/plugin-typescript
もありますが、あれは.d.ts
をまとめてくれないので、こちらを使います -
rollup-plugin-terser
はいわゆる minifier で、コードのサイズを最小限に抑えてくれるものです
import resolve from '@rollup/plugin-node-resolve'
import ts from 'rollup-plugin-ts'
import { terser } from 'rollup-plugin-terser'
import path from 'path'
const { LERNA_PACKAGE_NAME } = process.env
const PACKAGE_ROOT_PATH = process.cwd()
export default [
{
input: path.join(PACKAGE_ROOT_PATH, 'src', 'index.ts'),
output: [
{
file: `dist/index.cjs.js`,
format: 'cjs',
sourcemap: true,
},
{
file: `dist/index.esm.js`,
format: 'esm',
sourcemap: true,
},
{
name: LERNA_PACKAGE_NAME,
file: `dist/index.umd.js`,
sourcemap: true,
format: 'umd',
},
],
plugins: [
ts(),
resolve(),
terser()
],
},
]
この rollup.config.js
では、特に難しいことをやっていなくて、単純に PACKAGE_ROOT_PATH
(例えば plus なら .../packages/plus/
になります)から、./src/index.ts
をそれぞれ、CommonJS の ./dist/index.cjs.js
、ESModule の ./dist/index.esm.js
そして UMD の ./dist/index.umd.js
を生成するということです。
- CommonJS: Node.js
- ESM: 新バージョンの Node.js および新バージョンのブラウザー
- UMD: AMD と CommonJS を統合したもので、ブラウザーに使われる
ビルドする
今度は、tsc を rollup に替えます。
@@ -5,14 +5,18 @@
"bootstrap": "lerna bootstrap",
"test": "lerna exec -- jest --passWithNoTests",
"clean": "lerna exec -- rimraf dist/",
- "build": "lerna exec -- tsc",
+ "build": "lerna exec -- rollup -c ../../rollup.config.js",
"start": "node packages/app"
},
"devDependencies": {
+ "@rollup/plugin-node-resolve": "^13.1.3",
"@types/jest": "^27.4.0",
"jest": "^27.5.1",
"lerna": "^4.0.0",
"rimraf": "^3.0.2",
+ "rollup": "^2.67.2",
+ "rollup-plugin-terser": "^7.0.2",
+ "rollup-plugin-ts": "^2.0.5",
"ts-jest": "^27.1.3",
"tslib": "^2.3.1",
"typescript": "^4.5.5"
pnpm run clean
pnpm run build
すると、packages/**/dist
には、以下のような 9つの ファイルが出力されます。
エントリーポイントを再指定する
これで、三種類のバンドルしたファイルが作れたわけですが、外部からはまだアクセスできません。なので、packages/**/package.json
をもう一度弄ることになります。
@@ -1,6 +1,16 @@
{
"name": "@mkpoli-arithmetic/plus",
"version": "0.0.0",
- "type": "module",
- "main": "dist/index.js"
+ "main": "dist/index.cjs.js",
+ "module": "dist/index.esm.js",
+ "browser": "dist/index.umd.js",
+ "exports": {
+ "import": "dist/index.esm.js",
+ "require": "dist/index.cjs.js",
+ "browser": "dist/index.umd.js",
+ "default": "dist/index.cjs.js"
+ },
+ "files": [
+ "dist"
+ ]
}
@@ -1,8 +1,18 @@
{
"name": "@mkpoli-arithmetic/minus",
"version": "0.0.0",
- "type": "module",
- "main": "dist/index.js",
+ "main": "dist/index.cjs.js",
+ "module": "dist/index.esm.js",
+ "browser": "dist/index.umd.js",
+ "exports": {
+ "import": "dist/index.esm.js",
+ "require": "dist/index.cjs.js",
+ "browser": "dist/index.umd.js",
+ "default": "dist/index.cjs.js"
+ },
+ "files": [
+ "dist"
+ ],
"dependencies": {
"@mkpoli-arithmetic/plus": "^0.0.0"
}
これでもう無敵ですね……
アプリに関しては、そもそも npm に上げる必要がないので、exports
や複数のエントリーポイント指定も要りません。せっかく Rollup にビルドしてもらったので、直接 packages/app/dist/index.esm.js
を指定します。もちろん、Rollup の設定を細かく弄ることで、不必要な他のバンドル形式を生成しないようにすることもできますが、ここでは割愛させていただきます。
@@ -3,7 +3,7 @@
"private": true,
"version": "0.0.0",
"type": "module",
- "main": "dist/index.js",
+ "main": "dist/index.esm.js",
"dependencies": {
"@mkpoli-arithmetic/plus": "^0.0.0",
"@mkpoli-arithmetic/minus": "^0.0.0"
今度こそ本当に準備が整いました!
npm にアップする
公開するには
- npm のアカウントやログイン、場合によっては 2FA の設定などを済ましておく必要がある
- Lerna の publish 機能を使ってバージョンを上げつつ、パブリッシュする
流れをまとめる
まず、アップする前に、今までのビルドワークフローを一度見返してみましょう
pnpm run bootstrap
pnpm run clean
pnpm run build
pnpm run test
pnpm run start
( test を後ろに置いたのは、先にビルドして、内部依存関係を解決しなければならなかったから)
pnpm run start
は公開に必要がないので、bootstrap
・clean
、build
、test
の四つのステップが必要ということがわかります。
lerna publish
最後に以上のコマンドを付け加えたいのですが、やはりこれも統一して package.json
のスクリプトにしておいた方が良さそうです。
連続実行
さらに、この一連のコマンドを何度も実行するのも間違いやすいから、一遍に実行できるようにしたい!ということで、npm-run-all
を使います。
pnpm i -D npm-run-all
そして、一連の工程をまとめた release スクリプトを作ります。
@@ -6,13 +6,16 @@
"test": "lerna exec -- jest --passWithNoTests",
"clean": "lerna exec -- rimraf dist/",
"build": "lerna exec -- rollup -c ../../rollup.config.js",
- "start": "node packages/app"
+ "start": "node packages/app",
+ "publish": "lerna publish",
+ "release": "run-s bootstrap clean build test publish"
},
"devDependencies": {
"@rollup/plugin-node-resolve": "^13.1.3",
"@types/jest": "^27.4.0",
"jest": "^27.5.1",
"lerna": "^4.0.0",
+ "npm-run-all": "^4.1.5",
"rimraf": "^3.0.2",
"rollup": "^2.67.2",
"rollup-plugin-terser": "^7.0.2",
これで、以下のコマンドだけでリリースすることができます。
pnpm run release
パッケージのスコープ
npm のレジストリには、Scoped パッケージと Unscoped パッケージがある。簡単に言えば、@abc/def
のような前に変なものが付いたのが scoped、def
のような単純なパッケージ名が unscoped。
本稿の例としての @mkpoli-arithmetic/plus
が Scoped のものとなります。Unscoped パッケージは、特別な設定をせずとも、直接 npm に上げることができます。なので、もし Unscoped パッケージを上げたい場合は、前の節で既に上げることができます。
しかし、Scoped パッケージでは少しややこしくなります。まず、Scoped パッケージはスコープを獲得する必要があります。それをするには、まず npmjs.com で「組織を追加する」(Add Orgnization)必要があり、組織名称はそのままスコープになります。
それから、npm はデフォルトで、Scoped パッケージを上げる時、私有パッケージになります。しかし、これは課金しなければ使えません。更にそもそも公開という目的に反している。なので、特別な設定を行うことで、パッケージを公開する前提で publish しなければなりません。
具体的な手段は、沢山ありますが、packages/**/package.json
で以下のようなものを追加すればできます。
{
[...]
"publishConfig": {
"access": "public"
}
}
成功🎉
無事以下の2つのパッケージを npm に上げることができました!やったー
補足
lerna version と lerna publish を分ける
場合によっては、一気に lerna publish でバージョンを上げて、そして publish することが望ましくないかもしれません。例えば、バージョン情報をビルドに含めたい場合など、先にバージョンを更新してから、ビルドしたいこともあるでしょう。
その場合は、先に npx lerna version
を実行してからビルドし、最後に npx lerna publish from-git
でやればできます。
実際に使ってみる
では、早速試してみよう!
pnpm create vite test
cd test
pnpm install
pnpm run dev
以上で、フロントエンドのプロジェクトを作ります。ここでは Svelte-TS を例にします。
それから
pnpm install @mkpoli-arithmetic/plus @mkpoli-arithmetic/minus
<script lang="ts">
import { plus } from '@mkpoli-arithmetic/plus'
import { minus } from '@mkpoli-arithmetic/minus'
let count: number = 0
const increment = () => {
count = plus(count, 1)
}
const decrement = () => {
count = minus(count, 1)
}
</script>
100 + 1 - 1000 = {minus(plus(100, 1), 1000)}
<div class="container">
<button on:click={decrement}>-</button>
<div>Clicks: {count}</div>
<button on:click={increment}>+</button>
</div>
<style>
.container {
display: flex;
margin: 0 auto;
align-items: center;
justify-content: center;
gap: 2em;
}
button {
font-family: inherit;
font-size: inherit;
padding: 1em;
font-variant-numeric: tabular-nums;
cursor: pointer;
}
</style>
すると正常に作動していることがわかります。
結言
疲れたー
自分は余り聡明な人でもないし、何よりパソコンの環境を整えるのに何故かそこだけ運が悪くて、スムーズに一回だけで綺麗に環境を整えられた験がない……
さらに、よく言うと完璧主義、悪く言うと強迫観念、それの塊なので、満足の行くまで綺麗にできなきゃいけない、実用から程遠い細部に拘ってしまいがちで、結局時間をあり得ないほど無駄している。
今回の npm 問題で、少なくとも 30 時間は浪費しているけれど、まぁとりあえず経験にはなっているから、誰かのためになることを願います。
ここまでご覧くださり、ありがとうございました。
Discussion