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 { getConfig } from '@expo/config'; 8import assert from 'assert'; 9import chalk from 'chalk'; 10import fs from 'fs'; 11import path from 'path'; 12import prettyBytes from 'pretty-bytes'; 13import { inspect } from 'util'; 14 15import { getVirtualFaviconAssetsAsync } from './favicon'; 16import { Log } from '../log'; 17import { DevServerManager } from '../start/server/DevServerManager'; 18import { MetroBundlerDevServer } from '../start/server/metro/MetroBundlerDevServer'; 19import { logMetroErrorAsync } from '../start/server/metro/metroErrorInterface'; 20import { 21 getApiRoutesForDirectory, 22 getRouterDirectoryWithManifest, 23} from '../start/server/metro/router'; 24import { learnMore } from '../utils/link'; 25 26const debug = require('debug')('expo:export:generateStaticRoutes') as typeof console.log; 27 28type Options = { 29 outputDir: string; 30 minify: boolean; 31 exportServer: boolean; 32 basePath: string; 33 includeMaps: boolean; 34}; 35 36/** @private */ 37export async function unstable_exportStaticAsync(projectRoot: string, options: Options) { 38 Log.warn( 39 `Experimental static rendering is enabled. ` + 40 learnMore('https://docs.expo.dev/router/reference/static-rendering/') 41 ); 42 43 // TODO: Prevent starting the watcher. 44 const devServerManager = new DevServerManager(projectRoot, { 45 minify: options.minify, 46 mode: 'production', 47 location: {}, 48 }); 49 await devServerManager.startAsync([ 50 { 51 type: 'metro', 52 options: { 53 location: {}, 54 isExporting: true, 55 }, 56 }, 57 ]); 58 59 try { 60 await exportFromServerAsync(projectRoot, devServerManager, options); 61 } finally { 62 await devServerManager.stopAsync(); 63 } 64} 65 66/** Match `(page)` -> `page` */ 67function matchGroupName(name: string): string | undefined { 68 return name.match(/^\(([^/]+?)\)$/)?.[1]; 69} 70 71export async function getFilesToExportFromServerAsync( 72 projectRoot: string, 73 { 74 manifest, 75 renderAsync, 76 }: { 77 manifest: any; 78 renderAsync: (pathname: string) => Promise<string>; 79 } 80): Promise<Map<string, string>> { 81 // name : contents 82 const files = new Map<string, string>(); 83 84 await Promise.all( 85 getHtmlFiles({ manifest }).map(async (outputPath) => { 86 const pathname = outputPath.replace(/(?:index)?\.html$/, ''); 87 try { 88 files.set(outputPath, ''); 89 const data = await renderAsync(pathname); 90 files.set(outputPath, data); 91 } catch (e: any) { 92 await logMetroErrorAsync({ error: e, projectRoot }); 93 throw new Error('Failed to statically export route: ' + pathname); 94 } 95 }) 96 ); 97 98 return files; 99} 100 101/** Perform all fs commits */ 102export async function exportFromServerAsync( 103 projectRoot: string, 104 devServerManager: DevServerManager, 105 { outputDir, basePath, exportServer, minify, includeMaps }: Options 106): Promise<void> { 107 const { exp } = getConfig(projectRoot, { skipSDKVersionRequirement: true }); 108 const appDir = getRouterDirectoryWithManifest(projectRoot, exp); 109 110 const injectFaviconTag = await getVirtualFaviconAssetsAsync(projectRoot, { outputDir, basePath }); 111 112 const devServer = devServerManager.getDefaultDevServer(); 113 assert(devServer instanceof MetroBundlerDevServer); 114 115 const [resources, { manifest, renderAsync }] = await Promise.all([ 116 devServer.getStaticResourcesAsync({ mode: 'production', minify, includeMaps }), 117 devServer.getStaticRenderFunctionAsync({ 118 mode: 'production', 119 minify, 120 }), 121 ]); 122 123 debug('Routes:\n', inspect(manifest, { colors: true, depth: null })); 124 125 const files = await getFilesToExportFromServerAsync(projectRoot, { 126 manifest, 127 async renderAsync(pathname: string) { 128 const template = await renderAsync(pathname); 129 let html = await devServer.composeResourcesWithHtml({ 130 mode: 'production', 131 resources, 132 template, 133 basePath, 134 }); 135 136 if (injectFaviconTag) { 137 html = injectFaviconTag(html); 138 } 139 140 return html; 141 }, 142 }); 143 144 resources.forEach((resource) => { 145 files.set( 146 resource.filename, 147 modifyBundlesWithSourceMaps(resource.filename, resource.source, includeMaps) 148 ); 149 }); 150 151 if (exportServer) { 152 const apiRoutes = await exportApiRoutesAsync({ outputDir, server: devServer, appDir }); 153 154 // Add the api routes to the files to export. 155 for (const [route, contents] of apiRoutes) { 156 files.set(route, contents); 157 } 158 } else { 159 warnPossibleInvalidExportType(appDir); 160 } 161 162 fs.mkdirSync(path.join(outputDir), { recursive: true }); 163 164 Log.log(''); 165 Log.log(chalk.bold`Exporting ${files.size} files:`); 166 await Promise.all( 167 [...files.entries()] 168 .sort(([a], [b]) => a.localeCompare(b)) 169 .map(async ([file, contents]) => { 170 const length = Buffer.byteLength(contents, 'utf8'); 171 Log.log(file, chalk.gray`(${prettyBytes(length)})`); 172 const outputPath = path.join(outputDir, file); 173 await fs.promises.mkdir(path.dirname(outputPath), { recursive: true }); 174 await fs.promises.writeFile(outputPath, contents); 175 }) 176 ); 177 Log.log(''); 178} 179 180export function modifyBundlesWithSourceMaps( 181 filename: string, 182 source: string, 183 includeMaps: boolean 184): string { 185 if (filename.endsWith('.js')) { 186 // If the bundle ends with source map URLs then update them to point to the correct location. 187 188 // TODO: basePath support 189 const normalizedFilename = '/' + filename.replace(/^\/+/, ''); 190 //# sourceMappingURL=//localhost:8085/index.map?platform=web&dev=false&hot=false&lazy=true&minify=true&resolver.environment=client&transform.environment=client&serializer.output=static 191 //# sourceURL=http://localhost:8085/index.bundle//&platform=web&dev=false&hot=false&lazy=true&minify=true&resolver.environment=client&transform.environment=client&serializer.output=static 192 return source.replace(/^\/\/# (sourceMappingURL|sourceURL)=.*$/gm, (...props) => { 193 if (includeMaps) { 194 if (props[1] === 'sourceURL') { 195 return `//# ${props[1]}=` + normalizedFilename; 196 } else if (props[1] === 'sourceMappingURL') { 197 const mapName = normalizedFilename + '.map'; 198 return `//# ${props[1]}=` + mapName; 199 } 200 } 201 return ''; 202 }); 203 } 204 return source; 205} 206 207export function getHtmlFiles({ manifest }: { manifest: any }): string[] { 208 const htmlFiles = new Set<string>(); 209 210 function traverseScreens(screens: string | { screens: any; path: string }, basePath = '') { 211 for (const value of Object.values(screens)) { 212 if (typeof value === 'string') { 213 let filePath = basePath + value; 214 if (value === '') { 215 filePath = 216 basePath === '' 217 ? 'index' 218 : basePath.endsWith('/') 219 ? basePath + 'index' 220 : basePath.slice(0, -1); 221 } 222 // TODO: Dedupe requests for alias routes. 223 addOptionalGroups(filePath); 224 } else if (typeof value === 'object' && value?.screens) { 225 const newPath = basePath + value.path + '/'; 226 traverseScreens(value.screens, newPath); 227 } 228 } 229 } 230 231 function addOptionalGroups(path: string) { 232 const variations = getPathVariations(path); 233 for (const variation of variations) { 234 htmlFiles.add(variation); 235 } 236 } 237 238 traverseScreens(manifest.screens); 239 240 return Array.from(htmlFiles).map((value) => { 241 const parts = value.split('/'); 242 // Replace `:foo` with `[foo]` and `*foo` with `[...foo]` 243 const partsWithGroups = parts.map((part) => { 244 if (part.startsWith(':')) { 245 return `[${part.slice(1)}]`; 246 } else if (part.startsWith('*')) { 247 return `[...${part.slice(1)}]`; 248 } 249 return part; 250 }); 251 return partsWithGroups.join('/') + '.html'; 252 }); 253} 254 255// Given a route like `(foo)/bar/(baz)`, return all possible variations of the route. 256// e.g. `(foo)/bar/(baz)`, `(foo)/bar/baz`, `foo/bar/(baz)`, `foo/bar/baz`, 257export function getPathVariations(routePath: string): string[] { 258 const variations = new Set<string>([routePath]); 259 const segments = routePath.split('/'); 260 261 function generateVariations(segments: string[], index: number): void { 262 if (index >= segments.length) { 263 return; 264 } 265 266 const newSegments = [...segments]; 267 while ( 268 index < newSegments.length && 269 matchGroupName(newSegments[index]) && 270 newSegments.length > 1 271 ) { 272 newSegments.splice(index, 1); 273 variations.add(newSegments.join('/')); 274 generateVariations(newSegments, index + 1); 275 } 276 277 generateVariations(segments, index + 1); 278 } 279 280 generateVariations(segments, 0); 281 282 return Array.from(variations); 283} 284 285async function exportApiRoutesAsync({ 286 outputDir, 287 server, 288 appDir, 289}: { 290 outputDir: string; 291 server: MetroBundlerDevServer; 292 appDir: string; 293}): Promise<Map<string, string>> { 294 const funcDir = path.join(outputDir, '_expo/functions'); 295 fs.mkdirSync(path.join(funcDir), { recursive: true }); 296 297 const [manifest, files] = await Promise.all([ 298 server.getExpoRouterRoutesManifestAsync({ 299 appDir, 300 }), 301 server 302 .exportExpoRouterApiRoutesAsync({ 303 mode: 'production', 304 appDir, 305 }) 306 .then((routes) => { 307 const files = new Map<string, string>(); 308 for (const [route, contents] of routes) { 309 files.set(path.join('_expo/functions', route), contents); 310 } 311 return files; 312 }), 313 ]); 314 315 Log.log(chalk.bold`Exporting ${files.size} API Routes.`); 316 317 files.set('_expo/routes.json', JSON.stringify(manifest, null, 2)); 318 319 return files; 320} 321 322function warnPossibleInvalidExportType(appDir: string) { 323 const apiRoutes = getApiRoutesForDirectory(appDir); 324 if (apiRoutes.length) { 325 // TODO: Allow API Routes for native-only. 326 Log.warn( 327 chalk.yellow`Skipping export for API routes because \`web.output\` is not "server". You may want to remove the routes: ${apiRoutes 328 .map((v) => path.relative(appDir, v)) 329 .join(', ')}` 330 ); 331 } 332} 333