8
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

BunでESM、CJS両対応のTypeScriptプロジェクトを公開する!

Posted at

Bunを使ってESMとCJS両対応(あとDenoも)のTypeScriptプロジェクトを公開する!

ESM、CJS、ブラウザ(Deno)のどこでも使えるライブラリを公開するためのテンプレートを作成しました!
思ったよりも苦戦したので、その記録兼解説として残して置きます。

先に完成品の紹介

背景

JavaScript(Node.js)には数多のパッケージが存在するため、自身でパッケージを作らなくても大抵はなんとかなります。しかし、中々理想と言えるものに巡り会えることはありません。あと一歩…!と感じることも多いでしょう。

大抵の場合はOSSで開発されているため、コントリビュートのチャンスとも捉えることが出来ますが、小規模だったり特定の機能のみであれば、自作してしてしまったほうが早いこともあります。そして、折角作ったなら公開して社会に貢献したいところです。

ということで、TypeScript(JavaScript)のライブラリを手っ取り早く公開できるようにしてみましょう!

採用技術と設定解説(一部)

折角なので最新のトレンドを採用して行きたいと思います。

Bun

最近、正式リリースされたJavaScript(TypeScript)ランタイム兼パッケージマネージャー兼テストランナーです。動作については実感を得られるほどの規模で開発は行えていないですが、パッケージマネージャーとしては段違いに高速です。テンプレートはパッケージも少ないため、10秒もかからずインストールが完了するでしょう。

また、バンドラーとしての機能も持ち合わせています。後述の理由からバンドラーとしての採用を見送りましたが、今後開発が進めば、こちらに切り替えても良さそうです。

Biome

こちらも最近公開されたリンター兼フォーマッターです。Romeというプロジェクトからフォークされ、再始動したプロジェクトです。Prettierのテストの95%以上を満たすことに成功し、賞金を獲得したという実績も挙げています。

速度面はもちろん高速であることに加え、ESLintと比べて非常に設定が容易です。Prettier + ESLintの競合などの心配もありません。VueやAstro、Svelteのような独自テンプレートには未対応のようですが、今回のような純粋なJS、TSの構文のみ扱う場合は十分な性能だと思っています。

テンプレートリポジトリでは以下のコマンドで実行可能です。

bun check # 静的解析(型チェック込み)
bun fix # 静的解析(型チェック無し)+ 自動修正
bun test
bun precommit # 諸々全部実行

esbuild

こちらはすっかり定番となったバンドラーですね。

当初はBunのバンドラーを利用していたのですが、ESM以外の出力が出来ない点や、splittingを有効した際のimportパスがおかしい といった問題を解決できなかったため、採用を見送っています。この他にViteも検討していたのですが、今回の用途では過剰スペックである上、望んだ設定を実現できなかったため、Viteの内部でも利用されているesbuildを直接採用しました。

フロントエンドにおけるUIライブラリのような、JSX、独自テンプレートが絡む場合はViteやBunも再度視野に入ると思います。個別に必要になれば、追記していきたいと思います。

その他

その他は無難なところを採用しています。本記事では詳しい説明は省略します。

Circle CI

代表的なCI/CDパイプラインを提供するクラウドサービスです。コードの品質担保、自動公開のために導入しています。OSSの開発であれば、相当量の実行時間をもらえるところが嬉しいですね。Medium以上であればGHAのデフォルトスペックより高速になると感じています。

セマンティックバージョニングに従ったタグが公開されると自動でNPMに公開する仕様です。その他にBiome等による静的解析を行わせています。

現在、リポジトリからは削除していますが、GHAの公開用ワークフローも作成していました。参考までにリポジトリのURLを添付しておきます。

Circle CIでタグフィルターうまく機能しないので、こちらの方がいいかも…?

https://github.com/totto2727-org/esm-package/blob/b2f8d8af03a883299d19974e7930ca0a6a873a01/.github/workflows/github-package.yaml

changeset

公開パッケージのバージョン番号管理、変更内容の管理に利用しています。

renovate

週末にバージョンのチェック行うようにしています。セキュリティ的な面も考えて可能な限り最新に追従していきたいところです。

各種設定の解説

ここまでで大分長くなりましたが、ここからが本題です。

前提

  1. moduleResolution: node16 or bundlerのESM、CJSに対応している
    • node10の対応は捨てます
  2. esm.sh経由でDenoやブラウザからも利用できるようにする
  3. なるべくパッケージサイズを小さくする

1. ESM、CJSに対応している

テンプレートの作成で最も苦戦したところになります。Node.jsの歴史的にパッケージの仕様が複雑で、納得のいく設定にたどり着くまでそこそこ時間がかかりました…

具体的には以下のような対応を行っています。

attwによる評価を行う

パッケージを公開する際、怖い要素が適切にパッケージを公開できる(インポートできる)か確認するのが難しいことです。これでいいと思った設定で公開しても、適切に読み込めなくなる…といったことが何度か発生しました。

attwはpackage.jsonの設定を読み取り、適切にファイルをエクスポートできるか?、また型定義が適切に付与されているか?を確認するコマンドラインツールです。以下のような結果を出力します。

下記の画像は改善後のため、今回対象としているものはすべてグリーン(正しい状態)です。しかし、ESMとCJSで型定義を使い回す、ESMがCJSを偽装しているといった問題を起こす可能性がある状態では、対処法も含めて提示してくれるため、後述の設定をする上で非常に参考になりました。

スクリーンショット 2023-12-22 2.40.09.png

本テンプレートでは下記のコマンドで実行することが可能です。node10を無視する都合上エラーは避けられない(オプションで対応できるかも?)ので、CI/CDには含めていません。確認する際はローカルで実行してください。

bun prepublish

バンドル

