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