別にしんどくないブログ

技術のことや読書メモを書いています

Rust + Node-APIでクロスプラットフォーム向けnpmパッケージを公開する

この記事は Node.jsのカレンダー | Advent Calendar 2021 - Qiita の2日目の記事です。

今回はnapi-rsというNode-APIを使ったNode.js add-onをRustで書けるツールを紹介します。

napi.rs

目次

Node-API とは

napi-rsの紹介をする前にNode-APIについて説明します。

Node.jsは、Node.js自体をネイティブレベルで拡張するためのadd-onをユーザーが開発するためのNode-APIと呼ばれるAPIを提供しています。Node-APIは以前はN-APIと呼ばれており、napi-rsの名称の由来はN-APIだと思います。

nodejs.org

Node-APIC言語またはC++で書くことを前提にされており、node-gypCMake.js を使ってビルドします。node-gyp では、C/C++のコードはコンパイル.node という拡張子のファイルとして出力されます。それをJavaScriptで読み込んでNode.js上で実行します。

const Native = require('./hello.node');

Node-APIを使えば、JavaScriptのコードより高速に処理するネイティブコードを書くことができます。(ただし、必ずしもネイティブコードの方が速いわけではありません。)

napi-rsとは

そのNode-APIC/C++ではなくRustで書けるようにするツールが今回紹介するnapi-rsです。

すでにnapi-rsはSWCのビルドに使われています。 SWCはsuper fast な TypeScript / JavaScriptコンパイラです。 JavaScriptで書かれたBabelより20倍高速らしいので、Rustで書くメリットが発揮されています。(言語だけでなくアーキテクチャなども関係あるとは思います。)

swc.rs

また、bcryptやdeno-lintなどnapi-rsを使ったパッケージがnapi-rs/node-rsリポジトリ内で開発されています。

github.com

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-darwinx86_64-pc-windows-msvcx86_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

f:id:Shisama:20211203051242p:plain
napi-rsによるターゲットプラットフォームの選択

? 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がインストールされて、ビルドが始まります。

f:id:Shisama:20211203051341p:plain

ビルドが完了すると、ディレクトリ直下に<パッケージ名>.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);

実行すると次のような結果が出力されます。

f:id:Shisama:20211203053459g:plain

これは、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リポジトリに登録します。

トークンの発行方法は次の記事が詳しそうです。

neos21.net

次にGitHubリポジトリに発行したnpmのトークンを登録します。

GitHubリポジトリからSettings > Secrets > New secret へ遷移すると次の画面に遷移します。

f:id:Shisama:20211203051505p:plain

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で指定した環境向けにクロスコンパイルしてくれます。たとえば、macOSWindowsLinuxの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 にこれらのパッケージが公開されています。

f:id:Shisama:20211203051530p:plain

ロスコンパイルによって生成された環境ごとの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パッケージのコードは次のリポジトリに置いています。

github.com

ためしに使ってみたい方は次のコマンドでインストールできます。

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に書き換えてみようと思います。

github.com

著名npmパッケージのセキュリティインシデントとpostinstallの問題点

最後に今回紹介した napi-rs を触ってみようと思ったきっかけについて説明します。

ua-parser-jscoarc といった著名なnpmパッケージにパスワード抜き取りのマルウェアが仕込まれるセキュリティインシデントがありました。

このセキュリティインシデントの仕組みをざっくり説明すると、ユーザーが該当のnpmパッケージをインストールしたとき、それらのパッケージ内で定義されているpostinstallスクリプトが実行されて、マルウェアがユーザーのコンピューターにインストールされるというものでした。

npmアカウントの乗っ取りに成功した攻撃者がpostinstallスクリプトマルウェアをインストールするスクリプトを書くことで、攻撃が成功します。

これらの事件の解決策自体はnpmパッケージ作者がnpmアカウントに2要素認証を設定して、npmアカウントを乗っ取られないようにするといったものでした。

これについてはGitHubのブログに記載されています。

github.blog

ただ、これらの事件をきっかけにpostinstallスクリプトを動かすこと自体にセキュリティ的に問題があるのではないかという議論が加熱しました(議論自体はこれらの事件以前からされていました)。

postinstall をデフォルトで動かさずOpt-inで実行するようにnpmの仕様を変更したり、ユーザーが postinstall を動かさないようにOpt-outする仕組みを作ったほうがいいとか色々議論されています。

github.com

github.com

しかし、postinstallが動くことを前提に開発されているnpmパッケージはたくさんあります。それらが動かなくなれば、JavaScriptエコシステムに大きな影響を与える破壊的変更になってしまいます。

npmパッケージ開発者が postinstall スクリプトを実行しなくてもいいようにnpmパッケージを開発すればいいのですが、ユーザーのコンピューターに依存する処理が含まれている場合は postinstall を使わざるを得ないです。たとえば、ユーザーのOSのシステムコールに依存する処理が含まれていたり、パフォーマンス向上のためにCやC++などネイティブのバイナリを用いてる場合などです。

こういう場合は postinstall を使ってユーザーのコンピューター上でC/C++コンパイルしざるを得ないよなと考えていました。

その後、Next.js開発元のVercelのエンジニアがJavaScriptエコシステムはRustになっているという趣旨のブログを書きました。

leerob.io

このブログ自体はRustを使ったJSツールチェインなどについて書かれていたのですが、その中に「napi-rsを使ってRustで書いてNode.js add-onを書いてクロスコンパイルしたものをnpm へpublishすれば、node-gyppostinstallスクリプトが必要なくなる」ということが書かれていました。

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を実行できる

では、よい年末を!