Claude Media
Claude CodeでRustのWebAssemblyを作る — wasm-packとヘッドレスブラウザ検証

Claude CodeでRustのWebAssemblyを作る — wasm-packとヘッドレスブラウザ検証

wasm-packでRustをWebAssemblyにビルドし、wasm-bindgen-testのヘッドレスブラウザ実行で成果物を検証する反復を、CLAUDE.mdとpermissions、Stop hookの設定例で組みます。

RustのWebAssembly(wasm)をClaude Codeで作るときは、cargo checkが通ることを完了の条件にしないのが要点です。wasmはブラウザで動いて初めて成果物になります。ビルドの成否に加えて、ブラウザ上で関数が期待どおり動くかを、Claudeが自分で確かめられる形にしておきます。

この記事では、wasm-packによるビルドの流れ、wasm-bindgen-testをヘッドレスブラウザで回すテスト、そして検証コマンドをCLAUDE.md・permissions・Stop hookに固定する設定を扱います。

wasmで何を作るか — 既存フロントエンドの一部に使う場合

RustとWebAssemblyの使い道は、MDNによると2つあります。Rustでアプリ全体を作る方法と、既存のJavaScriptフロントエンドの一部をRustで置き換える方法です。MDNのチュートリアルが扱うのは後者で、全体を作る場合の例としてyewとleptosが挙げられています。この記事も、wasm-packでパッケージを作り、JavaScript側から呼ぶ構成を前提にします。パッケージにはwasmとJavaScriptだけが入るので、使う側にRustの環境は要りません。

wasmの成果物は何を検証すれば完了と言えるか

wasm-packは、Rustのコードをwasmにコンパイルし、ブラウザで使える形にパッケージ化するツールです。MDNのチュートリアルによると、wasm-pack buildは次の処理をまとめて行います。

  • Rustのコードをwasmにコンパイルする
  • wasm-bindgenを実行し、wasmをブラウザが扱えるモジュールに包むJavaScriptを生成する
  • pkgディレクトリを作り、JavaScriptとwasmをそこへ置く
  • Cargo.tomlから対応するpackage.jsonを作る
  • README.mdがあればパッケージへコピーする

ビルドが通っても、まだ確認できていないことが残ります。pkgの中身が想定どおりか、ブラウザがwasmを読み込めるか、Rustの関数が正しい値を返すか、の3つです。Claudeに任せるなら、この3つを別々のコマンドに分けて渡すのが安全です。

確認することコマンド分かること
コンパイルできるかコマンドcargo check分かること型・借用のエラー
配布物ができるかコマンドwasm-pack build --target web分かることpkgの生成
ブラウザで動くかコマンドwasm-pack test --headless --chrome分かること実ブラウザ上のテスト結果

最後の行がこの記事の中心です。alertが出ることを目で見る手順は人間には分かりやすい一方、Claudeには出力として返ってきません。ブラウザ上の動作は、テストの合否という文字列に落として初めて、Claudeが読んで直せる材料になります。

最初にClaudeへ渡す指示

検証の順序は、最初の指示にも書いておきます。次のように、完了条件を3段階で伝える形です。

src/lib.rs の関数を変更したら、cargo check、wasm-pack test --headless --chrome、
wasm-pack build --target web の順に実行して。どれかが失敗したら出力を読んで直し、
3つとも通るまで繰り返す。alert を呼ぶ関数は触らず、ロジック側だけを直す。

環境と雛形 — MDNのhello-wasmを検証しやすく直す

前提として、rustupでRustを入れ、wasm-packをcargo install wasm-packで入れます。MDNは、rustupのインストール後にcargoのbinディレクトリをPATHへ通す必要があり、反映にはターミナルの再起動が要ると注意しています。

cargo install wasm-pack
cargo new --lib hello-wasm

MDNのサンプルでは、greet関数がJavaScriptのalertを呼びます。このままだとテストで確かめられるのは「例外が出ない」ことだけです。そこで、文字列を組み立てる処理を別の関数に切り出します(以下は公式の例を検証向けに直した形です)。

use wasm_bindgen::prelude::*;
 
#[wasm_bindgen]
extern "C" {
    pub fn alert(s: &str);
}
 
#[wasm_bindgen]
pub fn greeting(name: &str) -> String {
    format!("Hello, {}!", name)
}
 
#[wasm_bindgen]
pub fn greet(name: &str) {
    alert(&greeting(name));
}

Cargo.tomlは、MDNではcrate-type = ["cdylib"]とwasm-bindgen = "0.2"が基本です。ここにテスト用の依存を足します。

