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, a draft
kept across reloads 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
├── manifest.json the web app manifest
├── sw.js the service worker, with -offline
├── static/ the frontend bundle and the worker
├── wasm_exec.js the Go runtime shim
└── app.wasm your app
A handful of 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.
Web manifest
The browser reads manifest.json before any Go runs, so the app cannot set it
from main. toolgui-wasm writes it instead:
go tool toolgui-wasm build -manifest manifest.json -assets assets ./cmd/myapp
-manifestis a json file, written asmanifest.json. It takes the same memberstgexec.Manifestdoes,"icons"and all.-assetsis a directory, copied toassets/.assets/icon.pngin it is"src": "assets/icon.png"in the manifest.- Without
-manifest, a build writestgexec.DefaultManifest(), unless the output already has amanifest.jsonof its own, which it keeps.
{
"name": "My Tool",
"short_name": "My Tool",
"start_url": ".",
"display": "standalone",
"icons": [
{"src": "assets/icon.png", "type": "image/png", "sizes": "512x512"}
]
}
Keep the urls relative, so the site still works under a project path.
iOS reads no manifest icons, so -manifest also writes the head tags it
reads instead:
apple-touch-icon: the png in"icons"closest to 180px. iOS takes no svg, so a manifest with no png gets none, and the build warns.apple-mobile-web-app-title:"short_name".theme-color:"theme_color", in place of the default.
A tag a -head file already has is left to it.
Icon
-icon points the favicon in index.html at a url, so the tab, bookmarks
and crawlers see it before any wasm loads. Ship the file with -assets:
go tool toolgui-wasm build -assets assets -icon assets/favicon.svg ./cmd/myapp
Once the app loads, the frontend sets the icon again from the app's config:
the page emoji, unless the app calls
app.SetIcon. Set both to the same url to keep one
icon throughout.
Head HTML
Crawlers run no wasm, so what they read has to be in index.html itself:
Open Graph or Twitter card meta, fonts, a CSP meta, analytics tags.
App.SetTitle runs too late for them. -head inserts a file at the
start of the head, right after the charset meta, so a CSP meta covers the links
after it:
go tool toolgui-wasm build -head head.html ./cmd/myapp
<meta property="og:title" content="My Tool" />
<meta property="og:image" content="https://example.com/assets/og.png" />
The server executor does the same with
SetHeadHTML.
Opening offline
-offline adds a service worker, so the app opens with no network once it has
been loaded once:
go tool toolgui-wasm build -offline -manifest manifest.json -o dist ./cmd/myapp
- The worker keeps a copy of the files the build wrote and serves it when the network fails. Online it gets out of the way: every load goes to the server and refreshes the copy, so a new build shows up on the next load, as it would without a worker.
- A build without
-offlineremoves the worker on its first load, along with its copy. - Only the site's own files are kept. What a page function fetches still needs the network, and the state is still the tab's: offline or not, a reload starts from an empty one.
- Service workers need a secure context too.
A large runtime the app loads on demand, such as Pyodide, would make every
install download it. Ship it with -lazy-assets instead:
go tool toolgui-wasm build -offline -lazy-assets runtime -o dist ./cmd/myapp
- It is copied to
assets/, like-assets; a path both write is an error. - The worker does not fetch it on install. A file is kept the first time the page fetches it, and works offline from then on; one never fetched still needs the network.
- A new build drops the kept files with the rest of the old copy.
- Without
-offlineit is a plain copy.
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.
Talking to the page
A worker's location is its script, not the page's URL, so what an app needs to know about the page is handed over at boot:
tgwasm.Query()is the page's query string, for an app that takes its own parameters such as?lang=en. Read once: it cannot change without a reload.tgwasm.Embedded()reports?embed, the frontend's display mode for a page shown in a frame, for an app laid out differently there.
The other way, tgwasm.Emit(name, detail) dispatches a toolgui:<name> DOM
event on the page's window, with detail sent as JSON. It reaches that tab
only, and is for a script of the page's own, such as analytics added with
-head:
tgwasm.Emit("download", map[string]any{"tool": "novel"})
addEventListener('toolgui:download', (e) => umami.track('download', e.detail))
Emit returns tgwasm.ErrNoEventCallback until the frontend has registered
for events, which it does at boot, before the first page runs.
Keeping data across reloads
A reload starts from an empty state. Data that must outlive it goes in a store, kept in the origin private file system:
var store = tgwasm.OpenStore("judge")
type Draft struct{ Lang, Code string }
func Index(p *tgframe.Params) error {
draft, err := tgwasm.GetJSON[Draft](store, "draft/0004")
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
...
return tgwasm.SetJSON(store, "draft/0004", draft)
}
- Only the wasm build has it. A server build stores data under its own login, in its own database.
- A Go global is still fine for what only needs to last until a reload: in a tab it belongs to that tab alone. The store is for the rest.
OpenStorecan be called frommain, but read the store in a page run. Its methods returnErrBeforeRununtilRuninstalls the bridge.- The first tab owns the store. A second tab opens a read-only snapshot, where
SetJSONreturnsErrReadOnly. Checkstore.ReadOnly()to tell the user the app is already open in another tab. - The browser may clear the data under storage pressure. Call
navigator.storage.persist()from the page, for example in a-headscript, if the app needs it kept.
The example app's Echo page keeps its text this way. How it works is in Persistent Store.
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. See Keeping data across reloads.
- No filesystem to open by path, and no listening socket.
net/httprequests becomefetch, so CORS applies to whatever your page function calls. GOMAXPROCSis 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.
SetManifestandSetAssetsareWebExecutorsettings. The browser build takes the same things as build flags instead; see Web manifest.
Hosting notes
- Serve it over
https. Uploads go to the origin private file system, and that belongs to a secure context, so on plainhttpfrom anything butlocalhostthere 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 arehttpsalready. - Serve
.wasmasapplication/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: gzipon the response is how to tell a host does it. A host that does not can serve a precompressedapp.wasm.gzor.brinstead, if it can set the header. - Keep
SetHashPageNameMode(true)unless the host can rewrite unknown paths toindex.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/v2and nouuid, whichtgjson,tgframeandtgutilimport.
Worth another look when TinyGo catches up with the Go release.