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 // Whole-request bounds, so a slow body or a wedged handler cannot
40 // hold a connection open forever. The write bound is generous enough
41 // for a Typst report, which has its own 30 second ceiling.
42 ReadTimeout: 30 * time.Second,
43 WriteTimeout: 60 * time.Second,
44 IdleTimeout: 120 * time.Second,
45 }
46
47 stop := make(chan os.Signal, 1)
48 signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
49
50 errs := make(chan error, 1)
51 go func() {
52 slog.Info("listening", slog.String("addr", addr))
53 if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
54 errs <- err
55 }
56 }()
57
58 select {
59 case err := <-errs:
60 return err
61 case sig := <-stop:
62 slog.Info("shutting down", slog.String("signal", sig.String()))
63 }
64
65 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
66 defer cancel()
67 return srv.Shutdown(ctx)
68}
69
70// HealthCheck probes a running server over the loopback and reports whether it
71// answered 200, so a container can check itself. Two of these images are FROM
72// scratch, with no shell and no curl for HEALTHCHECK to call.
73func HealthCheck(url string, timeout time.Duration) error {
74 client := &http.Client{Timeout: timeout}
75
76 resp, err := client.Get(url)
77 if err != nil {
78 return err
79 }
80 // Drained as well as closed, or the connection leaks out of the pool.
81 defer resp.Body.Close()
82 _, _ = io.Copy(io.Discard, resp.Body)
83
84 if resp.StatusCode != http.StatusOK {
85 return fmt.Errorf("%s: %s", url, resp.Status)
86 }
87 return nil
88}