@takahashim/mruby-wasm-js
v0.2.0
Published
Minimal mruby ↔ JavaScript bridge for WebAssembly. Run mruby in browsers and Node with a JS::Object DOM/JS interop API.
Downloads
312
Maintainers
Readme
mruby-wasm-js
Minimal mruby ↔ JavaScript bridge for WASM hosts. Lets Ruby code running
in an mruby VM compiled to WebAssembly call into the JS host (browser,
Node, etc.) and vice versa, with a ruby.wasm-compatible feel
(JS.global[:document][:title] = "hello").
Layout
src/— C primitives compiled into the wasmmrblib/js.rb— Ruby surface (JSmodule +JS::Objectclass)js/— JS-side adapter (createVMfactory + bundled WASI preview1)wasm_spec/— Node-driven test suite. Namedwasm_spec/(nottest/) to opt out of mruby-test'stest/*.rbauto-discovery, since the tests assume a JS host and can't run inside the standard mrbtest binary.
Using the gem
1. Add it to your build_config
MRuby::CrossBuild.new("wasi") do |conf|
conf.toolchain :clang
# ... your wasi-sdk setup ...
conf.gembox "default-no-stdio"
conf.gem File.expand_path("path/to/mruby-wasm-js")
# mruby-method / mruby-fiber / mruby-compiler are pulled in
# transitively via the gem's add_dependency declarations.
conf.gem core: "mruby-io" # optional (File.read/open via WASI fs)
conf.gem core: "mruby-time" # optional (Time.now via WASI clock_time_get)
conf.gem core: "mruby-random" # optional (rand/Random via WASI random_get)
# Optional sibling gems for WASI primitives mruby core doesn't ship.
conf.gem File.expand_path("path/to/mruby-wasi-dir") # Dir.entries / mkdir / rmdir / exist?
conf.gem File.expand_path("path/to/mruby-wasi-env") # ENV[] / ENV[]= / each / keys / ...
endThe gem's C side declares WASM imports under the module name js
(e.g. js.js_eval, js.js_call). Your linker must allow
these undefined symbols:
-Wl,--allow-undefined -Wl,--export=js_invoke_proc -Wl,--export=js_eval_handle2. Spawn a VM from the JS host
// Via npm / bare specifier (preferred — package.json#main resolves
// to ./index.js):
import { createVM } from "@takahashim/mruby-wasm-js";
// Or via explicit path (vendored / unpublished consumers):
import { createVM } from "./vendor/mruby-wasm-js/index.js";
const vm = await createVM({ wasm: "/path/to/mruby-js.wasm" });
vm.eval("puts JS.global[:navigator][:userAgent].to_s");TypeScript users get type definitions out of the box (index.d.ts
ships with the package; createVM is overloaded so the returned
shape narrows depending on whether you pass wasi or not).
createVM(options) fetches the wasm, instantiates it with all required
imports (js.* for the JS imports, wasi_snapshot_preview1.* for
puts, Time.now, File.read, etc.), runs the reactor's
_initialize entry, and returns a VM handle with all per-instance
state. Each createVM() call gets an independent handle table + WASI
state — multiple VMs can coexist in one process (useful for tests,
sandboxing, hot reload).
vm.eval(source) parses + runs Ruby source on the live VM. Each call
is auto-wrapped in a Fiber so JS::Object#await works at top level.
Common browser helpers are exposed directly on JS as thin wrappers:
JS.encode_uri_component("a b/c?d") # => "a%20b%2Fc%3Fd"
JS.decode_uri_component("a%20b") # => "a b"The VM handle exposes:
| Property | Purpose |
|---|---|
| vm.eval(src) | parse + run Ruby; returns 0 / 1 |
| vm.fs | Map-like facade over the tree VFS |
| vm.env | mutable env hash |
| vm.args | mutable argv array |
| vm.stdin | feed stdin (bytes, pushText(s)) |
| vm.instance | underlying WebAssembly.Instance |
| vm.alloc / vm.get / vm.release | low-level handle table |
| vm.handleCount() | live JS handle count (for leak detection) |
Module-level exports:
| Export | Purpose |
|---|---|
| createVM(options) | the factory above |
| Directory / File | tree-VFS node classes |
| createFsFacade(root) | wrap a Directory tree as a Map-style fs facade |
| debug | global debug toggle ({ trace: false }) |
createVM options
| Option | Default | Notes |
|---|---|---|
| wasm (string, required) | — |URL to mruby-js.wasm |
| env (object) | {} | initial environ, available to mruby via wasi-libc's getenv |
| args (string[]) | ["mruby-wasm-js"] | initial argv (args[1..] lands in Ruby's ARGV) |
| stdin (string | Uint8Array) | "" | initial stdin payload for STDIN.read / gets |
| fs (Directory) | empty Directory | declarative initial tree (or use vm.fs.set(...) after creation) |
| wasi (object) | bundled in-memory impl | replacement wasi_snapshot_preview1 import object |
| onStart (function) | calls _initialize() (or _start() as fallback) | post-instantiate callback; override for shims that need to bind the instance themselves |
Populating the virtual filesystem
import { createVM, Directory, File } from "@takahashim/mruby-wasm-js";
// 1. Declarative — hand the whole tree to createVM.
const vm = await createVM({
wasm: "/path/to/mruby-js.wasm",
fs: new Directory({
data: new Directory({
"poem.vtt": new File(new TextEncoder().encode("WEBVTT\n...")),
}),
empty_dir: new Directory(),
}),
});
// 2. Map-style after creation — auto-creates intermediate Directory nodes.
vm.fs.set("/config/app.json", new TextEncoder().encode("{}"));vm.fs supports set / get / has / delete / entries / keys /
values / Symbol.iterator / clear / size (Map-compatible), plus
populate(dir) and root for tree access. Iteration yields only File
leaves, in tree-traversal order.
3. Dispatch from Ruby
doc = JS.global[:document] # property access
doc.title = "hello" # method_missing → setter
doc.getElementById("audio") # method_missing → JS call
button.on(:click) { |ev| puts ev[:type].to_s } # block as callback
JS.global[:Promise].resolve(42).then { |v| ... } # JS Promise
JS.global[:Promise].resolve(42).await # blocking await (Fiber-based)
JS.global[:Date].new(2026, 4, 8) # JS constructorSee mrblib/js.rb for the full surface (==, typeof,
instanceof?, to_a, each, to_proc, inspect, ...).
Running the spec
The spec suite requires a JS host. From within this gem directory:
node wasm_spec/runner.mjsBy default it looks for mruby-js.wasm at ../../../build/mruby-js.wasm
(matches the repo's layout where make js writes the build output to
build/). To point at any other location, set the env var:
MRUBY_WASM_PATH=/abs/path/to/your/mruby-js.wasm \
node wasm_spec/runner.mjsExit code 0 on success, 1 on any failure.
Dependencies
Required gems (mruby-method, mruby-fiber, mruby-compiler) are
declared in mrbgem.rake and pulled in transitively. Add the following
yourself if you want the corresponding Ruby surfaces backed by WASI:
mruby-io, mruby-time, mruby-random.
Tested against mruby 4.0.0. Runs in any modern browser or Node.js with WebAssembly Exception Handling support — no flags or experimental options required.
Related projects
This gem ships the JS-host build of upstream mruby on WebAssembly.
A sibling command / wasmtime build (same upstream mruby, no JS bridge)
is built into dist/mruby-wasm-cmd/ from the same repo
(mruby-wasm-runtime). Useful for sandboxing and running mruby scripts
from wasmtime / node:wasi / other preview1 hosts. Build it via
make cmd from the repo root.
If you're doing mruby on WASM more broadly, see also:
- ruby/ruby.wasm — official
CRuby on WebAssembly. Heavier (full CRuby) but more compatible with
CRuby gems. The structural inspiration for this gem;
createVMis shaped after the same factory-returning-VM-handle pattern.
License
MIT (see mrbgem.rake).
