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