@bschauer/webtools-mcp-server
Version:
MCP server providing web analysis tools including screenshot, debug, performance, security, accessibility, SEO, and asset optimization capabilities
279 lines (247 loc) • 10.7 kB
JavaScript
/**
* JavaScript Coverage Analyzer
* Analyzes JavaScript coverage data from Chrome DevTools Protocol
*/
import { logInfo, logError } from "../../../utils/logging.js";
import { URL } from "url";
/**
* Analyze JavaScript coverage data
* @param {Array} jsCoverage - JavaScript coverage data from CDP
* @param {Object} options - Analysis options
* @param {string} options.url - The URL of the page being analyzed
* @param {boolean} options.includeThirdParty - Whether to include third-party scripts in the analysis
* @returns {Promise<Object>} JavaScript coverage analysis
*/
export async function analyzeJSCoverage(jsCoverage, options = {}) {
const { url, includeThirdParty = true } = options;
try {
if (!jsCoverage || !Array.isArray(jsCoverage) || jsCoverage.length === 0) {
return {
error: "No JavaScript coverage data available",
totalBytes: 0,
unusedBytes: 0,
unusedPercentage: 0,
files: [],
unusedByFile: [],
thirdPartyAnalysis: {
totalBytes: 0,
unusedBytes: 0,
unusedPercentage: 0,
files: [],
},
recommendations: [],
};
}
// Parse the base URL to determine first-party vs third-party
const baseUrl = new URL(url);
const baseDomain = baseUrl.hostname;
// Process each script
const scriptAnalysis = jsCoverage.map((script) => {
const scriptUrl = script.url;
let isThirdParty = false;
// Determine if script is third-party
if (scriptUrl) {
try {
const scriptDomain = new URL(scriptUrl).hostname;
isThirdParty = scriptDomain !== baseDomain && !scriptDomain.endsWith(`.${baseDomain}`);
} catch (e) {
// If URL parsing fails, consider it first-party (could be a data URL or relative path)
isThirdParty = false;
}
}
// Calculate coverage statistics
const totalBytes = script.functions.reduce((sum, func) => sum + func.ranges.reduce((s, range) => s + range.endOffset - range.startOffset, 0), 0);
// Calculate unused bytes
let unusedBytes = 0;
const unusedRanges = [];
for (const func of script.functions) {
for (const range of func.ranges) {
if (!range.count) {
unusedBytes += range.endOffset - range.startOffset;
unusedRanges.push({
startOffset: range.startOffset,
endOffset: range.endOffset,
startLine: getLineFromOffset(script.scriptSource, range.startOffset),
endLine: getLineFromOffset(script.scriptSource, range.endOffset),
length: range.endOffset - range.startOffset,
});
}
}
}
// Calculate percentage
const unusedPercentage = totalBytes > 0 ? (unusedBytes / totalBytes) * 100 : 0;
return {
url: scriptUrl || "(inline script)",
isThirdParty,
totalBytes,
unusedBytes,
unusedPercentage,
unusedRanges: unusedRanges.sort((a, b) => b.length - a.length).slice(0, 10), // Top 10 largest unused ranges
};
});
// Filter scripts based on third-party setting
const filteredScripts = includeThirdParty ? scriptAnalysis : scriptAnalysis.filter((script) => !script.isThirdParty);
// Calculate overall statistics
const totalBytes = filteredScripts.reduce((sum, script) => sum + script.totalBytes, 0);
const unusedBytes = filteredScripts.reduce((sum, script) => sum + script.unusedBytes, 0);
const unusedPercentage = totalBytes > 0 ? (unusedBytes / totalBytes) * 100 : 0;
// Sort scripts by unused bytes (descending)
const sortedScripts = [...filteredScripts].sort((a, b) => b.unusedBytes - a.unusedBytes);
// Analyze third-party scripts separately
const thirdPartyScripts = scriptAnalysis.filter((script) => script.isThirdParty);
const thirdPartyTotalBytes = thirdPartyScripts.reduce((sum, script) => sum + script.totalBytes, 0);
const thirdPartyUnusedBytes = thirdPartyScripts.reduce((sum, script) => sum + script.unusedBytes, 0);
const thirdPartyUnusedPercentage = thirdPartyTotalBytes > 0 ? (thirdPartyUnusedBytes / thirdPartyTotalBytes) * 100 : 0;
// Generate recommendations
const recommendations = generateJSRecommendations(sortedScripts, thirdPartyScripts);
return {
totalBytes,
unusedBytes,
unusedPercentage,
files: filteredScripts.map((script) => ({
url: script.url,
isThirdParty: script.isThirdParty,
totalBytes: script.totalBytes,
unusedBytes: script.unusedBytes,
unusedPercentage: script.unusedPercentage,
})),
unusedByFile: sortedScripts.slice(0, 10).map((script) => ({
url: script.url,
isThirdParty: script.isThirdParty,
totalBytes: script.totalBytes,
unusedBytes: script.unusedBytes,
unusedPercentage: script.unusedPercentage,
topUnusedRanges: script.unusedRanges,
})),
thirdPartyAnalysis: {
totalBytes: thirdPartyTotalBytes,
unusedBytes: thirdPartyUnusedBytes,
unusedPercentage: thirdPartyUnusedPercentage,
files: thirdPartyScripts.map((script) => ({
url: script.url,
totalBytes: script.totalBytes,
unusedBytes: script.unusedBytes,
unusedPercentage: script.unusedPercentage,
})),
},
recommendations,
};
} catch (error) {
logError("js_coverage_analyzer", "Failed to analyze JavaScript coverage", error);
return {
error: `Failed to analyze JavaScript coverage: ${error.message}`,
totalBytes: 0,
unusedBytes: 0,
unusedPercentage: 0,
files: [],
unusedByFile: [],
thirdPartyAnalysis: {
totalBytes: 0,
unusedBytes: 0,
unusedPercentage: 0,
files: [],
},
recommendations: [],
};
}
}
/**
* Get line number from character offset in source code
* @param {string} source - Source code
* @param {number} offset - Character offset
* @returns {number} Line number (1-based)
*/
function getLineFromOffset(source, offset) {
if (!source) return 1;
const lines = source.substring(0, offset).split("\n");
return lines.length;
}
/**
* Generate recommendations based on JavaScript coverage analysis
* @param {Array} sortedScripts - Scripts sorted by unused bytes (descending)
* @param {Array} thirdPartyScripts - Third-party scripts
* @returns {Array} Recommendations
*/
function generateJSRecommendations(sortedScripts, thirdPartyScripts) {
const recommendations = [];
// Check for large unused scripts
const largeUnusedScripts = sortedScripts.filter((script) => script.unusedPercentage > 50 && script.totalBytes > 50000);
if (largeUnusedScripts.length > 0) {
recommendations.push({
type: "code_splitting",
title: "Consider code splitting for large scripts",
description: `Found ${largeUnusedScripts.length} large scripts with more than 50% unused code. Consider implementing code splitting to load only the necessary code for each page.`,
impact: "high",
scripts: largeUnusedScripts.slice(0, 5).map((script) => ({
url: script.url,
unusedPercentage: script.unusedPercentage,
unusedBytes: script.unusedBytes,
})),
});
}
// Check for third-party scripts with high unused code
const inefficientThirdParty = thirdPartyScripts.filter((script) => script.unusedPercentage > 70 && script.totalBytes > 30000);
if (inefficientThirdParty.length > 0) {
recommendations.push({
type: "third_party_optimization",
title: "Optimize third-party script loading",
description: `Found ${inefficientThirdParty.length} third-party scripts with more than 70% unused code. Consider loading these scripts asynchronously, using defer, or implementing lazy loading.`,
impact: "medium",
scripts: inefficientThirdParty.slice(0, 5).map((script) => ({
url: script.url,
unusedPercentage: script.unusedPercentage,
unusedBytes: script.unusedBytes,
})),
});
}
// Check for potential tree-shaking opportunities
const treeShakingCandidates = sortedScripts.filter((script) => !script.isThirdParty && script.unusedPercentage > 40 && (script.url.includes("bundle") || script.url.includes("vendor") || script.url.includes("main")));
if (treeShakingCandidates.length > 0) {
recommendations.push({
type: "tree_shaking",
title: "Implement tree-shaking for bundled code",
description: `Found ${treeShakingCandidates.length} bundled scripts with significant unused code. Consider implementing tree-shaking in your build process to eliminate unused exports.`,
impact: "high",
scripts: treeShakingCandidates.slice(0, 5).map((script) => ({
url: script.url,
unusedPercentage: script.unusedPercentage,
unusedBytes: script.unusedBytes,
})),
});
}
// Check for inline scripts with high unused code
const inefficientInlineScripts = sortedScripts.filter((script) => script.url === "(inline script)" && script.unusedPercentage > 50 && script.totalBytes > 10000);
if (inefficientInlineScripts.length > 0) {
recommendations.push({
type: "inline_script_optimization",
title: "Optimize inline scripts",
description: `Found ${inefficientInlineScripts.length} large inline scripts with significant unused code. Consider refactoring these scripts or moving them to external files with proper loading strategies.`,
impact: "medium",
scripts: inefficientInlineScripts.slice(0, 3).map((script) => ({
unusedPercentage: script.unusedPercentage,
unusedBytes: script.unusedBytes,
totalBytes: script.totalBytes,
})),
});
}
// General recommendation if overall unused code is high
if (sortedScripts.length > 0) {
const totalBytes = sortedScripts.reduce((sum, script) => sum + script.totalBytes, 0);
const unusedBytes = sortedScripts.reduce((sum, script) => sum + script.unusedBytes, 0);
const overallUnusedPercentage = totalBytes > 0 ? (unusedBytes / totalBytes) * 100 : 0;
if (overallUnusedPercentage > 40) {
recommendations.push({
type: "general_js_optimization",
title: "Implement lazy loading for JavaScript",
description: `Overall, ${overallUnusedPercentage.toFixed(1)}% of JavaScript code is unused on this page. Consider implementing lazy loading strategies to defer non-critical JavaScript until needed.`,
impact: "high",
details: {
totalBytes,
unusedBytes,
unusedPercentage: overallUnusedPercentage,
},
});
}
}
return recommendations;
}