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