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