// src/tools/lighthouse.ts
// Lighthouse performance audit tool
import { exec } from 'child_process';
import { promisify } from 'util';
import * as fs from 'fs/promises';
import * as path from 'path';
import type {
LighthouseResult,
CoreWebVitals,
MetricResult,
LighthouseOpportunity,
LighthouseDiagnostic,
LighthouseAudit,
RunLighthouseInput,
} from '../types/index.js';
const execAsync = promisify(exec);
/**
* Run Lighthouse audit on a URL
*/
export async function runLighthouse(input: RunLighthouseInput): Promise<LighthouseResult> {
const {
url,
device = 'mobile',
categories = ['performance', 'accessibility', 'best-practices', 'seo'],
saveReport = false,
} = input;
// Create temp directory for output
const tmpDir = '/tmp/lighthouse-reports';
await fs.mkdir(tmpDir, { recursive: true });
const timestamp = Date.now();
const outputPath = path.join(tmpDir, `report-${timestamp}.json`);
const htmlOutputPath = saveReport ? path.join(tmpDir, `report-${timestamp}.html`) : null;
// Build Lighthouse command
const formFactor = device === 'mobile' ? 'mobile' : 'desktop';
const screenEmulation = device === 'mobile'
? '--screenEmulation.mobile --screenEmulation.width=412 --screenEmulation.height=823'
: '--screenEmulation.mobile=false --screenEmulation.width=1920 --screenEmulation.height=1080';
const categoriesArg = categories.map(c => `--only-categories=${c}`).join(' ');
const outputArg = saveReport
? `--output=json,html --output-path="${outputPath.replace('.json', '')}"`
: `--output=json --output-path="${outputPath}"`;
const cmd = `lighthouse "${url}" \
${outputArg} \
--chrome-flags="--headless --no-sandbox --disable-gpu" \
--preset=${formFactor} \
${screenEmulation} \
${categoriesArg} \
--quiet`;
console.error(`Running Lighthouse (${device}): ${url}`);
try {
await execAsync(cmd, {
timeout: 120000,
maxBuffer: 50 * 1024 * 1024, // 50MB buffer
});
} catch (error: any) {
// Lighthouse sometimes exits with non-zero even on success
// Check if output file was created
try {
await fs.access(outputPath);
} catch {
throw new Error(`Lighthouse failed: ${error.message}`);
}
}
// Parse the JSON report
const reportJson = await fs.readFile(outputPath, 'utf-8');
const report = JSON.parse(reportJson);
// Clean up temp files unless saveReport is true
if (!saveReport) {
await fs.unlink(outputPath).catch(() => {});
}
return parseReport(url, device, report, saveReport ? htmlOutputPath ?? undefined : undefined);
}
/**
* Parse Lighthouse JSON report into our format
*/
function parseReport(
url: string,
device: 'mobile' | 'desktop',
report: any,
reportPath?: string
): LighthouseResult {
const { categories, audits } = report;
// Extract Core Web Vitals
const coreWebVitals = extractCoreWebVitals(audits);
// Extract opportunities (things to fix)
const opportunities = extractOpportunities(audits);
// Extract diagnostics
const diagnostics = extractDiagnostics(audits);
// Extract SEO audits
const seoAudits = extractSeoAudits(audits);
// Extract accessibility audits
const accessibilityAudits = extractAccessibilityAudits(audits);
return {
url,
timestamp: new Date().toISOString(),
device,
scores: {
performance: Math.round((categories.performance?.score ?? 0) * 100),
accessibility: Math.round((categories.accessibility?.score ?? 0) * 100),
bestPractices: Math.round((categories['best-practices']?.score ?? 0) * 100),
seo: Math.round((categories.seo?.score ?? 0) * 100),
},
coreWebVitals,
opportunities,
diagnostics,
seoAudits,
accessibilityAudits,
reportPath,
};
}
/**
* Extract Core Web Vitals metrics
*/
function extractCoreWebVitals(audits: any): CoreWebVitals {
const getMetric = (id: string, unit: 'ms' | 'score' | 's' = 'ms'): MetricResult => {
const audit = audits[id];
const value = audit?.numericValue ?? 0;
const displayValue = audit?.displayValue ?? '';
// Determine rating based on metric type
let rating: 'good' | 'needs-improvement' | 'poor';
switch (id) {
case 'largest-contentful-paint':
rating = value <= 2500 ? 'good' : value <= 4000 ? 'needs-improvement' : 'poor';
break;
case 'cumulative-layout-shift':
rating = value <= 0.1 ? 'good' : value <= 0.25 ? 'needs-improvement' : 'poor';
break;
case 'total-blocking-time':
case 'max-potential-fid':
rating = value <= 200 ? 'good' : value <= 600 ? 'needs-improvement' : 'poor';
break;
case 'first-contentful-paint':
rating = value <= 1800 ? 'good' : value <= 3000 ? 'needs-improvement' : 'poor';
break;
case 'server-response-time':
rating = value <= 200 ? 'good' : value <= 500 ? 'needs-improvement' : 'poor';
break;
case 'speed-index':
rating = value <= 3400 ? 'good' : value <= 5800 ? 'needs-improvement' : 'poor';
break;
case 'interactive':
rating = value <= 3800 ? 'good' : value <= 7300 ? 'needs-improvement' : 'poor';
break;
default:
rating = 'good';
}
return {
value: unit === 'score' ? value : Math.round(value),
unit,
rating,
displayValue,
};
};
return {
lcp: getMetric('largest-contentful-paint'),
cls: getMetric('cumulative-layout-shift', 'score'),
inp: getMetric('total-blocking-time'), // TBT as lab proxy for INP
fcp: getMetric('first-contentful-paint'),
ttfb: getMetric('server-response-time'),
si: getMetric('speed-index'),
tti: getMetric('interactive'),
tbt: getMetric('total-blocking-time'),
};
}
/**
* Extract optimization opportunities
*/
function extractOpportunities(audits: any): LighthouseOpportunity[] {
const opportunityIds = [
'render-blocking-resources',
'unused-css-rules',
'unused-javascript',
'modern-image-formats',
'uses-optimized-images',
'uses-responsive-images',
'efficient-animated-content',
'uses-text-compression',
'preload-lcp-image',
'unminified-css',
'unminified-javascript',
'uses-rel-preconnect',
'server-response-time',
'redirects',
'uses-rel-preload',
'total-byte-weight',
'dom-size',
'offscreen-images',
];
return opportunityIds
.filter(id => audits[id] && audits[id].score !== null && audits[id].score < 1)
.map(id => {
const audit = audits[id];
return {
id,
title: audit.title,
description: audit.description?.replace(/<[^>]+>/g, '') ?? '', // Strip HTML
savings: audit.displayValue ?? '',
savingsMs: audit.details?.overallSavingsMs,
savingsBytes: audit.details?.overallSavingsBytes,
};
})
.sort((a, b) => (b.savingsMs ?? 0) - (a.savingsMs ?? 0));
}
/**
* Extract diagnostics
*/
function extractDiagnostics(audits: any): LighthouseDiagnostic[] {
const diagnosticIds = [
'critical-request-chains',
'long-tasks',
'layout-shift-elements',
'largest-contentful-paint-element',
'third-party-summary',
'bootup-time',
'mainthread-work-breakdown',
'font-display',
'unsized-images',
];
return diagnosticIds
.filter(id => audits[id] && audits[id].score !== null)
.map(id => {
const audit = audits[id];
return {
id,
title: audit.title,
description: audit.description?.replace(/<[^>]+>/g, '') ?? '',
displayValue: audit.displayValue,
};
});
}
/**
* Extract SEO-specific audits
*/
function extractSeoAudits(audits: any): LighthouseAudit[] {
const seoIds = [
'document-title',
'meta-description',
'http-status-code',
'link-text',
'crawlable-anchors',
'is-crawlable',
'robots-txt',
'hreflang',
'canonical',
'structured-data',
'viewport',
'font-size',
'tap-targets',
'image-alt',
];
return seoIds
.filter(id => audits[id])
.map(id => {
const audit = audits[id];
return {
id,
title: audit.title,
description: audit.description?.replace(/<[^>]+>/g, '') ?? '',
score: audit.score,
passed: audit.score === 1,
};
});
}
/**
* Extract accessibility audits
*/
function extractAccessibilityAudits(audits: any): LighthouseAudit[] {
const a11yIds = [
'aria-allowed-attr',
'aria-hidden-body',
'aria-required-attr',
'aria-valid-attr-value',
'aria-valid-attr',
'button-name',
'bypass',
'color-contrast',
'document-title',
'duplicate-id-active',
'duplicate-id-aria',
'form-field-multiple-labels',
'frame-title',
'heading-order',
'html-has-lang',
'html-lang-valid',
'image-alt',
'input-image-alt',
'label',
'link-name',
'list',
'listitem',
'meta-refresh',
'meta-viewport',
'object-alt',
'tabindex',
'td-headers-attr',
'th-has-data-cells',
'valid-lang',
'video-caption',
];
return a11yIds
.filter(id => audits[id] && audits[id].score !== null && audits[id].score < 1)
.map(id => {
const audit = audits[id];
return {
id,
title: audit.title,
description: audit.description?.replace(/<[^>]+>/g, '') ?? '',
score: audit.score,
passed: audit.score === 1,
};
});
}
/**
* Check if Lighthouse is installed
*/
export async function checkLighthouseInstalled(): Promise<boolean> {
try {
await execAsync('lighthouse --version');
return true;
} catch {
return false;
}
}
export default runLighthouse;