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