gob fetcher for wasm example

This commit is contained in:
2026-07-13 02:24:06 -04:00
parent b02df48a66
commit 93e36c9abc
13 changed files with 339 additions and 6 deletions

View File

@@ -16,10 +16,19 @@ go run ./server # codegen + SSR + hot reload at http://localhost:8085
```
Open http://localhost:8085. `/` and `/about` use the light **public** layout;
`/chart`, `/server`, and **`/kit`** (a UI-kit "kitchen-sink" demo of the webui
components) use the dark **app** layout. Edit any `.go` file and the browser
hot-swaps the new wasm **without a full reload or a flash**, preserving page
state; a build failure shows the Go compiler output as an overlay.
`/chart`, `/server`, **`/data`** (client-side fetching), and **`/kit`** (a UI-kit
"kitchen-sink" demo of the webui components) use the dark **app** layout. Edit
any `.go` file and the browser hot-swaps the new wasm **without a full reload or
a flash**, preserving page state; a build failure shows the Go compiler output
as an overlay.
**Data fetching** (`/data`) shows both directions of `kjol/httputil`: the server
answers `/api/quotes` with `httputil.RespondGob([]Quote)` and the client decodes
it straight back into `[]Quote` with `httputil.FetchGob` (the same Go type on
both ends — no JSON); and a **user-entered** GitHub repo (`owner/name`) is
fetched with `httputil.FetchJSON` into a tagged Go struct. The client HTTP
transport is `wasmruntime.FetchBytes`, injected once via
`httputil.SetClientTransport`.
Styling is **Tailwind**: the dev server (and `build.sh`) run `kjol/cmd/twcss`,
which scans the Go markup + the `webui` kit for utility classes and compiles
@@ -41,6 +50,7 @@ app/ the application — neutral, standalone functions (no centra
pages.go Deps + Shell + App/Public layouts + nav + Counter + pages
chart.go Chart page (go-chart, renders on both sides)
kit.go /kit — UI-kit demo page showcasing kjol/webui components
data.go /data — client fetch: gob from /api/quotes + third-party JSON
server_counter.go //gowasm:server component (server-only; clicks-over-time chart)
*.gen.go GENERATED by kjol/cmd/wasmgen (routes, layout dispatch, stubs)
css/app.css Tailwind entry (@import "tailwindcss" + @theme tokens)
@@ -57,6 +67,7 @@ wwwroot/ wasmboot.js (+ generated app.css, wasm_exec.js, app.wasm)
| `kjol/wasmruntime` | wasm client runtime: reconcile, `Run`/`Hydrate`, router, fetch, HMR state | `wasm/main.go` calls `Hydrate`/`Run` |
| `kjol/rsc` | stateless server components over HTTP (gob) | `//gowasm:server` + the generated client stub |
| `kjol/wasmdevserver` | reusable dev server: SSR, `/rsc`, hot reload, error overlay | `server/main.go` fills a `wasmdevserver.Config` |
| `kjol/httputil` | gob/JSON responders + typed client fetch (`RespondGob`, `FetchGob`, `FetchJSON`) | the `/data` page + the `/api/quotes` handler |
| `kjol/cmd/wasmgen` | directive codegen → `app/*.gen.go` | run by `buildWasm` and `//go:generate` |
The **golden rule** holds: `wasmdevserver` imports no app code. The app injects

View File

