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