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 * as runtimeEnv from '@expo/env'; 9import { SerialAsset } from '@expo/metro-config/build/serializer/serializerAssets'; 10import chalk from 'chalk'; 11import fetch from 'node-fetch'; 12import path from 'path'; 13 14import { exportAllApiRoutesAsync, rebundleApiRoute } from './bundleApiRoutes'; 15import { createRouteHandlerMiddleware } from './createServerRouteMiddleware'; 16import { fetchManifest } from './fetchRouterManifest'; 17import { instantiateMetroAsync } from './instantiateMetro'; 18import { metroWatchTypeScriptFiles } from './metroWatchTypeScriptFiles'; 19import { getRouterDirectoryWithManifest, isApiRouteConvention } from './router'; 20import { observeApiRouteChanges, observeFileChanges } from './waitForMetroToObserveTypeScriptFile'; 21import { Log } from '../../../log'; 22import getDevClientProperties from '../../../utils/analytics/getDevClientProperties'; 23import { logEventAsync } from '../../../utils/analytics/rudderstackClient'; 24import { CommandError } from '../../../utils/errors'; 25import { getFreePortAsync } from '../../../utils/port'; 26import { BundlerDevServer, BundlerStartOptions, DevServerInstance } from '../BundlerDevServer'; 27import { getStaticRenderFunctions } from '../getStaticRenderFunctions'; 28import { ContextModuleSourceMapsMiddleware } from '../middleware/ContextModuleSourceMapsMiddleware'; 29import { CreateFileMiddleware } from '../middleware/CreateFileMiddleware'; 30import { FaviconMiddleware } from '../middleware/FaviconMiddleware'; 31import { HistoryFallbackMiddleware } from '../middleware/HistoryFallbackMiddleware'; 32import { InterstitialPageMiddleware } from '../middleware/InterstitialPageMiddleware'; 33import { 34 createBundleUrlPath, 35 resolveMainModuleName, 36 shouldEnableAsyncImports, 37} from '../middleware/ManifestMiddleware'; 38import { ReactDevToolsPageMiddleware } from '../middleware/ReactDevToolsPageMiddleware'; 39import { 40 DeepLinkHandler, 41 RuntimeRedirectMiddleware, 42} from '../middleware/RuntimeRedirectMiddleware'; 43import { ServeStaticMiddleware } from '../middleware/ServeStaticMiddleware'; 44import { prependMiddleware } from '../middleware/mutations'; 45import { startTypescriptTypeGenerationAsync } from '../type-generation/startTypescriptTypeGeneration'; 46 47export class ForwardHtmlError extends CommandError { 48 constructor( 49 message: string, 50 public html: string, 51 public statusCode: number 52 ) { 53 super(message); 54 } 55} 56 57const debug = require('debug')('expo:start:server:metro') as typeof console.log; 58 59/** Default port to use for apps running in Expo Go. */ 60const EXPO_GO_METRO_PORT = 8081; 61 62/** Default port to use for apps that run in standard React Native projects or Expo Dev Clients. */ 63const DEV_CLIENT_METRO_PORT = 8081; 64 65export class MetroBundlerDevServer extends BundlerDevServer { 66 private metro: import('metro').Server | null = null; 67 68 get name(): string { 69 return 'metro'; 70 } 71 72 async resolvePortAsync(options: Partial<BundlerStartOptions> = {}): Promise<number> { 73 const port = 74 // If the manually defined port is busy then an error should be thrown... 75 options.port ?? 76 // Otherwise use the default port based on the runtime target. 77 (options.devClient 78 ? // Don't check if the port is busy if we're using the dev client since most clients are hardcoded to 8081. 79 Number(process.env.RCT_METRO_PORT) || DEV_CLIENT_METRO_PORT 80 : // Otherwise (running in Expo Go) use a free port that falls back on the classic 8081 port. 81 await getFreePortAsync(EXPO_GO_METRO_PORT)); 82 83 return port; 84 } 85 86 async getExpoRouterRoutesManifestAsync({ appDir }: { appDir: string }) { 87 const manifest = await fetchManifest(this.projectRoot, { 88 asJson: true, 89 appDir, 90 }); 91 92 if (!manifest) { 93 throw new CommandError( 94 'EXPO_ROUTER_SERVER_MANIFEST', 95 'Unexpected error: server manifest could not be fetched.' 96 ); 97 } 98 99 return manifest; 100 } 101 102 async exportExpoRouterApiRoutesAsync({ 103 mode, 104 appDir, 105 }: { 106 mode: 'development' | 'production'; 107 appDir: string; 108 }) { 109 return exportAllApiRoutesAsync(this.projectRoot, { 110 mode, 111 appDir, 112 port: this.getInstance()?.location.port, 113 shouldThrow: true, 114 }); 115 } 116 117 async composeResourcesWithHtml({ 118 mode, 119 resources, 120 template, 121 devBundleUrl, 122 basePath, 123 }: { 124 mode: 'development' | 'production'; 125 resources: SerialAsset[]; 126 template: string; 127 /** asset prefix used for deploying to non-standard origins like GitHub pages. */ 128 basePath: string; 129 devBundleUrl?: string; 130 }): Promise<string> { 131 if (!resources) { 132 return ''; 133 } 134 const isDev = mode === 'development'; 135 return htmlFromSerialAssets(resources, { 136 dev: isDev, 137 template, 138 basePath, 139 bundleUrl: isDev ? devBundleUrl : undefined, 140 }); 141 } 142 143 async getStaticRenderFunctionAsync({ 144 mode, 145 minify = mode !== 'development', 146 }: { 147 mode: 'development' | 'production'; 148 minify?: boolean; 149 }) { 150 const url = this.getDevServerUrl()!; 151 152 const { getStaticContent, getManifest } = await getStaticRenderFunctions( 153 this.projectRoot, 154 url, 155 { 156 minify, 157 dev: mode !== 'production', 158 // Ensure the API Routes are included 159 environment: 'node', 160 } 161 ); 162 return { 163 // Get routes from Expo Router. 164 manifest: await getManifest({ fetchData: true, preserveApiRoutes: false }), 165 // Get route generating function 166 async renderAsync(path: string) { 167 return await getStaticContent(new URL(path, url)); 168 }, 169 }; 170 } 171 172 async getStaticResourcesAsync({ 173 mode, 174 minify = mode !== 'development', 175 includeMaps, 176 }: { 177 mode: string; 178 minify?: boolean; 179 includeMaps?: boolean; 180 }): Promise<SerialAsset[]> { 181 const devBundleUrlPathname = createBundleUrlPath({ 182 platform: 'web', 183 mode, 184 minify, 185 environment: 'client', 186 serializerOutput: 'static', 187 serializerIncludeMaps: includeMaps, 188 mainModuleName: resolveMainModuleName(this.projectRoot, getConfig(this.projectRoot), 'web'), 189 lazy: shouldEnableAsyncImports(this.projectRoot), 190 }); 191 192 const bundleUrl = new URL(devBundleUrlPathname, this.getDevServerUrl()!); 193 194 // Fetch the generated HTML from our custom Metro serializer 195 const results = await fetch(bundleUrl.toString()); 196 197 const txt = await results.text(); 198 199 // console.log('STAT:', results.status, results.statusText); 200 let data: any; 201 try { 202 data = JSON.parse(txt); 203 } catch (error: any) { 204 debug(txt); 205 206 // Metro can throw this error when the initial module id cannot be resolved. 207 if (!results.ok && txt.startsWith('<!DOCTYPE html>')) { 208 throw new ForwardHtmlError( 209 `Metro failed to bundle the project. Check the console for more information.`, 210 txt, 211 results.status 212 ); 213 } 214 215 Log.error( 216 'Failed to generate resources with Metro, the Metro config may not be using the correct serializer. Ensure the metro.config.js is extending the expo/metro-config and is not overriding the serializer.' 217 ); 218 throw error; 219 } 220 221 // NOTE: This could potentially need more validation in the future. 222 if (Array.isArray(data)) { 223 return data; 224 } 225 226 if (data != null && (data.errors || data.type?.match(/.*Error$/))) { 227 // { 228 // type: 'InternalError', 229 // errors: [], 230 // message: 'Metro has encountered an error: While trying to resolve module `stylis` from file `/Users/evanbacon/Documents/GitHub/lab/emotion-error-test/node_modules/@emotion/cache/dist/emotion-cache.browser.esm.js`, the package `/Users/evanbacon/Documents/GitHub/lab/emotion-error-test/node_modules/stylis/package.json` was successfully found. However, this package itself specifies a `main` module field that could not be resolved (`/Users/evanbacon/Documents/GitHub/lab/emotion-error-test/node_modules/stylis/dist/stylis.mjs`. Indeed, none of these files exist:\n' + 231 // '\n' + 232 // ' * /Users/evanbacon/Documents/GitHub/lab/emotion-error-test/node_modules/stylis/dist/stylis.mjs(.web.ts|.ts|.web.tsx|.tsx|.web.js|.js|.web.jsx|.jsx|.web.json|.json|.web.cjs|.cjs|.web.scss|.scss|.web.sass|.sass|.web.css|.css)\n' + 233 // ' * /Users/evanbacon/Documents/GitHub/lab/emotion-error-test/node_modules/stylis/dist/stylis.mjs/index(.web.ts|.ts|.web.tsx|.tsx|.web.js|.js|.web.jsx|.jsx|.web.json|.json|.web.cjs|.cjs|.web.scss|.scss|.web.sass|.sass|.web.css|.css): /Users/evanbacon/Documents/GitHub/lab/emotion-error-test/node_modules/metro/src/node-haste/DependencyGraph.js (289:17)\n' + 234 // '\n' + 235 // '\x1B[0m \x1B[90m 287 |\x1B[39m }\x1B[0m\n' + 236 // '\x1B[0m \x1B[90m 288 |\x1B[39m \x1B[36mif\x1B[39m (error \x1B[36minstanceof\x1B[39m \x1B[33mInvalidPackageError\x1B[39m) {\x1B[0m\n' + 237 // '\x1B[0m\x1B[31m\x1B[1m>\x1B[22m\x1B[39m\x1B[90m 289 |\x1B[39m \x1B[36mthrow\x1B[39m \x1B[36mnew\x1B[39m \x1B[33mPackageResolutionError\x1B[39m({\x1B[0m\n' + 238 // '\x1B[0m \x1B[90m |\x1B[39m \x1B[31m\x1B[1m^\x1B[22m\x1B[39m\x1B[0m\n' + 239 // '\x1B[0m \x1B[90m 290 |\x1B[39m packageError\x1B[33m:\x1B[39m error\x1B[33m,\x1B[39m\x1B[0m\n' + 240 // '\x1B[0m \x1B[90m 291 |\x1B[39m originModulePath\x1B[33m:\x1B[39m \x1B[36mfrom\x1B[39m\x1B[33m,\x1B[39m\x1B[0m\n' + 241 // '\x1B[0m \x1B[90m 292 |\x1B[39m targetModuleName\x1B[33m:\x1B[39m to\x1B[33m,\x1B[39m\x1B[0m' 242 // } 243 // The Metro logger already showed this error. 244 throw new Error(data.message); 245 } 246 247 throw new Error( 248 'Invalid resources returned from the Metro serializer. Expected array, found: ' + data 249 ); 250 } 251 252 async getStaticPageAsync( 253 pathname: string, 254 { 255 mode, 256 minify = mode !== 'development', 257 basePath, 258 }: { 259 mode: 'development' | 'production'; 260 minify?: boolean; 261 basePath: string; 262 } 263 ) { 264 const devBundleUrlPathname = createBundleUrlPath({ 265 platform: 'web', 266 mode, 267 environment: 'client', 268 mainModuleName: resolveMainModuleName(this.projectRoot, getConfig(this.projectRoot), 'web'), 269 lazy: shouldEnableAsyncImports(this.projectRoot), 270 }); 271 272 const bundleStaticHtml = async (): Promise<string> => { 273 const { getStaticContent } = await getStaticRenderFunctions( 274 this.projectRoot, 275 this.getDevServerUrl()!, 276 { 277 minify: false, 278 dev: mode !== 'production', 279 // Ensure the API Routes are included 280 environment: 'node', 281 } 282 ); 283 284 const location = new URL(pathname, this.getDevServerUrl()!); 285 return await getStaticContent(location); 286 }; 287 288 const [resources, staticHtml] = await Promise.all([ 289 this.getStaticResourcesAsync({ mode, minify }), 290 bundleStaticHtml(), 291 ]); 292 const content = await this.composeResourcesWithHtml({ 293 mode, 294 resources, 295 template: staticHtml, 296 devBundleUrl: devBundleUrlPathname, 297 basePath, 298 }); 299 return { 300 content, 301 resources, 302 }; 303 } 304 305 async watchEnvironmentVariables() { 306 if (!this.instance) { 307 throw new Error( 308 'Cannot observe environment variable changes without a running Metro instance.' 309 ); 310 } 311 if (!this.metro) { 312 // This can happen when the run command is used and the server is already running in another 313 // process. 314 debug('Skipping Environment Variable observation because Metro is not running (headless).'); 315 return; 316 } 317 318 const envFiles = runtimeEnv 319 .getFiles(process.env.NODE_ENV) 320 .map((fileName) => path.join(this.projectRoot, fileName)); 321 322 observeFileChanges( 323 { 324 metro: this.metro, 325 server: this.instance.server, 326 }, 327 envFiles, 328 () => { 329 debug('Reloading environment variables...'); 330 // Force reload the environment variables. 331 runtimeEnv.load(this.projectRoot, { force: true }); 332 } 333 ); 334 } 335 336 protected async startImplementationAsync( 337 options: BundlerStartOptions 338 ): Promise<DevServerInstance> { 339 options.port = await this.resolvePortAsync(options); 340 this.urlCreator = this.getUrlCreator(options); 341 342 const parsedOptions = { 343 port: options.port, 344 maxWorkers: options.maxWorkers, 345 resetCache: options.resetDevServer, 346 347 // Use the unversioned metro config. 348 // TODO: Deprecate this property when expo-cli goes away. 349 unversioned: false, 350 }; 351 352 // Required for symbolication: 353 process.env.EXPO_DEV_SERVER_ORIGIN = `http://localhost:${options.port}`; 354 355 const { metro, server, middleware, messageSocket } = await instantiateMetroAsync( 356 this, 357 parsedOptions, 358 { 359 isExporting: !!options.isExporting, 360 } 361 ); 362 363 const manifestMiddleware = await this.getManifestMiddlewareAsync(options); 364 365 // Important that we noop source maps for context modules as soon as possible. 366 prependMiddleware(middleware, new ContextModuleSourceMapsMiddleware().getHandler()); 367 368 // We need the manifest handler to be the first middleware to run so our 369 // routes take precedence over static files. For example, the manifest is 370 // served from '/' and if the user has an index.html file in their project 371 // then the manifest handler will never run, the static middleware will run 372 // and serve index.html instead of the manifest. 373 // https://github.com/expo/expo/issues/13114 374 prependMiddleware(middleware, manifestMiddleware.getHandler()); 375 376 middleware.use( 377 new InterstitialPageMiddleware(this.projectRoot, { 378 // TODO: Prevent this from becoming stale. 379 scheme: options.location.scheme ?? null, 380 }).getHandler() 381 ); 382 middleware.use(new ReactDevToolsPageMiddleware(this.projectRoot).getHandler()); 383 384 const deepLinkMiddleware = new RuntimeRedirectMiddleware(this.projectRoot, { 385 onDeepLink: getDeepLinkHandler(this.projectRoot), 386 getLocation: ({ runtime }) => { 387 if (runtime === 'custom') { 388 return this.urlCreator?.constructDevClientUrl(); 389 } else { 390 return this.urlCreator?.constructUrl({ 391 scheme: 'exp', 392 }); 393 } 394 }, 395 }); 396 middleware.use(deepLinkMiddleware.getHandler()); 397 398 middleware.use(new CreateFileMiddleware(this.projectRoot).getHandler()); 399 400 // Append support for redirecting unhandled requests to the index.html page on web. 401 if (this.isTargetingWeb()) { 402 const { exp } = getConfig(this.projectRoot, { skipSDKVersionRequirement: true }); 403 const useServerRendering = ['static', 'server'].includes(exp.web?.output ?? ''); 404 405 // This MUST be after the manifest middleware so it doesn't have a chance to serve the template `public/index.html`. 406 middleware.use(new ServeStaticMiddleware(this.projectRoot).getHandler()); 407 408 // This should come after the static middleware so it doesn't serve the favicon from `public/favicon.ico`. 409 middleware.use(new FaviconMiddleware(this.projectRoot).getHandler()); 410 411 if (useServerRendering) { 412 const appDir = getRouterDirectoryWithManifest(this.projectRoot, exp); 413 middleware.use( 414 createRouteHandlerMiddleware(this.projectRoot, { 415 ...options, 416 appDir, 417 getWebBundleUrl: manifestMiddleware.getWebBundleUrl.bind(manifestMiddleware), 418 getStaticPageAsync: (pathname) => { 419 return this.getStaticPageAsync(pathname, { 420 mode: options.mode ?? 'development', 421 minify: options.minify, 422 // No base path in development 423 basePath: '', 424 }); 425 }, 426 }) 427 ); 428 429 // @ts-expect-error: TODO 430 if (exp.web?.output === 'server') { 431 // Cache observation for API Routes... 432 observeApiRouteChanges( 433 this.projectRoot, 434 { 435 metro, 436 server, 437 }, 438 async (filepath, op) => { 439 if (isApiRouteConvention(filepath)) { 440 debug(`[expo-cli] ${op} ${filepath}`); 441 if (op === 'change' || op === 'add') { 442 rebundleApiRoute(this.projectRoot, filepath, { 443 ...options, 444 appDir, 445 }); 446 } 447 448 if (op === 'delete') { 449 // TODO: Cancel the bundling of the deleted route. 450 } 451 } 452 } 453 ); 454 } 455 } else { 456 // This MUST run last since it's the fallback. 457 middleware.use( 458 new HistoryFallbackMiddleware(manifestMiddleware.getHandler().internal).getHandler() 459 ); 460 } 461 } 462 // Extend the close method to ensure that we clean up the local info. 463 const originalClose = server.close.bind(server); 464 465 server.close = (callback?: (err?: Error) => void) => { 466 return originalClose((err?: Error) => { 467 this.instance = null; 468 this.metro = null; 469 callback?.(err); 470 }); 471 }; 472 473 this.metro = metro; 474 return { 475 server, 476 location: { 477 // The port is the main thing we want to send back. 478 port: options.port, 479 // localhost isn't always correct. 480 host: 'localhost', 481 // http is the only supported protocol on native. 482 url: `http://localhost:${options.port}`, 483 protocol: 'http', 484 }, 485 middleware, 486 messageSocket, 487 }; 488 } 489 490 public async waitForTypeScriptAsync(): Promise<boolean> { 491 if (!this.instance) { 492 throw new Error('Cannot wait for TypeScript without a running server.'); 493 } 494 495 return new Promise<boolean>((resolve) => { 496 if (!this.metro) { 497 // This can happen when the run command is used and the server is already running in another 498 // process. In this case we can't wait for the TypeScript check to complete because we don't 499 // have access to the Metro server. 500 debug('Skipping TypeScript check because Metro is not running (headless).'); 501 return resolve(false); 502 } 503 504 const off = metroWatchTypeScriptFiles({ 505 projectRoot: this.projectRoot, 506 server: this.instance!.server, 507 metro: this.metro, 508 tsconfig: true, 509 throttle: true, 510 eventTypes: ['change', 'add'], 511 callback: async () => { 512 // Run once, this prevents the TypeScript project prerequisite from running on every file change. 513 off(); 514 const { TypeScriptProjectPrerequisite } = await import( 515 '../../doctor/typescript/TypeScriptProjectPrerequisite.js' 516 ); 517 518 try { 519 const req = new TypeScriptProjectPrerequisite(this.projectRoot); 520 await req.bootstrapAsync(); 521 resolve(true); 522 } catch (error: any) { 523 // Ensure the process doesn't fail if the TypeScript check fails. 524 // This could happen during the install. 525 Log.log(); 526 Log.error( 527 chalk.red`Failed to automatically setup TypeScript for your project. Try restarting the dev server to fix.` 528 ); 529 Log.exception(error); 530 resolve(false); 531 } 532 }, 533 }); 534 }); 535 } 536 537 public async startTypeScriptServices() { 538 return startTypescriptTypeGenerationAsync({ 539 server: this.instance?.server, 540 metro: this.metro, 541 projectRoot: this.projectRoot, 542 }); 543 } 544 545 protected getConfigModuleIds(): string[] { 546 return ['./metro.config.js', './metro.config.json', './rn-cli.config.js']; 547 } 548} 549 550export function getDeepLinkHandler(projectRoot: string): DeepLinkHandler { 551 return async ({ runtime }) => { 552 if (runtime === 'expo') return; 553 const { exp } = getConfig(projectRoot); 554 await logEventAsync('dev client start command', { 555 status: 'started', 556 ...getDevClientProperties(projectRoot, exp), 557 }); 558 }; 559} 560 561function htmlFromSerialAssets( 562 assets: SerialAsset[], 563 { 564 dev, 565 template, 566 basePath, 567 bundleUrl, 568 }: { 569 dev: boolean; 570 template: string; 571 basePath: string; 572 /** This is dev-only. */ 573 bundleUrl?: string; 574 } 575) { 576 // Combine the CSS modules into tags that have hot refresh data attributes. 577 const styleString = assets 578 .filter((asset) => asset.type === 'css') 579 .map(({ metadata, filename, source }) => { 580 if (dev) { 581 return `<style data-expo-css-hmr="${metadata.hmrId}">` + source + '\n</style>'; 582 } else { 583 return [ 584 `<link rel="preload" href="${basePath}/${filename}" as="style">`, 585 `<link rel="stylesheet" href="${basePath}/${filename}">`, 586 ].join(''); 587 } 588 }) 589 .join(''); 590 591 const jsAssets = assets.filter((asset) => asset.type === 'js'); 592 593 const scripts = bundleUrl 594 ? `<script src="${bundleUrl}" defer></script>` 595 : jsAssets 596 .map(({ filename }) => { 597 return `<script src="${basePath}/${filename}" defer></script>`; 598 }) 599 .join(''); 600 601 return template 602 .replace('</head>', `${styleString}</head>`) 603 .replace('</body>', `${scripts}\n</body>`); 604} 605