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    if (moduleResolutionError) {
108      return this.terminal.log(maybeAppendCodeFrame(moduleResolutionError, error.message));
109    }
110    return super._logBundlingError(error);
111  }
112}
113
114/**
115 * Formats an error where the user is attempting to import a module from the Node.js standard library.
116 * Exposed for testing.
117 *
118 * @param error
119 * @returns error message or null if not a module resolution error
120 */
121export function formatUsingNodeStandardLibraryError(
122  projectRoot: string,
123  error: SnippetError
124): string | null {
125  if (!error.message) {
126    return null;
127  }
128  const { targetModuleName, originModulePath } = error;
129  if (!targetModuleName || !originModulePath) {
130    return null;
131  }
132  const relativePath = path.relative(projectRoot, originModulePath);
133
134  const DOCS_PAGE_URL =
135    'https://docs.expo.dev/workflow/using-libraries/#using-third-party-libraries';
136
137  if (isNodeStdLibraryModule(targetModuleName)) {
138    if (originModulePath.includes('node_modules')) {
139      return [
140        `The package at "${chalk.bold(
141          relativePath
142        )}" attempted to import the Node standard library module "${chalk.bold(
143          targetModuleName
144        )}".`,
145        `It failed because the native React runtime does not include the Node standard library.`,
146        learnMore(DOCS_PAGE_URL),
147      ].join('\n');
148    } else {
149      return [
150        `You attempted attempted to import the Node standard library module "${chalk.bold(
151          targetModuleName
152        )}" from "${chalk.bold(relativePath)}".`,
153        `It failed because the native React runtime does not include the Node standard library.`,
154        learnMore(DOCS_PAGE_URL),
155      ].join('\n');
156    }
157  }
158  return `Unable to resolve "${targetModuleName}" from "${relativePath}"`;
159}
160
161export function isNodeStdLibraryModule(moduleName: string): boolean {
162  return /^node:/.test(moduleName) || NODE_STDLIB_MODULES.includes(moduleName);
163}
164
165/** If the code frame can be found then append it to the existing message.  */
166function maybeAppendCodeFrame(message: string, rawMessage: string): string {
167  const codeFrame = stripMetroInfo(rawMessage);
168  if (codeFrame) {
169    message += '\n' + codeFrame;
170  }
171  return message;
172}
173
174/**
175 * Remove the Metro cache clearing steps if they exist.
176 * In future versions we won't need this.
177 * Returns the remaining code frame logs.
178 */
179export function stripMetroInfo(errorMessage: string): string | null {
180  // Newer versions of Metro don't include the list.
181  if (!errorMessage.includes('4. Remove the cache')) {
182    return null;
183  }
184  const lines = errorMessage.split('\n');
185  const index = lines.findIndex((line) => line.includes('4. Remove the cache'));
186  if (index === -1) {
187    return null;
188  }
189  return lines.slice(index + 1).join('\n');
190}
191
192/** @returns if the message matches the initial startup log */
193function isAppRegistryStartupMessage(body: any[]): boolean {
194  return (
195    body.length === 1 &&
196    (/^Running application "main" with appParams:/.test(body[0]) ||
197      /^Running "main" with \{/.test(body[0]))
198  );
199}
200
201/** @returns platform specific tag for a `BundleDetails` object */
202function getPlatformTagForBuildDetails(bundleDetails?: BundleDetails | null): string {
203  const platform = bundleDetails?.platform ?? null;
204  if (platform) {
205    const formatted = { ios: 'iOS', android: 'Android', web: 'Web' }[platform] || platform;
206    return `${chalk.bold(formatted)} `;
207  }
208
209  return '';
210}
211