gob fetcher for wasm example
This commit is contained in:
@@ -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
|
||||
|
||||
147
go/cmd/examples/go-wasm-web/app/data.go
Normal file
147
go/cmd/examples/go-wasm-web/app/data.go
Normal 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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -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.
|
||||
|
||||
@@ -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
Binary file not shown.
66
go/httputil/fetch.go
Normal file
66
go/httputil/fetch.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user