Compiling Go to WASM
January 16th, 2025Background #
Last year I wrote a type-checker for Golang (written in Go) whose aim is to expand the language's type inference capabilities by using bidirectional typechecking. (It's just a small "what if" experiment.)
It was my first experience with that technique. In retrospect, I think I did a decent job but I am sure I did not grasp the concept fully at the time. For example, I would've liked to split my AST in the way that Conor McBride describes in this talk on type systems.
As I developed the type-checker, I found myself repeatedly clearing my terminal screen and re-running the tool to check its output. My write-run-debug loop was inefficient. So I put together a tiny web app.
The web app consisted of a frontend and a backend. The frontend makes use of
Monaco Editor to display two code
editors side by side (a la Compiler Explorer) -- one
to edit the source Go code and one to display the tool's output. The backend
receives POST /compile
requests from the frontend every time the user edits
the source code; the input is the user's code as text, and the output is the
type-checker's internal logs.
Admittedly, I spent too much time on this (I have a weak spot for webdev), but it worked out pretty well.
...and it wasn't long before I wanted to share my work with friends and colleagues ๐. So I used Google Cloud Run's free tier to build and deploy the app, which gave me access to a public URL for the app.
For the time being, I was satisfied with all the work that I did. And I moved on.
Fast forward to a few weeks ago...
During the holidays, I had enough time and energy to finally pour some love into my personal website. It had been neglected for years, and one of the reasons for such neglect was my annoyance of working with Ruby and Jekyll. I just found it clunky and uninteresting.
On my search for alternative static site generators, I found Lume. It is a dead-simple, batteries-included framework that just works. But more importantly, it just makes sense. I was instantly delighted, so I spent the following days moving every site that I own (mainly GitHub Pages) to Lume. I had lots of fun and was extremely productive using Deno, Lume, TypeScript, and Preact.
And then it hit me: What if I don't need a backend for my type-checker web app? Could I also turn it into a statically-built website?
The answer was yes. I was able to replace the backend call with a direct call to the type-checker compiled as WASM. That allowed me to get rid of the Google Cloud deployment and simply build the app with Lume and deploy it for free to GitHub Pages.
So, that's the why. Now, let's focus on the how.
Compiling and running #
(Note: I'm currently running Go version 1.23.4
.)
Compiling Go to WASM (docs) is pleasantly simple and easy:
GOOS=js GOARCH=wasm go build -o build/main.wasm github.com/garciat/gobid/cmd/main
To my surprise, the command ran successfully on the first try without any changes at all to the codebase. Props to the Golang dev team.
In order to execute the code,
the documentation asks us to
copy the file "$(go env GOROOT)/misc/wasm/wasm_exec.js"
into our project and
to import it as a helper script:
<script src="wasm_exec.js"></script>
<script>
const go = new Go();
WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject)
.then((result) => {
go.run(result.instance);
});
</script>
I did so, and it worked. I saw the following logged into the developer console:
wasm_exec.js:22 === CheckPackageDeclarations ===
wasm_exec.js:22 === CheckPackageCycles ===
wasm_exec.js:22 === ResolvePackageNames(main) ===
wasm_exec.js:22 === Loading Package (main) ===
wasm_exec.js:22 === Checking Package (main) ===
wasm_exec.js:22 === Verifying Package (main) ===
wasm_exec.js:22 === Verify ===
wasm_exec.js:22 === iteration 0 ===
wasm_exec.js:22 Context:
{} || {}
wasm_exec.js:22 Steps:
wasm_exec.js:22 === DONE ===
wasm_exec.js:22 {{ }}
But, wait, there's more.
My tool's main
executable is meant to be called with file paths passed as
commandline arguments. For example:
go run github.com/garciat/gobid/cmd/main hello.go
I needed to be able to:
- Provide program arguments. (
os.Args
) - Provide readable files.
- Capture
stdout
into a JS string, instead ofconsole.log
.
I could'ved skipped points 1 and 2 by changing the program to read from stdin
instead. That would've been good enough for an MVP. But for whatever reason I
chose to stick to its original behavior. How hard could that be?
Providing program arguments #
This is actually easy to do:
const go = new Go();
go.argv.push("hello.go");
// ...
But how did I figure that out?
Go's official documentation for WebAssembly
does not document the interface of the Go
object provided by the support
library.
Our only options are to either read the file itself to infer its API, or to read the code of one of the many provided example apps.
As we'll see further on, this will not be our last problem with underspecification.
Providing readable files #
The wasm_exec.js
support file
(link)
gives us a hint as to how filesystem access works in Go-WASM:
if (!globalThis.fs) {
let outputBuf = "";
globalThis.fs = {
constants: {/** ... */},
writeSync(fd, buf) {
// ...
},
// Several more methods like:
// write, chmod, chown, etc.
};
}
That piece of code seems to be defining a Node.js-like filesystem interface. But
there are essentially zero direct source references to fs
in the
wasm_exec.js
file itself. How is this being used then?
It turns out that the Go standard library file src/syscall/fs_js.go
(link) reads
the fs
global variable like so:
// NOTE: some lines were redacted away for brevity
package syscall
import (
"syscall/js"
)
var jsFS = js.Global().Get("fs")
var constants = jsFS.Get("constants")
func Ftruncate(fd int, length int64) error {
_, err := fsCall("ftruncate", fd, length)
return err
}
func fsCall(name string, args ...any) (js.Value, error) {
type callResult struct {
val js.Value
err error
}
c := make(chan callResult, 1)
f := js.FuncOf(func(this js.Value, args []js.Value) any {
var res callResult
if len(args) >= 1 { // on Node.js 8, fs.utimes calls the callback without any arguments
if jsErr := args[0]; !jsErr.IsNull() {
res.err = mapJSError(jsErr)
}
}
res.val = js.Undefined()
if len(args) >= 2 {
res.val = args[1]
}
c <- res
return nil
})
defer f.Release()
jsFS.Call(name, append(args, f)...)
res := <-c
return res.val, res.err
}
From this, we may infer the following:
- Go expects a global name
fs
. - Go redirects filesystem syscalls to method calls on
fs
. - The syscall API mimicks Node.js's fs module.
- Go relies on specific overloads of each function.
- For example: Node.js's
fs.ftruncate
(link) may be called withfd, callback
or withfd, len, callback
, but Go only uses thefd, len, callback
overload.
- All syscalls are callback-based.
- The Go callback specifically expects
null
when there is no error. I.e.undefined
or some other falsey value will not do.
- The Go callback specifically expects
A Google search for "nodejs fs on browser"
eventually led me to
ZenFS, a browser-compatible filesystem emulation
library that is compatible with Node.js's fs
API.
I tried doing the following:
import { fs } from "https://esm.sh/@zenfs/core";
globalThis.fs = fs;
const go = new Go();
fs.writeFileSync("main.go", "package main\n");
go.argv.push("main.go");
But it did not work right away. After hours of debugging, I found that I needed to write quite a bit of custom code to get ZenFS to work with Go's implicit expectations and in order to work around some of ZenFS's strange behavior.
In summary, I defined a GoFileSystemAdapter
class that wraps a ZenFS
FileSystem
object and exposes it as the Node.js-like fs
API that Go expects.
Capturing stdout
#
ZenFS itself has no support for any of the standard streams. I ended up manually
creating /dev/stdout
and /dev/stderr
files and redirecting operations on
file descriptors 1
and 2
respectively.
I did not spend time on enabling /dev/stdin
, though, because I did not need
it.
I also could've implemented
a custom ZenFS Device that
more accurately mimicks the usual stdin
and stdout
behavior. But the above
was sufficient for my use case.
Upon calling GoFileSystemAdapter#finalize()
, I close and fully read those
special files and return their contents as JS strings.
Updating wasm_exec.js
to JS modules #
I also spent some time modifying wasm_exec.js
to make it easier to import as a
JS module. More specifically, I made the following changes:
I converted the file extension .ts
for TypeScript support. This allowed me to
explicitly define the expected API of the filesystem FFI. For example:
export interface GoFileSystemError extends Error {
readonly errno: number;
readonly code: string;
}
export type GoFileSystemCallback<T> = (
err: GoFileSystemError | null,
value?: T,
) => void;
export interface GoFileSystemFFI {
// ...
write(
fd: number,
buf: Uint8Array,
offset: number,
length: number,
position: number | null,
callback: GoFileSystemCallback<number>,
): void;
// ...
}
Then, I changed the constructor of new Go(...)
to take in fs
and
process
as parameters instead of reading them from globalThis
; and I used
export class Go
instead of globalThis.Go = class Go
.
Sadly, because Go's dependency on globalThis.fs
happens in the standard
library, it would've been a pain to change this. I had previously
hacked on the Go
compiler,
but I did not want to go down this path at the time. (I will consider it in the
future.)
Finally, I renamed the file to wasm_exec@1.23.4.ts
in order to make it clear
that it is tied to Go version 1.23.4.
Putting it all together #
In the end, my 'compile' function ended up looking like this. I named the
function gobid
, because that's the name I chose for the type-checker back when
I started it (it is a portmanteau of Go and Bidirectional).
import { createFS } from "./go-fs.ts";
import { process } from "./go-process.ts";
import { Go } from "./wasm_exec@1.23.4.ts";
type Path = string;
type GoSourceCode = string;
export async function gobid(
inputs: Record<Path, GoSourceCode>,
): Promise<string> {
const fs = await createFS();
const go = new Go(fs, process);
for (const [path, source] of Object.entries(inputs)) {
fs.writeFileSync(path, source);
go.argv.push(path);
}
const source = await WebAssembly.instantiateStreaming(
fetch(import.meta.resolve("../build/main.wasm")),
go.importObject,
);
await go.run(source.instance);
const { stdout, stderr } = fs.finalize();
if (stderr) {
return stderr;
}
return stdout;
}
Although globalThis
shenanigans do end up happening, at the very least those
details do not leak into the client code.
Check it out #
You can access the web app here: https://garciat.com/gobid/
Conclusions #
Go's WASM support works well, but it is sorely underspecified. It would be great if its JS FFI contract were strictly specified in their documentation and, ideally, with TypeScript interfaces (or at least with JSDoc types).
Go's filesystem JS FFI reliance on a global fs
object makes it hard to isolate
WASM instances. As an alternative, the syscall code could use the Go
object
instance itself as a top-level scope and read fs
and other FFI dependencies
off it directly.
GitHub issue #56084 proposed a
similar approach back in 2022, but no changes have been landed since.
wasm_exec.js
does not leverage modern JavaScript features like
modules
(aka ESM), and is otherwise too coupled to Node.js API expectations. Also,
rather than copying the file off the local Go installation, it would be more
convenient to be able to download or import it from NPM or JSR. For example:
import { Go } from "jsr:@go/wasm-ffi@1.23.4"
Finally, implementing a fully-compliant filesystem for the Go JS FFI is non-trivial and there are no ready-made solutions available, as far as I can tell.