1/** 2 * Copyright © 2022 650 Industries. 3 * 4 * This source code is licensed under the MIT license found in the 5 * LICENSE file in the root directory of this source tree. 6 */ 7import assert from 'assert'; 8import chalk from 'chalk'; 9import fs from 'fs'; 10import path from 'path'; 11import prettyBytes from 'pretty-bytes'; 12import { inspect } from 'util'; 13 14import { Log } from '../log'; 15import { DevServerManager } from '../start/server/DevServerManager'; 16import { MetroBundlerDevServer } from '../start/server/metro/MetroBundlerDevServer'; 17import { stripAnsi } from '../utils/ansi'; 18 19const debug = require('debug')('expo:export:generateStaticRoutes') as typeof console.log; 20 21type Options = { outputDir: string; scripts: string[]; minify: boolean }; 22 23/** @private */ 24export async function unstable_exportStaticAsync(projectRoot: string, options: Options) { 25 // NOTE(EvanBacon): Please don't use this feature. 26 Log.warn('Static exporting with Metro is an experimental feature.'); 27 28 const devServerManager = new DevServerManager(projectRoot, { 29 minify: options.minify, 30 mode: 'production', 31 location: {}, 32 }); 33 34 await devServerManager.startAsync([ 35 { 36 type: 'metro', 37 }, 38 ]); 39 40 await exportFromServerAsync(devServerManager, options); 41 42 await devServerManager.stopAsync(); 43} 44 45async function getExpoRoutesAsync(devServerManager: DevServerManager) { 46 const server = devServerManager.getDefaultDevServer(); 47 assert(server instanceof MetroBundlerDevServer); 48 return server.getRoutesAsync(); 49} 50 51/** Match `(page)` -> `page` */ 52function matchGroupName(name: string): string | undefined { 53 return name.match(/^\(([^/]+?)\)$/)?.[1]; 54} 55 56function appendScriptsToHtml(html: string, scripts: string[]) { 57 return html.replace( 58 '</body>', 59 scripts.map((script) => `<script src="${script}" defer></script>`).join('') + '</body>' 60 ); 61} 62 63export async function getFilesToExportFromServerAsync({ 64 manifest, 65 scripts, 66 renderAsync, 67}: { 68 manifest: any; 69 scripts: string[]; 70 renderAsync: (pathname: string) => Promise<{ 71 fetchData: boolean; 72 scriptContents: string; 73 renderAsync: () => any; 74 }>; 75}): Promise<Map<string, string>> { 76 // name : contents 77 const files = new Map<string, string>(); 78 79 const sanitizeName = (segment: string) => { 80 // Strip group names from the segment 81 return segment 82 .split('/') 83 .map((s) => (matchGroupName(s) ? '' : s)) 84 .filter(Boolean) 85 .join('/'); 86 }; 87 88 const fetchScreens = ( 89 screens: Record<string, any>, 90 additionPath: string = '' 91 ): Promise<any>[] => { 92 async function fetchScreenExactAsync(pathname: string, filename: string) { 93 const outputPath = [additionPath, filename].filter(Boolean).join('/').replace(/^\//, ''); 94 // TODO: Ensure no duplicates in the manifest. 95 if (files.has(outputPath)) { 96 return; 97 } 98 99 // Prevent duplicate requests while running in parallel. 100 files.set(outputPath, ''); 101 102 try { 103 const data = await renderAsync(pathname); 104 105 if (data.fetchData) { 106 // console.log('ssr:', pathname); 107 } else { 108 files.set(outputPath, appendScriptsToHtml(data.renderAsync(), scripts)); 109 } 110 } catch (e: any) { 111 // TODO: Format Metro error message better... 112 Log.error('Failed to statically render route:', pathname); 113 e.message = stripAnsi(e.message); 114 Log.exception(e); 115 throw e; 116 } 117 } 118 119 async function fetchScreenAsync({ segment, filename }: { segment: string; filename: string }) { 120 // Strip group names from the segment 121 const cleanSegment = sanitizeName(segment); 122 123 if (cleanSegment !== segment) { 124 // has groups, should request multiple screens. 125 await fetchScreenExactAsync( 126 [additionPath, segment].filter(Boolean).join('/'), 127 [additionPath, filename].filter(Boolean).join('/').replace(/^\//, '') 128 ); 129 } 130 131 await fetchScreenExactAsync( 132 [additionPath, cleanSegment].filter(Boolean).join('/'), 133 [additionPath, sanitizeName(filename)].filter(Boolean).join('/').replace(/^\//, '') 134 ); 135 } 136 137 return Object.entries(screens).map(async ([name, segment]) => { 138 const filename = name + '.html'; 139 140 // Segment is a directory. 141 if (typeof segment !== 'string') { 142 const cleanSegment = sanitizeName(segment.path); 143 return Promise.all( 144 fetchScreens(segment.screens, [additionPath, cleanSegment].filter(Boolean).join('/')) 145 ); 146 } 147 148 // TODO: handle dynamic routes 149 if (segment !== '*') { 150 await fetchScreenAsync({ segment, filename }); 151 } 152 return null; 153 }); 154 }; 155 156 await Promise.all(fetchScreens(manifest.screens)); 157 158 return files; 159} 160 161/** Perform all fs commits */ 162export async function exportFromServerAsync( 163 devServerManager: DevServerManager, 164 { outputDir, scripts }: Options 165): Promise<void> { 166 const devServer = devServerManager.getDefaultDevServer(); 167 168 const manifest = await getExpoRoutesAsync(devServerManager); 169 170 debug('Routes:\n', inspect(manifest, { colors: true, depth: null })); 171 172 const files = await getFilesToExportFromServerAsync({ 173 manifest, 174 scripts, 175 renderAsync(pathname: string) { 176 assert(devServer instanceof MetroBundlerDevServer); 177 return devServer.getStaticPageAsync(pathname, { mode: 'production' }); 178 }, 179 }); 180 181 fs.mkdirSync(path.join(outputDir), { recursive: true }); 182 183 Log.log(`Exporting ${files.size} files:`); 184 await Promise.all( 185 [...files.entries()] 186 .sort(([a], [b]) => a.localeCompare(b)) 187 .map(async ([file, contents]) => { 188 const length = Buffer.byteLength(contents, 'utf8'); 189 Log.log(file, chalk.gray`(${prettyBytes(length)})`); 190 const outputPath = path.join(outputDir, file); 191 await fs.promises.mkdir(path.dirname(outputPath), { recursive: true }); 192 await fs.promises.writeFile(outputPath, contents); 193 }) 194 ); 195} 196