xref: /expo/packages/@expo/cli/src/export/exportApp.ts (revision 2d4e7de9)
17c98c357SEvan Baconimport chalk from 'chalk';
26d6b81f9SEvan Baconimport fs from 'fs';
3dc51e206SEvan Baconimport path from 'path';
4dc51e206SEvan Bacon
5dc51e206SEvan Baconimport { createBundlesAsync } from './createBundles';
69b2597baSEvan Baconimport { exportAssetsAsync, exportCssAssetsAsync } from './exportAssets';
70a6ddb20SEvan Baconimport { unstable_exportStaticAsync } from './exportStaticAsync';
842637653SEvan Baconimport { getVirtualFaviconAssetsAsync } from './favicon';
9dc51e206SEvan Baconimport { getPublicExpoManifestAsync } from './getPublicExpoManifest';
107c98c357SEvan Baconimport { persistMetroAssetsAsync } from './persistMetroAssets';
11dc51e206SEvan Baconimport { printBundleSizes } from './printBundleSizes';
12dc51e206SEvan Baconimport { Options } from './resolveOptions';
13dc51e206SEvan Baconimport {
14dc51e206SEvan Bacon  writeAssetMapAsync,
15dc51e206SEvan Bacon  writeBundlesAsync,
16dc51e206SEvan Bacon  writeDebugHtmlAsync,
17dc51e206SEvan Bacon  writeMetadataJsonAsync,
18dc51e206SEvan Bacon  writeSourceMapsAsync,
19dc51e206SEvan Bacon} from './writeContents';
208a424bebSJames Ideimport * as Log from '../log';
218a424bebSJames Ideimport { createTemplateHtmlFromExpoConfigAsync } from '../start/server/webTemplate';
228a424bebSJames Ideimport { copyAsync, ensureDirectoryAsync } from '../utils/dir';
238a424bebSJames Ideimport { env } from '../utils/env';
248a424bebSJames Ideimport { setNodeEnv } from '../utils/nodeEnv';
25dc51e206SEvan Bacon
26dc51e206SEvan Bacon/**
27dc51e206SEvan Bacon * The structure of the outputDir will be:
28dc51e206SEvan Bacon *
29dc51e206SEvan Bacon * ```
30dc51e206SEvan Bacon * ├── assets
31dc51e206SEvan Bacon * │   └── *
32dc51e206SEvan Bacon * ├── bundles
33dc51e206SEvan Bacon * │   ├── android-01ee6e3ab3e8c16a4d926c91808d5320.js
34dc51e206SEvan Bacon * │   └── ios-ee8206cc754d3f7aa9123b7f909d94ea.js
35dc51e206SEvan Bacon * └── metadata.json
36dc51e206SEvan Bacon * ```
37dc51e206SEvan Bacon */
38dc51e206SEvan Baconexport async function exportAppAsync(
39dc51e206SEvan Bacon  projectRoot: string,
40dc51e206SEvan Bacon  {
41dc51e206SEvan Bacon    platforms,
42dc51e206SEvan Bacon    outputDir,
43dc51e206SEvan Bacon    clear,
44dc51e206SEvan Bacon    dev,
45dc51e206SEvan Bacon    dumpAssetmap,
46dc51e206SEvan Bacon    dumpSourcemap,
471a3d836eSEvan Bacon    minify,
481a3d836eSEvan Bacon  }: Pick<
491a3d836eSEvan Bacon    Options,
501a3d836eSEvan Bacon    'dumpAssetmap' | 'dumpSourcemap' | 'dev' | 'clear' | 'outputDir' | 'platforms' | 'minify'
511a3d836eSEvan Bacon  >
52dc51e206SEvan Bacon): Promise<void> {
532dd43328SEvan Bacon  setNodeEnv(dev ? 'development' : 'production');
546a750d06SEvan Bacon  require('@expo/env').load(projectRoot);
552dd43328SEvan Bacon
56*2d4e7de9SEvan Bacon  const exp = await getPublicExpoManifestAsync(projectRoot, {
57*2d4e7de9SEvan Bacon    // Web doesn't require validation.
58*2d4e7de9SEvan Bacon    skipValidation: platforms.length === 1 && platforms[0] === 'web',
59*2d4e7de9SEvan Bacon  });
60dc51e206SEvan Bacon
6146f023faSEvan Bacon  const useServerRendering = ['static', 'server'].includes(exp.web?.output ?? '');
627c98c357SEvan Bacon  const basePath = (exp.experiments?.basePath?.replace(/\/+$/, '') ?? '').trim();
637c98c357SEvan Bacon
647c98c357SEvan Bacon  // Print out logs
657c98c357SEvan Bacon  if (basePath) {
667c98c357SEvan Bacon    Log.log();
677c98c357SEvan Bacon    Log.log(chalk.gray`Using (experimental) base path: ${basePath}`);
687c98c357SEvan Bacon    // Warn if not using an absolute path.
697c98c357SEvan Bacon    if (!basePath.startsWith('/')) {
707c98c357SEvan Bacon      Log.log(
717c98c357SEvan Bacon        chalk.yellow`  Base path does not start with a slash. Requests will not be absolute.`
727c98c357SEvan Bacon      );
737c98c357SEvan Bacon    }
747c98c357SEvan Bacon  }
759580591fSEvan Bacon
766d6b81f9SEvan Bacon  const publicPath = path.resolve(projectRoot, env.EXPO_PUBLIC_FOLDER);
776d6b81f9SEvan Bacon
78dc51e206SEvan Bacon  const outputPath = path.resolve(projectRoot, outputDir);
790a6ddb20SEvan Bacon  const staticFolder = outputPath;
800a6ddb20SEvan Bacon  const assetsPath = path.join(staticFolder, 'assets');
810a6ddb20SEvan Bacon  const bundlesPath = path.join(staticFolder, 'bundles');
82dc51e206SEvan Bacon
83dc51e206SEvan Bacon  await Promise.all([assetsPath, bundlesPath].map(ensureDirectoryAsync));
84dc51e206SEvan Bacon
850a6ddb20SEvan Bacon  await copyPublicFolderAsync(publicPath, staticFolder);
866d6b81f9SEvan Bacon
87dc51e206SEvan Bacon  // Run metro bundler and create the JS bundles/source maps.
88dc51e206SEvan Bacon  const bundles = await createBundlesAsync(
89dc51e206SEvan Bacon    projectRoot,
90dc51e206SEvan Bacon    { resetCache: !!clear },
91dc51e206SEvan Bacon    {
92dc51e206SEvan Bacon      platforms,
931a3d836eSEvan Bacon      minify,
949580591fSEvan Bacon      // TODO: Breaks asset exports
9546f023faSEvan Bacon      // platforms: useServerRendering
9646f023faSEvan Bacon      //   ? platforms.filter((platform) => platform !== 'web')
9746f023faSEvan Bacon      //   : platforms,
98dc51e206SEvan Bacon      dev,
99dc51e206SEvan Bacon      // TODO: Disable source map generation if we aren't outputting them.
100dc51e206SEvan Bacon    }
101dc51e206SEvan Bacon  );
102dc51e206SEvan Bacon
1039580591fSEvan Bacon  const bundleEntries = Object.entries(bundles);
1049580591fSEvan Bacon  if (bundleEntries.length) {
105dc51e206SEvan Bacon    // Log bundle size info to the user
106e330c216SEvan Bacon    printBundleSizes(
107e330c216SEvan Bacon      Object.fromEntries(
1089580591fSEvan Bacon        bundleEntries.map(([key, value]) => {
109e330c216SEvan Bacon          if (!dumpSourcemap) {
110e330c216SEvan Bacon            return [
111e330c216SEvan Bacon              key,
112e330c216SEvan Bacon              {
113e330c216SEvan Bacon                ...value,
114e330c216SEvan Bacon                // Remove source maps from the bundles if they aren't going to be written.
115e330c216SEvan Bacon                map: undefined,
116e330c216SEvan Bacon              },
117e330c216SEvan Bacon            ];
118e330c216SEvan Bacon          }
119e330c216SEvan Bacon
120e330c216SEvan Bacon          return [key, value];
121e330c216SEvan Bacon        })
122e330c216SEvan Bacon      )
123e330c216SEvan Bacon    );
1249580591fSEvan Bacon  }
125dc51e206SEvan Bacon
126dc51e206SEvan Bacon  // Write the JS bundles to disk, and get the bundle file names (this could change with async chunk loading support).
1270769e63bSEvan Bacon  const { hashes, fileNames } = await writeBundlesAsync({
1280769e63bSEvan Bacon    bundles,
12946f023faSEvan Bacon    useServerRendering,
1300769e63bSEvan Bacon    outputDir: bundlesPath,
1310769e63bSEvan Bacon  });
132dc51e206SEvan Bacon
133dc51e206SEvan Bacon  Log.log('Finished saving JS Bundles');
1349580591fSEvan Bacon
1359580591fSEvan Bacon  if (platforms.includes('web')) {
13646f023faSEvan Bacon    if (useServerRendering) {
1370a6ddb20SEvan Bacon      await unstable_exportStaticAsync(projectRoot, {
1380a6ddb20SEvan Bacon        outputDir: outputPath,
1391a3d836eSEvan Bacon        minify,
1407c98c357SEvan Bacon        basePath,
141573b0ea7SEvan Bacon        includeMaps: dumpSourcemap,
14246f023faSEvan Bacon        // @ts-expect-error: server not on type yet
14346f023faSEvan Bacon        exportServer: exp.web?.output === 'server',
1440a6ddb20SEvan Bacon      });
1450a6ddb20SEvan Bacon      Log.log('Finished saving static files');
1460a6ddb20SEvan Bacon    } else {
1479580591fSEvan Bacon      const cssLinks = await exportCssAssetsAsync({
1489580591fSEvan Bacon        outputDir,
1499580591fSEvan Bacon        bundles,
1507c98c357SEvan Bacon        basePath,
1519580591fSEvan Bacon      });
15242637653SEvan Bacon      let html = await createTemplateHtmlFromExpoConfigAsync(projectRoot, {
1537c98c357SEvan Bacon        scripts: [`${basePath}/bundles/${fileNames.web}`],
1549b2597baSEvan Bacon        cssLinks,
15542637653SEvan Bacon      });
15642637653SEvan Bacon      // Add the favicon assets to the HTML.
1577c98c357SEvan Bacon      const modifyHtml = await getVirtualFaviconAssetsAsync(projectRoot, {
1587c98c357SEvan Bacon        outputDir,
1597c98c357SEvan Bacon        basePath,
1607c98c357SEvan Bacon      });
16142637653SEvan Bacon      if (modifyHtml) {
16242637653SEvan Bacon        html = modifyHtml(html);
16342637653SEvan Bacon      }
16442637653SEvan Bacon      // Generate SPA-styled HTML file.
16542637653SEvan Bacon      // If web exists, then write the template HTML file.
16642637653SEvan Bacon      await fs.promises.writeFile(path.join(staticFolder, 'index.html'), html);
1670a6ddb20SEvan Bacon    }
1686d6b81f9SEvan Bacon
1690769e63bSEvan Bacon    // TODO: Use a different mechanism for static web.
1700769e63bSEvan Bacon    if (bundles.web) {
1716d6b81f9SEvan Bacon      // Save assets like a typical bundler, preserving the file paths on web.
1727c98c357SEvan Bacon      // TODO: Update React Native Web to support loading files from asset hashes.
1737c98c357SEvan Bacon      await persistMetroAssetsAsync(bundles.web.assets, {
1747c98c357SEvan Bacon        platform: 'web',
1757c98c357SEvan Bacon        outputDirectory: staticFolder,
1767c98c357SEvan Bacon        basePath,
1777c98c357SEvan Bacon      });
1786d6b81f9SEvan Bacon    }
1790769e63bSEvan Bacon  }
1806d6b81f9SEvan Bacon
1810769e63bSEvan Bacon  // Can be empty during web-only SSG.
1820769e63bSEvan Bacon  // TODO: Use same asset system across platforms again.
1830769e63bSEvan Bacon  if (Object.keys(fileNames).length) {
184dc51e206SEvan Bacon    const { assets } = await exportAssetsAsync(projectRoot, {
185dc51e206SEvan Bacon      exp,
1860a6ddb20SEvan Bacon      outputDir: staticFolder,
187dc51e206SEvan Bacon      bundles,
188dc51e206SEvan Bacon    });
189dc51e206SEvan Bacon
190dc51e206SEvan Bacon    if (dumpAssetmap) {
191dc51e206SEvan Bacon      Log.log('Dumping asset map');
1920a6ddb20SEvan Bacon      await writeAssetMapAsync({ outputDir: staticFolder, assets });
193dc51e206SEvan Bacon    }
194dc51e206SEvan Bacon    // build source maps
195dc51e206SEvan Bacon    if (dumpSourcemap) {
196dc51e206SEvan Bacon      Log.log('Dumping source maps');
197dc51e206SEvan Bacon      await writeSourceMapsAsync({
198dc51e206SEvan Bacon        bundles,
199dc51e206SEvan Bacon        hashes,
200dc51e206SEvan Bacon        outputDir: bundlesPath,
201dc51e206SEvan Bacon        fileNames,
202dc51e206SEvan Bacon      });
203dc51e206SEvan Bacon
204dc51e206SEvan Bacon      Log.log('Preparing additional debugging files');
205dc51e206SEvan Bacon      // If we output source maps, then add a debug HTML file which the user can open in
206dc51e206SEvan Bacon      // the web browser to inspect the output like web.
207dc51e206SEvan Bacon      await writeDebugHtmlAsync({
2080a6ddb20SEvan Bacon        outputDir: staticFolder,
209dc51e206SEvan Bacon        fileNames,
210dc51e206SEvan Bacon      });
211dc51e206SEvan Bacon    }
212dc51e206SEvan Bacon
213dc51e206SEvan Bacon    // Generate a `metadata.json` and the export is complete.
2140a6ddb20SEvan Bacon    await writeMetadataJsonAsync({ outputDir: staticFolder, bundles, fileNames });
215dc51e206SEvan Bacon  }
2160769e63bSEvan Bacon}
2176d6b81f9SEvan Bacon
2186d6b81f9SEvan Bacon/**
2196d6b81f9SEvan Bacon * Copy the contents of the public folder into the output folder.
2206d6b81f9SEvan Bacon * This enables users to add static files like `favicon.ico` or `serve.json`.
2216d6b81f9SEvan Bacon *
2226d6b81f9SEvan Bacon * The contents of this folder are completely universal since they refer to
2236d6b81f9SEvan Bacon * static network requests which fall outside the scope of React Native's magic
2246d6b81f9SEvan Bacon * platform resolution patterns.
2256d6b81f9SEvan Bacon */
2266d6b81f9SEvan Baconasync function copyPublicFolderAsync(publicFolder: string, outputFolder: string) {
2276d6b81f9SEvan Bacon  if (fs.existsSync(publicFolder)) {
2286d6b81f9SEvan Bacon    await copyAsync(publicFolder, outputFolder);
2296d6b81f9SEvan Bacon  }
2306d6b81f9SEvan Bacon}
231