今回はesbuildのCLIではなく、esbuildのJavaScript APIを用いてビルドしています。複数のエントリーポイントがあり、共通化が容易だったためTypeScriptで記述しています。思っていたよりもシンプルな記述で済み、体験がよかったです。shellが得意な方はCLIでも問題ありません。

files変数にエントリーポイントとなるファイルのパス(拡張子以外)を渡すことで対象ファイルをバンドルすることが出来ます。targetBasePathdistBasePathも自由に設定できるため、お好みでカスタマイズしてください。

esbuildはplatformformatで出力する形式を調整する事ができます。一部の設定は省略可能ですが、今回は明示的に指定しわかりやすさを優先しています。

build.mts
import * as esbuild from "esbuild";

const targetBasePath = `${import.meta.dir ?? "."}/src`;
const distBasePath = `${import.meta.dir ?? "."}/dist`;

// ベースディレクトリからのパス(拡張子は除く)
const files = ["index"];

function entrypoints(files: string[], extension: "ts" | "mts" | "cts") {
  return files.map((v) => `${targetBasePath}/${v}.${extension}`);
}

const esms = entrypoints(files, "mts");
const cjss = entrypoints(files, "cts");

await Promise.all([
  esbuild
    .build({
      entryPoints: esms,
      bundle: true,
      minify: true,
      splitting: true,
      outdir: `${distBasePath}/import`,
      platform: "neutral",
      format: "esm",
      target: "esnext",
      outExtension: { ".js": ".mjs" },
    })
    .then(console.info)
    .catch(console.error),
    
  esbuild
    .build({
      entryPoints: cjss,
      bundle: true,
      minify: true,
      outdir: `${distBasePath}/require`,
      platform: "node",
      format: "cjs",
      target: "esnext",
      outExtension: { ".js": ".cjs" },
    })
    .then(console.info)
    .catch(console.error),
    
    // Bunだとこんな感じ
    // Bun.build({
    //   entrypoints: esms,
    //   outdir: `${distBasePath}/import`,
    //   target: "node",
    //   format: "esm",
    //   splitting: true,
    //   minify: true,
    //   naming: "[dir]/[name].m[ext]",
    // })
    //   .then(console.info)
    //   .catch(console.error),
]);

型定義とソースマップ

TypeScriptの型定義ファイルには、CommonJSとESMの差はほぼないため、多くの場合で流用しても問題ありません。しかし、attwに警告が出る上設定も比較的容易なため、テンプレートではCommonJSとESMでそれぞれ型定義ファイルを出力させています。

また、IDEのコードジャンプを利用した際、型定義にジャンプし実装が分からず困った経験はないでしょうか?tsconfigの設定を行うことでソースマップも出力させ、この問題に対応しています。

extendsを利用し、tsconfig.jsonを開発用とビルド用で分割する構成にしています。切り替え自体は少々面倒ですが、cliのオプションで指定しています。

"build:esm": "tsc --project tsconfig.build.esm.json",
"build:cjs": "tsc --project tsconfig.build.cjs.json",

package.json

テンプレートでは以下の設定を行っています。スクリプトや依存関係は主題から外れるため省略しています。

  • セマンティックバージョン
  • メタ情報
  • 公開ファイルの指定
    • files
    • exports

セマンティックバージョンはchangesetによる更新が前提であり、主導で変更することは原則ありません。

メタ情報は各自で必要な分だけ記述してください。

filesではNPMパッケージとしてパッケージに含めるファイルをしています。ただ公開するだけであれば、distpackage.jsonのみで十分ですが、ソースマップのジャンプも考慮し、srctsconfig.jsonも含めています。

exportsは、パッケージとして公開する範囲を明示的に指定するフィールドです。加えて、ランタイムに応じたファイルの指定や、型定義とプログラム本体を紐づけるなどパッケージを公開する上で非常に重要な設定です。

以下のような設定にしており、エントリーポイントを増やす度にこちらも追記が必要です。

  "exports": {
    ".": {
      "import": {
        "types": "./dist/import/index.d.mts",
        "default": "./dist/import/index.mjs"
      },
      "require": {
        "types": "./dist/require/index.d.cts",
        "default": "./dist/require/index.cjs"
      }
    }
  },

2. esm.sh経由でDenoやブラウザからも利用できるようにする

Node.jsやBun依存のパッケージを使用していない限り、特に対応無しで利用可能なはずです。

現在公開しているパッケージは以下のような形で利用できることを確認しています。

import * as r from "https://esm.sh/@totto2727/result"
import * as rEager from "https://esm.sh/@totto2727/result/eager"
import * as rLazy from "https://esm.sh/@totto2727/result/lazy"

3. なるべくパッケージサイズを小さくする

おそらく、利用者側のフレームワークでも最適化される可能性が高いとは思いますが、インストールする事も考えると小さいに越したことはありません。幸い、esbuildはminifyに加えて、ESMのsplittingも可能なため、今回はこれらを用いて出力されるファイルサイズを小さくしています。

esbuildのsplittingは現在ESMのみの対応であり、CJSは対応していません。また2023年12月時点で開発段階の機能であるため。問題が起こった場合は無効にしてください。

https://esbuild.github.io/api/#splitting

その他の注意点

なるべくmtsのように拡張子を明確にしたほうが良さそうです。(理由を思い出せないのですが、躓いた記憶だけが残っています…)

最後に

以上が必要な設定となります!
パッケージ周りを触っていると技術的負債をひしひしと感じて中々大変でした…(tsconfigの設定確認しにいったり、パッケージ周りの挙動調べたり…)
でもこれがあれば、あとは実装に集中できるので、一つとは言わず色々作っていきたいところです。
みなさんも是非テンプレート利用してみてください!

8
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?