orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1package web
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "log/slog"
9 "net/http"
10 "os"
11 "os/signal"
12 "syscall"
13 "time"
14)
15
16// SetupLogging installs the process-wide structured logger. JSON, because these
17// logs are read by machine before they are read by a person.
18func SetupLogging() {
19 slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
20 Level: slog.LevelInfo,
21 // UTC is not slog's default, and local time in a container
22 // silently differs from the host's.
23 ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
24 if a.Key == slog.TimeKey && len(groups) == 0 {
25 a.Value = slog.TimeValue(a.Value.Time().UTC())
26 }
27 return a
28 },
29 })))
30}
31
32// Serve runs h on addr until SIGINT or SIGTERM, then drains in-flight requests
33// before returning.
34func Serve(addr string, h http.Handler) error {
35 srv := &http.Server{
36 Addr: addr,
37 Handler: h,
38 ReadHeaderTimeout: 10 * time.Second,
39 // No write bound here, unlike the copy in the sites that only serve
40 // pages. This one serves an event stream, where a connection is meant
41 // to stay open for as long as the browser is on the page, and Go's
42 // WriteTimeout bounds the whole response, so any value at all is the
43 // length of the longest session anyone gets. The read side is still
44 // bounded.
45 ReadTimeout: 30 * time.Second,
46 WriteTimeout: 0,
47 IdleTimeout: 120 * time.Second,
48 }
49
50 stop := make(chan os.Signal, 1)
51 signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
52
53 errs := make(chan error, 1)
54 go func() {
55 slog.Info("listening", slog.String("addr", addr))
56 if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
57 errs <- err
58 }
59 }()
60
61 select {
62 case err := <-errs:
63 return err
64 case sig := <-stop:
65 slog.Info("shutting down", slog.String("signal", sig.String()))
66 }
67
68 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
69 defer cancel()
70 return srv.Shutdown(ctx)
71}
72
73// HealthCheck probes a running server over the loopback and reports whether it
74// answered 200, so a container can check itself. Two of these images are FROM
75// scratch, with no shell and no curl for HEALTHCHECK to call.
76func HealthCheck(url string, timeout time.Duration) error {
77 client := &http.Client{Timeout: timeout}
78
79 resp, err := client.Get(url)
80 if err != nil {
81 return err
82 }
83 // Drained as well as closed, or the connection leaks out of the pool.
84 defer resp.Body.Close()
85 _, _ = io.Copy(io.Discard, resp.Body)
86
87 if resp.StatusCode != http.StatusOK {
88 return fmt.Errorf("%s: %s", url, resp.Status)
89 }
90 return nil
91}