Calling JavaScript from Ruby
Ruby has always been good at talking to other languages. C extensions are a founding feature. FFI makes it easy to call shared libraries. Then yesterday, I spotted Rubyx on Reddit — a gem that bridges Ruby and Python via Rust. Proxy objects, method_missing, transparent type conversion. You write np = Rubyx.import('numpy') and call NumPy methods as if they were Ruby methods. It's a beautiful design.
I looked at it and thought: could the same thing work for JavaScript?
mini_racer embeds V8 in Ruby and has been the standard answer for JS interop for years. It's solid, battle-tested, and fast. But the interface is ctx.eval("code string") — you're writing JavaScript inside Ruby strings. No import system, no ES modules, no npm packages without bundling first. And V8 brings a ~45MB binary, a C++ toolchain requirement, and platform constraints that have caused real pain for Windows users and fork-safety in server environments.
I wanted the Rubyx experience, but for JavaScript. Claude Code made it happen in hours.
Boa + Magnus
The pieces that make this possible are both Rust projects.
Boa is a JavaScript engine written entirely in Rust. No C++ dependency, no system library to link. It supports ES2023, ES modules, promises, and most of the standard library. It's pre-1.0 and has no JIT — so it's slower than V8 on compute-heavy work — but it compiles everywhere Rust does and produces a ~5-10MB binary.
Magnus provides high-level Rust bindings for Ruby's C API. It handles the boilerplate of defining Ruby classes, wrapping Rust structs, converting types. Combined with rb-sys for the low-level linkage, it's the same foundation Rubyx uses for its Python bridge.
Boax connects them:
Ruby VM
│ magnus (Ruby FFI)
▼
boax native extension (Rust cdylib)
│ boa_engine (JS engine)
│ oxc_resolver (npm module resolution)
▼
Boa JS engine (in-process, same address space)
No dynamic library loading. No subprocess. No IPC. JavaScript runs in the same process as Ruby, and values cross the boundary as Rust types.
What It Looks Like
require 'boax'
# JS globals work immediately
math = Boax.import('Math')
math.sqrt(144) # => 12
math.PI # => 3.141592653589793
json = Boax.import('JSON')
json.stringify({ a: 1, b: [2, 3] }) # => '{"a":1,"b":[2,3]}'
# npm packages work after init
Boax.init(root: __dir__)
_ = Boax.import('lodash-es')
_.chunk([1, 2, 3, 4, 5, 6], 2).to_ruby # => [[1, 2], [3, 4], [5, 6]]
_.camelCase('foo-bar') # => "fooBar"
Boax.import first checks JS globals (Math, JSON, Date). If the name isn't a global, it resolves as an npm package — creating a synthetic entry module, loading it through Boa's module system, and returning the namespace as a proxy object.
The proxy pattern is the same one Rubyx uses: method_missing forwards Ruby calls to JS property access and function calls. If the property is callable, it's called with the arguments. If it's a value, it's returned. If the name starts with an uppercase letter and the value is constructable, it's returned as a proxy so you can chain .new():
date_ctor = Boax.eval("Date")
d = date_ctor.new(2024, 0, 15)
d.getFullYear # => 2024
Module Resolution
npm packages use ES module syntax, and their package.json files can have complex exports maps, main fields, and conditional imports. Rather than reimplement this, Boax uses oxc_resolver — the same resolver that powers Rolldown and oxlint. It handles exports, main, module, conditional import/require resolution, and file extension probing.
The custom ModuleLoader integrates oxc_resolver with Boa's module system:
- Relative imports (
./foo,../bar) use Boa's built-in path resolution - Bare specifiers (
lodash-es) resolve via oxc_resolver againstnode_modules/ - Node built-in names (
path,fs,node:util) return synthetic Rust-implemented modules - All resolved modules are cached by absolute path
Node API Modules
Some npm packages import Node built-ins. To support them, Boax implements a subset as synthetic modules — Rust code registered as Boa modules:
path — join, resolve, basename, dirname, extname, parse, format, normalize, isAbsolute, relative. Implemented with Rust's std::path, so they use the actual OS path semantics.
fs — 17 sync operations (readFileSync, writeFileSync, statSync, mkdirSync, ...), callback variants (readFile(path, cb)), and fs.promises returning real JS Promises. The sync implementations wrap std::fs; callbacks and promises wrap the sync layer.
util — format (with %s/%d/%j/%o), inspect, inherits, isDeepStrictEqual, types (isDate, isRegExp, isMap, isSet, ...).
events — EventEmitter with on, off, once, emit, removeListener, listenerCount, eventNames.
These aren't complete Node.js implementations — they're the subset that real npm packages actually import. The pattern is designed for incremental growth: each module is a separate Rust file that registers exports via Boa's SyntheticModule API.
Type Conversion
Values cross the Ruby-JS boundary automatically:
| Ruby | JavaScript |
|---|---|
nil |
undefined |
true/false |
boolean |
Integer |
number (integer) |
Float |
number (float) |
String |
string |
Symbol |
string |
Array |
Array |
Hash |
plain object |
Boax::JsObject |
original JS value |
The last row matters: if you get a JS object from one call and pass it as an argument to another, it crosses back as the original JsValue — no serialization, no copying.
to_ruby does deep conversion: JS arrays become Ruby arrays, plain JS objects become Ruby hashes, and nested structures convert recursively. Non-plain objects (class instances, functions) stay as Boax::JsObject proxies.
The Bang Escape Hatch
One Ruby-JS friction point: Ruby's Kernel#then shadows JavaScript's Promise.then(). Since then is defined on every Ruby object, method_missing never sees it.
The solution: append ! to bypass Ruby and reach JavaScript directly:
promise = fs["promises"].readFile("data.txt")
promise.then!(callback) # calls JS promise.then(callback)
The ! is stripped before the JS property lookup. It works for any method name conflict, not just then.
What's Missing
This is a proof of concept, not a production library. The gaps are real:
- Performance — Boa has no JIT. Compute-heavy JS will be meaningfully slower than V8/mini_racer.
- Intl — Boa 0.21's
Intl.NumberFormatandIntl.DateTimeFormatthrow "unimplemented." - CommonJS — only ES modules. CJS packages need a bundler.
- Streams, HTTP, child_process — not implemented.
- GC coordination — a Boa GC issue can corrupt synthetic module namespace properties after heavy use. The workaround is to cache constructor references.
Some of these will improve as Boa matures — JIT work is on their roadmap, and Intl coverage is expanding. Others are scope decisions that would change based on what packages people actually want to use.
Standing on Shoulders
Boax exists because of the projects it builds on. Rubyx showed that the proxy-object pattern works beautifully for cross-language bridges in Rust, and its magnus + rb-sys architecture is exactly what Boax uses. mini_racer proved there's demand for calling JS from Ruby, and its eval-based API informed what an import-based API should feel like. Boa provides a JS engine that's embeddable without a C++ toolchain. oxc_resolver handles the hard problem of npm module resolution.
And Claude Code turned what would have been weeks of Rust/Boa API spelunking into a single sitting. The architecture doc, the implementation plan, five phases of development, 127 passing tests — all in one conversation. I steered; it built. That's a new kind of prototyping: not "AI wrote my code" but "AI made it feasible to explore an idea I wouldn't have attempted alone."
The interesting question isn't whether this specific gem is production-ready — it isn't. It's whether an import-based, proxy-object API for JavaScript is the right direction for Ruby-JS interop. If Boax.import('lodash-es').chunk([1,2,3,4], 2).to_ruby feels more natural than ctx.eval("..."), that's worth exploring further.
127 tests pass. The code is at github.com/rubys/boax.