main.go
// Command hn is the desktop/web entry point for the HN app. The widget tree,
// Root, and Config live in the importable examples/hn/ui package, keeping the
// entry point trivial and the UI easy to test and reuse.
package main
import (
"log"
"github.com/doug/gophics/app"
"github.com/doug/gophics/examples/hn/ui"
)
func main() {
if err := app.Run(ui.Root(), ui.Config()); err != nil {
log.Fatal(err)
}
}
mobile/hnmobile.go
// Package hnmobile is the gomobile-bind surface for the hn app.
//
// It holds only what is hn's own, which is nothing but building the tree.
// Everything generic — the frame loop, input, lifecycle, accessibility — is on
// shell/mobile.Bridge, which the CLI binds alongside this package, so a host
// calls those methods on the Bridge that Start returns.
//
// gomobile cannot bind package main and carries only a restricted vocabulary
// across the boundary, so this thin adapter is what a host talks to.
package hnmobile
import (
"github.com/doug/gophics/app"
"github.com/doug/gophics/shell/mobile"
hn "github.com/doug/gophics/examples/hn/ui"
)
// Start builds the app and returns the bridge the host drives it through.
//
// Call it once, before anything else. On failure it returns a nil bridge and
// the error to show — two results because the second is an error, which is the
// one shape gomobile allows.
func Start() (*mobile.Bridge, error) {
h, err := app.NewHandler(hn.Root(), hn.Config())
if err != nil {
return nil, err
}
return mobile.NewBridge(h), nil
}
ui/api.go
package ui
import (
"context"
"encoding/json"
"fmt"
"html"
"strings"
"time"
"github.com/doug/gophics/fetch"
"github.com/doug/gophics/layout"
"github.com/doug/gophics/paint"
)
// Item is a HackerNews item (story or comment) from the Firebase API.
type Item struct {
ID int `json:"id"`
Type string `json:"type"`
By string `json:"by"`
Title string `json:"title"`
Text string `json:"text"`
URL string `json:"url"`
Score int `json:"score"`
Descendants int `json:"descendants"`
Kids []int `json:"kids"`
Time int64 `json:"time"`
}
// API is the data source; Live hits the real Firebase endpoints, tests use
// a fixture implementation.
type API interface {
TopStories(ctx context.Context) ([]int, error)
Item(ctx context.Context, id int) (Item, error)
}
const apiTimeout = 15 * time.Second
type liveAPI struct{}
func newLiveAPI() *liveAPI { return &liveAPI{} }
// get fetches url and decodes the JSON into v.
//
// gophics/fetch rather than net/http directly, because this app is also built
// for the browser and net/http costs 2MB of gzipped wasm there for a socket
// path that cannot run — see that package. The timeout is the caller's
// context, so it is visible here rather than buried in a client.
func (a *liveAPI) get(ctx context.Context, url string, v any) error {
ctx, cancel := context.WithTimeout(ctx, apiTimeout)
defer cancel()
b, err := fetch.Get(ctx, url)
if err != nil {
return err
}
return json.Unmarshal(b, v)
}
func (a *liveAPI) TopStories(ctx context.Context) ([]int, error) {
var ids []int
err := a.get(ctx, "https://hacker-news.firebaseio.com/v0/topstories.json", &ids)
return ids, err
}
func (a *liveAPI) Item(ctx context.Context, id int) (Item, error) {
var it Item
err := a.get(ctx, fmt.Sprintf("https://hacker-news.firebaseio.com/v0/item/%d.json", id), &it)
return it, err
}
// Comment is a flattened comment with its nesting depth.
type Comment struct {
Item
Depth int
}
// loadComments fetches a story's comment tree, up to limit comments.
func loadComments(ctx context.Context, api API, story Item, limit int) []Comment {
return streamComments(ctx, api, story, limit, nil)
}
// streamComments fetches breadth-first and assembles depth-first.
//
// Those are two different orders on purpose. Reading order is depth-first — a
// comment followed by its replies, indented — but *fetching* in that order
// means descending one deep reply chain before the second top-level comment is
// even requested, over 80 serial round trips. Fetching by level instead makes
// every id at one depth a single concurrent batch, so the top-level comments,
// which are what the reader actually wants first, land in one trip.
//
// onProgress, when non-nil, is called after each level with the tree as
// assembled so far, so the thread fills in from the top instead of showing a
// spinner until the last reply arrives.
func streamComments(ctx context.Context, api API, story Item, limit int, onProgress func([]Comment)) []Comment {
loaded := map[int]Item{}
frontier := story.Kids
var out []Comment
for len(frontier) > 0 && ctx.Err() == nil {
items := fetchItems(ctx, api, frontier, nil)
var next []int
for _, it := range items {
loaded[it.ID] = it
// A comment with no text is deleted or dead; it is not shown, and
// its replies are not pursued — matching what the depth-first walk
// did, so a dead subtree does not cost a level of fetching.
if it.Text != "" {
next = append(next, it.Kids...)
}
}
out = assembleComments(story, loaded, limit)
if onProgress != nil {
onProgress(out)
}
if len(out) >= limit {
break // the tree is already longer than anything that will be shown
}
frontier = next
}
return out
}
// assembleComments walks the story depth-first over whatever has been loaded,
// which is what puts a reply directly under its parent at depth+1. Ids not yet
// fetched are simply absent, so the same walk works on a partial tree.
func assembleComments(story Item, loaded map[int]Item, limit int) []Comment {
out := make([]Comment, 0, limit)
var walk func(ids []int, depth int)
walk = func(ids []int, depth int) {
for _, id := range ids {
if len(out) >= limit {
return
}
it, ok := loaded[id]
if !ok || it.Text == "" {
continue
}
out = append(out, Comment{Item: it, Depth: depth})
walk(it.Kids, depth+1)
}
}
walk(story.Kids, 0)
return out
}
// plainText strips HN's comment HTML down to displayable text: paragraph
// breaks preserved, tags dropped, entities unescaped.
func plainText(s string) string {
var b strings.Builder
for _, sp := range parseSpans(s, spanStyle{}) {
b.WriteString(sp.Text)
}
return strings.TrimSpace(b.String())
}
// spanStyle carries the colors parseSpans assigns per span kind.
type spanStyle struct {
Text, Link, Emph paint.Color
}
// parseSpans converts HN's comment HTML subset (<p>, <a href>, <i>,
// <code>/<pre>) into rich spans: links tappable, italics emphasized,
// entities unescaped, paragraph breaks preserved.
func parseSpans(s string, style spanStyle) []layout.RichSpan {
var spans []layout.RichSpan
var cur strings.Builder
var href string
emph := false
flush := func() {
if cur.Len() == 0 {
return
}
sp := layout.RichSpan{Text: html.UnescapeString(cur.String()), Color: style.Text}
if href != "" {
sp.Link, sp.Color = href, style.Link
} else if emph {
sp.Color = style.Emph
}
spans = append(spans, sp)
cur.Reset()
}
for i := 0; i < len(s); {
if s[i] != '<' {
cur.WriteByte(s[i])
i++
continue
}
end := strings.IndexByte(s[i:], '>')
if end < 0 {
break
}
tag := s[i+1 : i+end]
i += end + 1
lower := strings.ToLower(tag)
switch {
case lower == "p" || lower == "/p":
flush()
if len(spans) > 0 {
spans = append(spans, layout.RichSpan{Text: "\n\n", Color: style.Text})
}
case strings.HasPrefix(lower, "a "):
flush()
if j := strings.Index(lower, `href="`); j >= 0 {
rest := tag[j+6:]
if before, _, ok := strings.Cut(rest, "\""); ok {
href = html.UnescapeString(before)
}
}
case lower == "/a":
flush()
href = ""
case lower == "i" || lower == "em":
flush()
emph = true
case lower == "/i" || lower == "/em":
flush()
emph = false
}
}
flush()
// Trim a leading paragraph break.
if len(spans) > 0 && strings.TrimSpace(spans[0].Text) == "" {
spans = spans[1:]
}
return spans
}
// domain extracts the host for the story's meta line.
func domain(url string) string {
s := strings.TrimPrefix(strings.TrimPrefix(url, "https://"), "http://")
s = strings.TrimPrefix(s, "www.")
if i := strings.IndexByte(s, '/'); i >= 0 {
s = s[:i]
}
return s
}
ui/config.go
package ui
import (
"golang.org/x/image/font/gofont/gobold"
"golang.org/x/image/font/gofont/goregular"
"github.com/doug/gophics/app"
"github.com/doug/gophics/geom"
)
// Config returns the app's window/runtime configuration. It lives alongside
// Root in this importable package so both are reusable and testable apart from
// the entry point.
func Config() app.Config {
return app.Config{
Title: "gophics · hn",
Size: geom.Size{W: 480, H: 720},
Background: Background(),
Font: goregular.TTF,
FontFamilies: map[string][]byte{"bold": gobold.TTF},
// The API is built once and never varies by position in the tree, so
// it is provided to the whole app here rather than nested around it.
// A test swaps in a fake by overriding this field.
Provide: []any{newLiveAPI()},
}
}
ui/fetch.go
package ui
import (
"context"
"sync"
)
// fetchConcurrency bounds in-flight item requests.
//
// The HN API has no batch endpoint — a story list of 30 is 31 requests and a
// comment thread of 80 is 80, and that is inherent. What is not inherent is
// doing them one at a time: serially, opening a story took 80 round trips
// before it drew anything, which is where the multi-second stall came from.
// Eight is well inside what Firebase serves without complaint, and turns those
// 80 trips into ten.
const fetchConcurrency = 8
// fetchItems resolves ids concurrently and returns the ones that loaded, in
// the order they were asked for — the feed is ranked, so arrival order is not
// an acceptable substitute.
//
// onPrefix, when non-nil, is called each time the finished run from the front
// grows, with the items resolved so far. That is what lets the UI paint the
// first stories while the tail is still in flight. Reporting on *any*
// completion instead would let item 20 appear above item 3 and then jump when
// 3 landed; a prefix only grows, so the list only ever appends.
func fetchItems(ctx context.Context, api API, ids []int, onPrefix func([]Item)) []Item {
var (
mu sync.Mutex
out = make([]Item, len(ids))
ok = make([]bool, len(ids)) // loaded successfully
resolved = make([]bool, len(ids)) // request finished, either way
sent int // length of the prefix already reported
)
// collect must be called with mu held.
collect := func(n int) []Item {
res := make([]Item, 0, n)
for i := 0; i < n; i++ {
if ok[i] {
res = append(res, out[i])
}
}
return res
}
sem := make(chan struct{}, fetchConcurrency)
var wg sync.WaitGroup
for i, id := range ids {
if ctx.Err() != nil {
break
}
wg.Add(1)
go func(i, id int) {
defer wg.Done()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
return
}
// Checked again after acquiring: a request that waited behind the
// semaphore may be for a page the reader has since left.
if ctx.Err() != nil {
return
}
it, err := api.Item(ctx, id)
mu.Lock()
defer mu.Unlock()
resolved[i] = true
if err == nil {
out[i], ok[i] = it, true
}
// A failed item still advances the prefix — otherwise one dead id
// holds every story behind it off the screen.
grew := false
for sent < len(ids) && resolved[sent] {
sent++
grew = true
}
if grew && onPrefix != nil {
onPrefix(collect(sent))
}
}(i, id)
}
wg.Wait()
mu.Lock()
defer mu.Unlock()
return collect(len(ids))
}
ui/ui.go
// Command hn is a HackerNews client, the app that drove the scrolling, text
// and navigation work to completion. Lazy story feed with fling scrolling, Navigator-driven
// pages with slide transitions, rich comments with tappable links, async
// loading over the real Firebase API — on desktop and web from one
// codebase.
package ui
import (
"fmt"
"github.com/doug/gophics/geom"
"github.com/doug/gophics/layout"
"github.com/doug/gophics/paint"
"github.com/doug/gophics/theme"
"github.com/doug/gophics/widget"
)
// colBg is the light-theme background. It is used before a widget context
// exists (app.Config.Background, via Background()) and by tests. Inside the
// tree every color comes from theme.Of(ctx), so the whole app follows the
// platform light/dark scheme automatically.
var colBg = theme.Light().Bg
// commentStyle is the light-theme span palette — used by tests and as a
// default. commentRow builds a themed palette per frame from theme.Of(ctx).
var commentStyle = spanStyle{
Text: theme.Light().Text,
Link: theme.Light().Primary,
Emph: theme.Light().Muted,
}
// HN is the root widget: the Navigator over the feed.
type HN struct {
PageSize int // stories to load (0 → 60)
}
func (h HN) pageSize() int {
if h.PageSize == 0 {
return 60
}
return h.PageSize
}
func (h HN) Build(ctx widget.Ctx) widget.Widget {
// Resolve the theme from the platform color scheme and provide it to the
// tree, so every page below reads colors with theme.Of(ctx) and the whole
// app follows light/dark automatically.
th := theme.Auto(ctx)
// Only the theme is provided here. It is derived — recomputed each build so
// the app follows the system's dark mode — so it belongs in the tree, where
// changing it rebuilds the subtree that reads it. The API is the opposite:
// built once at startup, identical everywhere, and provided app-wide from
// Config.
//
// Either way the pages carry only data (which story), so they stay plain
// serializable values — which is what lets `gophics dev` restore your
// navigation on a hot restart (see threadPage's registration below).
return widget.Provide[theme.Theme]{
Value: th,
Child: widget.Fill{Color: th.Bg, Child: widget.Navigator{Home: feedPage{N: h.pageSize()}}},
}
}
func init() {
// Register the pushable page(s) so the Navigator's stack survives a
// state-preserving hot-restart.
widget.RegisterSnapshotType[threadPage]()
}
func header(th theme.Theme, title string, lead widget.Widget) widget.Widget {
if lead == nil {
lead = widget.Text{Value: "Y", Size: th.Type.Heading, Color: th.OnPrimary}
}
return widget.Padding{Insets: geom.InsetsSymmetric(12, 10),
Child: widget.Row(
lead,
widget.Sized{W: 10},
widget.Expand(widget.Text{Value: title, Font: "bold", Size: th.Type.Heading, Color: th.OnPrimary}),
),
}
}
func backButton(ctx widget.Ctx) widget.Widget {
th := theme.Of(ctx)
nav := ctx.MustOf[widget.Nav]()
return widget.Interactive{
Gestures: widget.Gestures{OnTap: nav.Pop},
Child: widget.Padding{Insets: geom.InsetsSymmetric(6, 4),
Child: widget.Text{Value: "‹ Back", Size: th.Type.Body, Color: th.OnPrimary}},
}
}
func page(ctx widget.Ctx, headerW, body widget.Widget) widget.Widget {
th := theme.Of(ctx)
// Pad by the platform safe areas (status bar / notch / keyboard); the
// header bar's color extends behind the top inset.
in := ctx.SafeInsets()
col := widget.Column(
widget.Decorated{Color: th.Primary, Child: widget.Padding{
Insets: geom.Insets{Top: in.Top, Left: in.Left, Right: in.Right},
Child: headerW,
}},
widget.Expand(widget.Padding{
Insets: geom.Insets{Left: in.Left, Right: in.Right, Bottom: in.Bottom},
Child: widget.SelectionArea{Child: body},
}),
)
col.CrossAlign = layout.CrossStretch
// Pages carry their own opaque background so slide transitions cover
// the page beneath.
content := widget.Decorated{Color: th.Bg, Child: col}
// Responsive: on a wide viewport (desktop browser, large window, wide
// terminal) center a comfortable reading column with gutters; on a narrow
// one (phone, mobile web) fill the width unchanged.
return widget.LayoutBuilder{Build: func(cs layout.Constraints) widget.Widget {
const maxW = 720
if !cs.BoundedW() || cs.Max.W <= maxW+96 {
return content
}
row := widget.Row(
widget.Expand(widget.Sized{}),
widget.Sized{W: maxW, Child: content},
widget.Expand(widget.Sized{}),
)
row.CrossAlign = layout.CrossStretch
// Border reads as a subtle neutral frame around the reading column.
return widget.Decorated{Color: th.Border, Child: row}
}}
}
// feedPage lists top stories. It carries only data; the API comes from context.
type feedPage struct {
N int
}
func (f feedPage) CreateState() widget.State { return &feedState{} }
// feed is the result of one load. Grouping the three values means they move
// together: there is no assignment that leaves an error showing beside stale
// stories, or a spinner running after the items arrived. The zero value is the
// initial state — no items, no error, not yet loaded.
type feed struct {
items []Item
err error
done bool
}
type feedState struct {
widget.StateBase[feedPage]
feed feed
// refreshing is presentation, not result: it says the load was triggered by
// a pull rather than by opening the page, and so shows a different spinner.
refreshing bool
}
// stateHook lets tests observe the mounted feed state.
var stateHook func(*feedState)
func (s *feedState) Init(ctx widget.Ctx) {
if stateHook != nil {
stateHook(s)
}
s.fetch(ctx)
}
// fetch loads the top stories on a background goroutine and swaps them in.
// Used for the initial load and for pull-to-refresh.
//
// The context comes from the widget, so leaving the page stops the load: the
// per-item walk below is the expensive part, and without cancellation a feed
// that is closed a moment after opening keeps fetching every one of them.
func (s *feedState) fetch(ctx widget.Ctx) {
lifetime := ctx.Lifetime()
api := ctx.MustOf[API]()
n := s.W().N
go func() {
ids, err := api.TopStories(lifetime)
if err != nil {
s.PostState(func() { s.feed, s.refreshing = feed{err: err, done: true}, false })
return
}
if len(ids) > n {
ids = ids[:n]
}
// Concurrent, and streamed: each time the run of resolved items from
// the top grows, show it. The list is ranked, so it fills in from the
// first story down and never reorders under the reader.
show := func(items []Item, done bool) {
keep := items[:0]
for _, it := range items {
if it.Title != "" {
keep = append(keep, it)
}
}
s.PostState(func() {
s.feed = feed{items: keep, done: done}
if done {
s.refreshing = false
}
})
}
items := fetchItems(lifetime, api, ids, func(partial []Item) { show(partial, false) })
if lifetime.Err() != nil {
return // the feed is gone; nothing wants these
}
show(items, true)
}()
}
func (s *feedState) Build(ctx widget.Ctx) widget.Widget {
th := theme.Of(ctx)
var body widget.Widget
switch f := s.feed; {
case !f.done:
body = widget.Center(widget.Text{Value: "loading…", Size: th.Type.Body, Color: th.Muted})
case f.err != nil:
body = widget.Center(widget.Text{Value: f.err.Error(), Wrap: true, Size: th.Type.Body, Color: th.Muted})
default:
nav := ctx.MustOf[widget.Nav]()
body = widget.LazyList{
Count: len(s.feed.items),
EstimatedExtent: 66,
Build: func(i int) widget.Widget { return s.storyRow(th, nav, i) },
Refreshing: s.refreshing,
OnRefresh: func() {
s.SetState(func() { s.refreshing = true })
s.fetch(ctx)
},
}
}
return page(ctx, header(th, "Hacker News", nil), body)
}
func (s *feedState) storyRow(th theme.Theme, nav widget.Nav, i int) widget.Widget {
st := s.feed.items[i]
meta := fmt.Sprintf("%d points · %s · %d comments", st.Score, st.By, st.Descendants)
if d := domain(st.URL); d != "" {
meta = d + " · " + meta
}
title := widget.Column(
widget.Text{Value: st.Title, Font: "bold", Size: th.Type.Heading, Color: th.Text, Wrap: true},
widget.Sized{H: 4},
widget.Text{Value: meta, Size: th.Type.Caption, Color: th.Muted},
)
title.CrossAlign = layout.CrossStart
row := widget.Row(
widget.Sized{W: 34, Child: widget.Text{Value: fmt.Sprintf("%d.", i+1), Size: th.Type.Label, Color: th.Muted}},
widget.Expand(title),
)
row.CrossAlign = layout.CrossStart
return theme.Tappable{
OnTap: func() { nav.Push(threadPage{Story: st}) },
Background: th.Surface,
Pad: geom.InsetsSymmetric(12, 10),
Haptic: true, // a selection tick when opening a story
Child: row,
}
}
// threadPage shows one story's comments. It carries only the story (plain
// serializable data); the API comes from context. That makes it registerable
// so a hot-restart can rebuild it and land you back on the same thread.
type threadPage struct {
Story Item
}
func (t threadPage) CreateState() widget.State { return &threadState{} }
type threadState struct {
widget.StateBase[threadPage]
comments []Comment
loading bool
}
func (s *threadState) Init(ctx widget.Ctx) {
s.loading = true
lifetime := ctx.Lifetime()
api := ctx.MustOf[API]()
story := s.W().Story
go func() {
// Reported per level, so the top-level comments draw after one round
// trip instead of after the last reply in the tree.
comments := streamComments(lifetime, api, story, 80, func(partial []Comment) {
s.PostState(func() {
if len(partial) > 0 {
s.comments, s.loading = partial, false
}
})
})
s.PostState(func() { s.comments, s.loading = comments, false })
}()
}
func (s *threadState) Build(ctx widget.Ctx) widget.Widget {
th := theme.Of(ctx)
st := s.W().Story
var body widget.Widget
if s.loading {
body = widget.Center(widget.Text{Value: "loading comments…", Size: th.Type.Body, Color: th.Muted})
} else {
n := len(s.comments)
openURL := func(u string) { _ = ctx.OpenURL(u) }
// A thread is for reading, and reading includes quoting: without a
// SelectionArea every Text here is inert and a comment cannot be
// copied out. Wrapping the list rather than each row is what makes a
// drag across two comments one continuous selection.
body = widget.SelectionArea{Child: widget.LazyList{
Count: n + 1,
EstimatedExtent: 90,
Build: func(i int) widget.Widget {
if i == 0 {
return storyHeaderCell(th, st, openURL)
}
return commentRow(th, s.comments[i-1], openURL)
},
}}
}
return page(ctx, header(th, st.Title, backButton(ctx)), body)
}
func storyHeaderCell(th theme.Theme, st Item, openURL func(string)) widget.Widget {
meta := fmt.Sprintf("%d points by %s · %d comments", st.Score, st.By, st.Descendants)
kids := []widget.Widget{
widget.Text{Value: st.Title, Font: "bold", Size: th.Type.Heading, Color: th.Text, Wrap: true},
widget.Sized{H: 6},
widget.Text{Value: meta, Size: th.Type.Caption, Color: th.Muted},
}
if st.URL != "" {
kids = append(kids, widget.Sized{H: 6}, widget.Rich{
Spans: []layout.RichSpan{{Text: domain(st.URL) + " ↗", Color: th.Primary, Link: st.URL}},
Size: th.Type.Label,
OnLink: openURL,
})
}
col := widget.Column(kids...)
col.CrossAlign = layout.CrossStart
return widget.Decorated{Color: th.Surface,
Child: widget.Padding{All: 14, Child: col}}
}
func commentRow(th theme.Theme, c Comment, openURL func(string)) widget.Widget {
style := spanStyle{Text: th.Text, Link: th.Primary, Emph: th.Muted}
body := widget.Column(
widget.Text{Value: c.By, Size: th.Type.Label, Color: th.Primary},
widget.Sized{H: 4},
widget.Rich{Spans: parseSpans(c.Text, style), Size: th.Type.Body, OnLink: openURL},
)
body.CrossAlign = layout.CrossStart
return widget.Padding{
Insets: geom.Insets{Top: 6, Left: 12 + float32(c.Depth)*16, Right: 12},
Child: widget.Decorated{Color: th.Surface,
Child: widget.Padding{All: 10, Child: body}},
}
}
// Root returns the HN app widget over the live API.
func Root() widget.Widget { return HN{} }
// Background returns the app background color.
func Background() paint.Color { return colBg }
web/devserve.go
//go:build ignore
// Dev server for the HN web build: rebuilds the GPU wasm on every page load
// and serves everything with no-store headers, so the browser can never run a
// stale build (plain `go build` output is otherwise cached hard by browsers,
// which makes iterating on the wasm maddening).
//
// Run from the repo root:
//
// go run ./examples/hn/web/devserve.go
//
// then open http://localhost:8100/. Each reload rebuilds and reloads fresh.
package main
import (
"log"
"net/http"
"os"
"os/exec"
"sync"
"time"
)
const (
webDir = "examples/hn/web"
pkg = "./examples/hn/"
addr = ":8100"
)
var buildMu sync.Mutex
func buildWasm() (string, error) {
buildMu.Lock()
defer buildMu.Unlock()
start := time.Now()
cmd := exec.Command("go", "build", "-o", webDir+"/hn.wasm", pkg)
cmd.Env = append(os.Environ(), "GOOS=js", "GOARCH=wasm")
if out, err := cmd.CombinedOutput(); err != nil {
return string(out), err
}
log.Printf("rebuilt hn.wasm in %s", time.Since(start).Round(time.Millisecond))
return "", nil
}
func main() {
fs := http.FileServer(http.Dir(webDir))
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
w.Header().Set("Pragma", "no-cache")
// A page load rebuilds the wasm before serving the document, so the
// hn.wasm the browser fetches next is always current.
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
if out, err := buildWasm(); err != nil {
log.Printf("build failed: %v\n%s", err, out)
http.Error(w, "build failed:\n"+out, http.StatusInternalServerError)
return
}
}
fs.ServeHTTP(w, r)
})
log.Printf("HN web dev server on %s — rebuilds wasm per load, no cache; open http://localhost%s/", addr, addr)
log.Fatal(http.ListenAndServe(addr, nil))
}