Running Golang on the browser with WebAssembly and TinyGo


TL;DR

This is the story of how I managed to expose my Golang chess API project cheesse as a WebAssembly binary, compiled using TinyGo, so JavaScript could use it without needing a server.

The blogpost is optimised for helping others going through a similar exercise, rather than for readability, so expect a little too much of “if this, do this”. Sorry.

What are all those technologies?

WebAssembly

Assembly language of the Web, apparently. All major browsers support it, and the Go compiler supports js/wasm as an OS/ARCH target, so one can write Go code that runs on a webpage, rather than on a server.

TinyGo

It’s a Go compiler optimised for the WebAssembly target (and also for microcontrollers). The key advantage over the official Go compiler is that it generates tiny binaries. Even the Go wiki recommends using TinyGo.

Cheesse

Chess API that tells you, e.g.:

  • given chess board: what are the possible moves?
  • given a chess board and an action: what does the game look like after?
  • given a chess match in some notation: how does the chess board evolve?

What’s the goal?

  • The cheesse API compiles to a wasm binary, exposing the API to JavaScript without needing a server.
  • It is not necessary to maintain a separate version of the code to compile to this target.
  • The generated wasm binary has a reasonable size for use on the Web.

Exposing API to JavaScript

Consider one of cheesse’s basic endpoints:

ParseGame(game InputGame) (OutputGame, error)

You give it a chess game and it spits out all relevant info about it, or an error if input is wrong.

This is how you expose a function onto the JavaScript global scope:

// This should be in a "main_js.go"
func main() {
  js.Global().Set("ParseGame", js.FuncOf(ParseGame))
  select {} // Code must not finish
}

If you don’t do that select{}, or something to that effect, you’ll find this error in the Developer console of your browser:

Error: Go program has already exited

Mapping values from JS to Go and vice-versa

This is the implementation of the ParseGame function defined above:

func ParseGame(this js.Value, args []js.Value) interface{} {
  og, err := api.ParseGame(convertToInputGame(args[0]))
  return js.ValueOf(map[string]interface{}{
    "outputGame": convertOutputGame(og),
    "error": convertError(err),
  })
}

Basically just mapping the API’s inputs and outputs to js/syscall’s interfaces.

ParseGame follows js.FuncOf()’s interface:

(this js.Value, args []js.Value) interface{}

Where args are the arguments supplied to ParseGame from JavaScript.

Here’s a simplified implementation of convertToInputGame:

func convertToInputGame(v js.Value) api.InputGame {
  return api.InputGame{
    DefaultGame: jsBool(v.Get("defaultGame")),
    FENString: jsString(v.Get("fenString")),
  }
}

func  jsBool(j js.Value) bool {
  if j.IsUndefined() || j.IsNull() {
    return  false
  }
 return j.Bool()
}

func  jsString(j js.Value) string {
  if j.IsUndefined() || j.IsNull() {
    return  ""
  }
  return j.String()
}

For convertOutputGame, it’s trickier. Here’s a very simplified OutputGame:

type OutputGame struct {
  FENString string         `json:"fenString"`
  Actions   []OutputAction `json:"actions"`
}

type OutputAction struct {
  FromSquare      string  `json:"fromSquare"`
  ToSquare        string  `json:"toSquare"`
}

How do we map a struct with a slice of structs to something that js.ValueOf can receive? For this, first review the docs:

func ValueOf(x interface{}) Value

ValueOf returns x as a JavaScript value:

Go                    | JavaScript 
----------------------| -----------
js.Value              | [its value]
js.Func               | function   
nil                   | null       
bool                  | boolean    
integers and floats   | number     
string                | string     
[]interface{}         | new array  
map[string]interface{}| new object 

Panics if x is not one of the expected types.

Go struct => JS object, so top-layer we must send a map[string]interface{}.

The trick is: the map’s values (i.e. the interface{} part) follow the same rules from those docs. That’s how we achieve the nesting.

func convertOutputGame(og api.OutputGame) map[string]interface{} {
  return map[string]interface{}{
    "fenString": og.FENString,
    "actions": convertOutputActions(og.Actions),
  }
}

func convertOutputActions(as []api.OutputAction) []interface{} {
  is := make([]interface{}, len(as))
  for i := range as {
    is[i] = convertOutputAction(as[i])
  }
  return is
}

func convertOutputAction(a api.OutputAction) map[string]interface{} {
  return map[string]interface{}{
    "fromSquare": a.FromPieceSquare,
    "toSquare": a.ToSquare,
  }
}

Ok, compile time!

