We can use WebAssembly (WASM) to run program written in languages other than JavaScript in the browser at near-native speed. In this post, I'll explore how I created the Monkey Language Playground.
I wanted to be able to quickly demo my language without downloading a binary. A traditional approach would be to have a server which runs an instance of the application whenever a client connects. But I wanted to do it in the browser itself. WASM was a natural choice for this. I had done a previous project where I used webassembly to build a double pendulum simulation in rust and javascript . If I could compile my go code into wasm target, then I can call the interpret function from javascript and have the repl work in browser.
Interacting between Go and JavaScript
When compiling Go to WASM, the primary bridge is the syscall/js package. There are a few main ways to architect this interaction:
1. DOM Manipulation from Go
You can write your entire frontend logic in Go. By accessing the global document object, you can create elements, add event listeners, and modify the DOM structure directly from your Go code.
import "syscall/js"
func main() {
doc := js.Global().Get("document")
div := doc.Call("createElement", "div")
div.Set("innerText", "Hello from Go!")
doc.Get("body").Call("appendChild", div)
}
2. Exposing Go Functions to JavaScript
This is the approach I used as I already had my library in go. You keep the UI logic in HTML/CSS/JS and use Go as a library that performs heavy calculations or logic.
In Go:
func myGoFunc(this js.Value, args []js.Value) interface{} {
return js.ValueOf("Result from Go")
}
func main() {
js.Global().Set("callMeFromJS", js.FuncOf(myGoFunc))
select {} // Keep running
}
In JavaScript:
// Once WASM is loaded
const result = window.callMeFromJS();
console.log(result); // Output: "Result from Go"
3. Shared Memory
For high-performance data transfer, you can use WebAssembly.Memory to share a linear memory buffer between JS and Go. This avoids the overhead of copying data between the two worlds.
In this approach, Go exports a pointer to a specific memory location. JavaScript then creates a Uint8Array view on the raw WebAssembly memory buffer at that pointer address.
For a deep dive into this, check out the MDN WebAssembly.Memory documentation.
How I Built the Monkey Playground
My project, Monkey Lang, is an interpreter written in Go. I already had a binary for REPL but I wanted it to compile to wasm.
The Go Side
I needed a function that would take the inputCode and evaluated it, which I could then register to be called from javascript. I created a separate file web/wasm.go specifically for the WASM build. Here is the core logic:
//go:build wasm
package main
import (
"fmt"
"gks/monkey_intp/repl"
"syscall/js"
)
// Wrapper to call the interpreter
func evalProgram(this js.Value, args []js.Value) interface{} {
inputCode := args[0].String()
return js.ValueOf(repl.EvaluateProgramFromString(inputCode))
}
func registerCallbacks() {
js.Global().Set("evaluateProgram", js.FuncOf(evalProgram))
}
func main() {
c := make(chan struct{}, 0)
fmt.Println("Go WASM initialized")
registerCallbacks()
<-c // Block forever to prevent the program from exiting
}
The main function sets up the channel to keep the runtime alive. registerCallbacks exposes the evaluateProgram function to the global JavaScript scope.
Building the WASM
To compile this, we set the target OS to js and architecture to wasm:
$env:GOOS=js // on linux: set GOOS=js
$env:GOARCH=wasm // on linux: set GOARCH=wasm
go build -o monkey.wasm web/wasm.go
The JavaScript Integration
Go provides a glue file wasm_exec.js (found in the Go installation directory) that must be included. Then, we load the module and set up the UI interaction.
First, we initialize the WASM runtime:
const go = new Go();
WebAssembly.instantiateStreaming(fetch("monkey.wasm"), go.importObject)
.then((result) => {
go.run(result.instance);
console.log("WASM loaded");
});
Once loaded, the evaluateProgram function defined in Go becomes available on the window object. We can call it directly in our event listeners:
function runCode() {
// Get the code from the textarea
const code = document.getElementById("codeSnippet").value;
// Call the Go function directly!
// It takes a string and returns a string (as defined in our wrapper)
const result = evaluateProgram(code);
// Display the output
console.log(result);
}
Displaying the output
One interesting challenge was capturing the output. My interpreter prints puts calls to standard output (Stdout) using fmt.Println. In the browser, Go's WASM runtime redirects Stdout to the browser console (console.log).
My Hacky Solution
To display this output in the web UI, I intercepted the browser's console.log:
let oldConsole = console.log;
console.log = function(text) {
// Append text to my HTML output div
outputDiv.innerText += text + "\n";
// Still log to the real console
oldConsole.apply(console, arguments);
};
While this works, it captures everything logged to the console, which might include browser warnings or other debug info.
The Proper Solution
A more robust approach would be to modify the Go interpreter to write to a custom interface rather than direct to os.Stdout.
We could pass a JavaScript callback function into Go, and have the interpreter call that function with the output string. Alternatively, we could create a custom io.Writer implementation in Go that calls a JS function to update the DOM directly.
Conclusion
Getting the REPL to work was surprisingly easy once I figured out how to reigster a function that I can call from javascript. Check out the source code to see the full implementation.
References & Further Reading
- Go syscall/js Package Documentation - Official docs for the JS interop package.
- Go WebAssembly Wiki - The official wiki page for Go's WASM support.
- MDN WebAssembly Docs - Comprehensive documentation on WebAssembly concepts.