DenoでCLIを作るのはとても快適

最近ちょっと必要があって、DenoでCLIコマンドを作ってみた。 いままでこういったCLIコマンドはNodeやPythonで作ることが多かったのだけど、Denoの使いごこちが知りたくて作ってみたら、 思いのほかすごく快適だったので紹介する。

作ったのは、 decor というマークダウン変換ツールだけど、なにを作ったかよりも、 どう作ったかが重要なので、この記事では深掘りしない。

目次

Denoとは

Denoは、Node.jsのクリエイターである Ryan Dahl が、 Node.jsでの反省点 を盛り込んで作り直したJavaScriptランタイム。セキュリティーを念頭に置いて設計されている。詳しくは検索してください。

Deno気持ちいい

まずなにがいいって、TypeScriptがファーストクラスの言語として組み込まれていて、わずらわしい設定なしに、すぐTypeScript でプログラムを書きはじめられる。tsconfigを読み込ませることも可能ではあるけど、設定を変更するのは推奨されていない。 それでいい。

そしてそれだけでなく、フォーマッター、リンター、テストランナーなども組み込みコマンドとして付いてくる。いまどきの言語環境 らしく必要なもの全部入り。なんだかんだでJavaScript(TypeScript)が最も手に馴染んだ言語のひとつであるぼくのような プログラマーにとっては、とにかくお手軽にTypeScriptでプログラムを作れるdenoという環境が、ものすごくありがたい。

Denoはセキュリティーを念頭に置いて設計された環境でもある。具体的には、ファイルの読み込み、書き込み、 ネットワークアクセス、環境変数アクセスなど、どれも明示的な許可が必要になる。権限は、コマンドライン引数で指定するか、 または、ユーザーが都度インタラクティブに与えることもできる。なんとも先進的でクールだ。

パッケージについては、Nodeのnpmのように中央集権的なリポジトリがあるわけではない。基本的には、GitHubのリポジトリがその ままパッケージという形になる。https://deno.land/x というDenoのパッケージを一覧できるサイトもあるけど、本質的には GitHubリポジトリにアクセスするためのCDNに過ぎない。 package.json でパッケージを管理する必要はなく、

import { DOMParser } from 'https://deno.land/x/deno_dom@v0.1.42/deno-dom-wasm.ts';

こういうふうにリモートURLから直接インポートする。

ただし、npmもサポートしていて、 Node互換API も完全にではないにしろ用意されてはいるので、Node.js向けに公開されているパッケージもある程度利用できる。

感覚としては、npmの そこそこ複雑なモジュール解決の仕組み がガッツリないことで、ブラックボックス部分が少なく、シンプルに使えるという感覚がある。モジュール解決に柔軟性を持たせる 仕組みとして、JS標準の Import Maps も サポートしている。

プログラムの配布

CLIコマンドを書いたら配布したくなる。Denoで書いたプログラムを配布するのはとても簡単だ。ユーザーシステムにdenoがすでに インストールされている前提であれば、

deno install --allow-write --allow-read https://deno.land/x/decor/src/decor.ts

このようにソースコード(エントリポイント)を指定してinstallコマンドを実行するだけでいい。この際、指定した権限がインスト ールされるスクリプトに付与されるので、ユーザーが毎回権限を指定する必要はない。

また、ユーザーにdenoランタイムのインストールを要求したくない場合は、Typescriptのソースコードをネイティブバイナリ ファイルにコンパイルすることもできる。

deno compile --target x86_64-unknown-linux-gnu --allow-write --allow-read src/decor.ts

WindowsやmacOSなど各種環境へのクロスビルドが可能。便利。この機能を使って、リリースごとに CIで各種プラットフォーム向けの バイナリをビルドし、アセットとして添付するようにした。 denoで作ったプログラムを配布するのがいかに簡単かについては、HomebrewのクリエイターであるMax Howeellも 詳しく紹介している。

また、ぼくはふだんmacOSを使っているので、コマンドはなるべく Homebrew で管理したい。 なので、Homebrew用の Formula も用意した。Denoプログラム用には、 Node.jsのように便利なユーティリティー関数 もいまのところ用意されていないものの、denoの仕組み自体シンプルなので素直に実装できた。

def install
    prefix.install "src"
    system "deno", "install", "--allow-read", "--allow-write", "--root", ".", "#{prefix}/src/decor.ts"
    bin.install "bin/decor"
end

このように必要なコード一式をインストールしてから、 deno instal を実行して、ラッパースクリプトを生成する。 そして、最後に生成されたラッパースクリプトをインストールすればいい。