$ GOOS=js GOARCH=wasm go build -o poc/main.wasm
# github.com/marianogappa/cheesse
./main_js.go:39:6: main redeclared in this block
	previous declaration at ./main.go:17:6

Oh, right. We have two main(). We can use build constraints.

Put this on the first line of main.go:

// +build !js

package main

And this on the first line of main_js.go:

// +build js,wasm

package main

Don’t forget to leave a newline between the // and package main. That’s not optional and fails silently.

Also, don’t do +build !js,wasm. That doesn’t work either. There’s only wasm anyway, right?

Ok, compile time! #2

✔ $ GOOS=js GOARCH=wasm go build -o poc/main.wasm
✔ $

🎉

Follow these instructions on how to load the main.wasm into an index.html file.

If you try to open the index.html directly on your browser you’ll get this error on the browser’s Developer Console:

Fetch API cannot load file:///.../poc/main.wasm.
URL scheme must be "http" or "https" for CORS request.

You need to serve the files.

Serving the files

Your favourite one-liner server (is it Python’s SimpleHTTPServer for all of us?), might give you this error:

TypeError: Response has unsupported MIME type

You need a server that knows about WebAssembly’s MIME type.

The easy way:

# install goexec: go get -u github.com/shurcooL/goexec
$ goexec 'http.ListenAndServe(`:8080`, http.FileServer(http.Dir(`.`)))'

If you really want to use SimpleHTTPServer there’s a solution that I validated to work here.

Did it work?

Yes! 🎉

Working Proof of Concept

But there’s a catch:

$ ll -h main.wasm
-rwxr-xr-x  1 marianol  staff   7.6M 28 Mar 20:45 main.wasm

I know the Web is bloated, but 7.6MB is not gonna fly.

Enter TinyGo

Drop-in replacement for the Go compiler, right?

$ tinygo build -o poc/main.wasm -target wasm .
error: requires go version 1.11, 1.12, or 1.13, got go1.14

Ok, that’s not that bad. Go 1.14 is gonna be supported soon, but for now we’ll have to downgrade:

$ brew install go@1.13
...
$ echo 'export PATH="/usr/local/opt/go@1.13/bin:$PATH"' >> ~/.bash_profile
...
$ go version
go version go1.13.9 darwin/amd64

TinyGo, Round 2!

Drop-in replacement for the Go compiler, right?

$ tinygo build -o poc/main.wasm -target wasm .
# encoding/asn1
usr/local/Cellar/go@1.13/1.13.9/libexec/src/encoding/asn1/marshal.go:537:47: v.Type().NumMethod undefined (type reflect.Type has no field or method NumMethod)
usr/local/Cellar/go@1.13/1.13.9/libexec/src/encoding/asn1/marshal.go:549:14: DeepEqual not declared by package reflect
usr/local/Cellar/go@1.13/1.13.9/libexec/src/encoding/asn1/marshal.go:558:14: DeepEqual not declared by package reflect
usr/local/Cellar/go@1.13/1.13.9/libexec/src/encoding/asn1/marshal.go:479:93: t.Field(startingField).Tag.Get undefined (type string has no field or method Get)
usr/local/Cellar/go@1.13/1.13.9/libexec/src/encoding/asn1/marshal.go:483:103: t.Field(i + startingField).Tag.Get undefined (type string has no field or method Get)
...

Oh, God, what’s all this?

Ok, the thing with TinyGo is that there are many stdlib packages that are not yet supported, so if you must use them then you can’t use TinyGo, unfortunately.

https://tinygo.org/lang-support/stdlib/

Some key unsupported packages: encoding/json & net/http (I assume encoding/asn1 is a dependency of net/http).

I was using both. Since I don’t need them for the JS part, the solution is to use a TinyGo-specific build constraint instead of // +build !js:

// +build !tinygo

And vice-versa for main_js.go.

TinyGo, Round 3!

$ tinygo build -o poc/main.wasm -target wasm .
# github.com/marianogappa/cheesse
main_js.go:102:7: j.IsUndefined undefined (type js.Value has no field or method IsUndefined)
main_js.go:102:26: j.IsNull undefined (type js.Value has no field or method IsNull)
main_js.go:95:7: j.IsUndefined undefined (type js.Value has no field or method IsUndefined)
...

Unsupported js/syscall methods?

Nah, this one is not on TinyGo. We’ve downgraded Go to 1.13.9, so those methods don’t exist anymore.

Replace all:

j.IsUndefined()

With:

j == js.Undefined()

TinyGo, Round 4!

$ tinygo build -o poc/main.wasm -target wasm .
# github.com/marianogappa/cheesse/api
/usr/local/Cellar/tinygo/0.12.0/src/sync/pool.go:14:14 unsupported instruction during init evaluation:
  %23 = inttoptr i32 %22 to %runtime._interface (i8*, i8*)*, !dbg !1864

