ruby.wasm で await を使う

最近はずっと ruby.wasm で遊んでます。

2023/5/19 に ruby.wasm 2.0 が出ました

ruby.wasm 1.0 では await がうまく動かないことがあったけど、2.0 でちゃんと動くようになったんで、記念に前の記事以降にやったこと等をまとめてみた。

await

ruby.wasm で await を使うには2つ問題がある。

  1. Ruby スクリプトを eval ではなく evalAsync で実行する必要がある。
  2. スタックサイズが小さくてすぐに SystemStackError エラーが出てしまう。

Ruby スクリプトを eval ではなく evalAsync で実行する必要がある

HTML 内で <script type="text/ruby"> で気軽に Ruby スクリプトを書いたときに await を使うとエラーになってしまう。(ruby.wasm 1.0 ではエラーにならずに nil が返される)

<script src="https://cdn.jsdelivr.net/npm/ruby-3_2-wasm-wasi@2.0.0/dist/browser.script.iife.js"></script>
<script type="text/ruby">
  require 'js'
  def start
    p JS.global.fetch('hoge.html').await
    #=> JS::Object#await can be called only from evalAsync (RuntimeError)
  end
  start
</script>

Ruby スクリプトを実行する部分を eval から evalAsync に変えればいいんだけど、面倒くさい。 実は evalAsync は Fiber 内で eval してるので、await したいコードを Fiber 内で動かせばいいだけだったりする。

<script src="https://cdn.jsdelivr.net/npm/ruby-3_2-wasm-wasi@2.0.0/dist/browser.script.iife.js"></script>
<script type="text/ruby">
  require 'js'
  def start
    p JS.global.fetch('hoge.html').await
    #=> [objcet Response]
  end
  Fiber.new{start}.transfer
</script>

簡単!

HTLM 要素にイベントハンドラを設定するには addEventListener を使うんだけど、イベントハンドラ中ではそのままでは await を使えない。

<input id="b" type="button" value="button"></input>
<script type="text/ruby">
  require 'js'
  Document = JS.global[:document]
  b = Document.getElementById('b')
  b.addEventListener('click') do |e|
    p JS.global.fetch('hoge.html').await
    #=> in `await': JS::Object#await can be called only from evalAsync (RuntimeError)
  end
</script>

この場合も Fiber を使えば await を使うことができる。

<input id="b" type="button" value="button"></input>
<script type="text/ruby">
  require 'js'
  Document = JS.global[:document]
  b = Document.getElementById('b')
  b.addEventListener('click') do |e|
    Fiber.new do
      p JS.global.fetch('hoge.html').await
      #=> [objcet Response]
    end.transfer
  end
</script>

スタックサイズが小さくてすぐに SystemStackError エラーが出てしまう

Fiber のスタックサイズが小さいので、こんなスクリプトでも SystemStackError が出ちゃう。

<script src="https://cdn.jsdelivr.net/npm/ruby-3_2-wasm-wasi@2.0.0/dist/browser.script.iife.js"></script>
<script type="text/ruby">
  require 'js'
  def start
    pp [1] * 30
    #=> Uncaught (in promise) Error: SystemStackError: stack level too deep
  end
  Fiber.new{start}.transfer
</script>

これはもうどうしようもないので、ruby.wasm をビルドし直す必要がある。めんどくさい。

https://github.com/ruby/ruby.wasm/blob/0862cab421d5419e247a7a756b4312cb89011f65/packages/npm-packages/ruby-wasm-wasi/src/browser.ts#L73

ここの new WASI({});

  const wasi = new WASI({
    env: { "RUBY_FIBER_MACHINE_STACK_SIZE": "1048576" }
  });

のように変更してビルドする。

でもめんどくさいので、簡単にやるなら、

curl https://cdn.jsdelivr.net/npm/ruby-3_2-wasm-wasi@2.0.0/dist/browser.script.iife.js |
sed -e 's/const wasi = new s.*/const wasi = new s({env:{"RUBY_FIBER_MACHINE_STACK_SIZE":"1048576"}});/' > browser.script.iife.js

のようにして、ビルド済みのファイルの中を強制的に置換したものを使うという手もある。

<script src="browser.script.iife.js"></script>
<script type="text/ruby">
  require 'js'
  def start
    pp [1] * 30
  end
  Fiber.new{start}.transfer
</script>

とここまで書いて、ruby.wasm の main だとデフォルトで 16777216 に変更されていることに気がついた。次のバージョンでは何もしなくても使えるようになるかも。

https://github.com/ruby/ruby.wasm/blob/394841d142fabc2287e7f918a605c7009e545846/packages/npm-packages/ruby-wasm-wasi/src/browser.ts#L73-L81

デイリービルドのやつでよければ、https://cdn.jsdelivr.net/npm/ruby-3_2-wasm-wasi@2.0.0-2023-05-21-a/dist/browser.script.iife.js を使うだけでいけた。

Ruby 風にする

その他、いろいろ Ruby っぽく書けるようにした。

関数名をスネークケースで呼び出し

ruby.wasm の JS ライブラリは薄いラッパーなので、getElementById() みたいに JavaScript の関数名がそのまま使われる。

あまり Ruby ぽくないので、get_element_by_id() みたいなスネークケースでも呼べるようにした。

module JSrb
  def method_missing(sym, *args, &block)
    sym = sym.to_s.gsub(/_([a-z])/){$1.upcase}.intern
    super(sym, *args, &block)
  end
end

class JS::Object
  prepend JSrb
end

みたいな感じ。

プロパティを .prop_name 形式でも参照

JS::Object のプロパティを参照するには [:propName] のようにするけど、JS::Object.prop_name でも参照したい。

prop_namepropName に変換して [:propName] を呼び出せばよさそう。

module JSrb
  def method_missing(sym, *args, &block)
    sym = sym.to_s.gsub(/_([a-z])/){$1.upcase}.intern
    v = self.method(:[]).super_method.call(sym.intern)
    v = self.call(sym, *args, &block) if v.typeof == 'function'
    v
  end
end

こんな感じで。プロパティが関数の場合はそれを呼び出してる。

プロパティを設定するには、sym= で終わってる場合に同じような感じで []= を呼べばいい。

プリミティブ型を Ruby のオブジェクトに変換

JavaScript の値は Ruby からは JS::Object として見えるので、そのままだと使いにくい。 値の型に応じて JS::Object#to_iJS::Object#to_s みたいにして使うことになる。

これは、ベタだけど JS::Object#typeof を見て変換する感じで。

    case v.typeof
    when 'number'
      v.to_s =~ /\./ ? v.to_f : v.to_i
    when 'bigint'
      v.to_i
    when 'string'
      v.to_s
    when 'boolean'
      v.to_s == 'true'
    else
      if v.to_s =~ /\A\[object .*(List|Collection)\]\z/
        v.length.times.map{|i| v[i]}
      elsif v == JS::Null || v == JS::Undefined
        nil
      else
        v
      end
    end

〜List とか 〜Collection という名前のオブジェクトは Array にしたり、nullundefinednil にしたり。 この辺はかなりテキトーなので、うまく動かないこともあるかもしれない。

まとめ

これで、次のように書いてたスクリプトは、

children = JS.global[:document].getElementById('hoge')[:children]
children[:length].to_i.times do |i|
  p children[i][:tagName]
end

次のように書けるようになる。

JS.global.document.get_element_by_id('hoge').children.each do |c|
  p c.tag_name
end

かなり Ruby っぽくなった。

Ruby ぽくするやつの全体は https://mysql-params.tmtms.net/lib/jsrb.rb においてある。今後も変更していくと思うけど参考までに。

この続きはcodocで購入