xref: /expo/packages/@expo/cli/src/export/exportApp.ts (revision 76d98731)
1import chalk from 'chalk';
2import fs from 'fs';
3import path from 'path';
4
5import { createBundlesAsync } from './createBundles';
6import { exportAssetsAsync, exportCssAssetsAsync } from './exportAssets';
7import { unstable_exportStaticAsync } from './exportStaticAsync';
8import { getVirtualFaviconAssetsAsync } from './favicon';
9import { getPublicExpoManifestAsync } from './getPublicExpoManifest';
10import { persistMetroAssetsAsync } from './persistMetroAssets';
11import { printBundleSizes } from './printBundleSizes';
12import { Options } from './resolveOptions';
13import {
14  writeAssetMapAsync,
15  writeBundlesAsync,
16  writeDebugHtmlAsync,
17  writeMetadataJsonAsync,
18  writeSourceMapsAsync,
19} from './writeContents';
20import * as Log from '../log';
21import { createTemplateHtmlFromExpoConfigAsync } from '../start/server/webTemplate';
22import { copyAsync, ensureDirectoryAsync } from '../utils/dir';
23import { env } from '../utils/env';
24import { setNodeEnv } from '../utils/nodeEnv';
25
26/**
27 * The structure of the outputDir will be:
28 *
29 * ```
30 * ├── assets
31 * │   └── *
32 * ├── bundles
33 * │   ├── android-01ee6e3ab3e8c16a4d926c91808d5320.js
34 * │   └── ios-ee8206cc754d3f7aa9123b7f909d94ea.js
35 * └── metadata.json
36 * ```
37 */
38export async function exportAppAsync(
39  projectRoot: string,
40  {
41    platforms,
42    outputDir,
43    clear,
44    dev,
45    dumpAssetmap,
46    dumpSourcemap,
47    minify,
48  }: Pick<
49    Options,
50    'dumpAssetmap' | 'dumpSourcemap' | 'dev' | 'clear' | 'outputDir' | 'platforms' | 'minify'
51  >
52): Promise<void> {
53  setNodeEnv(dev ? 'development' : 'production');
54  require('@expo/env').load(projectRoot);
55
56  const exp = await getPublicExpoManifestAsync(projectRoot);
57
58  const useWebSSG = exp.web?.output === 'static';
59  const basePath = (exp.experiments?.basePath?.replace(/\/+$/, '') ?? '').trim();
60
61  // Print out logs
62  if (basePath) {
63    Log.log();
64    Log.log(chalk.gray`Using (experimental) base path: ${basePath}`);
65    // Warn if not using an absolute path.
66    if (!basePath.startsWith('/')) {
67      Log.log(
68        chalk.yellow`  Base path does not start with a slash. Requests will not be absolute.`
69      );
70    }
71  }
72
73  const publicPath = path.resolve(projectRoot, env.EXPO_PUBLIC_FOLDER);
74
75  const outputPath = path.resolve(projectRoot, outputDir);
76  const staticFolder = outputPath;
77  const assetsPath = path.join(staticFolder, 'assets');
78  const bundlesPath = path.join(staticFolder, 'bundles');
79
80  await Promise.all([assetsPath, bundlesPath].map(ensureDirectoryAsync));
81
82  await copyPublicFolderAsync(publicPath, staticFolder);
83
84  // Run metro bundler and create the JS bundles/source maps.
85  const bundles = await createBundlesAsync(
86    projectRoot,
87    { resetCache: !!clear },
88    {
89      platforms,
90      minify,
91      // TODO: Breaks asset exports
92      // platforms: useWebSSG ? platforms.filter((platform) => platform !== 'web') : platforms,
93      dev,
94      // TODO: Disable source map generation if we aren't outputting them.
95    }
96  );
97
98  const bundleEntries = Object.entries(bundles);
99  if (bundleEntries.length) {
100    // Log bundle size info to the user
101    printBundleSizes(
102      Object.fromEntries(
103        bundleEntries.map(([key, value]) => {
104          if (!dumpSourcemap) {
105            return [
106              key,
107              {
108                ...value,
109                // Remove source maps from the bundles if they aren't going to be written.
110                map: undefined,
111              },
112            ];
113          }
114
115          return [key, value];
116        })
117      )
118    );
119  }
120
121  // Write the JS bundles to disk, and get the bundle file names (this could change with async chunk loading support).
122  const { hashes, fileNames } = await writeBundlesAsync({
123    bundles,
124    useWebSSG,
125    outputDir: bundlesPath,
126  });
127
128  Log.log('Finished saving JS Bundles');
129
130  if (platforms.includes('web')) {
131    if (useWebSSG) {
132      await unstable_exportStaticAsync(projectRoot, {
133        outputDir: outputPath,
134        minify,
135        basePath,
136        includeMaps: dumpSourcemap,
137      });
138      Log.log('Finished saving static files');
139    } else {
140      const cssLinks = await exportCssAssetsAsync({
141        outputDir,
142        bundles,
143        basePath,
144      });
145      let html = await createTemplateHtmlFromExpoConfigAsync(projectRoot, {
146        scripts: [`${basePath}/bundles/${fileNames.web}`],
147        cssLinks,
148      });
149      // Add the favicon assets to the HTML.
150      const modifyHtml = await getVirtualFaviconAssetsAsync(projectRoot, {
151        outputDir,
152        basePath,
153      });
154      if (modifyHtml) {
155        html = modifyHtml(html);
156      }
157      // Generate SPA-styled HTML file.
158      // If web exists, then write the template HTML file.
159      await fs.promises.writeFile(path.join(staticFolder, 'index.html'), html);
160    }
161
162    // TODO: Use a different mechanism for static web.
163    if (bundles.web) {
164      // Save assets like a typical bundler, preserving the file paths on web.
165      // TODO: Update React Native Web to support loading files from asset hashes.
166      await persistMetroAssetsAsync(bundles.web.assets, {
167        platform: 'web',
168        outputDirectory: staticFolder,
169        basePath,
170      });
171    }
172  }
173
174  // Can be empty during web-only SSG.
175  // TODO: Use same asset system across platforms again.
176  if (Object.keys(fileNames).length) {
177    const { assets } = await exportAssetsAsync(projectRoot, {
178      exp,
179      outputDir: staticFolder,
180      bundles,
181    });
182
183    if (dumpAssetmap) {
184      Log.log('Dumping asset map');
185      await writeAssetMapAsync({ outputDir: staticFolder, assets });
186    }
187    // build source maps
188    if (dumpSourcemap) {
189      Log.log('Dumping source maps');
190      await writeSourceMapsAsync({
191        bundles,
192        hashes,
193        outputDir: bundlesPath,
194        fileNames,
195      });
196
197      Log.log('Preparing additional debugging files');
198      // If we output source maps, then add a debug HTML file which the user can open in
199      // the web browser to inspect the output like web.
200      await writeDebugHtmlAsync({
201        outputDir: staticFolder,
202        fileNames,
203      });
204    }
205
206    // Generate a `metadata.json` and the export is complete.
207    await writeMetadataJsonAsync({ outputDir: staticFolder, bundles, fileNames });
208  }
209}
210
211/**
212 * Copy the contents of the public folder into the output folder.
213 * This enables users to add static files like `favicon.ico` or `serve.json`.
214 *
215 * The contents of this folder are completely universal since they refer to
216 * static network requests which fall outside the scope of React Native's magic
217 * platform resolution patterns.
218 */
219async function copyPublicFolderAsync(publicFolder: string, outputFolder: string) {
220  if (fs.existsSync(publicFolder)) {
221    await copyAsync(publicFolder, outputFolder);
222  }
223}
224