import { createLogger } from "../util/logger.js"; import { extractAuditReport } from "./extract.js"; import { describeLaunchFailure, findAuditBrowser, NoBrowserError, type FoundBrowser } from "./find-browser.js"; import type { AuditCategory, AuditReport, LighthouseResultLike } from "./types.js"; const log = createLogger("lighthouse"); export interface AuditHooks { /** Receives the unabridged Lighthouse result, for persisting as an artifact. */ onRawResult?: (lhr: unknown) => void; } export interface AuditOptions { url: string; category: AuditCategory; /** "desktop" (default) or "mobile". */ device?: "desktop" | "mobile"; timeoutMs?: number; } export class AuditError extends Error { constructor(message: string) { super(message); this.name = "AuditError"; } } /** Screen, throttling and user agent have to describe the same device. */ const DEVICE_PROFILES = { desktop: { screenEmulation: { mobile: false, width: 1350, height: 940, deviceScaleFactor: 1, disabled: false }, throttlingPreset: "desktopDense4G", }, mobile: { screenEmulation: { mobile: true, width: 412, height: 823, deviceScaleFactor: 1.75, disabled: false }, throttlingPreset: "mobileSlow4G", }, } as const; /** * Lighthouse's own presets, so an audit and any future emulation agree on what * "mobile" means rather than each carrying its own numbers. * * Loaded on demand and memoised. A static import costs ~30ms at startup, * because the connector imports this module eagerly and only audits ever need * these values — and answering `initialize` promptly is the reason the second * process went away. */ let devicePresets: Promise<{ throttling: any; userAgents: any }> | undefined; function loadDevicePresets() { devicePresets ??= import("lighthouse/core/config/constants.js").then((m) => { const c = (m as any).default ?? m; return { throttling: c.throttling, userAgents: c.userAgents }; }); return devicePresets; } export interface LighthouseFlagOptions { category: AuditCategory; device: "desktop" | "mobile"; port: number; timeoutMs: number; } /** * Builds the flags for a run. * * formFactor and screenEmulation were set but throttling and emulatedUserAgent * were not, so a desktop audit ran a desktop viewport under Lighthouse's * default mobile Slow-4G throttling while identifying itself as a phone — * three settings disagreeing about the device, and a score shaped by whichever * one mattered most. */ export async function buildLighthouseFlags(options: LighthouseFlagOptions) { const profile = DEVICE_PROFILES[options.device]; const { throttling, userAgents } = await loadDevicePresets(); return { port: options.port, output: "json" as const, logLevel: "error" as const, onlyCategories: [options.category], formFactor: options.device, screenEmulation: profile.screenEmulation, throttling: throttling[profile.throttlingPreset], emulatedUserAgent: userAgents[options.device], maxWaitForLoad: options.timeoutMs, }; } /** Only real web pages can be audited; anything else is a configuration mistake. */ export function assertAuditableUrl(url: string): URL { let parsed: URL; try { parsed = new URL(url); } catch { throw new AuditError(`Not a valid URL: ${JSON.stringify(url)}`); } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { throw new AuditError( `Cannot audit ${parsed.protocol} URLs — navigate to an http(s) page first.` ); } return parsed; } const CHROME_FLAGS = [ "--headless=new", "--disable-gpu", "--no-sandbox", "--disable-dev-shm-usage", "--disable-extensions", "--mute-audio", ]; /** * Runs Lighthouse against a freshly launched headless Chrome. * * Chrome and Lighthouse are imported lazily so that a partial install degrades * to "audits unavailable" instead of preventing the whole server from starting. */ export async function runLighthouseAudit( options: AuditOptions, hooks: AuditHooks = {} ): Promise { const { category, device = "desktop", timeoutMs = 60_000 } = options; const url = assertAuditableUrl(options.url).toString(); let chrome: { port: number; kill: () => Promise } | undefined; let chosen: FoundBrowser | undefined; try { const [chromeLauncher, lighthouseModule] = await Promise.all([ import("chrome-launcher"), import("lighthouse"), ]); const lighthouse = (lighthouseModule.default ?? lighthouseModule) as any; // chrome-launcher only knows about Chrome and Chromium. Anyone running Arc, // Brave or Edge with no Chrome installed would otherwise lose every audit. const browser = findAuditBrowser({ installed: () => (chromeLauncher as any).Launcher?.getInstallations?.() ?? [], }); chosen = browser; log.debug(`Running the audit in ${browser.name} (${browser.source})`); chrome = (await chromeLauncher.launch({ chromeFlags: CHROME_FLAGS, chromePath: browser.path, })) as unknown as { port: number; kill: () => Promise }; const flags = await buildLighthouseFlags({ category, device, port: chrome.port, timeoutMs }); const result = (await withTimeout( lighthouse(url, flags), timeoutMs + 15_000, `Lighthouse did not finish within ${Math.round((timeoutMs + 15_000) / 1000)}s` )) as { lhr?: LighthouseResultLike } | undefined; const lhr = result?.lhr; if (!lhr) throw new AuditError("Lighthouse returned no result"); hooks.onRawResult?.(lhr); return extractAuditReport(lhr, url, category, device); } catch (error) { if (error instanceof AuditError) throw error; // Already a clear, actionable explanation; do not bury it. if (error instanceof NoBrowserError) throw new AuditError(error.message); const message = error instanceof Error ? error.message : String(error); if (/Cannot find module|ERR_MODULE_NOT_FOUND/.test(message)) { throw new AuditError( "Lighthouse or Chrome is not available in this install, so audits cannot run." ); } if (/No Chrome installations found|ChromePathNotSet/i.test(message)) { throw new AuditError( "No Chromium-based browser could be launched for the audit. Set CHROME_PATH to the " + "executable of a browser you have installed." ); } // A browser that was located but never opened its debugging port. if (chosen && /ECONNREFUSED|Failed to launch|spawn|SIGABRT|socket hang up/i.test(message)) { throw new AuditError(describeLaunchFailure(chosen, message)); } throw new AuditError(`${category} audit failed: ${message}`); } finally { if (chrome) { try { await chrome.kill(); } catch (error) { log.warn("Could not shut down the audit Chrome instance:", error); } } } } function withTimeout(promise: Promise, ms: number, message: string): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new AuditError(message)), ms); timer.unref?.(); promise.then( (value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); } ); }); }