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.
Reference in New Issue
Block a user