async function buildBundle(entryPath, distPath, opts = {minify: true}) {
  // List of paths (absolute / relative to config-helpers.js) to include
  // in bundle and make accessible via config-helpers.js `requireWrapper`.
  const dynamicModulePaths = [
    ...Runner.getGathererList().map(gatherer => `../gather/gatherers/${gatherer}`),
    ...Runner.getAuditList().map(gatherer => `../audits/${gatherer}`),
  ];

  // Include plugins.
  if (isDevtools(entryPath) || isLightrider(entryPath)) {
    dynamicModulePaths.push('lighthouse-plugin-soft-navigation');
    softNavAudits.forEach(softNavAudit => {
      dynamicModulePaths.push(softNavAudit);
    });
  }

  const bundledMapEntriesCode = dynamicModulePaths.map(modulePath => {
    const pathNoExt = modulePath.replace('.js', '');
    return `['${pathNoExt}', import('${modulePath}')]`;
  }).join(',\n');

  /** @type {Record<string, string>} */
  const shimsObj = {
    // zlib's decompression code is very large and we don't need it.
    // We export empty functions, instead of an empty module, simply to silence warnings
    // about no exports.
    '__zlib-lib/inflate': `
      export function inflateInit2() {};
      export function inflate() {};
      export function inflateEnd() {};
      export function inflateReset() {};
    `,
  };

  const modulesToIgnore = [
    'puppeteer-core',
    // ScreenRecorder.js imports `child_process` functions that are unavailable in bundled environments.
    // This module is imported dynamically in a function we never use, so safe to ignore:
    // https://github.com/puppeteer/puppeteer/blob/35f9c6d1e699ea37e89ef3bbb7940f5599df4724/packages/puppeteer-core/src/api/Page.ts#L2365-L2369
    'puppeteer-core/lib/esm/puppeteer/node/ScreenRecorder.js',
    'pako/lib/zlib/inflate.js',
    '@sentry/node',
    'source-map',
    'ws',
  ];

  // Don't include the stringified report in DevTools - see devtools-report-assets.js
  // Don't include in Lightrider - HTML generation isn't supported, so report assets aren't needed.
  if (isDevtools(entryPath) || isLightrider(entryPath)) {
    shimsObj[`${LH_ROOT}/report/generator/report-assets.js`] =
      'export const reportAssets = {}';
  }

  // Don't include locales in DevTools.
  if (isDevtools(entryPath)) {
    shimsObj[`${LH_ROOT}/shared/localization/locales.js`] = 'export const locales = {};';
  }

  // Don't bundle third-party-web (CDT provides its own copy). This prevents duplications of 40+ KB.
  if (isDevtools(entryPath)) {
    shimsObj['third-party-web/nostats-subset.js'] = 'export default {};';
  }

  for (const modulePath of modulesToIgnore) {
    shimsObj[modulePath] = 'export default {}';
  }

  await esbuild.build({
    entryPoints: [entryPath],
    outfile: distPath,
    write: false,
    format: 'iife',
    charset: 'utf8',
    bundle: true,
    minify: opts.minify,
    treeShaking: true,
    sourcemap: 'linked',
    banner: {js: banner},
    lineLimit: 1000,
    // Because of page-functions!
    keepNames: true,
    inject: ['./build/process-global.js'],
    /** @type {esbuild.Plugin[]} */
    plugins: [
      plugins.replaceModules({
        ...shimsObj,
        'url': `
          export const URL = globalThis.URL;
          export const fileURLToPath = url => url;
          export default {URL, fileURLToPath};
        `,
        'module': `
          export const createRequire = () => {
            return {
              resolve() {
                throw new Error('createRequire.resolve is not supported in bundled Lighthouse');
              },
            };
          };
        `,
      }, {
        // buildBundle is used in a lot of different contexts. Some share the same modules
        // that need to be replaced, but others don't use those modules at all.
        disableUnusedError: true,
      }),
      nodeModulesPolyfillPlugin(),
      plugins.bulkLoader([
        // TODO: when we used rollup, various things were tree-shaken out before inlineFs did its
        // thing. Now treeshaking only happens at the end, so the plugin sees more cases than it
        // did before. Some of those new cases emit warnings. Safe to ignore, but should be
        // resolved eventually.
        plugins.partialLoaders.inlineFs({
          verbose: Boolean(process.env.DEBUG),
        }),
        plugins.partialLoaders.rmGetModuleDirectory,
        plugins.partialLoaders.replaceText({
          '/* BUILD_REPLACE_BUNDLED_MODULES */': `[\n${bundledMapEntriesCode},\n]`,
          // TODO: Use globalThis directly.
          'global.isLightrider': 'globalThis.isLightrider',
          'global.isDevtools': 'globalThis.isDevtools',
          // By default esbuild converts `import.meta` to an empty object.
          // We need at least the url property for i18n things.
          /** @param {string} id */
          'import.meta': (id) => `{url: '${path.relative(LH_ROOT, id)}'}`,
        }),
      ]),
      {
        name: 'alias',
        setup({onResolve}) {
          onResolve({filter: /\.*/}, (args) => {
            /** @type {Record<string, string>} */
            const entries = {
              'debug': require.resolve('debug/src/browser.js'),
              'lighthouse-logger': require.resolve('../lighthouse-logger/index.js'),
            };
            if (args.path in entries) {
              return {path: entries[args.path]};
            }
          });
        },
      },
      {
        name: 'postprocess',
        setup({onEnd}) {
          onEnd(async (result) => {
            if (result.errors.length) {
              return;
            }

            const codeFile = result.outputFiles?.find(file => file.path.endsWith('.js'));
            const mapFile = result.outputFiles?.find(file => file.path.endsWith('.js.map'));
            if (!codeFile) {
              throw new Error('missing output');
            }

            // Just make sure the above shimming worked.
            let code = codeFile.text;
            if (code.includes('inflate_fast')) {
              throw new Error('Expected zlib inflate code to have been removed');
            }

            // Get rid of our extra license comments.
            // All comments would have been moved to the end of the file, so removing some will not break
            // source maps.
            // https://stackoverflow.com/a/35923766
            const re = /\/\*\*\s*\n([^*]|(\*(?!\/)))*\*\/\n/g;
            let hasSeenFirst = false;
            code = code.replace(re, (match) => {
              if (match.includes('@license') && match.match(/Lighthouse Authors|Google/)) {
                if (hasSeenFirst) {
                  return '';
                }

                hasSeenFirst = true;
              }

              return match;
            });

            await fs.promises.writeFile(codeFile.path, code);
            if (mapFile) {
              await fs.promises.writeFile(mapFile.path, mapFile.text);
            }
          });
        },
      },
    ],
  });
}
Bundle starting at entryPath, writing the minified result to distPath.
@param {string} entryPath
@param {string} distPath
@param {{minify: boolean}=} opts
@return {Promise<void>}