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