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