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