xref: /expo/packages/@expo/cli/src/export/exportApp.ts (revision 8d56e5c5)
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    // Web doesn't require validation.
58    skipValidation: platforms.length === 1 && platforms[0] === 'web',
59  });
60
61  const useServerRendering = ['static', 'server'].includes(exp.web?.output ?? '');
62  const basePath = (exp.experiments?.basePath?.replace(/\/+$/, '') ?? '').trim();
63
64  // Print out logs
65  if (basePath) {
66    Log.log();
67    Log.log(chalk.gray`Using (experimental) base path: ${basePath}`);
68    // Warn if not using an absolute path.
69    if (!basePath.startsWith('/')) {
70      Log.log(
71        chalk.yellow`  Base path does not start with a slash. Requests will not be absolute.`
72      );
73    }
74  }
75
76  const publicPath = path.resolve(projectRoot, env.EXPO_PUBLIC_FOLDER);
77
78  const outputPath = path.resolve(projectRoot, outputDir);
79  const staticFolder = outputPath;
80  const assetsPath = path.join(staticFolder, 'assets');
81  const bundlesPath = path.join(staticFolder, 'bundles');
82
83  await Promise.all([assetsPath, bundlesPath].map(ensureDirectoryAsync));
84
85  await copyPublicFolderAsync(publicPath, staticFolder);
86
87  // Run metro bundler and create the JS bundles/source maps.
88  const bundles = await createBundlesAsync(
89    projectRoot,
90    { resetCache: !!clear },
91    {
92      platforms,
93      minify,
94      // TODO: Breaks asset exports
95      // platforms: useServerRendering
96      //   ? platforms.filter((platform) => platform !== 'web')
97      //   : platforms,
98      dev,
99      // TODO: Disable source map generation if we aren't outputting them.
100    }
101  );
102
103  const bundleEntries = Object.entries(bundles);
104  if (bundleEntries.length) {
105    // Log bundle size info to the user
106    printBundleSizes(
107      Object.fromEntries(
108        bundleEntries.map(([key, value]) => {
109          if (!dumpSourcemap) {
110            return [
111              key,
112              {
113                ...value,
114                // Remove source maps from the bundles if they aren't going to be written.
115                map: undefined,
116              },
117            ];
118          }
119
120          return [key, value];
121        })
122      )
123    );
124  }
125
126  // Write the JS bundles to disk, and get the bundle file names (this could change with async chunk loading support).
127  const { hashes, fileNames } = await writeBundlesAsync({
128    bundles,
129    useServerRendering,
130    outputDir: bundlesPath,
131  });
132
133  Log.log('Finished saving JS Bundles');
134
135  if (platforms.includes('web')) {
136    if (useServerRendering) {
137      await unstable_exportStaticAsync(projectRoot, {
138        outputDir: outputPath,
139        minify,
140        basePath,
141        includeMaps: dumpSourcemap,
142        // @ts-expect-error: server not on type yet
143        exportServer: exp.web?.output === 'server',
144      });
145      Log.log('Finished saving static files');
146    } else {
147      const cssLinks = await exportCssAssetsAsync({
148        outputDir,
149        bundles,
150        basePath,
151      });
152      let html = await createTemplateHtmlFromExpoConfigAsync(projectRoot, {
153        scripts: [`${basePath}/bundles/${fileNames.web}`],
154        cssLinks,
155      });
156      // Add the favicon assets to the HTML.
157      const modifyHtml = await getVirtualFaviconAssetsAsync(projectRoot, {
158        outputDir,
159        basePath,
160      });
161      if (modifyHtml) {
162        html = modifyHtml(html);
163      }
164      // Generate SPA-styled HTML file.
165      // If web exists, then write the template HTML file.
166      await fs.promises.writeFile(path.join(staticFolder, 'index.html'), html);
167    }
168
169    // TODO: Use a different mechanism for static web.
170    if (bundles.web) {
171      // Save assets like a typical bundler, preserving the file paths on web.
172      // TODO: Update React Native Web to support loading files from asset hashes.
173      await persistMetroAssetsAsync(bundles.web.assets, {
174        platform: 'web',
175        outputDirectory: staticFolder,
176        basePath,
177      });
178    }
179  }
180
181  // Can be empty during web-only SSG.
182  // TODO: Use same asset system across platforms again.
183  if (Object.keys(fileNames).length) {
184    const { assets } = await exportAssetsAsync(projectRoot, {
185      exp,
186      outputDir: staticFolder,
187      bundles,
188    });
189
190    if (dumpAssetmap) {
191      Log.log('Dumping asset map');
192      await writeAssetMapAsync({ outputDir: staticFolder, assets });
193    }
194    // build source maps
195    if (dumpSourcemap) {
196      Log.log('Dumping source maps');
197      await writeSourceMapsAsync({
198        bundles,
199        hashes,
200        outputDir: bundlesPath,
201        fileNames,
202      });
203
204      Log.log('Preparing additional debugging files');
205      // If we output source maps, then add a debug HTML file which the user can open in
206      // the web browser to inspect the output like web.
207      await writeDebugHtmlAsync({
208        outputDir: staticFolder,
209        fileNames,
210      });
211    }
212
213    // Generate a `metadata.json` and the export is complete.
214    await writeMetadataJsonAsync({ outputDir: staticFolder, bundles, fileNames });
215  }
216}
217
218/**
219 * Copy the contents of the public folder into the output folder.
220 * This enables users to add static files like `favicon.ico` or `serve.json`.
221 *
222 * The contents of this folder are completely universal since they refer to
223 * static network requests which fall outside the scope of React Native's magic
224 * platform resolution patterns.
225 */
226async function copyPublicFolderAsync(publicFolder: string, outputFolder: string) {
227  if (fs.existsSync(publicFolder)) {
228    await copyAsync(publicFolder, outputFolder);
229  }
230}
231