@@ -0,0 +1,147 @@
package app
import (
"strconv"
"strings"
"kjol/httputil"
. "kjol/vdom"
ui "kjol/webui"
)
// Quote is the payload the /api/quotes endpoint returns. The server encodes a
// []Quote with httputil.RespondGob; the client decodes it straight back into
// []Quote — the SAME Go type, no JSON, no hand-written unmarshalling.
type Quote struct {
Author string
Text string
}
// repoInfo is a subset of GitHub's repo JSON (a third-party API), tagged for
// json decoding.
type repoInfo struct {
FullName string `json:"full_name"`
Description string `json:"description"`
Stars int `json:"stargazers_count"`
}
//gowasm:page /data layout=app
func DataPage(d Deps) func() *VNode {
// (1) gob from our own server via httputil.RespondGob / FetchGob.
quotes := NewSignal([]Quote{})
qLoading := NewSignal(true)
qErr := NewSignal("")
// (2) JSON from a third-party API (GitHub), for a user-entered repo.
repo := NewSignal(repoInfo{})
rLoading := NewSignal(true)
rErr := NewSignal("")
repoQuery := NewSignal("golang/go")
started := false
// fetchRepo loads owner/name from the GitHub API into the repo signal.
fetchRepo := func(q string) {
q = strings.Trim(strings.TrimSpace(q), "/")
if q == "" {
rErr.Set("enter a repo as owner/name")
rLoading.Set(false)
return
}
rErr.Set("")
rLoading.Set(true)
httputil.FetchJSON("https://api.github.com/repos/"+q, func(r repoInfo, err error) {
if err != nil {
rErr.Set(err.Error())
} else {
repo.Set(r)
}
rLoading.Set(false)
})
}
return func() *VNode {
// Fire the initial fetches once, on the client (no transport on the server,
// so SSR ships the loading state and the client takes over).
if !started {
started = true
httputil.FetchGob("/api/quotes", func(qs []Quote, err error) {
if err != nil {
qErr.Set(err.Error())
} else {
quotes.Set(qs)
}
qLoading.Set(false)
})
fetchRepo(repoQuery.Get())
}
return Div(Attr("class", "space-y-8"),
Div(
H2(Attr("class", "text-2xl font-semibold tracking-tight text-text-heading"), Text("Data fetching")),
P(Attr("class", "mt-1 text-neutral-500"), Text("Two client-side fetches: gob from our own server, and JSON from a third-party API you choose.")),
),
ui.Card("",
ui.CardHeader("", Text("gob — from our server")),
P(Attr("class", "mb-3 text-sm text-neutral-500"),
Text("The client GETs /api/quotes; the server responds with httputil.RespondGob "+
"(a gob-encoded []Quote) and httputil.FetchGob decodes it straight into []Quote — "+
"the same Go type on both ends, no JSON.")),
quotesBody(qLoading.Get(), qErr.Get(), quotes.Get()),
),
ui.Card("",
ui.CardHeader("", Text("JSON — from a third-party API")),
P(Attr("class", "mb-3 text-sm text-neutral-500"),
Text("Enter a GitHub repo; the client GETs api.github.com and httputil.FetchJSON "+
"decodes the response into a Go struct with `json:\"…\"` tags.")),
row("mb-4 flex items-end gap-2",
row("flex grow flex-col gap-1 max-w-sm",
ui.FormLabel(ui.FormLabelProps{}, Text("GitHub repo (owner/name)")),
ui.FormInput(ui.FormInputProps{
Value: repoQuery.Get(),
Placeholder: "golang/go",
OnInput: func(v string) { repoQuery.Set(v) },
}),
),
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Fetch", OnClick: func() { fetchRepo(repoQuery.Get()) }}),
),
repoBody(rLoading.Get(), rErr.Get(), repo.Get()),
),
)
}
}
func quotesBody(loading bool, failed string, quotes []Quote) *VNode {
switch {
case failed != "":
return ui.Alert(ui.AlertRed, "Fetch failed", Text(failed))
case loading:
return ui.Loader()
default:
cards := make([]*VNode, 0, len(quotes))
for _, q := range quotes {
cards = append(cards, ui.BorderCard("",
P(Attr("class", "text-neutral-800"), Text("“"+q.Text+"”")),
P(Attr("class", "mt-2 text-sm text-neutral-500"), Text("— "+q.Author)),
))
}
return row("grid gap-3 sm:grid-cols-2", cards...)
}
}
func repoBody(loading bool, failed string, r repoInfo) *VNode {
switch {
case failed != "":
return ui.Alert(ui.AlertRed, "Fetch failed", Text(failed))
case loading:
return ui.Loader()
default:
return ui.BorderCard("",
row("flex items-center gap-2",
Strong(Attr("class", "text-neutral-800"), Text(r.FullName)),
ui.Badge(ui.BadgeProps{Color: ui.BadgeAmber}, Text("★ "+strconv.Itoa(r.Stars))),
),
P(Attr("class", "mt-2 text-sm text-neutral-600"), Text(r.Description)),
)
}
}

View File