Huh?

Ok, that’s confusing, but it’s something to do with an instruction that runs during an init() that indirectly ends up in sync/pool, that must be unsupported or something.

I don’t use init(), and neither should you. But if you have a var in the global scope (i.e. a global variable) that calls something, then the Go compiler will put that in an init().

You shouldn’t have globals either, but we make some exceptions, and in my case it was like a million error literals like this one:

var errBlackCastle = fmt.Errorf("impossible for black to castle since king has moved")

It turns out fmt.Errorf ends up calling sync/pool. A brilliant workaround for this one is to replace fmt.Errorf with errors.New. This only works if you have nothing to interpolate, but since it’s a global, you probably don’t.

If you have other globals that call things, keep the var, but initialise the value in your main().

TinyGo: Round 5!

$ tinygo build -o poc/main.wasm -target wasm .
inlinable function call in a function with debug info must have a !dbg location
  call void @runtime.nilPanic(i8* undef, i8* null)
inlinable function call in a function with debug info must have a !dbg location
  %352 = call fastcc i1 @"github.com/marianogappa/cheesse/api.boolMatcher$1"(i32 %351, i8* %pack.int217, i8* %341, i8* undef)
inlinable function call in a function with debug info must have a !dbg location
  %353 = call fastcc i1 @"github.com/marianogappa/cheesse/api.intMatcher$1"(i32 %351, i8* %pack.int217, i8* %341, i8* undef)
inlinable function call in a function with debug info must have a !dbg location
  %354 = call fastcc i1 @"github.com/marianogappa/cheesse/api.pieceTypeMatcher$1"(i32 %351, i8* %pack.int217, i8* %341, i8* undef)
error: optimizations caused a verification failure

WAT. Ok, I don’t know. But if you get this, the workaround is to add the -no-debug flag to the compile line.

TinyGo, Round 6!

$ tinygo build -o poc/main.wasm -no-debug -target wasm .
$

OMG, YES! 🎉🎉🎉 Did it work???

Nope. If you get an error in the Developer Console on your browser, perhaps this one:

TypeError: import object field 'wasi_unstable' is not an Object

You need to take into account that every compiler you use, and every version of that compiler might have a different companion wasm_exec.js. For TinyGo, do this:

cp $(tinygo env TINYGOROOT)/targets/wasm_exec.js .

TinyGo, Round 7!

You might do absolutely everything right, get it compiled and then go to your browser’s Developer Console and see this:

panic: syscall/js: Value.Call: property _makeFuncWrapper is not a function, got undefined [wasm_exec.js:201:19](http://localhost:8000/wasm_exec.js)

That one is this issue: https://github.com/tinygo-org/tinygo/issues/716

It might be solved by the time you read this, but the workaround at the time of this writing is to switch to the dev version of TinyGo, which is pretty bleeding edge but seems to work well.

Follow the Source Install instructions and change branch before installing.

Something like:

$ brew install llvm
$ cd /tmp
$ export GO111MODULE=on
$ go get -d -u github.com/tinygo-org/tinygo
$ cd $GOPATH/src/github.com/tinygo-org/tinygo
$ git checkout dev
$ go install

TinyGo, Round 8!

$ tinygo build -o poc/main.wasm -no-debug -target wasm .
$

Before testing on the browser, remember that you’ve changed the version of TinyGo, so you must copy wasm_exec.js again, only this time the file is in a different place because you’ve built from source:

cp $GOPATH/src/github.com/tinygo-org/tinygo/targets/wasm_exec.js .

Did it work???

Working Proof of Concept

OMG, YES! 🎉🎉🎉

One last check

$ ll -h main.wasm
-rwxr-xr-x  1 marianol  staff   392K 29 Mar 00:21 main.wasm

392 KB! That’s an amazing improvement from 7.6MB! 🎉🎉🎉

Conclusion

Writing Go code for the browser is a reality today. But it’s no picnic. Ideally, we’d write whatever we want, run the Go compiler and get a 1kb npm package or something, right? Well, we’ll get there. Still a better love story than writing JavaScript, aye.

auto-play proof of concept

cheesse open source project

Acknowledgements

I didn’t come up with most of the solutions to the issues outlined in this blogpost. This was only possible thanks to the kind and patient help I received from TinyGo’s author Ayke van Laethem and contributors Jaden Weiss and Johan Brandhorst for hours on end at the #tinygo channel on GopherSlack. Also, thanks to Paul Jolly for pointing me in the right direction while getting started with WebAssembly.