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