import chalk from 'chalk'; import { Terminal } from 'metro-core'; import path from 'path'; import { logWarning, TerminalReporter } from './TerminalReporter'; import { BuildPhase, BundleDetails, BundleProgress, SnippetError } from './TerminalReporter.types'; import { NODE_STDLIB_MODULES } from './externals'; import { learnMore } from '../../../utils/link'; const MAX_PROGRESS_BAR_CHAR_WIDTH = 16; const DARK_BLOCK_CHAR = '\u2593'; const LIGHT_BLOCK_CHAR = '\u2591'; /** * Extends the default Metro logger and adds some additional features. * Also removes the giant Metro logo from the output. */ export class MetroTerminalReporter extends TerminalReporter { constructor( public projectRoot: string, terminal: Terminal ) { super(terminal); } // Used for testing _getElapsedTime(startTime: number): number { return Date.now() - startTime; } /** * Extends the bundle progress to include the current platform that we're bundling. * * @returns `iOS path/to/bundle.js ▓▓▓▓▓░░░░░░░░░░░ 36.6% (4790/7922)` */ _getBundleStatusMessage(progress: BundleProgress, phase: BuildPhase): string { const env = getEnvironmentForBuildDetails(progress.bundleDetails); const platform = env || getPlatformTagForBuildDetails(progress.bundleDetails); const inProgress = phase === 'in_progress'; if (!inProgress) { const status = phase === 'done' ? `Bundling complete ` : `Bundling failed `; const color = phase === 'done' ? chalk.green : chalk.red; const startTime = this._bundleTimers.get(progress.bundleDetails.buildID!); const time = startTime != null ? chalk.dim(this._getElapsedTime(startTime) + 'ms') : ''; // iOS Bundling complete 150ms return color(platform + status) + time; } const localPath = progress.bundleDetails.entryFile.startsWith(path.sep) ? path.relative(this.projectRoot, progress.bundleDetails.entryFile) : progress.bundleDetails.entryFile; const filledBar = Math.floor(progress.ratio * MAX_PROGRESS_BAR_CHAR_WIDTH); const _progress = inProgress ? chalk.green.bgGreen(DARK_BLOCK_CHAR.repeat(filledBar)) + chalk.bgWhite.white(LIGHT_BLOCK_CHAR.repeat(MAX_PROGRESS_BAR_CHAR_WIDTH - filledBar)) + chalk.bold(` ${(100 * progress.ratio).toFixed(1).padStart(4)}% `) + chalk.dim( `(${progress.transformedFileCount .toString() .padStart(progress.totalFileCount.toString().length)}/${progress.totalFileCount})` ) : ''; return ( platform + chalk.reset.dim(`${path.dirname(localPath)}/`) + chalk.bold(path.basename(localPath)) + ' ' + _progress ); } _logInitializing(port: number, hasReducedPerformance: boolean): void { // Don't print a giant logo... this.terminal.log('Starting Metro Bundler'); } shouldFilterClientLog(event: { type: 'client_log'; level: 'trace' | 'info' | 'warn' | 'log' | 'group' | 'groupCollapsed' | 'groupEnd' | 'debug'; data: unknown[]; }): boolean { return isAppRegistryStartupMessage(event.data); } /** Print the cache clear message. */ transformCacheReset(): void { logWarning( this.terminal, chalk`Bundler cache is empty, rebuilding {dim (this may take a minute)}` ); } /** One of the first logs that will be printed */ dependencyGraphLoading(hasReducedPerformance: boolean): void { // this.terminal.log('Dependency graph is loading...'); if (hasReducedPerformance) { // Extends https://github.com/facebook/metro/blob/347b1d7ed87995d7951aaa9fd597c04b06013dac/packages/metro/src/lib/TerminalReporter.js#L283-L290 this.terminal.log( chalk.red( [ 'Metro is operating with reduced performance.', 'Please fix the problem above and restart Metro.', ].join('\n') ) ); } } _logBundlingError(error: SnippetError): void { const moduleResolutionError = formatUsingNodeStandardLibraryError(this.projectRoot, error); const cause = error.cause as undefined | { _expoImportStack?: string }; if (moduleResolutionError) { let message = maybeAppendCodeFrame(moduleResolutionError, error.message); if (cause?._expoImportStack) { message += `\n\n${cause?._expoImportStack}`; } return this.terminal.log(message); } if (cause?._expoImportStack) { error.message += `\n\n${cause._expoImportStack}`; } return super._logBundlingError(error); } } /** * Formats an error where the user is attempting to import a module from the Node.js standard library. * Exposed for testing. * * @param error * @returns error message or null if not a module resolution error */ export function formatUsingNodeStandardLibraryError( projectRoot: string, error: SnippetError ): string | null { if (!error.message) { return null; } const { targetModuleName, originModulePath } = error; if (!targetModuleName || !originModulePath) { return null; } const relativePath = path.relative(projectRoot, originModulePath); const DOCS_PAGE_URL = 'https://docs.expo.dev/workflow/using-libraries/#using-third-party-libraries'; if (isNodeStdLibraryModule(targetModuleName)) { if (originModulePath.includes('node_modules')) { return [ `The package at "${chalk.bold( relativePath )}" attempted to import the Node standard library module "${chalk.bold( targetModuleName )}".`, `It failed because the native React runtime does not include the Node standard library.`, learnMore(DOCS_PAGE_URL), ].join('\n'); } else { return [ `You attempted to import the Node standard library module "${chalk.bold( targetModuleName )}" from "${chalk.bold(relativePath)}".`, `It failed because the native React runtime does not include the Node standard library.`, learnMore(DOCS_PAGE_URL), ].join('\n'); } } return `Unable to resolve "${targetModuleName}" from "${relativePath}"`; } export function isNodeStdLibraryModule(moduleName: string): boolean { return /^node:/.test(moduleName) || NODE_STDLIB_MODULES.includes(moduleName); } /** If the code frame can be found then append it to the existing message. */ function maybeAppendCodeFrame(message: string, rawMessage: string): string { const codeFrame = stripMetroInfo(rawMessage); if (codeFrame) { message += '\n' + codeFrame; } return message; } /** * Remove the Metro cache clearing steps if they exist. * In future versions we won't need this. * Returns the remaining code frame logs. */ export function stripMetroInfo(errorMessage: string): string | null { // Newer versions of Metro don't include the list. if (!errorMessage.includes('4. Remove the cache')) { return null; } const lines = errorMessage.split('\n'); const index = lines.findIndex((line) => line.includes('4. Remove the cache')); if (index === -1) { return null; } return lines.slice(index + 1).join('\n'); } /** @returns if the message matches the initial startup log */ function isAppRegistryStartupMessage(body: any[]): boolean { return ( body.length === 1 && (/^Running application "main" with appParams:/.test(body[0]) || /^Running "main" with \{/.test(body[0])) ); } /** @returns platform specific tag for a `BundleDetails` object */ function getPlatformTagForBuildDetails(bundleDetails?: BundleDetails | null): string { const platform = bundleDetails?.platform ?? null; if (platform) { const formatted = { ios: 'iOS', android: 'Android', web: 'Web' }[platform] || platform; return `${chalk.bold(formatted)} `; } return ''; } /** @returns platform specific tag for a `BundleDetails` object */ function getEnvironmentForBuildDetails(bundleDetails?: BundleDetails | null): string { // Expo CLI will pass `customTransformOptions.environment = 'node'` when bundling for the server. const env = bundleDetails?.customTransformOptions?.environment ?? null; if (env === 'node') { return `${chalk.bold('Server')} `; } return ''; }