ソースコード以外のアセットを配布する

Nodeであれば、パッケージに内包されるファイルは、 npm install 時に、配布先システムのnode_modules内に 配置される。しかし、denoにはパッケージをインストールするという概念がないし、バンドラーのように任意のファイルをimport することもできない。つまり、denoでは、任意のアセットをパッケージの一部として、手軽に配布する機能がない。

今回作成したプログラムでは、マークダウンファイルやHTMLをプログラムの一部として配布、というかプログラムから参照して使いた かった。もちろんソースコード内部に文字列として定義することもできるが、できれば別ファイルに分離してアセットとして管理 したい。調べたところ、Denoでは、以下のようにJSONファイルへの依存関係をimportで記述できることがわかった。

import assets from './assets.json' assert { type: 'json' }

そこで、必要なアセットを JSONに変換しておくことで プログラムの一部としてアセットを配布できるようにするという方法を考えた。バイナリファイルなども、BASE64エンコードすれば JSONに入れておくことができるだろう。

依存モジュールの自動更新

Nodeであれば、dependabotやrenovateといったツールで依存モジュールの自動更新ができるが、どちらのツールも現在のところ Denoをサポートしていない。

Denoでは、 udd というツールで依存ファイルの更新検出とアップデートができる ので、これを スケジュールジョブで回して 依存ライブラリの更新をするようにした。

ただし、更新してくれるのは直接依存しているファイルのみで、依存の依存までは見てくれないため、依存ライブラリが依存している ライブラリに脆弱性が発見されたときに、部分的に依存を更新するといったケースは、この仕組みでは対応できない (Nodeであれば、 package-lock.jsonyarn.lock の更新で対応できるケース)。

マークダウンパーサー

decorはマークダウンを扱うツールなので、マークダウンパーサーが必要だった。TypeScriptで使えるマークダウンパーサーには いくつか選択肢があるが、機能とAPIを検討した結果、今回は、 marked を 使うことにした。型情報が後付けではなく、最初からTypeScriptで開発されているのも、高評価ポイントのひとつ。

ただし、markedをDenoから使うにもいくつか方法がある:

  1. npmモジュールとして利用する方法: import { marked } from 'npm:marked';
  2. deno.landで 配布されているバージョン を利用する方法
  3. GitHubから直接参照する方法

方法1の場合は、元々TSで書かれたものが、JSにコンパイルされて型情報と分離された状態で配布される形になる。Denoは、Nodeで TypeScriptを使ったときと違って、d.ts から自動的に型情報を見つけてはくれない。型情報の定義先も 別途明示的に記述する必要がある。 何だか面倒なのでもっといい方法があるなら、そちらを選択したい。

2は、どこかの誰かが、markedをdenoから利用しやすいようにdeno.landな形にして登録したものらしい。試してみたところ、本来 得られて欲しい型情報がanyになっていたりして、いまいちだった。中身がどうなっているのか見てみると… 単にnpm specifierで importしているだけだった。

よくよく考えると、さきほども書いたようにmarkedのコードはそもそもTSで記述されている。そして、Denoはリモートのソース コードを直接参照できるので、GitHubから直接importすればいいだけなのではないか。つまり、こうなる。

export { marked } from 'https://raw.githubusercontent.com/markedjs/marked/v9.1.4/src/marked.ts'

TypeScriptのソースコードをそのままなので、型情報も完璧。Denoでは、このようにリポジトリから直接importできるので、 TypeScriptで書かれたライブラリを選択するモチベーションがより高くなると思う。

DOMパーサー

decorではHTMLのパーサーも必要だったので、いくつか選択肢を検討した。

Denoのサイトでも紹介されているように、DenoでHTMLをパースするときには、いくつかの選択肢がある。主なものは:

どれも型情報は提供されている。deno-domは唯一Deno向けにTypeScript(+Rust)で書かれたものなので、せっかくだから deno-domを使ってみることにした。途中、うまく動かない箇所があり、バグ報告しつつLinkeDOMのほうも試してみたら、こちらは こちらで型情報が間違っていて、目的が達成できないなどのトラブルがあった。そうこうしているうちに、deno-domのバグが修正 してもらえたので、けっきょくdeno-domで実装することができた。deno-domは報告したらけっこうサクッと問題修正してもらえ たので、安心感がある。

いずれもHTMLのDOM APIに寄せて作られているので、乗り換えはそこまで大変ではないと思う。

まとめ