❯ go-clikit
v0.10.0 pkg.go.dev ↗

guides › serve a page to the user

Serve a page to the user

Serve a local web page that only the user who started the program can reach, with live fragments, one server per user.

A program can show a page in the user’s browser, served from the user’s own machine. Such a server acts as the user. So whoever can send it a request acts as the user too: another user of the machine, any page in the user’s browser, and a page of another site that makes its name resolve to 127.0.0.1. localweb keeps the three out, each with a check of its own (decision 34).

listenerHostsSameOriginsessionhandlerother user: closed421 Misdirected403 cross-site403 no session
The handler of the program sees the request.

Procedure#

  1. Take the lock of the user’s server with launch.Acquire. When a server of the user runs already, open that one and stop.
  2. Listen with localweb.Listen on a loopback address or a Unix socket. On Linux, wrap the TCP listener with localweb.PeerListener.
  3. Make a launch token with session.NewToken, and a session.Manager.
  4. Chain the checks, outermost first: localweb.Headers, localweb.Hosts, localweb.SameOrigin, Manager.Middleware, then your handler.
  5. Serve with localweb.Serve, which sets every timeout and stops within a grace period when the context ends.
  6. Print the address with the token, and open the browser with a launch.Opener. The Opener writes the address to a private file, because every user can read a command line.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

b, err := localweb.Listen(ctx, "127.0.0.1:0", localweb.ListenOptions{})
if err != nil {
	log.Fatal(err)
}

mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
	fmt.Fprintln(w, "Hello from the user's machine.")
})
h := localweb.Headers("")(localweb.Hosts(b.Answers, localweb.HostsOptions{})(
	localweb.SameOrigin(localweb.OriginPolicy{Navigate: true})(mux)))
done := make(chan error, 1)
go func() { done <- localweb.Serve(ctx, b.Listeners, h, localweb.ServeOptions{}) }()

client := &http.Client{Transport: &http.Transport{DisableKeepAlives: true}}
for _, host := range []string{b.Host(), "rebind.example"} {
	req, err := http.NewRequest(http.MethodGet, "http://"+b.Host()+"/", nil)
	if err != nil {
		log.Fatal(err)
	}
	req.Host = host
	res, err := client.Do(req)
	if err != nil {
		log.Fatal(err)
	}
	body, _ := io.ReadAll(res.Body)
	_ = res.Body.Close()
	fmt.Printf("%s: %s %s", res.Status, res.Header.Get("X-Frame-Options"), body)
}

cancel()
fmt.Println(<-done)
200 OK: DENY Hello from the user's machine.
421 Misdirected Request: DENY This server answers to its loopback address only.
<nil>
ExampleListen in localweb/example_test.go. CI runs it and checks its output.
WARNING

Do not leave out a check because the server listens on a loopback address. Every user of the machine, and every page in the browser, can reach a loopback port.

Live fragments#

A page names its fragments, and the topics that each depends on. The store of the program notifies a topic when it changes. sse.Serve sends every fragment first, then each fragment whose topic changed and whose HTML is new. page.Set renders the page and its fragments from one template set, and localweb/htmx holds the scripts that swap them in.

ctx, stop := context.WithCancel(context.Background())
defer stop()
store := &Store{}
stream := func(w http.ResponseWriter, r *http.Request) {
	_ = sse.Serve(w, r, &store.notifier, func(changed []string) ([]sse.Event, error) {
		if changed == nil {
			changed = []string{"runs"}
		}
		var es []sse.Event
		for _, t := range changed {
			es = append(es, store.fragment(t))
		}
		return es, nil
	}, sse.Options{Done: ctx.Done()})
}
srv := httptest.NewServer(localweb.SameOrigin(localweb.OriginPolicy{})(http.HandlerFunc(stream)))
defer srv.Close()

resp, err := http.Get(srv.URL)
if err != nil {
	fmt.Println(err)
	return
}
defer resp.Body.Close()
events := localwebtest.NewReader(resp.Body)
e, _ := events.Next()
fmt.Println(e.Name, e.Data)
store.AddRun("create")
e, _ = events.Next()
fmt.Println(e.Name, e.Data)
runs <ul>0 runs</ul>
runs <ul>1 runs</ul>
ExampleServe in localweb/sse/example_test.go. CI runs it and checks its output.

Test the server#

localwebtest.Login gives a test a client that signed in with the token, and localwebtest.Reader reads the events of a stream.