1import { ExpoConfig, getConfigFilePaths, Platform } from '@expo/config';
2import type { LoadOptions } from '@expo/metro-config';
3import chalk from 'chalk';
4import Metro, { AssetData } from 'metro';
5import getMetroAssets from 'metro/src/DeltaBundler/Serializers/getAssets';
6import splitBundleOptions from 'metro/src/lib/splitBundleOptions';
7import type { BundleOptions as MetroBundleOptions } from 'metro/src/shared/types';
8import { ConfigT } from 'metro-config';
9
10import {
11  buildHermesBundleAsync,
12  isEnableHermesManaged,
13  maybeThrowFromInconsistentEngineAsync,
14} from './exportHermes';
15import { CSSAsset, getCssModulesFromBundler } from '../start/server/metro/getCssModulesFromBundler';
16import { loadMetroConfigAsync } from '../start/server/metro/instantiateMetro';
17import {
18  importMetroFromProject,
19  importMetroServerFromProject,
20} from '../start/server/metro/resolveFromProject';
21
22export type MetroDevServerOptions = LoadOptions & {
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  css: CSSAsset[];
41  assets: readonly BundleAssetWithFileHashes[];
42};
43
44let nextBuildID = 0;
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    isExporting: true,
77  });
78
79  const metroServer = await metro.runMetro(config, {
80    watch: false,
81  });
82
83  const buildAsync = async (bundle: BundleOptions): Promise<BundleOutput> => {
84    const buildID = `bundle_${nextBuildID++}_${bundle.platform}`;
85    const isHermes = isEnableHermesManaged(expoConfig, bundle.platform);
86    const bundleOptions: MetroBundleOptions = {
87      ...Server.DEFAULT_BUNDLE_OPTIONS,
88      bundleType: 'bundle',
89      platform: bundle.platform,
90      entryFile: bundle.entryPoint,
91      dev: bundle.dev ?? false,
92      minify: !isHermes && (bundle.minify ?? !bundle.dev),
93      inlineSourceMap: false,
94      sourceMapUrl: bundle.sourceMapUrl,
95      createModuleIdFactory: config.serializer.createModuleIdFactory,
96      onProgress: (transformedFileCount: number, totalFileCount: number) => {
97        if (!options.quiet) {
98          reporter.update({
99            buildID,
100            type: 'bundle_transform_progressed',
101            transformedFileCount,
102            totalFileCount,
103          });
104        }
105      },
106    };
107    const bundleDetails = {
108      ...bundleOptions,
109      buildID,
110    };
111    reporter.update({
112      buildID,
113      type: 'bundle_build_started',
114      bundleDetails,
115    });
116    try {
117      const { code, map } = await metroServer.build(bundleOptions);
118      const [assets, css] = await Promise.all([
119        getAssets(metroServer, bundleOptions),
120        // metroServer.getAssets(bundleOptions),
121        getCssModulesFromBundler(config, metroServer.getBundler(), bundleOptions),
122      ]);
123
124      reporter.update({
125        buildID,
126        type: 'bundle_build_done',
127      });
128      return { code, map, assets: assets as readonly BundleAssetWithFileHashes[], css };
129    } catch (error) {
130      reporter.update({
131        buildID,
132        type: 'bundle_build_failed',
133      });
134
135      throw error;
136    }
137  };
138
139  const maybeAddHermesBundleAsync = async (
140    bundle: BundleOptions,
141    bundleOutput: BundleOutput
142  ): Promise<BundleOutput> => {
143    const { platform } = bundle;
144    const isHermesManaged = isEnableHermesManaged(expoConfig, platform);
145    if (isHermesManaged) {
146      const platformTag = chalk.bold(
147        { ios: 'iOS', android: 'Android', web: 'Web' }[platform] || platform
148      );
149
150      reporter.terminal.log(`${platformTag} Building Hermes bytecode for the bundle`);
151
152      const hermesBundleOutput = await buildHermesBundleAsync(
153        projectRoot,
154        bundleOutput.code,
155        bundleOutput.map!,
156        bundle.minify ?? !bundle.dev
157      );
158      bundleOutput.hermesBytecodeBundle = hermesBundleOutput.hbc;
159      bundleOutput.hermesSourcemap = hermesBundleOutput.sourcemap;
160    }
161    return bundleOutput;
162  };
163
164  try {
165    const intermediateOutputs = await Promise.all(bundles.map((bundle) => buildAsync(bundle)));
166    const bundleOutputs: BundleOutput[] = [];
167    for (let i = 0; i < bundles.length; ++i) {
168      // hermesc does not support parallel building even we spawn processes.
169      // we should build them sequentially.
170      bundleOutputs.push(await maybeAddHermesBundleAsync(bundles[i], intermediateOutputs[i]));
171    }
172    return bundleOutputs;
173  } catch (error) {
174    // New line so errors don't show up inline with the progress bar
175    console.log('');
176    throw error;
177  } finally {
178    metroServer.end();
179  }
180}
181
182// Forked out of Metro because the `this._getServerRootDir()` doesn't match the development
183// behavior.
184export async function getAssets(
185  metro: Metro.Server,
186  options: MetroBundleOptions
187): Promise<readonly AssetData[]> {
188  const { entryFile, onProgress, resolverOptions, transformOptions } = splitBundleOptions(options);
189
190  // @ts-expect-error: _bundler isn't exposed on the type.
191  const dependencies = await metro._bundler.getDependencies(
192    [entryFile],
193    transformOptions,
194    resolverOptions,
195    { onProgress, shallow: false, lazy: false }
196  );
197
198  // @ts-expect-error
199  const _config = metro._config as ConfigT;
200
201  return await getMetroAssets(dependencies, {
202    processModuleFilter: _config.serializer.processModuleFilter,
203    assetPlugins: _config.transformer.assetPlugins,
204    platform: transformOptions.platform!,
205    projectRoot: _config.projectRoot, // this._getServerRootDir(),
206    publicPath: _config.transformer.publicPath,
207  });
208}
209