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