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 assert from 'assert'; 11import chalk from 'chalk'; 12import path from 'path'; 13 14import { Log } from '../../../log'; 15import getDevClientProperties from '../../../utils/analytics/getDevClientProperties'; 16import { logEventAsync } from '../../../utils/analytics/rudderstackClient'; 17import { env } from '../../../utils/env'; 18import { getFreePortAsync } from '../../../utils/port'; 19import { BundlerDevServer, BundlerStartOptions, DevServerInstance } from '../BundlerDevServer'; 20import { getStaticPageContentsAsync, getStaticRenderFunctions } from '../getStaticRenderFunctions'; 21import { CreateFileMiddleware } from '../middleware/CreateFileMiddleware'; 22import { HistoryFallbackMiddleware } from '../middleware/HistoryFallbackMiddleware'; 23import { InterstitialPageMiddleware } from '../middleware/InterstitialPageMiddleware'; 24import { ReactDevToolsPageMiddleware } from '../middleware/ReactDevToolsPageMiddleware'; 25import { 26 DeepLinkHandler, 27 RuntimeRedirectMiddleware, 28} from '../middleware/RuntimeRedirectMiddleware'; 29import { ServeStaticMiddleware } from '../middleware/ServeStaticMiddleware'; 30import { ServerNext, ServerRequest, ServerResponse } from '../middleware/server.types'; 31import { typescriptTypeGeneration } from '../type-generation'; 32import { instantiateMetroAsync } from './instantiateMetro'; 33import { getErrorOverlayHtmlAsync } from './metroErrorInterface'; 34import { metroWatchTypeScriptFiles } from './metroWatchTypeScriptFiles'; 35import { observeFileChanges } from './waitForMetroToObserveTypeScriptFile'; 36 37const debug = require('debug')('expo:start:server:metro') as typeof console.log; 38 39/** Default port to use for apps running in Expo Go. */ 40const EXPO_GO_METRO_PORT = 19000; 41 42/** Default port to use for apps that run in standard React Native projects or Expo Dev Clients. */ 43const DEV_CLIENT_METRO_PORT = 8081; 44 45export class MetroBundlerDevServer extends BundlerDevServer { 46 private metro: import('metro').Server | null = null; 47 48 get name(): string { 49 return 'metro'; 50 } 51 52 async resolvePortAsync(options: Partial<BundlerStartOptions> = {}): Promise<number> { 53 const port = 54 // If the manually defined port is busy then an error should be thrown... 55 options.port ?? 56 // Otherwise use the default port based on the runtime target. 57 (options.devClient 58 ? // Don't check if the port is busy if we're using the dev client since most clients are hardcoded to 8081. 59 Number(process.env.RCT_METRO_PORT) || DEV_CLIENT_METRO_PORT 60 : // Otherwise (running in Expo Go) use a free port that falls back on the classic 19000 port. 61 await getFreePortAsync(EXPO_GO_METRO_PORT)); 62 63 return port; 64 } 65 66 /** Get routes from Expo Router. */ 67 async getRoutesAsync() { 68 const url = this.getDevServerUrl(); 69 assert(url, 'Dev server must be started'); 70 const { getManifest } = await getStaticRenderFunctions(this.projectRoot, url, { 71 // Ensure the API Routes are included 72 environment: 'node', 73 }); 74 return getManifest({ fetchData: true }); 75 } 76 77 private async renderStaticErrorAsync(error: Error) { 78 return getErrorOverlayHtmlAsync({ 79 error, 80 projectRoot: this.projectRoot, 81 }); 82 } 83 84 async getStaticPageAsync( 85 pathname: string, 86 { 87 mode, 88 }: { 89 mode: 'development' | 'production'; 90 } 91 ) { 92 const location = new URL(pathname, 'https://example.dev'); 93 94 const load = await getStaticPageContentsAsync(this.projectRoot, this.getDevServerUrl()!, { 95 minify: mode === 'production', 96 dev: mode !== 'production', 97 // Ensure the API Routes are included 98 environment: 'node', 99 }); 100 101 return await load(location); 102 } 103 104 async watchEnvironmentVariables() { 105 if (!this.instance) { 106 throw new Error( 107 'Cannot observe environment variable changes without a running Metro instance.' 108 ); 109 } 110 if (!this.metro) { 111 // This can happen when the run command is used and the server is already running in another 112 // process. 113 debug('Skipping Environment Variable observation because Metro is not running (headless).'); 114 return; 115 } 116 117 const envFiles = runtimeEnv 118 .getFiles(process.env.NODE_ENV) 119 .map((fileName) => path.join(this.projectRoot, fileName)); 120 121 observeFileChanges( 122 { 123 metro: this.metro, 124 server: this.instance.server, 125 }, 126 envFiles, 127 () => { 128 debug('Reloading environment variables...'); 129 // Force reload the environment variables. 130 runtimeEnv.load(this.projectRoot, { force: true }); 131 } 132 ); 133 } 134 135 protected async startImplementationAsync( 136 options: BundlerStartOptions 137 ): Promise<DevServerInstance> { 138 options.port = await this.resolvePortAsync(options); 139 this.urlCreator = this.getUrlCreator(options); 140 141 const parsedOptions = { 142 port: options.port, 143 maxWorkers: options.maxWorkers, 144 resetCache: options.resetDevServer, 145 146 // Use the unversioned metro config. 147 // TODO: Deprecate this property when expo-cli goes away. 148 unversioned: false, 149 }; 150 151 // Required for symbolication: 152 process.env.EXPO_DEV_SERVER_ORIGIN = `http://localhost:${options.port}`; 153 154 const { metro, server, middleware, messageSocket } = await instantiateMetroAsync( 155 this, 156 parsedOptions 157 ); 158 159 const manifestMiddleware = await this.getManifestMiddlewareAsync(options); 160 161 // We need the manifest handler to be the first middleware to run so our 162 // routes take precedence over static files. For example, the manifest is 163 // served from '/' and if the user has an index.html file in their project 164 // then the manifest handler will never run, the static middleware will run 165 // and serve index.html instead of the manifest. 166 // https://github.com/expo/expo/issues/13114 167 168 prependMiddleware(middleware, manifestMiddleware.getHandler()); 169 170 middleware.use( 171 new InterstitialPageMiddleware(this.projectRoot, { 172 // TODO: Prevent this from becoming stale. 173 scheme: options.location.scheme ?? null, 174 }).getHandler() 175 ); 176 middleware.use(new ReactDevToolsPageMiddleware(this.projectRoot).getHandler()); 177 178 const deepLinkMiddleware = new RuntimeRedirectMiddleware(this.projectRoot, { 179 onDeepLink: getDeepLinkHandler(this.projectRoot), 180 getLocation: ({ runtime }) => { 181 if (runtime === 'custom') { 182 return this.urlCreator?.constructDevClientUrl(); 183 } else { 184 return this.urlCreator?.constructUrl({ 185 scheme: 'exp', 186 }); 187 } 188 }, 189 }); 190 middleware.use(deepLinkMiddleware.getHandler()); 191 192 middleware.use(new CreateFileMiddleware(this.projectRoot).getHandler()); 193 194 // Append support for redirecting unhandled requests to the index.html page on web. 195 if (this.isTargetingWeb()) { 196 // This MUST be after the manifest middleware so it doesn't have a chance to serve the template `public/index.html`. 197 middleware.use(new ServeStaticMiddleware(this.projectRoot).getHandler()); 198 199 const devServerUrl = `http://localhost:${options.port}`; 200 201 if (env.EXPO_USE_STATIC) { 202 middleware.use(async (req: ServerRequest, res: ServerResponse, next: ServerNext) => { 203 if (!req?.url) { 204 return next(); 205 } 206 207 // TODO: Formal manifest for allowed paths 208 if (req.url.endsWith('.ico')) { 209 return next(); 210 } 211 212 const location = new URL(req.url, devServerUrl); 213 214 try { 215 const { getStaticContent } = await getStaticRenderFunctions( 216 this.projectRoot, 217 devServerUrl, 218 { 219 minify: options.mode === 'production', 220 dev: options.mode !== 'production', 221 // Ensure the API Routes are included 222 environment: 'node', 223 } 224 ); 225 226 let content = await getStaticContent(location); 227 228 //TODO: Not this -- disable injection some other way 229 if (options.mode !== 'production') { 230 // Add scripts for rehydration 231 // TODO: bundle split 232 content = content.replace( 233 '</body>', 234 [`<script src="${manifestMiddleware.getWebBundleUrl()}" defer></script>`].join( 235 '\n' 236 ) + '</body>' 237 ); 238 } 239 240 res.setHeader('Content-Type', 'text/html'); 241 res.end(content); 242 return; 243 } catch (error: any) { 244 res.setHeader('Content-Type', 'text/html'); 245 res.end(await this.renderStaticErrorAsync(error)); 246 } 247 }); 248 } 249 250 // This MUST run last since it's the fallback. 251 if (!env.EXPO_USE_STATIC) { 252 middleware.use( 253 new HistoryFallbackMiddleware(manifestMiddleware.getHandler().internal).getHandler() 254 ); 255 } 256 } 257 // Extend the close method to ensure that we clean up the local info. 258 const originalClose = server.close.bind(server); 259 260 server.close = (callback?: (err?: Error) => void) => { 261 return originalClose((err?: Error) => { 262 this.instance = null; 263 this.metro = null; 264 callback?.(err); 265 }); 266 }; 267 268 this.metro = metro; 269 return { 270 server, 271 location: { 272 // The port is the main thing we want to send back. 273 port: options.port, 274 // localhost isn't always correct. 275 host: 'localhost', 276 // http is the only supported protocol on native. 277 url: `http://localhost:${options.port}`, 278 protocol: 'http', 279 }, 280 middleware, 281 messageSocket, 282 }; 283 } 284 285 public async waitForTypeScriptAsync(): Promise<boolean> { 286 if (!this.instance) { 287 throw new Error('Cannot wait for TypeScript without a running server.'); 288 } 289 290 return new Promise<boolean>((resolve) => { 291 if (!this.metro) { 292 // This can happen when the run command is used and the server is already running in another 293 // process. In this case we can't wait for the TypeScript check to complete because we don't 294 // have access to the Metro server. 295 debug('Skipping TypeScript check because Metro is not running (headless).'); 296 return resolve(false); 297 } 298 299 const off = metroWatchTypeScriptFiles({ 300 projectRoot: this.projectRoot, 301 server: this.instance!.server, 302 metro: this.metro, 303 tsconfig: true, 304 throttle: true, 305 eventTypes: ['change', 'add'], 306 callback: async () => { 307 // Run once, this prevents the TypeScript project prerequisite from running on every file change. 308 off(); 309 const { TypeScriptProjectPrerequisite } = await import( 310 '../../doctor/typescript/TypeScriptProjectPrerequisite' 311 ); 312 313 try { 314 const req = new TypeScriptProjectPrerequisite(this.projectRoot); 315 await req.bootstrapAsync(); 316 resolve(true); 317 } catch (error: any) { 318 // Ensure the process doesn't fail if the TypeScript check fails. 319 // This could happen during the install. 320 Log.log(); 321 Log.error( 322 chalk.red`Failed to automatically setup TypeScript for your project. Try restarting the dev server to fix.` 323 ); 324 Log.exception(error); 325 resolve(false); 326 } 327 }, 328 }); 329 }); 330 } 331 332 public async startTypeScriptServices() { 333 typescriptTypeGeneration({ 334 server: this.instance!.server, 335 metro: this.metro, 336 projectRoot: this.projectRoot, 337 }); 338 } 339 340 protected getConfigModuleIds(): string[] { 341 return ['./metro.config.js', './metro.config.json', './rn-cli.config.js']; 342 } 343} 344 345export function getDeepLinkHandler(projectRoot: string): DeepLinkHandler { 346 return async ({ runtime }) => { 347 if (runtime === 'expo') return; 348 const { exp } = getConfig(projectRoot); 349 await logEventAsync('dev client start command', { 350 status: 'started', 351 ...getDevClientProperties(projectRoot, exp), 352 }); 353 }; 354} 355