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 { getVirtualFaviconAssetsAsync } from './favicon';
15import { Log } from '../log';
16import { DevServerManager } from '../start/server/DevServerManager';
17import { MetroBundlerDevServer } from '../start/server/metro/MetroBundlerDevServer';
18import { logMetroErrorAsync } from '../start/server/metro/metroErrorInterface';
19import { learnMore } from '../utils/link';
20
21const debug = require('debug')('expo:export:generateStaticRoutes') as typeof console.log;
22
23type Options = { outputDir: string; minify: boolean; basePath: string; includeMaps: boolean };
24
25/** @private */
26export async function unstable_exportStaticAsync(projectRoot: string, options: Options) {
27  Log.warn(
28    `Experimental static rendering is enabled. ` +
29      learnMore('https://docs.expo.dev/router/reference/static-rendering/')
30  );
31
32  // TODO: Prevent starting the watcher.
33  const devServerManager = new DevServerManager(projectRoot, {
34    minify: options.minify,
35    mode: 'production',
36    location: {},
37  });
38  await devServerManager.startAsync([
39    {
40      type: 'metro',
41      options: {
42        location: {},
43        isExporting: true,
44      },
45    },
46  ]);
47
48  try {
49    await exportFromServerAsync(projectRoot, devServerManager, options);
50  } finally {
51    await devServerManager.stopAsync();
52  }
53}
54
55/** Match `(page)` -> `page` */
56function matchGroupName(name: string): string | undefined {
57  return name.match(/^\(([^/]+?)\)$/)?.[1];
58}
59
60export async function getFilesToExportFromServerAsync(
61  projectRoot: string,
62  {
63    manifest,
64    renderAsync,
65  }: {
66    manifest: any;
67    renderAsync: (pathname: string) => Promise<string>;
68  }
69): Promise<Map<string, string>> {
70  // name : contents
71  const files = new Map<string, string>();
72
73  await Promise.all(
74    getHtmlFiles({ manifest }).map(async (outputPath) => {
75      const pathname = outputPath.replace(/(?:index)?\.html$/, '');
76      try {
77        files.set(outputPath, '');
78        const data = await renderAsync(pathname);
79        files.set(outputPath, data);
80      } catch (e: any) {
81        await logMetroErrorAsync({ error: e, projectRoot });
82        throw new Error('Failed to statically export route: ' + pathname);
83      }
84    })
85  );
86
87  return files;
88}
89
90/** Perform all fs commits */
91export async function exportFromServerAsync(
92  projectRoot: string,
93  devServerManager: DevServerManager,
94  { outputDir, basePath, minify, includeMaps }: Options
95): Promise<void> {
96  const injectFaviconTag = await getVirtualFaviconAssetsAsync(projectRoot, {
97    basePath,
98    outputDir,
99  });
100
101  const devServer = devServerManager.getDefaultDevServer();
102  assert(devServer instanceof MetroBundlerDevServer);
103
104  const [resources, { manifest, renderAsync }] = await Promise.all([
105    devServer.getStaticResourcesAsync({ mode: 'production', minify, includeMaps }),
106    devServer.getStaticRenderFunctionAsync({
107      mode: 'production',
108      minify,
109    }),
110  ]);
111
112  debug('Routes:\n', inspect(manifest, { colors: true, depth: null }));
113
114  const files = await getFilesToExportFromServerAsync(projectRoot, {
115    manifest,
116    async renderAsync(pathname: string) {
117      const template = await renderAsync(pathname);
118      let html = await devServer.composeResourcesWithHtml({
119        mode: 'production',
120        resources,
121        template,
122        basePath,
123      });
124
125      if (injectFaviconTag) {
126        html = injectFaviconTag(html);
127      }
128
129      return html;
130    },
131  });
132
133  resources.forEach((resource) => {
134    files.set(
135      resource.filename,
136      modifyBundlesWithSourceMaps(resource.filename, resource.source, includeMaps)
137    );
138  });
139
140  fs.mkdirSync(path.join(outputDir), { recursive: true });
141
142  Log.log('');
143  Log.log(chalk.bold`Exporting ${files.size} files:`);
144  await Promise.all(
145    [...files.entries()]
146      .sort(([a], [b]) => a.localeCompare(b))
147      .map(async ([file, contents]) => {
148        const length = Buffer.byteLength(contents, 'utf8');
149        Log.log(file, chalk.gray`(${prettyBytes(length)})`);
150        const outputPath = path.join(outputDir, file);
151        await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
152        await fs.promises.writeFile(outputPath, contents);
153      })
154  );
155  Log.log('');
156}
157
158export function modifyBundlesWithSourceMaps(
159  filename: string,
160  source: string,
161  includeMaps: boolean
162): string {
163  if (filename.endsWith('.js')) {
164    // If the bundle ends with source map URLs then update them to point to the correct location.
165
166    // TODO: basePath support
167    const normalizedFilename = '/' + filename.replace(/^\/+/, '');
168    //# sourceMappingURL=//localhost:8085/index.map?platform=web&dev=false&hot=false&lazy=true&minify=true&resolver.environment=client&transform.environment=client&serializer.output=static
169    //# sourceURL=http://localhost:8085/index.bundle//&platform=web&dev=false&hot=false&lazy=true&minify=true&resolver.environment=client&transform.environment=client&serializer.output=static
170    return source.replace(/^\/\/# (sourceMappingURL|sourceURL)=.*$/gm, (...props) => {
171      if (includeMaps) {
172        if (props[1] === 'sourceURL') {
173          return `//# ${props[1]}=` + normalizedFilename;
174        } else if (props[1] === 'sourceMappingURL') {
175          const mapName = normalizedFilename + '.map';
176          return `//# ${props[1]}=` + mapName;
177        }
178      }
179      return '';
180    });
181  }
182  return source;
183}
184
185export function getHtmlFiles({ manifest }: { manifest: any }): string[] {
186  const htmlFiles = new Set<string>();
187
188  function traverseScreens(screens: string | { screens: any; path: string }, basePath = '') {
189    for (const value of Object.values(screens)) {
190      if (typeof value === 'string') {
191        let filePath = basePath + value;
192        if (value === '') {
193          filePath =
194            basePath === ''
195              ? 'index'
196              : basePath.endsWith('/')
197              ? basePath + 'index'
198              : basePath.slice(0, -1);
199        }
200        // TODO: Dedupe requests for alias routes.
201        addOptionalGroups(filePath);
202      } else if (typeof value === 'object' && value?.screens) {
203        const newPath = basePath + value.path + '/';
204        traverseScreens(value.screens, newPath);
205      }
206    }
207  }
208
209  function addOptionalGroups(path: string) {
210    const variations = getPathVariations(path);
211    for (const variation of variations) {
212      htmlFiles.add(variation);
213    }
214  }
215
216  traverseScreens(manifest.screens);
217
218  return Array.from(htmlFiles).map((value) => {
219    const parts = value.split('/');
220    // Replace `:foo` with `[foo]` and `*foo` with `[...foo]`
221    const partsWithGroups = parts.map((part) => {
222      if (part.startsWith(':')) {
223        return `[${part.slice(1)}]`;
224      } else if (part.startsWith('*')) {
225        return `[...${part.slice(1)}]`;
226      }
227      return part;
228    });
229    return partsWithGroups.join('/') + '.html';
230  });
231}
232
233// Given a route like `(foo)/bar/(baz)`, return all possible variations of the route.
234// e.g. `(foo)/bar/(baz)`, `(foo)/bar/baz`, `foo/bar/(baz)`, `foo/bar/baz`,
235export function getPathVariations(routePath: string): string[] {
236  const variations = new Set<string>([routePath]);
237  const segments = routePath.split('/');
238
239  function generateVariations(segments: string[], index: number): void {
240    if (index >= segments.length) {
241      return;
242    }
243
244    const newSegments = [...segments];
245    while (
246      index < newSegments.length &&
247      matchGroupName(newSegments[index]) &&
248      newSegments.length > 1
249    ) {
250      newSegments.splice(index, 1);
251      variations.add(newSegments.join('/'));
252      generateVariations(newSegments, index + 1);
253    }
254
255    generateVariations(segments, index + 1);
256  }
257
258  generateVariations(segments, 0);
259
260  return Array.from(variations);
261}
262