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