1import { ExpoConfig, getConfigFilePaths, Platform } from '@expo/config';
2import {
3  buildHermesBundleAsync,
4  isEnableHermesManaged,
5  maybeThrowFromInconsistentEngineAsync,
6} from '@expo/dev-server/build/HermesBundler';
7import {
8  importMetroFromProject,
9  importMetroServerFromProject,
10} from '@expo/dev-server/build/metro/importMetroFromProject';
11import { LoadOptions } from '@expo/metro-config';
12import chalk from 'chalk';
13import Metro from 'metro';
14
15import { loadMetroConfigAsync } from '../start/server/metro/instantiateMetro';
16
17export type MetroDevServerOptions = LoadOptions & {
18  logger: import('@expo/bunyan');
19  quiet?: boolean;
20};
21export type BundleOptions = {
22  entryPoint: string;
23  platform: 'android' | 'ios' | 'web';
24  dev?: boolean;
25  minify?: boolean;
26  sourceMapUrl?: string;
27};
28export type BundleAssetWithFileHashes = Metro.AssetData & {
29  fileHashes: string[]; // added by the hashAssets asset plugin
30};
31export type BundleOutput = {
32  code: string;
33  map?: string;
34  hermesBytecodeBundle?: Uint8Array;
35  hermesSourcemap?: string;
36  assets: readonly BundleAssetWithFileHashes[];
37};
38
39let nextBuildID = 0;
40
41// Fork of @expo/dev-server bundleAsync to add Metro logging back.
42
43async function assertEngineMismatchAsync(projectRoot: string, exp: ExpoConfig, platform: Platform) {
44  const isHermesManaged = isEnableHermesManaged(exp, platform);
45
46  const paths = getConfigFilePaths(projectRoot);
47  const configFilePath = paths.dynamicConfigPath ?? paths.staticConfigPath ?? 'app.json';
48  await maybeThrowFromInconsistentEngineAsync(
49    projectRoot,
50    configFilePath,
51    platform,
52    isHermesManaged
53  );
54}
55
56export async function bundleAsync(
57  projectRoot: string,
58  expoConfig: ExpoConfig,
59  options: MetroDevServerOptions,
60  bundles: BundleOptions[]
61): Promise<BundleOutput[]> {
62  // Assert early so the user doesn't have to wait until bundling is complete to find out that
63  // Hermes won't be available.
64  await Promise.all(
65    bundles.map(({ platform }) => assertEngineMismatchAsync(projectRoot, expoConfig, platform))
66  );
67
68  const metro = importMetroFromProject(projectRoot);
69  const Server = importMetroServerFromProject(projectRoot);
70
71  const { config, reporter } = await loadMetroConfigAsync(projectRoot, options, {
72    exp: expoConfig,
73  });
74
75  const metroServer = await metro.runMetro(config, {
76    watch: false,
77  });
78
79  const buildAsync = async (bundle: BundleOptions): Promise<BundleOutput> => {
80    const buildID = `bundle_${nextBuildID++}_${bundle.platform}`;
81    const isHermes = isEnableHermesManaged(expoConfig, bundle.platform);
82    const bundleOptions: Metro.BundleOptions = {
83      ...Server.DEFAULT_BUNDLE_OPTIONS,
84      bundleType: 'bundle',
85      platform: bundle.platform,
86      entryFile: bundle.entryPoint,
87      dev: bundle.dev ?? false,
88      minify: !isHermes && (bundle.minify ?? !bundle.dev),
89      inlineSourceMap: false,
90      sourceMapUrl: bundle.sourceMapUrl,
91      createModuleIdFactory: config.serializer.createModuleIdFactory,
92      onProgress: (transformedFileCount: number, totalFileCount: number) => {
93        if (!options.quiet) {
94          reporter.update({
95            buildID,
96            type: 'bundle_transform_progressed',
97            transformedFileCount,
98            totalFileCount,
99          });
100        }
101      },
102    };
103    const bundleDetails = {
104      ...bundleOptions,
105      buildID,
106    };
107    reporter.update({
108      buildID,
109      type: 'bundle_build_started',
110      // @ts-expect-error: TODO
111      bundleDetails,
112    });
113    try {
114      const { code, map } = await metroServer.build(bundleOptions);
115      const assets = (await metroServer.getAssets(
116        bundleOptions
117      )) as readonly BundleAssetWithFileHashes[];
118      reporter.update({
119        buildID,
120        type: 'bundle_build_done',
121      });
122      return { code, map, assets };
123    } catch (error) {
124      reporter.update({
125        buildID,
126        type: 'bundle_build_failed',
127      });
128
129      throw error;
130    }
131  };
132
133  const maybeAddHermesBundleAsync = async (
134    bundle: BundleOptions,
135    bundleOutput: BundleOutput
136  ): Promise<BundleOutput> => {
137    const { platform } = bundle;
138    const isHermesManaged = isEnableHermesManaged(expoConfig, platform);
139    if (isHermesManaged) {
140      const platformTag = chalk.bold(
141        { ios: 'iOS', android: 'Android', web: 'Web' }[platform] || platform
142      );
143
144      reporter.terminal.log(`${platformTag} Building Hermes bytecode for the bundle`);
145
146      const hermesBundleOutput = await buildHermesBundleAsync(
147        projectRoot,
148        bundleOutput.code,
149        bundleOutput.map!,
150        bundle.minify ?? !bundle.dev
151      );
152      bundleOutput.hermesBytecodeBundle = hermesBundleOutput.hbc;
153      bundleOutput.hermesSourcemap = hermesBundleOutput.sourcemap;
154    }
155    return bundleOutput;
156  };
157
158  try {
159    const intermediateOutputs = await Promise.all(bundles.map((bundle) => buildAsync(bundle)));
160    const bundleOutputs: BundleOutput[] = [];
161    for (let i = 0; i < bundles.length; ++i) {
162      // hermesc does not support parallel building even we spawn processes.
163      // we should build them sequentially.
164      bundleOutputs.push(await maybeAddHermesBundleAsync(bundles[i], intermediateOutputs[i]));
165    }
166    return bundleOutputs;
167  } catch (error) {
168    // New line so errors don't show up inline with the progress bar
169    console.log('');
170    throw error;
171  } finally {
172    metroServer.end();
173  }
174}
175