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