社内で使う npm パッケージの作成に Deno を採用した話

こんにちわ。フロントエンドエキスパートチームの@nus3_です。

最近、社内用の npm パッケージを作る必要があり、そのパッケージは依存が少なく、実装もシンプルだったので、npm パッケージの作成には Deno と dnt を採用しました。

dnt とは

dnt は Deno で実装したモジュールを CJS、ESM に対応した npm パッケージに変換してくれるビルドツールです。

使い方も簡単で、次のように dnt が提供するbuild関数にエントリーポイントや出力先などの必要な情報を渡すだけです。

import { build } from "https://deno.land/x/[email protected]/mod.ts";

await build({
  entryPoints: ["./mod/index.ts"], // Denoで実装したモジュールのエントリーポイント
  outDir: "./npm", // 出力先のディレクトリ
  shims: {}, // Deno名前空間などDeno独自の実装をNode.jsやブラウザで実行できるshimに置き換える設定
  package: {
    // package.jsonの内容
    name: "package-name",
    version: Deno.args[0],
    // ...
  },
});

あとは定義したbuild関数を Deno 側で実行すれば npm パッケージが出力されます。実際に dnt のbuild関数を実行すると以下のようなログがターミナル上に表示されます。

❯ deno task build
Task build deno run -A build_npm.ts
[dnt] Transforming...
[dnt] Running npm install...

added 4 packages, and audited 5 packages in 2s

found 0 vulnerabilities
[dnt] Building project...
[dnt] Type checking ESM...
[dnt] Emitting ESM package...
[dnt] Emitting script package...
[dnt] Running post build action...
[dnt] Running tests...

> test
> node test_runner.js

Running tests in ./script/nus3_test.js...

test テストケース ... ok

Running tests in ./esm/nus3_test.js...

test テストケース ... ok
[dnt] Complete!

このログにも表示されていますが、dnt には大きく 2 つの特徴があります。

  1. Deno で実装したモジュールがnpm: specifiersや、esm.shなどの CDN、Import Mapsから npm パッケージを使用している場合、それらのパッケージを依存関係として npm パッケージの package.json に追加する
  2. ビルド後の ESM、CJS 形式のファイルそれぞれに対して Deno で実装したテストを Node.js で実行する

dnt はどのように Deno のモジュールを npm パッケージとしてビルドするのか

では、実際に dnt がどのように Deno のモジュールを npm パッケージに変換しているのか、ざっくりとした流れを見てみましょう。

dnt(v0.38.1 時点) のディレクトリ構成は次のようになっています。

.
├── Cargo.lock
├── Cargo.toml
├── LICENSE
├── README.md
├── deno.jsonc
├── lib // wasmの出力先、shimsやts_morphのimport先
├── mod.ts // dntのbuildメソッドが定義されてる場所
├── rs-lib // deno_node_transform
├── rust-toolchain.toml
├── tests
├── transform.ts
└── wasm // dnt-wasm

ディレクトリ構成からもわかる通り、dnt は Deno のモジュールと Rust のクレートが同一リポジトリで管理されています。

dnt のbuild関数はリポジトリのルート直下の mod.ts で定義されており、引数で指定されたエントリーポイントなどの情報をtransform関数に渡します。

このtransform関数は dnt_wasm_bg.wasm という wasm ファイルから提供されています。dnt_wasm_bg.wasm は wasmbuild を使って Rust のコード(dnt-wasm)がコンパイルされたものです。

dnt-wasm では、deno_node_transform が提供する transform 関数が実行されています。

筆者が Rust の知識が浅いこともあり deno_node_transform のtransform関数が行なっている処理の詳細を把握はできていないんですが、transform関数のコメントを読む限り、エントリーポイントを解析して、依存するモジュールを取得し、TypeScript のコードをメモリ上に出力してるようです。

このtransformの返り値(TypeScript のコード)を ts_morphという TypeScript Compiler API のラッパーに渡します。ts_morph を使って、TypeScript のコードから型定義ファイルと JS を emitしています。

また、emit する際に、ESM、CJS用の変換をしています。

dntの処理の流れ
ざっくりとしたdntの処理の流れ

Deno と dnt を採用した理由

実際にフロントエンドエキスパートチームの探求時間に dnt を試した際に、次の点が良いと感じていました。

  • Lint、Format、Test、TypeCheck が設定なしで使える
  • CJS、ESM 両方に対応した npm パッケージが作成できる

また、今回作成する社内 npm パッケージは依存が少なく、実装もシンプルだったので、今後、何か問題があった際に移行するコストも低いと考え、採用を決めました。

実際に採用してみて

採用した理由でも述べましたが、やはり 1 番良かったのは、Linter や Formatter、tsconfig の設定をせずにすぐに開発を始められる点でした。おかげで実装したい機能に集中できたと感じています。

テストに関しても、ドキュメントを読むとビルトインで入っているテスト用の API の使い方などがすぐに理解でき、スムーズに書くことができました。(dnt でテストを実行すると CJS、ESM 形式のファイルそれぞれに対してテストを実行するので、モックをちゃんと restore しないと、ESM 形式だけパスして、CJS だけ落ちるようなケースはありましたが)

今回は作成するパッケージの依存関係が少ないこともあり、パッケージのアップデートは手動で行なっていますが、運用を続けていくことを考えるとどのように自動化するかは今後、対応する必要がありそうです。

これから運用を続けていく上で問題が発生することもあるかもしれませんが、今回実際に採用してみて、小規模な npm パッケージを作る際に Deno と dnt を使うと考慮すべき点を減らせ、実装したい機能にすぐに着手でき、とても良かったです。