intertwingly

It’s just data

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:

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:

pathjoin, 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.

utilformat (with %s/%d/%j/%o), inspect, inherits, isDeepStrictEqual, types (isDate, isRegExp, isMap, isSet, ...).

eventsEventEmitter 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:

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.