ruby.wasmを使ってみる

2023-03-16

その昔JavaScriptがあまり好きではなくて、「ブラウザでもRubyでスクリプト書きてぇ〜!」などと思っていた。 その後JavaScriptにもだいぶ慣れたのでそういうこともなくなったが、最近ではwasm化されて動かせるとのことなので触ってみた。

簡単な使い方

ruby.wasmのQuick Start (for Browser)に簡単な説明が書かれている:

<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 () => {
// Fetch and instantiate WebAssembly binary
const response = await fetch(
// Tips: Replace the binary with debug info if you want symbolicated stack trace.
// (only nightly release for now)
// "https://cdn.jsdelivr.net/npm/ruby-head-wasm-wasi@next/dist/ruby.debug+stdlib.wasm"
"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);

vm.printVersion();
vm.eval(`
require "js"
luckiness = ["Lucky", "Unlucky"].sample
JS::eval("document.body.innerText = '#{luckiness}'")
`);
};

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

vm.eval(Rubyコード文字列) でRubyコードを実行する。 Ruby側では require "js" して JS 経由でアクセスできる。

RubyからJavaScriptの関数を呼び出す

RubyからJavaScriptにアクセスするには、 JS::global でJavaScriptのグローバルを参照できる。

例えばJavaScriptで関数を定義しておいて:

function jsFunc(...args) {
console.log('jsFunc called!', args)
}

Rubyから呼び出す:

JS::global.call(:jsFunc, 1, 2, 3)
  • 呼び出す関数名はシンボルでも文字列でもよい

受け渡せる型

RubyからJavaScriptに受け渡せる値の型は、

  • シンプルな値(数値、文字列)は問題なし
  • 配列はダメ
  • ラムダ関数は可能(JSから普通にf()として呼び出せる):コールバックで使える

Uint8Array

RubyとJavaScript間で小規模以上のデータをやり取りしたいが配列はダメだった。 Uint8Array はどうかと試したら可能だった。

Rubyから

u8a = JS::eval('return new Uint8Array(30)')

で生成して受け取れる。 ブラケットを使って u8a[0] = 123 とか普通にアクセスできる。 JavaScriptの関数に渡せばJavaScript側からも見れた。

  • 長さの取得方法はわからなかった(.length はエラー)

Rubyから値を返して、JavaScriptから呼び出す

vm.eval だとJavaScriptの値を渡すことができない(リテラルとして埋め込んでならできるけど)のでなんとかしたい。 Rubyから関数を返してそれを呼び出してやればできるかと思ったがやり方がわからなかった。

これを参考に、クラスのインスタンスを返して、それに対して call("メソッド名", 引数, ...) で呼び出せる:

// JavaScript
const rnode = vm.eval(`
class RNode
def set_jnode(jnode)
@jnode = jnode
end
end
RNode.new
`);

rnode.call("set_jnode", vm.wrap(jnode));
  • vm.wrap(jsValue) でRubyの値に変換(数値とかでも自動には変換してくれない)

ファイルシステム

ファイルの扱いがどうなっているか。

カレントディレクトリ

ルートにいる、ファイルは空:

p Dir.pwd        # => "/" ルートディレクトリ
p Dir.glob('*') # => [] ファイルなし

ファイルの読み書き

自分の用途としてはブラウザ上で動かすことなので実際のローカルファイルに直接アクセスはできなくていいんだけど、メモリ上でファイルを扱えれば幅が広がると思って試してみたが、Errno::ENOTCAPABLE が出る:

File.open('foo.txt', 'w') do |f|
f.puts('Hello')
end
# Uncaught (in promise) Error: eval:10:in `initialize': Capabilities insufficient @ rb_sysopen - foo.txt (Errno::ENOTCAPABLE)

Dir.mkdir('/foo')
# Uncaught (in promise) Error: eval:13:in `mkdir': Capabilities insufficient @ dir_s_mkdir - bar (Errno::ENOTCAPABLE)

書き込みができないので、読み込みも対象ファイルがないためできず…。

ライブラリ読み込み

require "JS"はできるが、他のライブラリは使えないっぽい。

require "date"
p DateTime.now
# Uncaught (in promise) Error: eval:1:in `require': cannot load such file -- date (LoadError)

CDNから読み込むwasmバイナリをruby.wasmからruby+stdlib.wasmに変更すればライブラリは使える。

ruby.wasmでは内部で wasi-vfs が使われてる(Ruby 3.2.0 リリース)とのこと。 +stdlibはライブラリを事前にパックしてるもので、後からの書き込みはできないっぽい。

ファイル操作をできるようにするには

wasi-vfsでは実行時にファイルの書き込みなどはできなそう。 どうしたらいいかと漁ってたら、Ruby on Browserのソースが参考になりそうだった。 出来合いのruby.wasmを使うのでなく、@wasmer/wasi@wasmer/wasmfsと組み合わせることで実現している。

ちょっと自分でも試してみたがWebpack関係?でうまく動かなかったので、また後日確認したい。

リンク

他参考になりそうなページ