Skip to content

Instantly share code, notes, and snippets.

@x7ddf74479jn5
Last active June 18, 2024 08:49
Show Gist options
  • Save x7ddf74479jn5/8dd150540b962252a40e9d3b4b6d211e to your computer and use it in GitHub Desktop.
Save x7ddf74479jn5/8dd150540b962252a40e9d3b4b6d211e to your computer and use it in GitHub Desktop.
tsupでDual Package対応したい

tsup で Dual Package 対応したい

前提

Native ESM + TypeScript 拡張子問題: 歯にものが挟まったようなスッキリしない書き流し

ES Modules(ESM)と CommonJS(CJS)のパッケージの相互運用については課題があり、ESM のモジュールシステムと Node のモジュールシステムに互換性がないため歴史的経緯から非常にややこしいことになっている。

Node 環境ではデフォルトが CJS のプロジェクトになり、"type": "module"を指定することで Native ESM1なプロジェクトとなる。他には webpack を代表するバンドラーが CJS な環境で ESM の Syntax でモジュール解決を行う Fake ESM という環境があり、Next.js などで採用されいてる2

問題点としては例えば、ESM のモジュールは基本的に ESM のコードしかインポートできず、CJS のプロジェクトでは Dynamic Import で無理矢理インポートするという不格好なやり口を強いられる。

CommonJS から ES Modules への移行する方法。トップダウンかボトムアップか | Web Scratch

また、実行するのも一筋縄ではいかず、node --loader ts-node/esmのようにオプションを付与して実行するのだが、tsconfig.jsonpackage.jsonの設定ミス、実装のミスによってエラーにハマりやすい。難解なので初見者殺しを超えて中級者を含む広い範囲に苦しみを与えてしまう。

TypeScript の ESM でハマる - くらげになりたい。

何が問題だったかというと、単一のモジュールを別々のモジュールシステムで解決しようとしているところである。そこでDual Packageといわれる、ESM と CJS の両形式でそれぞれバンドルして CJS と ESM のプロジェクトどちらからも使えるようにする解決策がある。ESM か CJS かによってインポートするファイルを変えることで相互運用を実現する。これはConditional Exportsという。例えば、CJS のプロジェクトではindex.jsを、ESM のプロジェクトではindex.mjsをインポートするようpackage.jsonで指定するといったものだ。

基本情報

tsupは自作パッケージを CJS 形式と ESM 形式の両方で公開したいときに、このような Dual Package 対応の機能を提供してくれるバンドルツールだ。esbuildを採用しているため高速にビルドしてくれる。

一応 TypeScript 以外でもバンドルできることは留意したい。現代の開発では基本的に TypeScript で書くので公式のサンプルもそうなっているが、不可抗力的な事由で TypesScript で書ききれないときもある。

Anything that's supported by Node.js natively, namely .js, .json, .mjs. And TypeScript .ts, .tsx. CSS support is experimental.

使い方

pnpm add -D tsup
tsup src/index.ts src/cli.ts

/distindex.jscli.jsが吐き出される。

設定方法

config ファイルで設定する

  • tsup.config.ts
  • tsup.config.js
  • tsup.config.cjs
  • tsup.config.json
  • tsup property in your package.json

tsup.config.ts

import { defineConfig } from "tsup";

export default defineConfig({
  entry: {
    index: "src/index.js",
    foo: "src/presets/foo.js",
    bar: "src/presets/bar.js",
  },
  format: ["cjs", "esm"], // 出力する形式を指定
  splitting: false, // バンドルしないで分割するか
  sourcemap: false, // soucemapを出力するか
  clean: true, // build前にディレクトリ内を削除するか
  minify: process.env.NODE_ENV === "production",
  treeshake: true,
});

エントリーポイント

export default defineConfig({
  // Outputs `dist/index.js`
  entry: ["src/index.ts"],
  // Outputs `dist/a.js` and `dist/b.js`.
  entry: ["src/a.ts", "src/b.ts"],
  // Outputs `dist/foo.js` and `dist/bar.js`
  entry: {
    foo: "src/a.ts",
    bar: "src/b.ts",
  },
});
シングルエントリーポイント

index.jsをひとつだけ指定する。単一のエントリーポイントにモジュールを集約してビルドする形式3index.d.tsに全ての型定義があるため大きめのライブラリだと見通しが悪くなる。

