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