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