repos
/ orchard main

orchard

mirror

Every 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

2.5 KB · 92 lines · Go Raw History
 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}