18d307f52SEvan Baconimport chalk from 'chalk'; 28d307f52SEvan Baconimport { Terminal } from 'metro-core'; 38d307f52SEvan Baconimport path from 'path'; 48d307f52SEvan Bacon 58d307f52SEvan Baconimport { logWarning, TerminalReporter } from './TerminalReporter'; 68d307f52SEvan Baconimport { BuildPhase, BundleDetails, BundleProgress, SnippetError } from './TerminalReporter.types'; 757eba0f9SEvan Baconimport { NODE_STDLIB_MODULES } from './externals'; 88a424bebSJames Ideimport { learnMore } from '../../../utils/link'; 98d307f52SEvan Bacon 108d307f52SEvan Baconconst MAX_PROGRESS_BAR_CHAR_WIDTH = 16; 118d307f52SEvan Baconconst DARK_BLOCK_CHAR = '\u2593'; 128d307f52SEvan Baconconst LIGHT_BLOCK_CHAR = '\u2591'; 138d307f52SEvan Bacon/** 148d307f52SEvan Bacon * Extends the default Metro logger and adds some additional features. 158d307f52SEvan Bacon * Also removes the giant Metro logo from the output. 168d307f52SEvan Bacon */ 178d307f52SEvan Baconexport class MetroTerminalReporter extends TerminalReporter { 188a424bebSJames Ide constructor( 198a424bebSJames Ide public projectRoot: string, 208a424bebSJames Ide terminal: Terminal 218a424bebSJames Ide ) { 228d307f52SEvan Bacon super(terminal); 238d307f52SEvan Bacon } 248d307f52SEvan Bacon 258d307f52SEvan Bacon // Used for testing 268d307f52SEvan Bacon _getElapsedTime(startTime: number): number { 278d307f52SEvan Bacon return Date.now() - startTime; 288d307f52SEvan Bacon } 298d307f52SEvan Bacon /** 308d307f52SEvan Bacon * Extends the bundle progress to include the current platform that we're bundling. 318d307f52SEvan Bacon * 328d307f52SEvan Bacon * @returns `iOS path/to/bundle.js ▓▓▓▓▓░░░░░░░░░░░ 36.6% (4790/7922)` 338d307f52SEvan Bacon */ 348d307f52SEvan Bacon _getBundleStatusMessage(progress: BundleProgress, phase: BuildPhase): string { 357179edeaSEvan Bacon const env = getEnvironmentForBuildDetails(progress.bundleDetails); 367179edeaSEvan Bacon const platform = env || getPlatformTagForBuildDetails(progress.bundleDetails); 378d307f52SEvan Bacon const inProgress = phase === 'in_progress'; 388d307f52SEvan Bacon 398d307f52SEvan Bacon if (!inProgress) { 408d307f52SEvan Bacon const status = phase === 'done' ? `Bundling complete ` : `Bundling failed `; 418d307f52SEvan Bacon const color = phase === 'done' ? chalk.green : chalk.red; 428d307f52SEvan Bacon 4329975bfdSEvan Bacon const startTime = this._bundleTimers.get(progress.bundleDetails.buildID!); 4429975bfdSEvan Bacon const time = startTime != null ? chalk.dim(this._getElapsedTime(startTime) + 'ms') : ''; 458d307f52SEvan Bacon // iOS Bundling complete 150ms 468d307f52SEvan Bacon return color(platform + status) + time; 478d307f52SEvan Bacon } 488d307f52SEvan Bacon 4930986256SEvan Bacon const localPath = progress.bundleDetails.entryFile.startsWith(path.sep) 5030986256SEvan Bacon ? path.relative(this.projectRoot, progress.bundleDetails.entryFile) 5130986256SEvan Bacon : progress.bundleDetails.entryFile; 5230986256SEvan Bacon 538d307f52SEvan Bacon const filledBar = Math.floor(progress.ratio * MAX_PROGRESS_BAR_CHAR_WIDTH); 548d307f52SEvan Bacon 558d307f52SEvan Bacon const _progress = inProgress 568d307f52SEvan Bacon ? chalk.green.bgGreen(DARK_BLOCK_CHAR.repeat(filledBar)) + 578d307f52SEvan Bacon chalk.bgWhite.white(LIGHT_BLOCK_CHAR.repeat(MAX_PROGRESS_BAR_CHAR_WIDTH - filledBar)) + 588d307f52SEvan Bacon chalk.bold(` ${(100 * progress.ratio).toFixed(1).padStart(4)}% `) + 598d307f52SEvan Bacon chalk.dim( 608d307f52SEvan Bacon `(${progress.transformedFileCount 618d307f52SEvan Bacon .toString() 628d307f52SEvan Bacon .padStart(progress.totalFileCount.toString().length)}/${progress.totalFileCount})` 638d307f52SEvan Bacon ) 648d307f52SEvan Bacon : ''; 658d307f52SEvan Bacon 668d307f52SEvan Bacon return ( 678d307f52SEvan Bacon platform + 688d307f52SEvan Bacon chalk.reset.dim(`${path.dirname(localPath)}/`) + 698d307f52SEvan Bacon chalk.bold(path.basename(localPath)) + 708d307f52SEvan Bacon ' ' + 718d307f52SEvan Bacon _progress 728d307f52SEvan Bacon ); 738d307f52SEvan Bacon } 748d307f52SEvan Bacon 758d307f52SEvan Bacon _logInitializing(port: number, hasReducedPerformance: boolean): void { 768d307f52SEvan Bacon // Don't print a giant logo... 778d307f52SEvan Bacon this.terminal.log('Starting Metro Bundler'); 788d307f52SEvan Bacon } 798d307f52SEvan Bacon 808d307f52SEvan Bacon shouldFilterClientLog(event: { 818d307f52SEvan Bacon type: 'client_log'; 828d307f52SEvan Bacon level: 'trace' | 'info' | 'warn' | 'log' | 'group' | 'groupCollapsed' | 'groupEnd' | 'debug'; 838d307f52SEvan Bacon data: unknown[]; 848d307f52SEvan Bacon }): boolean { 858d307f52SEvan Bacon return isAppRegistryStartupMessage(event.data); 868d307f52SEvan Bacon } 878d307f52SEvan Bacon 888d307f52SEvan Bacon /** Print the cache clear message. */ 898d307f52SEvan Bacon transformCacheReset(): void { 908d307f52SEvan Bacon logWarning( 918d307f52SEvan Bacon this.terminal, 928d307f52SEvan Bacon chalk`Bundler cache is empty, rebuilding {dim (this may take a minute)}` 938d307f52SEvan Bacon ); 948d307f52SEvan Bacon } 958d307f52SEvan Bacon 968d307f52SEvan Bacon /** One of the first logs that will be printed */ 978d307f52SEvan Bacon dependencyGraphLoading(hasReducedPerformance: boolean): void { 988d307f52SEvan Bacon // this.terminal.log('Dependency graph is loading...'); 998d307f52SEvan Bacon if (hasReducedPerformance) { 1008d307f52SEvan Bacon // Extends https://github.com/facebook/metro/blob/347b1d7ed87995d7951aaa9fd597c04b06013dac/packages/metro/src/lib/TerminalReporter.js#L283-L290 1018d307f52SEvan Bacon this.terminal.log( 1028d307f52SEvan Bacon chalk.red( 1038d307f52SEvan Bacon [ 1048d307f52SEvan Bacon 'Metro is operating with reduced performance.', 1058d307f52SEvan Bacon 'Please fix the problem above and restart Metro.', 1068d307f52SEvan Bacon ].join('\n') 1078d307f52SEvan Bacon ) 1088d307f52SEvan Bacon ); 1098d307f52SEvan Bacon } 1108d307f52SEvan Bacon } 1118d307f52SEvan Bacon 1128d307f52SEvan Bacon _logBundlingError(error: SnippetError): void { 1138d307f52SEvan Bacon const moduleResolutionError = formatUsingNodeStandardLibraryError(this.projectRoot, error); 1142fbedb18SEvan Bacon const cause = error.cause as undefined | { _expoImportStack?: string }; 1158d307f52SEvan Bacon if (moduleResolutionError) { 1162fbedb18SEvan Bacon let message = maybeAppendCodeFrame(moduleResolutionError, error.message); 1172fbedb18SEvan Bacon if (cause?._expoImportStack) { 1182fbedb18SEvan Bacon message += `\n\n${cause?._expoImportStack}`; 1192fbedb18SEvan Bacon } 1202fbedb18SEvan Bacon return this.terminal.log(message); 1212fbedb18SEvan Bacon } 1222fbedb18SEvan Bacon if (cause?._expoImportStack) { 1232fbedb18SEvan Bacon error.message += `\n\n${cause._expoImportStack}`; 1248d307f52SEvan Bacon } 1258d307f52SEvan Bacon return super._logBundlingError(error); 1268d307f52SEvan Bacon } 1278d307f52SEvan Bacon} 1288d307f52SEvan Bacon 1298d307f52SEvan Bacon/** 1308d307f52SEvan Bacon * Formats an error where the user is attempting to import a module from the Node.js standard library. 1318d307f52SEvan Bacon * Exposed for testing. 1328d307f52SEvan Bacon * 1338d307f52SEvan Bacon * @param error 1348d307f52SEvan Bacon * @returns error message or null if not a module resolution error 1358d307f52SEvan Bacon */ 1368d307f52SEvan Baconexport function formatUsingNodeStandardLibraryError( 1378d307f52SEvan Bacon projectRoot: string, 1388d307f52SEvan Bacon error: SnippetError 1398d307f52SEvan Bacon): string | null { 1408d307f52SEvan Bacon if (!error.message) { 1418d307f52SEvan Bacon return null; 1428d307f52SEvan Bacon } 1438d307f52SEvan Bacon const { targetModuleName, originModulePath } = error; 1448d307f52SEvan Bacon if (!targetModuleName || !originModulePath) { 1458d307f52SEvan Bacon return null; 1468d307f52SEvan Bacon } 1478d307f52SEvan Bacon const relativePath = path.relative(projectRoot, originModulePath); 1488d307f52SEvan Bacon 1498d307f52SEvan Bacon const DOCS_PAGE_URL = 1508d307f52SEvan Bacon 'https://docs.expo.dev/workflow/using-libraries/#using-third-party-libraries'; 1518d307f52SEvan Bacon 1528d307f52SEvan Bacon if (isNodeStdLibraryModule(targetModuleName)) { 1538d307f52SEvan Bacon if (originModulePath.includes('node_modules')) { 1548d307f52SEvan Bacon return [ 1558d307f52SEvan Bacon `The package at "${chalk.bold( 1568d307f52SEvan Bacon relativePath 1578d307f52SEvan Bacon )}" attempted to import the Node standard library module "${chalk.bold( 1588d307f52SEvan Bacon targetModuleName 1598d307f52SEvan Bacon )}".`, 1608d307f52SEvan Bacon `It failed because the native React runtime does not include the Node standard library.`, 1618d307f52SEvan Bacon learnMore(DOCS_PAGE_URL), 1628d307f52SEvan Bacon ].join('\n'); 1638d307f52SEvan Bacon } else { 1648d307f52SEvan Bacon return [ 165*92ddc08bSEvan Bacon `You attempted to import the Node standard library module "${chalk.bold( 1668d307f52SEvan Bacon targetModuleName 1678d307f52SEvan Bacon )}" from "${chalk.bold(relativePath)}".`, 1688d307f52SEvan Bacon `It failed because the native React runtime does not include the Node standard library.`, 1698d307f52SEvan Bacon learnMore(DOCS_PAGE_URL), 1708d307f52SEvan Bacon ].join('\n'); 1718d307f52SEvan Bacon } 1728d307f52SEvan Bacon } 1738d307f52SEvan Bacon return `Unable to resolve "${targetModuleName}" from "${relativePath}"`; 1748d307f52SEvan Bacon} 1758d307f52SEvan Bacon 1768d307f52SEvan Baconexport function isNodeStdLibraryModule(moduleName: string): boolean { 1778d307f52SEvan Bacon return /^node:/.test(moduleName) || NODE_STDLIB_MODULES.includes(moduleName); 1788d307f52SEvan Bacon} 1798d307f52SEvan Bacon 1808d307f52SEvan Bacon/** If the code frame can be found then append it to the existing message. */ 1818d307f52SEvan Baconfunction maybeAppendCodeFrame(message: string, rawMessage: string): string { 1828d307f52SEvan Bacon const codeFrame = stripMetroInfo(rawMessage); 1838d307f52SEvan Bacon if (codeFrame) { 1848d307f52SEvan Bacon message += '\n' + codeFrame; 1858d307f52SEvan Bacon } 1868d307f52SEvan Bacon return message; 1878d307f52SEvan Bacon} 1888d307f52SEvan Bacon 1898d307f52SEvan Bacon/** 1908d307f52SEvan Bacon * Remove the Metro cache clearing steps if they exist. 1918d307f52SEvan Bacon * In future versions we won't need this. 1928d307f52SEvan Bacon * Returns the remaining code frame logs. 1938d307f52SEvan Bacon */ 19429975bfdSEvan Baconexport function stripMetroInfo(errorMessage: string): string | null { 1958d307f52SEvan Bacon // Newer versions of Metro don't include the list. 1968d307f52SEvan Bacon if (!errorMessage.includes('4. Remove the cache')) { 1978d307f52SEvan Bacon return null; 1988d307f52SEvan Bacon } 1998d307f52SEvan Bacon const lines = errorMessage.split('\n'); 2008d307f52SEvan Bacon const index = lines.findIndex((line) => line.includes('4. Remove the cache')); 2018d307f52SEvan Bacon if (index === -1) { 2028d307f52SEvan Bacon return null; 2038d307f52SEvan Bacon } 2048d307f52SEvan Bacon return lines.slice(index + 1).join('\n'); 2058d307f52SEvan Bacon} 2068d307f52SEvan Bacon 2078d307f52SEvan Bacon/** @returns if the message matches the initial startup log */ 2088d307f52SEvan Baconfunction isAppRegistryStartupMessage(body: any[]): boolean { 2098d307f52SEvan Bacon return ( 2108d307f52SEvan Bacon body.length === 1 && 2118d307f52SEvan Bacon (/^Running application "main" with appParams:/.test(body[0]) || 2128d307f52SEvan Bacon /^Running "main" with \{/.test(body[0])) 2138d307f52SEvan Bacon ); 2148d307f52SEvan Bacon} 2158d307f52SEvan Bacon 2168d307f52SEvan Bacon/** @returns platform specific tag for a `BundleDetails` object */ 2178d307f52SEvan Baconfunction getPlatformTagForBuildDetails(bundleDetails?: BundleDetails | null): string { 2188d307f52SEvan Bacon const platform = bundleDetails?.platform ?? null; 2198d307f52SEvan Bacon if (platform) { 2208d307f52SEvan Bacon const formatted = { ios: 'iOS', android: 'Android', web: 'Web' }[platform] || platform; 2218d307f52SEvan Bacon return `${chalk.bold(formatted)} `; 2228d307f52SEvan Bacon } 2238d307f52SEvan Bacon 2248d307f52SEvan Bacon return ''; 2258d307f52SEvan Bacon} 2267179edeaSEvan Bacon/** @returns platform specific tag for a `BundleDetails` object */ 2277179edeaSEvan Baconfunction getEnvironmentForBuildDetails(bundleDetails?: BundleDetails | null): string { 2287179edeaSEvan Bacon // Expo CLI will pass `customTransformOptions.environment = 'node'` when bundling for the server. 2297179edeaSEvan Bacon const env = bundleDetails?.customTransformOptions?.environment ?? null; 2307179edeaSEvan Bacon if (env === 'node') { 2317179edeaSEvan Bacon return `${chalk.bold('Server')} `; 2327179edeaSEvan Bacon } 2337179edeaSEvan Bacon 2347179edeaSEvan Bacon return ''; 2357179edeaSEvan Bacon} 236