Ruby Adds Support for WebAssembly

What does this mean for Ruby developers?

Ruby has joined the ranks of languages capable of targeting WebAssembly with its latest 3.2 release. This seemingly minor update might be the biggest thing that has happened to xrthe language since Rails, as it lets Ruby developers go beyond the backend. By porting their code to WebAssembly, they can run it anywhere: on the frontend, on embedded devices, as serverless functions, in place of containers, or on the edge. WebAssembly has the potential to make Ruby a universal language.

What is WebAssembly?

WebAssembly (commonly shortened as Wasm) is a binary low-level instruction format that runs on a virtual machine. The language was designed as an alternative to JavaScript. Its aim is to run applications on any browser at near-native speeds. Wasm can be targeted from any high-level language like C, Go, Rust, and now also Ruby.

Wasm became a W3C standard in 2019, opening the path to writing high-performing applications for the Web. The standard itself is still evolving, and its ecosystem is growing. Currently, this technology is receiving a lot of focus from the Cloud Native Computing Foundation (CNCF), with several projects under development.

Wasm's design sits on two pillars: portability and security. The Wasm binary can run on any modern browser, even mobile devices. For security, Wasm programs run in a sandboxed, memory-safe VM. As such, they cannot access any system resources: they can’t change the filesystem or access the network or memory.

WebAssembly brings portability to the next level

Let’s say you want to build an application targeting many systems, e.g. Linux, Windows, and macOS. What are your options?

You could use a compiled language like C and build a binary for each target.

Code is compiled into three formats: ELF binary for Linux, PE binary for Windows, and Mach binary for macOS. We have one source code and three binaries. Compiler portability creates multiple executable files

Or, if you can rely on having the appropriate runtime installed you could choose an interpreted language like JavaScript or one that compiles to bytecode like Java.

The code is compiled into one universal bytecode format, which is executed on the platform via a runtime environment. The diagram shows Java compilation and the JRE running on each platform. Code is compiled into an intermediate bytecode. This system relies on having a runtime environment, or VM, installed on the client.

What if you have a container runtime in the client? In that case, you could build a Docker image for each platform type.

Diagram of a container workflow. The code is built with Docker. The process generates three types of images: one for Linux, one for Windows and one for ARM. On the client, the runtime pulls the correct image type and runs it. Code is compiled into platform-dependent images. A container runtime is required for clients, which pulls the correct image automatically.

For Ruby developers historically, the only option was to distribute the code. That meant that users had to install the Ruby interpreter (or developers had to package the interpreter along with the application) to run the application.

The code is distributed directly. Clients must install the appropiate interpreter to execte the application. Code is shipped directly to users, who must have the interpreter installed in their systems to be able to run it.

All these mechanisms provide portability, but at a cost: you must build, test, and distribute many images. Sometimes, you must also ship a suitable runtime with the release or tell the user to install it independently.

WebAssembly (shortened as Wasm) takes portability to the next level: it allows you to build ONE binary and run it in any modern browser.

This shows the WebAssembly workflow. Code is compiled into a unique Wasm binary, which can run unmodified on any browser. We have a single binary that runs on Linux, macOS and Linux. WebAssembly compiles into a low-level assembly that every modern browser can execute. As a result, the same Wasm binary can run, unmodified, on every platform (even mobile).

The ability to run code at native speed has allowed developers to build sites like Figma, and Google Earth or even run Vim in the browser.

Ruby adds support for WebAssembly

The latest Ruby release ships with a Wasm port of the interpreter. Therefore, we can run Ruby code directly in the browser without the need for a backend.

As you can see in the example below, all it takes to get started with the Ruby Wasm port is a couple of lines. The script downloads ruby.wasm and instantiates the interpreter in the browser. After that, it takes the text of text/ruby type and feeds it into the WebAssembly program.

<html>
  <script src="https://cdn.jsdelivr.net/npm/ruby-head-wasm-wasi@0.5.0/dist/browser.script.iife.js"></script>
  <script type="text/ruby">
    puts "Hello, world!"
  </script>
</html>

You can confirm that Ruby is running from the browser, i.e. not connecting with a backend, by opening the developers' tools. Here, you'll find once ruby.wasm is downloaded, no further connections are needed.

 The browser console log is opened and shows: Hello, world! Traditionally, JavaScript has been touted as the best language to learn because you have it everywhere. With WebAssembly, everyone can learn and experiment with Ruby using a browser. The output is printed in the developer's console.

You can even see the contents of ruby.wasm disassembled into text format in the “Sources” tab:

 The browser’s developer tools are opened in the Sources tab. The list of sources includes a wasm folder with the Ruby interpreter downloaded. On the right pane, we see the contents of the disassembled binary in text form. We can see the downloaded Wasm file in the browsers web developer tools.

You can check out the Wasm port online at the Ruby playground.

Working with the sandbox

As said, Wasm programs run in a sandboxed VM that lacks access to the rest of the system. Therefore, Wasm applications do not have access to the browser, filesystem, memory or the network. We'll need some JavaScript code to send and receive data from the sandbox.

The following example shows how to read the output of a Ruby program and make changes to the page using the ruby-head-wasm-wasi NPM package:

<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 () => {
      const response = await fetch(
        "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>

The same package can also run Ruby code inside a Node project, allowing you to mix Ruby and JavaScript on the backend. You'll need to install the NPM package ruby-head-wasm-wasi for the example to work:

import fs from "fs/promises";
import { DefaultRubyVM } from "ruby-head-wasm-wasi/dist/node.cjs.js";

const main = async () => {
 const binary = await fs.readFile(
 // Tips: Replace the binary with debug info if you want symbolicated stack trace.
 // (only nightly release for now)
 // "./node_modules/ruby-head-wasm-wasi/dist/ruby.debug+stdlib.wasm"
 "./node_modules/ruby-head-wasm-wasi/dist/ruby.wasm"
 );
 const module = await WebAssembly.compile(binary);
 const { vm } = await DefaultRubyVM(module);

 vm.eval(`
 luckiness = ["Lucky", "Unlucky"].sample
 puts "You are #{luckiness}"
 `);
};

main();

Running ruby.wasm outside the browser

While Wasm's primary design goal is running binary code in the browser, developers quickly realized the potential of a fast, safe, and universally portable binary format for software delivery. Wasm has the potential to become as big a Docker, greatly simplifying application deployment for embedded systems, serverless functions, edge computing, or as a replacement for containers on Kubernetes.

Running a Wasm application outside the browser requires an appropriate runtime that implements the WebAssembly VM and provides interfaces to the underlying system. There are a few competing solutions in this field, the most popular being wasmtime, wasmer, and WAMR.

The Ruby repository provides a complete example for bundling your application code into a custom Ruby image.

Limitations

Let’s remember that this is all cutting-edge tech. The whole Wasm ecosystem is moving fast. Right now, Ruby Wasm has a few limitations which significantly limit its usability in big projects:

  • No thread support.
  • Spawning processes does not work.
  • No network support.
  • The garbage collector can create memory leaks.
  • Gems and modules are unavailable unless you build a custom Wasm image.

The future is bright

WebAssembly opens a world of exciting possibilities. It allows Ruby developers to escape the backend. As tooling around WebAssembly improves, Ruby will be able to reach new frontiers: the browser is no longer off-limits, and there will be new opportunities to run Ruby on the edge and as serverless applications.

With the latest release, Ruby developers can begin experimenting with WebAssembly. It's the first step, for sure, and there is much more work to do before we see complex Ruby applications running in with this technology.

Thanks for reading, and happy assembling!