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