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