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 includeGroupVariations, 77 }: { 78 manifest: any; 79 renderAsync: (pathname: string) => Promise<string>; 80 includeGroupVariations?: boolean; 81 } 82): Promise<Map<string, string>> { 83 // name : contents 84 const files = new Map<string, string>(); 85 86 await Promise.all( 87 getHtmlFiles({ manifest, includeGroupVariations }).map(async (outputPath) => { 88 const pathname = outputPath.replace(/(?:index)?\.html$/, ''); 89 try { 90 files.set(outputPath, ''); 91 const data = await renderAsync(pathname); 92 files.set(outputPath, data); 93 } catch (e: any) { 94 await logMetroErrorAsync({ error: e, projectRoot }); 95 throw new Error('Failed to statically export route: ' + pathname); 96 } 97 }) 98 ); 99 100 return files; 101} 102 103/** Perform all fs commits */ 104export async function exportFromServerAsync( 105 projectRoot: string, 106 devServerManager: DevServerManager, 107 { outputDir, basePath, exportServer, minify, includeMaps }: Options 108): Promise<void> { 109 const { exp } = getConfig(projectRoot, { skipSDKVersionRequirement: true }); 110 const appDir = getRouterDirectoryWithManifest(projectRoot, exp); 111 112 const injectFaviconTag = await getVirtualFaviconAssetsAsync(projectRoot, { outputDir, basePath }); 113 114 const devServer = devServerManager.getDefaultDevServer(); 115 assert(devServer instanceof MetroBundlerDevServer); 116 117 const [resources, { manifest, renderAsync }] = await Promise.all([ 118 devServer.getStaticResourcesAsync({ mode: 'production', minify, includeMaps }), 119 devServer.getStaticRenderFunctionAsync({ 120 mode: 'production', 121 minify, 122 }), 123 ]); 124 125 debug('Routes:\n', inspect(manifest, { colors: true, depth: null })); 126 127 const files = await getFilesToExportFromServerAsync(projectRoot, { 128 manifest, 129 // Servers can handle group routes automatically and therefore 130 // don't require the build-time generation of every possible group 131 // variation. 132 includeGroupVariations: !exportServer, 133 async renderAsync(pathname: string) { 134 const template = await renderAsync(pathname); 135 let html = await devServer.composeResourcesWithHtml({ 136 mode: 'production', 137 resources, 138 template, 139 basePath, 140 }); 141 142 if (injectFaviconTag) { 143 html = injectFaviconTag(html); 144 } 145 146 return html; 147 }, 148 }); 149 150 resources.forEach((resource) => { 151 files.set( 152 resource.filename, 153 modifyBundlesWithSourceMaps(resource.filename, resource.source, includeMaps) 154 ); 155 }); 156 157 if (exportServer) { 158 const apiRoutes = await exportApiRoutesAsync({ outputDir, server: devServer, appDir }); 159 160 // Add the api routes to the files to export. 161 for (const [route, contents] of apiRoutes) { 162 files.set(route, contents); 163 } 164 } else { 165 warnPossibleInvalidExportType(appDir); 166 } 167 168 fs.mkdirSync(path.join(outputDir), { recursive: true }); 169 170 Log.log(''); 171 Log.log(chalk.bold`Exporting ${files.size} files:`); 172 await Promise.all( 173 [...files.entries()] 174 .sort(([a], [b]) => a.localeCompare(b)) 175 .map(async ([file, contents]) => { 176 const length = Buffer.byteLength(contents, 'utf8'); 177 Log.log(file, chalk.gray`(${prettyBytes(length)})`); 178 const outputPath = path.join(outputDir, file); 179 await fs.promises.mkdir(path.dirname(outputPath), { recursive: true }); 180 await fs.promises.writeFile(outputPath, contents); 181 }) 182 ); 183 Log.log(''); 184} 185 186export function modifyBundlesWithSourceMaps( 187 filename: string, 188 source: string, 189 includeMaps: boolean 190): string { 191 if (filename.endsWith('.js')) { 192 // If the bundle ends with source map URLs then update them to point to the correct location. 193 194 // TODO: basePath support 195 const normalizedFilename = '/' + filename.replace(/^\/+/, ''); 196 //# sourceMappingURL=//localhost:8085/index.map?platform=web&dev=false&hot=false&lazy=true&minify=true&resolver.environment=client&transform.environment=client&serializer.output=static 197 //# 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 198 return source.replace(/^\/\/# (sourceMappingURL|sourceURL)=.*$/gm, (...props) => { 199 if (includeMaps) { 200 if (props[1] === 'sourceURL') { 201 return `//# ${props[1]}=` + normalizedFilename; 202 } else if (props[1] === 'sourceMappingURL') { 203 const mapName = normalizedFilename + '.map'; 204 return `//# ${props[1]}=` + mapName; 205 } 206 } 207 return ''; 208 }); 209 } 210 return source; 211} 212 213export function getHtmlFiles({ 214 manifest, 215 includeGroupVariations, 216}: { 217 manifest: any; 218 includeGroupVariations?: boolean; 219}): string[] { 220 const htmlFiles = new Set<string>(); 221 222 function traverseScreens(screens: string | { screens: any; path: string }, basePath = '') { 223 for (const value of Object.values(screens)) { 224 if (typeof value === 'string') { 225 let filePath = basePath + value; 226 if (value === '') { 227 filePath = 228 basePath === '' 229 ? 'index' 230 : basePath.endsWith('/') 231 ? basePath + 'index' 232 : basePath.slice(0, -1); 233 } 234 if (includeGroupVariations) { 235 // TODO: Dedupe requests for alias routes. 236 addOptionalGroups(filePath); 237 } else { 238 htmlFiles.add(filePath); 239 } 240 } else if (typeof value === 'object' && value?.screens) { 241 const newPath = basePath + value.path + '/'; 242 traverseScreens(value.screens, newPath); 243 } 244 } 245 } 246 247 function addOptionalGroups(path: string) { 248 const variations = getPathVariations(path); 249 for (const variation of variations) { 250 htmlFiles.add(variation); 251 } 252 } 253 254 traverseScreens(manifest.screens); 255 256 return Array.from(htmlFiles).map((value) => { 257 const parts = value.split('/'); 258 // Replace `:foo` with `[foo]` and `*foo` with `[...foo]` 259 const partsWithGroups = parts.map((part) => { 260 if (part.startsWith(':')) { 261 return `[${part.slice(1)}]`; 262 } else if (part.startsWith('*')) { 263 return `[...${part.slice(1)}]`; 264 } 265 return part; 266 }); 267 return partsWithGroups.join('/') + '.html'; 268 }); 269} 270 271// Given a route like `(foo)/bar/(baz)`, return all possible variations of the route. 272// e.g. `(foo)/bar/(baz)`, `(foo)/bar/baz`, `foo/bar/(baz)`, `foo/bar/baz`, 273export function getPathVariations(routePath: string): string[] { 274 const variations = new Set<string>(); 275 const segments = routePath.split('/'); 276 277 function generateVariations(segments: string[], current = ''): void { 278 if (segments.length === 0) { 279 if (current) variations.add(current); 280 return; 281 } 282 283 const [head, ...rest] = segments; 284 285 if (head.startsWith('(foo,foo')) { 286 } 287 288 if (matchGroupName(head)) { 289 const groups = head.slice(1, -1).split(','); 290 291 if (groups.length > 1) { 292 for (const group of groups) { 293 // If there are multiple groups, recurse on each group. 294 generateVariations([`(${group.trim()})`, ...rest], current); 295 } 296 return; 297 } else { 298 // Start a fork where this group is included 299 generateVariations(rest, current ? `${current}/(${groups[0]})` : `(${groups[0]})`); 300 // This code will continue and add paths without this group included` 301 } 302 } else if (current) { 303 current = `${current}/${head}`; 304 } else { 305 current = head; 306 } 307 308 generateVariations(rest, current); 309 } 310 311 generateVariations(segments); 312 313 return Array.from(variations); 314} 315 316async function exportApiRoutesAsync({ 317 outputDir, 318 server, 319 appDir, 320}: { 321 outputDir: string; 322 server: MetroBundlerDevServer; 323 appDir: string; 324}): Promise<Map<string, string>> { 325 const funcDir = path.join(outputDir, '_expo/functions'); 326 fs.mkdirSync(path.join(funcDir), { recursive: true }); 327 328 const [manifest, files] = await Promise.all([ 329 server.getExpoRouterRoutesManifestAsync({ 330 appDir, 331 }), 332 server 333 .exportExpoRouterApiRoutesAsync({ 334 mode: 'production', 335 appDir, 336 }) 337 .then((routes) => { 338 const files = new Map<string, string>(); 339 for (const [route, contents] of routes) { 340 files.set(path.join('_expo/functions', route), contents); 341 } 342 return files; 343 }), 344 ]); 345 346 Log.log(chalk.bold`Exporting ${files.size} API Routes.`); 347 348 files.set('_expo/routes.json', JSON.stringify(manifest, null, 2)); 349 350 return files; 351} 352 353function warnPossibleInvalidExportType(appDir: string) { 354 const apiRoutes = getApiRoutesForDirectory(appDir); 355 if (apiRoutes.length) { 356 // TODO: Allow API Routes for native-only. 357 Log.warn( 358 chalk.yellow`Skipping export for API routes because \`web.output\` is not "server". You may want to remove the routes: ${apiRoutes 359 .map((v) => path.relative(appDir, v)) 360 .join(', ')}` 361 ); 362 } 363} 364