18d307f52SEvan Baconimport chalk from 'chalk';
28d307f52SEvan Baconimport { Terminal } from 'metro-core';
38d307f52SEvan Baconimport path from 'path';
48d307f52SEvan Bacon
58d307f52SEvan Baconimport { logWarning, TerminalReporter } from './TerminalReporter';
68d307f52SEvan Baconimport { BuildPhase, BundleDetails, BundleProgress, SnippetError } from './TerminalReporter.types';
757eba0f9SEvan Baconimport { NODE_STDLIB_MODULES } from './externals';
88a424bebSJames Ideimport { learnMore } from '../../../utils/link';
98d307f52SEvan Bacon
108d307f52SEvan Baconconst MAX_PROGRESS_BAR_CHAR_WIDTH = 16;
118d307f52SEvan Baconconst DARK_BLOCK_CHAR = '\u2593';
128d307f52SEvan Baconconst LIGHT_BLOCK_CHAR = '\u2591';
138d307f52SEvan Bacon/**
148d307f52SEvan Bacon * Extends the default Metro logger and adds some additional features.
158d307f52SEvan Bacon * Also removes the giant Metro logo from the output.
168d307f52SEvan Bacon */
178d307f52SEvan Baconexport class MetroTerminalReporter extends TerminalReporter {
188a424bebSJames Ide  constructor(
198a424bebSJames Ide    public projectRoot: string,
208a424bebSJames Ide    terminal: Terminal
218a424bebSJames Ide  ) {
228d307f52SEvan Bacon    super(terminal);
238d307f52SEvan Bacon  }
248d307f52SEvan Bacon
258d307f52SEvan Bacon  // Used for testing
268d307f52SEvan Bacon  _getElapsedTime(startTime: number): number {
278d307f52SEvan Bacon    return Date.now() - startTime;
288d307f52SEvan Bacon  }
298d307f52SEvan Bacon  /**
308d307f52SEvan Bacon   * Extends the bundle progress to include the current platform that we're bundling.
318d307f52SEvan Bacon   *
328d307f52SEvan Bacon   * @returns `iOS path/to/bundle.js ▓▓▓▓▓░░░░░░░░░░░ 36.6% (4790/7922)`
338d307f52SEvan Bacon   */
348d307f52SEvan Bacon  _getBundleStatusMessage(progress: BundleProgress, phase: BuildPhase): string {
357179edeaSEvan Bacon    const env = getEnvironmentForBuildDetails(progress.bundleDetails);
367179edeaSEvan Bacon    const platform = env || getPlatformTagForBuildDetails(progress.bundleDetails);
378d307f52SEvan Bacon    const inProgress = phase === 'in_progress';
388d307f52SEvan Bacon
398d307f52SEvan Bacon    if (!inProgress) {
408d307f52SEvan Bacon      const status = phase === 'done' ? `Bundling complete ` : `Bundling failed `;
418d307f52SEvan Bacon      const color = phase === 'done' ? chalk.green : chalk.red;
428d307f52SEvan Bacon
4329975bfdSEvan Bacon      const startTime = this._bundleTimers.get(progress.bundleDetails.buildID!);
4429975bfdSEvan Bacon      const time = startTime != null ? chalk.dim(this._getElapsedTime(startTime) + 'ms') : '';
458d307f52SEvan Bacon      // iOS Bundling complete 150ms
468d307f52SEvan Bacon      return color(platform + status) + time;
478d307f52SEvan Bacon    }
488d307f52SEvan Bacon
4930986256SEvan Bacon    const localPath = progress.bundleDetails.entryFile.startsWith(path.sep)
5030986256SEvan Bacon      ? path.relative(this.projectRoot, progress.bundleDetails.entryFile)
5130986256SEvan Bacon      : progress.bundleDetails.entryFile;
5230986256SEvan Bacon
538d307f52SEvan Bacon    const filledBar = Math.floor(progress.ratio * MAX_PROGRESS_BAR_CHAR_WIDTH);
548d307f52SEvan Bacon
558d307f52SEvan Bacon    const _progress = inProgress
568d307f52SEvan Bacon      ? chalk.green.bgGreen(DARK_BLOCK_CHAR.repeat(filledBar)) +
578d307f52SEvan Bacon        chalk.bgWhite.white(LIGHT_BLOCK_CHAR.repeat(MAX_PROGRESS_BAR_CHAR_WIDTH - filledBar)) +
588d307f52SEvan Bacon        chalk.bold(` ${(100 * progress.ratio).toFixed(1).padStart(4)}% `) +
598d307f52SEvan Bacon        chalk.dim(
608d307f52SEvan Bacon          `(${progress.transformedFileCount
618d307f52SEvan Bacon            .toString()
628d307f52SEvan Bacon            .padStart(progress.totalFileCount.toString().length)}/${progress.totalFileCount})`
638d307f52SEvan Bacon        )
648d307f52SEvan Bacon      : '';
658d307f52SEvan Bacon
668d307f52SEvan Bacon    return (
678d307f52SEvan Bacon      platform +
688d307f52SEvan Bacon      chalk.reset.dim(`${path.dirname(localPath)}/`) +
698d307f52SEvan Bacon      chalk.bold(path.basename(localPath)) +
708d307f52SEvan Bacon      ' ' +
718d307f52SEvan Bacon      _progress
728d307f52SEvan Bacon    );
738d307f52SEvan Bacon  }
748d307f52SEvan Bacon
758d307f52SEvan Bacon  _logInitializing(port: number, hasReducedPerformance: boolean): void {
768d307f52SEvan Bacon    // Don't print a giant logo...
778d307f52SEvan Bacon    this.terminal.log('Starting Metro Bundler');
788d307f52SEvan Bacon  }
798d307f52SEvan Bacon
808d307f52SEvan Bacon  shouldFilterClientLog(event: {
818d307f52SEvan Bacon    type: 'client_log';
828d307f52SEvan Bacon    level: 'trace' | 'info' | 'warn' | 'log' | 'group' | 'groupCollapsed' | 'groupEnd' | 'debug';
838d307f52SEvan Bacon    data: unknown[];
848d307f52SEvan Bacon  }): boolean {
858d307f52SEvan Bacon    return isAppRegistryStartupMessage(event.data);
868d307f52SEvan Bacon  }
878d307f52SEvan Bacon
888d307f52SEvan Bacon  /** Print the cache clear message. */
898d307f52SEvan Bacon  transformCacheReset(): void {
908d307f52SEvan Bacon    logWarning(
918d307f52SEvan Bacon      this.terminal,
928d307f52SEvan Bacon      chalk`Bundler cache is empty, rebuilding {dim (this may take a minute)}`
938d307f52SEvan Bacon    );
948d307f52SEvan Bacon  }
958d307f52SEvan Bacon
968d307f52SEvan Bacon  /** One of the first logs that will be printed */
978d307f52SEvan Bacon  dependencyGraphLoading(hasReducedPerformance: boolean): void {
988d307f52SEvan Bacon    // this.terminal.log('Dependency graph is loading...');
998d307f52SEvan Bacon    if (hasReducedPerformance) {
1008d307f52SEvan Bacon      // Extends https://github.com/facebook/metro/blob/347b1d7ed87995d7951aaa9fd597c04b06013dac/packages/metro/src/lib/TerminalReporter.js#L283-L290
1018d307f52SEvan Bacon      this.terminal.log(
1028d307f52SEvan Bacon        chalk.red(
1038d307f52SEvan Bacon          [
1048d307f52SEvan Bacon            'Metro is operating with reduced performance.',
1058d307f52SEvan Bacon            'Please fix the problem above and restart Metro.',
1068d307f52SEvan Bacon          ].join('\n')
1078d307f52SEvan Bacon        )
1088d307f52SEvan Bacon      );
1098d307f52SEvan Bacon    }
1108d307f52SEvan Bacon  }
1118d307f52SEvan Bacon
1128d307f52SEvan Bacon  _logBundlingError(error: SnippetError): void {
1138d307f52SEvan Bacon    const moduleResolutionError = formatUsingNodeStandardLibraryError(this.projectRoot, error);
1142fbedb18SEvan Bacon    const cause = error.cause as undefined | { _expoImportStack?: string };
1158d307f52SEvan Bacon    if (moduleResolutionError) {
1162fbedb18SEvan Bacon      let message = maybeAppendCodeFrame(moduleResolutionError, error.message);
1172fbedb18SEvan Bacon      if (cause?._expoImportStack) {
1182fbedb18SEvan Bacon        message += `\n\n${cause?._expoImportStack}`;
1192fbedb18SEvan Bacon      }
1202fbedb18SEvan Bacon      return this.terminal.log(message);
1212fbedb18SEvan Bacon    }
1222fbedb18SEvan Bacon    if (cause?._expoImportStack) {
1232fbedb18SEvan Bacon      error.message += `\n\n${cause._expoImportStack}`;
1248d307f52SEvan Bacon    }
1258d307f52SEvan Bacon    return super._logBundlingError(error);
1268d307f52SEvan Bacon  }
1278d307f52SEvan Bacon}
1288d307f52SEvan Bacon
1298d307f52SEvan Bacon/**
1308d307f52SEvan Bacon * Formats an error where the user is attempting to import a module from the Node.js standard library.
1318d307f52SEvan Bacon * Exposed for testing.
1328d307f52SEvan Bacon *
1338d307f52SEvan Bacon * @param error
1348d307f52SEvan Bacon * @returns error message or null if not a module resolution error
1358d307f52SEvan Bacon */
1368d307f52SEvan Baconexport function formatUsingNodeStandardLibraryError(
1378d307f52SEvan Bacon  projectRoot: string,
1388d307f52SEvan Bacon  error: SnippetError
1398d307f52SEvan Bacon): string | null {
1408d307f52SEvan Bacon  if (!error.message) {
1418d307f52SEvan Bacon    return null;
1428d307f52SEvan Bacon  }
1438d307f52SEvan Bacon  const { targetModuleName, originModulePath } = error;
1448d307f52SEvan Bacon  if (!targetModuleName || !originModulePath) {
1458d307f52SEvan Bacon    return null;
1468d307f52SEvan Bacon  }
1478d307f52SEvan Bacon  const relativePath = path.relative(projectRoot, originModulePath);
1488d307f52SEvan Bacon
1498d307f52SEvan Bacon  const DOCS_PAGE_URL =
1508d307f52SEvan Bacon    'https://docs.expo.dev/workflow/using-libraries/#using-third-party-libraries';
1518d307f52SEvan Bacon
1528d307f52SEvan Bacon  if (isNodeStdLibraryModule(targetModuleName)) {
1538d307f52SEvan Bacon    if (originModulePath.includes('node_modules')) {
1548d307f52SEvan Bacon      return [
1558d307f52SEvan Bacon        `The package at "${chalk.bold(
1568d307f52SEvan Bacon          relativePath
1578d307f52SEvan Bacon        )}" attempted to import the Node standard library module "${chalk.bold(
1588d307f52SEvan Bacon          targetModuleName
1598d307f52SEvan Bacon        )}".`,
1608d307f52SEvan Bacon        `It failed because the native React runtime does not include the Node standard library.`,
1618d307f52SEvan Bacon        learnMore(DOCS_PAGE_URL),
1628d307f52SEvan Bacon      ].join('\n');
1638d307f52SEvan Bacon    } else {
1648d307f52SEvan Bacon      return [
165*92ddc08bSEvan Bacon        `You attempted to import the Node standard library module "${chalk.bold(
1668d307f52SEvan Bacon          targetModuleName
1678d307f52SEvan Bacon        )}" from "${chalk.bold(relativePath)}".`,
1688d307f52SEvan Bacon        `It failed because the native React runtime does not include the Node standard library.`,
1698d307f52SEvan Bacon        learnMore(DOCS_PAGE_URL),
1708d307f52SEvan Bacon      ].join('\n');
1718d307f52SEvan Bacon    }
1728d307f52SEvan Bacon  }
1738d307f52SEvan Bacon  return `Unable to resolve "${targetModuleName}" from "${relativePath}"`;
1748d307f52SEvan Bacon}
1758d307f52SEvan Bacon
1768d307f52SEvan Baconexport function isNodeStdLibraryModule(moduleName: string): boolean {
1778d307f52SEvan Bacon  return /^node:/.test(moduleName) || NODE_STDLIB_MODULES.includes(moduleName);
1788d307f52SEvan Bacon}
1798d307f52SEvan Bacon
1808d307f52SEvan Bacon/** If the code frame can be found then append it to the existing message.  */
1818d307f52SEvan Baconfunction maybeAppendCodeFrame(message: string, rawMessage: string): string {
1828d307f52SEvan Bacon  const codeFrame = stripMetroInfo(rawMessage);
1838d307f52SEvan Bacon  if (codeFrame) {
1848d307f52SEvan Bacon    message += '\n' + codeFrame;
1858d307f52SEvan Bacon  }
1868d307f52SEvan Bacon  return message;
1878d307f52SEvan Bacon}
1888d307f52SEvan Bacon
1898d307f52SEvan Bacon/**
1908d307f52SEvan Bacon * Remove the Metro cache clearing steps if they exist.
1918d307f52SEvan Bacon * In future versions we won't need this.
1928d307f52SEvan Bacon * Returns the remaining code frame logs.
1938d307f52SEvan Bacon */
19429975bfdSEvan Baconexport function stripMetroInfo(errorMessage: string): string | null {
1958d307f52SEvan Bacon  // Newer versions of Metro don't include the list.
1968d307f52SEvan Bacon  if (!errorMessage.includes('4. Remove the cache')) {
1978d307f52SEvan Bacon    return null;
1988d307f52SEvan Bacon  }
1998d307f52SEvan Bacon  const lines = errorMessage.split('\n');
2008d307f52SEvan Bacon  const index = lines.findIndex((line) => line.includes('4. Remove the cache'));
2018d307f52SEvan Bacon  if (index === -1) {
2028d307f52SEvan Bacon    return null;
2038d307f52SEvan Bacon  }
2048d307f52SEvan Bacon  return lines.slice(index + 1).join('\n');
2058d307f52SEvan Bacon}
2068d307f52SEvan Bacon
2078d307f52SEvan Bacon/** @returns if the message matches the initial startup log */
2088d307f52SEvan Baconfunction isAppRegistryStartupMessage(body: any[]): boolean {
2098d307f52SEvan Bacon  return (
2108d307f52SEvan Bacon    body.length === 1 &&
2118d307f52SEvan Bacon    (/^Running application "main" with appParams:/.test(body[0]) ||
2128d307f52SEvan Bacon      /^Running "main" with \{/.test(body[0]))
2138d307f52SEvan Bacon  );
2148d307f52SEvan Bacon}
2158d307f52SEvan Bacon
2168d307f52SEvan Bacon/** @returns platform specific tag for a `BundleDetails` object */
2178d307f52SEvan Baconfunction getPlatformTagForBuildDetails(bundleDetails?: BundleDetails | null): string {
2188d307f52SEvan Bacon  const platform = bundleDetails?.platform ?? null;
2198d307f52SEvan Bacon  if (platform) {
2208d307f52SEvan Bacon    const formatted = { ios: 'iOS', android: 'Android', web: 'Web' }[platform] || platform;
2218d307f52SEvan Bacon    return `${chalk.bold(formatted)} `;
2228d307f52SEvan Bacon  }
2238d307f52SEvan Bacon
2248d307f52SEvan Bacon  return '';
2258d307f52SEvan Bacon}
2267179edeaSEvan Bacon/** @returns platform specific tag for a `BundleDetails` object */
2277179edeaSEvan Baconfunction getEnvironmentForBuildDetails(bundleDetails?: BundleDetails | null): string {
2287179edeaSEvan Bacon  // Expo CLI will pass `customTransformOptions.environment = 'node'` when bundling for the server.
2297179edeaSEvan Bacon  const env = bundleDetails?.customTransformOptions?.environment ?? null;
2307179edeaSEvan Bacon  if (env === 'node') {
2317179edeaSEvan Bacon    return `${chalk.bold('Server')} `;
2327179edeaSEvan Bacon  }
2337179edeaSEvan Bacon
2347179edeaSEvan Bacon  return '';
2357179edeaSEvan Bacon}
236