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