1import { MessageSocket } from '@expo/dev-server'; 2import assert from 'assert'; 3import openBrowserAsync from 'better-opn'; 4import resolveFrom from 'resolve-from'; 5 6import { APISettings } from '../../api/settings'; 7import * as Log from '../../log'; 8import { FileNotifier } from '../../utils/FileNotifier'; 9import { resolveWithTimeout } from '../../utils/delay'; 10import { env } from '../../utils/env'; 11import { CommandError } from '../../utils/errors'; 12import { 13 BaseOpenInCustomProps, 14 BaseResolveDeviceProps, 15 PlatformManager, 16} from '../platforms/PlatformManager'; 17import { AsyncNgrok } from './AsyncNgrok'; 18import { DevelopmentSession } from './DevelopmentSession'; 19import { CreateURLOptions, UrlCreator } from './UrlCreator'; 20import { PlatformBundlers } from './platformBundlers'; 21import { typescriptTypeGeneration } from './type-generation'; 22 23const debug = require('debug')('expo:start:server:devServer') as typeof console.log; 24 25export type ServerLike = { 26 close(callback?: (err?: Error) => void): void; 27 addListener?(event: string, listener: (...args: any[]) => void): unknown; 28}; 29 30export type DevServerInstance = { 31 /** Bundler dev server instance. */ 32 server: ServerLike; 33 /** Dev server URL location properties. */ 34 location: { 35 url: string; 36 port: number; 37 protocol: 'http' | 'https'; 38 host?: string; 39 }; 40 /** Additional middleware that's attached to the `server`. */ 41 middleware: any; 42 /** Message socket for communicating with the runtime. */ 43 messageSocket: MessageSocket; 44}; 45 46export interface BundlerStartOptions { 47 /** Should the dev server use `https` protocol. */ 48 https?: boolean; 49 /** Should start the dev servers in development mode (minify). */ 50 mode?: 'development' | 'production'; 51 /** Is dev client enabled. */ 52 devClient?: boolean; 53 /** Should run dev servers with clean caches. */ 54 resetDevServer?: boolean; 55 /** Which manifest type to serve. */ 56 forceManifestType?: 'expo-updates' | 'classic'; 57 /** Code signing private key path (defaults to same directory as certificate) */ 58 privateKeyPath?: string; 59 60 /** Max amount of workers (threads) to use with Metro bundler, defaults to undefined for max workers. */ 61 maxWorkers?: number; 62 /** Port to start the dev server on. */ 63 port?: number; 64 65 /** Should start a headless dev server e.g. mock representation to approximate info from a server running in a different process. */ 66 headless?: boolean; 67 /** Should instruct the bundler to create minified bundles. */ 68 minify?: boolean; 69 70 // Webpack options 71 /** Should modify and create PWA icons. */ 72 isImageEditingEnabled?: boolean; 73 74 location: CreateURLOptions; 75} 76 77const PLATFORM_MANAGERS = { 78 simulator: () => 79 require('../platforms/ios/ApplePlatformManager') 80 .ApplePlatformManager as typeof import('../platforms/ios/ApplePlatformManager').ApplePlatformManager, 81 emulator: () => 82 require('../platforms/android/AndroidPlatformManager') 83 .AndroidPlatformManager as typeof import('../platforms/android/AndroidPlatformManager').AndroidPlatformManager, 84}; 85 86const MIDDLEWARES = { 87 classic: () => 88 require('./middleware/ClassicManifestMiddleware') 89 .ClassicManifestMiddleware as typeof import('./middleware/ClassicManifestMiddleware').ClassicManifestMiddleware, 90 'expo-updates': () => 91 require('./middleware/ExpoGoManifestHandlerMiddleware') 92 .ExpoGoManifestHandlerMiddleware as typeof import('./middleware/ExpoGoManifestHandlerMiddleware').ExpoGoManifestHandlerMiddleware, 93}; 94 95export abstract class BundlerDevServer { 96 /** Name of the bundler. */ 97 abstract get name(): string; 98 99 /** Ngrok instance for managing tunnel connections. */ 100 protected ngrok: AsyncNgrok | null = null; 101 /** Interfaces with the Expo 'Development Session' API. */ 102 protected devSession: DevelopmentSession | null = null; 103 /** Http server and related info. */ 104 protected instance: DevServerInstance | null = null; 105 /** Native platform interfaces for opening projects. */ 106 private platformManagers: Record<string, PlatformManager<any>> = {}; 107 /** Manages the creation of dev server URLs. */ 108 protected urlCreator?: UrlCreator | null = null; 109 110 private notifier: FileNotifier | null = null; 111 112 constructor( 113 /** Project root folder. */ 114 public projectRoot: string, 115 /** A mapping of bundlers to platforms. */ 116 public platformBundlers: PlatformBundlers, 117 // TODO: Replace with custom scheme maybe... 118 public isDevClient?: boolean 119 ) {} 120 121 protected setInstance(instance: DevServerInstance) { 122 this.instance = instance; 123 } 124 125 /** Get the manifest middleware function. */ 126 protected async getManifestMiddlewareAsync( 127 options: Pick< 128 BundlerStartOptions, 129 'minify' | 'mode' | 'forceManifestType' | 'privateKeyPath' 130 > = {} 131 ) { 132 const manifestType = options.forceManifestType || 'classic'; 133 assert(manifestType in MIDDLEWARES, `Manifest middleware for type '${manifestType}' not found`); 134 const Middleware = MIDDLEWARES[manifestType](); 135 136 const urlCreator = this.getUrlCreator(); 137 const middleware = new Middleware(this.projectRoot, { 138 constructUrl: urlCreator.constructUrl.bind(urlCreator), 139 mode: options.mode, 140 minify: options.minify, 141 isNativeWebpack: this.name === 'webpack' && this.isTargetingNative(), 142 privateKeyPath: options.privateKeyPath, 143 }); 144 return middleware; 145 } 146 147 /** Start the dev server using settings defined in the start command. */ 148 public async startAsync(options: BundlerStartOptions): Promise<DevServerInstance> { 149 await this.stopAsync(); 150 151 let instance: DevServerInstance; 152 if (options.headless) { 153 instance = await this.startHeadlessAsync(options); 154 } else { 155 instance = await this.startImplementationAsync(options); 156 } 157 158 this.setInstance(instance); 159 await this.postStartAsync(options); 160 return instance; 161 } 162 163 protected abstract startImplementationAsync( 164 options: BundlerStartOptions 165 ): Promise<DevServerInstance>; 166 167 public async waitForTypeScriptAsync(): Promise<boolean> { 168 return false; 169 } 170 171 public async startTypeScriptServices(): Promise<void> { 172 return typescriptTypeGeneration({ 173 server: this.instance!.server, 174 projectRoot: this.projectRoot, 175 }); 176 } 177 178 public async watchEnvironmentVariables(): Promise<void> { 179 // noop -- We've only implemented this functionality in Metro. 180 } 181 182 /** 183 * Creates a mock server representation that can be used to estimate URLs for a server started in another process. 184 * This is used for the run commands where you can reuse the server from a previous run. 185 */ 186 private async startHeadlessAsync(options: BundlerStartOptions): Promise<DevServerInstance> { 187 if (!options.port) 188 throw new CommandError('HEADLESS_SERVER', 'headless dev server requires a port option'); 189 this.urlCreator = this.getUrlCreator(options); 190 191 return { 192 // Create a mock server 193 server: { 194 close: () => { 195 this.instance = null; 196 }, 197 addListener() {}, 198 }, 199 location: { 200 // The port is the main thing we want to send back. 201 port: options.port, 202 // localhost isn't always correct. 203 host: 'localhost', 204 // http is the only supported protocol on native. 205 url: `http://localhost:${options.port}`, 206 protocol: 'http', 207 }, 208 middleware: {}, 209 messageSocket: { 210 broadcast: () => { 211 throw new CommandError('HEADLESS_SERVER', 'Cannot broadcast messages to headless server'); 212 }, 213 }, 214 }; 215 } 216 217 /** 218 * Runs after the `startAsync` function, performing any additional common operations. 219 * You can assume the dev server is started by the time this function is called. 220 */ 221 protected async postStartAsync(options: BundlerStartOptions) { 222 if ( 223 options.location.hostType === 'tunnel' && 224 !APISettings.isOffline && 225 // This is a hack to prevent using tunnel on web since we block it upstream for some reason. 226 this.isTargetingNative() 227 ) { 228 await this._startTunnelAsync(); 229 } 230 await this.startDevSessionAsync(); 231 232 this.watchConfig(); 233 } 234 235 protected abstract getConfigModuleIds(): string[]; 236 237 protected watchConfig() { 238 this.notifier?.stopObserving(); 239 this.notifier = new FileNotifier(this.projectRoot, this.getConfigModuleIds()); 240 this.notifier.startObserving(); 241 } 242 243 /** Create ngrok instance and start the tunnel server. Exposed for testing. */ 244 public async _startTunnelAsync(): Promise<AsyncNgrok | null> { 245 const port = this.getInstance()?.location.port; 246 if (!port) return null; 247 debug('[ngrok] connect to port: ' + port); 248 this.ngrok = new AsyncNgrok(this.projectRoot, port); 249 await this.ngrok.startAsync(); 250 return this.ngrok; 251 } 252 253 protected async startDevSessionAsync() { 254 // This is used to make Expo Go open the project in either Expo Go, or the web browser. 255 // Must come after ngrok (`startTunnelAsync`) setup. 256 this.devSession?.stopNotifying?.(); 257 this.devSession = new DevelopmentSession( 258 this.projectRoot, 259 // This URL will be used on external devices so the computer IP won't be relevant. 260 this.isTargetingNative() 261 ? this.getNativeRuntimeUrl() 262 : this.getDevServerUrl({ hostType: 'localhost' }), 263 () => { 264 // TODO: This appears to be happening consistently after an hour. 265 // We should investigate why this is happening and fix it on our servers. 266 // Log.error( 267 // chalk.red( 268 // '\nAn unexpected error occurred while updating the Dev Session API. This project will not appear in the "Development servers" section of the Expo Go app until this process has been restarted.' 269 // ) 270 // ); 271 // Log.exception(error); 272 this.devSession?.closeAsync().catch((error) => { 273 debug('[dev-session] error closing: ' + error.message); 274 }); 275 } 276 ); 277 278 await this.devSession.startAsync({ 279 runtime: this.isTargetingNative() ? 'native' : 'web', 280 }); 281 } 282 283 public isTargetingNative() { 284 // Temporary hack while we implement multi-bundler dev server proxy. 285 return true; 286 } 287 288 public isTargetingWeb() { 289 return this.platformBundlers.web === this.name; 290 } 291 292 /** 293 * Sends a message over web sockets to any connected device, 294 * does nothing when the dev server is not running. 295 * 296 * @param method name of the command. In RN projects `reload`, and `devMenu` are available. In Expo Go, `sendDevCommand` is available. 297 * @param params 298 */ 299 public broadcastMessage( 300 method: 'reload' | 'devMenu' | 'sendDevCommand', 301 params?: Record<string, any> 302 ) { 303 this.getInstance()?.messageSocket.broadcast(method, params); 304 } 305 306 /** Get the running dev server instance. */ 307 public getInstance() { 308 return this.instance; 309 } 310 311 /** Stop the running dev server instance. */ 312 async stopAsync() { 313 // Stop file watching. 314 this.notifier?.stopObserving(); 315 316 // Stop the dev session timer and tell Expo API to remove dev session. 317 await this.devSession?.closeAsync(); 318 319 // Stop ngrok if running. 320 await this.ngrok?.stopAsync().catch((e) => { 321 Log.error(`Error stopping ngrok:`); 322 Log.exception(e); 323 }); 324 325 return resolveWithTimeout( 326 () => 327 new Promise<void>((resolve, reject) => { 328 // Close the server. 329 debug(`Stopping dev server (bundler: ${this.name})`); 330 331 if (this.instance?.server) { 332 this.instance.server.close((error) => { 333 debug(`Stopped dev server (bundler: ${this.name})`); 334 this.instance = null; 335 if (error) { 336 reject(error); 337 } else { 338 resolve(); 339 } 340 }); 341 } else { 342 debug(`Stopped dev server (bundler: ${this.name})`); 343 this.instance = null; 344 resolve(); 345 } 346 }), 347 { 348 // NOTE(Bacon): Metro dev server doesn't seem to be closing in time. 349 timeout: 1000, 350 errorMessage: `Timeout waiting for '${this.name}' dev server to close`, 351 } 352 ); 353 } 354 355 protected getUrlCreator(options: Partial<Pick<BundlerStartOptions, 'port' | 'location'>> = {}) { 356 if (!this.urlCreator) { 357 assert(options?.port, 'Dev server instance not found'); 358 this.urlCreator = new UrlCreator(options.location, { 359 port: options.port, 360 getTunnelUrl: this.getTunnelUrl.bind(this), 361 }); 362 } 363 return this.urlCreator; 364 } 365 366 public getNativeRuntimeUrl(opts: Partial<CreateURLOptions> = {}) { 367 return this.isDevClient 368 ? this.getUrlCreator().constructDevClientUrl(opts) ?? this.getDevServerUrl() 369 : this.getUrlCreator().constructUrl({ ...opts, scheme: 'exp' }); 370 } 371 372 /** Get the URL for the running instance of the dev server. */ 373 public getDevServerUrl(options: { hostType?: 'localhost' } = {}): string | null { 374 const instance = this.getInstance(); 375 if (!instance?.location) { 376 return null; 377 } 378 const { location } = instance; 379 if (options.hostType === 'localhost') { 380 return `${location.protocol}://localhost:${location.port}`; 381 } 382 return location.url ?? null; 383 } 384 385 /** Get the base URL for JS inspector */ 386 public getJsInspectorBaseUrl(): string { 387 if (this.name !== 'metro') { 388 throw new CommandError( 389 'DEV_SERVER', 390 `Cannot get the JS inspector base url - bundler[${this.name}]` 391 ); 392 } 393 return this.getUrlCreator().constructUrl({ scheme: 'http' }); 394 } 395 396 /** Get the tunnel URL from ngrok. */ 397 public getTunnelUrl(): string | null { 398 return this.ngrok?.getActiveUrl() ?? null; 399 } 400 401 /** Open the dev server in a runtime. */ 402 public async openPlatformAsync( 403 launchTarget: keyof typeof PLATFORM_MANAGERS | 'desktop', 404 resolver: BaseResolveDeviceProps<any> = {} 405 ) { 406 if (launchTarget === 'desktop') { 407 const serverUrl = this.getDevServerUrl({ hostType: 'localhost' }); 408 // Allow opening the tunnel URL when using Metro web. 409 const url = this.name === 'metro' ? this.getTunnelUrl() ?? serverUrl : serverUrl; 410 await openBrowserAsync(url!); 411 return { url }; 412 } 413 414 const runtime = this.isTargetingNative() ? (this.isDevClient ? 'custom' : 'expo') : 'web'; 415 const manager = await this.getPlatformManagerAsync(launchTarget); 416 return manager.openAsync({ runtime }, resolver); 417 } 418 419 /** Open the dev server in a runtime. */ 420 public async openCustomRuntimeAsync( 421 launchTarget: keyof typeof PLATFORM_MANAGERS, 422 launchProps: Partial<BaseOpenInCustomProps> = {}, 423 resolver: BaseResolveDeviceProps<any> = {} 424 ) { 425 const runtime = this.isTargetingNative() ? (this.isDevClient ? 'custom' : 'expo') : 'web'; 426 if (runtime !== 'custom') { 427 throw new CommandError( 428 `dev server cannot open custom runtimes either because it does not target native platforms or because it is not targeting dev clients. (target: ${runtime})` 429 ); 430 } 431 432 const manager = await this.getPlatformManagerAsync(launchTarget); 433 return manager.openAsync({ runtime: 'custom', props: launchProps }, resolver); 434 } 435 436 /** Get the URL for opening in Expo Go. */ 437 protected getExpoGoUrl(): string { 438 return this.getUrlCreator().constructUrl({ scheme: 'exp' }); 439 } 440 441 /** Should use the interstitial page for selecting which runtime to use. */ 442 protected isRedirectPageEnabled(): boolean { 443 return ( 444 !env.EXPO_NO_REDIRECT_PAGE && 445 // if user passed --dev-client flag, skip interstitial page 446 !this.isDevClient && 447 // Checks if dev client is installed. 448 !!resolveFrom.silent(this.projectRoot, 'expo-dev-client') 449 ); 450 } 451 452 /** Get the redirect URL when redirecting is enabled. */ 453 public getRedirectUrl(platform: keyof typeof PLATFORM_MANAGERS | null = null): string | null { 454 if (!this.isRedirectPageEnabled()) { 455 debug('Redirect page is disabled'); 456 return null; 457 } 458 459 return ( 460 this.getUrlCreator().constructLoadingUrl( 461 {}, 462 platform === 'emulator' ? 'android' : platform === 'simulator' ? 'ios' : null 463 ) ?? null 464 ); 465 } 466 467 public getReactDevToolsUrl(): string { 468 return new URL( 469 '_expo/react-devtools', 470 this.getUrlCreator().constructUrl({ scheme: 'http' }) 471 ).toString(); 472 } 473 474 protected async getPlatformManagerAsync(platform: keyof typeof PLATFORM_MANAGERS) { 475 if (!this.platformManagers[platform]) { 476 const Manager = PLATFORM_MANAGERS[platform](); 477 const port = this.getInstance()?.location.port; 478 if (!port || !this.urlCreator) { 479 throw new CommandError( 480 'DEV_SERVER', 481 'Cannot interact with native platforms until dev server has started' 482 ); 483 } 484 debug(`Creating platform manager (platform: ${platform}, port: ${port})`); 485 this.platformManagers[platform] = new Manager(this.projectRoot, port, { 486 getCustomRuntimeUrl: this.urlCreator.constructDevClientUrl.bind(this.urlCreator), 487 getExpoGoUrl: this.getExpoGoUrl.bind(this), 488 getRedirectUrl: this.getRedirectUrl.bind(this, platform), 489 getDevServerUrl: this.getDevServerUrl.bind(this, { hostType: 'localhost' }), 490 }); 491 } 492 return this.platformManagers[platform]; 493 } 494} 495