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