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        getCssModulesFromBundler(config, metroServer.getBundler(), bundleOptions),
121      ]);
122
123      reporter.update({
124        buildID,
125        type: 'bundle_build_done',
126      });
127      return { code, map, assets: assets as readonly BundleAssetWithFileHashes[], css };
128    } catch (error) {
129      reporter.update({
130        buildID,
131        type: 'bundle_build_failed',
132      });
133
134      throw error;
135    }
136  };
137
138  const maybeAddHermesBundleAsync = async (
139    bundle: BundleOptions,
140    bundleOutput: BundleOutput
141  ): Promise<BundleOutput> => {
142    const { platform } = bundle;
143    const isHermesManaged = isEnableHermesManaged(expoConfig, platform);
144    if (isHermesManaged) {
145      const platformTag = chalk.bold(
146        { ios: 'iOS', android: 'Android', web: 'Web' }[platform] || platform
147      );
148
149      reporter.terminal.log(`${platformTag} Building Hermes bytecode for the bundle`);
150
151      const hermesBundleOutput = await buildHermesBundleAsync(
152        projectRoot,
153        bundleOutput.code,
154        bundleOutput.map!,
155        bundle.minify ?? !bundle.dev
156      );
157      bundleOutput.hermesBytecodeBundle = hermesBundleOutput.hbc;
158      bundleOutput.hermesSourcemap = hermesBundleOutput.sourcemap;
159    }
160    return bundleOutput;
161  };
162
163  try {
164    const intermediateOutputs = await Promise.all(bundles.map((bundle) => buildAsync(bundle)));
165    const bundleOutputs: BundleOutput[] = [];
166    for (let i = 0; i < bundles.length; ++i) {
167      // hermesc does not support parallel building even we spawn processes.
168      // we should build them sequentially.
169      bundleOutputs.push(await maybeAddHermesBundleAsync(bundles[i], intermediateOutputs[i]));
170    }
171    return bundleOutputs;
172  } catch (error) {
173    // New line so errors don't show up inline with the progress bar
174    console.log('');
175    throw error;
176  } finally {
177    metroServer.end();
178  }
179}
180
181// Forked out of Metro because the `this._getServerRootDir()` doesn't match the development
182// behavior.
183export async function getAssets(
184  metro: Metro.Server,
185  options: MetroBundleOptions
186): Promise<readonly AssetData[]> {
187  const { entryFile, onProgress, resolverOptions, transformOptions } = splitBundleOptions(options);
188
189  // @ts-expect-error: _bundler isn't exposed on the type.
190  const dependencies = await metro._bundler.getDependencies(
191    [entryFile],
192    transformOptions,
193    resolverOptions,
194    { onProgress, shallow: false, lazy: false }
195  );
196
197  // @ts-expect-error
198  const _config = metro._config as ConfigT;
199
200  return await getMetroAssets(dependencies, {
201    processModuleFilter: _config.serializer.processModuleFilter,
202    assetPlugins: _config.transformer.assetPlugins,
203    platform: transformOptions.platform!,
204    projectRoot: _config.projectRoot, // this._getServerRootDir(),
205    publicPath: _config.transformer.publicPath,
206  });
207}
208