lighthouse-report/handler.js

262 lines
8.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Import the required modules
import { chromium } from '@playwright/test';
import lighthouse from 'lighthouse';
import lighthouseDesktopConfig from "lighthouse/core/config/desktop-config.js"
import fs from 'fs';
import express from 'express';
import bodyParser from 'body-parser';
// Define the port to listen on
const PORT = process.env.BOLT_APPLICATION_PORT || 8080;
const DEV_MODE = process.env.BOLT_DEVELOPMENT_MODE || false;
// Log Level
// Possible values: silent, error, warn, info, verbose
const LOG_LEVEL = process.env.LOG_LEVEL || "error";
// Maximum allowed connections in the connection pool
const BROWSER_PORT = parseInt(process.env.BROWSER_PORT) || 9000;
// Save reports to disk
const SAVE_REPORTS_TO_DISK = process.env.SAVE_REPORTS_TO_DISK === 'true';
// Type of report to generate: pdf or html
let REPORT_TYPE = process.env.REPORT_TYPE || "pdf";
REPORT_TYPE = REPORT_TYPE.toLowerCase();
// Default timeout for Lighthouse
const DEFAULT_TIMEOUT_IN_SECONDS = parseInt(process.env.DEFAULT_TIMEOUT_IN_SECONDS) || 60;
// Evaluation Categories
const CATEGORIES = [
"accessibility",
"best-practices",
"performance",
"pwa",
"seo",
]
// Device Types
const DEVICE_TYPES = {
DESKTOP: "desktop",
MOBILE: "mobile",
}
// Declare global browser instance
let GLOBAL_BROWSER = null;
/**
* Express.js handler for incoming requests.
* Run Lighthouse and return the result.
* @param {Object} req - Express request object.
* @param {Object} res - Express response object.
*/
export const handler = async (req, res) => {
try {
console.log('Received request:', JSON.stringify(req.body));
// Ensure the request method is POST
if (req.method !== 'POST') {
return res.status(400).json({ error: 'Invalid method, only POST allowed.' });
}
// Validate REPORT_TYPE
if (REPORT_TYPE !== "pdf" && REPORT_TYPE !== "html") {
return res.status(400).json({ error: `Invalid REPORT_TYPE ${REPORT_TYPE}, only pdf or html allowed.` });
}
// Extract and validate request body
validateRequestBody(req.body);
// Initialize startTime
const startTime = new Date();
// Run Lighthouse
const result = await runLighthouse(req.body);
// Calculate elapsed time in seconds
const elapsedTime = (new Date() - startTime) / 1000;
// Log the elapsed time
console.log(`Lighthouse execution time: ${elapsedTime.toFixed(3)} seconds`);
// Send the result
res.setHeader('Content-Type', 'application/json');
res.status(200).json({
...result,
executionTime: Number(elapsedTime.toFixed(3))
});
} catch (error) {
console.error('Error handling request:', error.message);
res.status(500).json({ error: error.message });
}
};
/**
* Validates the request body for required fields and structure.
* @param {Object} body - Request body containing the required fields.
* @throws {Error} If validation fails.
*/
const validateRequestBody = ({ url, timeout, categories, device }) => {
const errors = [];
// Ensure the query string is provided and is a valid URL
if (!url) {
errors.push('URL is required.');
} else {
const urlPattern = /^(https?:\/\/)[^\s/$.?#].[^\s]*$/i;
if (!urlPattern.test(url)) {
errors.push(`URL ${url} is not valid.`);
}
}
// Ensure the timeout is a positive integer
if (timeout && (isNaN(timeout) || timeout <= 0)) {
errors.push('Timeout must be a positive integer.');
}
// Ensure the categories selected is from the allowed values
if (categories && categories.length > 0) {
for (const category of categories) {
if (!CATEGORIES.includes(category)) {
errors.push(`Category ${category} is not valid.`);
}
}
}
// Ensure the device is from the allowed values
if (device && !Object.values(DEVICE_TYPES).includes(device)) {
errors.push(`Device ${device} is not valid.`);
}
// Throw error if any validation fails
if (errors.length) {
var msg = `Validation errors: ${errors.join(' ')}`
console.log(msg)
throw new Error(msg);
}
};
async function runLighthouse({ url, timeout = DEFAULT_TIMEOUT_IN_SECONDS, device = DEVICE_TYPES.DESKTOP, categories = CATEGORIES }) {
let context;
let page;
try {
// Launch browser only if it's not already running
if (!GLOBAL_BROWSER) {
GLOBAL_BROWSER = await chromium.launch({
args: [`--remote-debugging-port=${BROWSER_PORT}`],
});
console.log('Launched new browser instance.');
} else {
console.log('Reusing existing browser instance.');
}
// Set up Lighthouse options
const options = {
logLevel: LOG_LEVEL,
output: 'html',
port: BROWSER_PORT,
ignoreStatusCode: false,
maxWaitForLoad: timeout * 1000,
...(device === DEVICE_TYPES.DESKTOP ? lighthouseDesktopConfig.settings : {}),
emulatedUserAgent: `Boltic Serverless v1.0 with mode ${device}`,
onlyCategories: categories,
};
console.log("Running Lighthouse with options:", JSON.stringify(options))
const runnerResult = await lighthouse(url, options);
const reportHtml = runnerResult.report;
// Extract performance metrics from the lighthouse results
const metrics = {
firstContentfulPaint: runnerResult.lhr.audits['first-contentful-paint'].numericValue ?? 0,
largestContentfulPaint: runnerResult.lhr.audits['largest-contentful-paint'].numericValue ?? 0,
speedIndex: runnerResult.lhr.audits['speed-index'].numericValue ?? 0,
totalBlockingTime: runnerResult.lhr.audits['total-blocking-time'].numericValue ?? 0,
cumulativeLayoutShift: runnerResult.lhr.audits['cumulative-layout-shift'].numericValue ?? 0,
timeToInteractive: runnerResult.lhr.audits['interactive'].numericValue ?? 0,
performanceScore: (runnerResult.lhr.categories.performance?.score ?? 0) * 100
};
// Create a new incognito browser context
context = await GLOBAL_BROWSER.newContext();
page = await context.newPage()
// Generate PDF directly without saving HTML first
await page.setContent(reportHtml, {
waitUntil: 'load',
});
const pdfBuffer = await page.pdf({
format: 'A4',
printBackground: true,
margin: {
top: '10px',
right: '10px',
bottom: '10px',
left: '10px'
}
});
if (SAVE_REPORTS_TO_DISK) {
fs.writeFileSync('lighthouse-report.html', reportHtml);
fs.writeFileSync('lighthouse-report.pdf', pdfBuffer);
}
// Check report size
const reportSizeInMB = Buffer.byteLength(reportHtml) / (1024 * 1024);
if (reportSizeInMB > 4) {
console.warn(`Warning: Report size (${reportSizeInMB.toFixed(2)} MB) exceeds 4MB`);
// throw new Error(`Report size (${reportSizeInMB.toFixed(2)} MB) exceeds 4MB`) // Uncomment if needed
} else {
console.log(`Report size: ${reportSizeInMB.toFixed(2)} MB`);
}
return {
reports: {
html: REPORT_TYPE === "html" ? Buffer.from(reportHtml).toString('base64') : "",
pdf: REPORT_TYPE === "pdf" ? pdfBuffer.toString('base64') : "",
type: REPORT_TYPE
},
metrics: {
firstContentfulPaint: Number((metrics.firstContentfulPaint / 1000).toFixed(2)),
largestContentfulPaint: Number((metrics.largestContentfulPaint / 1000).toFixed(2)),
speedIndex: Number((metrics.speedIndex / 1000).toFixed(2)),
totalBlockingTime: Number(metrics.totalBlockingTime.toFixed(2)),
cumulativeLayoutShift: Number(metrics.cumulativeLayoutShift.toFixed(3)),
timeToInteractive: Number((metrics.timeToInteractive / 1000).toFixed(2)),
performanceScore: Number(metrics.performanceScore.toFixed(0)),
}
};
} catch (error) {
console.error('Error generating lighthouse report:', error);
throw error;
} finally {
if (page) await page.close();
if (context) await context.close();
// Don't close the browser here keep it running globally
}
}
// Initialize the express application
const app = express();
// Disable x-powered-by header
app.disable('x-powered-by');
// parse application/json
app.use(bodyParser.json())
// Use the request handler function for all routes
app.all('*', handler);
// Start the server and listen on the defined port
app.listen(PORT, () => {
if (DEV_MODE) {
console.log(
`Listening for events on port ${PORT} in development mode`
);
} else {
console.log(
`Listening for events`
);
}
});
process.on('SIGINT', async () => {
console.log('Shutting down...');
if (GLOBAL_BROWSER) {
await GLOBAL_BROWSER.close();
console.log('Browser closed.');
}
process.exit();
});