1import Log from '@expo/bunyan'; 2import { ExpoConfig, getConfigFilePaths } from '@expo/config'; 3import { 4 buildHermesBundleAsync, 5 isEnableHermesManaged, 6 maybeThrowFromInconsistentEngineAsync, 7} from '@expo/dev-server/build/HermesBundler'; 8import { 9 importExpoMetroConfigFromProject, 10 importMetroFromProject, 11 importMetroServerFromProject, 12} from '@expo/dev-server/build/metro/importMetroFromProject'; 13import { LoadOptions } from '@expo/metro-config'; 14import chalk from 'chalk'; 15import Metro from 'metro'; 16import { Terminal } from 'metro-core'; 17 18import { MetroTerminalReporter } from '../start/server/metro/MetroTerminalReporter'; 19 20export type MetroDevServerOptions = LoadOptions & { 21 logger: Log; 22 quiet?: boolean; 23}; 24export type BundleOptions = { 25 entryPoint: string; 26 platform: 'android' | 'ios' | 'web'; 27 dev?: boolean; 28 minify?: boolean; 29 sourceMapUrl?: string; 30}; 31export type BundleAssetWithFileHashes = Metro.AssetData & { 32 fileHashes: string[]; // added by the hashAssets asset plugin 33}; 34export type BundleOutput = { 35 code: string; 36 map: string; 37 hermesBytecodeBundle?: Uint8Array; 38 hermesSourcemap?: string; 39 assets: readonly BundleAssetWithFileHashes[]; 40}; 41 42function getExpoMetroConfig( 43 projectRoot: string, 44 { logger }: Pick<MetroDevServerOptions, 'logger'> 45): typeof import('@expo/metro-config') { 46 try { 47 return importExpoMetroConfigFromProject(projectRoot); 48 } catch { 49 // If expo isn't installed, use the unversioned config and warn about installing expo. 50 } 51 52 const unversionedVersion = require('@expo/metro-config/package.json').version; 53 logger.info( 54 { tag: 'expo' }, 55 chalk.gray( 56 `\u203A Unversioned ${chalk.bold`@expo/metro-config@${unversionedVersion}`} is being used. Bundling apps may not work as expected, and is subject to breaking changes. Install ${chalk.bold`expo`} or set the app.json sdkVersion to use a stable version of @expo/metro-config.` 57 ) 58 ); 59 60 return require('@expo/metro-config'); 61} 62 63let nextBuildID = 0; 64 65// Fork of @expo/dev-server bundleAsync to add Metro logging back. 66 67export async function bundleAsync( 68 projectRoot: string, 69 expoConfig: ExpoConfig, 70 options: MetroDevServerOptions, 71 bundles: BundleOptions[] 72): Promise<BundleOutput[]> { 73 const metro = importMetroFromProject(projectRoot); 74 const Server = importMetroServerFromProject(projectRoot); 75 76 let reportEvent: ((event: any) => void) | undefined; 77 78 const terminal = new Terminal(process.stdout); 79 const terminalReporter = new MetroTerminalReporter(projectRoot, terminal); 80 81 const reporter = { 82 update(event: any) { 83 terminalReporter.update(event); 84 if (reportEvent) { 85 reportEvent(event); 86 } 87 }, 88 }; 89 90 const ExpoMetroConfig = getExpoMetroConfig(projectRoot, options); 91 92 const config = await ExpoMetroConfig.loadAsync(projectRoot, { reporter, ...options }); 93 const buildID = `bundle_${nextBuildID++}`; 94 95 // @ts-expect-error 96 const metroServer = await metro.runMetro(config, { 97 watch: false, 98 }); 99 100 const buildAsync = async (bundle: BundleOptions): Promise<BundleOutput> => { 101 const bundleOptions: Metro.BundleOptions = { 102 ...Server.DEFAULT_BUNDLE_OPTIONS, 103 bundleType: 'bundle', 104 platform: bundle.platform, 105 entryFile: bundle.entryPoint, 106 dev: bundle.dev ?? false, 107 minify: bundle.minify ?? !bundle.dev, 108 inlineSourceMap: false, 109 sourceMapUrl: bundle.sourceMapUrl, 110 createModuleIdFactory: config.serializer.createModuleIdFactory, 111 onProgress: (transformedFileCount: number, totalFileCount: number) => { 112 if (!options.quiet) { 113 reporter.update({ 114 buildID, 115 type: 'bundle_transform_progressed', 116 transformedFileCount, 117 totalFileCount, 118 }); 119 } 120 }, 121 }; 122 reporter.update({ 123 buildID, 124 type: 'bundle_build_started', 125 bundleDetails: { 126 bundleType: bundleOptions.bundleType, 127 platform: bundle.platform, 128 entryFile: bundle.entryPoint, 129 dev: bundle.dev ?? false, 130 minify: bundle.minify ?? false, 131 }, 132 }); 133 const { code, map } = await metroServer.build(bundleOptions); 134 const assets = (await metroServer.getAssets( 135 bundleOptions 136 )) as readonly BundleAssetWithFileHashes[]; 137 reporter.update({ 138 buildID, 139 type: 'bundle_build_done', 140 }); 141 return { code, map, assets }; 142 }; 143 144 const maybeAddHermesBundleAsync = async ( 145 bundle: BundleOptions, 146 bundleOutput: BundleOutput 147 ): Promise<BundleOutput> => { 148 const { platform } = bundle; 149 const isHermesManaged = isEnableHermesManaged(expoConfig, platform); 150 151 const paths = getConfigFilePaths(projectRoot); 152 const configFilePath = paths.dynamicConfigPath ?? paths.staticConfigPath ?? 'app.json'; 153 await maybeThrowFromInconsistentEngineAsync( 154 projectRoot, 155 configFilePath, 156 platform, 157 isHermesManaged 158 ); 159 160 if (isHermesManaged) { 161 const platformTag = chalk.bold( 162 { ios: 'iOS', android: 'Android', web: 'Web' }[platform] || platform 163 ); 164 options.logger.info( 165 { tag: 'expo' }, 166 ` ${platformTag} Building Hermes bytecode for the bundle` 167 ); 168 const hermesBundleOutput = await buildHermesBundleAsync( 169 projectRoot, 170 bundleOutput.code, 171 bundleOutput.map, 172 bundle.minify 173 ); 174 bundleOutput.hermesBytecodeBundle = hermesBundleOutput.hbc; 175 bundleOutput.hermesSourcemap = hermesBundleOutput.sourcemap; 176 } 177 return bundleOutput; 178 }; 179 180 try { 181 const intermediateOutputs = await Promise.all(bundles.map((bundle) => buildAsync(bundle))); 182 const bundleOutputs: BundleOutput[] = []; 183 for (let i = 0; i < bundles.length; ++i) { 184 // hermesc does not support parallel building even we spawn processes. 185 // we should build them sequentially. 186 bundleOutputs.push(await maybeAddHermesBundleAsync(bundles[i], intermediateOutputs[i])); 187 } 188 return bundleOutputs; 189 } finally { 190 metroServer.end(); 191 } 192} 193