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