Dual Package対応のnpmパッケージの開発環境を考える

達成したいこと
- TypeScriptでnpmパッケージを開発
- 利用方法
- 別のnpmプロジェクトから読み込み
- Node.jsなどで利用
- バンドルしてブラウザで利用
- ブラウザから直接読み込み
- 別のnpmプロジェクトから読み込み
- 出力形式
- CJS/ESM/UMD
- ESMは拡張子指定などもやる、いわゆるNative ESM
- CJS/ESM/UMD
- ソースコード
- TypeScript
- ESM
- monorepo
- テスト
- Lint/Format

技術選定するもの
-
開発言語: TypeScript
- tsconfigの設定
- パッケージマネージャー: pnpm
- バンドラ: ???
- テストランナー: Vitest
- Linter/Formatter: ESLint + @cybozu/eslint-config + Prettier + publint
- CI/CD基盤: GitHub Actions
- ライセンス管理: - @cybozu/license-manager
- リリース管理: release-please
サンプルリポジトリ

バンドラ
- tsc
- rollup
- esbuild
- vite
- その他
- micorbundle
- tsdx
- tsup
- unbuild
ref. https://zenn.dev/rizzzse/scraps/845a13f6a399a4
実はこの時点で大体触ってtsupに決めかけているのだけれど、ちゃんとメモを残すことにした
評価観点
- コンフィグを頑張らなくて良いか
- メンテナンスされているか
- 利用実績があるか
コンフィグは実際に試す。
メンテナンス・利用実績はnpm trendsとかmoivaとかsnyk Advisorとか見る (ref)
利用実績・メンテナンス
- tsc: 公式コンパイラ
- vite or rollup or esbuild => vite (とりあえず)
- その他 => tsup
その他についてはとりあえず利用数ベースでtsupにした。
tsupの詳細はこちらの記事が分かりやすかった。
最近のESM/CJS周りのややこしい設定をゼロコンフィグでいい感じにやってくれるのが強み。
Dual Packageの実現方法
ここからが本命
1つのTypeScriptのソースコードからCJS/ESM両方のコードを出力したい。
ソースコードはtype: module
、つまりESM。
1つのパッケージ内でESM/CJSの両方を配信するため、出力したコードのうち、ESMがESMとして、CJSがCJSとして読み込まれるように工夫する必要がある。
- 拡張子を適切に設定する、ESMのコードは.js or .mjsを、CJSのコードは.cjsを指定する
- ref.
- ディレクトリを分けて、
type: module | commonjs
を指定したpackage.jsonをそれぞれに配置する
これをtsc, Vite, tsupで実際にやってみると以下のようになる。
tsc
tsconfigをSolution Styleで複数用意する。
- tsconfig.json: 共通設定
- tsconfig.build.esm.json: ESMビルド用
- tsconfig.build.cjs.json: CJSビルド用
- tsconfig.typecheck.json: 型チェック用、テストコードなども対象にしている
tscではjs -> cjsの拡張子変換をやってくれないので、ビルド後にESM/CJSそれぞれの出力ディレクトリにpackage.jsonを追加する。
- ESM:
echo '{\"type\": \"module\"}' > lib/esm/package.json
- CJS:
echo '{\"type\": \"commonjs\"}' > lib/cjs/package.json
Vite
Viteの場合、JSの出力は拡張子も適切に変換してくれる。しかし型情報をvite-plugin-dts
で追加する場合、型情報は.d.ts
-> .d.cts
の変換は行ってくれない。(ref)
そのため、以下のどちらかを実施する。
-
生成された
.d.ts
をコピーしてCJS用に拡張子・import文を書き換える -
JSの生成時に拡張子の変換を行わずに、ESM/CJSどちらも
.js
で出力する。その後、型情報も.d.ts
で出力して、ESM/CJSそれぞれの出力ディレクトリにpackage.jsonを追加する。 -
の場合、vite-plugin-dtsの設定に変換処理を書くことになるが、かなりややこしい。
- の場合、割と簡単にできる。
tsup
ほぼゼロコンフィグでサクッと拡張子変換までできてた。良い。
しかし、npmパッケージはバンドルしない方が良いのでは的な話もある。
他にもCJS->ESMのラッパーを作成するなどの方法もあるけど今回はやらない

パッケージマネージャー
monorepoだし、pnpmで良さそう
(標準のnpmとの互換性も高いのでそこまで突き詰めて決めなくても良さそう。)
この記事がよくまとまってた↓

Linter/Formatter
Linter
ESLintを使う。
Shared configがあるため。
そのうちBiomeの利用を検討する
Formatter
Prettierを使う。
Biomeを検討したがまだYAMLとMarkDownが未サポートのため。
Linter for package.json
npmパッケージとして正常にインポートできるようにpackage.jsonの設定を検査したい。
Dual Packageサポートするので尚更。
publintのほうがゼロコンフィグでできるので一旦publintを導入する。
合わせてeslint-plugin-package-jsonも導入する。
結論
=> eslint + prettier + publint

テストランナー
あまり考えずにVitestでもいい感じはある。
Jest + babel-jestで頑張ってたけどそろそろVitestでいいかも
Node.js test runnerはそのうち選択してもいいけどまだかな。
=> Vitest

CI/CD基盤
=> GitHub Actions

ライセンス管理
licensedはgithubがメンテナンスしてたと記憶しているけどlicensee orgに移管されて、今はlow maintenance modeになっているので新規採用はしない。
LicenseFinderとlicensedはRubyGem。
@cybozu/license-managerがnpmパッケージとして利用できるのでnpmプロジェクトで利用するには良い感じ。
=> @cybozu/license-manager