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