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; 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, minify, includeMaps }: Options
95): Promise<void> {
96  const injectFaviconTag = await getVirtualFaviconAssetsAsync(projectRoot, outputDir);
97
98  const devServer = devServerManager.getDefaultDevServer();
99  assert(devServer instanceof MetroBundlerDevServer);
100
101  const [resources, { manifest, renderAsync }] = await Promise.all([
102    devServer.getStaticResourcesAsync({ mode: 'production', minify, includeMaps }),
103    devServer.getStaticRenderFunctionAsync({
104      mode: 'production',
105      minify,
106    }),
107  ]);
108
109  debug('Routes:\n', inspect(manifest, { colors: true, depth: null }));
110
111  const files = await getFilesToExportFromServerAsync(projectRoot, {
112    manifest,
113    async renderAsync(pathname: string) {
114      const template = await renderAsync(pathname);
115      let html = await devServer.composeResourcesWithHtml({
116        mode: 'production',
117        resources,
118        template,
119      });
120
121      if (injectFaviconTag) {
122        html = injectFaviconTag(html);
123      }
124
125      return html;
126    },
127  });
128
129  resources.forEach((resource) => {
130    files.set(
131      resource.filename,
132      modifyBundlesWithSourceMaps(resource.filename, resource.source, includeMaps)
133    );
134  });
135
136  fs.mkdirSync(path.join(outputDir), { recursive: true });
137
138  Log.log('');
139  Log.log(chalk.bold`Exporting ${files.size} files:`);
140  await Promise.all(
141    [...files.entries()]
142      .sort(([a], [b]) => a.localeCompare(b))
143      .map(async ([file, contents]) => {
144        const length = Buffer.byteLength(contents, 'utf8');
145        Log.log(file, chalk.gray`(${prettyBytes(length)})`);
146        const outputPath = path.join(outputDir, file);
147        await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
148        await fs.promises.writeFile(outputPath, contents);
149      })
150  );
151  Log.log('');
152}
153
154export function modifyBundlesWithSourceMaps(
155  filename: string,
156  source: string,
157  includeMaps: boolean
158): string {
159  if (filename.endsWith('.js')) {
160    // If the bundle ends with source map URLs then update them to point to the correct location.
161
162    // TODO: basePath support
163    const normalizedFilename = '/' + filename.replace(/^\/+/, '');
164    //# sourceMappingURL=//localhost:8085/index.map?platform=web&dev=false&hot=false&lazy=true&minify=true&resolver.environment=client&transform.environment=client&serializer.output=static
165    //# 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
166    return source.replace(/^\/\/# (sourceMappingURL|sourceURL)=.*$/gm, (...props) => {
167      if (includeMaps) {
168        if (props[1] === 'sourceURL') {
169          return `//# ${props[1]}=` + normalizedFilename;
170        } else if (props[1] === 'sourceMappingURL') {
171          const mapName = normalizedFilename + '.map';
172          return `//# ${props[1]}=` + mapName;
173        }
174      }
175      return '';
176    });
177  }
178  return source;
179}
180
181export function getHtmlFiles({ manifest }: { manifest: any }): string[] {
182  const htmlFiles = new Set<string>();
183
184  function traverseScreens(screens: string | { screens: any; path: string }, basePath = '') {
185    for (const value of Object.values(screens)) {
186      if (typeof value === 'string') {
187        let filePath = basePath + value;
188        if (value === '') {
189          filePath =
190            basePath === ''
191              ? 'index'
192              : basePath.endsWith('/')
193              ? basePath + 'index'
194              : basePath.slice(0, -1);
195        }
196        // TODO: Dedupe requests for alias routes.
197        addOptionalGroups(filePath);
198      } else if (typeof value === 'object' && value?.screens) {
199        const newPath = basePath + value.path + '/';
200        traverseScreens(value.screens, newPath);
201      }
202    }
203  }
204
205  function addOptionalGroups(path: string) {
206    const variations = getPathVariations(path);
207    for (const variation of variations) {
208      htmlFiles.add(variation);
209    }
210  }
211
212  traverseScreens(manifest.screens);
213
214  return Array.from(htmlFiles).map((value) => {
215    const parts = value.split('/');
216    // Replace `:foo` with `[foo]` and `*foo` with `[...foo]`
217    const partsWithGroups = parts.map((part) => {
218      if (part.startsWith(':')) {
219        return `[${part.slice(1)}]`;
220      } else if (part.startsWith('*')) {
221        return `[...${part.slice(1)}]`;
222      }
223      return part;
224    });
225    return partsWithGroups.join('/') + '.html';
226  });
227}
228
229// Given a route like `(foo)/bar/(baz)`, return all possible variations of the route.
230// e.g. `(foo)/bar/(baz)`, `(foo)/bar/baz`, `foo/bar/(baz)`, `foo/bar/baz`,
231export function getPathVariations(routePath: string): string[] {
232  const variations = new Set<string>([routePath]);
233  const segments = routePath.split('/');
234
235  function generateVariations(segments: string[], index: number): void {
236    if (index >= segments.length) {
237      return;
238    }
239
240    const newSegments = [...segments];
241    while (
242      index < newSegments.length &&
243      matchGroupName(newSegments[index]) &&
244      newSegments.length > 1
245    ) {
246      newSegments.splice(index, 1);
247      variations.add(newSegments.join('/'));
248      generateVariations(newSegments, index + 1);
249    }
250
251    generateVariations(segments, index + 1);
252  }
253
254  generateVariations(segments, 0);
255
256  return Array.from(variations);
257}
258