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