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
1// Command dash serves dash.bythewood.me: markets, news, weather and the state
2// of every site in this repo, on one page that updates itself. Everything it
3// shows is fetched by one poller and pushed to every open browser over
4// server-sent events, so the page costs the upstreams the same whether nobody
5// or everybody is watching.
6package main
7
8import (
9 "context"
10 "embed"
11 "flag"
12 "fmt"
13 "io/fs"
14 "log/slog"
15 "net/http"
16 "os"
17 "os/signal"
18 "strings"
19 "syscall"
20 "time"
21
22 "dash.bythewood.me/web"
23)
24
25// Templates are source, so they ship in the binary unconditionally; the Vite
26// bundle is build output and only embeds in a release build.
27//
28//go:embed templates
29var templateFS embed.FS
30
31const listenAddr = ":8000"
32
33// waitForListener blocks until this process answers its own health check. The
34// health strip probes over loopback, so a first round started before Serve has
35// bound the socket reports this site as unknown for a minute after every
36// deploy. There is no deadline because a server that never binds has already
37// exited on the error.
38func waitForListener(ctx context.Context) {
39 for {
40 if err := web.HealthCheck("http://127.0.0.1:8000/healthz", time.Second); err == nil {
41 return
42 }
43 select {
44 case <-ctx.Done():
45 return
46 case <-time.After(50 * time.Millisecond):
47 }
48 }
49}
50
51func dir(env, fallback string) string {
52 if v := os.Getenv(env); v != "" {
53 return v
54 }
55 return fallback
56}
57
58// csp allows 'unsafe-inline' for the analytics collector snippet and for the
59// sparkline paths, which are computed per quote and written as style attributes
60// rather than into a stylesheet that would need a nonce per render.
61func csp() string {
62 return strings.Join([]string{
63 "default-src 'self'",
64 "script-src 'self' 'unsafe-inline' https://analytics.bythewood.me",
65 "style-src 'self' 'unsafe-inline'",
66 "img-src 'self' data:",
67 "font-src 'self'",
68 "connect-src 'self' https://analytics.bythewood.me",
69 "base-uri 'self'",
70 "form-action 'self'",
71 "frame-ancestors 'self'",
72 }, "; ")
73}
74
75type site struct {
76 renderer *web.Renderer
77 store *Store
78 hub *Hub
79 guard *Guard
80
81 script string
82 styles []string
83}
84
85func main() {
86 web.SetupLogging()
87
88 healthcheck := flag.Bool("healthcheck", false, "probe a running server on this host and exit")
89 flag.Parse()
90
91 if *healthcheck {
92 if err := web.HealthCheck("http://127.0.0.1:8000/healthz", 3*time.Second); err != nil {
93 slog.Info(fmt.Sprintf("healthcheck: %v", err))
94 os.Exit(1)
95 }
96 return
97 }
98
99 shipper := web.ShipLogs(selfSource, web.HTTPSink())
100 defer shipper.Close()
101
102 dist := distFS()
103
104 assets, err := web.LoadAssets(dist)
105 if err != nil {
106 slog.Error("startup failed", slog.Any("err", err))
107 os.Exit(1)
108 }
109
110 templates, err := fs.Sub(templateFS, "templates")
111 if err != nil {
112 slog.Error("startup failed", slog.Any("err", err))
113 os.Exit(1)
114 }
115
116 renderer, err := web.NewRenderer(
117 templates,
118 templateFuncs,
119 []string{"base.html", "partials.html"},
120 []string{"home.html", "notfound.html"},
121 )
122 if err != nil {
123 slog.Error("startup failed", slog.Any("err", err))
124 os.Exit(1)
125 }
126
127 dataDir := dir("SITE_DATA", "build/data")
128 if err := os.MkdirAll(dataDir, 0o755); err != nil {
129 slog.Error("startup failed", slog.Any("err", err))
130 os.Exit(1)
131 }
132
133 hub := NewHub()
134 guard := NewGuard(dataDir)
135 store := NewStore(hub)
136
137 s := &site{
138 renderer: renderer,
139 store: store,
140 hub: hub,
141 guard: guard,
142 script: assets.Script("index.js"),
143 styles: assets.Styles("index.js"),
144 }
145
146 // Cancelled on shutdown so an in-flight fetch is abandoned rather than
147 // holding the process past the stop grace period.
148 pollCtx, stopPolling := context.WithCancel(context.Background())
149 defer stopPolling()
150
151 store.Prime(pollCtx, guard)
152 go func() {
153 waitForListener(pollCtx)
154 store.Run(pollCtx, guard)
155 }()
156
157 // The guard's counters only reach disk on Flush, and a container stop that
158 // skipped it would forget an open breaker.
159 stopping := make(chan os.Signal, 1)
160 signal.Notify(stopping, syscall.SIGINT, syscall.SIGTERM)
161 go func() {
162 <-stopping
163 stopPolling()
164 guard.Flush()
165 }()
166
167 mux := http.NewServeMux()
168
169 mux.HandleFunc("GET /{$}", s.home)
170 mux.HandleFunc("GET /events", s.events)
171 mux.HandleFunc("GET /api/state", s.state)
172
173 mux.HandleFunc("GET /favicon.ico", favicon)
174 mux.HandleFunc("GET /favicon.svg", favicon)
175 mux.HandleFunc("GET /robots.txt", robots)
176 mux.HandleFunc("GET /sitemap.xml", sitemap)
177
178 mux.Handle("GET /static/", web.Static(dist, assets))
179
180 mux.HandleFunc("GET /healthz", s.healthz)
181
182 mux.HandleFunc("GET /", s.notFound)
183
184 handler := web.Chain(mux,
185 web.Recovered,
186 web.Logged,
187 web.SecurityHeaders(csp()),
188 )
189
190 slog.Info(fmt.Sprintf("dash serving %s (staging=%t)", baseURL, Staging))
191 if err := web.Serve(listenAddr, handler); err != nil {
192 slog.Error("startup failed", slog.Any("err", err))
193 os.Exit(1)
194 }
195}