Single-binary self-hosted uptime monitoring and status pages on Rust axum: HTTP probes, Lighthouse audits, SEO crawler, and PDF reports.
axumdockerrustself-hostedsqlitestatus-pageuptime-monitoringvite
1use serde_json::Value;
2use std::path::PathBuf;
3use std::process::Stdio;
4use std::time::Duration;
5use thiserror::Error;
6use tokio::process::Command;
7
8/// Locate a chromium binary for lighthouse to drive. Tries `CHROMIUM_BIN`,
9/// then a PATH search for the common binary names, then a glob over
10/// `/opt/playwright-browsers/` so the webdev container Just Works without
11/// per-shell env vars. Lighthouse passes the resulting path via the
12/// `CHROME_PATH` env var (the npm CLI looks for that).
13fn find_chromium() -> Option<String> {
14 if let Ok(p) = std::env::var("CHROMIUM_BIN") {
15 let path = PathBuf::from(&p);
16 if path.is_file() {
17 return Some(p);
18 }
19 }
20 let names = [
21 "chromium",
22 "chromium-browser",
23 "google-chrome",
24 "chrome",
25 "chrome-headless-shell",
26 ];
27 if let Some(path_var) = std::env::var_os("PATH") {
28 for dir in std::env::split_paths(&path_var) {
29 for name in &names {
30 let candidate = dir.join(name);
31 if candidate.is_file() {
32 return Some(candidate.to_string_lossy().into_owned());
33 }
34 }
35 }
36 }
37 if let Ok(entries) = std::fs::read_dir("/opt/playwright-browsers") {
38 for entry in entries.flatten() {
39 let base = entry.path();
40 // Lighthouse needs a full chrome (it drives DevTools), not the
41 // headless-shell. Prefer chrome-linux64/chrome, fall back to the
42 // chromium build that ships under chromium-*/chrome-linux/chrome.
43 for rel in [
44 "chrome-linux64/chrome",
45 "chrome-linux/chrome",
46 "chrome-headless-shell-linux64/chrome-headless-shell",
47 ] {
48 let candidate = base.join(rel);
49 if candidate.is_file() {
50 return Some(candidate.to_string_lossy().into_owned());
51 }
52 }
53 }
54 }
55 None
56}
57
58const SUBPROCESS_TIMEOUT_SECS: u64 = 180;
59const CHROME_FLAGS: &str = "--headless --no-sandbox --disable-dev-shm-usage --disable-gpu";
60
61#[derive(Debug, Error)]
62pub enum LighthouseError {
63 #[error("lighthouse binary missing at {0:?}")]
64 BinaryMissing(PathBuf),
65 #[error("lighthouse timed out after {0}s")]
66 Timeout(u64),
67 #[error("lighthouse exited {code}: {stderr}")]
68 ExitNonZero { code: i32, stderr: String },
69 #[error("could not parse lighthouse output: {0}")]
70 Parse(#[from] serde_json::Error),
71 #[error("missing category in lighthouse output: {0}")]
72 MissingCategory(&'static str),
73 #[error("null score(s) returned by lighthouse: {0:?}")]
74 NullScores(Vec<&'static str>),
75 #[error("subprocess io: {0}")]
76 Io(#[from] std::io::Error),
77}
78
79/// Run the lighthouse npm CLI and return the parsed JSON report.
80pub async fn fetch(root: &std::path::Path, url: &str) -> Result<Value, LighthouseError> {
81 let bin = root.join("node_modules/.bin/lighthouse");
82 if !bin.exists() {
83 return Err(LighthouseError::BinaryMissing(bin));
84 }
85
86 let chromium = find_chromium();
87
88 // `bun run --bun` symlinks `node` → bun, so the lighthouse shim's
89 // `#!/usr/bin/env node` shebang resolves to bun's runtime. Lets us drop
90 // nodejs/npm from the image entirely.
91 let mut cmd = Command::new("bun");
92 cmd.arg("run")
93 .arg("--bun")
94 .arg(&bin)
95 .arg(url)
96 .arg(format!("--chrome-flags={CHROME_FLAGS}"))
97 .arg("--output=json")
98 .arg("--output-path=stdout")
99 .arg("--quiet")
100 .env_clear()
101 .env("PATH", "/usr/bin:/bin:/usr/local/bin")
102 .stdout(Stdio::piped())
103 .stderr(Stdio::piped())
104 // On timeout the wait_with_output future is dropped; without this the
105 // orphaned lighthouse/chromium subprocess would keep running forever.
106 .kill_on_drop(true);
107 if let Some(c) = chromium {
108 cmd.env("CHROME_PATH", &c);
109 }
110
111 let child = cmd.spawn()?;
112 let output = match tokio::time::timeout(
113 Duration::from_secs(SUBPROCESS_TIMEOUT_SECS),
114 child.wait_with_output(),
115 )
116 .await
117 {
118 Ok(r) => r?,
119 Err(_) => return Err(LighthouseError::Timeout(SUBPROCESS_TIMEOUT_SECS)),
120 };
121
122 if !output.status.success() {
123 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
124 let truncated = stderr
125 .chars()
126 .rev()
127 .take(500)
128 .collect::<String>()
129 .chars()
130 .rev()
131 .collect::<String>();
132 return Err(LighthouseError::ExitNonZero {
133 code: output.status.code().unwrap_or(-1),
134 stderr: truncated,
135 });
136 }
137
138 Ok(serde_json::from_slice(&output.stdout)?)
139}
140
141#[derive(Debug, serde::Serialize)]
142pub struct Scores {
143 #[serde(rename = "Performance")]
144 pub performance: i64,
145 #[serde(rename = "Accessibility")]
146 pub accessibility: i64,
147 #[serde(rename = "Best practices")]
148 pub best_practices: i64,
149 #[serde(rename = "SEO")]
150 pub seo: i64,
151}
152
153pub fn parse_scores(results: &Value) -> Result<Scores, LighthouseError> {
154 let cats = results
155 .get("categories")
156 .ok_or(LighthouseError::MissingCategory("categories"))?;
157 let pull = |k: &'static str| -> Result<Option<f64>, LighthouseError> {
158 let cat = cats.get(k).ok_or(LighthouseError::MissingCategory(k))?;
159 Ok(cat.get("score").and_then(|v| v.as_f64()))
160 };
161 let p = pull("performance")?;
162 let a = pull("accessibility")?;
163 let b = pull("best-practices")?;
164 let s = pull("seo")?;
165 let mut nulls = Vec::new();
166 if p.is_none() { nulls.push("Performance"); }
167 if a.is_none() { nulls.push("Accessibility"); }
168 if b.is_none() { nulls.push("Best practices"); }
169 if s.is_none() { nulls.push("SEO"); }
170 if !nulls.is_empty() {
171 return Err(LighthouseError::NullScores(nulls));
172 }
173 let to_pct = |v: f64| (v * 100.0).round() as i64;
174 Ok(Scores {
175 performance: to_pct(p.unwrap()),
176 accessibility: to_pct(a.unwrap()),
177 best_practices: to_pct(b.unwrap()),
178 seo: to_pct(s.unwrap()),
179 })
180}
181
182#[derive(Debug, serde::Serialize)]
183pub struct Details {
184 pub metrics: Vec<Value>,
185 pub opportunities: Vec<Value>,
186}
187
188pub fn parse_details(results: &Value) -> Option<Details> {
189 let category = results.get("categories")?.get("performance")?;
190 let audits = results.get("audits")?.as_object()?;
191
192 let mut metrics: Vec<Value> = Vec::new();
193 let mut opportunities: Vec<Value> = Vec::new();
194
195 if let Some(refs) = category.get("auditRefs").and_then(|v| v.as_array()) {
196 for r in refs {
197 let id = r.get("id").and_then(|v| v.as_str()).unwrap_or_default();
198 let Some(audit) = audits.get(id) else { continue };
199 let group = r.get("group").and_then(|v| v.as_str()).unwrap_or("");
200 let weight = r.get("weight").and_then(|v| v.as_f64()).unwrap_or(0.0);
201 let score = audit.get("score").and_then(|v| v.as_f64());
202
203 if group == "metrics" && weight > 0.0 {
204 metrics.push(serde_json::json!({
205 "id": id,
206 "acronym": r.get("acronym").and_then(|v| v.as_str()).unwrap_or(id),
207 "title": audit.get("title"),
208 "display_value": audit.get("displayValue"),
209 "score": score,
210 "weight": weight,
211 }));
212 continue;
213 }
214
215 // Opportunities/diagnostics: skip passing/manual/not-applicable.
216 // `group: "hidden"` covers TTI and other audits Lighthouse keeps
217 // around but no longer scores; they shouldn't masquerade as wins.
218 if group == "hidden" {
219 continue;
220 }
221 let mode = audit
222 .get("scoreDisplayMode")
223 .and_then(|v| v.as_str())
224 .unwrap_or("");
225 if matches!(mode, "manual" | "notApplicable" | "informative") {
226 continue;
227 }
228 let Some(s) = score else { continue };
229 if s >= 0.9 {
230 continue;
231 }
232 let savings_ms = audit
233 .get("details")
234 .and_then(|d| d.get("overallSavingsMs"))
235 .and_then(|v| v.as_f64())
236 .unwrap_or(0.0);
237 let savings_bytes = audit
238 .get("details")
239 .and_then(|d| d.get("overallSavingsBytes"))
240 .and_then(|v| v.as_f64())
241 .unwrap_or(0.0);
242 let has_metric_savings = audit
243 .get("metricSavings")
244 .and_then(|v| v.as_object())
245 .map(|m| {
246 m.values()
247 .any(|v| v.as_f64().map(|n| n > 0.0).unwrap_or(false))
248 })
249 .unwrap_or(false);
250 // Require at least one actionable signal; pure diagnostics
251 // (forced-reflow, network-dependency-tree, etc.) carry none and
252 // would otherwise show up with a meaningless 0 score.
253 if savings_ms == 0.0 && savings_bytes == 0.0 && !has_metric_savings {
254 continue;
255 }
256 opportunities.push(serde_json::json!({
257 "id": id,
258 "title": audit.get("title"),
259 "display_value": audit.get("displayValue"),
260 "savings_ms": savings_ms,
261 }));
262 }
263 }
264
265 metrics.sort_by(|a, b| {
266 let aw = a.get("weight").and_then(|v| v.as_f64()).unwrap_or(0.0);
267 let bw = b.get("weight").and_then(|v| v.as_f64()).unwrap_or(0.0);
268 bw.partial_cmp(&aw).unwrap_or(std::cmp::Ordering::Equal)
269 });
270 opportunities.sort_by(|a, b| {
271 let asav = a.get("savings_ms").and_then(|v| v.as_f64()).unwrap_or(0.0);
272 let bsav = b.get("savings_ms").and_then(|v| v.as_f64()).unwrap_or(0.0);
273 bsav.partial_cmp(&asav).unwrap_or(std::cmp::Ordering::Equal)
274 });
275 opportunities.truncate(10);
276
277 Some(Details { metrics, opportunities })
278}