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