@@ -81,6 +81,7 @@ func AppLayout(d Deps, content *VNode) *VNode {
Ul(Attr("class", "ml-4 flex items-center gap-1"),
navItem(d, "/chart", "Chart", true),
navItem(d, "/server", "Server", true),
navItem(d, "/data", "Data", true),
navItem(d, "/kit", "UI Kit", true)),
Ul(Attr("class", "ml-auto flex items-center"),
navItem(d, "/", "Home", true)),

View File

@@ -9,6 +9,7 @@ func Routes(d Deps) map[string]func() *vdom.VNode {
"/": HomePage(d),
"/about": AboutPage(d),
"/chart": ChartPage(d),
"/data": DataPage(d),
"/kit": KitPage(d),
"/server": ServerPage(d),
}
@@ -26,6 +27,7 @@ var RouteLayout = map[string]string{
"/": "public",
"/about": "public",
"/chart": "app",
"/data": "app",
"/kit": "app",
"/server": "app",
}

View File

@@ -12,10 +12,12 @@ package main
import (
"flag"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"kjol/httputil"
"kjol/vdom"
"kjol/wasmdevserver"
@@ -35,9 +37,28 @@ func main() {
Build: buildWasm,
Render: render,
Document: document,
Handle: apiRoutes,
}))
}
// apiRoutes registers the example's API endpoints. /api/quotes responds with a
// gob-encoded []app.Quote (via httputil.RespondGob) — the /data page fetches and
// decodes it on the client with encoding/gob (Go types end to end, no JSON).
func apiRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/quotes", func(w http.ResponseWriter, r *http.Request) {
httputil.RespondGob(w, http.StatusOK, sampleQuotes())
})
}
func sampleQuotes() []app.Quote {
return []app.Quote{
{Author: "Rob Pike", Text: "A little copying is better than a little dependency."},
{Author: "Rob Pike", Text: "Don't communicate by sharing memory; share memory by communicating."},
{Author: "Ken Thompson", Text: "When in doubt, use brute force."},
{Author: "Alan Kay", Text: "The best way to predict the future is to invent it."},
}
}
// render SSRs a static route's #app inner HTML; ok=false ships an empty #app
// (client-rendered). It's the same neutral render the client runs, so the client
// hydrates it.

View File

