First steps with ruby.wasm: or how we built Ruby Next Playground

Cover for First steps with ruby.wasm: or how we built Ruby Next Playground

Translations

If you’re interested in translating or adapting this post, please contact us first.

The web browser has come all the way from being an HTML/CSS renderer to a sophisticated execution environment, from being a thin client to becoming a thick one… and beyond (if there is no need for a server, should you call yourself a client?) Now, thanks to WebAssembly support, you can write a program in (almost) any language and run it on a web page. And Ruby is no exception. Allow me to share my first and still-fresh impressions of putting Ruby online!

WebAssembly (or Wasm) was released back in 2017, and today it is supported by 97% of all devices. However, in order to leverage its portability power, you need support from both sides: the execution environment (such as a browser) and the programming language/runtime you want to use. The major browsers are Wasm-ready, but what about Ruby?

Ruby (or more precisely, CRuby aka MRI) is written in C. Thus, we could always (though not easily) compile Ruby code to a Wasm module via emscripten (see emruby). Emscripten targets JavaScript runtimes, but WebAssembly is not limited to the Web (or Node.js). For example, Wasm modules can be used in serverless environments or be embedded into other programs (for user-provided scripting). To achieve better portability, a new standard has been introduced in 2019—WASI (WebAssembly System Interface).

For interoperability, WASI requires a source program to use compliant low-level (system) calls. That, in turn, requires the source code to be WASI-aware. And that’s exactly what happened to Ruby in 2022: MRI became WASI-compatible (thanks to the amazing work of Yuta Saito). That was one of the highlights of the 3.2.0 release, and the one that I couldn’t really grasp 😄. A year later, I took a closer look at this feature and discovered the ruby.wasm project.

In this post, I’d like to share my first experience with packaging a Ruby program (Ruby Next) into Wasm via ruby.wasm and making it available online. Before we jump into technical details, take a look at what came out of it:

Ruby Next Playground in action

What is ruby.wasm?

The ruby.wasm project was created with the intention of helping you get your Ruby code packed as a Wasm-WASI module. It also ships ready-to-use Wasm modules, so you can, for example, try Ruby in the browser without even building it yourself! Let’s take a look at this example snippet:

<!DOCTYPE html>
<html lang="en">
  <head>
    <script src="https://cdn.jsdelivr.net/npm/@ruby/3.3-wasm-wasi@2.4.1/dist/browser.script.iife.js"></script>
    <script type="text/ruby">
      require "js"

      trick = ((1..6).to_a+(3..9).to_a+(6..12).to_a+[2]*4).map{|i|("#"*i*4).center(80, " ")}
      version = "Hello from #{RUBY_VERSION} (#{RUBY_PLATFORM})"

      content = JS.global[:document].querySelector("p")

      tid = JS.global.setInterval(proc {
        content[:innerText] = content[:innerText].to_s + "\n" + trick.shift
        JS.global.clearInterval(tid) if trick.empty?
      }, 240)

      JS.global[:document].querySelector("h2")[:innerText] = version
    </script>
  </head>
  <body>
    <div style="font-family: monospace; text-align: center; width: 100%">
      <h2 style="color: #b70000"></h2>
      <p style="color: #004c00"></p>
    </div>
  </body>
</html>

All we need is to load the ruby.wasm JS library and write some Ruby code within the script tag using the text/ruby type. Are you curious about what the code above does? Click on the “Run project” button below to bring it to life:

Precompiled ruby.wasm modules only allow you to use default Ruby gems, and this is good enough for a few quick experiments, but we’ll likely need to add some gems to build something real. No worries, here because ruby.wasm has got you covered!

In addition to providing Wasm modules, ruby.wasm also gives you tools for packaging your Ruby application into Wasm with all dependencies. Let’s learn how to do that with an example!

Ruby Next goes online

One of the primary goals of Ruby Next, a transpiler for Ruby, is to simplify experimenting with the Ruby syntax. I believe there must be a way to play with syntax proposals without dealing with parsers, grammar, compilation, and so on. We came as close as possible to this idea with the v1.0 release of Ruby Next, which added the ability to write robust syntax transformers in Ruby at runtime (using parse combinators via the Paco gem).

Adding a more accessible way to hack with Ruby was the missing piece in the puzzle that blocked us from providing a next-level experience—the ability to use Ruby Next without leaving your browser and sharing your experiments with others simply by sending a link.

That’s where Ruby Next met ruby.wasm, and Ruby Next Playground was born.

Ruby Next Playground aims to be a testing ground for Ruby language designers and enthusiasts to work on new Ruby syntax features.

Packing a Ruby application with rbwasm build

I was browsing the ruby.wasm repo when I found a very cool feature that had been recently merged: Bundler support. This feature allows you to compile a custom Ruby Wasm module with all the dependencies from your Gemfile.lock included and managed by Bundler (via regular require "bundler/setup" command). Let’s take a deeper look at this process.

Before you begin, make sure you have the Rust toolchain installed in your system (the ruby_wasm gem relies on the native extension written in Rust).

Now, let’s create a Gemfile for our project as follows:

bundle init

bundle add ruby_wasm ruby-next

bundle install

Finally, let’s execute the rbwasm build command to generate a Wasm module containing Ruby and all the dependencies for our project:

$ bundle exec rbwasm build -o ruby.wasm

