From 68a2421ce823087a251ab54441ebdf2cd75fab3c Mon Sep 17 00:00:00 2001 From: Connor Clark Date: Thu, 20 Jul 2023 13:01:54 -0700 Subject: [PATCH] misc(build): bundle with esbuild minification instead of terser (#15283) --- build/build-bundle.js | 21 +---- core/gather/driver/execution-context.js | 17 ++-- core/gather/gatherers/trace-elements.js | 10 ++- core/lib/page-functions.js | 88 ++++++++++++++++--- .../gather/driver/execution-context-test.js | 14 +-- 5 files changed, 107 insertions(+), 43 deletions(-) diff --git a/build/build-bundle.js b/build/build-bundle.js index 78c3e752404e..40dc0003e5fc 100644 --- a/build/build-bundle.js +++ b/build/build-bundle.js @@ -20,7 +20,6 @@ import esbuild from 'esbuild'; import PubAdsPlugin from 'lighthouse-plugin-publisher-ads'; // @ts-expect-error: plugin has no types. import SoftNavPlugin from 'lighthouse-plugin-soft-navigation'; -import * as terser from 'terser'; import * as plugins from './esbuild-plugins.js'; import {Runner} from '../core/runner.js'; @@ -146,13 +145,11 @@ async function buildBundle(entryPath, distPath, opts = {minify: true}) { const result = await esbuild.build({ entryPoints: [entryPath], - outfile: distPath, write: false, format: 'iife', charset: 'utf8', bundle: true, - // For now, we defer to terser. - minify: false, + minify: opts.minify, treeShaking: true, sourcemap: DEBUG, banner: {js: banner}, @@ -250,26 +247,14 @@ async function buildBundle(entryPath, distPath, opts = {minify: true}) { ], }); - let code = result.outputFiles[0].text; + const code = result.outputFiles[0].text; // Just make sure the above shimming worked. if (code.includes('inflate_fast')) { throw new Error('Expected zlib inflate code to have been removed'); } - // Ideally we'd let esbuild minify, but we need to disable variable name mangling otherwise - // code generated dynamically to run inside the browser (pageFunctions) breaks. For example, - // the `truncate` function is unable to properly reference `Util`. - if (opts.minify) { - code = (await terser.minify(result.outputFiles[0].text, { - mangle: false, - format: { - max_line_len: 1000, - }, - })).code || ''; - } - - await fs.promises.writeFile(result.outputFiles[0].path, code); + await fs.promises.writeFile(distPath, code); } /** diff --git a/core/gather/driver/execution-context.js b/core/gather/driver/execution-context.js index 466358b80969..e8017cc7eb2a 100644 --- a/core/gather/driver/execution-context.js +++ b/core/gather/driver/execution-context.js @@ -108,7 +108,7 @@ class ExecutionContext { ${ExecutionContext._cachedNativesPreamble}; globalThis.__lighthouseExecutionContextUniqueIdentifier = ${uniqueExecutionContextIdentifier}; - ${pageFunctions.esbuildFunctionNameStubString} + ${pageFunctions.esbuildFunctionWrapperString} return new Promise(function (resolve) { return Promise.resolve() .then(_ => ${expression}) @@ -277,13 +277,20 @@ class ExecutionContext { * @return {string} */ static serializeDeps(deps) { - deps = [pageFunctions.esbuildFunctionNameStubString, ...deps || []]; + deps = [pageFunctions.esbuildFunctionWrapperString, ...deps || []]; return deps.map(dep => { if (typeof dep === 'function') { // esbuild will change the actual function name (ie. function actualName() {}) - // always, despite minification settings, but preserve the real name in `actualName.name` - // (see esbuildFunctionNameStubString). - return `const ${dep.name} = ${dep}`; + // always, and preserve the real name in `actualName.name`. + // See esbuildFunctionWrapperString. + const output = dep.toString(); + const runtimeName = pageFunctions.getRuntimeFunctionName(dep); + if (runtimeName !== dep.name) { + // In addition to exposing the mangled name, also expose the original as an alias. + return `${output}; const ${dep.name} = ${runtimeName};`; + } else { + return output; + } } else { return dep; } diff --git a/core/gather/gatherers/trace-elements.js b/core/gather/gatherers/trace-elements.js index 82c3b3ea7478..09c9ec11b046 100644 --- a/core/gather/gatherers/trace-elements.js +++ b/core/gather/gatherers/trace-elements.js @@ -23,6 +23,7 @@ import {ProcessedNavigation} from '../../computed/processed-navigation.js'; import {LighthouseError} from '../../lib/lh-error.js'; import {Responsiveness} from '../../computed/metrics/responsiveness.js'; import {CumulativeLayoutShift} from '../../computed/metrics/cumulative-layout-shift.js'; +import {ExecutionContext} from '../driver/execution-context.js'; /** @typedef {{nodeId: number, score?: number, animations?: {name?: string, failureReasonsMask?: number, unsupportedProperties?: string[]}[], type?: string}} TraceElementData */ @@ -284,12 +285,15 @@ class TraceElements extends BaseGatherer { try { const objectId = await resolveNodeIdToObjectId(session, backendNodeId); if (!objectId) continue; + + const deps = ExecutionContext.serializeDeps([ + pageFunctions.getNodeDetails, + getNodeDetailsData, + ]); response = await session.sendCommand('Runtime.callFunctionOn', { objectId, functionDeclaration: `function () { - ${pageFunctions.esbuildFunctionNameStubString} - ${getNodeDetailsData.toString()}; - ${pageFunctions.getNodeDetails}; + ${deps} return getNodeDetailsData.call(this); }`, returnByValue: true, diff --git a/core/lib/page-functions.js b/core/lib/page-functions.js index f612d3d1aea9..56f67aab1b19 100644 --- a/core/lib/page-functions.js +++ b/core/lib/page-functions.js @@ -4,6 +4,8 @@ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ +import {createRequire} from 'module'; + import {Util} from '../../shared/util.js'; /** @@ -50,7 +52,7 @@ function wrapRuntimeEvalErrorInBrowser(err) { /** * @template {string} T - * @param {T} selector Optional simple CSS selector to filter nodes on. + * @param {T=} selector Optional simple CSS selector to filter nodes on. * Combinators are not supported. * @return {Array>} */ @@ -513,24 +515,89 @@ function truncate(string, characterLimit) { return Util.truncate(string, characterLimit); } +function isBundledEnvironment() { + // If we're in DevTools or LightRider, we are definitely bundled. + // TODO: refactor and delete `global.isDevtools`. + if (global.isDevtools || global.isLightrider) return true; + + const require = createRequire(import.meta.url); + + try { + // Not foolproof, but `lighthouse-logger` is a dependency of lighthouse that should always be resolvable. + // `require.resolve` will only throw in atypical/bundled environments. + require.resolve('lighthouse-logger'); + return false; + } catch (err) { + return true; + } +} + // This is to support bundled lighthouse. // esbuild calls every function with a builtin `__name` (since we set keepNames: true), // whose purpose is to store the real name of the function so that esbuild can rename it to avoid // collisions. Anywhere we inject dynamically generated code at runtime for the browser to process, // we must manually include this function (because esbuild only does so once at the top scope of // the bundle, which is irrelevant for code executed in the browser). -const esbuildFunctionNameStubString = 'var __name=(fn)=>fn;'; +// When minified, esbuild will mangle the name of this wrapper function, so we need to determine what it +// is at runtime in order to recreate it within the page. +const esbuildFunctionWrapperString = createEsbuildFunctionWrapper(); -/** @type {string} */ -const truncateRawString = truncate.toString(); -truncate.toString = () => `function truncate(string, characterLimit) { +function createEsbuildFunctionWrapper() { + if (!isBundledEnvironment()) { + return ''; + } + + const functionAsString = (()=>{ + // eslint-disable-next-line no-unused-vars + const a = ()=>{}; + }).toString() + // When not minified, esbuild annotates the call to this function wrapper with PURE. + // We know further that the name of the wrapper function should be `__name`, but let's not + // hardcode that. Remove the PURE annotation to simplify the regex. + .replace('/* @__PURE__ */', ''); + const functionStringMatch = functionAsString.match(/=\s*([\w_]+)\(/); + if (!functionStringMatch) { + throw new Error('Could not determine esbuild function wrapper name'); + } + + /** + * @param {Function} fn + * @param {string} value + */ + const esbuildFunctionWrapper = (fn, value) => + Object.defineProperty(fn, 'name', {value, configurable: true}); + const wrapperFnName = functionStringMatch[1]; + return `let ${wrapperFnName}=${esbuildFunctionWrapper}`; +} + +/** + * @param {Function} fn + * @return {string} + */ +function getRuntimeFunctionName(fn) { + const match = fn.toString().match(/function ([\w$]+)/); + if (!match) throw new Error(`could not find function name for: ${fn}`); + return match[1]; +} + +// We setup a number of our page functions to automatically include their dependencies. +// Because of esbuild bundling, we must refer to the actual (mangled) runtime function name. +/** @type {Record} */ +const names = { + truncate: getRuntimeFunctionName(truncate), + getNodeLabel: getRuntimeFunctionName(getNodeLabel), + getOuterHTMLSnippet: getRuntimeFunctionName(getOuterHTMLSnippet), + getNodeDetails: getRuntimeFunctionName(getNodeDetails), +}; + +truncate.toString = () => `function ${names.truncate}(string, characterLimit) { const Util = { ${Util.truncate} }; - return (${truncateRawString})(string, characterLimit); + return Util.truncate(string, characterLimit); }`; /** @type {string} */ const getNodeLabelRawString = getNodeLabel.toString(); -getNodeLabel.toString = () => `function getNodeLabel(element) { +getNodeLabel.toString = () => `function ${names.getNodeLabel}(element) { ${truncate}; return (${getNodeLabelRawString})(element); }`; @@ -538,14 +605,14 @@ getNodeLabel.toString = () => `function getNodeLabel(element) { /** @type {string} */ const getOuterHTMLSnippetRawString = getOuterHTMLSnippet.toString(); // eslint-disable-next-line max-len -getOuterHTMLSnippet.toString = () => `function getOuterHTMLSnippet(element, ignoreAttrs = [], snippetCharacterLimit = 500) { +getOuterHTMLSnippet.toString = () => `function ${names.getOuterHTMLSnippet}(element, ignoreAttrs = [], snippetCharacterLimit = 500) { ${truncate}; return (${getOuterHTMLSnippetRawString})(element, ignoreAttrs, snippetCharacterLimit); }`; /** @type {string} */ const getNodeDetailsRawString = getNodeDetails.toString(); -getNodeDetails.toString = () => `function getNodeDetails(element) { +getNodeDetails.toString = () => `function ${names.getNodeDetails}(element) { ${truncate}; ${getNodePath}; ${getNodeSelector}; @@ -568,5 +635,6 @@ export const pageFunctions = { wrapRequestIdleCallback, getBoundingClientRect, truncate, - esbuildFunctionNameStubString, + esbuildFunctionWrapperString, + getRuntimeFunctionName, }; diff --git a/core/test/gather/driver/execution-context-test.js b/core/test/gather/driver/execution-context-test.js index c8ea9439e7ed..025c452d27f6 100644 --- a/core/test/gather/driver/execution-context-test.js +++ b/core/test/gather/driver/execution-context-test.js @@ -230,11 +230,11 @@ const performance = globalThis.__nativePerformance || globalThis.performance; const fetch = globalThis.__nativeFetch || globalThis.fetch; globalThis.__lighthouseExecutionContextUniqueIdentifier = undefined; - var __name=(fn)=>fn; + return new Promise(function (resolve) { return Promise.resolve() .then(_ => (() => { - var __name=(fn)=>fn; + return (function main(value) { return value; })(1); @@ -274,7 +274,7 @@ const fetch = globalThis.__nativeFetch || globalThis.fetch; const code = mockFn.mock.calls[0][0]; expect(trimTrailingWhitespace(code)).toBe(`(() => { - var __name=(fn)=>fn; + return (function mainFn(value) { return value; })(1); @@ -297,7 +297,7 @@ const fetch = globalThis.__nativeFetch || globalThis.fetch; const code = mockFn.mock.calls[0][0]; expect(trimTrailingWhitespace(code)).toBe(`(() => { - var __name=(fn)=>fn; + return ((value) => { return value; })(1); @@ -339,11 +339,11 @@ const fetch = globalThis.__nativeFetch || globalThis.fetch; const code = mockFn.mock.calls[0][0]; expect(trimTrailingWhitespace(code)).toEqual(`(() => { - var __name=(fn)=>fn; -const abs = function abs(val) { + +function abs(val) { return Math.abs(val); } -const square = function square(val) { +function square(val) { return val * val; } return (function mainFn({a, b}, passThru) {