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:
- Opal (current): Ruby compiled to JavaScript via source-to-source translation
- ruby.wasm: Full CRuby compiled to WebAssembly
- 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:
- Battle-tested, works reliably
- ~5MB bundle size
Cons:
- Cannot use Prism directly (it's written in C)
- Could use
@ruby/prismWASM with a walker that produces Opal's Ruby objects, but this adds complexity - Stuck on whitequark parser gem (Ruby 3.3 syntax only)
- Opal runtime overhead
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:
- 41MB WASM file
- 0.37s load time
- Full Prism support working
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:
- Full Ruby compatibility
- Real Prism parser
- Will work once Ruby 4.0 ships (December 2025)
Cons:
- 41MB is 8x larger than Opal
- Blocked until Ruby 4.0 release
- Complex build process with monkey-patched
require - Heavy: running a full interpreter for a transpiler
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:
- Proof of capability - Demonstrates that Ruby2JS can handle a substantial, real-world application (itself)
- Dogfooding - Using our own tool reveals gaps and rough edges that synthetic tests miss
- Filter improvements - Patterns addressed in the selfhost filter often represent common needs that can be extracted into general-purpose filters for all users
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:
- Literals: integers, floats, strings, symbols, nil, true, false
- Variables: local (
x), instance (@x), assignments - Collections: arrays, hashes
- Control flow:
if/else/elsif,case/when - Definitions:
def foo(args)→function foo(args) - Operators: arithmetic, comparison, logical
- Begin blocks: multiple statements
What's left:
- No implicit
returnfor method bodies yet - Comments not preserved
- Filters not yet transpiled (functions, esm, camelCase)
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):
lib/ruby2js/filter/selfhost.rb(~950 lines) - Pattern transformations- JavaScript preamble (~60 lines) - Stubs for Node, s(), etc.
- Build script (~50 lines) - Concatenates output
- Browser demo HTML (~100 lines)
Generated (auto-rebuild):
transpiled_walker.mjs(~1,700 lines)selfhost_converter.mjs(~6,500 lines)
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:
- Implicit returns - Wrap method body last expression
- Comment preservation - Extract comments from Prism
- 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:
- Transpile filters - functions, esm, camelCase, return
- Source maps - Map generated JS back to Ruby source
- Error handling - User-friendly error messages
Long-term:
- Replace demo - Use self-hosted version as the default
- npm package - Publish as
@ruby2js/ruby2js, replacing the Opal version - 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.