import { ExpoConfig, getConfig, getConfigFilePaths, Platform } from '@expo/config'; import { buildHermesBundleAsync, isEnableHermesManaged, maybeThrowFromInconsistentEngineAsync, } from '@expo/dev-server/build/HermesBundler'; import { importExpoMetroConfigFromProject, importMetroFromProject, importMetroServerFromProject, } from '@expo/dev-server/build/metro/importMetroFromProject'; import { LoadOptions } from '@expo/metro-config'; import chalk from 'chalk'; import Metro from 'metro'; import { Terminal } from 'metro-core'; import { MetroTerminalReporter } from '../start/server/metro/MetroTerminalReporter'; import { withMetroMultiPlatformAsync } from '../start/server/metro/withMetroMultiPlatform'; import { getPlatformBundlers } from '../start/server/platformBundlers'; export type MetroDevServerOptions = LoadOptions & { logger: import('@expo/bunyan'); quiet?: boolean; }; export type BundleOptions = { entryPoint: string; platform: 'android' | 'ios' | 'web'; dev?: boolean; minify?: boolean; sourceMapUrl?: string; }; export type BundleAssetWithFileHashes = Metro.AssetData & { fileHashes: string[]; // added by the hashAssets asset plugin }; export type BundleOutput = { code: string; map?: string; hermesBytecodeBundle?: Uint8Array; hermesSourcemap?: string; assets: readonly BundleAssetWithFileHashes[]; }; function getExpoMetroConfig( projectRoot: string, { logger }: Pick ): typeof import('@expo/metro-config') { try { return importExpoMetroConfigFromProject(projectRoot); } catch { // If expo isn't installed, use the unversioned config and warn about installing expo. } const unversionedVersion = require('@expo/metro-config/package.json').version; logger.info( { tag: 'expo' }, chalk.gray( `\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.` ) ); return require('@expo/metro-config'); } let nextBuildID = 0; // Fork of @expo/dev-server bundleAsync to add Metro logging back. async function assertEngineMismatchAsync(projectRoot: string, exp: ExpoConfig, platform: Platform) { const isHermesManaged = isEnableHermesManaged(exp, platform); const paths = getConfigFilePaths(projectRoot); const configFilePath = paths.dynamicConfigPath ?? paths.staticConfigPath ?? 'app.json'; await maybeThrowFromInconsistentEngineAsync( projectRoot, configFilePath, platform, isHermesManaged ); } export async function bundleAsync( projectRoot: string, expoConfig: ExpoConfig, options: MetroDevServerOptions, bundles: BundleOptions[] ): Promise { // Assert early so the user doesn't have to wait until bundling is complete to find out that // Hermes won't be available. await Promise.all( bundles.map(({ platform }) => assertEngineMismatchAsync(projectRoot, expoConfig, platform)) ); const metro = importMetroFromProject(projectRoot); const Server = importMetroServerFromProject(projectRoot); const terminal = new Terminal(process.stdout); const terminalReporter = new MetroTerminalReporter(projectRoot, terminal); const reporter = { update(event: any) { terminalReporter.update(event); }, }; const ExpoMetroConfig = getExpoMetroConfig(projectRoot, options); const { exp } = getConfig(projectRoot, { skipSDKVersionRequirement: true }); let config = await ExpoMetroConfig.loadAsync(projectRoot, { reporter, ...options }); const bundlerPlatforms = getPlatformBundlers(exp); config = await withMetroMultiPlatformAsync(projectRoot, config, bundlerPlatforms); const metroServer = await metro.runMetro(config, { watch: false, }); const buildAsync = async (bundle: BundleOptions): Promise => { const buildID = `bundle_${nextBuildID++}_${bundle.platform}`; const bundleOptions: Metro.BundleOptions = { ...Server.DEFAULT_BUNDLE_OPTIONS, bundleType: 'bundle', platform: bundle.platform, entryFile: bundle.entryPoint, dev: bundle.dev ?? false, minify: bundle.minify ?? !bundle.dev, inlineSourceMap: false, sourceMapUrl: bundle.sourceMapUrl, createModuleIdFactory: config.serializer.createModuleIdFactory, onProgress: (transformedFileCount: number, totalFileCount: number) => { if (!options.quiet) { terminalReporter.update({ buildID, type: 'bundle_transform_progressed', transformedFileCount, totalFileCount, }); } }, }; const bundleDetails = { ...bundleOptions, buildID, }; terminalReporter.update({ buildID, type: 'bundle_build_started', // @ts-expect-error: TODO bundleDetails, }); try { const { code, map } = await metroServer.build(bundleOptions); const assets = (await metroServer.getAssets( bundleOptions )) as readonly BundleAssetWithFileHashes[]; terminalReporter.update({ buildID, type: 'bundle_build_done', }); return { code, map, assets }; } catch (error) { terminalReporter.update({ buildID, type: 'bundle_build_failed', }); throw error; } }; const maybeAddHermesBundleAsync = async ( bundle: BundleOptions, bundleOutput: BundleOutput ): Promise => { const { platform } = bundle; const isHermesManaged = isEnableHermesManaged(expoConfig, platform); if (isHermesManaged) { const platformTag = chalk.bold( { ios: 'iOS', android: 'Android', web: 'Web' }[platform] || platform ); terminalReporter.terminal.log(`${platformTag} Building Hermes bytecode for the bundle`); const hermesBundleOutput = await buildHermesBundleAsync( projectRoot, bundleOutput.code, bundleOutput.map!, bundle.minify ); bundleOutput.hermesBytecodeBundle = hermesBundleOutput.hbc; bundleOutput.hermesSourcemap = hermesBundleOutput.sourcemap; } return bundleOutput; }; try { const intermediateOutputs = await Promise.all(bundles.map((bundle) => buildAsync(bundle))); const bundleOutputs: BundleOutput[] = []; for (let i = 0; i < bundles.length; ++i) { // hermesc does not support parallel building even we spawn processes. // we should build them sequentially. bundleOutputs.push(await maybeAddHermesBundleAsync(bundles[i], intermediateOutputs[i])); } return bundleOutputs; } catch (error) { // New line so errors don't show up inline with the progress bar console.log(''); throw error; } finally { metroServer.end(); } }