iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
💎

Using await in ruby.wasm

に公開

I've been playing with ruby.wasm all the time lately.

On 2023/5/19, ruby.wasm 2.0 was released.

In ruby.wasm 1.0, await sometimes didn't work correctly, but it has started working properly in 2.0. To celebrate, I've summarized what I've done since my previous article.

await

There are two problems with using await in ruby.wasm.

  1. Ruby scripts need to be executed with evalAsync instead of eval.
  2. The stack size is small, leading to frequent SystemStackError errors.

Ruby scripts need to be executed with evalAsync instead of eval

If you casually write a Ruby script in HTML using <script type="text/ruby">, using await results in an error. (In ruby.wasm 1.0, it didn't error but returned 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>

Changing the part that executes the Ruby script from eval to evalAsync would fix it, but it's tedious.
Actually, since evalAsync runs eval inside a Fiber, you just need to run the code you want to await inside a 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>

Easy!

To set event handlers for HTML elements, you use addEventListener, but you cannot use await directly inside the event handler.

<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>

In this case as well, you can use await by using a Fiber.

<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>

The stack size is small, leading to frequent SystemStackError errors

The Fiber stack size is small, so even a script like this results in a 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>

Since there's no way around this, you have to rebuild ruby.wasm. It's a hassle.

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

Modify the new WASI({}); part here to:

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

and then rebuild.

However, since that's tedious, a simpler way is to forcibly replace the content within the pre-built file like this:

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

Then you can use it like this:

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

While writing this, I noticed that in the ruby.wasm main branch, the default has been changed to 16777216. It might work without any extra effort in the next version.

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

If you're okay with a daily build, using https://cdn.jsdelivr.net/npm/ruby-3_2-wasm-wasi@2.0.0-2023-05-21-a/dist/browser.script.iife.js worked fine.

Making it Ruby-like

I've made various other adjustments to allow writing in a more Ruby-like way.

Calling function names in snake_case

The JS library of ruby.wasm is a thin wrapper, so JavaScript function names like getElementById() are used as-is.

Since it doesn't feel very Ruby-like, I made it possible to call them in snake_case, such as get_element_by_id().

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

class JS::Object
  prepend JSrb
end

Something like that.

Referencing properties in .prop_name format

To reference a property of JS::Object, you normally use something like [:propName], but I also want to be able to reference it as JS::Object.prop_name.

It seems like converting prop_name to propName and calling [:propName] should work.

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

Like this. If the property is a function, it calls it.

To set a property, you can call []= in a similar way if sym ends with =.

Converting primitive types to Ruby objects

Since JavaScript values appear as JS::Object from Ruby, they are somewhat difficult to use directly as-is.
You end up having to use JS::Object#to_i or JS::Object#to_s depending on the type of value.

To handle this, I'm checking JS::Object#typeof and converting it, albeit in a simple way.

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

Objects with names like ...List or ...Collection are converted to an Array, and null or undefined are converted to nil.
This part is quite rough, so it might not work perfectly in some cases.

Summary

With this, a script that was previously written like this:

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

can now be written as follows:

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

It looks much more like Ruby now.

The full code for making it Ruby-like is available at https://mysql-params.tmtms.net/lib/jsrb.rb. I will likely continue to make changes, but feel free to check it out for reference.

Discussion