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; basePath: string; 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, basePath, minify, includeMaps }: Options 95): Promise<void> { 96 const injectFaviconTag = await getVirtualFaviconAssetsAsync(projectRoot, { 97 basePath, 98 outputDir, 99 }); 100 101 const devServer = devServerManager.getDefaultDevServer(); 102 assert(devServer instanceof MetroBundlerDevServer); 103 104 const [resources, { manifest, renderAsync }] = await Promise.all([ 105 devServer.getStaticResourcesAsync({ mode: 'production', minify, includeMaps }), 106 devServer.getStaticRenderFunctionAsync({ 107 mode: 'production', 108 minify, 109 }), 110 ]); 111 112 debug('Routes:\n', inspect(manifest, { colors: true, depth: null })); 113 114 const files = await getFilesToExportFromServerAsync(projectRoot, { 115 manifest, 116 async renderAsync(pathname: string) { 117 const template = await renderAsync(pathname); 118 let html = await devServer.composeResourcesWithHtml({ 119 mode: 'production', 120 resources, 121 template, 122 basePath, 123 }); 124 125 if (injectFaviconTag) { 126 html = injectFaviconTag(html); 127 } 128 129 return html; 130 }, 131 }); 132 133 resources.forEach((resource) => { 134 files.set( 135 resource.filename, 136 modifyBundlesWithSourceMaps(resource.filename, resource.source, includeMaps) 137 ); 138 }); 139 140 fs.mkdirSync(path.join(outputDir), { recursive: true }); 141 142 Log.log(''); 143 Log.log(chalk.bold`Exporting ${files.size} files:`); 144 await Promise.all( 145 [...files.entries()] 146 .sort(([a], [b]) => a.localeCompare(b)) 147 .map(async ([file, contents]) => { 148 const length = Buffer.byteLength(contents, 'utf8'); 149 Log.log(file, chalk.gray`(${prettyBytes(length)})`); 150 const outputPath = path.join(outputDir, file); 151 await fs.promises.mkdir(path.dirname(outputPath), { recursive: true }); 152 await fs.promises.writeFile(outputPath, contents); 153 }) 154 ); 155 Log.log(''); 156} 157 158export function modifyBundlesWithSourceMaps( 159 filename: string, 160 source: string, 161 includeMaps: boolean 162): string { 163 if (filename.endsWith('.js')) { 164 // If the bundle ends with source map URLs then update them to point to the correct location. 165 166 // TODO: basePath support 167 const normalizedFilename = '/' + filename.replace(/^\/+/, ''); 168 //# sourceMappingURL=//localhost:8085/index.map?platform=web&dev=false&hot=false&lazy=true&minify=true&resolver.environment=client&transform.environment=client&serializer.output=static 169 //# 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 170 return source.replace(/^\/\/# (sourceMappingURL|sourceURL)=.*$/gm, (...props) => { 171 if (includeMaps) { 172 if (props[1] === 'sourceURL') { 173 return `//# ${props[1]}=` + normalizedFilename; 174 } else if (props[1] === 'sourceMappingURL') { 175 const mapName = normalizedFilename + '.map'; 176 return `//# ${props[1]}=` + mapName; 177 } 178 } 179 return ''; 180 }); 181 } 182 return source; 183} 184 185export function getHtmlFiles({ manifest }: { manifest: any }): string[] { 186 const htmlFiles = new Set<string>(); 187 188 function traverseScreens(screens: string | { screens: any; path: string }, basePath = '') { 189 for (const value of Object.values(screens)) { 190 if (typeof value === 'string') { 191 let filePath = basePath + value; 192 if (value === '') { 193 filePath = 194 basePath === '' 195 ? 'index' 196 : basePath.endsWith('/') 197 ? basePath + 'index' 198 : basePath.slice(0, -1); 199 } 200 // TODO: Dedupe requests for alias routes. 201 addOptionalGroups(filePath); 202 } else if (typeof value === 'object' && value?.screens) { 203 const newPath = basePath + value.path + '/'; 204 traverseScreens(value.screens, newPath); 205 } 206 } 207 } 208 209 function addOptionalGroups(path: string) { 210 const variations = getPathVariations(path); 211 for (const variation of variations) { 212 htmlFiles.add(variation); 213 } 214 } 215 216 traverseScreens(manifest.screens); 217 218 return Array.from(htmlFiles).map((value) => { 219 const parts = value.split('/'); 220 // Replace `:foo` with `[foo]` and `*foo` with `[...foo]` 221 const partsWithGroups = parts.map((part) => { 222 if (part.startsWith(':')) { 223 return `[${part.slice(1)}]`; 224 } else if (part.startsWith('*')) { 225 return `[...${part.slice(1)}]`; 226 } 227 return part; 228 }); 229 return partsWithGroups.join('/') + '.html'; 230 }); 231} 232 233// Given a route like `(foo)/bar/(baz)`, return all possible variations of the route. 234// e.g. `(foo)/bar/(baz)`, `(foo)/bar/baz`, `foo/bar/(baz)`, `foo/bar/baz`, 235export function getPathVariations(routePath: string): string[] { 236 const variations = new Set<string>([routePath]); 237 const segments = routePath.split('/'); 238 239 function generateVariations(segments: string[], index: number): void { 240 if (index >= segments.length) { 241 return; 242 } 243 244 const newSegments = [...segments]; 245 while ( 246 index < newSegments.length && 247 matchGroupName(newSegments[index]) && 248 newSegments.length > 1 249 ) { 250 newSegments.splice(index, 1); 251 variations.add(newSegments.join('/')); 252 generateVariations(newSegments, index + 1); 253 } 254 255 generateVariations(segments, index + 1); 256 } 257 258 generateVariations(segments, 0); 259 260 return Array.from(variations); 261} 262