1import Log from '@expo/bunyan';
2import { ExpoConfig, getConfigFilePaths } from '@expo/config';
3import {
4  buildHermesBundleAsync,
5  isEnableHermesManaged,
6  maybeThrowFromInconsistentEngineAsync,
7} from '@expo/dev-server/build/HermesBundler';
8import {
9  importExpoMetroConfigFromProject,
10  importMetroFromProject,
11  importMetroServerFromProject,
12} from '@expo/dev-server/build/metro/importMetroFromProject';
13import { LoadOptions } from '@expo/metro-config';
14import chalk from 'chalk';
15import Metro from 'metro';
16import { Terminal } from 'metro-core';
17
18import { MetroTerminalReporter } from '../start/server/metro/MetroTerminalReporter';
19
20export type MetroDevServerOptions = LoadOptions & {
21  logger: Log;
22  quiet?: boolean;
23};
24export type BundleOptions = {
25  entryPoint: string;
26  platform: 'android' | 'ios' | 'web';
27  dev?: boolean;
28  minify?: boolean;
29  sourceMapUrl?: string;
30};
31export type BundleAssetWithFileHashes = Metro.AssetData & {
32  fileHashes: string[]; // added by the hashAssets asset plugin
33};
34export type BundleOutput = {
35  code: string;
36  map: string;
37  hermesBytecodeBundle?: Uint8Array;
38  hermesSourcemap?: string;
39  assets: readonly BundleAssetWithFileHashes[];
40};
41
42function getExpoMetroConfig(
43  projectRoot: string,
44  { logger }: Pick<MetroDevServerOptions, 'logger'>
45): typeof import('@expo/metro-config') {
46  try {
47    return importExpoMetroConfigFromProject(projectRoot);
48  } catch {
49    // If expo isn't installed, use the unversioned config and warn about installing expo.
50  }
51
52  const unversionedVersion = require('@expo/metro-config/package.json').version;
53  logger.info(
54    { tag: 'expo' },
55    chalk.gray(
56      `\u203A Unversioned ${chalk.bold`@expo/metro-config@${unversionedVersion}`} is being used. Bundling apps may not work as expected, and is subject to breaking changes. Install ${chalk.bold`expo`} or set the app.json sdkVersion to use a stable version of @expo/metro-config.`
57    )
58  );
59
60  return require('@expo/metro-config');
61}
62
63let nextBuildID = 0;
64
65// Fork of @expo/dev-server bundleAsync to add Metro logging back.
66
67export async function bundleAsync(
68  projectRoot: string,
69  expoConfig: ExpoConfig,
70  options: MetroDevServerOptions,
71  bundles: BundleOptions[]
72): Promise<BundleOutput[]> {
73  const metro = importMetroFromProject(projectRoot);
74  const Server = importMetroServerFromProject(projectRoot);
75
76  let reportEvent: ((event: any) => void) | undefined;
77
78  const terminal = new Terminal(process.stdout);
79  const terminalReporter = new MetroTerminalReporter(projectRoot, terminal);
80
81  const reporter = {
82    update(event: any) {
83      terminalReporter.update(event);
84      if (reportEvent) {
85        reportEvent(event);
86      }
87    },
88  };
89
90  const ExpoMetroConfig = getExpoMetroConfig(projectRoot, options);
91
92  const config = await ExpoMetroConfig.loadAsync(projectRoot, { reporter, ...options });
93  const buildID = `bundle_${nextBuildID++}`;
94
95  // @ts-expect-error
96  const metroServer = await metro.runMetro(config, {
97    watch: false,
98  });
99
100  const buildAsync = async (bundle: BundleOptions): Promise<BundleOutput> => {
101    const bundleOptions: Metro.BundleOptions = {
102      ...Server.DEFAULT_BUNDLE_OPTIONS,
103      bundleType: 'bundle',
104      platform: bundle.platform,
105      entryFile: bundle.entryPoint,
106      dev: bundle.dev ?? false,
107      minify: bundle.minify ?? !bundle.dev,
108      inlineSourceMap: false,
109      sourceMapUrl: bundle.sourceMapUrl,
110      createModuleIdFactory: config.serializer.createModuleIdFactory,
111      onProgress: (transformedFileCount: number, totalFileCount: number) => {
112        if (!options.quiet) {
113          reporter.update({
114            buildID,
115            type: 'bundle_transform_progressed',
116            transformedFileCount,
117            totalFileCount,
118          });
119        }
120      },
121    };
122    reporter.update({
123      buildID,
124      type: 'bundle_build_started',
125      bundleDetails: {
126        bundleType: bundleOptions.bundleType,
127        platform: bundle.platform,
128        entryFile: bundle.entryPoint,
129        dev: bundle.dev ?? false,
130        minify: bundle.minify ?? false,
131      },
132    });
133    const { code, map } = await metroServer.build(bundleOptions);
134    const assets = (await metroServer.getAssets(
135      bundleOptions
136    )) as readonly BundleAssetWithFileHashes[];
137    reporter.update({
138      buildID,
139      type: 'bundle_build_done',
140    });
141    return { code, map, assets };
142  };
143
144  const maybeAddHermesBundleAsync = async (
145    bundle: BundleOptions,
146    bundleOutput: BundleOutput
147  ): Promise<BundleOutput> => {
148    const { platform } = bundle;
149    const isHermesManaged = isEnableHermesManaged(expoConfig, platform);
150
151    const paths = getConfigFilePaths(projectRoot);
152    const configFilePath = paths.dynamicConfigPath ?? paths.staticConfigPath ?? 'app.json';
153    await maybeThrowFromInconsistentEngineAsync(
154      projectRoot,
155      configFilePath,
156      platform,
157      isHermesManaged
158    );
159
160    if (isHermesManaged) {
161      const platformTag = chalk.bold(
162        { ios: 'iOS', android: 'Android', web: 'Web' }[platform] || platform
163      );
164      options.logger.info(
165        { tag: 'expo' },
166        `�� ${platformTag} Building Hermes bytecode for the bundle`
167      );
168      const hermesBundleOutput = await buildHermesBundleAsync(
169        projectRoot,
170        bundleOutput.code,
171        bundleOutput.map,
172        bundle.minify
173      );
174      bundleOutput.hermesBytecodeBundle = hermesBundleOutput.hbc;
175      bundleOutput.hermesSourcemap = hermesBundleOutput.sourcemap;
176    }
177    return bundleOutput;
178  };
179
180  try {
181    const intermediateOutputs = await Promise.all(bundles.map((bundle) => buildAsync(bundle)));
182    const bundleOutputs: BundleOutput[] = [];
183    for (let i = 0; i < bundles.length; ++i) {
184      // hermesc does not support parallel building even we spawn processes.
185      // we should build them sequentially.
186      bundleOutputs.push(await maybeAddHermesBundleAsync(bundles[i], intermediateOutputs[i]));
187    }
188    return bundleOutputs;
189  } finally {
190    metroServer.end();
191  }
192}
193