Browser App (WebAssembly)

The same App can run inside the browser, compiled to WebAssembly, with no Go process behind it. Only the executor changes: pages, components and state work exactly as they do on the web.

//go:build js && wasm

package main

import (
	"github.com/voilelab/toolgui/toolgui/tgcomp"
	"github.com/voilelab/toolgui/toolgui/tgframe"
	"github.com/voilelab/toolgui/toolgui/tgwasm"
)

func main() {
	app := tgframe.NewApp()
	app.AddPage("index", "Index", func(p *tgframe.Params) error {
		tgcomp.Text(p.Main, "Hello world")
		return nil
	})

	// A static host cannot route paths, so pages live in the hash.
	app.SetHashPageNameMode(true)

	tgwasm.NewExecutor(app).Run()
}

Pages are then linked as /#/{name} — #/index here, not #index. See Page name in the URL.

Run installs the bridge the page talks to and blocks forever, keeping the wasm instance alive to answer it.

tgwasm is behind //go:build js && wasm, so an app that also ships a server build splits its entry points:

page.go          the pages, no build tag
main_server.go   //go:build !(js && wasm)   tgexec.NewWebExecutor(app).StartService("127.0.0.1:3000")
main_wasm.go     //go:build js && wasm      tgwasm.NewExecutor(app).Run()

Building it

toolgui-wasm compiles the app and writes the site around it:

go get -tool github.com/voilelab/toolgui/cmd/toolgui-wasm

go tool toolgui-wasm build -o dist ./cmd/myapp   # the site, into dist/
go tool toolgui-wasm serve ./cmd/myapp           # the same, at :3000

-ldflags goes through to go build, e.g. -ldflags="-X main.version=1.0". -s -w saves little here: wasm keeps most of its size in code, not symbols.

serve is there so the browser gets the binary as application/wasm and can compile it while it downloads; deploying needs no server at all. Neither command deletes anything it did not write, so -o can point at a web root.

The example app

toolgui/tgwasm/example/hello is a runnable version, with three pages, a sidebar textbox, a button and a file the page function reads without it leaving the tab:

task build_wasm_hello   # static site, in toolgui/tgwasm/example/hello/build
task run_wasm_hello     # the same, served at http://localhost:3000

The component demo runs in the browser too — task run_wasm_demo, and it is published beside this book.

What a build produces

build/
├── index.html      the page
├── static/         the frontend bundle and the worker
├── wasm_exec.js    the Go runtime shim
└── app.wasm        your app

Four static files. Any file server serves them; there is no backend. It does have to be an https one, though, or localhost — see the hosting notes.

The CLI does nothing a shell cannot:

GOOS=js GOARCH=wasm go build -o dist/app.wasm ./your/package
cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" dist/
# plus the frontend, which the CLI carries as an embedded copy

wasm_exec.js is copied out of the toolchain that built the binary rather than vendored: the two have to match. That is also why the CLI shells out to go build instead of asking you to run it — one toolchain, both halves.

Where it runs

The Go program runs in a dedicated Web Worker, not on the page's thread, and it has to: the synchronous file handles the file store keeps uploads in exist in a worker and nowhere else. It also never touches the DOM — it sends the same packs the update websocket carries, and the React components on the main thread render them — so a page function that takes a while leaves the UI responsive.

What the browser takes away

  • One session per tab, created on load. A reload starts from an empty state: there is no server to keep it.
  • No filesystem to open by path, and no listening socket. net/http requests become fetch, so CORS applies to whatever your page function calls.
  • GOMAXPROCS is 1. Goroutines interleave, nothing runs in parallel.
  • No timezone database unless the app imports time/tzdata.
  • Uploaded files are kept in the origin private file system rather than the tab's memory, and go with the session. Storage there is per origin and bounded, so an upload can fail for want of room. Getting there is a stream: the worker copies the picked file straight into storage and hands Go a handle on it, so an upload is bounded by the origin's room for it and not by the tab's memory.
  • The binary is public, like any other static asset. No secrets in it.
  • SetManifest and SetAssets are WebExecutor settings, so the browser build does without them. A static site can carry a manifest.json and its own files next to index.html instead.

Hosting notes

  • Serve it over https. Uploads go to the origin private file system, and that belongs to a secure context, so on plain http from anything but localhost there is nowhere to put one and every upload fails. It says so rather than falling back to the tab's memory, which would put the files back where this build stopped keeping them without anyone noticing. GitHub Pages, Netlify and the like are https already.
  • Serve .wasm as application/wasm, so the browser can compile it while it downloads.
  • Compress it. With Go 1.27 the example app is 8.2 MB, 2.2 MB gzipped; the component demo is 10.2 MB, 2.7 MB gzipped. It is cached after the first load, and the page shows how much of it has arrived until then.
  • GitHub Pages gzips it for any browser that asks, which is all of them, so a visitor downloads the gzipped size. DevTools' Network panel shows both: the transferred size is what went over the wire, the resource size is the binary after decompression. Content-Encoding: gzip on the response is how to tell a host does it. A host that does not can serve a precompressed app.wasm.gz or .br instead, if it can set the header.
  • Keep SetHashPageNameMode(true) unless the host can rewrite unknown paths to index.html.
  • The build uses relative asset URLs, so it works at a site root and under a project path like /toolgui/ without rebuilding.

Why not TinyGo

TinyGo makes much smaller binaries, but it cannot build a ToolGUI app. Checked with TinyGo 0.40.1:

  • It supports Go up to 1.25, and this module needs 1.27.
  • Past that, its standard library has no encoding/json/v2 and no uuid, which tgjson, tgframe and tgutil import.

Worth another look when TinyGo catches up with the Go release.