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