[lib]
crate-type = ["cdylib", "rlib"]
 
[dependencies]
wasm-bindgen = "0.2"
 
[dev-dependencies]
wasm-bindgen-test = "0.3"

rlibは、tests/のファイルから自分のクレートの関数をuseするために足しています。MDNの例はcdylibだけなので、テストが関数を見つけられないときはここを疑います。

wasm-bindgen-testをヘッドレスブラウザで動かす

wasm-bindgen-testは、wasm向けのテストハーネスです。通常の#[test]に近い書き方で、#[wasm_bindgen_test]属性を付けます。テストはtests/wasm.rsに置きます。

use hello_wasm::greeting;
use wasm_bindgen_test::*;
 
wasm_bindgen_test_configure!(run_in_browser);
 
#[wasm_bindgen_test]
fn greeting_includes_name() {
    assert_eq!(greeting("WebAssembly"), "Hello, WebAssembly!");
}

既定ではテストはNode.jsで動きます。wasm_bindgen_test_configure!(run_in_browser);を書くとブラウザで動かす指定になり、この場合は環境変数の指定が無視されます。ブラウザはwasm-pack testのフラグで選びます。

wasm-pack test --headless --chrome

--chromeのほか--firefoxと--safariがあり、複数を並べると各ブラウザで順に走ります。そのブラウザがマシンに入っている必要があります。公式は、--headlessを付けないと通常のウィンドウで開くので、失敗したテストをdevtoolsで調べられる、とも説明しています。Claudeに回させるのは--headless付き、人間が原因を追うときは外す、と使い分けると噛み合います。

失敗時の出力は、テスト名とassert_eq!の左右の値、続いてJavaScript側の例外が並ぶ形です。公式のサンプルではtest wasm::fail ... FAILEDの行と、left: 1・right: 2が出ます。この形ならClaudeは出力をそのまま読んで直せます。

テストの置き場所と設定のつまずき

公式が明記している制約が2つあります。1つは、テストをクレートのルートかpub modの中に置くことです。非公開のモジュールに入れると動きません。もう1つは、ブラウザの能力を変えたいときの設定です。クレート直下にwebdriver.jsonを置くか、環境変数WASM_BINDGEN_TEST_WEBDRIVER_JSONでファイルを指します。Chromeならgoog:chromeOptions、Firefoxならmoz:firefoxOptionsの節に書きます。

wasm-packを使わずcargo test --target wasm32-unknown-unknownで回す方法もあります。その場合は、wasm-bindgen-cliをCargo.tomlのwasm-bindgenと同じバージョンで入れ、.cargo/configにrunner = 'wasm-bindgen-test-runner'を書く必要があります。迷ったらwasm-pack経由が楽です。

ビルド成果物をブラウザで読み込めるか確かめる

テストが通っても、配布するpkgがそのままブラウザで読めるかは別の話です。MDNの手順では、wasm-pack build --target webでpkgを作り、プロジェクトのルートにindex.htmlを置いて、ローカルサーバーで配信します。

<!doctype html>
<html lang="en-US">
  <head><meta charset="utf-8" /><title>hello-wasm example</title></head>
  <body>
    <script type="module">
      import init, { greet } from "./pkg/hello_wasm.js";
      init().then(() => { greet("WebAssembly"); });
    </script>
  </body>
</html>

MDNは、サーバーがapplication/wasmのMIMEタイプに対応している必要があり、古いサーバーでは対応していない場合がある、と注意しています。これはコマンドで確かめられます。

python3 -m http.server 8000 &
curl -sI http://localhost:8000/pkg/hello_wasm_bg.wasm | grep -i content-type

content-typeにapplication/wasmが出れば、配信側の条件は満たしています。ここでも、画面を見る代わりにレスポンスヘッダーという文字列で判定させる考え方です。

配布先に合わせて--targetを選ぶ

--targetの選択は、成果物の使い方で決まります。MDNが扱うのは次の2つです。

用途コマンド読み込み方
ネイティブのESモジュールとして直接読むコマンドwasm-pack build --target web読み込み方index.htmlから./pkg/hello_wasm.jsをimport
webpackなどのバンドラーで使うコマンドwasm-pack build --target bundler読み込み方npmパッケージとしてimport * as wasm from "hello-wasm"

bundlerの場合、MDNのwebpack設定ではexperiments: { asyncWebAssembly: true }を有効にしています。この設定が抜けると読み込みで失敗するため、CLAUDE.mdに書く規約の候補になります。

