10a6ddb20SEvan Bacon/** 20a6ddb20SEvan Bacon * Copyright © 2022 650 Industries. 30a6ddb20SEvan Bacon * 40a6ddb20SEvan Bacon * This source code is licensed under the MIT license found in the 50a6ddb20SEvan Bacon * LICENSE file in the root directory of this source tree. 60a6ddb20SEvan Bacon */ 746f023faSEvan Baconimport { getConfig } from '@expo/config'; 80a6ddb20SEvan Baconimport assert from 'assert'; 90a6ddb20SEvan Baconimport chalk from 'chalk'; 100a6ddb20SEvan Baconimport fs from 'fs'; 110a6ddb20SEvan Baconimport path from 'path'; 120a6ddb20SEvan Baconimport prettyBytes from 'pretty-bytes'; 130a6ddb20SEvan Baconimport { inspect } from 'util'; 140a6ddb20SEvan Bacon 158a424bebSJames Ideimport { getVirtualFaviconAssetsAsync } from './favicon'; 160a6ddb20SEvan Baconimport { Log } from '../log'; 170a6ddb20SEvan Baconimport { DevServerManager } from '../start/server/DevServerManager'; 180a6ddb20SEvan Baconimport { MetroBundlerDevServer } from '../start/server/metro/MetroBundlerDevServer'; 1985531d53SEvan Baconimport { logMetroErrorAsync } from '../start/server/metro/metroErrorInterface'; 2046f023faSEvan Baconimport { 2146f023faSEvan Bacon getApiRoutesForDirectory, 2246f023faSEvan Bacon getRouterDirectoryWithManifest, 2346f023faSEvan Bacon} from '../start/server/metro/router'; 249a348a4eSEvan Baconimport { learnMore } from '../utils/link'; 250a6ddb20SEvan Bacon 260a6ddb20SEvan Baconconst debug = require('debug')('expo:export:generateStaticRoutes') as typeof console.log; 270a6ddb20SEvan Bacon 2846f023faSEvan Bacontype Options = { 2946f023faSEvan Bacon outputDir: string; 3046f023faSEvan Bacon minify: boolean; 3146f023faSEvan Bacon exportServer: boolean; 3246f023faSEvan Bacon basePath: string; 3346f023faSEvan Bacon includeMaps: boolean; 3446f023faSEvan Bacon}; 350a6ddb20SEvan Bacon 360a6ddb20SEvan Bacon/** @private */ 370a6ddb20SEvan Baconexport async function unstable_exportStaticAsync(projectRoot: string, options: Options) { 389a348a4eSEvan Bacon Log.warn( 399a348a4eSEvan Bacon `Experimental static rendering is enabled. ` + 409a348a4eSEvan Bacon learnMore('https://docs.expo.dev/router/reference/static-rendering/') 419a348a4eSEvan Bacon ); 420a6ddb20SEvan Bacon 437179edeaSEvan Bacon // TODO: Prevent starting the watcher. 440a6ddb20SEvan Bacon const devServerManager = new DevServerManager(projectRoot, { 450a6ddb20SEvan Bacon minify: options.minify, 460a6ddb20SEvan Bacon mode: 'production', 470a6ddb20SEvan Bacon location: {}, 480a6ddb20SEvan Bacon }); 490a6ddb20SEvan Bacon await devServerManager.startAsync([ 500a6ddb20SEvan Bacon { 510a6ddb20SEvan Bacon type: 'metro', 52429dc7fcSEvan Bacon options: { 53429dc7fcSEvan Bacon location: {}, 54429dc7fcSEvan Bacon isExporting: true, 55429dc7fcSEvan Bacon }, 560a6ddb20SEvan Bacon }, 570a6ddb20SEvan Bacon ]); 580a6ddb20SEvan Bacon 595b5e713eSEvan Bacon try { 6085531d53SEvan Bacon await exportFromServerAsync(projectRoot, devServerManager, options); 615b5e713eSEvan Bacon } finally { 620a6ddb20SEvan Bacon await devServerManager.stopAsync(); 630a6ddb20SEvan Bacon } 645b5e713eSEvan Bacon} 650a6ddb20SEvan Bacon 660a6ddb20SEvan Bacon/** Match `(page)` -> `page` */ 670a6ddb20SEvan Baconfunction matchGroupName(name: string): string | undefined { 680a6ddb20SEvan Bacon return name.match(/^\(([^/]+?)\)$/)?.[1]; 690a6ddb20SEvan Bacon} 700a6ddb20SEvan Bacon 7185531d53SEvan Baconexport async function getFilesToExportFromServerAsync( 7285531d53SEvan Bacon projectRoot: string, 7385531d53SEvan Bacon { 740a6ddb20SEvan Bacon manifest, 750a6ddb20SEvan Bacon renderAsync, 76e015d41cSEvan Bacon includeGroupVariations, 770a6ddb20SEvan Bacon }: { 780a6ddb20SEvan Bacon manifest: any; 799580591fSEvan Bacon renderAsync: (pathname: string) => Promise<string>; 80e015d41cSEvan Bacon includeGroupVariations?: boolean; 8185531d53SEvan Bacon } 8285531d53SEvan Bacon): Promise<Map<string, string>> { 830a6ddb20SEvan Bacon // name : contents 840a6ddb20SEvan Bacon const files = new Map<string, string>(); 850a6ddb20SEvan Bacon 869580591fSEvan Bacon await Promise.all( 87e015d41cSEvan Bacon getHtmlFiles({ manifest, includeGroupVariations }).map(async (outputPath) => { 887179edeaSEvan Bacon const pathname = outputPath.replace(/(?:index)?\.html$/, ''); 890a6ddb20SEvan Bacon try { 909580591fSEvan Bacon files.set(outputPath, ''); 910a6ddb20SEvan Bacon const data = await renderAsync(pathname); 929580591fSEvan Bacon files.set(outputPath, data); 930a6ddb20SEvan Bacon } catch (e: any) { 9485531d53SEvan Bacon await logMetroErrorAsync({ error: e, projectRoot }); 9585531d53SEvan Bacon throw new Error('Failed to statically export route: ' + pathname); 960a6ddb20SEvan Bacon } 979580591fSEvan Bacon }) 980a6ddb20SEvan Bacon ); 990a6ddb20SEvan Bacon 1000a6ddb20SEvan Bacon return files; 1010a6ddb20SEvan Bacon} 1020a6ddb20SEvan Bacon 1030a6ddb20SEvan Bacon/** Perform all fs commits */ 1040a6ddb20SEvan Baconexport async function exportFromServerAsync( 10585531d53SEvan Bacon projectRoot: string, 1060a6ddb20SEvan Bacon devServerManager: DevServerManager, 10746f023faSEvan Bacon { outputDir, basePath, exportServer, minify, includeMaps }: Options 1080a6ddb20SEvan Bacon): Promise<void> { 10946f023faSEvan Bacon const { exp } = getConfig(projectRoot, { skipSDKVersionRequirement: true }); 11046f023faSEvan Bacon const appDir = getRouterDirectoryWithManifest(projectRoot, exp); 11146f023faSEvan Bacon 11246f023faSEvan Bacon const injectFaviconTag = await getVirtualFaviconAssetsAsync(projectRoot, { outputDir, basePath }); 11342637653SEvan Bacon 1140a6ddb20SEvan Bacon const devServer = devServerManager.getDefaultDevServer(); 1159580591fSEvan Bacon assert(devServer instanceof MetroBundlerDevServer); 1160a6ddb20SEvan Bacon 1177179edeaSEvan Bacon const [resources, { manifest, renderAsync }] = await Promise.all([ 118573b0ea7SEvan Bacon devServer.getStaticResourcesAsync({ mode: 'production', minify, includeMaps }), 1199580591fSEvan Bacon devServer.getStaticRenderFunctionAsync({ 1209580591fSEvan Bacon mode: 'production', 1211a3d836eSEvan Bacon minify, 1229580591fSEvan Bacon }), 1239580591fSEvan Bacon ]); 1240a6ddb20SEvan Bacon 1250a6ddb20SEvan Bacon debug('Routes:\n', inspect(manifest, { colors: true, depth: null })); 1260a6ddb20SEvan Bacon 12785531d53SEvan Bacon const files = await getFilesToExportFromServerAsync(projectRoot, { 1280a6ddb20SEvan Bacon manifest, 129e015d41cSEvan Bacon // Servers can handle group routes automatically and therefore 130e015d41cSEvan Bacon // don't require the build-time generation of every possible group 131e015d41cSEvan Bacon // variation. 132e015d41cSEvan Bacon includeGroupVariations: !exportServer, 1339580591fSEvan Bacon async renderAsync(pathname: string) { 1349580591fSEvan Bacon const template = await renderAsync(pathname); 13542637653SEvan Bacon let html = await devServer.composeResourcesWithHtml({ 1369580591fSEvan Bacon mode: 'production', 1379580591fSEvan Bacon resources, 1389580591fSEvan Bacon template, 1397c98c357SEvan Bacon basePath, 1409580591fSEvan Bacon }); 14142637653SEvan Bacon 14242637653SEvan Bacon if (injectFaviconTag) { 14342637653SEvan Bacon html = injectFaviconTag(html); 14442637653SEvan Bacon } 14542637653SEvan Bacon 14642637653SEvan Bacon return html; 1470a6ddb20SEvan Bacon }, 1480a6ddb20SEvan Bacon }); 1490a6ddb20SEvan Bacon 1509580591fSEvan Bacon resources.forEach((resource) => { 151573b0ea7SEvan Bacon files.set( 152573b0ea7SEvan Bacon resource.filename, 153573b0ea7SEvan Bacon modifyBundlesWithSourceMaps(resource.filename, resource.source, includeMaps) 154573b0ea7SEvan Bacon ); 1559580591fSEvan Bacon }); 1569580591fSEvan Bacon 15746f023faSEvan Bacon if (exportServer) { 15846f023faSEvan Bacon const apiRoutes = await exportApiRoutesAsync({ outputDir, server: devServer, appDir }); 15946f023faSEvan Bacon 16046f023faSEvan Bacon // Add the api routes to the files to export. 16146f023faSEvan Bacon for (const [route, contents] of apiRoutes) { 16246f023faSEvan Bacon files.set(route, contents); 16346f023faSEvan Bacon } 16446f023faSEvan Bacon } else { 16546f023faSEvan Bacon warnPossibleInvalidExportType(appDir); 16646f023faSEvan Bacon } 16746f023faSEvan Bacon 1680a6ddb20SEvan Bacon fs.mkdirSync(path.join(outputDir), { recursive: true }); 1690a6ddb20SEvan Bacon 1709580591fSEvan Bacon Log.log(''); 1719580591fSEvan Bacon Log.log(chalk.bold`Exporting ${files.size} files:`); 1720a6ddb20SEvan Bacon await Promise.all( 1730a6ddb20SEvan Bacon [...files.entries()] 1740a6ddb20SEvan Bacon .sort(([a], [b]) => a.localeCompare(b)) 1750a6ddb20SEvan Bacon .map(async ([file, contents]) => { 1760a6ddb20SEvan Bacon const length = Buffer.byteLength(contents, 'utf8'); 1770a6ddb20SEvan Bacon Log.log(file, chalk.gray`(${prettyBytes(length)})`); 1780a6ddb20SEvan Bacon const outputPath = path.join(outputDir, file); 1790a6ddb20SEvan Bacon await fs.promises.mkdir(path.dirname(outputPath), { recursive: true }); 1800a6ddb20SEvan Bacon await fs.promises.writeFile(outputPath, contents); 1810a6ddb20SEvan Bacon }) 1820a6ddb20SEvan Bacon ); 1839580591fSEvan Bacon Log.log(''); 1849580591fSEvan Bacon} 1859580591fSEvan Bacon 186573b0ea7SEvan Baconexport function modifyBundlesWithSourceMaps( 187573b0ea7SEvan Bacon filename: string, 188573b0ea7SEvan Bacon source: string, 189573b0ea7SEvan Bacon includeMaps: boolean 190573b0ea7SEvan Bacon): string { 191573b0ea7SEvan Bacon if (filename.endsWith('.js')) { 192573b0ea7SEvan Bacon // If the bundle ends with source map URLs then update them to point to the correct location. 193573b0ea7SEvan Bacon 194573b0ea7SEvan Bacon // TODO: basePath support 195573b0ea7SEvan Bacon const normalizedFilename = '/' + filename.replace(/^\/+/, ''); 196573b0ea7SEvan Bacon //# sourceMappingURL=//localhost:8085/index.map?platform=web&dev=false&hot=false&lazy=true&minify=true&resolver.environment=client&transform.environment=client&serializer.output=static 197573b0ea7SEvan Bacon //# 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 198573b0ea7SEvan Bacon return source.replace(/^\/\/# (sourceMappingURL|sourceURL)=.*$/gm, (...props) => { 199573b0ea7SEvan Bacon if (includeMaps) { 200573b0ea7SEvan Bacon if (props[1] === 'sourceURL') { 201573b0ea7SEvan Bacon return `//# ${props[1]}=` + normalizedFilename; 202573b0ea7SEvan Bacon } else if (props[1] === 'sourceMappingURL') { 203573b0ea7SEvan Bacon const mapName = normalizedFilename + '.map'; 204573b0ea7SEvan Bacon return `//# ${props[1]}=` + mapName; 205573b0ea7SEvan Bacon } 206573b0ea7SEvan Bacon } 207573b0ea7SEvan Bacon return ''; 208573b0ea7SEvan Bacon }); 209573b0ea7SEvan Bacon } 210573b0ea7SEvan Bacon return source; 211573b0ea7SEvan Bacon} 212573b0ea7SEvan Bacon 213e015d41cSEvan Baconexport function getHtmlFiles({ 214e015d41cSEvan Bacon manifest, 215e015d41cSEvan Bacon includeGroupVariations, 216e015d41cSEvan Bacon}: { 217e015d41cSEvan Bacon manifest: any; 218e015d41cSEvan Bacon includeGroupVariations?: boolean; 219e015d41cSEvan Bacon}): string[] { 2209580591fSEvan Bacon const htmlFiles = new Set<string>(); 2219580591fSEvan Bacon 2229580591fSEvan Bacon function traverseScreens(screens: string | { screens: any; path: string }, basePath = '') { 2239580591fSEvan Bacon for (const value of Object.values(screens)) { 2249580591fSEvan Bacon if (typeof value === 'string') { 2259580591fSEvan Bacon let filePath = basePath + value; 2269580591fSEvan Bacon if (value === '') { 2279580591fSEvan Bacon filePath = 2289580591fSEvan Bacon basePath === '' 2299580591fSEvan Bacon ? 'index' 2309580591fSEvan Bacon : basePath.endsWith('/') 2319580591fSEvan Bacon ? basePath + 'index' 2329580591fSEvan Bacon : basePath.slice(0, -1); 2339580591fSEvan Bacon } 234e015d41cSEvan Bacon if (includeGroupVariations) { 2359580591fSEvan Bacon // TODO: Dedupe requests for alias routes. 2369580591fSEvan Bacon addOptionalGroups(filePath); 237e015d41cSEvan Bacon } else { 238e015d41cSEvan Bacon htmlFiles.add(filePath); 239e015d41cSEvan Bacon } 2409580591fSEvan Bacon } else if (typeof value === 'object' && value?.screens) { 2419580591fSEvan Bacon const newPath = basePath + value.path + '/'; 2429580591fSEvan Bacon traverseScreens(value.screens, newPath); 2439580591fSEvan Bacon } 2449580591fSEvan Bacon } 2459580591fSEvan Bacon } 2469580591fSEvan Bacon 2479580591fSEvan Bacon function addOptionalGroups(path: string) { 2489580591fSEvan Bacon const variations = getPathVariations(path); 2499580591fSEvan Bacon for (const variation of variations) { 2509580591fSEvan Bacon htmlFiles.add(variation); 2519580591fSEvan Bacon } 2529580591fSEvan Bacon } 2539580591fSEvan Bacon 2549580591fSEvan Bacon traverseScreens(manifest.screens); 2559580591fSEvan Bacon 2569580591fSEvan Bacon return Array.from(htmlFiles).map((value) => { 2579580591fSEvan Bacon const parts = value.split('/'); 2589580591fSEvan Bacon // Replace `:foo` with `[foo]` and `*foo` with `[...foo]` 2599580591fSEvan Bacon const partsWithGroups = parts.map((part) => { 2609580591fSEvan Bacon if (part.startsWith(':')) { 2619580591fSEvan Bacon return `[${part.slice(1)}]`; 2629580591fSEvan Bacon } else if (part.startsWith('*')) { 2639580591fSEvan Bacon return `[...${part.slice(1)}]`; 2649580591fSEvan Bacon } 2659580591fSEvan Bacon return part; 2669580591fSEvan Bacon }); 2679580591fSEvan Bacon return partsWithGroups.join('/') + '.html'; 2689580591fSEvan Bacon }); 2699580591fSEvan Bacon} 2709580591fSEvan Bacon 2719580591fSEvan Bacon// Given a route like `(foo)/bar/(baz)`, return all possible variations of the route. 2729580591fSEvan Bacon// e.g. `(foo)/bar/(baz)`, `(foo)/bar/baz`, `foo/bar/(baz)`, `foo/bar/baz`, 2739580591fSEvan Baconexport function getPathVariations(routePath: string): string[] { 274*34a1b52dSMark Lawlor const variations = new Set<string>(); 2759580591fSEvan Bacon const segments = routePath.split('/'); 2769580591fSEvan Bacon 277*34a1b52dSMark Lawlor function generateVariations(segments: string[], current = ''): void { 278*34a1b52dSMark Lawlor if (segments.length === 0) { 279*34a1b52dSMark Lawlor if (current) variations.add(current); 2809580591fSEvan Bacon return; 2819580591fSEvan Bacon } 2829580591fSEvan Bacon 283*34a1b52dSMark Lawlor const [head, ...rest] = segments; 284*34a1b52dSMark Lawlor 285*34a1b52dSMark Lawlor if (head.startsWith('(foo,foo')) { 2869580591fSEvan Bacon } 2879580591fSEvan Bacon 288*34a1b52dSMark Lawlor if (matchGroupName(head)) { 289*34a1b52dSMark Lawlor const groups = head.slice(1, -1).split(','); 290*34a1b52dSMark Lawlor 291*34a1b52dSMark Lawlor if (groups.length > 1) { 292*34a1b52dSMark Lawlor for (const group of groups) { 293*34a1b52dSMark Lawlor // If there are multiple groups, recurse on each group. 294*34a1b52dSMark Lawlor generateVariations([`(${group.trim()})`, ...rest], current); 295*34a1b52dSMark Lawlor } 296*34a1b52dSMark Lawlor return; 297*34a1b52dSMark Lawlor } else { 298*34a1b52dSMark Lawlor // Start a fork where this group is included 299*34a1b52dSMark Lawlor generateVariations(rest, current ? `${current}/(${groups[0]})` : `(${groups[0]})`); 300*34a1b52dSMark Lawlor // This code will continue and add paths without this group included` 301*34a1b52dSMark Lawlor } 302*34a1b52dSMark Lawlor } else if (current) { 303*34a1b52dSMark Lawlor current = `${current}/${head}`; 304*34a1b52dSMark Lawlor } else { 305*34a1b52dSMark Lawlor current = head; 3069580591fSEvan Bacon } 3079580591fSEvan Bacon 308*34a1b52dSMark Lawlor generateVariations(rest, current); 309*34a1b52dSMark Lawlor } 310*34a1b52dSMark Lawlor 311*34a1b52dSMark Lawlor generateVariations(segments); 3129580591fSEvan Bacon 3139580591fSEvan Bacon return Array.from(variations); 3140a6ddb20SEvan Bacon} 31546f023faSEvan Bacon 31646f023faSEvan Baconasync function exportApiRoutesAsync({ 31746f023faSEvan Bacon outputDir, 31846f023faSEvan Bacon server, 31946f023faSEvan Bacon appDir, 32046f023faSEvan Bacon}: { 32146f023faSEvan Bacon outputDir: string; 32246f023faSEvan Bacon server: MetroBundlerDevServer; 32346f023faSEvan Bacon appDir: string; 32446f023faSEvan Bacon}): Promise<Map<string, string>> { 32546f023faSEvan Bacon const funcDir = path.join(outputDir, '_expo/functions'); 32646f023faSEvan Bacon fs.mkdirSync(path.join(funcDir), { recursive: true }); 32746f023faSEvan Bacon 32846f023faSEvan Bacon const [manifest, files] = await Promise.all([ 32946f023faSEvan Bacon server.getExpoRouterRoutesManifestAsync({ 33046f023faSEvan Bacon appDir, 33146f023faSEvan Bacon }), 33246f023faSEvan Bacon server 33346f023faSEvan Bacon .exportExpoRouterApiRoutesAsync({ 33446f023faSEvan Bacon mode: 'production', 33546f023faSEvan Bacon appDir, 33646f023faSEvan Bacon }) 33746f023faSEvan Bacon .then((routes) => { 33846f023faSEvan Bacon const files = new Map<string, string>(); 33946f023faSEvan Bacon for (const [route, contents] of routes) { 34046f023faSEvan Bacon files.set(path.join('_expo/functions', route), contents); 34146f023faSEvan Bacon } 34246f023faSEvan Bacon return files; 34346f023faSEvan Bacon }), 34446f023faSEvan Bacon ]); 34546f023faSEvan Bacon 34646f023faSEvan Bacon Log.log(chalk.bold`Exporting ${files.size} API Routes.`); 34746f023faSEvan Bacon 34846f023faSEvan Bacon files.set('_expo/routes.json', JSON.stringify(manifest, null, 2)); 34946f023faSEvan Bacon 35046f023faSEvan Bacon return files; 35146f023faSEvan Bacon} 35246f023faSEvan Bacon 35346f023faSEvan Baconfunction warnPossibleInvalidExportType(appDir: string) { 35446f023faSEvan Bacon const apiRoutes = getApiRoutesForDirectory(appDir); 35546f023faSEvan Bacon if (apiRoutes.length) { 35646f023faSEvan Bacon // TODO: Allow API Routes for native-only. 35746f023faSEvan Bacon Log.warn( 35846f023faSEvan Bacon chalk.yellow`Skipping export for API routes because \`web.output\` is not "server". You may want to remove the routes: ${apiRoutes 35946f023faSEvan Bacon .map((v) => path.relative(appDir, v)) 36046f023faSEvan Bacon .join(', ')}` 36146f023faSEvan Bacon ); 36246f023faSEvan Bacon } 36346f023faSEvan Bacon} 364