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