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 main
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "sort"
12 "strings"
13 "time"
14)
15
16// `bun run --bun` below keeps Node out of the image: it symlinks `node` to bun,
17// so the lighthouse shim's `#!/usr/bin/env node` shebang resolves to bun.
18
19const (
20 lighthouseTimeout = 180 * time.Second
21 chromeFlags = "--headless --no-sandbox --disable-dev-shm-usage --disable-gpu"
22 // Bounded so a subprocess that dies noisily cannot fill a database column.
23 stderrKeep = 500
24)
25
26// ErrLighthouseMissing means node_modules/.bin/lighthouse is not on disk, the
27// normal state of a checkout without `bun install`. Audits are skipped.
28var ErrLighthouseMissing = errors.New("lighthouse CLI not installed")
29
30// findChromium tries CHROMIUM_BIN, then PATH, then the Playwright browser
31// directory. Lighthouse drives DevTools and needs a full Chrome, so
32// headless-shell is last: it can fail in ways that look like a broken site.
33func findChromium() string {
34 if p := os.Getenv("CHROMIUM_BIN"); p != "" {
35 if isFile(p) {
36 return p
37 }
38 }
39
40 for _, name := range []string{
41 "chromium", "chromium-browser", "google-chrome", "chrome", "chrome-headless-shell",
42 } {
43 if p, err := exec.LookPath(name); err == nil {
44 return p
45 }
46 }
47
48 entries, err := os.ReadDir(playwrightDir)
49 if err != nil {
50 return ""
51 }
52 // Sorted so the choice is stable when two browser builds sit side by side.
53 names := make([]string, 0, len(entries))
54 for _, e := range entries {
55 names = append(names, e.Name())
56 }
57 sort.Strings(names)
58
59 for _, rel := range []string{
60 "chrome-linux64/chrome",
61 "chrome-linux/chrome",
62 "chrome-headless-shell-linux64/chrome-headless-shell",
63 } {
64 for _, name := range names {
65 candidate := filepath.Join(playwrightDir, name, rel)
66 if isFile(candidate) {
67 return candidate
68 }
69 }
70 }
71 return ""
72}
73
74const playwrightDir = "/opt/playwright-browsers"
75
76func isFile(path string) bool {
77 info, err := os.Stat(path)
78 return err == nil && !info.IsDir()
79}
80
81// runLighthouse audits a URL and returns the parsed JSON report.
82func runLighthouse(ctx context.Context, root, target string) (map[string]any, error) {
83 bin := filepath.Join(root, "node_modules/.bin/lighthouse")
84 if !isFile(bin) {
85 return nil, fmt.Errorf("%w at %s", ErrLighthouseMissing, bin)
86 }
87
88 ctx, cancel := context.WithTimeout(ctx, lighthouseTimeout)
89 defer cancel()
90
91 cmd := exec.CommandContext(ctx, "bun", "run", "--bun", bin, target,
92 "--chrome-flags="+chromeFlags,
93 "--output=json",
94 "--output-path=stdout",
95 "--quiet",
96 )
97 // A minimal environment: Lighthouse and Chromium both read a lot of ambient
98 // configuration, and the numbers must not depend on the calling shell.
99 cmd.Env = []string{"PATH=/usr/bin:/bin:/usr/local/bin"}
100 if chromium := findChromium(); chromium != "" {
101 cmd.Env = append(cmd.Env, "CHROME_PATH="+chromium)
102 }
103 // CommandContext kills only the direct child, so a timeout would otherwise
104 // leave the Chromium this spawned running forever.
105 setProcessGroup(cmd)
106
107 var stdout, stderr strings.Builder
108 cmd.Stdout = &stdout
109 cmd.Stderr = &stderr
110
111 if err := cmd.Start(); err != nil {
112 return nil, fmt.Errorf("start lighthouse: %w", err)
113 }
114 waitErr := cmd.Wait()
115 killProcessGroup(cmd)
116
117 if ctx.Err() == context.DeadlineExceeded {
118 return nil, fmt.Errorf("lighthouse timed out after %s", lighthouseTimeout)
119 }
120 if waitErr != nil {
121 return nil, fmt.Errorf("lighthouse exited: %w: %s", waitErr, tailString(stderr.String(), stderrKeep))
122 }
123
124 var report map[string]any
125 if err := json.Unmarshal([]byte(stdout.String()), &report); err != nil {
126 return nil, fmt.Errorf("parse lighthouse output: %w", err)
127 }
128 return report, nil
129}
130
131// tailString keeps the last n characters, where a stack trace puts the failure.
132func tailString(s string, n int) string {
133 s = strings.TrimSpace(s)
134 runes := []rune(s)
135 if len(runes) <= n {
136 return s
137 }
138 return string(runes[len(runes)-n:])
139}
140
141// Scores are the four category headlines, as whole percentages. The JSON names
142// are the rendered labels and are stored that way, so renaming one orphans rows.
143type Scores struct {
144 Performance int64 `json:"Performance"`
145 Accessibility int64 `json:"Accessibility"`
146 BestPractices int64 `json:"Best practices"`
147 SEO int64 `json:"SEO"`
148}
149
150// parseScores pulls the four category scores. A null is an error, not a zero:
151// Lighthouse returns null for a category it could not evaluate, and a stored 0
152// would draw a red bar claiming the site scored nothing.
153func parseScores(report map[string]any) (*Scores, error) {
154 cats, ok := report["categories"].(map[string]any)
155 if !ok {
156 return nil, errors.New("lighthouse output has no categories")
157 }
158
159 pull := func(key string) (float64, bool, error) {
160 cat, ok := cats[key].(map[string]any)
161 if !ok {
162 return 0, false, fmt.Errorf("lighthouse output has no %s category", key)
163 }
164 score, ok := cat["score"].(float64)
165 return score, ok, nil
166 }
167
168 type field struct {
169 key string
170 label string
171 into *int64
172 }
173 var s Scores
174 fields := []field{
175 {"performance", "Performance", &s.Performance},
176 {"accessibility", "Accessibility", &s.Accessibility},
177 {"best-practices", "Best practices", &s.BestPractices},
178 {"seo", "SEO", &s.SEO},
179 }
180
181 var nulls []string
182 for _, f := range fields {
183 score, present, err := pull(f.key)
184 if err != nil {
185 return nil, err
186 }
187 if !present {
188 nulls = append(nulls, f.label)
189 continue
190 }
191 *f.into = int64(score*100 + 0.5)
192 }
193 if len(nulls) > 0 {
194 return nil, fmt.Errorf("lighthouse returned null scores: %s", strings.Join(nulls, ", "))
195 }
196 return &s, nil
197}
198
199// Metric is one of the weighted performance metrics (LCP, CLS, TBT...).
200type Metric struct {
201 ID string `json:"id"`
202 Acronym string `json:"acronym"`
203 Title string `json:"title"`
204 DisplayValue string `json:"display_value"`
205 Score *float64 `json:"score"`
206 Weight float64 `json:"weight"`
207}
208
209// Opportunity is a failed audit with an actionable saving attached.
210type Opportunity struct {
211 ID string `json:"id"`
212 Title string `json:"title"`
213 DisplayValue string `json:"display_value"`
214 SavingsMS float64 `json:"savings_ms"`
215}
216
217// Details is the performance breakdown under the headline score.
218type Details struct {
219 Metrics []Metric `json:"metrics"`
220 Opportunities []Opportunity `json:"opportunities"`
221}
222
223// parseDetails extracts the weighted metrics and the top opportunities. Group
224// "hidden" is audits Lighthouse keeps but no longer scores, and an opportunity
225// with no saving attached is a diagnostic; both would read as findings.
226func parseDetails(report map[string]any) *Details {
227 cats, ok := report["categories"].(map[string]any)
228 if !ok {
229 return nil
230 }
231 perf, ok := cats["performance"].(map[string]any)
232 if !ok {
233 return nil
234 }
235 audits, ok := report["audits"].(map[string]any)
236 if !ok {
237 return nil
238 }
239 refs, ok := perf["auditRefs"].([]any)
240 if !ok {
241 return nil
242 }
243
244 details := &Details{Metrics: []Metric{}, Opportunities: []Opportunity{}}
245
246 for _, raw := range refs {
247 ref, ok := raw.(map[string]any)
248 if !ok {
249 continue
250 }
251 id, _ := ref["id"].(string)
252 audit, ok := audits[id].(map[string]any)
253 if !ok {
254 continue
255 }
256 group, _ := ref["group"].(string)
257 weight, _ := ref["weight"].(float64)
258 score, hasScore := audit["score"].(float64)
259
260 title, _ := audit["title"].(string)
261 displayValue, _ := audit["displayValue"].(string)
262
263 if group == "metrics" && weight > 0 {
264 acronym, _ := ref["acronym"].(string)
265 if acronym == "" {
266 acronym = id
267 }
268 m := Metric{
269 ID: id,
270 Acronym: acronym,
271 Title: title,
272 DisplayValue: displayValue,
273 Weight: weight,
274 }
275 if hasScore {
276 v := score
277 m.Score = &v
278 }
279 details.Metrics = append(details.Metrics, m)
280 continue
281 }
282
283 if group == "hidden" {
284 continue
285 }
286 switch mode, _ := audit["scoreDisplayMode"].(string); mode {
287 case "manual", "notApplicable", "informative":
288 continue
289 }
290 if !hasScore || score >= 0.9 {
291 continue
292 }
293
294 savingsMS, savingsBytes := 0.0, 0.0
295 if d, ok := audit["details"].(map[string]any); ok {
296 savingsMS, _ = d["overallSavingsMs"].(float64)
297 savingsBytes, _ = d["overallSavingsBytes"].(float64)
298 }
299 hasMetricSavings := false
300 if ms, ok := audit["metricSavings"].(map[string]any); ok {
301 for _, v := range ms {
302 if n, ok := v.(float64); ok && n > 0 {
303 hasMetricSavings = true
304 break
305 }
306 }
307 }
308 if savingsMS == 0 && savingsBytes == 0 && !hasMetricSavings {
309 continue
310 }
311
312 details.Opportunities = append(details.Opportunities, Opportunity{
313 ID: id,
314 Title: title,
315 DisplayValue: displayValue,
316 SavingsMS: savingsMS,
317 })
318 }
319
320 // SliceStable, so equal weights keep Lighthouse's own order: two metrics
321 // swapping places between audits would look like the page had changed.
322 sort.SliceStable(details.Metrics, func(i, j int) bool {
323 return details.Metrics[i].Weight > details.Metrics[j].Weight
324 })
325 sort.SliceStable(details.Opportunities, func(i, j int) bool {
326 return details.Opportunities[i].SavingsMS > details.Opportunities[j].SavingsMS
327 })
328 if len(details.Opportunities) > 10 {
329 details.Opportunities = details.Opportunities[:10]
330 }
331
332 return details
333}
334
335// ScorePair is one category score, for iterating in a fixed order.
336type ScorePair struct {
337 Label string
338 Score int64
339}
340
341// Pairs returns the four scores in Lighthouse's own report order. A slice, not
342// a map, so the tiles cannot rearrange themselves between page loads.
343func (s *Scores) Pairs() []ScorePair {
344 return []ScorePair{
345 {"Performance", s.Performance},
346 {"Accessibility", s.Accessibility},
347 {"Best practices", s.BestPractices},
348 {"SEO", s.SEO},
349 }
350}