WebAssembly¶
The browser target builds, and a CI lane gates it
This page carried a danger admonition saying GOOS=js GOARCH=wasm failed to
compile, measured on 3e8e3cc (2026-09-26). It was fixed by
ruby#684, which closed the
three missing syscall constants and added the lane that would have caught
it — wasm (js, browser) in ci.yml, green on every pull request since.
Re-measured on 4872e01 (2026-10-11):
./cmd/rbgo, ./cmd/wasm and ./... all exit 0, so the playground, the JS
bridge and rbgo build --closed --target wasm build as this page describes.
WASI (GOOS=wasip1 GOARCH=wasm) does work, and is gated in CI:
$ CGO_ENABLED=0 GOOS=wasip1 GOARCH=wasm go build -o rbgo.wasm ./cmd/rbgo
$ wazero run rbgo.wasm -e 'puts (1..10).sum'
55
The rest of this page describes the browser design as intended. go-embedded-ruby is pure Go with cgo disabled, so the interpreter, the numeric stack and the cgo-free image pipeline are meant to compile to a single WebAssembly module and run entirely in the browser, with no server-side code — and did until the regression above.
There are two distinct ways to ship Ruby to the browser, and they answer different needs:
Playground (cmd/wasm) |
Closed-world app (rbgo build --target wasm) |
|
|---|---|---|
| What runs | arbitrary Ruby the user types | one program you baked in |
| Front-end | linked (lexer/parser/compiler) | dropped |
| Built by | web/build.sh |
rbgo build --closed --target wasm |
| Entry | rbgoEval / rbgoImage JS functions |
the embedded program, then select{} |
| Use it for | a REPL / live demo page | a self-contained Ruby browser app |
Both produce a .wasm module that you serve next to Go's wasm_exec.js loader.
What is not available in the browser¶
A browser tab has no TCP sockets and no cgo, so the gem backends that open
real network connections or use OS facilities cannot function there. They are
compiled out of the GOOS=js GOARCH=wasm build — their Go files carry a
//go:build !(js && wasm) tag — which also keeps roughly 90 MB of driver
code (Arrow, go-redis, franz-go, the MongoDB / MySQL / gRPC clients, …) out of
the module. The native build is entirely unchanged: every gem is present,
loads and conforms exactly as before.
Two tiers degrade on wasm:
| tier | require |
behaviour on wasm | features |
|---|---|---|---|
| compiled out | raises LoadError |
require "kafka" → cannot load such file -- kafka (not available in the wasm build) |
grpc, nats, kafka, mysql2 / mysql, mongo / bson, arrow, parquet, openstack, and the redis-backed sidekiq / resque |
| graceful stub | succeeds | the module loads, but constructing a connection / handle raises (e.g. SQLite3::Database.new) |
sqlite3, sequel, activerecord, bolt, bleve, etcd |
Everything else — plain Ruby, the numeric / image stack, and the JS DOM/Canvas
bridge below — is fully available in the browser. A REPL that sticks to language
features and the numeric/image APIs behaves identically to the native build.
1. The playground — the full interpreter in the browser¶
The playground links the whole interpreter — front-end (lexer, parser,
compiler), VM, the numeric stack and the cgo-free image pipeline — so the page
can evaluate any Ruby the user types. It lives in
web/ and is a Ruby
REPL plus an image pipeline (load an image → gaussian_blur/sobel/canny →
render to a <canvas>).
build.sh cross-compiles cmd/wasm
with GOOS=js GOARCH=wasm and copies Go's wasm_exec.js loader from the active
toolchain. Build without serve to produce just the artifacts for static hosting
(e.g. GitHub Pages):
The module publishes two functions on the JS global object:
| function | returns | used by |
|---|---|---|
rbgoEval(src) |
{output, value, error} |
the REPL |
rbgoImage(src, bytes) |
{output, value, error, bytes} |
the image demo |
rbgoImage binds the input image's raw bytes to the Ruby constant INPUT (a
String), runs src, and — when the program's result is a String (e.g. the
output of Image#to_png) — hands those bytes back as a Uint8Array for the page
to paint. The image demo's pipeline is plain Ruby:
Arbitrary REPL input can never crash the interpreter: a native binding that
faults on bad arguments is converted to a rescuable Ruby ArgumentError.
2. rbgo build --target wasm — a closed-world wasm app¶
To ship a single program rather than a REPL, AOT-bake it into a closed-world wasm module that drops the front-end:
--target wasmrequires--closed— the wasm entry point runs the embedded program; there is no command-line file to read in a browser tab. The program is frozen to bytecode at build time and the lexer/parser/compiler are left out of the link (see the AOT compiler for how closed-world builds work).- The build sets
GOOS=js GOARCH=wasmon the nestedgo build, so it links the wasm closed-world entry. After the embedded program returns it does not exit: it parks onselect{}, because a Go wasm module that returns frommain()is torn down — it must stay alive for any JS event or animation callbacks the program registered.
Serve the emitted app.wasm next to wasm_exec.js (copy it from
$(go env GOROOT)/lib/wasm/wasm_exec.js).
The JS module — DOM, Canvas and events from Ruby¶
A closed-world wasm program reaches the page through the built-in JS
module, which the VM registers automatically on wasm. So a Ruby browser app can
render and handle events with no hand-written JavaScript:
doc = JS.document
canvas = doc.call("getElementById", "screen")
ctx = canvas.call("getContext", "2d")
ctx.set("fillStyle", "tomato")
ctx.call("fillRect", 10, 10, 120, 80)
doc.call("getElementById", "go").on("click") do |_event|
JS.log("clicked!")
end
# an animation loop: reschedule from inside the block
draw = nil
draw = ->(t) {
ctx.call("clearRect", 0, 0, 320, 240)
ctx.call("fillRect", (t % 300), 50, 20, 20)
JS.raf(&draw)
}
JS.raf(&draw)
The surface:
| call | does |
|---|---|
JS.global / JS.window / JS.document |
the JS globals, as JS::Ref handles |
JS.log(*args) |
console.log |
JS.raf { \|t\| … } |
requestAnimationFrame (reschedule inside for a loop) |
ref.get(name) / ref[name] |
read a property |
ref.set(name, value) |
write a property |
ref.call(method, *args) |
invoke a method |
ref.on(event) { \|e\| … } |
addEventListener |
JS values flow both ways: primitives convert directly (numbers, strings,
booleans, null/undefined → nil), and objects/functions become opaque JS::Ref
handles.
Verifying a wasm build¶
The closed-world wasm path is covered end to end by an integration test
(cmd/rbgo/wasm_build_test.go,
gated behind RBGO_BUILD_IT=1 because it shells out to the Go toolchain). It
bakes a JS-using program, then asserts the output carries the WebAssembly magic
(\0asm) and that the linker dropped the front-end (no go-ruby-parser/parser
or internal/compiler symbols in the module):
Both targets are meant to build directly, but both currently fail with the
internal/vm/errno.go error at the top of this page:
GOOS=js GOARCH=wasm go build ./cmd/wasm # the playground — fails today
GOOS=js GOARCH=wasm go build ./cmd/rbgo # the CLI / closed-world entry — fails today
The WASI target builds and runs: