intertwingly

It’s just data

Three Paths to Ruby2JS in the Browser


The Ruby2JS online demo needs an upgrade. It currently uses Opal to run Ruby in the browser, producing a 5MB JavaScript bundle. More importantly, Opal can't use Prism—Ruby's new parser written in C—so the demo is stuck with the whitequark parser gem which only supports Ruby 3.3 syntax.

Three paths forward emerged:

  1. Opal (current): Ruby compiled to JavaScript via source-to-source translation
  2. ruby.wasm: Full CRuby compiled to WebAssembly
  3. Self-hosting: Transpile Ruby2JS itself to JavaScript using Ruby2JS

TL;DR: Self-hosting works for basic Ruby. The converter runs in the browser at ~8x smaller than Opal.


The Contenders

Opal: The Incumbent

Opal compiles Ruby source code to JavaScript. It's been powering the Ruby2JS demo for years.

Pros:

Cons:

Verdict: Prism support possible but awkward.


ruby.wasm: Full Ruby in WebAssembly

ruby.wasm compiles the actual CRuby interpreter to WebAssembly. You get real Ruby—including Prism—running in the browser.

I got a proof of concept working with Ruby 4.0 nightly builds:

# Download pre-built Ruby 4.0 WASM from npm
npm pack @ruby/head-wasm-wasi@latest

# Pack gems on top
rbwasm pack ruby-head.wasm \
  --dir /path/to/ruby2js/lib::/gems/ruby2js \
  --dir /path/to/parser/lib::/gems/parser/lib \
  -o ruby2js-4.0.wasm

Results:

The catch: Ruby 3.3 and 3.4 WASM builds don't work properly with rbwasm pack. Something about the VFS and realpath_rec breaks gem loading. Only the Ruby 4.0 nightly builds cooperate—and Ruby 4.0 isn't released yet.

Pros:

Cons:

Verdict: Viable but heavy, and blocked on Ruby 4.0.


Self-Hosting: Ruby2JS All the Way Down

What if Ruby2JS could transpile itself to JavaScript?

The @ruby/prism npm package provides Prism compiled to WebAssembly with JavaScript bindings. It parses Ruby source and produces an AST—the same AST structure that Prism produces in Ruby.

If Ruby2JS can process that JavaScript AST, we don't need Ruby in the browser at all.

The architecture:

Ruby Source (user input)
    ↓
@ruby/prism (WASM, ~2.7MB)
    ↓
Prism AST (JavaScript objects)
    ↓
PrismWalker (transpiled from Ruby)
    ↓
Parser-compatible AST
    ↓
Converter (transpiled from Ruby2JS source)
    ↓
JavaScript Output

This works. The converter runs in the browser for basic Ruby constructs.

Beyond replacing the demo, self-hosting has broader benefits:


How Self-Hosting Works

The Prism Walker

I built a direct AST walker that translates Prism's native AST to the format Ruby2JS expects. It's pure Ruby with no external dependencies:

class PrismWalker
  def visit_integer_node(node)
    s(:int, node.value)
  end

  def visit_call_node(node)
    s(:send, visit(node.receiver), node.name, *visit_all(node.arguments))
  end

  # ~100 more visitor methods
end

This code transpiles to JavaScript. The @ruby/prism npm package produces JavaScript objects with the same structure—IntegerNode, CallNode, etc.—so the walker logic translates directly.

The Selfhost Filter

Ruby2JS already converts Ruby to JavaScript. But Ruby2JS's own code uses patterns that need special handling:

# S-expressions with symbols
s(:send, target, :method)
# Must become: s('send', target, 'method')

# Type comparisons
node.type == :str
# Must become: node.type === 'str'

# Handler registration
handle :str do |value|
  put value.inspect
end
# Must become: on_str(value) { ... }

The selfhost filter handles these transformations, plus many more:

Ruby Pattern JavaScript Output
s(:type, ...) s('type', ...)
node.type == :sym node.type === 'string'
visit_integer_node visitIntegerNode
@sep, @nl this._sep, this._nl
Hash === obj typeof obj === 'object' && !obj.type
hash[:key].to_s (hash.key || '').toString()
hash.include?(key) key in hash
respond_to?(:prop) typeof obj === 'object' && 'prop' in obj

The Serializer Rewrite

One hurdle: Ruby2JS's Serializer used Ruby-specific patterns that don't transpile well:

# Ruby - Token inherits from String
class Token < String
  def +(other)
    # custom concatenation
  end
end

I refactored the Serializer to use composition instead of inheritance. No more Token < String—instead, Token wraps a string. This made it fully transpilable without hand-written JavaScript stubs.

Class Reopening

Ruby2JS's code is split across many files using Ruby's class reopening:

# In prism_walker/literals.rb
module Ruby2JS
  class PrismWalker
    def visit_integer_node(node)
      # ...
    end
  end
end

The selfhost filter now handles this:

PrismWalker.prototype.visitIntegerNode = function(node) {
  // ...
}

All 12 prism_walker sub-modules transpile successfully.


Results

Bundle size:

Approach JavaScript Total with WASM
Self-hosted ~250KB ~2.9MB
Opal-based ~5MB ~5MB
ruby.wasm N/A ~41MB

The self-hosted JavaScript (converter + walker) is about 250KB. With Prism WASM (~2.7MB), the total is about 2.9MB—roughly 8x smaller than the current Opal demo and 14x smaller than ruby.wasm.

What works:

What's left:


Try It

The self-hosted demo is available at /demo/selfhost/ on ruby2js.fly.dev, or you can run it locally:

git clone https://github.com/ruby2js/ruby2js.git
cd ruby2js
git checkout self-hosting
cd demo/selfhost
npm install
python3 -m http.server 8080
# Open http://localhost:8080/browser_demo.html

There's also a Node.js CLI:

echo 'x = 42' | node ruby2js
# Output: x = 42

node ruby2js script.rb

Maintenance Model

The self-hosted version requires minimal ongoing maintenance:

Hand-written (~1,150 lines):

Generated (auto-rebuild):

Changes to the Ruby converter automatically flow through to the browser version on rebuild. The selfhost filter encodes all the Ruby→JavaScript pattern translations.


What's Next

Short-term:

  1. Implicit returns - Wrap method body last expression
  2. Comment preservation - Extract comments from Prism
  3. Transpile tests - Ruby2JS tests use to_js(input).must_equal(output), which can be transpiled to JavaScript. This ensures the self-hosted version produces identical output to the Ruby version.

Medium-term:

  1. Transpile filters - functions, esm, camelCase, return
  2. Source maps - Map generated JS back to Ruby source
  3. Error handling - User-friendly error messages

Long-term:

  1. Replace demo - Use self-hosted version as the default
  2. npm package - Publish as @ruby2js/ruby2js, replacing the Opal version
  3. Refactoring - Eliminate duplicate code, consolidate logic, and extract useful patterns from the selfhost filter into general-purpose filters

Significant work remains to reach feature parity with the Opal-based demo, but the approach is proven.


Ruby2JS is open source: github.com/ruby2js/ruby2js. The self-hosting work is on the self-hosting branch.