01a — Hello World Explicit
Same model as example 01, but with everything unbundled. Where 01 uses app.Run() (one call does everything), this example shows the explicit wiring: custom template, separate route handlers, and a service worker for the WASM build.
The model — unchanged
The model is identical to example 01. It lives in model.go and is shared by both server and WASM builds:
func model(app *lofigui.App) {
lofigui.Print("Hello world.")
for i := 0; i < 5; i++ {
app.Sleep(1 * time.Second)
lofigui.Printf("Count %d", i)
}
lofigui.Print("Done.")
}
Explicit routes — setupRoutes()
Instead of app.Run(), all routes are registered explicitly in setupRoutes(), which returns a standard *http.ServeMux:
func setupRoutes(app *lofigui.App) *http.ServeMux {
mux := http.NewServeMux()
// GET / — display current state
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
app.HandleDisplay(w, r)
})
// POST /start — reset buffer, start model, redirect to /
mux.HandleFunc("POST /start", func(w http.ResponseWriter, r *http.Request) {
app.HandleRoot(w, r, model, true)
})
// POST /cancel — cancel running action, redirect to /
mux.HandleFunc("POST /cancel", app.HandleCancel("/"))
mux.HandleFunc("GET /favicon.ico", lofigui.ServeFavicon)
return mux
}
Handle() which auto-starts the model on the first request and auto-shuts down the server when done. Here, HandleRoot starts the model explicitly on POST /start, and HandleDisplay renders the current state on GET /. The model is started by a form submit, not by the first page visit.
Run() registers this internally. Here it's explicit: POST /cancel cancels the running action and redirects to /.
Custom template
The template lives at go/templates/hello.html and is pulled into the Go binary with //go:embed:
import _ "embed"
//go:embed templates/hello.html
var helloTemplate string
Embedding means the same binary works for server and WASM — WASM has no filesystem at runtime, so os.ReadFile wouldn't work, but bytes captured at build time do. The template itself is ordinary html/template:
<nav class="navbar is-primary">
<div class="navbar-brand">
<span class="navbar-item has-text-weight-bold">{{.version}}</span>
</div>
<div class="navbar-end">
{{if eq .polling "Running"}}
<span class="tag is-warning">Running</span>
<form action="cancel" method="post">
<button class="tag is-danger is-light" type="submit">Cancel</button>
</form>
{{else}}
<span class="tag is-success">Ready</span>
{{end}}
</div>
</nav>
<section class="section">
<div class="container content">
{{.results}}
{{if ne .polling "Running"}}
<form action="start" method="post">
<button class="button is-success" type="submit">Start</button>
</form>
{{end}}
</div>
</section>
method="post". No JavaScript needed. The template conditionally shows the Start button when idle and the Cancel button when running.
"Running" while the model is active, "Stopped" when idle. Combined with the HTTP Refresh header (set by HandleDisplay), the page auto-refreshes while running and stops when done.
Server vs WASM: same handlers, different entry points
The key insight: setupRoutes() is called by both builds. Only the entry point differs.
Server (main.go)
func main() {
app := lofigui.NewApp()
app.Version = "Hello World Explicit v1.0"
app.SetDisplayURL("/")
ctrl, _ := lofigui.NewController(lofigui.ControllerConfig{
TemplateString: helloTemplate,
Name: "Hello World Explicit",
})
app.SetController(ctrl)
mux := setupRoutes(app)
log.Fatal(http.ListenAndServe(":1341", mux))
}
WASM (main_wasm.go)
func main() {
app := lofigui.NewApp()
app.Version = "Hello World Explicit v1.0"
app.SetDisplayURL("/")
ctrl, _ := lofigui.NewController(lofigui.ControllerConfig{
TemplateString: helloTemplate,
Name: "Hello World Explicit",
})
app.SetController(ctrl)
mux := setupRoutes(app)
wasmhttp.Serve(mux) // ← only this line differs
}
http.ListenAndServe(). The go-wasm-http-server library registers a service worker that intercepts browser fetch events and routes them through the Go *http.ServeMux running inside WASM. The browser makes real HTTP requests; the service worker answers them from Go.
JavaScript: direct exports vs service worker
This is the fundamental difference between example 01 and 01a's WASM builds:
Example 01 — direct syscall/js exports (app.js)
// Go exports functions to JavaScript via RunWASM():
// goStart(), goRender(), goCancel(), goIsRunning()
// JavaScript actively polls Go for updates:
renderInterval = setInterval(function() {
outputDiv.innerHTML = goRender(); // pull HTML from Go
updateStatus(); // check goIsRunning()
}, 500);
// Button clicks call Go directly:
startBtn.addEventListener('click', function() { goStart(); });
cancelBtn.addEventListener('click', function() { goCancel(); });
Example 01a — service worker (go/templates/sw/)
The sw/ subdirectory holds the whole SW integration — each file is hand-written and visible. The sw.js vendors the go-wasm-http-server runtime as a local wasmhttp_sw.js (not fetched from a CDN) so that Firefox's strict CSP and Enhanced Tracking Protection can't block it:
// sw.js
importScripts('wasm_exec.js');
importScripts('wasmhttp_sw.js'); // local copy, not CDN
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', e => e.waitUntil(clients.claim()));
registerWasmHTTPListener('main.wasm', {
passthrough: function(request) {
var url = new URL(request.url);
if (url.hostname !== self.location.hostname) return true;
if (url.pathname.endsWith('/index.html')) return true;
return false;
}
});
/, the service worker routes it to Go's HandleDisplay. When a form POSTs to /start, the service worker routes it to Go's HandleRoot. No custom JavaScript, no polling logic, no button state management — just standard HTML forms and HTTP semantics.
When to use which
| Approach | Best for | Trade-off |
|---|---|---|
| Direct exports (01) | Simple apps, single-page output, no routing | Requires custom JS per app; JS manages all state |
| Service worker (01a) | Multi-route apps, forms, HTMX | Requires service worker support; slightly larger binary |
The service worker approach scales to complex apps (examples 09-12) because adding a new route is just adding a handler in setupRoutes() — no JS changes needed. The direct export approach stays simpler for single-page demos where you just need goStart() and goRender().
Running
# Server mode
task go-example:01a
# Opens http://localhost:1341 — click Start, watch output, click Cancel
# WASM demo (via docs)
task docs:build-wasm
tp pages
# Navigate to http://localhost:8080/01a_hello_world_explicit/wasm_demo/sw/