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