secure-web/lighthouse/server.js

329 lines
11 KiB
JavaScript

/**
* Lighthouse Scanner Service
*
* This service provides an HTTP API for running Lighthouse audits.
* It's designed to be called from the Django backend via Celery tasks.
*/
const express = require('express');
const lighthouse = require('lighthouse');
const chromeLauncher = require('chrome-launcher');
const { v4: uuidv4 } = require('uuid');
const fs = require('fs').promises;
const path = require('path');
const app = express();
app.use(express.json());
const PORT = process.env.PORT || 3001;
const REPORTS_DIR = path.join(__dirname, 'reports');
// Ensure reports directory exists
fs.mkdir(REPORTS_DIR, { recursive: true }).catch(console.error);
/**
* Health check endpoint
*/
app.get('/health', (req, res) => {
res.json({ status: 'healthy', service: 'lighthouse-scanner' });
});
/**
* Run Lighthouse audit for a given URL
*
* POST /scan
* Body: { "url": "https://example.com" }
*
* Returns: Lighthouse audit results as JSON
*/
app.post('/scan', async (req, res) => {
const { url } = req.body;
if (!url) {
return res.status(400).json({ error: 'URL is required' });
}
// Validate URL format
try {
new URL(url);
} catch (e) {
return res.status(400).json({ error: 'Invalid URL format' });
}
const scanId = uuidv4();
console.log(`[${scanId}] Starting Lighthouse scan for: ${url}`);
let chrome = null;
try {
// Launch Chrome
chrome = await chromeLauncher.launch({
chromeFlags: [
'--headless',
'--disable-gpu',
'--no-sandbox',
'--disable-dev-shm-usage',
'--disable-extensions',
'--disable-background-networking',
'--disable-sync',
'--disable-translate',
'--metrics-recording-only',
'--mute-audio',
'--no-first-run',
'--safebrowsing-disable-auto-update'
]
});
console.log(`[${scanId}] Chrome launched on port ${chrome.port}`);
// Lighthouse configuration
const options = {
logLevel: 'error',
output: 'json',
port: chrome.port,
onlyCategories: ['performance', 'accessibility', 'best-practices', 'seo'],
// Throttling settings for more realistic results
throttling: {
cpuSlowdownMultiplier: 4,
downloadThroughputKbps: 1638.4,
uploadThroughputKbps: 675,
rttMs: 150
},
screenEmulation: {
mobile: false,
width: 1920,
height: 1080,
deviceScaleFactor: 1,
disabled: false
},
formFactor: 'desktop'
};
// Run Lighthouse
const runnerResult = await lighthouse(url, options);
// Extract the report
const report = runnerResult.lhr;
// Process and extract key metrics
const result = {
scanId,
url: report.finalUrl || url,
fetchTime: report.fetchTime,
// Category scores (0-100)
scores: {
performance: Math.round((report.categories.performance?.score || 0) * 100),
accessibility: Math.round((report.categories.accessibility?.score || 0) * 100),
bestPractices: Math.round((report.categories['best-practices']?.score || 0) * 100),
seo: Math.round((report.categories.seo?.score || 0) * 100)
},
// Core Web Vitals and key metrics
metrics: {
firstContentfulPaint: {
value: report.audits['first-contentful-paint']?.numericValue || null,
unit: 'ms',
score: report.audits['first-contentful-paint']?.score || null
},
largestContentfulPaint: {
value: report.audits['largest-contentful-paint']?.numericValue || null,
unit: 'ms',
score: report.audits['largest-contentful-paint']?.score || null
},
speedIndex: {
value: report.audits['speed-index']?.numericValue || null,
unit: 'ms',
score: report.audits['speed-index']?.score || null
},
timeToInteractive: {
value: report.audits['interactive']?.numericValue || null,
unit: 'ms',
score: report.audits['interactive']?.score || null
},
totalBlockingTime: {
value: report.audits['total-blocking-time']?.numericValue || null,
unit: 'ms',
score: report.audits['total-blocking-time']?.score || null
},
cumulativeLayoutShift: {
value: report.audits['cumulative-layout-shift']?.numericValue || null,
unit: 'score',
score: report.audits['cumulative-layout-shift']?.score || null
}
},
// JavaScript and resource audits
resources: {
totalByteWeight: report.audits['total-byte-weight']?.numericValue || null,
bootupTime: report.audits['bootup-time']?.numericValue || null,
mainThreadWork: report.audits['mainthread-work-breakdown']?.numericValue || null,
// Unused resources
unusedJavascript: extractUnusedResources(report.audits['unused-javascript']),
unusedCss: extractUnusedResources(report.audits['unused-css-rules']),
// Render blocking resources
renderBlockingResources: extractRenderBlockingResources(report.audits['render-blocking-resources']),
// Large bundles
scriptTreemap: extractLargeScripts(report.audits['script-treemap-data']),
// Third party usage
thirdPartySummary: extractThirdPartySummary(report.audits['third-party-summary'])
},
// Diagnostics
diagnostics: {
numRequests: report.audits['network-requests']?.details?.items?.length || 0,
numScripts: countResourcesByType(report.audits['network-requests'], 'Script'),
numStylesheets: countResourcesByType(report.audits['network-requests'], 'Stylesheet'),
numImages: countResourcesByType(report.audits['network-requests'], 'Image'),
numFonts: countResourcesByType(report.audits['network-requests'], 'Font'),
totalTransferSize: report.audits['total-byte-weight']?.numericValue || 0
},
// Failed audits (potential issues)
issues: extractFailedAudits(report)
};
// Save full report to file for debugging
const reportPath = path.join(REPORTS_DIR, `${scanId}.json`);
await fs.writeFile(reportPath, JSON.stringify(report, null, 2));
console.log(`[${scanId}] Scan completed successfully`);
res.json(result);
} catch (error) {
console.error(`[${scanId}] Scan failed:`, error);
res.status(500).json({
error: 'Lighthouse scan failed',
message: error.message,
scanId
});
} finally {
if (chrome) {
await chrome.kill();
}
}
});
/**
* Get a saved report by ID
*/
app.get('/report/:scanId', async (req, res) => {
const { scanId } = req.params;
const reportPath = path.join(REPORTS_DIR, `${scanId}.json`);
try {
const report = await fs.readFile(reportPath, 'utf8');
res.json(JSON.parse(report));
} catch (error) {
res.status(404).json({ error: 'Report not found' });
}
});
// =============================================================================
// Helper Functions
// =============================================================================
function extractUnusedResources(audit) {
if (!audit?.details?.items) return [];
return audit.details.items.slice(0, 10).map(item => ({
url: item.url,
totalBytes: item.totalBytes,
wastedBytes: item.wastedBytes,
wastedPercent: item.wastedPercent
}));
}
function extractRenderBlockingResources(audit) {
if (!audit?.details?.items) return [];
return audit.details.items.map(item => ({
url: item.url,
wastedMs: item.wastedMs,
totalBytes: item.totalBytes
}));
}
function extractLargeScripts(audit) {
if (!audit?.details?.nodes) return [];
// Get scripts larger than 100KB
const largeScripts = [];
const processNode = (node, path = '') => {
const currentPath = path ? `${path}/${node.name}` : node.name;
if (node.resourceBytes > 100 * 1024) {
largeScripts.push({
name: currentPath,
resourceBytes: node.resourceBytes,
unusedBytes: node.unusedBytes || 0
});
}
if (node.children) {
node.children.forEach(child => processNode(child, currentPath));
}
};
audit.details.nodes.forEach(node => processNode(node));
return largeScripts.slice(0, 20);
}
function extractThirdPartySummary(audit) {
if (!audit?.details?.items) return [];
return audit.details.items.slice(0, 10).map(item => ({
entity: item.entity,
transferSize: item.transferSize,
blockingTime: item.blockingTime,
mainThreadTime: item.mainThreadTime
}));
}
function countResourcesByType(audit, type) {
if (!audit?.details?.items) return 0;
return audit.details.items.filter(item => item.resourceType === type).length;
}
function extractFailedAudits(report) {
const issues = [];
const categoriesToCheck = ['performance', 'accessibility', 'best-practices', 'seo'];
categoriesToCheck.forEach(categoryId => {
const category = report.categories[categoryId];
if (!category?.auditRefs) return;
category.auditRefs.forEach(ref => {
const audit = report.audits[ref.id];
// Include audits with score < 0.5 (50%)
if (audit && audit.score !== null && audit.score < 0.5) {
issues.push({
id: audit.id,
category: categoryId,
title: audit.title,
description: audit.description,
score: audit.score,
displayValue: audit.displayValue,
impact: ref.weight || 0
});
}
});
});
// Sort by impact (weight) descending
issues.sort((a, b) => b.impact - a.impact);
return issues.slice(0, 30);
}
// Start the server
app.listen(PORT, '0.0.0.0', () => {
console.log(`Lighthouse Scanner Service running on port ${PORT}`);
});