lighthouse/build/esbuild-plugins.js

288 строки
9.4 KiB
JavaScript

/**
* @license
* Copyright 2022 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'fs';
import path from 'path';
import {createRequire} from 'module';
// eslint-disable-next-line no-unused-vars
import esbuild from 'esbuild';
import builtin from 'builtin-modules';
import {inlineFsPlugin} from './plugins/esbuild-inline-fs.js';
/**
* @typedef PartialLoader
* @property {string} name
* @property {(code: string, args: esbuild.OnLoadArgs) => Promise<{code: string, warnings?: esbuild.PartialMessage[]} | null>} onLoad
*/
const partialLoaders = {
inlineFs: inlineFsPlugin,
/** @type {PartialLoader} */
rmGetModuleDirectory: {
name: 'rm-get-module-directory',
async onLoad(code) {
return {code: code.replace(/getModuleDirectory\(import.meta\)/g, '""')};
},
},
/**
* @param {Record<string, string | ((id: string) => string)>} replacements
* @return {PartialLoader}
*/
replaceText(replacements) {
return {
name: 'text-replace',
async onLoad(code, args) {
for (const [k, v] of Object.entries(replacements)) {
let replaceWith;
if (v instanceof Function) {
replaceWith = v(args.path);
} else {
replaceWith = v;
}
code = code.replaceAll(k, replaceWith);
}
return {code};
},
};
},
};
/**
* Bundles multiple partial loaders (string => string JS transforms) into a single esbuild Loader plugin.
* A partial loader that doesn't want to do any transform should return null.
* @param {PartialLoader[]} partialLoaders
* @return {esbuild.Plugin}
*/
function bulkLoader(partialLoaders) {
return {
name: 'bulk-loader',
setup(build) {
build.onLoad({filter: /\.*.js$/}, async (args) => {
/** @type {esbuild.PartialMessage[]} */
const warnings = [];
// TODO: source maps? lol.
let code = await fs.promises.readFile(args.path, 'utf-8');
for (const partialLoader of partialLoaders) {
const partialResult = await partialLoader.onLoad(code, args);
if (partialResult === null) continue;
code = partialResult.code;
if (partialResult.warnings) {
for (const warning of partialResult.warnings) {
warning.notes = warning.notes || [];
warning.notes.unshift({text: `partial loader: ${partialLoader.name}`});
}
warnings.push(...partialResult.warnings);
}
}
return {contents: code, warnings, resolveDir: path.dirname(args.path)};
});
},
};
}
/**
* Given a module path, replace the contents with the provided text.
*
* - If the module is a file on disk, the path MUST be absolute.
* - Bare builtin specifiers (ex: 'fs', 'path') work too.
* - Other loaders may give a resolved path that doesn't reference a filepath.
* - In all cases where a module is replaced, no other loaders will process that module.
* If this is ever problematic, this plugin should be converted to be a partial loader.
* - This plugin should always be the first loader plugin.
*
* @param {Record<string, string>} replaceMap
* @param {{disableUnusedError: boolean}} opts
* @return {esbuild.Plugin}
*/
function replaceModules(replaceMap, opts = {disableUnusedError: false}) {
// Allow callers to specifier an unresolved path, but normalize things
// by resolving those paths now.
// TODO: really this should use import.meta.resolve, but... that's not a thing yet!
const require = createRequire(import.meta.url);
for (const [k, v] of Object.entries(replaceMap)) {
try {
const resolvedPath = require.resolve(k);
if (resolvedPath !== k) {
replaceMap[resolvedPath] = v;
delete replaceMap[k];
}
} catch {}
}
return {
name: 'replace-modules',
setup(build) {
// Capture modules of interest and resolve them to their absolute paths.
// This handles real-files on disk, and builtin specifiers.
build.onResolve({filter: /.*/}, (args) => {
let resolvedPath;
try {
resolvedPath = require.resolve(args.path, {paths: [args.resolveDir]});
} catch {
// We should append .js and .ts and .tsx to try and find the correct file...
// but we aren't shimming such modules at the moment, so whatever.
return;
}
// `resolvedPath` is now either an absolute path on disk, or a builtin module (like `url`).
if (!(resolvedPath in replaceMap)) return;
// Put everything we see here into our namespace.
return {path: resolvedPath, namespace: 'replace-modules'};
});
const modulesNotSeen = new Set(Object.keys(replaceMap));
build.onLoad({filter: /.*/, namespace: 'replace-modules'}, async (args) => {
// Anything in our namespace is guaranteed to be something in replaceMap.
modulesNotSeen.delete(args.path);
return {contents: replaceMap[args.path], resolveDir: path.dirname(args.path)};
});
// Handle the third case - when a module is created by some other plugin, and the user of this
// plugin wishes to replace it.
build.onLoad({filter: /.*/}, async (args) => {
// The `onResolve` hook moved all the real modules (builtins and real files on disk) to the `replace-modules`
// namespace. What remains here are modules that were injected by other plugins. Example: __zlib-lib/inflate
if (args.path in replaceMap) {
modulesNotSeen.delete(args.path);
return {contents: replaceMap[args.path], resolveDir: path.dirname(args.path)};
}
return null;
});
if (!opts.disableUnusedError) {
build.onEnd(() => {
if (modulesNotSeen.size > 0) {
throw new Error('Unused module replacements: ' + [...modulesNotSeen]);
}
});
}
},
};
}
/**
* @param {{exclude?: string[]}=} opts
* @return {esbuild.Plugin}
*/
function ignoreBuiltins(opts = {}) {
let builtinList = [...builtin];
if (opts.exclude) {
builtinList = builtinList.filter(b => !opts?.exclude?.includes(b));
}
const builtinRegexp = new RegExp(`^(${builtinList.join('|')})\\/?(.+)?`);
return {
name: 'ignore-builtins',
setup(build) {
build.onResolve({filter: builtinRegexp}, (args) => {
if (args.path.match(builtinRegexp)) {
return {path: args.path, namespace: 'ignore-builtins'};
}
});
build.onLoad({filter: builtinRegexp, namespace: 'ignore-builtins'}, async () => {
return {contents: ''};
});
},
};
}
/**
* Currently there is no umd support in esbuild,
* so we take the output of an iife build and create our own umd bundle.
* https://github.com/evanw/esbuild/pull/1331
* @param {string} iifeCode expected to use `globalName: 'umdExports'`
* @param {string} moduleName
* @return {string}
*/
function generateUMD(iifeCode, moduleName) {
const moduleComponents = moduleName.split('.');
const moduleLastName = moduleComponents[moduleComponents.length - 1];
if (moduleComponents.length > 2) {
throw new Error('only one level of modules is supported currently');
}
const initParentModules = moduleComponents.length === 2 ?
`root.${moduleComponents[0]} = root.${moduleComponents[0]} || {}` :
'';
const initModule = moduleComponents.length === 2 ?
`root.${moduleComponents[0]}.${moduleComponents[1]} = factory();` :
`root.${moduleName} = factory();`;
// TODO: we need to change `Lighthouse.ReportGenerator.ReportGenerator` to `Lighthouse.ReportGenerator` in CDT.
const devtoolsHack = moduleName === 'Lighthouse.ReportGenerator' ?
'root.Lighthouse.ReportGenerator.ReportGenerator = root.Lighthouse.ReportGenerator;' :
'';
return `(function(root, factory) {
if (typeof define === "function" && define.amd) {
define(factory);
} else if (typeof module === "object" && module.exports) {
module.exports = factory();
} else {
${initParentModules}
${initModule}
${devtoolsHack}
}
}(typeof self !== "undefined" ? self : this, function() {
"use strict";
${iifeCode.replace('"use strict";\n', '')};
return umdExports.${moduleLastName} ?? umdExports;
}));
`;
}
/**
* @param {string} moduleName
* @return {esbuild.Plugin}
*/
function umd(moduleName) {
return {
name: 'umd',
setup(build) {
// We _must_ disable the write option so that `result.outputFiles` is set.
// Node API defaults to false.
const originalWrite = build.initialOptions.write ?? true;
build.initialOptions.write = false;
if (build.initialOptions.globalName) {
throw new Error('Using the umd plugin requires not setting `globalName`');
}
build.initialOptions.globalName = 'umdExports';
if (build.initialOptions.format) {
throw new Error('Using the umd plugin requires not setting `format`');
}
build.initialOptions.format = 'iife';
build.onEnd(async (result) => {
if (result.outputFiles?.length !== 1) {
throw new Error('unexpected number of output files');
}
const umdCode = generateUMD(result.outputFiles[0].text, moduleName);
// @ts-expect-error build-viewer needs to extract the umd bundle as a string.
result.outputFiles[0].textUmd = umdCode;
if (originalWrite) {
await fs.promises.writeFile(result.outputFiles[0].path, umdCode);
}
});
},
};
}
export {
partialLoaders,
bulkLoader,
replaceModules,
ignoreBuiltins,
umd,
};