ruby.wasm で遊ぶ ～Web Workerを使ってバックグラウンドで動かすまで

Ruby 3.2 から WASI ベースの WebAssembly がサポートされるということで、すでに Preview 版も公開されています。

この記事は、正直 WebAssembly とか WASI とかよく分かっていない[^1]人間がブラウザ上で Ruby を色々動かしてみる記事です。とりあえず動けばいいという感じなので、おそらく無駄な記述も多いかと思います。ご了承ください。

[^1]: そもそも JavaScript だってよく分かっていないし、Ruby だって分かっていると言えるか怪しい。

# 作るもの

テキストボックス等に記述された任意の Ruby スクリプトをブラウザ上で動かして、その実行結果を得られるようなもの。

要するに [RubyOnBrowser](https://rubyonbrowser.ongaeshi.me/) とか [TryRuby](https://try.ruby-lang.org/playground/) とかの二番煎じを作りたいのです。

# とりあえず Ruby スクリプトを動かす

[ruby.wasm の github](https://github.com/ruby/ruby.wasm/) 上に [Quick Start (for Browser)](https://github.com/ruby/ruby.wasm/tree/main/packages/npm-packages/ruby-head-wasm-wasi#quick-start-for-browser) が載っているので、まずはこれをほぼそのまま。

```html:ruby1.html
<html>
  <script src="https://cdn.jsdelivr.net/npm/ruby-head-wasm-wasi@latest/dist/browser.umd.js"></script>
  <script>
    const { DefaultRubyVM } = window["ruby-wasm-wasi"];
    const main = async () => {
      const response = await fetch(
        "https://cdn.jsdelivr.net/npm/ruby-head-wasm-wasi@latest/dist/ruby.wasm"
      );
      const buffer = await response.arrayBuffer();
      const module = await WebAssembly.compile(buffer);
      const { vm } = await DefaultRubyVM(module);

      alert(vm.eval(`(1..10).inject(:*)`).toString());
    };

    main();
  </script>
  <body></body>
</html>
```

これで、`(1..10).inject(:*)` の計算結果がアラートで表示されます。

## 標準ライブラリを使用できるようにする

上の HTML で読み込んでいる `https://cdn.jsdelivr.net/npm/ruby-head-wasm-wasi@latest/dist/ruby.wasm` には、標準ライブラリは基本的に入っていません。[^stdlib] ですので `require "date"` とか書くとエラーになります。標準ライブラリを使いたい場合は、別のファイルを読み込む必要があります。

[^stdlib]: まったく入っていないわけではなく、`stringio` は入っていました。他に何が入っているかは調べていません。

注意しなければならないのは、latest( https://cdn.jsdelivr.net/npm/ruby-head-wasm-wasi@latest/ )の場所には現在標準ライブラリ入りのファイルは置かれていない、ということです。nightly release 版にしか標準ライブラリ入りは無いとのことなので、上のリンク先の右上にあるセレクトボックスで「ruby-head-wasm-wasi@0.3.0-（日付）-a」を選び、`dist` → `ruby+stdlib.wasm` を使います。また、先の HTML ファイル 2 行目の `<script src="～">` で読み込んでいるファイルも併せて同じ日付版の `browser.umd.js` に変更しておきましょう。

おそらく 3.2 正式版リリースの際には latest の場所にも `ruby+stdlib.wasm` が置かれるのだと思いますが、とりあえず現状はこうした注意が必要なようです。

※ 今回の記事では、以後とりあえず 2022/09/29 版を使用して進めていきます。 

## vm.eval

`vm.eval` に与えた文字列が、Ruby スクリプトとして実行されます。`Kernel.#eval` 同様、最後の式の値が戻ってきます。

戻ってきたデータはなんだかよく分からないオブジェクトですが、`.toString()` してやることで、Ruby で言えば `.to_s` した時の文字列が得られるようです。

このように Ruby 側から JavaScript 側に文字列データを送ることはできるので、例えば JSON にした文字列を送るようにすれば配列やハッシュのデータも送れます。（もしかしたら もっと簡単な方法も用意されているのかもしれませんが、あまり調べていません。）

# テキストボックスに入力されたスクリプトを実行できるように

```html:ruby2.html
<html>
  <script src="https://cdn.jsdelivr.net/npm/ruby-head-wasm-wasi@0.3.0-2022-09-29-a/dist/browser.umd.js"></script>
  <script>
    let RubyModule;
    const { DefaultRubyVM } = window["ruby-wasm-wasi"];
    const main = async () => {
      const response = await fetch(
        "https://cdn.jsdelivr.net/npm/ruby-head-wasm-wasi@0.3.0-2022-09-29-a/dist/ruby+stdlib.wasm"
      );
      const buffer = await response.arrayBuffer();
      const module = await WebAssembly.compile(buffer);
      RubyModule = module;
      document.getElementById("run").disabled = false;
    };

    main();

    async function run(){
      const script = document.getElementById("script").value;
      const result = document.getElementById("result");
      const { vm } = await DefaultRubyVM(RubyModule);
      result.value = "";
      result.value += vm.eval(script).toString();
    }
  </script>
  <body>
    <textarea id="script">(1..10).inject(:*)</textarea>
    <button id="run" onclick="run()" disabled>実行</button>
    <textarea id="result" readonly></textarea>
  </body>
</html>
```

とりあえずこんな感じで可能です。

## 実行するたびにリセットされるようにするために

今回作りたいものの性質を考えると、実行するたびにリセットされることが望ましいでしょう。つまり最初にテキストボックスに `$a = 1` を入力して実行し、続いてテキストボックスを `$a` に変えて実行したら、`1` ではなく `nil` になってほしいのです。

最初の `ruby1.html` では、最終的に Ruby スクリプトを実行する際に必要な `vm` を用意する際に、`response`, `buffer`, `module` という中間生成物が作られていました。

試してみたところ、`vm` を保存して使い回すと前回の実行結果が残るのですが、`module` を保存しておいて そこから `vm` を生成して使えば、前回の実行結果はリセットされるようです。上の `ruby2.html` ではそうしています。

# Ruby から JavaScript を操作

```ruby
require "js"
JS::eval("実行したい JavaScript コード")
```

とすれば、Ruby から JavaScript を実行できます。DOM 操作等もできるので、Ruby 側からブラウザをコントロールできます。

# 出力を得る
そのままでは Ruby スクリプト内で出力された内容は、console には表示されるものの、値として得ることができません。

RubyOnBrowser や TryRuby では、`WasmFs` の `writeSync` を置き換えることで出力を得ています。そのやり方を使う場合は、[RubyOnBrowser の該当箇所](https://github.com/ongaeshi/rubyonbrowser/blob/main/src/browser.ts#L25)や [TryRuby の該当箇所](https://github.com/ruby/TryRuby/blob/master/source/javascripts/try_ruby.js.rb#L137)を見てください。 （私はよく分かっていません。）

が、もっと単純な方法としては、以下のようなやり方でも出力を得ることが可能です。

```javascript
vm.eval(`
require "stringio"
$stdout = $stderr = StringIO.new(+"", "w")
`);
let output;
try {
  vm.eval(script);
  output = vm.eval(`$stdout.string`).toString();
} catch(err) {
  output = err.toString();
}
```

要するに Ruby 内で標準出力をリダイレクトさせ、最後にその値を戻しています。

### 入力を与える

出力の応用で、標準入力に何らかの文字列を与えることもできます。

```javascript
vm.eval(`
require "stringio"
$stdin = StringIO.new("foo")
`);
```

それから入力をダイアログで対話的に取得したいのであれば、こんな方法もあります。（※ この記事を書いている時点では、latest のところに置かれたファイルでは正しく動きません。ある程度新しい日付のものを使う必要があるようです。）

```javascript
vm.eval(`
require "js"
module Kernel
  def gets
    JS::eval("return prompt()").to_s + "\n"
  end
end
`)
```

### 出力があるたびに値を得る

なお、上述の出力取得方法ではスクリプトの実行が終わるまで出力を得ることができませんが、出力があるたびに得たい場合は、無理矢理ですがこんな方法も可能です。

```javascript
vm.eval(`
require "stringio"
$stdout = $stderr = StringIO.new(+"", "w")
require "js"
def $stdout.write(str)
  JS::eval("result.value += \`" + str.gsub("\\\\", "\\\\\\\\").gsub("\`", "\\\\\`") + "\`")
end
`)
```

ただしこの方法を使って `puts 1; sleep 1; puts 2` のようなスクリプトを実行させても、結局スクリプトが終了するまでテキストボックスの値は変わりません。出力があるたびに画面にも即表示させたい場合は、この後の Web Worker を用いる方法と組み合わせる必要があります。

# Web Worker を用いてバックグラウンドで動作させる

ここまでのやり方には実は大きな問題がありました。Ruby スクリプト実行中はページが固まってしまうのです。

今回作りたいものが「テキストボックス等に記述した任意の Ruby スクリプトを実行する」というものである以上、無限ループする Ruby スクリプトが実行される可能性もあります。そうした場合にページが完全に固まり、最悪の場合タブやブラウザを閉じる必要があります。

しかし、Web Worker という仕組みを利用すれば、バックグラウンドで Ruby スクリプトを動作させることで、たとえ無限ループであろうがページが固まらなくて済むようになります。

```html:ruby3.html
<html>
  <script>
    let worker = new Worker("worker.js");
    worker.addEventListener("message", workerEvent, false);

    function workerEvent(e) {
      if (e.data[0] == "init") {
        document.getElementById("run").disabled = false;
      } else if (e.data[0] == "output") {
        document.getElementById("result").value += e.data[1];
      }
    }

    async function run(){
      const script = document.getElementById("script").value;
      document.getElementById("result").value = "";
      worker.postMessage(["script", script]);
    }
  </script>
  <body>
    <textarea id="script">puts (1..10).inject(:*)</textarea>
    <button id="run" onclick="run()" disabled>実行</button>
    <textarea id="result" readonly></textarea>
  </body>
</html>
```

```javascript:worker.js
importScripts("https://cdn.jsdelivr.net/npm/ruby-head-wasm-wasi@0.3.0-2022-09-29-a/dist/browser.umd.js");

let RubyModule;

const { DefaultRubyVM } = this["ruby-wasm-wasi"];
const main = async () => {
  const response = await fetch(
    "https://cdn.jsdelivr.net/npm/ruby-head-wasm-wasi@0.3.0-2022-09-29-a/dist/ruby+stdlib.wasm"
    );
  const buffer = await response.arrayBuffer();
  const module = await WebAssembly.compile(buffer);
  RubyModule = module;
  self.postMessage(["init", ""]);
};

main();

self.addEventListener("message", async function(e) {
  if (e.data[0] == "script") {
    const script = e.data[1];
    const { vm } = await DefaultRubyVM(RubyModule);
    vm.eval(`
require "stringio"
$stdout = $stderr = StringIO.new(+"", "w")
    `)
    let output;
    try{
      vm.eval(script);
      output = vm.eval(`$stdout.string`).toString();
    } catch(err) {
      output = err.toString();
    }
    self.postMessage(["output", output]);
  }
}, false);
```

このように、`worker.js` にバックグラウンド動作させたいスクリプトを移し、それから今までは HTML から `<script>` で読み込んでいた `browser.umd.js` を、`worker.js` から `importScripts` を用いて読み込むように変更してやります。（← この部分がなかなか分からなくて時間を費やしました…）

なお、Web Worker 上からは windows オブジェクト等にアクセスできなくなるので、先述の「入力をダイアログで対話的に取得」の方法は window.prompt を使用しているため使えなくなります。

## ローカルでも動作するようにする

上記の `ruby3.html` + `worker.js` は、どこかの Web サーバーに載せたり、あるいはローカルで Web サーバーを立ち上げ localhost からアクセスしたりすれば問題なく動作するのですが、ローカルに置いたファイルをただ開いた場合、セキュリティの問題により動作しません。

が、以下のような対策があるようです。[^security]

https://tshino.hatenablog.com/entry/20180106/1515218776

[^security]: そもそもどういうセキュリティ上の理由で制限されているのか、そしてこんな簡単に回避できるのに制限する必要があるのか、よく分かりません。

# 動作を停止できるようにする

Web Worker を使うことで、無限ループスクリプトによってページが固まる問題は解決します。しかし、結局は裏で無限ループが回り続けてしまうので CPU の無駄ですし、その状態では新たな実行もできなくなってしまいます。

ですので、例えば停止ボタンを用意したり、あるいは数秒経過した時点で、 `Worker.terminate()` を使って Web Worker を終了させる必要があります。

ただし、終了させるだけだと その後実行できなくなってしまうので、`terminate` で終了 + Web Worker 再生成＋イベントリスナーの登録 をまとめて行う必要があります。

# 成果物

ここまでの内容を、少し手直ししたものを載せておきます。ソースは [Github リポジトリ](https://github.com/nodai2hITC/ruby.wasm_example)を見てください。

- [ruby.wasm 利用サンプル (Without Web Worker)](https://nodai2hitc.github.io/ruby.wasm_example/example_without_worker.html)
    - Web Worker を使用していないバージョン。上記の「入力をダイアログで対話的に取得」の方法を用いて、`gets` でプロンプトから入力を得ることができます。
- [ruby.wasm 利用サンプル (With Web Worker)](https://nodai2hitc.github.io/ruby.wasm_example/example_with_worker.html)
    - Web Worker を使用したバージョン。出力があるたびに出力用 Textarea が反映され、停止ボタンを押すか 指定した秒数が経過すると自動的に停止することを確かめてください。

まだまだ ruby.wasm に関する情報は少ないので、これから ruby.wasm を使ってみたい人の参考になれば幸いです。

ちなみに私が調べる上で役立ったのは先述した RubyOnBrowser や TryRuby のほかに、Python の Wasm 実装である [Pyodide](https://pyodide.org/) でした。こちらのほうが歴史があるぶん情報が揃っています。（実際この記事中に書いた `importScripts` を使う必要があることは、Pyodide の情報のおかげで解りました。）

# 成果物その２

上の成果物は ruby.wasm を使って何か作ってみたい人の参考としては役立つかもしれませんが、実用性はありません。

実用的なものとして、こんなものも作ってみました。

- [一括コードテスト](https://nodai2hitc.github.io/batchcodetest/ruby.html)

Ruby での競技プログラマー向けに、複数の入力例に対してプログラムを実行し、その実行結果と出力例が合っているかを一括でチェックできるシステムです。もし Ruby で競技プログラミングをしている方が居たら、ご利用いただけると嬉しいです。（Pyodide を使った [Python 版](https://nodai2hitc.github.io/batchcodetest/python.html)もあります。）