Deno アドベントカレンダー 24日目の記事です。
今日は Deno を使って Node でも Deno でも動くライブラリ(もしくはツール)を作成する方法を紹介します。
Deno 界隈では最近 npm 互換性機能がリリースされて、Deno から使えるツールが一気に増えたということで話題になっていますが、逆に Deno で作ったツールを Node/npm に対応させるという逆向きの互換性についても開発が進んでいます。
この記事では、Deno が公式で提供している dnt というツールを使う方法を紹介します。
dnt を使った場合の、Node と Deno 両対応の主な流れは次のようになります。
- まずは Deno で動くようにツールを作る (Deno に対応)
- dnt を設定して、ツールのエントリーポイントを Node 用に変換出来るように準備する
- dnt を実行して Node 用モジュールを指定のディレクトリに書き出す
- 書き出されたディレクトリから npm publish を実行する (Node に対応)
dnt のセットアップ
まずは、Deno で動く状態でライブラリを作ります。
ここでは例として、ライブラリのすべての API が mod.ts から export されているとして、以下のように build_npm.ts
を用意します。
import { build, emptyDir } from "https://deno.land/x/dnt/mod.ts";
await emptyDir("./npm");
await build({
entryPoints: ["./mod.ts"],
outDir: "./npm",
shims: {
deno: true,
},
package: {
name: "your-package",
version: "0.1.0",
description: "Your package.",
license: "MIT",
main: "mod.js",
repository: {
type: "git",
url: "git+https://github.com/username/repo.git",
},
},
});
// ソース以外の必要ファイルをコピー
Deno.copyFileSync("LICENSE", "npm/LICENSE");
Deno.copyFileSync("README.md", "npm/README.md");
emptyDir("./npm")
で、まず ./npm
というディレクトリを空にしています。
次に build()
呼び出しで、entrypoints
で指定した Deno 用のプログラムが Node 用プログラムに変換されます。URL import などの外部ファイルへの依存は自動的にダウンロードされ、ローカルファイル化されます。Deno namespace への依存については、@deno/shim-deno という Deno namespace を Node.js の API で polyfill した npm モジュールの依存に書き換えられます。
build
関数の package
オプションはそのまま package.json
の内容になります。ここに npm モジュールとしてのメタ情報を設定します (package.json の具体的な書き方については npm の package.json のドキュメントを参照してください)。
最後に、Deno.copyFileSync()
呼び出しで、ソースファイル以外の必要ファイルを npm/
ディレクトリにコピーしています (ここでは README と LICENSE ファイルをコピーしています)。
このスクリプトを次のコマンドで実行します。
deno run -A build_npm.ts
実行すると、npm/
以下に Node.js 対応化されたソースコードが書き出されるはずです。コマンドが成功したら、さらに次のコマンドを実行します。
cd npm
npm publish
ここまで実行すると、npm 化が完了です 🎉
例の紹介: license_checker
上のような手法で、Node と Deno の両対応をしている例として、筆者が作成・メンテナンスしている license checker というツールを紹介します。
このツールは以下の様な設定ファイルを書いた上で、
{
"**/*.ts": "// Copyright 2022 My Name. All rights reserved. MIT license."
}
deno run --allow-read https://deno.land/x/license_checker@v3.2.2/main.ts
というコマンドでプログラムを実行すると、上の設定ファイルで指定された範囲 (**/*.ts
) のソースコードに指定のライセンスヘッダー (この例では // Copyright 2022 My Name. All rights reserved. MIT license.
) がきちんと表記されているかをチェックしてくれる小物 CLI ツールです (余談ですが、MIT ライセンス等では "すべてのソースコード" にライセンスへの言及が必要という考え方があり、例えば Deno 本体では、すべてのファイルに必ずライセンス表記を記載しています。こういうポリシーのプロジェクトの場合にこのツールを使うとライセンスヘッダーの記載漏れ、記載ミスを防ぐことが出来ます)。
当初は Deno 用のツールとして、開発していた CLI ツールでしたが、後に dnt を使って npm モジュール化して、今では、上の Deno コマンドの代わりに、以下のような npx コマンドでも実行できるようになっています。
npx @kt3k/license-checker
CLI ツールのため、dnt の設定の書き方に若干の違いがありますが、Deno 用に開発したコードから Node 用のコードを生成していて、Node 用に特別なコードはほとんど書いていません(実は部分的に Node 専用のコードを書いている部分もありますが詳細は後述します)。
(license checker での dnt の具体的な設定例はこちら参照)
この方法のメリット
dnt を使って Deno から Node に変換することには様々なメリットがあります。
1つめのメリットとして開発環境の簡略化が挙げられます。Deno を導入することで、TypeScript の設定、lint 設定、formatter 設定、テストフレームワークがすべて Deno だけで解決出来るようになるため、開発環境の構築作業で悩まなくて良くなります(悩む事が大幅に減ります)。
Dual Package 対応
2つめのメリットは ESM / CJS (commonjs) 両対応の問題が解決出来ることです。Node.js には CJS と ESM という2つのモジュールシステムが存在していて、お互いに呼び出すことは出来るものの、ESM / CJS をまたぐ際にはいろいろな制約があり、悩ましい問題になっています。理想的には、ESM ユーザのためには ESM を提供して、CJS ユーザのためには CJS のモジュールを提供するような形が理想と言われることがありますが、そのような publish の仕方をしようとすればメンテナンスコストが跳ね上がってしまい、現実には、CJS / ESM どちからだけが提供される場合がほとんどだと思われます (たとえば、有名な例として Sindre Sorhus は今後は ESM でしかライブラリを提供しないことを明示的に表明しています)。dnt は CJS / ESM 両方のコードを吐き出す事ができるため、自分のコードは Deno の ESM1 で書いたソースのみで、Dual Package を出力することが出来、上記の問題を理想的に解決することが出来ます2。
型定義出力自動化
3つめのメリットは、型定義ファイルの書き出しが自動化される点です。TypeScript を自分で設定して、TS の実装から型定義を抽出して、package に同梱して、という設定をやり始めるとなかなか面倒になりますが、dnt を使えばそこもすべて任せる事が出来ます3。
両方の環境でちゃんとテストできる
4つめのメリットは、テストも自動的に変換され、実行まで行われるという点です。Deno のテストランナーはファイル名が *test.{ts,js}
で終わるファイルを自動的にテストと認識して実行しますが、dnt も同じルールでテストを自動認識して Node 用に変換します4。変換したテストケースを使って、Node 環境でのテストの実行まで行ってくれます。このことによって、単に変換するだけでなく、Node 環境でも、Deno 環境で通ったテストケースと同じテストケースが通る状態を確認した上で安心して npm モジュールを作成する事が出来ます。
トラブルシューティング
dnt は Deno と Node の差異を大部分吸収してくれるものの、一部どうしても差異が吸収しきれないケースがあったり、dnt の解決方法とは違った方法で Node 対応をしたいというような場合があります。その場合は、以下のような手段で、カスタムな対応を入れることが可能です。
1つめの方法は x/which_runtime
というモジュールを使って、環境が Node か Deno かを判別して if 文で分岐する方法です。
import { isNode } from "https://deno.land/x/which_runtime@0.2.0/mod.ts";
const baseArgs = isNode ? ["node", "../../main.js"] : [
Deno.execPath(),
"run",
"--unstable",
"--allow-read",
"--allow-write",
"--allow-net=localhost:8000",
"../../main.ts",
];
上の例は、サブコマンド実行時のパラメータを Node か Deno かで切り替えるコードです。license checker のテストコードの中でこのコードを使っています。Deno と Node の差異が小さい場合にはこの手段が向いています。
2つめの方法は、dnt の mapping
オプションで、特定のファイルを Node 環境では別のファイルに置き換える手法です。
await build({
...
mappings: {
"./serve.ts": "serve_node.ts",
},
});
上の例は、serve.ts
というファイルを Node 変換時には serve_node.ts
で代替する、という設定になっています。何らかの理由で dnt 変換がうまくいかない際に、その API を一つのファイルにまとめて、そこだけ手動で Node 用実装を与えて、解決するという手法です。筆者の license checker では、テスト時に使う単純なモックサーバーの実装が dnt の変換ではうまく動かなかったため、そこだけ手動で Node 用実装を書く、という使い方をしました。
Deno と Node の差異が比較的大きい場合にはこちらのファイル単位で置き換える手法が向いているでしょう。
その他の例
GitHub 検索で deno.land/x/dnt language:TypeScript
というクエリーで検索することで、具体的に dnt を使っている例を見つけることが出来ます。
↓検索ページへのリンク
現在は 200個ほどのツールがこの手法で Deno から npm に対応しているようです。
まとめ
dnt を使って Deno のプログラムを Node と Deno の両方に対応させる方法を紹介しました。
-
Deno はモジュールシステムとして ESM だけを持ったランタイム。 ↩
-
ただし、CJS 出力をするためには、Top Level Await を使えない、もしくは Top Level Await を使ったモジュールに依存できない、などコードの書き方上の制約が一部存在します。詳細は dnt のドキュメンと参照するか、実際に dnt を実行して試してみてください。 ↩
-
なお、型定義出力を行いたい場合は、
build()
API のオプションで、declaration: true
を指定する必要があります。 ↩ -
なお、テスト変換・実行を行いたい場合は
build()
API のオプションでtest: true
を指定する必要があります。 ↩