export default defineConfig({
  entry: ["src/index.ts"],
});
// index.ts
export * from "./foo/a";
export * from "./utils";
.
├── dist
│  ├── index.d.ts
│  └── index.js
└── src
  ├── foo
  │  └── a.ts
  ├── index.ts
  └── utils
    └── index.ts
マルチエントリーポイント

デフォルトのエントリーポイント以外のファイルやディレクトリを柔軟に公開できる利点がある。ディレクトリ構造を維持してファイルがまとまっているため見通がよくなる。

export default defineConfig({
  entry: ["src/**/*.ts"],
});
.
├── dist
│  ├── foo
│  │  ├── a.d.ts
│  │  └── a.js
│  └── utils
│    ├── index.d.ts
│  	  └── index.js
└── src
  ├── foo
  │  └── a.ts
  └── utils
    └── index.ts

モジュールを別名でビルドしたいとき

export default defineConfig({
  entry: {
    ".": "index.ts",
    foo: "src/foo/a.ts",
    utils: "src/utils/*.ts",
  },
});
.
├── dist
│  ├── foo.d.ts
│  ├── foo.js
│  ├── index.d.ts
│  ├── index.js
│  └── utils
│    ├── index.d.ts
│  	  └── index.js
└── src
  ├── foo
  │  └── a.ts
  ├── index.ts
  └── utils
    └── index.ts
import index from "@x7ddf74479jn5/example-package";
import foo from "@x7ddf74479jn5/example-package/foo";
import utils from "@x7ddf74479jn5/example-package/utils";

CLI でオプションで指定する

tsup src/index.ts --format esm,cjs

Options

option value description
format esm, cjs, iife バンドル形式
dts - 型定義を出力する
sourcemap - sourcemap を出力する
watch - watch モード
minify - minify する
treeshake - treeshake するか

補足

tsup(esbuild)は tsc とは別のビルド方式であり、そのため tsup はtsconfig.jsonを見ない。つまり、tsconfig.jsonで例えばsourcemap: trueにしても反映されない。また、tsup は declaration map のサポートを意図的にしていない。必要な場合はビルドチェーン上で別途 tsc を使い出力する。

Generate TypeScript declaration maps (.d.ts.map)

TypeScript declaration maps are mainly used to quickly jump to type definitions in the context of a monorepo (see source issue and official documentation).
They should not be included in a published NPM package and should not be confused with sourcemaps.
Tsup is not able to generate those files. Instead, you should use the TypeScript compiler directly, by running the following command after the build is done: tsc --emitDeclarationOnly --declaration.

バンドルサイズとトレードオフだが、パッケージに ts ファイルと declaration map を含めたほうがいいようだ。IDE によるコードジャンプが可能で開発者体験としてはよい。

調査:良い DX をライブラリユーザーに提供するために、TypeScript ライブラリの tsconfig 設定はどうあるべきか?

package.jsonの設定

デュアルパッケージ開発者のための tsconfig (Dual Package) | TypeScript 入門『サバイバル TypeScript』

