lighthouse
Version:
Automated auditing, performance metrics, and best practices for the web.
352 lines (302 loc) • 12.2 kB
JavaScript
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview Identifies polyfills and transforms that should not be present if needing to support only Baseline browsers.
* @see https://docs.google.com/document/d/1ItjJwAd6e0Ts6yMbvh8TN3BBh_sAd58rYE1whnpuxaA/edit Design document (old, based on module/nomodule pattern)
* @see https://docs.google.com/spreadsheets/d/1z28Au8wo8-c2UsM2lDVEOJcI3jOkb2c951xEBqzBKCc/edit?usp=sharing Legacy babel transforms / polyfills
* ./core/scripts/legacy-javascript - verification tool.
*/
/** @typedef {{name: string, expression: string, estimateBytes?: (content: string) => number}} Pattern */
/** @typedef {{name: string, line: number, column: number}} PatternMatchResult */
/** @typedef {{matches: PatternMatchResult[], estimatedByteSavings: number}} Result */
import fs from 'fs';
import {LH_ROOT} from '../../../shared/root.js';
const polyfillModuleDataJson = fs.readFileSync(
`${LH_ROOT}/core/lib/legacy-javascript/polyfill-module-data.json`, 'utf-8');
/** @type {import('../../scripts/legacy-javascript/create-polyfill-module-data.js').PolyfillModuleData} */
const polyfillModuleData = JSON.parse(polyfillModuleDataJson);
const graphJson = fs.readFileSync(
`${LH_ROOT}/core/lib/legacy-javascript/polyfill-graph-data.json`, 'utf-8');
/** @type {import('../../scripts/legacy-javascript/create-polyfill-size-estimation.js').PolyfillSizeEstimator} */
const graph = JSON.parse(graphJson);
/**
* Takes a list of patterns (consisting of a name identifier and a RegExp expression string)
* and via `match` returns match results with line / column information for a given code input.
* Only returns the first match per pattern given.
*/
class CodePatternMatcher {
/**
* @param {Pattern[]} patterns
*/
constructor(patterns) {
this.patterns = patterns;
}
/**
* @param {string} code
* @return {PatternMatchResult[]}
*/
match(code) {
if (!this.re) {
const patternsExpression =
this.patterns.map(pattern => `(${pattern.expression})`).join('|');
this.re = new RegExp(`(^\r\n|\r|\n)|${patternsExpression}`, 'g');
}
// Reset RegExp state.
this.re.lastIndex = 0;
const seen = new Set();
/** @type {PatternMatchResult[]} */
const matches = [];
/** @type {RegExpExecArray | null} */
let result;
let line = 0;
let lineBeginsAtIndex = 0;
// Each pattern maps to one subgroup in the generated regex. For each iteration of RegExp.exec,
// only one subgroup will be defined. Exec until no more matches.
while ((result = this.re.exec(code)) !== null) {
// Discard first value in `result` - it's just the entire match.
const captureGroups = result.slice(1);
// isNewline - truthy if matching a newline, used to track the line number.
// `patternExpressionMatches` maps to each possible pattern in `this.patterns`.
// Only one of [isNewline, ...patternExpressionMatches] is ever truthy.
const [isNewline, ...patternExpressionMatches] = captureGroups;
if (isNewline) {
line++;
lineBeginsAtIndex = result.index + 1;
continue;
}
const pattern = this.patterns[patternExpressionMatches.findIndex(Boolean)];
if (seen.has(pattern)) {
continue;
}
seen.add(pattern);
matches.push({
name: pattern.name,
line,
column: result.index - lineBeginsAtIndex,
});
}
return matches;
}
}
/**
* @param {string?} object
* @param {string} property
* @param {string} coreJs3Module
*/
function buildPolyfillExpression(object, property, coreJs3Module) {
const qt = (/** @type {string} */ token) =>
`['"]${token}['"]`; // don't worry about matching string delims
let expression = '';
if (object) {
// String.prototype.startsWith =
expression += `${object}\\.${property}\\s?=[^=]`;
} else {
// Promise =
// window.Promise =// Promise =Z
// but not: SomePromise =
expression += `(?:window\\.|[\\s;]+)${property}\\s?=[^=]`;
}
// String.prototype['startsWith'] =
if (object) {
expression += `|${object}\\[${qt(property)}\\]\\s?=[^=]`;
}
// Object.defineProperty(String.prototype, 'startsWith'
expression += `|defineProperty\\(${object || 'window'},\\s?${qt(property)}`;
// es-shims
// no(Object,{entries:r},{entries:function
if (object) {
expression += `|\\(${object},\\s*{${property}:.*},\\s*{${property}`;
}
// core-js
if (object) {
const objectWithoutPrototype = object.replace('.prototype', '');
// e(e.S,"Object",{values
// Minified + mangled pattern found in CDN babel-polyfill.
// see https://cdnjs.cloudflare.com/ajax/libs/babel-polyfill/7.2.5/polyfill.min.js
// TODO: perhaps this is the wrong place to check for a CDN polyfill. Remove?
// expression += `|;e\\([^,]+,${qt(objectWithoutPrototype)},{${property}:`;
// core-js@3 minified pattern.
// {target:"Array",proto:true},{fill:fill
// {target:"Array",proto:true,forced:!HAS_SPECIES_SUPPORT||!USES_TO_LENGTH},{filter:
expression += `|{target:${qt(objectWithoutPrototype)}[^;]*},{${property}:`;
} else {
// Detect polyfills for new classes: Map, Set, WeakSet, etc.
// TODO: so far, no class polyfills are enabled for detection.
// See `modulesToSkip` in create-polyfill-module-data.js
// collection("Map",
// expression += `|collection\\(${qt(property)},`;
}
// Un-minified code may have module names.
// core-js/modules/es.object.is-frozen
expression += `|${coreJs3Module.replaceAll('.', '\\.')}(?:\\.js)?"`;
return expression;
}
function getCoreJsPolyfillData() {
return polyfillModuleData.filter(d => d.corejs).map(d => {
return {
name: d.name,
coreJs3Module: d.modules[0],
};
});
}
/**
* @return {Pattern[]}
*/
function getPolyfillPatterns() {
/** @type {Pattern[]} */
const patterns = [];
for (const {name, coreJs3Module} of getCoreJsPolyfillData()) {
const parts = name.split('.');
const object = parts.length > 1 ? parts.slice(0, parts.length - 1).join('.') : null;
const property = parts[parts.length - 1];
patterns.push({
name,
expression: buildPolyfillExpression(object, property, coreJs3Module),
});
}
return patterns;
}
/**
* @return {Pattern[]}
*/
function getTransformPatterns() {
/**
* @param {string} content
* @param {RegExp|string} pattern
* @return {number}
*/
const count = (content, pattern) => {
// Split is slightly faster than match.
if (typeof pattern === 'string') {
return content.split(pattern).length - 1;
}
return (content.match(pattern) ?? []).length;
};
// For expression: prefer a string that is found in the transform runtime support code (those won't ever be minified).
return [
// @babel/plugin-transform-classes
//
// input:
//
// class MyTestClass {
// log() {
// console.log(1);
// }
// };
//
// output:
//
// function _classCallCheck(a, n) { if (!(a instanceof n)) throw new TypeError("Cannot call a class as a function"); }
// function _defineProperties(e, r) { for (var t = 0; t < r.length; t++) { var o = r[t]; o.enumerable = o.enumerable || !1, o.configurable = !0, "value" in o && (o.writable = !0), Object.defineProperty(e, _toPropertyKey(o.key), o); } }
// function _createClass(e, r, t) { return r && _defineProperties(e.prototype, r), t && _defineProperties(e, t), Object.defineProperty(e, "prototype", { writable: !1 }), e; }
// function _toPropertyKey(t) { var i = _toPrimitive(t, "string"); return "symbol" == typeof i ? i : i + ""; }
// function _toPrimitive(t, r) { if ("object" != typeof t || !t) return t; var e = t[Symbol.toPrimitive]; if (void 0 !== e) { var i = e.call(t, r || "default"); if ("object" != typeof i) return i; throw new TypeError("@@toPrimitive must return a primitive value."); } return ("string" === r ? String : Number)(t); }
// let MyTestClass = function () {
// function MyTestClass() {
// _classCallCheck(this, MyTestClass);
// }
// return _createClass(MyTestClass, [{
// key: "log",
// value: function log() {
// console.log(1);
// }
// }]);
// }();
{
name: '@babel/plugin-transform-classes',
expression: 'Cannot call a class as a function',
estimateBytes: content => {
return 1000 + (count(content, '_classCallCheck') - 1) * '_classCallCheck()'.length;
},
},
{
name: '@babel/plugin-transform-regenerator',
expression: 'Generator is already running|regeneratorRuntime',
// Example of this transform: https://gist.github.com/connorjclark/af8bccfff377ac44efc104a79bc75da2
// `regeneratorRuntime.awrap` is generated for every usage of `await`, and adds ~80 bytes each.
estimateBytes: content => {
return count(content, /regeneratorRuntime\(?\)?\.a?wrap/g) * 80;
},
},
{
name: '@babel/plugin-transform-spread',
expression: 'Invalid attempt to spread non-iterable instance',
estimateBytes: content => {
const per = '_toConsumableArray()'.length;
return 1169 + count(content, /\.apply\(void 0,\s?_toConsumableArray/g) * per;
},
},
];
}
/**
* @param {string} content
* @param {PatternMatchResult[]} matches
* @return {number}
*/
function estimateWastedBytes(content, matches) {
// Split up results based on polyfill / transform. Only transforms start with @.
const polyfillResults = matches.filter(m => !m.name.startsWith('@'));
const transformResults = matches.filter(m => m.name.startsWith('@'));
let estimatedWastedBytesFromPolyfills = 0;
const modulesSeen = new Set();
for (const result of polyfillResults) {
const modules = graph.dependencies[result.name];
if (!modules) continue; // Shouldn't happen.
for (const module of modules) {
modulesSeen.add(module);
}
}
estimatedWastedBytesFromPolyfills += [...modulesSeen].reduce((acc, moduleIndex) => {
return acc + graph.moduleSizes[moduleIndex];
}, 0);
estimatedWastedBytesFromPolyfills = Math.min(estimatedWastedBytesFromPolyfills, graph.maxSize);
let estimatedWastedBytesFromTransforms = 0;
for (const result of transformResults) {
const pattern = getTransformPatterns().find(p => p.name === result.name);
if (!pattern || !pattern.estimateBytes || !content) continue;
estimatedWastedBytesFromTransforms += pattern.estimateBytes(content);
}
const estimatedWastedBytes =
estimatedWastedBytesFromPolyfills + estimatedWastedBytesFromTransforms;
return estimatedWastedBytes;
}
const matcher = new CodePatternMatcher([
...getPolyfillPatterns(),
...getTransformPatterns(),
]);
/**
* @param {string} content
* @param {import('../cdt/generated/SourceMap.js')|null} map
* @return {Result}
*/
function detectLegacyJavaScript(content, map) {
if (!content) return {matches: [], estimatedByteSavings: 0};
// Start with pattern matching against the downloaded script.
let matches = matcher.match(content);
// If it's a bundle with source maps, add in the polyfill modules by name too.
if (map) {
for (const {name, modules} of polyfillModuleData) {
// Skip if the pattern matching found a match for this polyfill.
if (matches.some(m => m.name === name)) continue;
const source = map.sourceURLs().find(source => modules.some(module => {
return source.endsWith(`/${module}.js`) || source.includes(`node_modules/${module}/`);
}));
if (!source) continue;
const mapping = map.mappings().find(m => m.sourceURL === source);
if (mapping) {
matches.push({name, line: mapping.lineNumber, column: mapping.columnNumber});
} else {
matches.push({name, line: 0, column: 0});
}
}
}
matches = matches.sort((a, b) => a.name > b.name ? 1 : a.name === b.name ? 0 : -1);
return {
matches,
estimatedByteSavings: estimateWastedBytes(content, matches),
};
}
export {detectLegacyJavaScript, getTransformPatterns, getCoreJsPolyfillData};