😏

ruby.wasmを使って自作Rubyライブラリをブラウザで試せるようにした話

2023/01/22に公開

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>

この方法は簡単に使う分には便利なのですが、実際に使おうとすると次のような問題点に突き当たります。

  1. ruby.wasm の読み込み先がCDNに固定されていて、自前でビルドしたものを利用できない。
  2. wasmer-jsWebAssembly の設定ができない。
  3. 自作ライブラリのファイルを追加して 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つです。

  1. ruby.wasm が認識できるファイルシステムにソースコード (lib 以下) を追加する。
  2. $LOAD_PATH にソースコードのあるディレクトリを追加する。

2は単に eval して $LOAD_PATHunshiftpush すればいいので (ここでやっている)、問題となるのは1のruby.wasmの認識できるファイルシステムにファイルを追加する方法です。

通常、WebAssemblyの実行環境は実際の実行環境から隔離されていて、ローカルのファイルなどにもそのままではアクセスできません。そこで、ファイルアクセスの方法などを規定するのがWASIで、そのブラウザ上での実装が wasmer-js です。wasmer-js ではメモリ上の仮想のファイルシステムを渡すことになっています。さらに、ruby.wasm では wasi-vfs を使って標準ライブラリをWebAssemblyバイナリに埋め込んでいます。

つまり、この2つの方法のうちのいずれかが利用できるはずです。

  1. memfs にソースコードを追加して、wasmer-js に渡す方法
  2. 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 となって読み込めません。一応、requireevalFile.read の組み合わせに置換するか、モンキーパッチすれば動くようにはできたのですが、ダーティーハックな感じがするのであまり採用したくありませんでした。

wasi-vfs を使う方法

というわけで、今回は wasi-vfs を利用する方法を取っています。

wasi-vfs はファイルをWebAssemblyに埋め込み、WASI のファイル読み込みの関数をフックしてそれらを読み込むようにするツールです。

https://github.com/kateinoigakukun/wasi-vfs

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 から読み込めるファイルを追加するには memfswasi-vfs が使えるけれど、現状は wasi-vfs の方が良さそう。

感想としては、使えなくはないけど、実用するのはまだちょっと環境が整ってないな、と思います。使う中で wasmer-js の実装を調べたり、ruby.wasm のビルドの流れを調べたり、ともかく調べている時間が長かったです。おかげで仕組みにはある程度詳しくなれたのですが、もう少し手軽に使えるようになったら嬉しいです。

最後まで目を通していただきありがとうございました。

Discussion