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 fs from 'fs'; 8import fetch from 'node-fetch'; 9import path from 'path'; 10import requireString from 'require-from-string'; 11import resolveFrom from 'resolve-from'; 12 13import { delayAsync } from '../../utils/delay'; 14import { memoize } from '../../utils/fn'; 15import { profile } from '../../utils/profile'; 16import { getMetroServerRoot } from './middleware/ManifestMiddleware'; 17 18const debug = require('debug')('expo:start:server:node-renderer') as typeof console.log; 19 20function wrapBundle(str: string) { 21 // Skip the metro runtime so debugging is a bit easier. 22 // Replace the __r() call with an export statement. 23 return str.replace(/^(__r\(.*\);)$/m, 'module.exports = $1'); 24} 25 26function stripProcess(str: string) { 27 // TODO: Remove from the metro prelude 28 return str.replace(/process=this\.process\|\|{},/m, ''); 29} 30 31// TODO(EvanBacon): Group all the code together and version. 32const getRenderModuleId = (projectRoot: string): string => { 33 const moduleId = resolveFrom.silent(projectRoot, 'expo-router/node/render.js'); 34 if (!moduleId) { 35 throw new Error( 36 `A version of expo-router with Node.js support is not installed in the project.` 37 ); 38 } 39 40 return moduleId; 41}; 42 43type StaticRenderOptions = { 44 // Ensure the style format is `css-xxxx` (prod) instead of `css-view-xxxx` (dev) 45 dev?: boolean; 46 minify?: boolean; 47 platform?: string; 48 environment?: 'node'; 49}; 50 51const moveStaticRenderFunction = memoize(async (projectRoot: string, requiredModuleId: string) => { 52 // Copy the file into the project to ensure it works in monorepos. 53 // This means the file cannot have any relative imports. 54 const tempDir = path.join(projectRoot, '.expo/static'); 55 await fs.promises.mkdir(tempDir, { recursive: true }); 56 const moduleId = path.join(tempDir, 'render.js'); 57 await fs.promises.writeFile(moduleId, await fs.promises.readFile(requiredModuleId, 'utf8')); 58 // Sleep to give watchman time to register the file. 59 await delayAsync(50); 60 return moduleId; 61}); 62 63/** @returns the js file contents required to generate the static generation function. */ 64export async function getStaticRenderFunctionsContentAsync( 65 projectRoot: string, 66 devServerUrl: string, 67 { dev = false, minify = false, environment }: StaticRenderOptions = {} 68): Promise<string> { 69 const root = getMetroServerRoot(projectRoot); 70 const requiredModuleId = getRenderModuleId(root); 71 let moduleId = requiredModuleId; 72 73 // Cannot be accessed using Metro's server API, we need to move the file 74 // into the project root and try again. 75 if (path.relative(root, moduleId).startsWith('..')) { 76 moduleId = await moveStaticRenderFunction(projectRoot, requiredModuleId); 77 } 78 79 return requireFileContentsWithMetro(root, devServerUrl, moduleId, { dev, minify, environment }); 80} 81 82async function ensureFileInRootDirectory(projectRoot: string, otherFile: string) { 83 // Cannot be accessed using Metro's server API, we need to move the file 84 // into the project root and try again. 85 if (!path.relative(projectRoot, otherFile).startsWith('../')) { 86 return otherFile; 87 } 88 89 // Copy the file into the project to ensure it works in monorepos. 90 // This means the file cannot have any relative imports. 91 const tempDir = path.join(projectRoot, '.expo/static-tmp'); 92 await fs.promises.mkdir(tempDir, { recursive: true }); 93 const moduleId = path.join(tempDir, path.basename(otherFile)); 94 await fs.promises.writeFile(moduleId, await fs.promises.readFile(otherFile, 'utf8')); 95 // Sleep to give watchman time to register the file. 96 await delayAsync(50); 97 return moduleId; 98} 99 100export async function createMetroEndpointAsync( 101 projectRoot: string, 102 devServerUrl: string, 103 absoluteFilePath: string, 104 { dev = false, platform = 'web', minify = false, environment }: StaticRenderOptions = {} 105): Promise<string> { 106 const root = getMetroServerRoot(projectRoot); 107 const safeOtherFile = await ensureFileInRootDirectory(projectRoot, absoluteFilePath); 108 const serverPath = path.relative(root, safeOtherFile).replace(/\.[jt]sx?$/, '.bundle'); 109 debug('fetching from Metro:', root, serverPath); 110 111 let url = `${devServerUrl}/${serverPath}?platform=${platform}&dev=${dev}&minify=${minify}`; 112 113 if (environment) { 114 url += `&resolver.environment=${environment}&transform.environment=${environment}`; 115 } 116 return url; 117} 118 119export async function requireFileContentsWithMetro( 120 projectRoot: string, 121 devServerUrl: string, 122 absoluteFilePath: string, 123 props: StaticRenderOptions = {} 124): Promise<string> { 125 const url = await createMetroEndpointAsync(projectRoot, devServerUrl, absoluteFilePath, props); 126 127 const res = await fetch(url); 128 129 // TODO: Improve error handling 130 if (res.status === 500) { 131 const text = await res.text(); 132 if (text.startsWith('{"originModulePath"')) { 133 const errorObject = JSON.parse(text); 134 throw new Error(errorObject.message); 135 } 136 throw new Error(`[${res.status}]: ${res.statusText}\n${text}`); 137 } 138 139 if (!res.ok) { 140 throw new Error(`Error fetching bundle for static rendering: ${res.status} ${res.statusText}`); 141 } 142 143 const content = await res.text(); 144 145 let bun = wrapBundle(content); 146 147 // This exposes the entire environment to the bundle. 148 if (props.environment === 'node') { 149 bun = stripProcess(bun); 150 } 151 152 return bun; 153} 154export async function requireWithMetro<T>( 155 projectRoot: string, 156 devServerUrl: string, 157 absoluteFilePath: string, 158 options: StaticRenderOptions = {} 159): Promise<T> { 160 const content = await requireFileContentsWithMetro( 161 projectRoot, 162 devServerUrl, 163 absoluteFilePath, 164 options 165 ); 166 167 return profile(requireString, 'eval-metro-bundle')(content); 168} 169 170export async function getStaticRenderFunctions( 171 projectRoot: string, 172 devServerUrl: string, 173 options: StaticRenderOptions = {} 174): Promise<any> { 175 const scriptContents = await getStaticRenderFunctionsContentAsync( 176 projectRoot, 177 devServerUrl, 178 options 179 ); 180 return profile(requireString, 'eval-metro-bundle')(scriptContents); 181} 182 183export async function getStaticPageContentsAsync( 184 projectRoot: string, 185 devServerUrl: string, 186 options: StaticRenderOptions = {} 187) { 188 const scriptContents = await getStaticRenderFunctionsContentAsync( 189 projectRoot, 190 devServerUrl, 191 options 192 ); 193 194 const { 195 getStaticContent, 196 // getDataLoader 197 } = profile(requireString, 'eval-metro-bundle')(scriptContents); 198 199 return function loadPageAsync(url: URL) { 200 // const fetchData = getDataLoader(url); 201 202 return { 203 fetchData: false, 204 scriptContents, 205 renderAsync: () => getStaticContent(url), 206 }; 207 }; 208} 209