commit 2c9eba0c0230156ec5a28f8e6e7bc8879d09769c Author: Sukhpreet Lohiya Date: Mon Feb 9 06:37:30 2026 +0000 code update recorded at: 09/02/26 06:37:30 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..51981a9 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/handler.js b/handler.js new file mode 100644 index 0000000..9d13762 --- /dev/null +++ b/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(); +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..1268ac1 --- /dev/null +++ b/package.json @@ -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" +} \ No newline at end of file