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