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