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

9.5 KB · 351 lines · Go Raw History
  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}