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    includeGroupVariations,
77  }: {
78    manifest: any;
79    renderAsync: (pathname: string) => Promise<string>;
80    includeGroupVariations?: boolean;
81  }
82): Promise<Map<string, string>> {
83  // name : contents
84  const files = new Map<string, string>();
85
86  await Promise.all(
87    getHtmlFiles({ manifest, includeGroupVariations }).map(async (outputPath) => {
88      const pathname = outputPath.replace(/(?:index)?\.html$/, '');
89      try {
90        files.set(outputPath, '');
91        const data = await renderAsync(pathname);
92        files.set(outputPath, data);
93      } catch (e: any) {
94        await logMetroErrorAsync({ error: e, projectRoot });
95        throw new Error('Failed to statically export route: ' + pathname);
96      }
97    })
98  );
99
100  return files;
101}
102
103/** Perform all fs commits */
104export async function exportFromServerAsync(
105  projectRoot: string,
106  devServerManager: DevServerManager,
107  { outputDir, basePath, exportServer, minify, includeMaps }: Options
108): Promise<void> {
109  const { exp } = getConfig(projectRoot, { skipSDKVersionRequirement: true });
110  const appDir = getRouterDirectoryWithManifest(projectRoot, exp);
111
112  const injectFaviconTag = await getVirtualFaviconAssetsAsync(projectRoot, { outputDir, basePath });
113
114  const devServer = devServerManager.getDefaultDevServer();
115  assert(devServer instanceof MetroBundlerDevServer);
116
117  const [resources, { manifest, renderAsync }] = await Promise.all([
118    devServer.getStaticResourcesAsync({ mode: 'production', minify, includeMaps }),
119    devServer.getStaticRenderFunctionAsync({
120      mode: 'production',
121      minify,
122    }),
123  ]);
124
125  debug('Routes:\n', inspect(manifest, { colors: true, depth: null }));
126
127  const files = await getFilesToExportFromServerAsync(projectRoot, {
128    manifest,
129    // Servers can handle group routes automatically and therefore
130    // don't require the build-time generation of every possible group
131    // variation.
132    includeGroupVariations: !exportServer,
133    async renderAsync(pathname: string) {
134      const template = await renderAsync(pathname);
135      let html = await devServer.composeResourcesWithHtml({
136        mode: 'production',
137        resources,
138        template,
139        basePath,
140      });
141
142      if (injectFaviconTag) {
143        html = injectFaviconTag(html);
144      }
145
146      return html;
147    },
148  });
149
150  resources.forEach((resource) => {
151    files.set(
152      resource.filename,
153      modifyBundlesWithSourceMaps(resource.filename, resource.source, includeMaps)
154    );
155  });
156
157  if (exportServer) {
158    const apiRoutes = await exportApiRoutesAsync({ outputDir, server: devServer, appDir });
159
160    // Add the api routes to the files to export.
161    for (const [route, contents] of apiRoutes) {
162      files.set(route, contents);
163    }
164  } else {
165    warnPossibleInvalidExportType(appDir);
166  }
167
168  fs.mkdirSync(path.join(outputDir), { recursive: true });
169
170  Log.log('');
171  Log.log(chalk.bold`Exporting ${files.size} files:`);
172  await Promise.all(
173    [...files.entries()]
174      .sort(([a], [b]) => a.localeCompare(b))
175      .map(async ([file, contents]) => {
176        const length = Buffer.byteLength(contents, 'utf8');
177        Log.log(file, chalk.gray`(${prettyBytes(length)})`);
178        const outputPath = path.join(outputDir, file);
179        await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
180        await fs.promises.writeFile(outputPath, contents);
181      })
182  );
183  Log.log('');
184}
185
186export function modifyBundlesWithSourceMaps(
187  filename: string,
188  source: string,
189  includeMaps: boolean
190): string {
191  if (filename.endsWith('.js')) {
192    // If the bundle ends with source map URLs then update them to point to the correct location.
193
194    // TODO: basePath support
195    const normalizedFilename = '/' + filename.replace(/^\/+/, '');
196    //# sourceMappingURL=//localhost:8085/index.map?platform=web&dev=false&hot=false&lazy=true&minify=true&resolver.environment=client&transform.environment=client&serializer.output=static
197    //# 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
198    return source.replace(/^\/\/# (sourceMappingURL|sourceURL)=.*$/gm, (...props) => {
199      if (includeMaps) {
200        if (props[1] === 'sourceURL') {
201          return `//# ${props[1]}=` + normalizedFilename;
202        } else if (props[1] === 'sourceMappingURL') {
203          const mapName = normalizedFilename + '.map';
204          return `//# ${props[1]}=` + mapName;
205        }
206      }
207      return '';
208    });
209  }
210  return source;
211}
212
213export function getHtmlFiles({
214  manifest,
215  includeGroupVariations,
216}: {
217  manifest: any;
218  includeGroupVariations?: boolean;
219}): string[] {
220  const htmlFiles = new Set<string>();
221
222  function traverseScreens(screens: string | { screens: any; path: string }, basePath = '') {
223    for (const value of Object.values(screens)) {
224      if (typeof value === 'string') {
225        let filePath = basePath + value;
226        if (value === '') {
227          filePath =
228            basePath === ''
229              ? 'index'
230              : basePath.endsWith('/')
231              ? basePath + 'index'
232              : basePath.slice(0, -1);
233        }
234        if (includeGroupVariations) {
235          // TODO: Dedupe requests for alias routes.
236          addOptionalGroups(filePath);
237        } else {
238          htmlFiles.add(filePath);
239        }
240      } else if (typeof value === 'object' && value?.screens) {
241        const newPath = basePath + value.path + '/';
242        traverseScreens(value.screens, newPath);
243      }
244    }
245  }
246
247  function addOptionalGroups(path: string) {
248    const variations = getPathVariations(path);
249    for (const variation of variations) {
250      htmlFiles.add(variation);
251    }
252  }
253
254  traverseScreens(manifest.screens);
255
256  return Array.from(htmlFiles).map((value) => {
257    const parts = value.split('/');
258    // Replace `:foo` with `[foo]` and `*foo` with `[...foo]`
259    const partsWithGroups = parts.map((part) => {
260      if (part.startsWith(':')) {
261        return `[${part.slice(1)}]`;
262      } else if (part.startsWith('*')) {
263        return `[...${part.slice(1)}]`;
264      }
265      return part;
266    });
267    return partsWithGroups.join('/') + '.html';
268  });
269}
270
271// Given a route like `(foo)/bar/(baz)`, return all possible variations of the route.
272// e.g. `(foo)/bar/(baz)`, `(foo)/bar/baz`, `foo/bar/(baz)`, `foo/bar/baz`,
273export function getPathVariations(routePath: string): string[] {
274  const variations = new Set<string>([routePath]);
275  const segments = routePath.split('/');
276
277  function generateVariations(segments: string[], index: number): void {
278    if (index >= segments.length) {
279      return;
280    }
281
282    const newSegments = [...segments];
283    while (
284      index < newSegments.length &&
285      matchGroupName(newSegments[index]) &&
286      newSegments.length > 1
287    ) {
288      newSegments.splice(index, 1);
289      variations.add(newSegments.join('/'));
290      generateVariations(newSegments, index + 1);
291    }
292
293    generateVariations(segments, index + 1);
294  }
295
296  generateVariations(segments, 0);
297
298  return Array.from(variations);
299}
300
301async function exportApiRoutesAsync({
302  outputDir,
303  server,
304  appDir,
305}: {
306  outputDir: string;
307  server: MetroBundlerDevServer;
308  appDir: string;
309}): Promise<Map<string, string>> {
310  const funcDir = path.join(outputDir, '_expo/functions');
311  fs.mkdirSync(path.join(funcDir), { recursive: true });
312
313  const [manifest, files] = await Promise.all([
314    server.getExpoRouterRoutesManifestAsync({
315      appDir,
316    }),
317    server
318      .exportExpoRouterApiRoutesAsync({
319        mode: 'production',
320        appDir,
321      })
322      .then((routes) => {
323        const files = new Map<string, string>();
324        for (const [route, contents] of routes) {
325          files.set(path.join('_expo/functions', route), contents);
326        }
327        return files;
328      }),
329  ]);
330
331  Log.log(chalk.bold`Exporting ${files.size} API Routes.`);
332
333  files.set('_expo/routes.json', JSON.stringify(manifest, null, 2));
334
335  return files;
336}
337
338function warnPossibleInvalidExportType(appDir: string) {
339  const apiRoutes = getApiRoutesForDirectory(appDir);
340  if (apiRoutes.length) {
341    // TODO: Allow API Routes for native-only.
342    Log.warn(
343      chalk.yellow`Skipping export for API routes because \`web.output\` is not "server". You may want to remove the routes: ${apiRoutes
344        .map((v) => path.relative(appDir, v))
345        .join(', ')}`
346    );
347  }
348}
349