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 assert from 'assert';
8import chalk from 'chalk';
9import fs from 'fs';
10import path from 'path';
11import prettyBytes from 'pretty-bytes';
12import { inspect } from 'util';
13
14import { Log } from '../log';
15import { DevServerManager } from '../start/server/DevServerManager';
16import { MetroBundlerDevServer } from '../start/server/metro/MetroBundlerDevServer';
17import { stripAnsi } from '../utils/ansi';
18
19const debug = require('debug')('expo:export:generateStaticRoutes') as typeof console.log;
20
21type Options = { outputDir: string; scripts: string[]; minify: boolean };
22
23/** @private */
24export async function unstable_exportStaticAsync(projectRoot: string, options: Options) {
25  // NOTE(EvanBacon): Please don't use this feature.
26  Log.warn('Static exporting with Metro is an experimental feature.');
27
28  const devServerManager = new DevServerManager(projectRoot, {
29    minify: options.minify,
30    mode: 'production',
31    location: {},
32  });
33
34  await devServerManager.startAsync([
35    {
36      type: 'metro',
37    },
38  ]);
39
40  await exportFromServerAsync(devServerManager, options);
41
42  await devServerManager.stopAsync();
43}
44
45async function getExpoRoutesAsync(devServerManager: DevServerManager) {
46  const server = devServerManager.getDefaultDevServer();
47  assert(server instanceof MetroBundlerDevServer);
48  return server.getRoutesAsync();
49}
50
51/** Match `(page)` -> `page` */
52function matchGroupName(name: string): string | undefined {
53  return name.match(/^\(([^/]+?)\)$/)?.[1];
54}
55
56function appendScriptsToHtml(html: string, scripts: string[]) {
57  return html.replace(
58    '</body>',
59    scripts.map((script) => `<script src="${script}" defer></script>`).join('') + '</body>'
60  );
61}
62
63export async function getFilesToExportFromServerAsync({
64  manifest,
65  scripts,
66  renderAsync,
67}: {
68  manifest: any;
69  scripts: string[];
70  renderAsync: (pathname: string) => Promise<{
71    fetchData: boolean;
72    scriptContents: string;
73    renderAsync: () => any;
74  }>;
75}): Promise<Map<string, string>> {
76  // name : contents
77  const files = new Map<string, string>();
78
79  const sanitizeName = (segment: string) => {
80    // Strip group names from the segment
81    return segment
82      .split('/')
83      .map((s) => (matchGroupName(s) ? '' : s))
84      .filter(Boolean)
85      .join('/');
86  };
87
88  const fetchScreens = (
89    screens: Record<string, any>,
90    additionPath: string = ''
91  ): Promise<any>[] => {
92    async function fetchScreenExactAsync(pathname: string, filename: string) {
93      const outputPath = [additionPath, filename].filter(Boolean).join('/').replace(/^\//, '');
94      // TODO: Ensure no duplicates in the manifest.
95      if (files.has(outputPath)) {
96        return;
97      }
98
99      // Prevent duplicate requests while running in parallel.
100      files.set(outputPath, '');
101
102      try {
103        const data = await renderAsync(pathname);
104
105        if (data.fetchData) {
106          // console.log('ssr:', pathname);
107        } else {
108          files.set(outputPath, appendScriptsToHtml(data.renderAsync(), scripts));
109        }
110      } catch (e: any) {
111        // TODO: Format Metro error message better...
112        Log.error('Failed to statically render route:', pathname);
113        e.message = stripAnsi(e.message);
114        Log.exception(e);
115        throw e;
116      }
117    }
118
119    async function fetchScreenAsync({ segment, filename }: { segment: string; filename: string }) {
120      // Strip group names from the segment
121      const cleanSegment = sanitizeName(segment);
122
123      if (cleanSegment !== segment) {
124        // has groups, should request multiple screens.
125        await fetchScreenExactAsync(
126          [additionPath, segment].filter(Boolean).join('/'),
127          [additionPath, filename].filter(Boolean).join('/').replace(/^\//, '')
128        );
129      }
130
131      await fetchScreenExactAsync(
132        [additionPath, cleanSegment].filter(Boolean).join('/'),
133        [additionPath, sanitizeName(filename)].filter(Boolean).join('/').replace(/^\//, '')
134      );
135    }
136
137    return Object.entries(screens).map(async ([name, segment]) => {
138      const filename = name + '.html';
139
140      // Segment is a directory.
141      if (typeof segment !== 'string') {
142        const cleanSegment = sanitizeName(segment.path);
143        return Promise.all(
144          fetchScreens(segment.screens, [additionPath, cleanSegment].filter(Boolean).join('/'))
145        );
146      }
147
148      // TODO: handle dynamic routes
149      if (segment !== '*') {
150        await fetchScreenAsync({ segment, filename });
151      }
152      return null;
153    });
154  };
155
156  await Promise.all(fetchScreens(manifest.screens));
157
158  return files;
159}
160
161/** Perform all fs commits */
162export async function exportFromServerAsync(
163  devServerManager: DevServerManager,
164  { outputDir, scripts }: Options
165): Promise<void> {
166  const devServer = devServerManager.getDefaultDevServer();
167
168  const manifest = await getExpoRoutesAsync(devServerManager);
169
170  debug('Routes:\n', inspect(manifest, { colors: true, depth: null }));
171
172  const files = await getFilesToExportFromServerAsync({
173    manifest,
174    scripts,
175    renderAsync(pathname: string) {
176      assert(devServer instanceof MetroBundlerDevServer);
177      return devServer.getStaticPageAsync(pathname, { mode: 'production' });
178    },
179  });
180
181  fs.mkdirSync(path.join(outputDir), { recursive: true });
182
183  Log.log(`Exporting ${files.size} files:`);
184  await Promise.all(
185    [...files.entries()]
186      .sort(([a], [b]) => a.localeCompare(b))
187      .map(async ([file, contents]) => {
188        const length = Buffer.byteLength(contents, 'utf8');
189        Log.log(file, chalk.gray`(${prettyBytes(length)})`);
190        const outputPath = path.join(outputDir, file);
191        await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
192        await fs.promises.writeFile(outputPath, contents);
193      })
194  );
195}
196