@@ -6,6 +6,7 @@ package main
import (
"gowasmweb/app"
"kjol/httputil"
"kjol/vdom"
"kjol/wasmruntime"
)
@@ -15,6 +16,7 @@ func main() {
// across an in-place hot swap. On a normal load RestoreState is nil (fresh
// state); after a dev hot-reload it carries the previous instance's values,
// which NewSignal restores by creation order.
httputil.SetClientTransport(wasmruntime.FetchBytes) // typed gob/json fetches for app pages
collector := vdom.BeginCollect(wasmruntime.RestoreState())
router := wasmruntime.NewRouter()
deps := app.Deps{Path: router.Path, Navigate: router.Navigate}

File diff suppressed because one or more lines are too long

66
go/httputil/fetch.go Normal file
View File

@@ -0,0 +1,66 @@
package httputil
import (
"bytes"
"encoding/gob"
"encoding/json"
"errors"
)
// Client-side fetch helpers — the counterparts to RespondGob / RespondJSON. In a
// gowasm app the server and the WebAssembly client share the same Go types, so
// the server encodes with RespondGob(data) and the client decodes straight back
// into that type with FetchGob — no JSON, no hand-written unmarshalling. These
// are neutral (they only touch encoding/*), so app code can call them while
// staying SSR-able; the actual HTTP transport is client-only and injected at
// startup by the wasm runtime (SetClientTransport), the inversion vdom.Schedule
// uses.
// clientTransport does an HTTP GET returning the raw body; nil on the server.
var clientTransport func(url string) ([]byte, error)
// SetClientTransport installs the HTTP GET used by FetchGob / FetchJSON. The
// wasm client runtime calls this at startup with wasmruntime.FetchBytes.
func SetClientTransport(get func(url string) ([]byte, error)) { clientTransport = get }
func fetchInto[T any](url string, cb func(T, error), decode func([]byte, *T) error) {
go func() {
var out T
if clientTransport == nil {
cb(out, errors.New("httputil: no client transport installed (client-only)"))
return
}
body, err := clientTransport(url)
if err != nil {
cb(out, err)
return
}
if err := decode(body, &out); err != nil {
cb(out, err)
return
}
cb(out, nil)
}()
}
// FetchGob GETs url and gob-decodes the body into T (pair with RespondGob), then
// calls cb(result, err) on a background goroutine — safe to call from a render
// or event handler; set the result into a signal in the callback. T is inferred
// from the callback:
//
// httputil.FetchGob("/api/quotes", func(qs []Quote, err error) {
// if err != nil { /* … */ } else { quotes.Set(qs) }
// })
func FetchGob[T any](url string, cb func(T, error)) {
fetchInto(url, cb, func(b []byte, out *T) error {
return gob.NewDecoder(bytes.NewReader(b)).Decode(out)
})
}
// FetchJSON GETs url and json-decodes the body into T — for third-party JSON
// APIs (or your own RespondJSON). Same callback/goroutine shape as FetchGob.
func FetchJSON[T any](url string, cb func(T, error)) {
fetchInto(url, cb, func(b []byte, out *T) error {
return json.Unmarshal(b, out)
})
}

View File

@@ -1,6 +1,7 @@
package httputil
import (
"encoding/gob"
"encoding/json"
"net/http"
)
@@ -11,6 +12,12 @@ func RespondJSON(w http.ResponseWriter, statusCode int, data any) {
json.NewEncoder(w).Encode(data)
}
func RespondGob(w http.ResponseWriter, statusCode int, data any) {
w.Header().Set("Content-Type", "application/gob")
w.WriteHeader(statusCode)
gob.NewEncoder(w).Encode(data)
}
func RespondError(w http.ResponseWriter, statusCode int, message string) {
RespondJSON(w, statusCode, map[string]string{"error": message})
}

View File

@@ -40,6 +40,7 @@ type Config struct {
Build func() ([]byte, error) // (re)build the wasm bundle; combined output on failure
Render func(path string) (inner string, ok bool) // SSR the #app inner HTML for a route (ok=false => client-rendered)
Document func(inner string) string // wrap #app inner HTML in a full HTML document
Handle func(mux *http.ServeMux) // optional: register extra routes (e.g. app API endpoints)
}
// Serve builds once (in watch mode), wires the routes, and blocks serving.
@@ -70,6 +71,9 @@ func Serve(cfg Config) error {
}
mux.HandleFunc("POST /rsc", rsc.Handler) // server components
if cfg.Handle != nil {
cfg.Handle(mux) // app-registered routes (API endpoints, etc.)
}
mux.HandleFunc("/", rootHandler(cfg))
log.Printf("serving %q on http://localhost%s", cfg.Dir, cfg.Addr)

View File

@@ -5,10 +5,22 @@ package wasmruntime
import (
"errors"
"fmt"
"strings"
"sync"
"syscall/js"
)
// absURL resolves a relative path against the current origin (a browser resolves
// relative fetch URLs itself, but building the absolute URL keeps behavior
// uniform — and lets non-browser hosts, e.g. tests, fetch too).
func absURL(url string) string {
if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") {
return url
}
loc := js.Global().Get("location")
return loc.Get("protocol").String() + "//" + loc.Get("host").String() + url
}
// Fetch performs an HTTP GET via the browser Fetch API and returns the response
// body as a string. It bridges a JS Promise into Go: it registers then/catch
// callbacks and blocks the calling goroutine on a channel until the request
@@ -67,7 +79,7 @@ func Fetch(url string) (string, error) {
return nil
})
js.Global().Call("fetch", url).Call("then", onResp).Call("catch", onErr)
js.Global().Call("fetch", absURL(url)).Call("then", onResp).Call("catch", onErr)
<-done
onResp.Release()
@@ -75,3 +87,63 @@ func Fetch(url string) (string, error) {
onErr.Release()
return body, ferr
}
// FetchBytes is like Fetch but returns the raw response body as bytes (via
// resp.arrayBuffer()), for binary payloads such as gob (Fetch's resp.text()
// would corrupt non-UTF-8 data). It blocks the calling goroutine the same way —
// call it from a goroutine and write the result into signals when it returns.
func FetchBytes(url string) ([]byte, error) {
var (
once sync.Once
done = make(chan struct{})
body []byte
ferr error
onResp, onBuf, onErr js.Func
)
finish := func() { once.Do(func() { close(done) }) }
onErr = js.FuncOf(func(this js.Value, args []js.Value) any {
msg := "fetch error"
if len(args) > 0 && args[0].Truthy() {
e := args[0]
if m := e.Get("message"); m.Truthy() {
msg = m.String()
} else {
msg = e.Call("toString").String()
}
}
ferr = errors.New(msg)
finish()
return nil
})
onBuf = js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) > 0 {
arr := js.Global().Get("Uint8Array").New(args[0])
b := make([]byte, arr.Get("length").Int())
js.CopyBytesToGo(b, arr)
body = b
}
finish()
return nil
})
onResp = js.FuncOf(func(this js.Value, args []js.Value) any {
resp := args[0]
if !resp.Get("ok").Bool() {
ferr = fmt.Errorf("HTTP %d", resp.Get("status").Int())
finish()
return nil
}
resp.Call("arrayBuffer").Call("then", onBuf).Call("catch", onErr)
return nil
})
js.Global().Call("fetch", absURL(url)).Call("then", onResp).Call("catch", onErr)
<-done
onResp.Release()
onBuf.Release()
onErr.Release()
return body, ferr
}