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