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 isHermes = isEnableHermesManaged(expoConfig, bundle.platform); 121 const bundleOptions: Metro.BundleOptions = { 122 ...Server.DEFAULT_BUNDLE_OPTIONS, 123 bundleType: 'bundle', 124 platform: bundle.platform, 125 entryFile: bundle.entryPoint, 126 dev: bundle.dev ?? false, 127 minify: !isHermes && (bundle.minify ?? !bundle.dev), 128 inlineSourceMap: false, 129 sourceMapUrl: bundle.sourceMapUrl, 130 createModuleIdFactory: config.serializer.createModuleIdFactory, 131 onProgress: (transformedFileCount: number, totalFileCount: number) => { 132 if (!options.quiet) { 133 terminalReporter.update({ 134 buildID, 135 type: 'bundle_transform_progressed', 136 transformedFileCount, 137 totalFileCount, 138 }); 139 } 140 }, 141 }; 142 const bundleDetails = { 143 ...bundleOptions, 144 buildID, 145 }; 146 terminalReporter.update({ 147 buildID, 148 type: 'bundle_build_started', 149 // @ts-expect-error: TODO 150 bundleDetails, 151 }); 152 try { 153 const { code, map } = await metroServer.build(bundleOptions); 154 const assets = (await metroServer.getAssets( 155 bundleOptions 156 )) as readonly BundleAssetWithFileHashes[]; 157 terminalReporter.update({ 158 buildID, 159 type: 'bundle_build_done', 160 }); 161 return { code, map, assets }; 162 } catch (error) { 163 terminalReporter.update({ 164 buildID, 165 type: 'bundle_build_failed', 166 }); 167 168 throw error; 169 } 170 }; 171 172 const maybeAddHermesBundleAsync = async ( 173 bundle: BundleOptions, 174 bundleOutput: BundleOutput 175 ): Promise<BundleOutput> => { 176 const { platform } = bundle; 177 const isHermesManaged = isEnableHermesManaged(expoConfig, platform); 178 if (isHermesManaged) { 179 const platformTag = chalk.bold( 180 { ios: 'iOS', android: 'Android', web: 'Web' }[platform] || platform 181 ); 182 183 terminalReporter.terminal.log(`${platformTag} Building Hermes bytecode for the bundle`); 184 185 const hermesBundleOutput = await buildHermesBundleAsync( 186 projectRoot, 187 bundleOutput.code, 188 bundleOutput.map!, 189 bundle.minify ?? !bundle.dev 190 ); 191 bundleOutput.hermesBytecodeBundle = hermesBundleOutput.hbc; 192 bundleOutput.hermesSourcemap = hermesBundleOutput.sourcemap; 193 } 194 return bundleOutput; 195 }; 196 197 try { 198 const intermediateOutputs = await Promise.all(bundles.map((bundle) => buildAsync(bundle))); 199 const bundleOutputs: BundleOutput[] = []; 200 for (let i = 0; i < bundles.length; ++i) { 201 // hermesc does not support parallel building even we spawn processes. 202 // we should build them sequentially. 203 bundleOutputs.push(await maybeAddHermesBundleAsync(bundles[i], intermediateOutputs[i])); 204 } 205 return bundleOutputs; 206 } catch (error) { 207 // New line so errors don't show up inline with the progress bar 208 console.log(''); 209 throw error; 210 } finally { 211 metroServer.end(); 212 } 213} 214