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