{
  "name": "@x7ddf74479jn5/example-package", // [1]
  "version": "1.0.0",
  "description": "x7ddf74479jn5's example-package",
  "main": "./dist/index.cjs", // [2]
  "module": "./dist/index.js", // [3]
  "types": "./dist/index.js", // [4]
  "exports": {
    ".": {
      "require": "./dist/index.cjs", // [5]
      "import": "./dist/index.js", // [6]
      "default": "./dist/index.js" //[7]
    },
    // [8]
    ".": {
      "import": {
        "types": "./dist/index.d.ts",
        "default": "./dist/index.js"
      },
      "require": {
        "types": "./dist/index.d.ts",
        "default": "./dist/index.cjs"
      }
    },
    "/foo": {
      "import": ".dist/foo.js"
    }
  },
  // [9]
  "files": ["dist", "src", "!src/**/*.test.ts"],
  // [10]
  "sideEffects": ["**/*.css"],
  "type": "module", // [11]
  "devDependencies": {
    "tsup": "6.7.0",
    "typescript": "5.0.3"
  },
  "scripts": {
    "tsup": "tsup"
  },

  "license": "MIT",
  "private": false, // [12]
  // [13]
  "publishConfig": {
    "access": "public",
    "registry": "https://registry.npmjs.org/"
  },
  // [14]
  "keywords": ["example"],
  "author": "x7ddf74479jn5 <[email protected]>",
  "homepage": "https://github.com/x7ddf74479jn5/example-package/tree/main/#readme",
  "repository": {
    "type": "git",
    "url": "https://github.com/x7ddf74479jn5/example-package.git"
    // "directory": "packages/foo"
  },
  "bugs": {
    "url": "https://github.com/x7ddf74479jn5/configs/issues"
  }
}
  1. {npm の organization name | @username}/{package-name}の形式で指定するのが推奨されいている4。npm Package registry は同一名のパッケージを公開できないため前半部のユーザー名や組織名が名前空間として機能する。
  2. CJS でのエントリーポイント(フォールバック)
  3. ESM でのエントリーポイント(フォールバック)Node 公式にサポートされているわけではないが経緯的な理由でバンドラーがサポートを続けている5
  4. 型定義ファイルのエントリーポイント(フォールバック)
  5. CJS でのエントリーポイント
  6. ESM でのエントリーポイント
  7. ESM でのエントリーポイントのフォールバック(なくてもいい)
  8. 実体ファイルと型定義ファイルを同時に指定する記法。typesを指定しなければフォールバックの方のtypesを見に行くが、明示的な指定が推奨されいている。
  9. 公開するファイルやフォルダを指定する。このプロパティに指定したものがインストールされる。逆に言えば、指定しなかったものはインストールに含まれないのでインストールサイズの削減につながる。秘匿情報に注意して最小限の範囲で記述すべき。
  10. tree-shaking 最適化のため副作用があるファイルをバンドラーに知らせる。ここで指定していないファイルの副作用は無視される。副作用のあるファイルがない場合は"sideEffects": falseと記述する。
  11. typemoduleのときは ESM 形式のプロジェクト、CJS(デフォルト)のときは CJS 形式のプロジェクト。tsup はこの値を見て出力ファイル(バンドル形式)を決める。typemoduleのときはindex.jsindex.cjsを出力する。typeCJSのときはindex.jsindex.mjsを出力する。
  12. trueだとパッケージを公開できない。公開する必要のない開発時にだけ利用する内部パッケージや検証用のアプリ、モノレポのルートのpackage.jsonではtrueを指定し、公開するパッケージのみfalseを指定する。
  13. npm のレジストリの他に GitHub や GitLab のレジストリがある。npm はエコシステムに全体公開されるため、プライベートに利用したいパッケージは後者の GitHub などのレジストリに上げればいい。ただし、その場合 GitHub の PAT が必要になるのでpackage.json内に記述するのは避け、.npmrcの方に書き、トークンは環境変数から渡すなどする6

exports の補足

mainexportsの両方が存在する場合、exportsが優先されるが、mainexportsの両方を定義しておくことが推奨されている。mainフィールドはexportsがサポートされていない環境でのフォールバックになる。

Conditional exports を実現するためにはexportsのフィールドにimportrequireが設定されいてる必要がある。ここに ESM と CJS のように環境ごとで異なるエントリーポイントをパス指定する。

/fooのような形でパスフィールドを指定すると、例えばimport foo from "@x7ddf74479jn5/example-package/foo"のように下の階層のモジュールのみをパス指定でインポートできる。

npm のページに掲載される情報

  • author: レポジトリの作成者のユーザー名
  • homepage: 公式サイトがあるなら
  • repository: レポジトリの URL を指定(モノレポならパッケージのディレクトリを指定->packages/foo)
  • bugs: レポジトリの Issue ページの URL
  • keywords: npm の検索用タグ

その他

npm pack --dry-runで実際に公開されるファイル一覧を確認できる。注意点としてnpm packpnpm packではなぜか挙動に違いがあり、含まれるファイルが違ったりする。

独自の npm registry を使う - Qiita: 検証用にローカルのパッケージをインストールする方法やローカルサーバーをレジストリとして登録する方法。

参考

Footnotes

  1. Fake ESM に対して Node のネイティブな ESM 環境。

  2. CJS 環境でimportが使えるのはこのため。

  3. barrel export とも呼ばれる。

  4. scope | npm Docs

  5. javascript - What is the "module" package.json field for? - Stack Overflow

  6. GitHub Packages を npm install するための手段あれこれ - mizdra's blog

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment