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