本セッションの登壇者
セッション動画
ご紹介ありがとうございます。よろしくお願いします。
このセッションでは、パッケージ開発者の苦悩についてお話しします。まずは自己紹介、小田島です。手品をやっています。

多様なJSランタイムに幅広く対応するために
最初に今回の発表について説明します。今回はnpmとかDeno向けのパッケージ開発をしている人が対象です。なので、Node.jsとかDenoについてはある程度知っていることが前提です。レベルでいうと「もう完全に理解した」から「何もわからない」あたりを想定しています。Bunについては、「TypeScriptが動くNode.js」ぐらいの認識で大丈夫です。もちろんほかにもいろいろあるのですが、この発表の中ではこれくらいで大丈夫です。スライドを公開していますので、見逃したところとかは後でゆっくり見てください…というわけで、しばらくお付き合いください。

まず背景ですが、JavaScriptランタイムっていろいろあるじゃないですか。パッケージ開発者としては、できるだけ広い環境に対応させたいという思いがあります。Webアプリケーションであれば、たとえば「Ubuntu 22のNode.js 18だけで動けばいい」みたいなことは普通にあるんですが、パッケージとかフレームワークは多くの人に使ってほしいので、たとえサポートが切れているバージョンであっても対応したかったりします。ただ、とくにNode.jsはもう歴史が古いだけあっていろんな技術的な負債を抱えています。そのためにトランスパイラというのもいろいろ開発されてきました。

そこで、今話したことを何とかすることを目標とします。具体的には、
- まずはNode.jsとDenoとBunの3つのランタイムに対応する
- そしてなるべく古いバージョンにも対応する
- Node.jsではCommonJSとES Modulesの両方に対応する
- そしてパッケージの利用者がトランスパイラを使っても使わなくても動作する
ということを目指します。

加えて、単一のコードベースからパッケージを作りたいということです。

というわけで、古いバージョンの話もするので、”最前線”というテーマのわりには昔話が多めです。DenoとかBunの話もちょっとだけするのでそれで勘弁してください。

一般的な話をすると絶対8分では終わらないので、いくつか前提条件を付けます。この中のランタイムのAPIに非依存というのは、たとえばNode.jsのfsモジュールとか、netモジュールみたいなものを使わずにロジックだけで完結するという意味です。後の条件はとくに問題ないんじゃないかと思います。

パッケージ開発には6つの苦悩がある
まずは待ち構えている苦悩について説明します。
最初はですね、ランタイムごとにモジュールの扱いが違うということですね。なので、単一のコードベースから複数の形式のファイルを生成する必要があります。でもこれは普通にビルド時に複数生成すればいいだけなので、たいしたことはありません。やり方もここでは省略します。

2つ目、ES Modulesは拡張子を省略できないということですね。これ、けっこう悩みます。Node.jsでは拡張子.mjsを、DenoやBunでは.tsを付ける必要があるので、共通のコードベースからビルド時に出し分けなければなりません。

3つ目、さっきの出し分けに関連しますけれども、tscではimport対象の拡張子を変えてくれません。これもけっこう悩みます。これはもう、TypeScriptの設計思想にも関係する問題です。

「ビルド時に拡張子を追加してよ」というissueが出ていて、ポリシーに反するということで却下されています。これは私の知る限りでも3つか4つぐらい同じようなissueが出てて、その度に却下されています。

4つ目、Dual Packageについてですね。なんやかんや仮にうまいこと出し分けができたとして、じゃあrequireとimportで参照させるファイルを変えるにはどうすればいいでしょうかという話です。これを苦悩レベル5にしている理由は、LTでは時間がないので公開スライドのおまけページに詳しく書いてますので、良ければ見てください。

5つ目はdefault importの扱いです。tscやBabelでは、default importの構文を変換すると最後に.default
が付きます。

つまり、パッケージの利用者がtscやBabelを使っても、生のCommonJSで書いてても、どっちでも動くようにしたいという問題です。

最後です。以前のDenoではNPMに対応していなかったので、Deno用のパッケージはエントリーポイントをURLで公開してやる必要がありました。これはGitHubなどで公開すればいいだけなのでたいしたことはありません。これも説明は省略します。

ソースコードの書き方やツールで解決
というわけで、6つの苦悩を紹介しました。今から、これらを解決するパッケージの作り方を説明します。ちょっと時間が残り少ないので苦労話は置いておいて、結論だけぽんぽんと説明していきます。
まず、ソースコードを書くときのポイントですが、import文に相対パスを使うということと、ファイルの拡張子を付けないということです。拡張子はビルドのときに解決します。やり方は後で説明します。

2つ目、パッケージのエントリーポイントからは、named exportを使わずにdefault exportだけを行ってください。これも理由は後で説明します。

次にビルド時のポイントです。CommonJS用のビルド方法は、普通にtscを使うだけなので省略しますが、エントリーポイントの最後にこの2行
module.exports = exports.default;
module.export.default = exports.default;
を追加してやります。これは何をやってるかというと「.default
を付けても付けなくてもちゃんと読み込めるようにする」という強引なやり方です。

ビルド時のポイントの2つ目、Node.js用のES Moduleのビルドは、tscとBabelの2つを使います。最初にtscで文法変換と型定義ファイルを作成して、次にBabelでimport文の拡張子を解決するという二段構えです。

この拡張子の解決には、Babelのプラグイン(babel-plugin-module-extension-resolver)を使います。ちなみにこれは私が作りました。ちょっと名前が長いですけど。

DenoとBun用には拡張子にドットを付ける必要があるのですが、これは専用のツール(deno-module-extension-resolver)で付けます。これも私が作りました。

ここまで聞いて、バンドラーを使えば拡張子に悩まなくて済むんじゃないかと思った人がいるかもしれません。ただ、DenoとかBun向けにはTypeScriptのままでまとめてくれるバンドラーだとか、型定義ファイルをひとつにまとめてくれるバンドラーみたいなのが必要なのですが、いくつか調べた限りではJavaScriptに変換した後でまとめるものばかりでした。もしTypeScriptのままでまとめてくれるようなバンドラーを知ってたら誰か教えてください。

package.jsonの書き方なんですけれども、こんな感じでエントリーポイントを指定してください。Conditional ExportsにはBun用のエントリーポイントも指定できます。

これだとNode.jsのバージョンによって挙動が違ってて、特定のバージョンではES ModulesからCommonJSのコードが参照されてしまいます。

ECMAScriptではCommonJSとES Modulesの相互運用が規定されていて、ES ModulesからCommonJSを参照するときはdefault importしか使えないという制限があります。ちょっと前にdefault exportだけしてくださいと言ったのはこれが理由です。

ソースコード、ビルド時、package.jsonのそれぞれで対応
今までの内容を実践して、実際に作ったパッケージがこれです。Webアプリケーションを作るときに入力値のチェックや場合によっては値の変換が必要になりますが、これを簡単にやってくれます。たとえばidは数値型で小数点以下を切り捨てて、nameは文字列型で最大何文字まで、みたいな、そういう制限をルールとして定義して、入力値にこのルールを適用するといい感じに変換します。

駆け足でここまで来ましたが、まとめます。
単一のコードベースでハイブリッドパッケージを作る方法です。具体的にはここに書いたような感じです。

以上です。ご清聴ありがとうございました。