# ...
# This process takes quite a bit of time; hold on...
#
# ...
#
# In the end, you will see the list of libraries included and the total size of the Wasm bundle
#
INFO: Packaging gem: ast-2.4.2
INFO: Packaging gem: diff-lcs-1.5.0
INFO: Packaging gem: paco-0.2.3
INFO: Packaging gem: racc-1.7.3
INFO: Packaging gem: parser-3.3.0.5
INFO: Packaging gem: require-hooks-0.2.2
INFO: Packaging gem: ruby-next-core-1.0.0
INFO: Packaging gem: ruby-next-parser-3.2.2.0
INFO: Packaging gem: unparser-0.6.12
INFO: Packaging gem: ruby-next-1.0.0
INFO: Packaging setup.rb: bundle/setup.rb
INFO: Size: 58.92 MB

Now, how can we test our ruby.wasm bundle? Recall, I mentioned in the beginning that WASI-compatible Wasm modules could be executed in different environments, not just browsers. And one such environment is wasmtime. Let’s install it and run it as follows:

$ wasmtime run ruby.wasm -W0 -r/bundle/setup -ruby-next -e 'proc { puts it }.call("hello")'

hello

Running wasmtime run ruby.wasm is similar to running a regular ruby executable: we pass the same options (turn off warnings via -W0, activate Bundler via -r/bundle/setup, and activate Ruby Next via -ruby-next) and use puts to print something to standard output. In the snippet above, we utilize a future Ruby 3.4 feature, the implicit it argument in a Proc. The code works, although we’re still on Ruby 3.3—Ruby Next took action and transpiled it for us!

We’ve just experienced a happy path of building Ruby projects with ruby.wasm. However, there are always caveats and pitfalls hiding somewhere. On that note, here are some problems I encountered along the way:

  • Not all Ruby APIs and features are supported within Wasm (see limitations). For example, I found that a top-level gem call fails to resolve gem versions, so I just stopped calling it within Wasm in Ruby Next (see the patch).

  • Similarly, ruby.wasm also tries to compile C extensions into Wasm, but that doesn’t work for complex ones (e.g., nio4r and sqlite3 gems).

Preparing for Web

To run our ruby.wasm bundle in a browser, we’ll need it to be JavaScript-aware. For this, we must add the js gem to our bundle and re-compile it:

bundle add js

bundle exec rbwasm build -o ruby-web.wasm

The js gem glues our WASI Wasm module with the JavaScript WebAssembly runtime. The newly compiled ruby-web.wasm module can now be used in the browser.

On the JavaScript side, we can use the @ruby/wasm-wasi package to configure and initialize a Ruby VM within Wasm. Here’s an example setup:

import { DefaultRubyVM } from "@ruby/wasm-wasi/dist/browser";
import ruby from "./ruby-web.wasm";

export default async function initVM() {
  const module = await ruby();

  const { vm } = await DefaultRubyVM(module);

  vm.eval(`
    require "/bundle/setup"
    require "ruby-next/language"

    def transform(...) = RubyNext::Language.transform(...)
  `);

  return vm;
}

Now, you can use an instance of a Ruby VM to run arbitrary Ruby code:

const vm = await initVM();

const source = `
greet = proc do
case it
  in hello: hello if hello =~ /human/i
    '🙂'
  in hello: 'martian'
    '👽'
  end
end

puts greet.call(hello: 'martian')
`

// First, let's transpile our code to match the current Ruby version
const transpiled = vm.eval(`transform("${source})`).toString();

// Then, we can make sure it works as expected by executing it
vm.eval(transpiled);

That’s almost everything! The core functionality for the fully in-browser Ruby Next playground is ready. However, there is one thing I’d like to talk about: intercepting puts calls.

I want to show the program output on a web page (not just in the DevTools console), and, as per the documentation, the DefaultRubyVM allows us to provide a custom printer object, but I didn’t manage to get it to work. However, a solution was found in the browser_wasi_shim documentation (the library used inside the DefaultRubyVM function). To make things work, we have to create a virtual machine instance ourselves—hence, we have a bit of boilerplate to write:

import { RubyVM } from "@ruby/wasm-wasi";
import { File, WASI, OpenFile, ConsoleStdout } from "@bjorn3/browser_wasi_shim";

// This is our logs store
const output = [];
output.flush = function () {
  return this.splice(0, this.length).join("\n");
};

const setStdout = function (val) {
  console.log(val);
  output.push(val);
};

const setStderr = function (val) {
  console.warn(val);
  output.push(`[warn] ${val}`);
};

// Here we manually set up the VM
const fds = [
  new OpenFile(new File([])), // stdin
  ConsoleStdout.lineBuffered(setStdout), // stdout
  ConsoleStdout.lineBuffered(setStderr), // stderr
];
const wasi = new WASI([], [], fds, { debug: false });
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.initialize(instance);
vm.initialize();

// Finally, store the reference to the output in our VM object
vm.$output = output;

We manually configure file descriptors for standard output and error IO devices within our WASI environment. Now, whenever we execute a Ruby code, we can obtain output as follows:

function evaluate(source) {
  const result = vm.eval(source).toString();
  const output = vm.$output.flush();

  return {result, output}
}

The rest of the Ruby Next Playground codebase deals with code editors, styling, and code sharing—feel free to browse it on Github! And, of course, you can play with Ruby syntax online and share your experiments using ruby-next.github.io!

Special thanks to Yuta Saito for kindly taking his time to review this article, and for all his work on ruby.wasm!

At Evil Martians, we transform growth-stage startups into unicorns, build developer tools, and create open source products. If you’re ready to engage warp drive, give us a shout!

Join our email newsletter

Get all the new posts delivered directly to your inbox. Unsubscribe anytime.

In the same orbit

How can we help you?

Martians at a glance
17
years in business

We transform growth-stage startups into unicorns, build developer tools, and create open source products.

If you prefer email, write to us at surrender@evilmartians.com