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