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 static 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 docPage("Rendering", "Data fetching", "Fetching happens in the browser, so a server-rendered page ships its LOADING state and the "+ "client fills it in. Two shapes are shown here: gob against your own server, where the same "+ "Go type crosses the wire untranslated, and JSON against somebody else's API.", docSection("gob", "gob — the same Go type on both ends", prose("Your server already speaks Go and so does your client, so there is no reason to translate "+ "through JSON in between. The handler answers with httputil.RespondGob([]Quote) and the "+ "client decodes straight back into []Quote — one type, declared once, with no tags and no "+ "hand-written unmarshalling to drift out of sync with it."), code("app/data.go + server/main.go", gobSnippet), demo("GET /api/quotes, decoded into []Quote", quotesBody(qLoading.Get(), qErr.Get(), quotes.Get()), ), ), docSection("json", "JSON — for everyone else's API", prose("A third-party API does not speak gob, so httputil.FetchJSON decodes into a tagged struct "+ "the ordinary way. Enter a repository and the browser calls api.github.com directly."), demo("GET api.github.com/repos/…, decoded into a tagged struct", 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()), ), ), docSection("ssr", "What the server renders", prose("This route is static, so the server pre-renders it — but there is no fetch on the server: "+ "no transport is installed there, and inventing one would mean the server quietly making "+ "requests on the user's behalf. So a fetch started during SSR does nothing at all, the page "+ "renders its spinner, and the client runs the fetch for real once it has hydrated."), note("A fetch that fails on the server is a bug in the framework, not in your page", "An earlier version of this returned an error from SSR, and every static page that fetched "+ "anything rendered \"no client transport installed\" into its own HTML. Loading is the "+ "correct server-side answer to \"have you fetched this yet?\"."), apiTable( apiRow{"httputil.RespondGob", "Server: write a Go value as gob."}, apiRow{"httputil.FetchGob", "Client: decode a gob response into a Go value."}, apiRow{"httputil.FetchJSON", "Client: decode a JSON response into a tagged struct."}, apiRow{"httputil.SetClientTransport", "Override the transport — a base URL, auth headers. The runtime installs a fetch-based one for you."}, ), ), ) } } const gobSnippet = `// One type. Both ends. No tags, no JSON. type Quote struct { Author string Text string } // --- server --- mux.HandleFunc("GET /api/quotes", func(w http.ResponseWriter, r *http.Request) { httputil.RespondGob(w, http.StatusOK, sampleQuotes()) // []Quote }) // --- client --- httputil.FetchGob("/api/quotes", func(qs []Quote, err error) { if err != nil { qErr.Set(err.Error()); return } quotes.Set(qs) // []Quote })` 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-ink"), Text("“"+q.Text+"”")), P(Attr("class", "mt-2 text-sm text-ink-muted"), 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-ink"), Text(r.FullName)), ui.Badge(ui.BadgeProps{Color: ui.BadgeAmber}, Text("★ "+strconv.Itoa(r.Stars))), ), P(Attr("class", "mt-2 text-sm text-ink-soft"), Text(r.Description)), ) } }