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';
14
15import { CSSAsset, getCssModulesFromBundler } from '../start/server/metro/getCssDependencies';
16import { loadMetroConfigAsync } from '../start/server/metro/instantiateMetro';
17
18export type MetroDevServerOptions = LoadOptions & {
19  logger: import('@expo/bunyan');
20  quiet?: boolean;
21};
22export type BundleOptions = {
23  entryPoint: string;
24  platform: 'android' | 'ios' | 'web';
25  dev?: boolean;
26  minify?: boolean;
27  sourceMapUrl?: string;
28};
29export type BundleAssetWithFileHashes = Metro.AssetData & {
30  fileHashes: string[]; // added by the hashAssets asset plugin
31};
32export type BundleOutput = {
33  code: string;
34  map?: string;
35  hermesBytecodeBundle?: Uint8Array;
36  hermesSourcemap?: string;
37  css: CSSAsset[];
38  assets: readonly BundleAssetWithFileHashes[];
39};
40
41let nextBuildID = 0;
42
43// Fork of @expo/dev-server bundleAsync to add Metro logging back.
44
45async function assertEngineMismatchAsync(projectRoot: string, exp: ExpoConfig, platform: Platform) {
46  const isHermesManaged = isEnableHermesManaged(exp, platform);
47
48  const paths = getConfigFilePaths(projectRoot);
49  const configFilePath = paths.dynamicConfigPath ?? paths.staticConfigPath ?? 'app.json';
50  await maybeThrowFromInconsistentEngineAsync(
51    projectRoot,
52    configFilePath,
53    platform,
54    isHermesManaged
55  );
56}
57
58export async function bundleAsync(
59  projectRoot: string,
60  expoConfig: ExpoConfig,
61  options: MetroDevServerOptions,
62  bundles: BundleOptions[]
63): Promise<BundleOutput[]> {
64  // Assert early so the user doesn't have to wait until bundling is complete to find out that
65  // Hermes won't be available.
66  await Promise.all(
67    bundles.map(({ platform }) => assertEngineMismatchAsync(projectRoot, expoConfig, platform))
68  );
69
70  const metro = importMetroFromProject(projectRoot);
71  const Server = importMetroServerFromProject(projectRoot);
72
73  const { config, reporter } = await loadMetroConfigAsync(projectRoot, options, {
74    exp: expoConfig,
75  });
76
77  const metroServer = await metro.runMetro(config, {
78    watch: false,
79  });
80
81  const buildAsync = async (bundle: BundleOptions): Promise<BundleOutput> => {
82    const buildID = `bundle_${nextBuildID++}_${bundle.platform}`;
83    const isHermes = isEnableHermesManaged(expoConfig, bundle.platform);
84    const bundleOptions: Metro.BundleOptions = {
85      ...Server.DEFAULT_BUNDLE_OPTIONS,
86      bundleType: 'bundle',
87      platform: bundle.platform,
88      entryFile: bundle.entryPoint,
89      dev: bundle.dev ?? false,
90      minify: !isHermes && (bundle.minify ?? !bundle.dev),
91      inlineSourceMap: false,
92      sourceMapUrl: bundle.sourceMapUrl,
93      createModuleIdFactory: config.serializer.createModuleIdFactory,
94      onProgress: (transformedFileCount: number, totalFileCount: number) => {
95        if (!options.quiet) {
96          reporter.update({
97            buildID,
98            type: 'bundle_transform_progressed',
99            transformedFileCount,
100            totalFileCount,
101          });
102        }
103      },
104    };
105    const bundleDetails = {
106      ...bundleOptions,
107      buildID,
108    };
109    reporter.update({
110      buildID,
111      type: 'bundle_build_started',
112      // @ts-expect-error: TODO
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