1import chalk from 'chalk';
2import { Terminal } from 'metro-core';
3import path from 'path';
4
5import { logWarning, TerminalReporter } from './TerminalReporter';
6import { BuildPhase, BundleDetails, BundleProgress, SnippetError } from './TerminalReporter.types';
7import { NODE_STDLIB_MODULES } from './externals';
8import { learnMore } from '../../../utils/link';
9
10const MAX_PROGRESS_BAR_CHAR_WIDTH = 16;
11const DARK_BLOCK_CHAR = '\u2593';
12const LIGHT_BLOCK_CHAR = '\u2591';
13/**
14 * Extends the default Metro logger and adds some additional features.
15 * Also removes the giant Metro logo from the output.
16 */
17export class MetroTerminalReporter extends TerminalReporter {
18  constructor(
19    public projectRoot: string,
20    terminal: Terminal
21  ) {
22    super(terminal);
23  }
24
25  // Used for testing
26  _getElapsedTime(startTime: number): number {
27    return Date.now() - startTime;
28  }
29  /**
30   * Extends the bundle progress to include the current platform that we're bundling.
31   *
32   * @returns `iOS path/to/bundle.js ▓▓▓▓▓░░░░░░░░░░░ 36.6% (4790/7922)`
33   */
34  _getBundleStatusMessage(progress: BundleProgress, phase: BuildPhase): string {
35    const platform = getPlatformTagForBuildDetails(progress.bundleDetails);
36    const inProgress = phase === 'in_progress';
37
38    if (!inProgress) {
39      const status = phase === 'done' ? `Bundling complete ` : `Bundling failed `;
40      const color = phase === 'done' ? chalk.green : chalk.red;
41
42      const startTime = this._bundleTimers.get(progress.bundleDetails.buildID!);
43      const time = startTime != null ? chalk.dim(this._getElapsedTime(startTime) + 'ms') : '';
44      // iOS Bundling complete 150ms
45      return color(platform + status) + time;
46    }
47
48    const localPath = path.relative('.', progress.bundleDetails.entryFile);
49    const filledBar = Math.floor(progress.ratio * MAX_PROGRESS_BAR_CHAR_WIDTH);
50
51    const _progress = inProgress
52      ? chalk.green.bgGreen(DARK_BLOCK_CHAR.repeat(filledBar)) +
53        chalk.bgWhite.white(LIGHT_BLOCK_CHAR.repeat(MAX_PROGRESS_BAR_CHAR_WIDTH - filledBar)) +
54        chalk.bold(` ${(100 * progress.ratio).toFixed(1).padStart(4)}% `) +
55        chalk.dim(
56          `(${progress.transformedFileCount
57            .toString()
58            .padStart(progress.totalFileCount.toString().length)}/${progress.totalFileCount})`
59        )
60      : '';
61
62    return (
63      platform +
64      chalk.reset.dim(`${path.dirname(localPath)}/`) +
65      chalk.bold(path.basename(localPath)) +
66      ' ' +
67      _progress
68    );
69  }
70
71  _logInitializing(port: number, hasReducedPerformance: boolean): void {
72    // Don't print a giant logo...
73    this.terminal.log('Starting Metro Bundler');
74  }
75
76  shouldFilterClientLog(event: {
77    type: 'client_log';
78    level: 'trace' | 'info' | 'warn' | 'log' | 'group' | 'groupCollapsed' | 'groupEnd' | 'debug';
79    data: unknown[];
80  }): boolean {
81    return isAppRegistryStartupMessage(event.data);
82  }
83
84  /** Print the cache clear message. */
85  transformCacheReset(): void {
86    logWarning(
87      this.terminal,
88      chalk`Bundler cache is empty, rebuilding {dim (this may take a minute)}`
89    );
90  }
91
92  /** One of the first logs that will be printed */
93  dependencyGraphLoading(hasReducedPerformance: boolean): void {
94    // this.terminal.log('Dependency graph is loading...');
95    if (hasReducedPerformance) {
96      // Extends https://github.com/facebook/metro/blob/347b1d7ed87995d7951aaa9fd597c04b06013dac/packages/metro/src/lib/TerminalReporter.js#L283-L290
97      this.terminal.log(
98        chalk.red(
99          [
100            'Metro is operating with reduced performance.',
101            'Please fix the problem above and restart Metro.',
102          ].join('\n')
103        )
104      );
105    }
106  }
107
108  _logBundlingError(error: SnippetError): void {
109    const moduleResolutionError = formatUsingNodeStandardLibraryError(this.projectRoot, error);
110    const cause = error.cause as undefined | { _expoImportStack?: string };
111    if (moduleResolutionError) {
112      let message = maybeAppendCodeFrame(moduleResolutionError, error.message);
113      if (cause?._expoImportStack) {
114        message += `\n\n${cause?._expoImportStack}`;
115      }
116      return this.terminal.log(message);
117    }
118    if (cause?._expoImportStack) {
119      error.message += `\n\n${cause._expoImportStack}`;
120    }
121    return super._logBundlingError(error);
122  }
123}
124
125/**
126 * Formats an error where the user is attempting to import a module from the Node.js standard library.
127 * Exposed for testing.
128 *
129 * @param error
130 * @returns error message or null if not a module resolution error
131 */
132export function formatUsingNodeStandardLibraryError(
133  projectRoot: string,
134  error: SnippetError
135): string | null {
136  if (!error.message) {
137    return null;
138  }
139  const { targetModuleName, originModulePath } = error;
140  if (!targetModuleName || !originModulePath) {
141    return null;
142  }
143  const relativePath = path.relative(projectRoot, originModulePath);
144
145  const DOCS_PAGE_URL =
146    'https://docs.expo.dev/workflow/using-libraries/#using-third-party-libraries';
147
148  if (isNodeStdLibraryModule(targetModuleName)) {
149    if (originModulePath.includes('node_modules')) {
150      return [
151        `The package at "${chalk.bold(
152          relativePath
153        )}" attempted to import the Node standard library module "${chalk.bold(
154          targetModuleName
155        )}".`,
156        `It failed because the native React runtime does not include the Node standard library.`,
157        learnMore(DOCS_PAGE_URL),
158      ].join('\n');
159    } else {
160      return [
161        `You attempted attempted to import the Node standard library module "${chalk.bold(
162          targetModuleName
163        )}" from "${chalk.bold(relativePath)}".`,
164        `It failed because the native React runtime does not include the Node standard library.`,
165        learnMore(DOCS_PAGE_URL),
166      ].join('\n');
167    }
168  }
169  return `Unable to resolve "${targetModuleName}" from "${relativePath}"`;
170}
171
172export function isNodeStdLibraryModule(moduleName: string): boolean {
173  return /^node:/.test(moduleName) || NODE_STDLIB_MODULES.includes(moduleName);
174}
175
176/** If the code frame can be found then append it to the existing message.  */
177function maybeAppendCodeFrame(message: string, rawMessage: string): string {
178  const codeFrame = stripMetroInfo(rawMessage);
179  if (codeFrame) {
180    message += '\n' + codeFrame;
181  }
182  return message;
183}
184
185/**
186 * Remove the Metro cache clearing steps if they exist.
187 * In future versions we won't need this.
188 * Returns the remaining code frame logs.
189 */
190export function stripMetroInfo(errorMessage: string): string | null {
191  // Newer versions of Metro don't include the list.
192  if (!errorMessage.includes('4. Remove the cache')) {
193    return null;
194  }
195  const lines = errorMessage.split('\n');
196  const index = lines.findIndex((line) => line.includes('4. Remove the cache'));
197  if (index === -1) {
198    return null;
199  }
200  return lines.slice(index + 1).join('\n');
201}
202
203/** @returns if the message matches the initial startup log */
204function isAppRegistryStartupMessage(body: any[]): boolean {
205  return (
206    body.length === 1 &&
207    (/^Running application "main" with appParams:/.test(body[0]) ||
208      /^Running "main" with \{/.test(body[0]))
209  );
210}
211
212/** @returns platform specific tag for a `BundleDetails` object */
213function getPlatformTagForBuildDetails(bundleDetails?: BundleDetails | null): string {
214  const platform = bundleDetails?.platform ?? null;
215  if (platform) {
216    const formatted = { ios: 'iOS', android: 'Android', web: 'Web' }[platform] || platform;
217    return `${chalk.bold(formatted)} `;
218  }
219
220  return '';
221}
222