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