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