この記事は Node.jsのカレンダー | Advent Calendar 2021 - Qiita の2日目の記事です。
今回はnapi-rsというNode-APIを使ったNode.js add-onをRustで書けるツールを紹介します。
目次
- 目次
- Node-API とは
- napi-rsとは
- napi-rsの使い方
- Rust のコードを追加してみる
- napi-rsを使ったnpm publish
- 今後試したいこと
- 著名npmパッケージのセキュリティインシデントとpostinstallの問題点
- まとめ
Node-API とは
napi-rsの紹介をする前にNode-APIについて説明します。
Node.jsは、Node.js自体をネイティブレベルで拡張するためのadd-onをユーザーが開発するためのNode-APIと呼ばれるAPIを提供しています。Node-APIは以前はN-APIと呼ばれており、napi-rsの名称の由来はN-APIだと思います。
Node-APIはC言語またはC++で書くことを前提にされており、node-gyp や CMake.js を使ってビルドします。node-gyp では、C/C++のコードはコンパイル後 .node
という拡張子のファイルとして出力されます。それをJavaScriptで読み込んでNode.js上で実行します。
const Native = require('./hello.node');
Node-APIを使えば、JavaScriptのコードより高速に処理するネイティブコードを書くことができます。(ただし、必ずしもネイティブコードの方が速いわけではありません。)
napi-rsとは
そのNode-APIをC/C++ではなくRustで書けるようにするツールが今回紹介するnapi-rsです。
すでにnapi-rsはSWCのビルドに使われています。 SWCはsuper fast な TypeScript / JavaScriptのコンパイラです。 JavaScriptで書かれたBabelより20倍高速らしいので、Rustで書くメリットが発揮されています。(言語だけでなくアーキテクチャなども関係あるとは思います。)
また、bcryptやdeno-lintなどnapi-rsを使ったパッケージがnapi-rs/node-rsリポジトリ内で開発されています。
napi-rsの使い方
napi-rsはCLIが用意されており、@napi-rs/cli
をインストールして使います。公式ドキュメントではyarnでのインストール方法が紹介されています。
yarn global add @napi-rs/cli
インストールが成功すれば、napi
というコマンドが使えるようになります。
napi new
コマンドは新しいプロジェクトを生成するジェネレーターになっています。
napi new
napi new
を実行すれば、順番に次の質問をされるので回答を入力します。
? Package name: (The name filed in your package.json) ? Dir name ? Choose targets you want to support ? Enable github actions? (Y/n)
? Package name
は任意のパッケージ名を入力してください。
? Dir name
はプロジェクトのディレクトリ名を入力してください。
? Choose targets you want to support
の質問では、どの実行環境をサポートするか質問されます。次の選択肢が表示されるので、サポートしたい実行環境を選択してください。デフォルトで x86_64-apple-darwin
、x86_64-pc-windows-msvc
、 x86_64-unknown-linux-gnu
が選択されています。
◯ aarch64-unknown-linux-gnu ◯ aarch64-unknown-linux-musl ◯ aarch64-pc-windows-msvc ◯ armv7-unknown-linux-gnueabihf ◉ x86_64-apple-darwin ◉ x86_64-pc-windows-msvc ◉ x86_64-unknown-linux-gnu ◯ x86_64-unknown-linux-musl ◯ x86_64-unknown-freebsd ◯ i686-pc-windows-msvc
? Enable github actions? (Y/n)
はGitHub Actionsを使うかどうかを質問されます。
質問に答えてCLIが実行を終えると、次のファイルを含むディレクトリが生成されます。
. ├── Cargo.toml ├── LICENSE ├── README.md ├── .npmignore ├── build.rs ├── index.js ├── npm │ ├── darwin-x64 │ │ ├── README.md │ │ └── package.json │ ├── linux-x64-gnu │ │ ├── README.md │ │ └── package.json │ └── win32-x64-msvc │ ├── README.md │ └── package.json ├── package.json ├── src │ └── lib.rs └── workflows └── CI.yml
Node.js add-onを作る雛形が生成されました。この時点でビルドしてnpm へ公開することができます。不要なものを公開しないように.npmignore
も生成されています。
ディレクトリへ移動して試しにビルドしてみましょう。
yarn yarn build
すると、RustのCrateがインストールされて、ビルドが始まります。
ビルドが完了すると、ディレクトリ直下に<パッケージ名>.darwin-x64.node
といったファイルが生成されます。たとえば、napi-sample
というパッケージ名の場合、napi-sample.darwin-x64.node
になります。
この時点で、src/lib.rs
に定義されたRustで書かれた関数をJavaScriptから呼び出してNode.jsで実行することができます。
ためしに run.js
というファイルを生成して src/lib.rs
に定義されている関数を呼び出してみましょう。
const mod = require('.'); console.log(mod.sync(10)); console.log(mod.sync(20)); mod.sleep(3000).then(console.log);
実行すると次のような結果が出力されます。
これは、src/lib.rs
に定義されているsync_fn
関数とsleep
関数の実行結果です。
#[module_exports] fn init(mut exports: JsObject) -> Result<()> { exports.create_named_method("sync", sync_fn)?; exports.create_named_method("sleep", sleep)?; Ok(()) } #[js_function(1)] fn sync_fn(ctx: CallContext) -> Result<JsNumber> { let argument: u32 = ctx.get::<JsNumber>(0)?.try_into()?; ctx.env.create_uint32(argument + 100) } #[js_function(1)] fn sleep(ctx: CallContext) -> Result<JsObject> { let argument: u32 = ctx.get::<JsNumber>(0)?.try_into()?; let task = AsyncTask(argument); let async_task = ctx.env.spawn(task)?; Ok(async_task.promise_object()) }
#[module_exports]
が付いている init
関数はJavaScriptで扱える関数を登録しています。
exports.create_named_method("sync", sync_fn)?;
では、JavaScriptで扱うときの名前を"sync"
とし、その実装はsync_fn
であることを宣言しています。
JavaScriptから実行されたときに実際に動くRustの関数には #[js_function(1)]
を付与します。
(1)
の部分は関数の引数の数です。
Rust のコードを追加してみる
ためしに、napi-rsのREADMEに記載されているフィボナッチ数列の関数を src/lib.rs
に追加してみましょう。
#[module_exports] fn init(mut exports: JsObject) -> Result<()> { exports.create_named_method("sync", sync_fn)?; exports.create_named_method("sleep", sleep)?; exports.create_named_method("fibonacci", fibonacci)?; Ok(()) } #[js_function(1)] // ------> arguments length, omit for zero fn fibonacci(ctx: CallContext) -> Result<JsNumber> { let n = ctx.get::<JsNumber>(0)?.try_into()?; ctx.env.create_int64(fibonacci_native(n)) } #[inline(always)] fn fibonacci_native(n: i64) -> i64 { match n { 1 | 2 => 1, _ => fibonacci_native(n - 1) + fibonacci_native(n - 2), } }
次に呼び出し側で追加した fibonacci
関数を実行してみましょう。
// run.js const mod = require('.'); // 10個目のフィボナッチ数を得る const result = mod.fibonacci(10); console.log(result);
run.js
の結果は 55
となります。正しく実行されています。
このように napi new
で生成した雛形の中のsrc/lib.rs
を修正していけば、Node.jsで実行可能な関数をRustで書くことができます。
もちろん、RustのCrateを使った開発も可能です。
napi-rsを使ったnpm publish
最後に作成したnpmパッケージのpublish方法を紹介します。
publish は GitHub Actionsで行います。GitHub ActionsでpublishするためにGitHub レポジトリに対して npm へのアクセス権を付与します。
npmのアクセストークンを発行して、トークンをGitHubリポジトリに登録します。
トークンの発行方法は次の記事が詳しそうです。
次にGitHubのリポジトリに発行したnpmのトークンを登録します。
GitHub のリポジトリからSettings > Secrets > New secret へ遷移すると次の画面に遷移します。
NameにNPM_TOKEN
とし、Valueに発行したnpmのトークンの文字列を入力してAdd secretボタンをクリックしてください。
登録が終わったら、publishの準備は整いました。実際にpublishしてみましょう。
まず、ローカルのPCで npm version
コマンドを実行して、publishするパッケージのバージョンを指定します。たとえば、マイナーバージョンを上げたい場合は次のコマンドを実行します。
npm version minor
次にGitHub に push すれば、GitHub Actionsが走ります。このときブランチはmainをpushしてください。napi new
で生成されるCI.yml
にはmainブランチがpushされたときにGitHub Actionsが実行されるように定義されているためです。
git push -u origin main
するとGitHub Actionsが実行されて、napi-rsが実行されてRustのコンパイルが始まります。
このとき、ターゲットとなるOSや環境は最初にnapi new
で指定した環境向けにクロスコンパイルしてくれます。たとえば、macOS、Windows、Linuxのx64(つまりIntel)のチップをターゲットとしている場合はそれらの実行環境用にRustをコンパイルします。さらに、Node.js 12, 14, 16向けにコンパイルします(2021年時点のバージョン)。
コンパイルのジョブ内でテストを実行しようとnpm test
が実行されます。もし、package.jsonのscriptsにtest
を定義していない場合はエラーになるかもしれません。
ですから、テストを書くか、CI.ymlからテスト用のジョブを削除してください。
コンパイルのジョブが完了するとnpm publishのジョブが実行されます。publishのジョブが完了すると、指定した環境ごとのnpmパッケージが公開されます。たとえば、パッケージ名が @shisama/napi-sample
の場合、次のパッケージが公開されます。
@shisama/napi-sample
@shisama/napi-sample-linux-x64-gnu
@shisama/napi-sample-darwin-x64
@shisama/napi-sample-win32-x64-msvc
すべてCIがグリーンになっていれば、npm にこれらのパッケージが公開されています。
クロスコンパイルによって生成された環境ごとのnpmパッケージが公開されますが、ユーザーがそれらを選んでインストールするわけではありません。
ユーザーはあくまで利用するパッケージ名を指定してインストールします。たとえば、@shisama/napi-sample
の例だと次のコマンドを実行するだけです(yarnの場合)。
yarn add @shisama/napi-sample
SWCを使ったことがある方はわかると思いますが、SWCを使うときにわざわざ@swc/core-linux-x64-musl
などをインストールしないと思います。ユーザーは swc のみをインストールしているはずです。ただ、swcがインストールされたとき、optionalDependenciesに定義されたパッケージからユーザーの環境に合わせたパッケージをインストールしています。
node_modules内のswcのpackage.jsonには次のようにoptionalDependenciesが記載されています。
"optionalDependencies": { "@swc/core-win32-x64-msvc": "^1.2.117", "@swc/core-darwin-x64": "^1.2.117", "@swc/core-linux-x64-gnu": "^1.2.117", "@swc/core-linux-x64-musl": "^1.2.117", "@swc/core-freebsd-x64": "^1.2.117", "@swc/core-win32-ia32-msvc": "^1.2.117", "@swc/core-linux-arm64-gnu": "^1.2.117", "@swc/core-linux-arm-gnueabihf": "^1.2.117", "@swc/core-darwin-arm64": "^1.2.117", "@swc/core-android-arm64": "^1.2.117", "@swc/core-linux-arm64-musl": "^1.2.117", "@swc/core-win32-arm64-msvc": "^1.2.117" },
napi-rsがpublish前に自動でoptionalDependenciesを追記してくれます。
そのため、macOSでswcをインストールしたときは、@swc/core-darwin-x64
がインストールされます。
swcの例のように、napi-rsでpublishしたnpm パッケージのpackage.jsonにも同じようにoptionalDependenciesが記載されるので、ユーザーの実行環境に応じてoptionalDependenciesに記載されたクロスコンパイルされて生成されたnpmパッケージがインストールされます。
このoptionalDependenciesからインストールされたプラットフォーム別のnpmパッケージにはRustをクロスコンパイルして生成した .node
ファイルが含まれています。
ユーザーが実行するNode.js add-onはプラットフォーム別に生成されたnpmパッケージ内の .node
ファイルです。
では、実際に今回作った fibonacci
関数を実行してみましょう。
次のように公開したnpmパッケージをインポートして、関数を実行します。
const napi = require('@shisama/napi-sample'); const { equal } = require('assert'); equal(napi.fibonacci(10), 55);
エラーがでなければ、正しくRustで書いた fibonacci
関数が実行されています。
今回この記事を書くために作ったnpmパッケージのコードは次のリポジトリに置いています。
ためしに使ってみたい方は次のコマンドでインストールできます。
npm install @shisama/napi-sample
今後試したいこと
napi-rsを使えば、RustでNode.js add-onを作ってクロスプラットフォーム向けにnpmパッケージを公開することができました。
次にやりたいこととしては、現在C/C++で書かれたNode.js add-onを使ったnpmパッケージをRustで書き直してnapi-rsを使ってクロスプラットフォーム向けにnpm registryに公開したいなと考えています。
現在C/C++で書かれたNode.js add-onを使った著名なパッケージにfseventsがあるので、少しずつRustに書き換えてみようと思います。
著名npmパッケージのセキュリティインシデントとpostinstallの問題点
最後に今回紹介した napi-rs を触ってみようと思ったきっかけについて説明します。
ua-parser-js や coa 、rc といった著名なnpmパッケージにパスワード抜き取りのマルウェアが仕込まれるセキュリティインシデントがありました。
このセキュリティインシデントの仕組みをざっくり説明すると、ユーザーが該当のnpmパッケージをインストールしたとき、それらのパッケージ内で定義されているpostinstall
スクリプトが実行されて、マルウェアがユーザーのコンピューターにインストールされるというものでした。
npmアカウントの乗っ取りに成功した攻撃者がpostinstall
スクリプトにマルウェアをインストールするスクリプトを書くことで、攻撃が成功します。
これらの事件の解決策自体はnpmパッケージ作者がnpmアカウントに2要素認証を設定して、npmアカウントを乗っ取られないようにするといったものでした。
これについてはGitHubのブログに記載されています。
ただ、これらの事件をきっかけにpostinstall
スクリプトを動かすこと自体にセキュリティ的に問題があるのではないかという議論が加熱しました(議論自体はこれらの事件以前からされていました)。
postinstall
をデフォルトで動かさずOpt-inで実行するようにnpmの仕様を変更したり、ユーザーが postinstall
を動かさないようにOpt-outする仕組みを作ったほうがいいとか色々議論されています。
しかし、postinstall
が動くことを前提に開発されているnpmパッケージはたくさんあります。それらが動かなくなれば、JavaScriptエコシステムに大きな影響を与える破壊的変更になってしまいます。
npmパッケージ開発者が postinstall
スクリプトを実行しなくてもいいようにnpmパッケージを開発すればいいのですが、ユーザーのコンピューターに依存する処理が含まれている場合は postinstall
を使わざるを得ないです。たとえば、ユーザーのOSのシステムコールに依存する処理が含まれていたり、パフォーマンス向上のためにCやC++などネイティブのバイナリを用いてる場合などです。
こういう場合は postinstall
を使ってユーザーのコンピューター上でC/C++をコンパイルしざるを得ないよなと考えていました。
その後、Next.js開発元のVercelのエンジニアがJavaScriptエコシステムはRustになっているという趣旨のブログを書きました。
このブログ自体はRustを使ったJSツールチェインなどについて書かれていたのですが、その中に「napi-rsを使ってRustで書いてNode.js add-onを書いてクロスコンパイルしたものをnpm へpublishすれば、node-gyp
やpostinstall
スクリプトが必要なくなる」ということが書かれていました。
napi-rs allows you to build pre-compiled Node.js add-ons with Rust. It provides an out-of-the-box solution for cross-compilation and publishing native binaries to NPM, without needing node-gyp or postinstall scripts.
これはもしかすると前述したpostinstall
の問題の解決策の1つになりそうだと感じ、napi-rsを触ってみました。
postinstallしなくても良かったり、ユーザーのPCでnode-gypを実行しなくてもいい未来が来てほしいですね。
まとめ
- napi-rsはNode.jsをネイティブ拡張するadd-onをRustで書くためのツール
- napi-rsを使えば複数のプラットフォーム向けにクロスコンパイルしたNode.js add-onを作成して公開できる
- セキュリティの問題によって postinstall が問題視されはじめている
- napi-rsを使えば、ユーザーのPCでpostinstallによるスクリプトを実行せずにNode.js add-onを実行できる
では、よい年末を!