🦀

Rust を WebAssembly にコンパイルして JavaScript から呼び出す方法のまとめ

2025/01/06に公開

以前の記事にも書いたように、Rust コードを JavaScript でコンパイルするのは wasm-pack を使うと簡単にできます。しかし、wasm-pack が使えない場合はいくつか面倒な点があったり、wasm-pack を使う場合でも若干引っかかる点があったりします。

この記事では、JavaScript 側のビルドツールとしては Vite を使う前提で説明します。

また、ツールやライブラリのバージョンは以下を前提とします。

  • rustc 1.83.0
  • emcc 3.1.74
  • vite 5.4.11
  • vite-plugin-top-level-await 1.4.4

wasm-pack が使える場合

wasm-pack が使える場合にはこれを使うのが一番簡単です。wasm-pack には様々な利点があります。

  • 比較的複雑なデータ型も直接 Rust - JavaScript 間でやり取りできる
  • JavaScript として自然な形で呼び出すためのグルーコードも TypeScript の型宣言も含めて自動で生成される。このグルーコードにより、普通の JavaScript モジュールを読み込むような感覚で Rust コードを利用できる。

ただし一つだけ注意点があります。wasm-packbuild targetbundler (デフォルト値) にした場合、Vite の production build (npm run build) では正常に動作しないという問題があります。これは vite-plugin-wasm などを使っても解消できません。

この問題は、build target を web にし、wasm のインスタンス化部分を明示的に呼び出すことで解決できます。ここで、インスタンス化は非同期的に行われることに注意が必要です。グルーコード中の default export 関数 (__wbg_init) の返す Promise をもとにインスタンス化の完了を明示的に待つ方法もありますが、vite-plugin-top-level-await などを使ってトップレベルでインスタンス化を呼び出すのが簡単であると思われます。この場合 vite-plugin-wasm はなくても動きます。

なお、記事執筆時点では、vite-plugin-top-level-awaitvite 6 系列と互換性がなかったようで、vite のバージョンを 5 系列にしないと動きませんでした。どうしても vite 6 系列が必要な場合は、トップレベルのインスタンス化を避けて、ロード時など適当なタイミングでインスタンス化をする必要があると考えられます。

wasm-pack を使わない場合

必ずしも wasm-pack が使えるとは限りません。例えば、cc を使ってプロジェクトの一部を C++ で記述する場合、wasm-pack では C++ 部分を (wasm32-unknown-unknown ターゲットに) コンパイルするところで失敗します。
この場合は、Rust / JavaScript 間のデータのやり取りを自前で書いた上で、wasm32-unknown-emscripten ターゲットにビルドするということが必要になります。

ビルドの方法

ライブラリ (cdylib) crate を wasm32-unknown-emscripten ターゲットにビルドする場合、出力先ファイルの拡張子が .wasm になり、グルーコードとなる .js ファイルが生成されません。拡張子は Rust のソースコード内 にハードコードされており、調べた限りではこれを変更できるオプションは見つかりませんでした。

bin target であれば出力ファイルの拡張子は .js となるため(この場合 .wasm ファイルは暗黙に生成されます)、問題なくグルーコードが生成されるように見えます。しかし実際に使おうとしてみると、#[wasm_bindgen] を付けた関数が出力ファイルに存在しない場合があります。(TODO: 実際に確かめる) これは、bin target では main() から呼ばれない関数は本来必要がないため、最適化の過程で消されてしまうためであると考えられます。

これらの問題は、かなり強引ではありますが emcc のラッパーを挟んでライブラリとしてビルドするという方法で解決することができます。このために必要なものは次の 2 つです。

.cargo/config.toml

リンク時にラッパーを使うようにする指定と、リンク時に emcc に渡す追加の引数の指定をここで行います。

記載例を下に示します:

[target.wasm32-unknown-emscripten]
linker = "./util/emcc_wrapper"
rustflags = [
    "-C",
    "panic=abort",
    "-C",
    "link-args=-s MODULARIZE=1 -s ALLOW_MEMORY_GROWTH=1 -s FILESYSTEM=0 -s ENVIRONMENT=web,worker -s EXPORT_ES6=1",
]

