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