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>).
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 also build directly: