10a6ddb20SEvan Bacon/** 20a6ddb20SEvan Bacon * Copyright © 2022 650 Industries. 30a6ddb20SEvan Bacon * 40a6ddb20SEvan Bacon * This source code is licensed under the MIT license found in the 50a6ddb20SEvan Bacon * LICENSE file in the root directory of this source tree. 60a6ddb20SEvan Bacon */ 70a6ddb20SEvan Baconimport fs from 'fs'; 80a6ddb20SEvan Baconimport fetch from 'node-fetch'; 90a6ddb20SEvan Baconimport path from 'path'; 100a6ddb20SEvan Baconimport requireString from 'require-from-string'; 110a6ddb20SEvan Baconimport resolveFrom from 'resolve-from'; 120a6ddb20SEvan Bacon 138a424bebSJames Ideimport { logMetroError } from './metro/metroErrorInterface'; 148a424bebSJames Ideimport { getMetroServerRoot } from './middleware/ManifestMiddleware'; 159580591fSEvan Baconimport { stripAnsi } from '../../utils/ansi'; 160a6ddb20SEvan Baconimport { delayAsync } from '../../utils/delay'; 179580591fSEvan Baconimport { SilentError } from '../../utils/errors'; 180a6ddb20SEvan Baconimport { memoize } from '../../utils/fn'; 190a6ddb20SEvan Baconimport { profile } from '../../utils/profile'; 200a6ddb20SEvan Bacon 210a6ddb20SEvan Baconconst debug = require('debug')('expo:start:server:node-renderer') as typeof console.log; 220a6ddb20SEvan Bacon 230a6ddb20SEvan Baconfunction wrapBundle(str: string) { 240a6ddb20SEvan Bacon // Skip the metro runtime so debugging is a bit easier. 250a6ddb20SEvan Bacon // Replace the __r() call with an export statement. 26a5de6e72SEvan Bacon // Use gm to apply to the last require line. This is needed when the bundle has side-effects. 27a5de6e72SEvan Bacon return str.replace(/^(__r\(.*\);)$/gm, 'module.exports = $1'); 280a6ddb20SEvan Bacon} 290a6ddb20SEvan Bacon 300a6ddb20SEvan Bacon// TODO(EvanBacon): Group all the code together and version. 310a6ddb20SEvan Baconconst getRenderModuleId = (projectRoot: string): string => { 320a6ddb20SEvan Bacon const moduleId = resolveFrom.silent(projectRoot, 'expo-router/node/render.js'); 330a6ddb20SEvan Bacon if (!moduleId) { 340a6ddb20SEvan Bacon throw new Error( 350a6ddb20SEvan Bacon `A version of expo-router with Node.js support is not installed in the project.` 360a6ddb20SEvan Bacon ); 370a6ddb20SEvan Bacon } 380a6ddb20SEvan Bacon 390a6ddb20SEvan Bacon return moduleId; 400a6ddb20SEvan Bacon}; 410a6ddb20SEvan Bacon 420a6ddb20SEvan Bacontype StaticRenderOptions = { 430a6ddb20SEvan Bacon // Ensure the style format is `css-xxxx` (prod) instead of `css-view-xxxx` (dev) 440a6ddb20SEvan Bacon dev?: boolean; 450a6ddb20SEvan Bacon minify?: boolean; 4657eba0f9SEvan Bacon platform?: string; 4757eba0f9SEvan Bacon environment?: 'node'; 480a6ddb20SEvan Bacon}; 490a6ddb20SEvan Bacon 500a6ddb20SEvan Baconconst moveStaticRenderFunction = memoize(async (projectRoot: string, requiredModuleId: string) => { 510a6ddb20SEvan Bacon // Copy the file into the project to ensure it works in monorepos. 520a6ddb20SEvan Bacon // This means the file cannot have any relative imports. 530a6ddb20SEvan Bacon const tempDir = path.join(projectRoot, '.expo/static'); 540a6ddb20SEvan Bacon await fs.promises.mkdir(tempDir, { recursive: true }); 550a6ddb20SEvan Bacon const moduleId = path.join(tempDir, 'render.js'); 560a6ddb20SEvan Bacon await fs.promises.writeFile(moduleId, await fs.promises.readFile(requiredModuleId, 'utf8')); 570a6ddb20SEvan Bacon // Sleep to give watchman time to register the file. 580a6ddb20SEvan Bacon await delayAsync(50); 590a6ddb20SEvan Bacon return moduleId; 600a6ddb20SEvan Bacon}); 610a6ddb20SEvan Bacon 620a6ddb20SEvan Bacon/** @returns the js file contents required to generate the static generation function. */ 630a6ddb20SEvan Baconexport async function getStaticRenderFunctionsContentAsync( 640a6ddb20SEvan Bacon projectRoot: string, 650a6ddb20SEvan Bacon devServerUrl: string, 664d061c81SEvan Bacon { dev = false, minify = false, environment }: StaticRenderOptions = {} 670a6ddb20SEvan Bacon): Promise<string> { 680a6ddb20SEvan Bacon const root = getMetroServerRoot(projectRoot); 690a6ddb20SEvan Bacon const requiredModuleId = getRenderModuleId(root); 700a6ddb20SEvan Bacon let moduleId = requiredModuleId; 710a6ddb20SEvan Bacon 720a6ddb20SEvan Bacon // Cannot be accessed using Metro's server API, we need to move the file 730a6ddb20SEvan Bacon // into the project root and try again. 740a6ddb20SEvan Bacon if (path.relative(root, moduleId).startsWith('..')) { 750a6ddb20SEvan Bacon moduleId = await moveStaticRenderFunction(projectRoot, requiredModuleId); 760a6ddb20SEvan Bacon } 770a6ddb20SEvan Bacon 784d061c81SEvan Bacon return requireFileContentsWithMetro(root, devServerUrl, moduleId, { dev, minify, environment }); 7957eba0f9SEvan Bacon} 800a6ddb20SEvan Bacon 8157eba0f9SEvan Baconasync function ensureFileInRootDirectory(projectRoot: string, otherFile: string) { 8257eba0f9SEvan Bacon // Cannot be accessed using Metro's server API, we need to move the file 8357eba0f9SEvan Bacon // into the project root and try again. 8457eba0f9SEvan Bacon if (!path.relative(projectRoot, otherFile).startsWith('../')) { 8557eba0f9SEvan Bacon return otherFile; 8657eba0f9SEvan Bacon } 8757eba0f9SEvan Bacon 8857eba0f9SEvan Bacon // Copy the file into the project to ensure it works in monorepos. 8957eba0f9SEvan Bacon // This means the file cannot have any relative imports. 9057eba0f9SEvan Bacon const tempDir = path.join(projectRoot, '.expo/static-tmp'); 9157eba0f9SEvan Bacon await fs.promises.mkdir(tempDir, { recursive: true }); 9257eba0f9SEvan Bacon const moduleId = path.join(tempDir, path.basename(otherFile)); 9357eba0f9SEvan Bacon await fs.promises.writeFile(moduleId, await fs.promises.readFile(otherFile, 'utf8')); 9457eba0f9SEvan Bacon // Sleep to give watchman time to register the file. 9557eba0f9SEvan Bacon await delayAsync(50); 9657eba0f9SEvan Bacon return moduleId; 9757eba0f9SEvan Bacon} 9857eba0f9SEvan Bacon 9924228e75SEvan Baconexport async function createMetroEndpointAsync( 10057eba0f9SEvan Bacon projectRoot: string, 10157eba0f9SEvan Bacon devServerUrl: string, 10257eba0f9SEvan Bacon absoluteFilePath: string, 10357eba0f9SEvan Bacon { dev = false, platform = 'web', minify = false, environment }: StaticRenderOptions = {} 10457eba0f9SEvan Bacon): Promise<string> { 10557eba0f9SEvan Bacon const root = getMetroServerRoot(projectRoot); 10657eba0f9SEvan Bacon const safeOtherFile = await ensureFileInRootDirectory(projectRoot, absoluteFilePath); 10757eba0f9SEvan Bacon const serverPath = path.relative(root, safeOtherFile).replace(/\.[jt]sx?$/, '.bundle'); 10857eba0f9SEvan Bacon debug('fetching from Metro:', root, serverPath); 10957eba0f9SEvan Bacon 11057eba0f9SEvan Bacon let url = `${devServerUrl}/${serverPath}?platform=${platform}&dev=${dev}&minify=${minify}`; 11157eba0f9SEvan Bacon 11257eba0f9SEvan Bacon if (environment) { 11357eba0f9SEvan Bacon url += `&resolver.environment=${environment}&transform.environment=${environment}`; 11457eba0f9SEvan Bacon } 11524228e75SEvan Bacon return url; 11624228e75SEvan Bacon} 11724228e75SEvan Bacon 1189580591fSEvan Baconexport class MetroNodeError extends Error { 1198a424bebSJames Ide constructor( 1208a424bebSJames Ide message: string, 1218a424bebSJames Ide public rawObject: any 1228a424bebSJames Ide ) { 1239580591fSEvan Bacon super(message); 1249580591fSEvan Bacon } 1259580591fSEvan Bacon} 1269580591fSEvan Bacon 12724228e75SEvan Baconexport async function requireFileContentsWithMetro( 12824228e75SEvan Bacon projectRoot: string, 12924228e75SEvan Bacon devServerUrl: string, 13024228e75SEvan Bacon absoluteFilePath: string, 13124228e75SEvan Bacon props: StaticRenderOptions = {} 13224228e75SEvan Bacon): Promise<string> { 13324228e75SEvan Bacon const url = await createMetroEndpointAsync(projectRoot, devServerUrl, absoluteFilePath, props); 13457eba0f9SEvan Bacon 13557eba0f9SEvan Bacon const res = await fetch(url); 1360a6ddb20SEvan Bacon 1370a6ddb20SEvan Bacon // TODO: Improve error handling 1380a6ddb20SEvan Bacon if (res.status === 500) { 1390a6ddb20SEvan Bacon const text = await res.text(); 1409580591fSEvan Bacon if (text.startsWith('{"originModulePath"') || text.startsWith('{"type":"TransformError"')) { 1410a6ddb20SEvan Bacon const errorObject = JSON.parse(text); 1429580591fSEvan Bacon 1439580591fSEvan Bacon throw new MetroNodeError(stripAnsi(errorObject.message) ?? errorObject.message, errorObject); 1440a6ddb20SEvan Bacon } 1450a6ddb20SEvan Bacon throw new Error(`[${res.status}]: ${res.statusText}\n${text}`); 1460a6ddb20SEvan Bacon } 1470a6ddb20SEvan Bacon 1480a6ddb20SEvan Bacon if (!res.ok) { 1490a6ddb20SEvan Bacon throw new Error(`Error fetching bundle for static rendering: ${res.status} ${res.statusText}`); 1500a6ddb20SEvan Bacon } 1510a6ddb20SEvan Bacon 1520a6ddb20SEvan Bacon const content = await res.text(); 1530a6ddb20SEvan Bacon 1544c178d05SEvan Bacon return wrapBundle(content); 1550a6ddb20SEvan Bacon} 156*46f023faSEvan Bacon 157*46f023faSEvan Baconexport async function requireWithMetro<T extends Record<string, (...args: any[]) => Promise<any>>>( 15857eba0f9SEvan Bacon projectRoot: string, 15957eba0f9SEvan Bacon devServerUrl: string, 16057eba0f9SEvan Bacon absoluteFilePath: string, 16157eba0f9SEvan Bacon options: StaticRenderOptions = {} 16257eba0f9SEvan Bacon): Promise<T> { 16357eba0f9SEvan Bacon const content = await requireFileContentsWithMetro( 16457eba0f9SEvan Bacon projectRoot, 16557eba0f9SEvan Bacon devServerUrl, 16657eba0f9SEvan Bacon absoluteFilePath, 16757eba0f9SEvan Bacon options 16857eba0f9SEvan Bacon ); 169*46f023faSEvan Bacon return evalMetroAndWrapFunctions<T>(projectRoot, content); 17057eba0f9SEvan Bacon} 1710a6ddb20SEvan Bacon 1720a6ddb20SEvan Baconexport async function getStaticRenderFunctions( 1730a6ddb20SEvan Bacon projectRoot: string, 1740a6ddb20SEvan Bacon devServerUrl: string, 1750a6ddb20SEvan Bacon options: StaticRenderOptions = {} 1769580591fSEvan Bacon): Promise<Record<string, (...args: any[]) => Promise<any>>> { 1770a6ddb20SEvan Bacon const scriptContents = await getStaticRenderFunctionsContentAsync( 1780a6ddb20SEvan Bacon projectRoot, 1790a6ddb20SEvan Bacon devServerUrl, 1800a6ddb20SEvan Bacon options 1810a6ddb20SEvan Bacon ); 1829580591fSEvan Bacon 183*46f023faSEvan Bacon return evalMetroAndWrapFunctions(projectRoot, scriptContents); 184*46f023faSEvan Bacon} 185*46f023faSEvan Bacon 186*46f023faSEvan Baconexport function evalMetroAndWrapFunctions<T = Record<string, (...args: any[]) => Promise<any>>>( 187*46f023faSEvan Bacon projectRoot: string, 188*46f023faSEvan Bacon script: string 189*46f023faSEvan Bacon): Promise<T> { 190*46f023faSEvan Bacon const contents = evalMetro(script); 1919580591fSEvan Bacon 1929580591fSEvan Bacon // wrap each function with a try/catch that uses Metro's error formatter 1939580591fSEvan Bacon return Object.keys(contents).reduce((acc, key) => { 1949580591fSEvan Bacon const fn = contents[key]; 1959580591fSEvan Bacon if (typeof fn !== 'function') { 1969580591fSEvan Bacon return { ...acc, [key]: fn }; 1970a6ddb20SEvan Bacon } 1980a6ddb20SEvan Bacon 1999580591fSEvan Bacon acc[key] = async function (...props: any[]) { 2009580591fSEvan Bacon try { 2019580591fSEvan Bacon return await fn.apply(this, props); 2029580591fSEvan Bacon } catch (error: any) { 2039580591fSEvan Bacon await logMetroError(projectRoot, { error }); 2049580591fSEvan Bacon throw new SilentError(error); 2059580591fSEvan Bacon } 2060a6ddb20SEvan Bacon }; 2079580591fSEvan Bacon return acc; 2089580591fSEvan Bacon }, {} as any); 2099580591fSEvan Bacon} 2109580591fSEvan Bacon 2119580591fSEvan Baconfunction evalMetro(src: string) { 2129580591fSEvan Bacon return profile(requireString, 'eval-metro-bundle')(src); 2130a6ddb20SEvan Bacon} 214