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