(https://github.com/semiexp/enigma_csp/blob/main/.cargo/config.toml)

emcc のラッパースクリプト

必要な機能は次の 2 つです。

  • 出力先のファイル名の拡張子が .wasm であれば、.js に変更する
  • 出力先のファイル名に応じて、EXPORTED_FUNCTIONS を追加する

後者については必ずしも必要ではありませんが、_malloc_free などを使いたい場合には指定する必要が出てきます。デフォルトだと Rust 側で実装して #[no_mangle] がついている関数は自動で出力に含まれるようなのですが、_malloc_free といった関数は export されません(参考)。これらの関数は、JavaScript 側から Rust のメモリ領域を明示的に確保、解放するために必要です[1]

これについては config.toml に書けばいいじゃないかと思われるかもしれません。しかし、ビルドの過程で複数の cdylib crate がビルドされる場合にはそれらすべてに同じ指定が反映されてしまい、存在しない関数名を指定するとエラーになるため、出力先に応じて指定を変える必要が出てきます。

実装例を下に示します:

#!/usr/bin/env python3

# A simple wrapper for emcc that changes the output file extension to .js

import argparse
import os
import subprocess


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("-o", type=str, required=True)
    overwritten_args, extra_args = parser.parse_known_args()

    orig_output = overwritten_args.o
    root, ext = os.path.splitext(orig_output)
    assert ext in [".wasm", ".js"]
    output = root + ".js"

    if os.path.basename(root) == "<file_name>":
        extra_args += ["-s", "EXPORTED_FUNCTIONS=<function_name>,_malloc,_free"]

    args = ["emcc", "-o", output] + extra_args
    subprocess.check_call(args)


if __name__ == "__main__":
    main()

(https://github.com/semiexp/cspuz_core/blob/main/util/emcc_wrapper)

なおこのスクリプトは実行可能にしておく必要があります (chmod +x emcc_wrapper)。

データのやり取り

emscripten を使う場合、JavaScript と C++ の間で直接やり取りできるデータは、数値(整数、浮動小数点数)およびポインタのみです(参考)。ポインタは JavaScript 側では単なる数値として扱われるため、実質的に数値しか扱えないと言えます。Rust についても同様に、JavaScript 側に公開する関数の引数、戻り値としては数値型や生ポインタ型しか使えないと考えられます。

渡せるものが定数個の数値だけでは複雑な処理を行うことはできないので、可変長のデータを渡せる必要があります。もし文字列を渡すことができれば、serde などを使った JSON 表現を挟むことで、より複雑なデータもやり取りできるようになります。

以下、JavaScript と Rust の間で文字列をやり取りする方法について説明します。

Rust 側

文字列を受け取る / 渡す関数のシグネチャは次のようになります。

fn foo(input: *const u8) -> *const u8 {
  ...
}

まず受け取り側、つまり input をどう解釈するかですが、std::slice::from_raw_parts を使うと生ポインタとスライスの長さからスライス &[u8] を得ることができます。これを使うなら、関数の引数として文字列の長さも受け取るようにすると良いでしょう。&[u8] が得られれば、str::from_utf8&str が得られますし、serde_json::from_slice で直接 JSON deserialize を行うこともできます。

文字列を返すのはもう少し複雑になります。文字列は可変長かつ関数呼び出しの後もポインタが利用可能である必要があるため、必然的にヒープ領域上に値を置くことになりますが、

  • Rust 側から見ると、関数呼び出しの後も値を生存させる必要がある
  • 一方、JavaScript 側では受け取った値を使い終わったら解放する必要がある(そうでないとメモリリークが発生する)

というように、Rust の領域でのメモリ管理の一部を JavaScript 側で明示的に行う必要が出てきます。

簡易的には、戻り値を static mut SHARED_ARRAY: Vec<u8> などに保管して、そのポインタを返すことで対応できます。この場合、次の関数の呼び出しが発生する前に戻り値を JavaScript 側で処理しきることが前提となります。また細かい問題として、戻り値の文字列の長さをどう渡すかということがありますが、SHARED_ARRAY の先頭数バイトを文字列の長さを入れておく場所としておくなどで対処できます。

(筆者はやったことがありませんが)もう少し丁寧に実装するなら、

  • 返す値は一度 Box に入れ、into_raw して生ポインタを返す
  • こうして返された生ポインタからデータにアクセスするための関数、および Box を解放するための関数も別途用意し、JavaScript 側ではこれを経由してデータにアクセスする

のようになるかと思われます。

JavaScript 側

JavaScript から Rust の関数を呼び出すときの手順は次のようになります。

  • 文字列については、_malloc などを使って渡すデータのための領域を確保し、データをコピーする(呼び出し後に _free などで解放する)
  • 関数を呼び出す(このとき、関数名には先頭に _ がつく)
  • 戻り値の値を JavaScript 側にコピーする。Rust 側の実装によっては、使い終わった後にメモリ解放用の関数を明示的に呼び出す

この方法による実装例が https://github.com/semiexp/cspuz_core/blob/main/tests/test_cspuz_solver_backend.js にあります。

脚注
  1. _malloc_free に対応する関数を Rust のコードとして自前で用意するという方法もあります。余計な関数(特に、Rust と C++ の境界面の関数で、JavaScript 側から使われることを意図しないもの)が export されても問題がない場合は、このようにした上で明示的な EXPORTED_FUNCTIONS の指定を省略するということも考えられます。 ↩︎

Discussion