CLAUDE.mdに検証の手順を固定する

CLAUDE.mdには、コマンドと完了条件を書きます。上の表をそのまま規約にします。

## Rust / wasm 規約
- 変更後は `cargo check` を実行し、エラーがなくなるまで直す
- ロジックは `alert` などJS呼び出しから分離し、`tests/wasm.rs` でテストする
- 仕上げに `wasm-pack test --headless --chrome` を実行し、全テスト通過を確認する
- 配布物を変えたときは `wasm-pack build --target web` で `pkg` が生成されることを確認する
- `cargo publish` と `wasm-pack publish` は実行しない

2行目が効きます。JavaScript呼び出しとロジックを分けておくと、ブラウザ固有の副作用を避けて値を検証できます。

cargoとwasm-packをpermissionsで許可する

反復の間、Claudeは同じコマンドを何度も実行します。.claude/settings.jsonに許可ルールを置けば、毎回の確認を省けます。

{
  "permissions": {
    "allow": [
      "Bash(cargo check *)",
      "Bash(wasm-pack build *)",
      "Bash(wasm-pack test *)"
    ],
    "deny": [
      "Bash(wasm-pack publish *)",
      "Bash(cargo publish *)"
    ]
  }
}

公式のルール記法では、末尾の*の前に空白を置くと素のコマンドにも一致します。評価順はdeny、ask、allowの順で、最初に一致したものが結果を決めます。公開系をdenyに置けば、allowの書き方に関わらず止まります。MDNによるとwasm-pack publishは内部でnpm publishを呼ぶため、npmへの公開が走るコマンドとして明示的に止めておく価値があります。

Stop hookでClaudeの完了前にテストを走らせる

規約は文脈にすぎず、Claudeが守る保証はありません。完了前に必ず検証させたいなら、Stop hookが使えます。公式のhooksガイドによると、StopはClaudeが応答を終えるときに動き、JSONでdecision: "block"とreasonを返すと、理由がClaudeに返されて作業が続きます。

#!/bin/bash
# .claude/hooks/wasm-check.sh
INPUT=$(cat)
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
  exit 0
fi
if ! OUT=$(wasm-pack test --headless --chrome 2>&1); then
  jq -n --arg r "$(echo "$OUT" | tail -40)" '{decision: "block", reason: $r}'
fi
exit 0
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          { "type": "command", "command": ".claude/hooks/wasm-check.sh" }
        ]
      }
    ]
  }
}

stop_hook_activeの確認は公式が勧める定石です。Claude Codeは、Claudeのツール呼び出しを挟まずにStop hookが連続8回ブロックすると、フックを上書きして止めます。テストが落ち続けても無限には回りません。出力の末尾40行だけを返しているのは、失敗箇所をClaudeへ渡しつつ、長いログで文脈を埋めないためです。

テスト自動化の別の型はhooksでテストを自動化するtipsに、hooksそのものの全体像はhooksの解説にあります。

公開前にパッケージの中身を確かめる

npmへ出す場合、MDNはwasm-pack packでtarballを作る手順を示しています。出力にはhello_wasm_bg.wasmやpackage.jsonなどの収録ファイルが並び、公開の前に中身を目で確認できます。公開には、npmアカウントとnpm loginによる認証が必要です。この確認までをClaudeに任せ、publishは人間が実行する分担にすると、前節のdenyルールと合います。

よくあるつまずき

  • テストがNode.jsで動いてしまう: 既定はNode.jsです。wasm_bindgen_test_configure!(run_in_browser);かWASM_BINDGEN_USE_BROWSER=1でブラウザを指定します
  • ブラウザが見つからない: --chromeならChromeがマシンに必要です。hookやCIの実行環境にも入っているかを確認します
  • pkgが古いままブラウザに出る: --targetを切り替えるたびにpkgは作り直されます。webとbundlerで出力の形が違うため、読み込む側と揃っているかを見ます
  • alertの動作をテストに書きたくなる: ダイアログは出力に残りません。値を返す関数に分けて検証します
  • テストが「見つからない」: 非公開モジュールに置いていないか、rlibを足しているかを確認します

RustでWebAPIを作る場合の検証ループは、AxumのRust API開発で扱っています。ゲームなど別の成果物はBevy、デスクトップアプリはTauriの記事が対応します。wasmは、成果物がブラウザで動く点だけが他と違い、その検証をヘッドレスブラウザに任せるのがこの記事の型です。

この記事を共有:XはてブLinkedIn