ruby.wasmを使って自作Rubyライブラリをブラウザで試せるようにした話
Ecoji.rb
はじめに少し、作ったライブラリの紹介をします。
作ったのは、EcojiのRuby実装で、Ecoji.rbというものです。
EcojiはBase64の絵文字版みたいなエンコーディングです。hello world
が 👲🔩🚗🌷🍉🤣🦒🪁👡☕
とエンコードされます。当然ですが、これをデコードして、元に戻すこともできます。バイト数が約4倍になるので何が便利なのかはよく分かりませんが、見ていて楽しいので楽しい用途に使うといいのだと思います。技術的には、5バイト (40ビット) を10ビットずつ区切って、対応する絵文字に置き換えていくだけのシンプルなものになっています (実装)。
記事のタイトルのとおり、ブラウザから試せるようになっているので、ぜひ使ってみてください。上のテキストボックスに文字列を入力すると、下のテキストボックスにエンコードしたものが表示されます。また、下にEcojiでエンコードされた文字列を入れると、上にデコードしたものが表示されます。
ruby.wasm
先日リリースされたRuby 3.2.0ではWASIベースのWebAssemblyへのビルドが正式にサポートされました。それにより、ブラウザでもWASI実装のwasmer-jsを使うことで、本物のRubyを動かせるようになりました。このWebAssemblyへビルドされたRuby処理系などを ruby.wasm
と呼びます。
今回、Ecoji.rbのデモをブラウザで動かすために、 ruby.wasm
を利用しました。その中で得たruby.wasm
を使う上での知見を共有したいと思います。
デモの構成
https://makenowjust.github.io/ecoji.rb/ にアクセスして出てくるページのうち、次の図の赤線で囲まれている部分は ruby.wasm
を使って描画されています。実際にアクセスするとローディングに少し時間がかかり、それからこの赤線の部分が表示されるので、動的に追加されていることは分かるのではないかと思います。
静的サイトのビルドツールとして、今回はParcelを利用しました。デモの実装は、web/src
ディレクトリにまとめられていて、次の3つのファイルが中心となっています。
-
main.ts
: エントリポイント。下のbootstrap
の呼び出しとRubyの実行をする。 -
bootstrap.ts
:ruby.wasm
の読み込みや設定などを行なう。 -
main.rb
: Rubyで書かれたデモの実装の本体。
注目すべきは、Rubyのソースコードが静的サイトのJavaScriptのソースコードと同列に並べられていて、実際にブラウザで実行されているという点です。これはなかなか未来を感じるのではないかと思います。
あまり長くないので、main.rb
の内容も記事に載せておきましょう。require 'js'
をしていて、JS
モジュールからJavaScriptの値に触れているところ以外は普通のRubyのソースコードです。JavaScriptとRubyを知っていればなんとなく読めるのではないかと思います。自作ライブラリが require 'ecoji'
と require
できているところも注目ポイントです。これをどうやって実現したかは、後で説明します。
# frozen_string_literal: true
require 'js'
require 'ecoji'
puts Ecoji.encode('Ecoji.rb')
document = JS.global['document']
app = document.querySelector('#app')
app['innerHTML'] = <<~HTML
<div class="demo">
<div class="input">
<label for="encode">Encode using <a href="https://github.com/makenowjust/ecoji.rb/">Ecoji.rb</a></label>
<textarea id="encode" placeholder="😊 Let's type a text to encode here!"></textarea>
</div>
<div class="input">
<label for="decode">Decode using <a href="https://github.com/makenowjust/ecoji.rb/">Ecoji.rb</a></label>
<textarea id="decode" placeholder="🥴📊🧭📲🐂🔪🧏🤠🍉🛃🔯🌭🍉📤⛵🌭💲🚾⛵🌷🍉🔩🥈🤜👢🔥⛪🌭💚🔥🌆☕"></textarea>
</div>
<div class="info">
<p>Powered by <a href="https://github.com/ruby/ruby.wasm">ruby.wasm</a></p>
<p>#{RUBY_DESCRIPTION}</p>
</div>
</div>
HTML
encode_element = document.querySelector('#encode')
decode_element = document.querySelector('#decode')
encode_element.addEventListener('input') do
# `to_s` is necessary because it is `JS::Object` actually.
input = encode_element['value'].to_s
decode_element['value'] = Ecoji.encode(input)
end
decode_element.addEventListener('input') do
# `to_s` is necessary because it is `JS::Object` actually.
# `force_encoding` is also necessary because its encoding is `ASCII_8BIT`.
input = decode_element['value'].to_s.force_encoding(Encoding::UTF_8)
begin
encode_element['value'] = Ecoji.decode(input)
rescue Ecoji::Error
encode_element['value'] = '🤨 It seems that your input was not Ecoji™ encoded'
end
end
ruby.wasm
の読み込み
最初に ruby.wasm
を使う上で障害となるのが、どのようにして ruby.wasm
を読み込むのか、という点です。調べるとすぐに出てくるのは、次の <script>
タグをHTMLに追加する方法です。
<script src="https://cdn.jsdelivr.net/npm/ruby-head-wasm-wasi@latest/dist/browser.script.iife.js"></script>
<script type="text/ruby">
puts "hello world"
</script>
この方法は簡単に使う分には便利なのですが、実際に使おうとすると次のような問題点に突き当たります。
-
ruby.wasm
の読み込み先がCDNに固定されていて、自前でビルドしたものを利用できない。 -
wasmer-js
やWebAssembly
の設定ができない。 - 自作ライブラリのファイルを追加して
require
することができない。
これらの問題を解決するために、今回のデモでは自前で ruby.wasm
を読み込むことにしました。
実は ruby.wasm
はNPMで ruby-head-wasm-wasi
というパッケージで公開されています。これをインストールして、Parcelの fs.readFileSync
のインライン化を利用して読み込むことにしました。
import fs from "fs";
const buffer = fs.readFileSync(__dirname + "/../node_modules/ruby-head-wasm-wasi/dist/ruby+stdlib.wasm");
const module = await WebAssembly.compile(buffer);
さらに、WASIを利用できるようにするために、wasmer-js
のセットアップをしていきます。このために、バージョン 0.12.0
の @wasmer/wasi
と @wasmer/wasmfs
も追加でインストールする必要があります。
import { WASI } from "@wasmer/wasi";
import { WasmFs } from "@wasmer/wasmfs";
import browserBindings from "@wasmer/wasi/lib/bindings/browser";
const wasmFs = new WasmFs();
const wasi = new WASI({
bindings: {
...browserBindings,
fs: wasmFs.fs,
},
});
最後に、ruby-head-wasm-wasi
の提供している RubyVM
クラスにWebAssemblyモジュールを渡して諸々のセットアップをすると、Rubyが実行できるようになります。
import { RubyVM } from "ruby-head-wasm-wasi/dist/index";
const vm = new RubyVM();
const imports = {
wasi_snapshot_preview1: wasi.wasiImport,
};
vm.addToImports(imports);
const instance = await WebAssembly.instantiate(module, imports);
await vm.setInstance(instance);
wasi.setMemory(instance.exports.memory);
instance.exports._initialize();
vm.initialize();
// Now, Ruby `vm` is available!
vm.printVersion();
vm.eval("p 'hello world'");
実はこれらの処理は ruby-head-wasm-wasi
の提供する DefaultRubyVM
という処理と大体同じなのですが、DefaultRubyVM
を使うと wasmer-js
の設定値などが変更できず問題になりそうだったので、自前で用意することにしました。
デモではこれらの処理は bootstrap.ts
にまとめられています。
require '自作ライブラリ'
の実現
次の課題は require '自作ライブラリ'
(今回の場合 require 'ecoji'
) の実現です。
require
するために必要なことは次の2つです。
-
ruby.wasm
が認識できるファイルシステムにソースコード (lib
以下) を追加する。 -
$LOAD_PATH
にソースコードのあるディレクトリを追加する。
2は単に eval
して $LOAD_PATH
に unshift
や push
すればいいので (ここでやっている)、問題となるのは1のruby.wasm
の認識できるファイルシステムにファイルを追加する方法です。
通常、WebAssemblyの実行環境は実際の実行環境から隔離されていて、ローカルのファイルなどにもそのままではアクセスできません。そこで、ファイルアクセスの方法などを規定するのがWASIで、そのブラウザ上での実装が wasmer-js
です。wasmer-js
ではメモリ上の仮想のファイルシステムを渡すことになっています。さらに、ruby.wasm
では wasi-vfs
を使って標準ライブラリをWebAssemblyバイナリに埋め込んでいます。
つまり、この2つの方法のうちのいずれかが利用できるはずです。
-
memfs
にソースコードを追加して、wasmer-js
に渡す方法 -
wasi-vfs
を使ってruby.wasm
にソースコードを埋め込む方法
両方の方法を試して検討し、今回は2の wasi-vfs
を使うことにしたので、その辺りの経緯も含めて解説していきたいと思います。
memfs
を使う方法
memfs
にソースコードを追加して、wasmer-js
に渡す方法の解説をします。
この方法では、ruby.wasm
を読み込む際に作った wasmFs.fs
(中身は memfs
のはず) にライブラリのソースコードを追加します。具体的には、次のように fs.readFileSync
を使ってソースコードの内容を読み込んで、wasmFs.fs.writeFileSync
で追加していきます。
const files = {
"ecoji.rb": fs.readFileSync(__dirname + "/../lib/ecoji.rb", "utf8"),
"ecoji/emojis.rb": fs.readFileSync(__dirname + "/../lib/ecoji/emojis.rb", "utf8"),
"ecoji/version.rb": fs.readFileSync(__dirname + "/../lib/ecoji/version.rb", "utf8"),
};
wasmFs.fs.mkdirSync("/ecoji");
for (const filename of Object.keys(files)) {
wasmFs.fs.writeFileSync("/ecoji/" + filename, files[filename]);
}
ただし、wasmFs.fs
に追加するだけで ruby.wasm
から読み込めるようになるわけではありません。WASI
のインスタンスを作成する際に、preopens
で読み込めるようにしたいディレクトリを指定しておく必要があります。
const wasi = new WASI({
preopens: { '/ecoji': '/ecoji' },
bindings: {
...browserBindings,
fs: wasmFs.fs,
},
});
これで puts File.read("/ecoji/ecoji.rb")
などとすると、ソースコードを読み込めます。
しかし、ここで $LOAD_PATH
に "/ecoji"
を追加しても、なぜか Function not implemented
となって読み込めません。一応、require
を eval
と File.read
の組み合わせに置換するか、モンキーパッチすれば動くようにはできたのですが、ダーティーハックな感じがするのであまり採用したくありませんでした。
wasi-vfs
を使う方法
というわけで、今回は wasi-vfs
を利用する方法を取っています。
wasi-vfs
はファイルをWebAssemblyに埋め込み、WASI のファイル読み込みの関数をフックしてそれらを読み込むようにするツールです。
ruby.wasm
では標準ライブラリなどを埋め込むために使っているみたいです。これでライブラリのソースコードを ruby.wasm
に追加して、そちらを読み込むようにすることで、require
できるようになるはずです。
次のようにして、ライブラリのソースコードの ruby.wasm
に追加します。
$ # Install `wasi-vfs`
$ export WASI_VFS_VERSION=0.2.0
$ curl -LO "https://github.com/kateinoigakukun/wasi-vfs/releases/download/v${WASI_VFS_VERSION}/wasi-vfs-cli-aarch64-apple-darwin.zip"
$ unzip wasi-vfs-cli-aarch64-apple-darwin.zip
$ # Pack `lib` files
$ wasi-vfs node_modules/ruby-head-wasm-wasi/dist/ruby+stdlib.wasm --mapdir /ecoji::./lib -o ruby+stdlib.packed.wasm
そして、読み込むファイルを変更します。
const buffer = fs.readFileSync(__dirname + "/../ruby+stdlib.packed.wasm");
const module = await WebAssembly.compile(buffer);
こうすることで、無事 require 'ecoji'
できるようになりました。
ファイルの実行
最後に、main.rb
の実行をする部分の解説です。
これは単純で、fs.readFileSync
で読み込んだファイルの内容を vm.eval
に渡すだけです。
vm.eval(fs.readFileSync(__dirname + "/main.rb", "utf8"));
これでRubyが実行されて、デモが動作します。
まとめ
というわけで今回は、自作のRubyライブラリのデモをブラウザで動作させるために、ruby.wasm
を使いました。その中で得た知見を整理しておきます。
-
ruby.wasm
は<script>
タグでCDNから読み込む以外にも、NPMからインストールして使うこともできる。 - 自前で
wasm
ファイルを読み込んで設定することで、細かく設定できる。 -
ruby.wasm
から読み込めるファイルを追加するにはmemfs
かwasi-vfs
が使えるけれど、現状はwasi-vfs
の方が良さそう。
感想としては、使えなくはないけど、実用するのはまだちょっと環境が整ってないな、と思います。使う中で wasmer-js
の実装を調べたり、ruby.wasm
のビルドの流れを調べたり、ともかく調べている時間が長かったです。おかげで仕組みにはある程度詳しくなれたのですが、もう少し手軽に使えるようになったら嬉しいです。
最後まで目を通していただきありがとうございました。
Discussion