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