Open9

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

tasshitasshi

達成したいこと

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

バンドラ

  • 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にした。
https://npmtrends.com/microbundle-vs-tsdx-vs-tsup-vs-unbuild

tsupの詳細はこちらの記事が分かりやすかった。
最近のESM/CJS周りのややこしい設定をゼロコンフィグでいい感じにやってくれるのが強み。
https://zenn.dev/okunokentaro/articles/01jf78zf9ebjsq8ywjm4jrtk30

Dual Packageの実現方法

ここからが本命
1つのTypeScriptのソースコードからCJS/ESM両方のコードを出力したい。
ソースコードはtype: module、つまりESM。
1つのパッケージ内でESM/CJSの両方を配信するため、出力したコードのうち、ESMがESMとして、CJSがCJSとして読み込まれるように工夫する必要がある。

これを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

再現リポジトリ: https://github.com/tasshi-playground/demo-typescript-monorepo-2025/tree/main/packages/demo-package-tsc

Vite

Viteの場合、JSの出力は拡張子も適切に変換してくれる。しかし型情報をvite-plugin-dtsで追加する場合、型情報は.d.ts -> .d.ctsの変換は行ってくれない。(ref)

そのため、以下のどちらかを実施する。

  1. 生成された.d.tsをコピーしてCJS用に拡張子・import文を書き換える

  2. JSの生成時に拡張子の変換を行わずに、ESM/CJSどちらも.jsで出力する。その後、型情報も.d.tsで出力して、ESM/CJSそれぞれの出力ディレクトリにpackage.jsonを追加する。

  3. の場合、vite-plugin-dtsの設定に変換処理を書くことになるが、かなりややこしい。

https://github.com/qmhc/vite-plugin-dts/issues/267#issuecomment-2142950802

再現リポジトリ: https://github.com/tasshi-playground/demo-typescript-monorepo-2025/tree/main/packages/demo-package-vite

  1. の場合、割と簡単にできる。

再現リポジトリ: https://github.com/tasshi-playground/demo-typescript-monorepo-2025/tree/main/packages/demo-package-vite-2

tsup

ほぼゼロコンフィグでサクッと拡張子変換までできてた。良い。

再現リポジトリ: https://github.com/tasshi-playground/demo-typescript-monorepo-2025/tree/main/packages/demo-package-tsup


しかし、npmパッケージはバンドルしない方が良いのでは的な話もある。

https://zenn.dev/nissy_dev/articles/how-to-make-tree-shakeable-libraries
https://cmdcolin.github.io/posts/2022-05-27-youmaynotneedabundler


他にもCJS->ESMのラッパーを作成するなどの方法もあるけど今回はやらない
https://github.com/microsoft/TypeScript/issues/49462#issuecomment-1633279027
https://zenn.dev/teppeis/articles/2022-07-npm-dual-pacakge-cjs-proxy

tasshitasshi

Linter/Formatter

Linter

ESLintを使う。
Shared configがあるため。

https://github.com/cybozu/eslint-config

そのうちBiomeの利用を検討する

Formatter

Prettierを使う。

Biomeを検討したがまだYAMLとMarkDownが未サポートのため。
https://biomejs.dev/internals/language-support/

Linter for package.json

npmパッケージとして正常にインポートできるようにpackage.jsonの設定を検査したい。
Dual Packageサポートするので尚更。

publintのほうがゼロコンフィグでできるので一旦publintを導入する。
合わせてeslint-plugin-package-jsonも導入する。

結論

=> eslint + prettier + publint

tasshitasshi

ライセンス管理

licensedはgithubがメンテナンスしてたと記憶しているけどlicensee orgに移管されて、今はlow maintenance modeになっているので新規採用はしない。

LicenseFinderとlicensedはRubyGem。
@cybozu/license-managerがnpmパッケージとして利用できるのでnpmプロジェクトで利用するには良い感じ。

=> @cybozu/license-manager