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