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