Skip to content

WebAssembly (js/wasm)

GOOS=js GOARCH=wasm is a first-class target. go-embedded-ruby is pure Go with cgo disabled, so the interpreter, the numeric stack and the cgo-free image pipeline compile to a single WebAssembly module and run entirely in the browser — there is no server-side code.

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>).

./web/build.sh serve     # build web/rbgo.wasm and serve http://localhost:8080

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

./web/build.sh           # produces web/rbgo.wasm + web/wasm_exec.js

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:

Image.decode(INPUT).gaussian_blur(2.0).sobel_mag.to_png

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:

rbgo build --closed --target wasm -o app.wasm app.rb
  • --target wasm requires --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=wasm on the nested go build, so it links the wasm closed-world entry. After the embedded program returns it does not exit: it parks on select{}, because a Go wasm module that returns from main() 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):

RBGO_BUILD_IT=1 go test ./cmd/rbgo -run TestClosedWasmBuildIntegration

Both targets also build directly:

GOOS=js GOARCH=wasm go build ./cmd/wasm    # the playground
GOOS=js GOARCH=wasm go build ./cmd/rbgo    # the rbgo CLI / closed-world entry