Porting a Ruby Gem to the browser with ruby.wasm
When I built a small Ruby command-line tool - Tints ‘N Shades - I wondered: What does it take to run this library in the browser? Can it be done? Should it be done? It is pretty cool to make Ruby libraries available to play around with in the browser, after all.
The only way to find out is to try. I did, and now I can happily answer those questions. But first, a bit of context.
Ruby & Web Assembly
The way to run Ruby code in the browser is through Web Assembly (WASM), which is well-supported. The theory is straightforward: Take some Ruby code and compile it to a WASM module. Ship that module to the browser - to any browser - and it just works. Easy, right?
Of course, the reality is nowhere as simple as that. A lot of amazing work has been done in recent years, with Ruby 3.2 adding Web Assembly/WASI support and libraries such as ruby.wasm simplifying the process of compiling Ruby to WASM.
If you want to give it a quick try, ruby.wasm provides various Ruby runtimes as WASM modules, so all it takes to run Ruby code in the browser is this:
<!-- index.html -->
<html>
<script src="https://cdn.jsdelivr.net/npm/@ruby/[email protected]/dist/browser.script.iife.js"></script>
<script type="text/ruby">
require "js"
JS.global[:document].write "Hello, world!"
</script>
</html>
That works for standard functions - but we want something else. We want to use our library code in the browser, so the basic Ruby runtime is insufficient. We have to incorporate our library into a WASM module. Luckily, ruby.wasm is also a toolchain that helps with that.
So what do we want to run in the browser? Let’s use Tints N’ Shades as an example. It generates tints and shades for a given color in various formats, similarily to Tints.dev or other web based tools. It has basically no dependencies - apart from Thor. The library code exposes a simple interface so it’s easy to call from JavaScript.
Let’s try it.
Compiling to Web Assembly
The simplest way to compile an existing Ruby library to WASM is by utilizing ruby.wasm’s built in bundler support . To create and run our custom WASM module in the browser, we first need to add the ruby_wasm
and js
gems.
Because we are building on top of an existing gem, there already is a Gemfile. I ended up creating a separate one (Gemfile-web
) and prefixing all bundler-related commands with BUNDLE_GEMFILE=Gemfile-web
. It works, okay?
BUNDLE_GEMFILE=Gemfile-web bundle add ruby_wasm js
While we’re at it, let’s modify the Gemfile to use our own gem as a dependency. It should now look something like this.
gem "js", "~> 2.5"
gem "ruby_wasm", "~> 2.5"
gem "tints-n-shades", path: "."
That’s all there is to it, really. Let’s compile a WASM module using the following command. This will take a while.
BUNDLE_GEMFILE=Gemfile-web bundle install
BUNDLE_GEMFILE=Gemfile-web bundle exec rbwasm build -o ruby-web.wasm
Running in the Browser
There are several different ways to use your module in the browser. The simplest one - as it requires no additional build steps - is to follow the instructions over at MDN. Let’s create a new JavaScript file and run some exemplary Ruby code to verify that everything works.
import { DefaultRubyVM } from "https://cdn.jsdelivr.net/npm/@ruby/[email protected]/dist/browser/+esm";
const response = await fetch("./ruby-web.wasm");
const module = await WebAssembly.compileStreaming(response);
const { vm } = await DefaultRubyVM(module);
vm.eval(`
require "/bundle/setup"
require "js"
require "tns"
JS.global[:document].write "Version: #{TNS::VERSION}"
`);
Note that the js
gem is only required to interact with JavaScript from within your Ruby code. You will likely want to return primitives from your Ruby code to your JS runtime, and you can do that by simply returning values from vm.eval
.
const result = vm.eval(`
require "/bundle/setup"
require "tns"
TNS::VERSION
`);
console.log("Version:", result.toString());
Create a minimal index.html
to load the JavaScript, open it in your browser, and you’ll see the
ibrary version printed to the console 👌
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Ruby WASM</title>
</head>
<body></body>
<script type="module" src="index.js"></script>
</html>
Outlook
Compared to a couple of years ago, compiling Ruby code to WASM has become incredibly simple. There is no overly complex setup required. However, some practical issues remain. For one, the size of the WASM module is significant — 52MB in our case.
ls -lah
5.7K Mar 25 09:50 index.html
1.6K Mar 25 09:49 index.js
52M Mar 18 13:36 ruby-web.wasm
When compressed, that file size goes down to something like 15MB, but still - that’s not great.
Because Ruby is an interpreted language, running Ruby as a WASM module requires that that module incorporate an entire Ruby VM. And that thing ain’t small. We can do some things to reduce the module size, for example by using a minimal build profile with rbwasm build
.
BUNDLE_GEMFILE=Gemfile-web bundle exec rbwasm build --build-profile minimal -o ruby-web.wasm
There are some downsides, though. Using the minimal
profile means that several standard modules (e.g. js
, yaml
, stdio
) are excluded. Depending on your specific use case, that may lead to breakage. I found that this shaves around 10MB from the final module, which also isn’t that significant in the grand scheme of things.
So, is it practical to use Ruby WASM modules rather than plain old JavaScript? Given the file size, probably not. Is it interesting and fun, though? Absolutely!
I am excited and curious to learn how the Ruby WASM story continues. You can see Tints ‘N Shades running in the browser here.