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};
43
44const moveStaticRenderFunction = memoize(async (projectRoot: string, requiredModuleId: string) => {
45  // Copy the file into the project to ensure it works in monorepos.
46  // This means the file cannot have any relative imports.
47  const tempDir = path.join(projectRoot, '.expo/static');
48  await fs.promises.mkdir(tempDir, { recursive: true });
49  const moduleId = path.join(tempDir, 'render.js');
50  await fs.promises.writeFile(moduleId, await fs.promises.readFile(requiredModuleId, 'utf8'));
51  // Sleep to give watchman time to register the file.
52  await delayAsync(50);
53  return moduleId;
54});
55
56/** @returns the js file contents required to generate the static generation function. */
57export async function getStaticRenderFunctionsContentAsync(
58  projectRoot: string,
59  devServerUrl: string,
60  { dev = false, minify = false }: StaticRenderOptions = {}
61): Promise<string> {
62  const root = getMetroServerRoot(projectRoot);
63  const requiredModuleId = getRenderModuleId(root);
64  let moduleId = requiredModuleId;
65
66  // Cannot be accessed using Metro's server API, we need to move the file
67  // into the project root and try again.
68  if (path.relative(root, moduleId).startsWith('..')) {
69    moduleId = await moveStaticRenderFunction(projectRoot, requiredModuleId);
70  }
71
72  const serverPath = path.relative(root, moduleId).replace(/\.[jt]sx?$/, '.bundle');
73  console.log(serverPath);
74  debug('Loading render functions from:', moduleId, moduleId, root);
75
76  const res = await fetch(`${devServerUrl}/${serverPath}?platform=web&dev=${dev}&minify=${minify}`);
77
78  // TODO: Improve error handling
79  if (res.status === 500) {
80    const text = await res.text();
81    if (text.startsWith('{"originModulePath"')) {
82      const errorObject = JSON.parse(text);
83      throw new Error(errorObject.message);
84    }
85    throw new Error(`[${res.status}]: ${res.statusText}\n${text}`);
86  }
87
88  if (!res.ok) {
89    throw new Error(`Error fetching bundle for static rendering: ${res.status} ${res.statusText}`);
90  }
91
92  const content = await res.text();
93
94  return wrapBundle(content);
95}
96
97export async function getStaticRenderFunctions(
98  projectRoot: string,
99  devServerUrl: string,
100  options: StaticRenderOptions = {}
101): Promise<any> {
102  const scriptContents = await getStaticRenderFunctionsContentAsync(
103    projectRoot,
104    devServerUrl,
105    options
106  );
107  return profile(requireString, 'eval-metro-bundle')(scriptContents);
108}
109
110export async function getStaticPageContentsAsync(
111  projectRoot: string,
112  devServerUrl: string,
113  options: StaticRenderOptions = {}
114) {
115  const scriptContents = await getStaticRenderFunctionsContentAsync(
116    projectRoot,
117    devServerUrl,
118    options
119  );
120
121  const {
122    getStaticContent,
123    // getDataLoader
124  } = profile(requireString, 'eval-metro-bundle')(scriptContents);
125
126  return function loadPageAsync(url: URL) {
127    // const fetchData = getDataLoader(url);
128
129    return {
130      fetchData: false,
131      scriptContents,
132      renderAsync: () => getStaticContent(url),
133    };
134  };
135}
136