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