From 0a6ddb200d4265a18e38ccccf1640a0ddc1af15c Mon Sep 17 00:00:00 2001 From: Evan Bacon Date: Mon, 13 Mar 2023 10:57:58 -0500 Subject: [PATCH] feat(cli): add basic static rendering for router projects (#21572) # Why - Implement an experimental static rendering system for Metro websites using Expo Router. - Behavior is undocumented and highly experimental. # How - Add support to `start` and `export` which pre-renders static web pages to HTML to improve SEO support on web. - The system implements [React Navigation's SSR](https://reactnavigation.org/docs/server-rendering) support. - Head elements can be used with `import { Head } from 'expo-router/head'`. - The root HTML is not exposed to the user. - There are no data fetching mechanisms. - There's no ability to provide a 404 page or other server features. # Test Plan - e2e test for exporting a router project statically. - `EXPO_USE_STATIC=1 yarn expo` -> websites are pre-rendered before being served. - `EXPO_USE_STATIC=1 yarn expo export -p web` -> static routes are rendered to static HTML files by the same name (dynamic routes are not supported). # Checklist - [ ] Documentation is up to date to reflect these changes (eg: https://docs.expo.dev and README.md). - [ ] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) - [ ] This diff will work correctly for `expo prebuild` & EAS Build (eg: updated a module plugin). --------- Co-authored-by: Expo Bot <34669131+expo-bot@users.noreply.github.com> --- .gitignore | 1 + packages/@expo/cli/CHANGELOG.md | 1 + .../__snapshots__/export-test.ts.snap | 261 ++++++++++++ .../@expo/cli/e2e/__tests__/config-test.ts | 40 +- .../@expo/cli/e2e/__tests__/export-test.ts | 395 +++++++++++------- .../@expo/cli/e2e/__tests__/start-test.ts | 166 ++++---- packages/@expo/cli/e2e/__tests__/utils.ts | 27 +- .../cli/e2e/fixtures/with-router/app.json | 14 + .../e2e/fixtures/with-router/app/_layout.js | 13 + .../cli/e2e/fixtures/with-router/app/about.js | 15 + .../cli/e2e/fixtures/with-router/app/index.js | 6 + .../e2e/fixtures/with-router/babel.config.js | 7 + .../cli/e2e/fixtures/with-router/index.js | 1 + .../cli/e2e/fixtures/with-router/package.json | 22 + .../fixtures/with-router/public/favicon.ico | 0 packages/@expo/cli/package.json | 5 +- .../__tests__/exportStaticAsync.test.ts | 41 ++ .../@expo/cli/src/export/createBundles.ts | 7 +- packages/@expo/cli/src/export/exportApp.ts | 41 +- .../@expo/cli/src/export/exportStaticAsync.ts | 195 +++++++++ .../cli/src/start/server/BundlerDevServer.ts | 2 +- .../start/server/getStaticRenderFunctions.ts | 135 ++++++ .../server/metro/MetroBundlerDevServer.ts | 112 ++++- .../start/server/metro/instantiateMetro.ts | 4 +- .../server/metro/withMetroMultiPlatform.ts | 5 + .../server/middleware/ManifestMiddleware.ts | 47 ++- .../server/webpack/WebpackBundlerDevServer.ts | 2 +- packages/@expo/cli/src/utils/env.ts | 7 + yarn.lock | 30 +- 29 files changed, 1316 insertions(+), 286 deletions(-) create mode 100644 packages/@expo/cli/e2e/__tests__/__snapshots__/export-test.ts.snap create mode 100644 packages/@expo/cli/e2e/fixtures/with-router/app.json create mode 100644 packages/@expo/cli/e2e/fixtures/with-router/app/_layout.js create mode 100644 packages/@expo/cli/e2e/fixtures/with-router/app/about.js create mode 100644 packages/@expo/cli/e2e/fixtures/with-router/app/index.js create mode 100644 packages/@expo/cli/e2e/fixtures/with-router/babel.config.js create mode 100644 packages/@expo/cli/e2e/fixtures/with-router/index.js create mode 100644 packages/@expo/cli/e2e/fixtures/with-router/package.json create mode 100644 packages/@expo/cli/e2e/fixtures/with-router/public/favicon.ico create mode 100644 packages/@expo/cli/src/export/__tests__/exportStaticAsync.test.ts create mode 100644 packages/@expo/cli/src/export/exportStaticAsync.ts create mode 100644 packages/@expo/cli/src/start/server/getStaticRenderFunctions.ts diff --git a/.gitignore b/.gitignore index 1e702d229e3cfd..bc8e45b6aafa54 100644 --- a/.gitignore +++ b/.gitignore @@ -96,6 +96,7 @@ templates/**/android/app/build templates/**/Pods/** templates/**/Podfile.lock templates/**/yarn.lock +templates/**/*.tgz # EYW template /packages/expo-yarn-workspaces/workspace-template/yarn.lock diff --git a/packages/@expo/cli/CHANGELOG.md b/packages/@expo/cli/CHANGELOG.md index 4a926966b4f827..f4c29eb4f0add9 100644 --- a/packages/@expo/cli/CHANGELOG.md +++ b/packages/@expo/cli/CHANGELOG.md @@ -15,6 +15,7 @@ - Reduce install prompt. ([#21264](https://github.com/expo/expo/pull/21264) by [@EvanBacon](https://github.com/EvanBacon)) - Improve multi-target iOS scheme resolution for `expo run:ios`. ([#21240](https://github.com/expo/expo/pull/21240) by [@EvanBacon](https://github.com/EvanBacon)) - Added experimental react-devtools integration. ([#21462](https://github.com/expo/expo/pull/21462) by [@kudo](https://github.com/kudo)) +- Add experimental static rendering for Metro web in Expo Router. ([#21572](https://github.com/expo/expo/pull/21572) by [@EvanBacon](https://github.com/EvanBacon)) - Add experimental inspector proxy to handle more CDP requests. ([#21449](https://github.com/expo/expo/pull/21449) by [@byCedric](https://github.com/byCedric)) - Add inspector proxy workarounds for known issues with vscode debugger and Hermes CDP messages. ([#21560](https://github.com/expo/expo/pull/21560) by [@byCedric](https://github.com/byCedric)) diff --git a/packages/@expo/cli/e2e/__tests__/__snapshots__/export-test.ts.snap b/packages/@expo/cli/e2e/__tests__/__snapshots__/export-test.ts.snap new file mode 100644 index 00000000000000..2647f1bdf0301a --- /dev/null +++ b/packages/@expo/cli/e2e/__tests__/__snapshots__/export-test.ts.snap @@ -0,0 +1,261 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`server runs \`npx expo export -p web\` for static rendering 1`] = ` +"About | Website
About

about

;
" +`; diff --git a/packages/@expo/cli/e2e/__tests__/config-test.ts b/packages/@expo/cli/e2e/__tests__/config-test.ts index b73e53241e2f38..bf7a0a64a2c35a 100644 --- a/packages/@expo/cli/e2e/__tests__/config-test.ts +++ b/packages/@expo/cli/e2e/__tests__/config-test.ts @@ -49,26 +49,30 @@ it('runs `npx expo config --help`', async () => { `); }); -it('runs `npx expo config --json`', async () => { - const projectName = 'basic-config'; - const projectRoot = getRoot(projectName); - // Create the project root aot - await fs.mkdir(projectRoot, { recursive: true }); - // Create a fake package.json -- this is a terminal file that cannot be overwritten. - await fs.writeFile(path.join(projectRoot, 'package.json'), '{ "version": "1.0.0" }'); - await fs.writeFile(path.join(projectRoot, 'app.json'), '{ "expo": { "name": "foobar" } }'); +it( + 'runs `npx expo config --json`', + async () => { + const projectName = 'basic-config'; + const projectRoot = getRoot(projectName); + // Create the project root aot + await fs.mkdir(projectRoot, { recursive: true }); + // Create a fake package.json -- this is a terminal file that cannot be overwritten. + await fs.writeFile(path.join(projectRoot, 'package.json'), '{ "version": "1.0.0" }'); + await fs.writeFile(path.join(projectRoot, 'app.json'), '{ "expo": { "name": "foobar" } }'); - const results = await execute('config', projectName, '--json'); - // @ts-ignore - const exp = JSON.parse(results.stdout); + const results = await execute('config', projectName, '--json'); + // @ts-ignore + const exp = JSON.parse(results.stdout); - expect(exp.name).toEqual('foobar'); - expect(exp.slug).toEqual('foobar'); - expect(exp.platforms).toStrictEqual([]); - expect(exp.version).toBe('1.0.0'); - expect(exp._internal.dynamicConfigPath).toBe(null); - expect(exp._internal.staticConfigPath).toMatch(/\/basic-config\/app\.json$/); -}); + expect(exp.name).toEqual('foobar'); + expect(exp.slug).toEqual('foobar'); + expect(exp.platforms).toStrictEqual([]); + expect(exp.version).toBe('1.0.0'); + expect(exp._internal.dynamicConfigPath).toBe(null); + expect(exp._internal.staticConfigPath).toMatch(/\/basic-config\/app\.json$/); + }, // Could take 45s depending on how fast npm installs + 120 * 1000 +); it('throws on invalid project root', async () => { expect.assertions(1); diff --git a/packages/@expo/cli/e2e/__tests__/export-test.ts b/packages/@expo/cli/e2e/__tests__/export-test.ts index 768af7b9e8d0b7..0a1e6cd332db20 100644 --- a/packages/@expo/cli/e2e/__tests__/export-test.ts +++ b/packages/@expo/cli/e2e/__tests__/export-test.ts @@ -5,7 +5,14 @@ import fs from 'fs-extra'; import klawSync from 'klaw-sync'; import path from 'path'; -import { execute, projectRoot, getLoadedModulesAsync, setupTestProjectAsync, bin } from './utils'; +import { + execute, + projectRoot, + getLoadedModulesAsync, + setupTestProjectAsync, + bin, + ensurePortFreeAsync, +} from './utils'; const originalForceColor = process.env.FORCE_COLOR; const originalCI = process.env.CI; @@ -15,6 +22,7 @@ beforeAll(async () => { process.env.FORCE_COLOR = '0'; process.env.CI = '1'; process.env.EXPO_USE_PATH_ALIASES = '1'; + delete process.env.EXPO_USE_STATIC; }); afterAll(() => { @@ -63,157 +71,242 @@ it('runs `npx expo export --help`', async () => { `); }); -it( - 'runs `npx expo export`', - async () => { - const projectRoot = await setupTestProjectAsync('basic-export', 'with-assets'); - // `npx expo export` - await execa('node', [bin, 'export', '--dump-sourcemap', '--dump-assetmap'], { - cwd: projectRoot, - }); - - const outputDir = path.join(projectRoot, 'dist'); - // List output files with sizes for snapshotting. - // This is to make sure that any changes to the output are intentional. - // Posix path formatting is used to make paths the same across OSes. - const files = klawSync(outputDir) - .map((entry) => { - if (entry.path.includes('node_modules') || !entry.stats.isFile()) { - return null; - } - return path.posix.relative(outputDir, entry.path); - }) - .filter(Boolean); - - const metadata = await JsonFile.readAsync(path.resolve(outputDir, 'metadata.json')); - - expect(metadata).toEqual({ - bundler: 'metro', - fileMetadata: { - android: { - assets: [ - { - ext: 'png', - path: 'assets/fb960eb5e4eb49ec8786c7f6c4a57ce2', - }, - { - ext: 'png', - path: 'assets/9ce7db807e4147e00df372d053c154c2', - }, - { - ext: 'ttf', - path: 'assets/3858f62230ac3c915f300c664312c63f', - }, - ], - bundle: expect.stringMatching(/bundles\/android-.*\.js/), +describe('server', () => { + beforeEach(() => ensurePortFreeAsync(19000)); + it( + 'runs `npx expo export`', + async () => { + const projectRoot = await setupTestProjectAsync('basic-export', 'with-assets'); + // `npx expo export` + await execa('node', [bin, 'export', '--dump-sourcemap', '--dump-assetmap'], { + cwd: projectRoot, + }); + + const outputDir = path.join(projectRoot, 'dist'); + // List output files with sizes for snapshotting. + // This is to make sure that any changes to the output are intentional. + // Posix path formatting is used to make paths the same across OSes. + const files = klawSync(outputDir) + .map((entry) => { + if (entry.path.includes('node_modules') || !entry.stats.isFile()) { + return null; + } + return path.posix.relative(outputDir, entry.path); + }) + .filter(Boolean); + + const metadata = await JsonFile.readAsync(path.resolve(outputDir, 'metadata.json')); + + expect(metadata).toEqual({ + bundler: 'metro', + fileMetadata: { + android: { + assets: [ + { + ext: 'png', + path: 'assets/fb960eb5e4eb49ec8786c7f6c4a57ce2', + }, + { + ext: 'png', + path: 'assets/9ce7db807e4147e00df372d053c154c2', + }, + { + ext: 'ttf', + path: 'assets/3858f62230ac3c915f300c664312c63f', + }, + ], + bundle: expect.stringMatching(/bundles\/android-.*\.js/), + }, + ios: { + assets: [ + { + ext: 'png', + path: 'assets/fb960eb5e4eb49ec8786c7f6c4a57ce2', + }, + { + ext: 'png', + path: 'assets/9ce7db807e4147e00df372d053c154c2', + }, + { + ext: 'ttf', + path: 'assets/2f334f6c7ca5b2a504bdf8acdee104f3', + }, + ], + bundle: expect.stringMatching(/bundles\/ios-.*\.js/), + }, + web: { + assets: [ + { + ext: 'png', + path: 'assets/fb960eb5e4eb49ec8786c7f6c4a57ce2', + }, + { + ext: 'png', + path: 'assets/9ce7db807e4147e00df372d053c154c2', + }, + { + ext: 'ttf', + path: 'assets/3858f62230ac3c915f300c664312c63f', + }, + ], + bundle: expect.stringMatching(/bundles\/web-.*\.js/), + }, }, - ios: { - assets: [ - { - ext: 'png', - path: 'assets/fb960eb5e4eb49ec8786c7f6c4a57ce2', - }, - { - ext: 'png', - path: 'assets/9ce7db807e4147e00df372d053c154c2', - }, - { - ext: 'ttf', - path: 'assets/2f334f6c7ca5b2a504bdf8acdee104f3', - }, - ], - bundle: expect.stringMatching(/bundles\/ios-.*\.js/), + version: 0, + }); + + const assetmap = await JsonFile.readAsync(path.resolve(outputDir, 'assetmap.json')); + expect(assetmap).toEqual({ + '2f334f6c7ca5b2a504bdf8acdee104f3': { + __packager_asset: true, + fileHashes: ['2f334f6c7ca5b2a504bdf8acdee104f3'], + fileSystemLocation: expect.stringMatching(/\/.*\/basic-export\/assets/), + files: [expect.stringMatching(/\/.*\/basic-export\/assets\/font\.ios\.ttf/)], + hash: '2f334f6c7ca5b2a504bdf8acdee104f3', + httpServerLocation: '/assets/assets', + name: 'font', + scales: [1], + type: 'ttf', }, - web: { - assets: [ - { - ext: 'png', - path: 'assets/fb960eb5e4eb49ec8786c7f6c4a57ce2', - }, - { - ext: 'png', - path: 'assets/9ce7db807e4147e00df372d053c154c2', - }, - { - ext: 'ttf', - path: 'assets/3858f62230ac3c915f300c664312c63f', - }, + + '3858f62230ac3c915f300c664312c63f': { + __packager_asset: true, + fileHashes: ['3858f62230ac3c915f300c664312c63f'], + fileSystemLocation: expect.stringMatching(/\/.*\/basic-export\/assets/), + files: [expect.stringMatching(/\/.*\/basic-export\/assets\/font\.ttf/)], + hash: '3858f62230ac3c915f300c664312c63f', + httpServerLocation: '/assets/assets', + name: 'font', + scales: [1], + type: 'ttf', + }, + d48d481475a80809fcf9253a765193d1: { + __packager_asset: true, + fileHashes: ['fb960eb5e4eb49ec8786c7f6c4a57ce2', '9ce7db807e4147e00df372d053c154c2'], + fileSystemLocation: expect.stringMatching(/\/.*\/basic-export\/assets/), + files: [ + expect.stringMatching(/\/.*\/basic-export\/assets\/icon\.png/), + expect.stringMatching(/\/.*\/basic-export\/assets\/icon@2x\.png/), ], - bundle: expect.stringMatching(/bundles\/web-.*\.js/), + hash: 'd48d481475a80809fcf9253a765193d1', + height: 1, + httpServerLocation: '/assets/assets', + name: 'icon', + scales: [1, 2], + type: 'png', + width: 1, + }, + }); + + // If this changes then everything else probably changed as well. + expect(files).toEqual([ + 'assetmap.json', + 'assets/2f334f6c7ca5b2a504bdf8acdee104f3', + 'assets/3858f62230ac3c915f300c664312c63f', + 'assets/9ce7db807e4147e00df372d053c154c2', + 'assets/assets/font.ttf', + 'assets/assets/icon.png', + 'assets/assets/icon@2x.png', + + 'assets/fb960eb5e4eb49ec8786c7f6c4a57ce2', + expect.stringMatching(/bundles\/android-[\w\d]+\.js/), + expect.stringMatching(/bundles\/android-[\w\d]+\.map/), + expect.stringMatching(/bundles\/ios-[\w\d]+\.js/), + expect.stringMatching(/bundles\/ios-[\w\d]+\.map/), + expect.stringMatching(/bundles\/web-[\w\d]+\.js/), + expect.stringMatching(/bundles\/web-[\w\d]+\.map/), + 'debug.html', + 'drawable-mdpi/assets_icon.png', + 'drawable-xhdpi/assets_icon.png', + 'favicon.ico', + 'index.html', + 'metadata.json', + 'raw/assets_font.ttf', + ]); + }, + // Could take 45s depending on how fast npm installs + 120 * 1000 + ); + + it( + 'runs `npx expo export -p web` for static rendering', + async () => { + const projectRoot = await setupTestProjectAsync('export-router', 'with-router', '48.0.0'); + await execa('node', [bin, 'export', '-p', 'web'], { + cwd: projectRoot, + env: { + EXPO_USE_STATIC: '1', }, - }, - version: 0, - }); - - const assetmap = await JsonFile.readAsync(path.resolve(outputDir, 'assetmap.json')); - expect(assetmap).toEqual({ - '2f334f6c7ca5b2a504bdf8acdee104f3': { - __packager_asset: true, - fileHashes: ['2f334f6c7ca5b2a504bdf8acdee104f3'], - fileSystemLocation: expect.stringMatching(/\/.*\/basic-export\/assets/), - files: [expect.stringMatching(/\/.*\/basic-export\/assets\/font\.ios\.ttf/)], - hash: '2f334f6c7ca5b2a504bdf8acdee104f3', - httpServerLocation: '/assets/assets', - name: 'font', - scales: [1], - type: 'ttf', - }, - - '3858f62230ac3c915f300c664312c63f': { - __packager_asset: true, - fileHashes: ['3858f62230ac3c915f300c664312c63f'], - fileSystemLocation: expect.stringMatching(/\/.*\/basic-export\/assets/), - files: [expect.stringMatching(/\/.*\/basic-export\/assets\/font\.ttf/)], - hash: '3858f62230ac3c915f300c664312c63f', - httpServerLocation: '/assets/assets', - name: 'font', - scales: [1], - type: 'ttf', - }, - d48d481475a80809fcf9253a765193d1: { - __packager_asset: true, - fileHashes: ['fb960eb5e4eb49ec8786c7f6c4a57ce2', '9ce7db807e4147e00df372d053c154c2'], - fileSystemLocation: expect.stringMatching(/\/.*\/basic-export\/assets/), - files: [ - expect.stringMatching(/\/.*\/basic-export\/assets\/icon\.png/), - expect.stringMatching(/\/.*\/basic-export\/assets\/icon@2x\.png/), - ], - hash: 'd48d481475a80809fcf9253a765193d1', - height: 1, - httpServerLocation: '/assets/assets', - name: 'icon', - scales: [1, 2], - type: 'png', - width: 1, - }, - }); - - // If this changes then everything else probably changed as well. - expect(files).toEqual([ - 'assetmap.json', - 'assets/2f334f6c7ca5b2a504bdf8acdee104f3', - 'assets/3858f62230ac3c915f300c664312c63f', - 'assets/9ce7db807e4147e00df372d053c154c2', - 'assets/assets/font.ttf', - 'assets/assets/icon.png', - 'assets/assets/icon@2x.png', - - 'assets/fb960eb5e4eb49ec8786c7f6c4a57ce2', - expect.stringMatching(/bundles\/android-[\w\d]+\.js/), - expect.stringMatching(/bundles\/android-[\w\d]+\.map/), - expect.stringMatching(/bundles\/ios-[\w\d]+\.js/), - expect.stringMatching(/bundles\/ios-[\w\d]+\.map/), - expect.stringMatching(/bundles\/web-[\w\d]+\.js/), - expect.stringMatching(/bundles\/web-[\w\d]+\.map/), - 'debug.html', - 'drawable-mdpi/assets_icon.png', - 'drawable-xhdpi/assets_icon.png', - 'favicon.ico', - 'index.html', - 'metadata.json', - 'raw/assets_font.ttf', - ]); - }, - // Could take 45s depending on how fast npm installs - 120 * 1000 -); + }); + + const outputDir = path.join(projectRoot, 'dist'); + // List output files with sizes for snapshotting. + // This is to make sure that any changes to the output are intentional. + // Posix path formatting is used to make paths the same across OSes. + const files = klawSync(outputDir) + .map((entry) => { + if (entry.path.includes('node_modules') || !entry.stats.isFile()) { + return null; + } + return path.posix.relative(outputDir, entry.path); + }) + .filter(Boolean); + + const metadata = await JsonFile.readAsync(path.resolve(outputDir, 'metadata.json')); + + expect(metadata).toEqual({ + bundler: 'metro', + fileMetadata: { + web: { + assets: expect.anything(), + bundle: expect.stringMatching(/bundles\/web-.*\.js/), + }, + }, + version: 0, + }); + + // If this changes then everything else probably changed as well. + expect(files).toEqual([ + '[...404].html', + '_sitemap.html', + 'about.html', + 'assets/35ba0eaec5a4f5ed12ca16fabeae451d', + 'assets/369745d4a4a6fa62fa0ed495f89aa964', + 'assets/4f355ba1efca4b9c0e7a6271af047f61', + 'assets/5223c8d9b0d08b82a5670fb5f71faf78', + 'assets/52dec48a970c0a4eed4119cd1252ab09', + 'assets/5b50965d3dfbc518fe50ce36c314a6ec', + 'assets/817aca47ff3cea63020753d336e628a4', + 'assets/b2de8e638d92e0f719fa92fa4085e02a', + 'assets/cbbeac683d803ac27cefb817787d2bfa', + 'assets/e62addcde857ebdb7342e6b9f1095e97', + expect.stringMatching(/bundles\/web-[\w\d]+\.js/), + 'favicon.ico', + 'index.html', + 'metadata.json', + ]); + + const about = await fs.readFile(path.join(outputDir, 'about.html'), 'utf8'); + + // Route-specific head tags + expect(about).toContain(`About | Website`); + + // Nested head tags from layout route + expect(about).toContain(''); + + // Root element + expect(about).toContain('
'); + // Content of the page + expect(about).toContain('data-testid="content">About
'); + + // `).join('') + '' + ); +} + +export async function getFilesToExportFromServerAsync({ + manifest, + scripts, + renderAsync, +}: { + manifest: any; + scripts: string[]; + renderAsync: (pathname: string) => Promise<{ + fetchData: boolean; + scriptContents: string; + renderAsync: () => any; + }>; +}): Promise> { + // name : contents + const files = new Map(); + + const sanitizeName = (segment: string) => { + // Strip group names from the segment + return segment + .split('/') + .map((s) => (matchGroupName(s) ? '' : s)) + .filter(Boolean) + .join('/'); + }; + + const fetchScreens = ( + screens: Record, + additionPath: string = '' + ): Promise[] => { + async function fetchScreenExactAsync(pathname: string, filename: string) { + const outputPath = [additionPath, filename].filter(Boolean).join('/').replace(/^\//, ''); + // TODO: Ensure no duplicates in the manifest. + if (files.has(outputPath)) { + return; + } + + // Prevent duplicate requests while running in parallel. + files.set(outputPath, ''); + + try { + const data = await renderAsync(pathname); + + if (data.fetchData) { + // console.log('ssr:', pathname); + } else { + files.set(outputPath, appendScriptsToHtml(data.renderAsync(), scripts)); + } + } catch (e: any) { + // TODO: Format Metro error message better... + Log.error('Failed to statically render route:', pathname); + e.message = stripAnsi(e.message); + Log.exception(e); + throw e; + } + } + + async function fetchScreenAsync({ segment, filename }: { segment: string; filename: string }) { + // Strip group names from the segment + const cleanSegment = sanitizeName(segment); + + if (cleanSegment !== segment) { + // has groups, should request multiple screens. + await fetchScreenExactAsync( + [additionPath, segment].filter(Boolean).join('/'), + [additionPath, filename].filter(Boolean).join('/').replace(/^\//, '') + ); + } + + await fetchScreenExactAsync( + [additionPath, cleanSegment].filter(Boolean).join('/'), + [additionPath, sanitizeName(filename)].filter(Boolean).join('/').replace(/^\//, '') + ); + } + + return Object.entries(screens).map(async ([name, segment]) => { + const filename = name + '.html'; + + // Segment is a directory. + if (typeof segment !== 'string') { + const cleanSegment = sanitizeName(segment.path); + return Promise.all( + fetchScreens(segment.screens, [additionPath, cleanSegment].filter(Boolean).join('/')) + ); + } + + // TODO: handle dynamic routes + if (segment !== '*') { + await fetchScreenAsync({ segment, filename }); + } + return null; + }); + }; + + await Promise.all(fetchScreens(manifest.screens)); + + return files; +} + +/** Perform all fs commits */ +export async function exportFromServerAsync( + devServerManager: DevServerManager, + { outputDir, scripts }: Options +): Promise { + const devServer = devServerManager.getDefaultDevServer(); + + const manifest = await getExpoRoutesAsync(devServerManager); + + debug('Routes:\n', inspect(manifest, { colors: true, depth: null })); + + const files = await getFilesToExportFromServerAsync({ + manifest, + scripts, + renderAsync(pathname: string) { + assert(devServer instanceof MetroBundlerDevServer); + return devServer.getStaticPageAsync(pathname, { mode: 'production' }); + }, + }); + + fs.mkdirSync(path.join(outputDir), { recursive: true }); + + Log.log(`Exporting ${files.size} files:`); + await Promise.all( + [...files.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(async ([file, contents]) => { + const length = Buffer.byteLength(contents, 'utf8'); + Log.log(file, chalk.gray`(${prettyBytes(length)})`); + const outputPath = path.join(outputDir, file); + await fs.promises.mkdir(path.dirname(outputPath), { recursive: true }); + await fs.promises.writeFile(outputPath, contents); + }) + ); +} diff --git a/packages/@expo/cli/src/start/server/BundlerDevServer.ts b/packages/@expo/cli/src/start/server/BundlerDevServer.ts index 9f41fdee5dbba2..9fcc21c13920d3 100644 --- a/packages/@expo/cli/src/start/server/BundlerDevServer.ts +++ b/packages/@expo/cli/src/start/server/BundlerDevServer.ts @@ -140,7 +140,7 @@ export abstract class BundlerDevServer { isNativeWebpack: this.name === 'webpack' && this.isTargetingNative(), privateKeyPath: options.privateKeyPath, }); - return middleware.getHandler(); + return middleware; } /** Start the dev server using settings defined in the start command. */ diff --git a/packages/@expo/cli/src/start/server/getStaticRenderFunctions.ts b/packages/@expo/cli/src/start/server/getStaticRenderFunctions.ts new file mode 100644 index 00000000000000..bf21e53f0cbcce --- /dev/null +++ b/packages/@expo/cli/src/start/server/getStaticRenderFunctions.ts @@ -0,0 +1,135 @@ +/** + * Copyright © 2022 650 Industries. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +import fs from 'fs'; +import fetch from 'node-fetch'; +import path from 'path'; +import requireString from 'require-from-string'; +import resolveFrom from 'resolve-from'; + +import { delayAsync } from '../../utils/delay'; +import { memoize } from '../../utils/fn'; +import { profile } from '../../utils/profile'; +import { getMetroServerRoot } from './middleware/ManifestMiddleware'; + +const debug = require('debug')('expo:start:server:node-renderer') as typeof console.log; + +function wrapBundle(str: string) { + // Skip the metro runtime so debugging is a bit easier. + // Replace the __r() call with an export statement. + return str.replace(/^(__r\(.*\);)$/m, 'module.exports = $1'); +} + +// TODO(EvanBacon): Group all the code together and version. +const getRenderModuleId = (projectRoot: string): string => { + const moduleId = resolveFrom.silent(projectRoot, 'expo-router/node/render.js'); + if (!moduleId) { + throw new Error( + `A version of expo-router with Node.js support is not installed in the project.` + ); + } + + return moduleId; +}; + +type StaticRenderOptions = { + // Ensure the style format is `css-xxxx` (prod) instead of `css-view-xxxx` (dev) + dev?: boolean; + minify?: boolean; +}; + +const moveStaticRenderFunction = memoize(async (projectRoot: string, requiredModuleId: string) => { + // Copy the file into the project to ensure it works in monorepos. + // This means the file cannot have any relative imports. + const tempDir = path.join(projectRoot, '.expo/static'); + await fs.promises.mkdir(tempDir, { recursive: true }); + const moduleId = path.join(tempDir, 'render.js'); + await fs.promises.writeFile(moduleId, await fs.promises.readFile(requiredModuleId, 'utf8')); + // Sleep to give watchman time to register the file. + await delayAsync(50); + return moduleId; +}); + +/** @returns the js file contents required to generate the static generation function. */ +export async function getStaticRenderFunctionsContentAsync( + projectRoot: string, + devServerUrl: string, + { dev = false, minify = false }: StaticRenderOptions = {} +): Promise { + const root = getMetroServerRoot(projectRoot); + const requiredModuleId = getRenderModuleId(root); + let moduleId = requiredModuleId; + + // Cannot be accessed using Metro's server API, we need to move the file + // into the project root and try again. + if (path.relative(root, moduleId).startsWith('..')) { + moduleId = await moveStaticRenderFunction(projectRoot, requiredModuleId); + } + + const serverPath = path.relative(root, moduleId).replace(/\.[jt]sx?$/, '.bundle'); + console.log(serverPath); + debug('Loading render functions from:', moduleId, moduleId, root); + + const res = await fetch(`${devServerUrl}/${serverPath}?platform=web&dev=${dev}&minify=${minify}`); + + // TODO: Improve error handling + if (res.status === 500) { + const text = await res.text(); + if (text.startsWith('{"originModulePath"')) { + const errorObject = JSON.parse(text); + throw new Error(errorObject.message); + } + throw new Error(`[${res.status}]: ${res.statusText}\n${text}`); + } + + if (!res.ok) { + throw new Error(`Error fetching bundle for static rendering: ${res.status} ${res.statusText}`); + } + + const content = await res.text(); + + return wrapBundle(content); +} + +export async function getStaticRenderFunctions( + projectRoot: string, + devServerUrl: string, + options: StaticRenderOptions = {} +): Promise { + const scriptContents = await getStaticRenderFunctionsContentAsync( + projectRoot, + devServerUrl, + options + ); + return profile(requireString, 'eval-metro-bundle')(scriptContents); +} + +export async function getStaticPageContentsAsync( + projectRoot: string, + devServerUrl: string, + options: StaticRenderOptions = {} +) { + const scriptContents = await getStaticRenderFunctionsContentAsync( + projectRoot, + devServerUrl, + options + ); + + const { + getStaticContent, + // getDataLoader + } = profile(requireString, 'eval-metro-bundle')(scriptContents); + + return function loadPageAsync(url: URL) { + // const fetchData = getDataLoader(url); + + return { + fetchData: false, + scriptContents, + renderAsync: () => getStaticContent(url), + }; + }; +} diff --git a/packages/@expo/cli/src/start/server/metro/MetroBundlerDevServer.ts b/packages/@expo/cli/src/start/server/metro/MetroBundlerDevServer.ts index bc552889965694..4e4a59d9d5e432 100644 --- a/packages/@expo/cli/src/start/server/metro/MetroBundlerDevServer.ts +++ b/packages/@expo/cli/src/start/server/metro/MetroBundlerDevServer.ts @@ -1,12 +1,21 @@ +/** + * Copyright © 2022 650 Industries. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ import { getConfig } from '@expo/config'; import { prependMiddleware } from '@expo/dev-server'; +import assert from 'assert'; import chalk from 'chalk'; import { Log } from '../../../log'; import getDevClientProperties from '../../../utils/analytics/getDevClientProperties'; import { logEventAsync } from '../../../utils/analytics/rudderstackClient'; +import { env } from '../../../utils/env'; import { getFreePortAsync } from '../../../utils/port'; import { BundlerDevServer, BundlerStartOptions, DevServerInstance } from '../BundlerDevServer'; +import { getStaticRenderFunctions, getStaticPageContentsAsync } from '../getStaticRenderFunctions'; import { CreateFileMiddleware } from '../middleware/CreateFileMiddleware'; import { HistoryFallbackMiddleware } from '../middleware/HistoryFallbackMiddleware'; import { InterstitialPageMiddleware } from '../middleware/InterstitialPageMiddleware'; @@ -16,6 +25,7 @@ import { RuntimeRedirectMiddleware, } from '../middleware/RuntimeRedirectMiddleware'; import { ServeStaticMiddleware } from '../middleware/ServeStaticMiddleware'; +import { ServerNext, ServerRequest, ServerResponse } from '../middleware/server.types'; import { instantiateMetroAsync } from './instantiateMetro'; import { waitForMetroToObserveTypeScriptFile } from './waitForMetroToObserveTypeScriptFile'; @@ -48,6 +58,32 @@ export class MetroBundlerDevServer extends BundlerDevServer { return port; } + /** Get routes from Expo Router. */ + async getRoutesAsync() { + const url = this.getDevServerUrl(); + assert(url, 'Dev server must be started'); + const { getManifest } = await getStaticRenderFunctions(this.projectRoot, url); + return getManifest({ fetchData: true }); + } + + async getStaticPageAsync( + pathname: string, + { + mode, + }: { + mode: 'development' | 'production'; + } + ) { + const location = new URL(pathname, 'https://example.dev'); + + const load = await getStaticPageContentsAsync(this.projectRoot, this.getDevServerUrl()!, { + minify: mode === 'production', + dev: mode !== 'production', + }); + + return await load(location); + } + protected async startImplementationAsync( options: BundlerStartOptions ): Promise { @@ -77,7 +113,8 @@ export class MetroBundlerDevServer extends BundlerDevServer { // then the manifest handler will never run, the static middleware will run // and serve index.html instead of the manifest. // https://github.com/expo/expo/issues/13114 - prependMiddleware(middleware, manifestMiddleware); + + prependMiddleware(middleware, manifestMiddleware.getHandler()); middleware.use( new InterstitialPageMiddleware(this.projectRoot, { @@ -108,8 +145,62 @@ export class MetroBundlerDevServer extends BundlerDevServer { // This MUST be after the manifest middleware so it doesn't have a chance to serve the template `public/index.html`. middleware.use(new ServeStaticMiddleware(this.projectRoot).getHandler()); + const devServerUrl = `http://localhost:${options.port}`; + + if (env.EXPO_USE_STATIC) { + middleware.use(async (req: ServerRequest, res: ServerResponse, next: ServerNext) => { + if (!req?.url) { + return next(); + } + + // TODO: Formal manifest for allowed paths + if (req.url.endsWith('.ico')) { + return next(); + } + + const location = new URL(req.url, devServerUrl); + + try { + const { getStaticContent } = await getStaticRenderFunctions( + this.projectRoot, + devServerUrl, + { + minify: options.mode === 'production', + dev: options.mode !== 'production', + } + ); + + let content = await getStaticContent(location); + + //TODO: Not this -- disable injection some other way + if (options.mode !== 'production') { + // Add scripts for rehydration + // TODO: bundle split + content = content.replace( + '', + [``].join( + '\n' + ) + '' + ); + } + + res.setHeader('Content-Type', 'text/html'); + res.end(content); + return; + } catch (error: any) { + console.error(error); + res.setHeader('Content-Type', 'text/html'); + res.end(getErrorResult(error)); + } + }); + } + // This MUST run last since it's the fallback. - middleware.use(new HistoryFallbackMiddleware(manifestMiddleware.internal).getHandler()); + if (!env.EXPO_USE_STATIC) { + middleware.use( + new HistoryFallbackMiddleware(manifestMiddleware.getHandler().internal).getHandler() + ); + } } // Extend the close method to ensure that we clean up the local info. const originalClose = server.close.bind(server); @@ -182,6 +273,23 @@ export class MetroBundlerDevServer extends BundlerDevServer { } } +function getErrorResult(error: Error) { + return ` + + + + + + Error + + +

Failed to render static app

+
${error.stack}
+ + + `; +} + export function getDeepLinkHandler(projectRoot: string): DeepLinkHandler { return async ({ runtime }) => { if (runtime === 'expo') return; diff --git a/packages/@expo/cli/src/start/server/metro/instantiateMetro.ts b/packages/@expo/cli/src/start/server/metro/instantiateMetro.ts index b87c517d81ade9..ac35ad9c0b220e 100644 --- a/packages/@expo/cli/src/start/server/metro/instantiateMetro.ts +++ b/packages/@expo/cli/src/start/server/metro/instantiateMetro.ts @@ -11,6 +11,7 @@ import { getMetroProperties } from '../../../utils/analytics/getMetroProperties' import { createDebuggerTelemetryMiddleware } from '../../../utils/analytics/metroDebuggerMiddleware'; import { logEventAsync } from '../../../utils/analytics/rudderstackClient'; import { env } from '../../../utils/env'; +import { getMetroServerRoot } from '../middleware/ManifestMiddleware'; import { createDevServerMiddleware } from '../middleware/createDevServerMiddleware'; import { getPlatformBundlers } from '../platformBundlers'; import { MetroTerminalReporter } from './MetroTerminalReporter'; @@ -31,9 +32,10 @@ export async function loadMetroConfigAsync( }: { exp?: ExpoConfig } = {} ) { let reportEvent: ((event: any) => void) | undefined; + const serverRoot = getMetroServerRoot(projectRoot); const terminal = new Terminal(process.stdout); - const terminalReporter = new MetroTerminalReporter(projectRoot, terminal); + const terminalReporter = new MetroTerminalReporter(serverRoot, terminal); const reporter = { update(event: any) { diff --git a/packages/@expo/cli/src/start/server/metro/withMetroMultiPlatform.ts b/packages/@expo/cli/src/start/server/metro/withMetroMultiPlatform.ts index 824c9ffd2bf91c..a35f28e409092d 100644 --- a/packages/@expo/cli/src/start/server/metro/withMetroMultiPlatform.ts +++ b/packages/@expo/cli/src/start/server/metro/withMetroMultiPlatform.ts @@ -269,6 +269,11 @@ export async function withMetroMultiPlatformAsync( process.env.EXPO_ROUTER_APP_ROOT = getAppRouterRelativeEntryPath(projectRoot); process.env.EXPO_PROJECT_ROOT = process.env.EXPO_PROJECT_ROOT ?? projectRoot; + if (env.EXPO_USE_STATIC) { + // Enable static rendering in runtime space. + process.env.EXPO_PUBLIC_USE_STATIC = '1'; + } + if (platformBundlers.web === 'metro') { await new WebSupportProjectPrerequisite(projectRoot).assertAsync(); } else if (!env.EXPO_USE_PATH_ALIASES) { diff --git a/packages/@expo/cli/src/start/server/middleware/ManifestMiddleware.ts b/packages/@expo/cli/src/start/server/middleware/ManifestMiddleware.ts index ab6ff7a00ffb26..660ede7b572c3a 100644 --- a/packages/@expo/cli/src/start/server/middleware/ManifestMiddleware.ts +++ b/packages/@expo/cli/src/start/server/middleware/ManifestMiddleware.ts @@ -30,6 +30,17 @@ export function getWorkspaceRoot(projectRoot: string): string | null { } } +export function getEntryWithServerRoot( + projectRoot: string, + projectConfig: ProjectConfig, + platform: string +) { + return path.relative( + getMetroServerRoot(projectRoot), + resolveAbsoluteEntryPoint(projectRoot, platform, projectConfig) + ); +} + export function getMetroServerRoot(projectRoot: string) { if (env.EXPO_USE_METRO_WORKSPACE_ROOT) { return getWorkspaceRoot(projectRoot) ?? projectRoot; @@ -133,10 +144,7 @@ export abstract class ManifestMiddleware< /** Get the main entry module ID (file) relative to the project root. */ private resolveMainModuleName(projectConfig: ProjectConfig, platform: string): string { - let entryPoint = path.relative( - getMetroServerRoot(this.projectRoot), - resolveAbsoluteEntryPoint(this.projectRoot, platform, projectConfig) - ); + let entryPoint = getEntryWithServerRoot(this.projectRoot, projectConfig, platform); debug(`Resolved entry point: ${entryPoint} (project root: ${this.projectRoot})`); @@ -267,6 +275,16 @@ export abstract class ManifestMiddleware< await resolveGoogleServicesFile(this.projectRoot, manifest); } + public getWebBundleUrl() { + const platform = 'web'; + // Read from headers + const mainModuleName = this.resolveMainModuleName(this.initialProjectConfig, platform); + return this._getBundleUrlPath({ + platform, + mainModuleName, + }); + } + /** * Web platforms should create an index.html response using the same script resolution as native. * @@ -274,13 +292,8 @@ export abstract class ManifestMiddleware< * to an `index.html`, this enables the web platform to load JavaScript from the server. */ private async handleWebRequestAsync(req: ServerRequest, res: ServerResponse) { - const platform = 'web'; // Read from headers - const mainModuleName = this.resolveMainModuleName(this.initialProjectConfig, platform); - const bundleUrl = this._getBundleUrlPath({ - platform, - mainModuleName, - }); + const bundleUrl = this.getWebBundleUrl(); res.setHeader('Content-Type', 'text/html'); @@ -293,7 +306,7 @@ export abstract class ManifestMiddleware< } /** Exposed for testing. */ - async checkBrowserRequestAsync(req: ServerRequest, res: ServerResponse) { + async checkBrowserRequestAsync(req: ServerRequest, res: ServerResponse, next: ServerNext) { // Read the config const bundlers = getPlatformBundlers(this.initialProjectConfig.exp); if (bundlers.web === 'metro') { @@ -303,8 +316,14 @@ export abstract class ManifestMiddleware< const platform = parsePlatformHeader(req); // On web, serve the public folder if (!platform || platform === 'web') { - await this.handleWebRequestAsync(req, res); - return true; + // Skip the spa-styled index.html when static generation is enabled. + if (env.EXPO_USE_STATIC) { + next(); + return true; + } else { + await this.handleWebRequestAsync(req, res); + return true; + } } } return false; @@ -316,7 +335,7 @@ export abstract class ManifestMiddleware< next: ServerNext ): Promise { // First check for standard JavaScript runtimes (aka legacy browsers like Chrome). - if (await this.checkBrowserRequestAsync(req, res)) { + if (await this.checkBrowserRequestAsync(req, res, next)) { return; } diff --git a/packages/@expo/cli/src/start/server/webpack/WebpackBundlerDevServer.ts b/packages/@expo/cli/src/start/server/webpack/WebpackBundlerDevServer.ts index b83a78f1622640..1d33b9cae32868 100644 --- a/packages/@expo/cli/src/start/server/webpack/WebpackBundlerDevServer.ts +++ b/packages/@expo/cli/src/start/server/webpack/WebpackBundlerDevServer.ts @@ -134,7 +134,7 @@ export class WebpackBundlerDevServer extends BundlerDevServer { const middleware = await this.getManifestMiddlewareAsync(options); - nativeMiddleware.middleware.use(middleware); + nativeMiddleware.middleware.use(middleware.getHandler()); return nativeMiddleware; } diff --git a/packages/@expo/cli/src/utils/env.ts b/packages/@expo/cli/src/utils/env.ts index f0b9bcf891cd65..525634358d7fba 100644 --- a/packages/@expo/cli/src/utils/env.ts +++ b/packages/@expo/cli/src/utils/env.ts @@ -137,6 +137,13 @@ class Env { return process.env.HTTP_PROXY || process.env.http_proxy || ''; } + /** + * **Experimental:** Use static generation for Metro web projects. This only works with Expo Router. + */ + get EXPO_USE_STATIC(): boolean { + return boolish('EXPO_USE_STATIC', false); + } + /** **Experimental:** Prevent Metro from using the `compilerOptions.paths` feature from `tsconfig.json` (or `jsconfig.json`) to enable import aliases. */ get EXPO_USE_PATH_ALIASES(): boolean { return boolish('EXPO_USE_PATH_ALIASES', false); diff --git a/yarn.lock b/yarn.lock index 8b974467fba2a7..e572311ecdde0f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9289,7 +9289,7 @@ find-pkg@^0.1.2: dependencies: find-file-up "^0.1.2" -find-process@^1.4.4: +find-process@^1.4.4, find-process@^1.4.7: version "1.4.7" resolved "https://registry.yarnpkg.com/find-process/-/find-process-1.4.7.tgz#8c76962259216c381ef1099371465b5b439ea121" integrity sha512-/U4CYp1214Xrp3u3Fqr9yNynUrr5Le4y0SsJh2lMDDSbpwYSz3M2SMWQC+wqcx79cN8PQtHQIL8KnuY9M66fdg== @@ -10670,6 +10670,11 @@ inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, i resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== +inherits@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.1.tgz#b17d08d326b4423e568eff719f91b0b1cbdf69f1" + integrity sha512-8nWq2nLTAwd02jTqJExUYFSD/fKq6VH9Y/oG2accc/kdI0V98Bag8d5a4gi3XHz73rDWa2PvTtvcWYquKqSENA== + inherits@2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" @@ -18666,7 +18671,28 @@ util.promisify@~1.0.0: has-symbols "^1.0.1" object.getownpropertydescriptors "^2.1.0" -util@0.10.3, util@^0.10.3, util@^0.11.0, util@^0.12.0, util@~0.12.4: +util@0.10.3: + version "0.10.3" + resolved "https://registry.yarnpkg.com/util/-/util-0.10.3.tgz#7afb1afe50805246489e3db7fe0ed379336ac0f9" + integrity sha512-5KiHfsmkqacuKjkRkdV7SsfDJ2EGiPsK92s2MhNSY0craxjTdKTtqKsJaCWp4LW33ZZ0OPUv1WO/TFvNQRiQxQ== + dependencies: + inherits "2.0.1" + +util@^0.10.3: + version "0.10.4" + resolved "https://registry.yarnpkg.com/util/-/util-0.10.4.tgz#3aa0125bfe668a4672de58857d3ace27ecb76901" + integrity sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A== + dependencies: + inherits "2.0.3" + +util@^0.11.0: + version "0.11.1" + resolved "https://registry.yarnpkg.com/util/-/util-0.11.1.tgz#3236733720ec64bb27f6e26f421aaa2e1b588d61" + integrity sha512-HShAsny+zS2TZfaXxD9tYj4HQGlBezXZMZuM/S5PKLLoZkShZiGk9o5CzukI1LVHZvjdvZ2Sj1aW/Ndn2NB/HQ== + dependencies: + inherits "2.0.3" + +util@^0.12.0: version "0.12.5" resolved "https://registry.yarnpkg.com/util/-/util-0.12.5.tgz#5f17a6059b73db61a875668781a1c2b136bd6fbc" integrity sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==