10a6ddb20SEvan Bacon/**
20a6ddb20SEvan Bacon * Copyright © 2022 650 Industries.
30a6ddb20SEvan Bacon *
40a6ddb20SEvan Bacon * This source code is licensed under the MIT license found in the
50a6ddb20SEvan Bacon * LICENSE file in the root directory of this source tree.
60a6ddb20SEvan Bacon */
746f023faSEvan Baconimport { getConfig } from '@expo/config';
80a6ddb20SEvan Baconimport assert from 'assert';
90a6ddb20SEvan Baconimport chalk from 'chalk';
100a6ddb20SEvan Baconimport fs from 'fs';
110a6ddb20SEvan Baconimport path from 'path';
120a6ddb20SEvan Baconimport prettyBytes from 'pretty-bytes';
130a6ddb20SEvan Baconimport { inspect } from 'util';
140a6ddb20SEvan Bacon
158a424bebSJames Ideimport { getVirtualFaviconAssetsAsync } from './favicon';
160a6ddb20SEvan Baconimport { Log } from '../log';
170a6ddb20SEvan Baconimport { DevServerManager } from '../start/server/DevServerManager';
180a6ddb20SEvan Baconimport { MetroBundlerDevServer } from '../start/server/metro/MetroBundlerDevServer';
1985531d53SEvan Baconimport { logMetroErrorAsync } from '../start/server/metro/metroErrorInterface';
2046f023faSEvan Baconimport {
2146f023faSEvan Bacon  getApiRoutesForDirectory,
2246f023faSEvan Bacon  getRouterDirectoryWithManifest,
2346f023faSEvan Bacon} from '../start/server/metro/router';
249a348a4eSEvan Baconimport { learnMore } from '../utils/link';
250a6ddb20SEvan Bacon
260a6ddb20SEvan Baconconst debug = require('debug')('expo:export:generateStaticRoutes') as typeof console.log;
270a6ddb20SEvan Bacon
2846f023faSEvan Bacontype Options = {
2946f023faSEvan Bacon  outputDir: string;
3046f023faSEvan Bacon  minify: boolean;
3146f023faSEvan Bacon  exportServer: boolean;
3246f023faSEvan Bacon  basePath: string;
3346f023faSEvan Bacon  includeMaps: boolean;
3446f023faSEvan Bacon};
350a6ddb20SEvan Bacon
360a6ddb20SEvan Bacon/** @private */
370a6ddb20SEvan Baconexport async function unstable_exportStaticAsync(projectRoot: string, options: Options) {
389a348a4eSEvan Bacon  Log.warn(
399a348a4eSEvan Bacon    `Experimental static rendering is enabled. ` +
409a348a4eSEvan Bacon      learnMore('https://docs.expo.dev/router/reference/static-rendering/')
419a348a4eSEvan Bacon  );
420a6ddb20SEvan Bacon
437179edeaSEvan Bacon  // TODO: Prevent starting the watcher.
440a6ddb20SEvan Bacon  const devServerManager = new DevServerManager(projectRoot, {
450a6ddb20SEvan Bacon    minify: options.minify,
460a6ddb20SEvan Bacon    mode: 'production',
470a6ddb20SEvan Bacon    location: {},
480a6ddb20SEvan Bacon  });
490a6ddb20SEvan Bacon  await devServerManager.startAsync([
500a6ddb20SEvan Bacon    {
510a6ddb20SEvan Bacon      type: 'metro',
52429dc7fcSEvan Bacon      options: {
53429dc7fcSEvan Bacon        location: {},
54429dc7fcSEvan Bacon        isExporting: true,
55429dc7fcSEvan Bacon      },
560a6ddb20SEvan Bacon    },
570a6ddb20SEvan Bacon  ]);
580a6ddb20SEvan Bacon
595b5e713eSEvan Bacon  try {
6085531d53SEvan Bacon    await exportFromServerAsync(projectRoot, devServerManager, options);
615b5e713eSEvan Bacon  } finally {
620a6ddb20SEvan Bacon    await devServerManager.stopAsync();
630a6ddb20SEvan Bacon  }
645b5e713eSEvan Bacon}
650a6ddb20SEvan Bacon
660a6ddb20SEvan Bacon/** Match `(page)` -> `page` */
670a6ddb20SEvan Baconfunction matchGroupName(name: string): string | undefined {
680a6ddb20SEvan Bacon  return name.match(/^\(([^/]+?)\)$/)?.[1];
690a6ddb20SEvan Bacon}
700a6ddb20SEvan Bacon
7185531d53SEvan Baconexport async function getFilesToExportFromServerAsync(
7285531d53SEvan Bacon  projectRoot: string,
7385531d53SEvan Bacon  {
740a6ddb20SEvan Bacon    manifest,
750a6ddb20SEvan Bacon    renderAsync,
76e015d41cSEvan Bacon    includeGroupVariations,
770a6ddb20SEvan Bacon  }: {
780a6ddb20SEvan Bacon    manifest: any;
799580591fSEvan Bacon    renderAsync: (pathname: string) => Promise<string>;
80e015d41cSEvan Bacon    includeGroupVariations?: boolean;
8185531d53SEvan Bacon  }
8285531d53SEvan Bacon): Promise<Map<string, string>> {
830a6ddb20SEvan Bacon  // name : contents
840a6ddb20SEvan Bacon  const files = new Map<string, string>();
850a6ddb20SEvan Bacon
869580591fSEvan Bacon  await Promise.all(
87e015d41cSEvan Bacon    getHtmlFiles({ manifest, includeGroupVariations }).map(async (outputPath) => {
887179edeaSEvan Bacon      const pathname = outputPath.replace(/(?:index)?\.html$/, '');
890a6ddb20SEvan Bacon      try {
909580591fSEvan Bacon        files.set(outputPath, '');
910a6ddb20SEvan Bacon        const data = await renderAsync(pathname);
929580591fSEvan Bacon        files.set(outputPath, data);
930a6ddb20SEvan Bacon      } catch (e: any) {
9485531d53SEvan Bacon        await logMetroErrorAsync({ error: e, projectRoot });
9585531d53SEvan Bacon        throw new Error('Failed to statically export route: ' + pathname);
960a6ddb20SEvan Bacon      }
979580591fSEvan Bacon    })
980a6ddb20SEvan Bacon  );
990a6ddb20SEvan Bacon
1000a6ddb20SEvan Bacon  return files;
1010a6ddb20SEvan Bacon}
1020a6ddb20SEvan Bacon
1030a6ddb20SEvan Bacon/** Perform all fs commits */
1040a6ddb20SEvan Baconexport async function exportFromServerAsync(
10585531d53SEvan Bacon  projectRoot: string,
1060a6ddb20SEvan Bacon  devServerManager: DevServerManager,
10746f023faSEvan Bacon  { outputDir, basePath, exportServer, minify, includeMaps }: Options
1080a6ddb20SEvan Bacon): Promise<void> {
10946f023faSEvan Bacon  const { exp } = getConfig(projectRoot, { skipSDKVersionRequirement: true });
11046f023faSEvan Bacon  const appDir = getRouterDirectoryWithManifest(projectRoot, exp);
11146f023faSEvan Bacon
11246f023faSEvan Bacon  const injectFaviconTag = await getVirtualFaviconAssetsAsync(projectRoot, { outputDir, basePath });
11342637653SEvan Bacon
1140a6ddb20SEvan Bacon  const devServer = devServerManager.getDefaultDevServer();
1159580591fSEvan Bacon  assert(devServer instanceof MetroBundlerDevServer);
1160a6ddb20SEvan Bacon
1177179edeaSEvan Bacon  const [resources, { manifest, renderAsync }] = await Promise.all([
118573b0ea7SEvan Bacon    devServer.getStaticResourcesAsync({ mode: 'production', minify, includeMaps }),
1199580591fSEvan Bacon    devServer.getStaticRenderFunctionAsync({
1209580591fSEvan Bacon      mode: 'production',
1211a3d836eSEvan Bacon      minify,
1229580591fSEvan Bacon    }),
1239580591fSEvan Bacon  ]);
1240a6ddb20SEvan Bacon
1250a6ddb20SEvan Bacon  debug('Routes:\n', inspect(manifest, { colors: true, depth: null }));
1260a6ddb20SEvan Bacon
12785531d53SEvan Bacon  const files = await getFilesToExportFromServerAsync(projectRoot, {
1280a6ddb20SEvan Bacon    manifest,
129e015d41cSEvan Bacon    // Servers can handle group routes automatically and therefore
130e015d41cSEvan Bacon    // don't require the build-time generation of every possible group
131e015d41cSEvan Bacon    // variation.
132e015d41cSEvan Bacon    includeGroupVariations: !exportServer,
1339580591fSEvan Bacon    async renderAsync(pathname: string) {
1349580591fSEvan Bacon      const template = await renderAsync(pathname);
13542637653SEvan Bacon      let html = await devServer.composeResourcesWithHtml({
1369580591fSEvan Bacon        mode: 'production',
1379580591fSEvan Bacon        resources,
1389580591fSEvan Bacon        template,
1397c98c357SEvan Bacon        basePath,
1409580591fSEvan Bacon      });
14142637653SEvan Bacon
14242637653SEvan Bacon      if (injectFaviconTag) {
14342637653SEvan Bacon        html = injectFaviconTag(html);
14442637653SEvan Bacon      }
14542637653SEvan Bacon
14642637653SEvan Bacon      return html;
1470a6ddb20SEvan Bacon    },
1480a6ddb20SEvan Bacon  });
1490a6ddb20SEvan Bacon
1509580591fSEvan Bacon  resources.forEach((resource) => {
151573b0ea7SEvan Bacon    files.set(
152573b0ea7SEvan Bacon      resource.filename,
153573b0ea7SEvan Bacon      modifyBundlesWithSourceMaps(resource.filename, resource.source, includeMaps)
154573b0ea7SEvan Bacon    );
1559580591fSEvan Bacon  });
1569580591fSEvan Bacon
15746f023faSEvan Bacon  if (exportServer) {
15846f023faSEvan Bacon    const apiRoutes = await exportApiRoutesAsync({ outputDir, server: devServer, appDir });
15946f023faSEvan Bacon
16046f023faSEvan Bacon    // Add the api routes to the files to export.
16146f023faSEvan Bacon    for (const [route, contents] of apiRoutes) {
16246f023faSEvan Bacon      files.set(route, contents);
16346f023faSEvan Bacon    }
16446f023faSEvan Bacon  } else {
16546f023faSEvan Bacon    warnPossibleInvalidExportType(appDir);
16646f023faSEvan Bacon  }
16746f023faSEvan Bacon
1680a6ddb20SEvan Bacon  fs.mkdirSync(path.join(outputDir), { recursive: true });
1690a6ddb20SEvan Bacon
1709580591fSEvan Bacon  Log.log('');
1719580591fSEvan Bacon  Log.log(chalk.bold`Exporting ${files.size} files:`);
1720a6ddb20SEvan Bacon  await Promise.all(
1730a6ddb20SEvan Bacon    [...files.entries()]
1740a6ddb20SEvan Bacon      .sort(([a], [b]) => a.localeCompare(b))
1750a6ddb20SEvan Bacon      .map(async ([file, contents]) => {
1760a6ddb20SEvan Bacon        const length = Buffer.byteLength(contents, 'utf8');
1770a6ddb20SEvan Bacon        Log.log(file, chalk.gray`(${prettyBytes(length)})`);
1780a6ddb20SEvan Bacon        const outputPath = path.join(outputDir, file);
1790a6ddb20SEvan Bacon        await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
1800a6ddb20SEvan Bacon        await fs.promises.writeFile(outputPath, contents);
1810a6ddb20SEvan Bacon      })
1820a6ddb20SEvan Bacon  );
1839580591fSEvan Bacon  Log.log('');
1849580591fSEvan Bacon}
1859580591fSEvan Bacon
186573b0ea7SEvan Baconexport function modifyBundlesWithSourceMaps(
187573b0ea7SEvan Bacon  filename: string,
188573b0ea7SEvan Bacon  source: string,
189573b0ea7SEvan Bacon  includeMaps: boolean
190573b0ea7SEvan Bacon): string {
191573b0ea7SEvan Bacon  if (filename.endsWith('.js')) {
192573b0ea7SEvan Bacon    // If the bundle ends with source map URLs then update them to point to the correct location.
193573b0ea7SEvan Bacon
194573b0ea7SEvan Bacon    // TODO: basePath support
195573b0ea7SEvan Bacon    const normalizedFilename = '/' + filename.replace(/^\/+/, '');
196573b0ea7SEvan Bacon    //# sourceMappingURL=//localhost:8085/index.map?platform=web&dev=false&hot=false&lazy=true&minify=true&resolver.environment=client&transform.environment=client&serializer.output=static
197573b0ea7SEvan Bacon    //# 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
198573b0ea7SEvan Bacon    return source.replace(/^\/\/# (sourceMappingURL|sourceURL)=.*$/gm, (...props) => {
199573b0ea7SEvan Bacon      if (includeMaps) {
200573b0ea7SEvan Bacon        if (props[1] === 'sourceURL') {
201573b0ea7SEvan Bacon          return `//# ${props[1]}=` + normalizedFilename;
202573b0ea7SEvan Bacon        } else if (props[1] === 'sourceMappingURL') {
203573b0ea7SEvan Bacon          const mapName = normalizedFilename + '.map';
204573b0ea7SEvan Bacon          return `//# ${props[1]}=` + mapName;
205573b0ea7SEvan Bacon        }
206573b0ea7SEvan Bacon      }
207573b0ea7SEvan Bacon      return '';
208573b0ea7SEvan Bacon    });
209573b0ea7SEvan Bacon  }
210573b0ea7SEvan Bacon  return source;
211573b0ea7SEvan Bacon}
212573b0ea7SEvan Bacon
213e015d41cSEvan Baconexport function getHtmlFiles({
214e015d41cSEvan Bacon  manifest,
215e015d41cSEvan Bacon  includeGroupVariations,
216e015d41cSEvan Bacon}: {
217e015d41cSEvan Bacon  manifest: any;
218e015d41cSEvan Bacon  includeGroupVariations?: boolean;
219e015d41cSEvan Bacon}): string[] {
2209580591fSEvan Bacon  const htmlFiles = new Set<string>();
2219580591fSEvan Bacon
2229580591fSEvan Bacon  function traverseScreens(screens: string | { screens: any; path: string }, basePath = '') {
2239580591fSEvan Bacon    for (const value of Object.values(screens)) {
2249580591fSEvan Bacon      if (typeof value === 'string') {
2259580591fSEvan Bacon        let filePath = basePath + value;
2269580591fSEvan Bacon        if (value === '') {
2279580591fSEvan Bacon          filePath =
2289580591fSEvan Bacon            basePath === ''
2299580591fSEvan Bacon              ? 'index'
2309580591fSEvan Bacon              : basePath.endsWith('/')
2319580591fSEvan Bacon              ? basePath + 'index'
2329580591fSEvan Bacon              : basePath.slice(0, -1);
2339580591fSEvan Bacon        }
234e015d41cSEvan Bacon        if (includeGroupVariations) {
2359580591fSEvan Bacon          // TODO: Dedupe requests for alias routes.
2369580591fSEvan Bacon          addOptionalGroups(filePath);
237e015d41cSEvan Bacon        } else {
238e015d41cSEvan Bacon          htmlFiles.add(filePath);
239e015d41cSEvan Bacon        }
2409580591fSEvan Bacon      } else if (typeof value === 'object' && value?.screens) {
2419580591fSEvan Bacon        const newPath = basePath + value.path + '/';
2429580591fSEvan Bacon        traverseScreens(value.screens, newPath);
2439580591fSEvan Bacon      }
2449580591fSEvan Bacon    }
2459580591fSEvan Bacon  }
2469580591fSEvan Bacon
2479580591fSEvan Bacon  function addOptionalGroups(path: string) {
2489580591fSEvan Bacon    const variations = getPathVariations(path);
2499580591fSEvan Bacon    for (const variation of variations) {
2509580591fSEvan Bacon      htmlFiles.add(variation);
2519580591fSEvan Bacon    }
2529580591fSEvan Bacon  }
2539580591fSEvan Bacon
2549580591fSEvan Bacon  traverseScreens(manifest.screens);
2559580591fSEvan Bacon
2569580591fSEvan Bacon  return Array.from(htmlFiles).map((value) => {
2579580591fSEvan Bacon    const parts = value.split('/');
2589580591fSEvan Bacon    // Replace `:foo` with `[foo]` and `*foo` with `[...foo]`
2599580591fSEvan Bacon    const partsWithGroups = parts.map((part) => {
2609580591fSEvan Bacon      if (part.startsWith(':')) {
2619580591fSEvan Bacon        return `[${part.slice(1)}]`;
2629580591fSEvan Bacon      } else if (part.startsWith('*')) {
2639580591fSEvan Bacon        return `[...${part.slice(1)}]`;
2649580591fSEvan Bacon      }
2659580591fSEvan Bacon      return part;
2669580591fSEvan Bacon    });
2679580591fSEvan Bacon    return partsWithGroups.join('/') + '.html';
2689580591fSEvan Bacon  });
2699580591fSEvan Bacon}
2709580591fSEvan Bacon
2719580591fSEvan Bacon// Given a route like `(foo)/bar/(baz)`, return all possible variations of the route.
2729580591fSEvan Bacon// e.g. `(foo)/bar/(baz)`, `(foo)/bar/baz`, `foo/bar/(baz)`, `foo/bar/baz`,
2739580591fSEvan Baconexport function getPathVariations(routePath: string): string[] {
274*34a1b52dSMark Lawlor  const variations = new Set<string>();
2759580591fSEvan Bacon  const segments = routePath.split('/');
2769580591fSEvan Bacon
277*34a1b52dSMark Lawlor  function generateVariations(segments: string[], current = ''): void {
278*34a1b52dSMark Lawlor    if (segments.length === 0) {
279*34a1b52dSMark Lawlor      if (current) variations.add(current);
2809580591fSEvan Bacon      return;
2819580591fSEvan Bacon    }
2829580591fSEvan Bacon
283*34a1b52dSMark Lawlor    const [head, ...rest] = segments;
284*34a1b52dSMark Lawlor
285*34a1b52dSMark Lawlor    if (head.startsWith('(foo,foo')) {
2869580591fSEvan Bacon    }
2879580591fSEvan Bacon
288*34a1b52dSMark Lawlor    if (matchGroupName(head)) {
289*34a1b52dSMark Lawlor      const groups = head.slice(1, -1).split(',');
290*34a1b52dSMark Lawlor
291*34a1b52dSMark Lawlor      if (groups.length > 1) {
292*34a1b52dSMark Lawlor        for (const group of groups) {
293*34a1b52dSMark Lawlor          // If there are multiple groups, recurse on each group.
294*34a1b52dSMark Lawlor          generateVariations([`(${group.trim()})`, ...rest], current);
295*34a1b52dSMark Lawlor        }
296*34a1b52dSMark Lawlor        return;
297*34a1b52dSMark Lawlor      } else {
298*34a1b52dSMark Lawlor        // Start a fork where this group is included
299*34a1b52dSMark Lawlor        generateVariations(rest, current ? `${current}/(${groups[0]})` : `(${groups[0]})`);
300*34a1b52dSMark Lawlor        // This code will continue and add paths without this group included`
301*34a1b52dSMark Lawlor      }
302*34a1b52dSMark Lawlor    } else if (current) {
303*34a1b52dSMark Lawlor      current = `${current}/${head}`;
304*34a1b52dSMark Lawlor    } else {
305*34a1b52dSMark Lawlor      current = head;
3069580591fSEvan Bacon    }
3079580591fSEvan Bacon
308*34a1b52dSMark Lawlor    generateVariations(rest, current);
309*34a1b52dSMark Lawlor  }
310*34a1b52dSMark Lawlor
311*34a1b52dSMark Lawlor  generateVariations(segments);
3129580591fSEvan Bacon
3139580591fSEvan Bacon  return Array.from(variations);
3140a6ddb20SEvan Bacon}
31546f023faSEvan Bacon
31646f023faSEvan Baconasync function exportApiRoutesAsync({
31746f023faSEvan Bacon  outputDir,
31846f023faSEvan Bacon  server,
31946f023faSEvan Bacon  appDir,
32046f023faSEvan Bacon}: {
32146f023faSEvan Bacon  outputDir: string;
32246f023faSEvan Bacon  server: MetroBundlerDevServer;
32346f023faSEvan Bacon  appDir: string;
32446f023faSEvan Bacon}): Promise<Map<string, string>> {
32546f023faSEvan Bacon  const funcDir = path.join(outputDir, '_expo/functions');
32646f023faSEvan Bacon  fs.mkdirSync(path.join(funcDir), { recursive: true });
32746f023faSEvan Bacon
32846f023faSEvan Bacon  const [manifest, files] = await Promise.all([
32946f023faSEvan Bacon    server.getExpoRouterRoutesManifestAsync({
33046f023faSEvan Bacon      appDir,
33146f023faSEvan Bacon    }),
33246f023faSEvan Bacon    server
33346f023faSEvan Bacon      .exportExpoRouterApiRoutesAsync({
33446f023faSEvan Bacon        mode: 'production',
33546f023faSEvan Bacon        appDir,
33646f023faSEvan Bacon      })
33746f023faSEvan Bacon      .then((routes) => {
33846f023faSEvan Bacon        const files = new Map<string, string>();
33946f023faSEvan Bacon        for (const [route, contents] of routes) {
34046f023faSEvan Bacon          files.set(path.join('_expo/functions', route), contents);
34146f023faSEvan Bacon        }
34246f023faSEvan Bacon        return files;
34346f023faSEvan Bacon      }),
34446f023faSEvan Bacon  ]);
34546f023faSEvan Bacon
34646f023faSEvan Bacon  Log.log(chalk.bold`Exporting ${files.size} API Routes.`);
34746f023faSEvan Bacon
34846f023faSEvan Bacon  files.set('_expo/routes.json', JSON.stringify(manifest, null, 2));
34946f023faSEvan Bacon
35046f023faSEvan Bacon  return files;
35146f023faSEvan Bacon}
35246f023faSEvan Bacon
35346f023faSEvan Baconfunction warnPossibleInvalidExportType(appDir: string) {
35446f023faSEvan Bacon  const apiRoutes = getApiRoutesForDirectory(appDir);
35546f023faSEvan Bacon  if (apiRoutes.length) {
35646f023faSEvan Bacon    // TODO: Allow API Routes for native-only.
35746f023faSEvan Bacon    Log.warn(
35846f023faSEvan Bacon      chalk.yellow`Skipping export for API routes because \`web.output\` is not "server". You may want to remove the routes: ${apiRoutes
35946f023faSEvan Bacon        .map((v) => path.relative(appDir, v))
36046f023faSEvan Bacon        .join(', ')}`
36146f023faSEvan Bacon    );
36246f023faSEvan Bacon  }
36346f023faSEvan Bacon}
364