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