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