code update recorded at: 09/02/26 06:37:30
This commit is contained in:
commit
567263d508
|
|
@ -0,0 +1,43 @@
|
||||||
|
# Use the official Playwright base image as the builder stage
|
||||||
|
FROM mcr.microsoft.com/playwright:v1.51.1-jammy AS builder
|
||||||
|
|
||||||
|
# Install essential build tools and dependencies required for native modules and general tooling
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
curl \
|
||||||
|
gcc \
|
||||||
|
git \
|
||||||
|
libc-dev \
|
||||||
|
openssh-client \
|
||||||
|
make \
|
||||||
|
g++
|
||||||
|
|
||||||
|
# Create and set working directory for the application
|
||||||
|
RUN mkdir /lighthouse-report
|
||||||
|
WORKDIR /lighthouse-report
|
||||||
|
|
||||||
|
# Copy package definition files for dependency installation
|
||||||
|
COPY ./package.json .
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Copy the rest of the application code into the image
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# ----------------------------------------
|
||||||
|
|
||||||
|
# Create a second stage using the same base image to keep the final image slim
|
||||||
|
FROM mcr.microsoft.com/playwright:v1.51.1-jammy
|
||||||
|
|
||||||
|
# Create and set the working directory
|
||||||
|
RUN mkdir /lighthouse-report
|
||||||
|
WORKDIR /lighthouse-report
|
||||||
|
|
||||||
|
# Copy the application code and installed node_modules from the builder stage
|
||||||
|
COPY --from=builder /lighthouse-report .
|
||||||
|
|
||||||
|
# Install required Playwright browsers (like Chromium)
|
||||||
|
RUN npx playwright install
|
||||||
|
|
||||||
|
# Set the default entry point for the container
|
||||||
|
ENTRYPOINT ["node", "handler.js"]
|
||||||
|
|
@ -0,0 +1,261 @@
|
||||||
|
// 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();
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"name": "boltic-lighthouse-report",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Analyze web apps and web pages using Lighthouse to collect modern performance metrics and gain insights into developer best practices.",
|
||||||
|
"main": "handler.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node handler.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@playwright/test": "^1.51.1",
|
||||||
|
"body-parser": "^2.2.0",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"lighthouse": "^12.5.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0"
|
||||||
|
},
|
||||||
|
"author": "Boltic",
|
||||||
|
"license": "MIT",
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user