alltom.com

Thoughts On: Go for Web Programming

I haven't written straight JavaScript in years. I use Go for everything, including DOM manipulation, integrating with audio libraries and TensorFlow models, and using graphics libraries like Three.js.

This page explains how.

Pros and Cons of Go for web programming

The good: Go is a great glue language, and most web programming is gluing things together. Even though Go doesn't have a complicated type system, most code I write works the first time. Go compiles to every architecture and platform that I've ever cared about, including web browsers, so I can usually reuse my packages regardless of where the code runs.

On the other hand, the syscall/js syntax is annoyingly verbose, particularly when it comes to callbacks, and particularly when those callbacks are used with Promises. I mitigate these issues by isolating usage of syscall/js in shared packages, as you might for any other dependency.

The motivation for me to write this article is that I feel very productive writing web sites with Go and I want to share that secret superpower with you. However, the point of writing this article is to make writing Go on the web easy by showing you the hard parts up front. Since I try to illustrate every roadblock with as little code as possible, it might look like Go web development is all roadblocks, but that's not true. Please don't be discouraged! It gets easier as your code base scales.

Scaffolding

There's one entry point for the server, and one for the code that runs in the browser. It just takes these four files to get off the ground:

Inititalize the project by running go mod init yourproject in the project/ directory. This is good Go hygeine these days; it creates a file in your project directory that tracks version numbers and hashes of all your dependencies.


project/server/main.go

Until you add more server-side features, project/server/main.go just serves the static files in the public/ directory:

package main

import (
	"flag"
	"net/http"
)

var httpAddress = flag.String("http_address", "localhost:8080", "Address for serving HTTP")

func main() {
	http.Handle("/", http.FileServer(http.Dir("public")))
	http.ListenAndServe(*httpAddress, nil)
}

project/server/public/index.html

index.html just needs to invoke the compiled Go code:

<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>yourproject</title>

<script src="wasm_exec.js"></script>
<script>
if (!WebAssembly.instantiateStreaming) {
	// Polyfill, required for Safari.
	WebAssembly.instantiateStreaming = async (resp, importObject) => {
		const source = await (await resp).arrayBuffer();
		return await WebAssembly.instantiate(source, importObject);
	};
}

const go = new Go();
WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject).then((result) => {
	go.run(result.instance);
});
</script>

The section below about generate.go explains where wasm_exec.js and main.wasm come from.


project/browser/main.go

This is the entry-point for the Go code that runs in the browser, including some examples of how to access global JavaScript objects (which is the same for document.body and the rest of the DOM):

// +build js,wasm

package main

import (
	"syscall/js"
)

func main() {
	js.Global().Get("console").Call("log", "Hello, World!")

	var cb js.Func
	cb = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
		js.Global().Call("alert", "Hello, World!")
		cb.Release()
		return nil
	})
	js.Global().Call("setTimeout", cb, 2000)

	<-(make(chan bool))
}

I told you the syscall/js syntax was awful.

I end with <-(make(chan bool)) because otherwise, the Go program exits, and any DOM callbacks you've registered (click handlers, timers, etc) that call into Go code will fail.


project/browser/generate.go

index.html refers to two files that you don't write by hand: wasm_exec.js ships with Go, and main.wasm is compiled from project/browser/main.go.

I put go:generate directives in generate.go, which allows you to run go generate to put both files into the public/ directory:

package main

//go:generate cp $GOROOT/misc/wasm/wasm_exec.js ../server/public/
//go:generate env GOOS=js GOARCH=wasm go build -o ../server/public/main.wasm

Development

Start the server in one terminal:

$ cd project/server/
$ go run main.go

Re-compile the code that runs in the browser whenever you make a change:

$ cd project/browser/
$ go generate

View your new, fancy web site by visiting http://localhost:8080/

Miscellaneous notes

foo == null

When I wrote JavaScript, I avoided having to care about the difference between JavaScript's null and undefined by always coercing them to the same value with ==. For example, foo == null is true regardless of whether foo is null or undefined.

That option isn't available with Go's js.Null() and js.Undefined(), so be prepared to care about the difference, or write a helper.

Promises and async functions

As you saw above, Go functions that are used as JavaScript callbacks are not automatically garbage-collected and have elaborate syntax. That makes Promises and async functions hard to deal with.

So I use this helper to convert Promises to idiomatic Go return values:

func UnwrapPromise(promise js.Value) (js.Value, error) {
	retc := make(chan js.Value)
	errc := make(chan error)

	var release func()
	success := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
		retc <- args[0]
		release()
		return nil
	})
	failure := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
		errc <- fmt.Errorf("%v", args[0])
		release()
		return nil
	})
	release = func() {
		success.Release()
		failure.Release()
	}

	promise.Call("then", success).Call("catch", failure)
	select {
	case ret := <-retc:
		return ret, nil
	case err := <-errc:
		return js.Undefined(), err
	}
}