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