はじめに
dnt は Deno で公式にリリースされたモジュールビルダーです。 Deno ベースのコードから、NPM 用のモジュールをビルドできます。
これには型定義ファイルの出力や、インポートマップの解決など、 Deno-first を後押しする機能が詰まっています。
今回は dnt
を使って Deno-first なモジュールを作成し、
deno.land/x と NPM へのリリースを行う方法を紹介します。
余談ですが、記事全体として、広義のライブラリやパッケージという言葉を モジュールという単語で統一しています。 語彙の厳密性については考慮していませんのでご了承ください。
Deno と Node.js の違い
Node.js から Deno に変えることで、コードベースに現れる変化としては次のようになります。
- インポートに必ず拡張子が必要
- URL スキーマインポートのサポート
- 型定義ファイルの出力ツールがまだサポートされていない
インポートに必ず拡張子が必要
Deno は暗黙の処理を行わないことを一つの指針としています。
Node.js ではできた .js
の省略や index.js
の特別化はしません。
この拡張子の有無が非常に厄介です。 NPM にあるビルドツールは、概ね拡張子があると上手く処理できません。
URL スキーマインポートのサポート
Deno は URL スキーマのインポートが出来ます。 これはさながらブラウザの様ですが、 ブラウザとの互換性を重視している Deno の哲学が反映された結果とも言えるでしょう。
型定義ファイルの出力ツールがまだサポートされていない
そもそも Deno だけであれば、型定義ファイルの出力は不要ですが、 後述するデュアルモジュールの出荷する際、NPM 用に出力する必要があります。
Deno ではまだ、型定義ファイルの出力のコマンドをサポートしていません。 Deno.emit Runtime API で、頑張る以外はないはずです。
一方、NPM の tsc
はインポートパスに拡張子があると上手く処理できません。
Deno ベースのコードから型定義ファイルを出力するには、けっこう大変です。
デュアルモジュールの必要性
デュアルモジュールとはここでは NPM registry と deno.land/x registry に対するモジュールのことを言います。 このセクションでは、なぜデュアルモジュール開発が必要なのか説明します。
Deno と Node.js のモジュールシステム
Deno は NPM の資産を利用できます。前述の通り Deno は URL スキーマのインポートができます。 そのため、ES modules 形式でモジュールが提供されていれば、基本的には NPM にあるモジュールは CDN 経由で利用できます。
CDN として有名なのは skypack や esm.sh ですね。
また、これらの CDN は型定義の提供もしてくれるため、TypeScript でも問題なく開発できます。
例えば lodash
は次のように使えます。
ちなみに lodash は Deno 用のモジュール が提供されているので、そちらを使ったほうがいいですが参考までに。
deno run cli.ts
一方、deno.land/x にあるモジュールはどうでしょう。残念ながらこれらを Node.js が使える可能性はかなり低いです。
これは、Node.js のモジュール解決アルゴリズムが、package.json
と密接にあることが起因しています。
モジュールシステムを作り変えるのは、かなり大変なことは想像に易いでしょう。
また、--experimental-loader
でローダーを使ったインポートが出来なくはないようですが、現実的ではないでしょう。
詳しくは Dynamic import with HTTP URLs in Node.js が参考になるかと思います。
加えて、URL スキーマをサポートしたとしても、Node.js は TypeScript をサポートしていません。
Deno とデュアルモジュール
上記のことから、NPM の資産は Deno で使えるが、deno.land/x の資産は Node.js で使えません。 Deno の compat モードでも無理です。
現状はこの一方向性を受け入れなければなりません。
この時点で開発者には2つの選択肢があります。
- 従前どおり Node.js ベースで開発し、NPM にリリースする。Deno での利用は CDN 経由
- Deno ベースで開発し、deno.land/x と NPM へリリースする。
dnt がない世界では、Deno ベースのコードを Node.js 用にビルドするのにかなり手間がかかりました。 しかし、ビルドの問題が解決されれば、あとはリリースだけなので、そこまで負担にはなりません。
個人的には、今後新しいプロジェクトは、Deno ベースで運用していくのがいいのではないかと思います。
dnt でビルドする
さて、前置き長くなりましたが、実際にビルドしてみましょう。
なお、実際に運用しているレポジトリは TomokiMiyauci/isx にあるので、適宜参考にしてください。
とても小さなプロジェクトを例にやってみます。
どうでもいいですが、 例中の isx
というのは私が作っている is? というものを集めたコレクションです。
この例では次の2つのことを行っています。
- URL スキーマを利用したインポート
- ファイルパスを利用した拡張子付きのインポート
これを Node.js 用にビルドするために、次のスクリプトを用意します。
バージョン情報はコマンド引数から渡すことが推奨されています。
これを実行すると、outDir
で指定したディレクトリ下に NPM 用のビルド結果が出力されます。
デフォルトでは、 ES Modules, CommonJS, 型宣言ファイルの出力と、型チェックおよびテストが行われます。
また、package.json
は次のようになっています。
ビルドスクリプトの package
フィールドに指定したメタ情報および、エントリーポイントや依存関係が追加され出力されます。
すでに公開できる状態になっているので、あとは npm publish
などで公開するだけです。
dnt と依存関係
依存関係がどのように解決されたか見てみます。例では isx
という外部モジュールを利用していました。
しかし、package.json
の dependencies
フィールドは空です。
依存関係は NPM に同じものがあるとは限らないため、デフォルトでは fetch した上で、成果物に含まれます。
例えば esm
ディレクトリ配下は次のようになります。
deps
配下に依存関係が配置されました。また、依存関係の参照はファイルストラクチャーに合わせて書き換えられます。
ちなみに依存関係の型定義は types
配下に配置されます。
素晴らしいですね。
依存関係のマッピング
依存関係をマッピングすることも出来ます。
先程の isx
というモジュールは deno.land/x にホスティングされていますが、 isxx
という NPM にあるものに変えてみます1。
ビルドスクリプトを変更します。
mappings
フィールドに NPM のモジュール名をマッピングします。
これでビルドすると次のようになります。
package.json
の dependencies
フィールドに加わり、依存関係の fetch は行われませんでした。
Node.js で利用された際、依存関係を事前にバンドルしてしまうと、2重バンドルが起こりやすくなります。
そのため、NPM に同じモジュールがあるなら、できるだけマッピングを利用したほうが良いと思います。
Deno.shim の注入
Deno のグローバルコンテキストと Node.js のそれは異なります。 そのため、Deno 固有のプログラムは Node.js では動きません。
dnt はそれらに対しても解決策を提供しています。
例えば fetch
を使うプログラムを考えます。
Deno は fetch
をサポートしていますが、Node.js ではサポートしていません。
このコードに対し dnt は デフォルトで Deno shim を注入します。
このコードをビルドすると次のような結果になります。
deno.ns
モジュールにより、Node.js でも実行できるようになります。
また、Deno shim の注入を無効にするには、// deno-shim-ignore
コメントを該当コードの上に付けます。
この他にも、マルチエントリーポイントや、 bin
スクリプトの生成もサポートしています。
デュアルモジュールのリリースフロー
以上で dnt の紹介は終わりですが、ここからは実運用上問題になる点について触れたいと思います。
最初に悩むのは恐らくリリースフローです。 2 つのレジストリにリリースしなければならないため、手動でのリリースは避けたいです。
Deno は元々サードパーティモジュールのリリースを GitHub の webhook を使うように推奨しています。詳しくは Publish a module を参考にしてください。
GitHub のリリースタグの生成をトリガーに webhook を呼ぶように構成します。
Deno へのリリースはリリースタグの生成なので、NPM へのリリースも同じようにするのが自然でしょう。
GitHub Actions だと次のようになるかと思います。
リリースタグのパースが若干複雑ですが、やっていることはシンプルです。 例えば v1.1.0 タグが発行されたとします。
GitHub Actions のコンテキストから v1.1.0
を抜き出し、package.json
のバージョンにします。
先程の例で、バージョン文字列を Deno.args[0]?.replace(/^v/, "")
で変換していたのはこのためです2。
その後、semver のパースをし、NPM のリリースタグを導出します。
通常は latest
タグを付ければいいですが、プレリリースの場合はそれ用のタグを付けてあげます。
というように若干面倒ですが、GitHub のリリースタグの生成で 2 つのレジストリへリリースが出来ます。
参考までに、上に加えてわたしは semantic-release を使って conventional commits で GitHub リリースタグ の自動生成も行っています。 詳しくは TomokiMiyauci/isx を参照してください。
Deno とテスト
最後は宣伝です。
コードベースを Deno に移したときに直面するのがテストの問題です。 Deno は標準でテストランナーおよび、標準モジュールとして アサーションモジュールを提供しています。
これである程度の規模のテストは十分機能します。
しかし、Node.js のデファクトスタンダードである jest
には、機能的には劣ります。
そこで jest like なテストフレームワーク unitest を開発しています。
jest と同じ expect
構文を採用しながら、Deno-first でユニバーサル性とバンドルサイズをかなり意識しています。
現状 Deno を採用する上での最大の障壁の一つである、フロントエンドのテスト環境もサポートする予定です。
ぜひお試しください。
また、デュアルモジュールを作るとき、両方のレジストリで名前空間が空いているのか調べる手間があります。 これを解決する registerable というサービスを提供しています。
各モジュールレジストリに名前空間が使用可能かどうか問い合わせることが出来ます。
こちらも合わせてご利用いただけると嬉しいです。
Edit this page on GitHub