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; 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<boolean> { 167 return false; 168 } 169 170 public async startTypeScriptServices(): Promise<void> { 171 // noop -- We've only implemented this functionality in Metro. 172 } 173 174 public async watchEnvironmentVariables(): Promise<void> { 175 // noop -- We've only implemented this functionality in Metro. 176 } 177 178 /** 179 * Creates a mock server representation that can be used to estimate URLs for a server started in another process. 180 * This is used for the run commands where you can reuse the server from a previous run. 181 */ 182 private async startHeadlessAsync(options: BundlerStartOptions): Promise<DevServerInstance> { 183 if (!options.port) 184 throw new CommandError('HEADLESS_SERVER', 'headless dev server requires a port option'); 185 this.urlCreator = this.getUrlCreator(options); 186 187 return { 188 // Create a mock server 189 server: { 190 close: () => { 191 this.instance = null; 192 }, 193 addListener() {}, 194 }, 195 location: { 196 // The port is the main thing we want to send back. 197 port: options.port, 198 // localhost isn't always correct. 199 host: 'localhost', 200 // http is the only supported protocol on native. 201 url: `http://localhost:${options.port}`, 202 protocol: 'http', 203 }, 204 middleware: {}, 205 messageSocket: { 206 broadcast: () => { 207 throw new CommandError('HEADLESS_SERVER', 'Cannot broadcast messages to headless server'); 208 }, 209 }, 210 }; 211 } 212 213 /** 214 * Runs after the `startAsync` function, performing any additional common operations. 215 * You can assume the dev server is started by the time this function is called. 216 */ 217 protected async postStartAsync(options: BundlerStartOptions) { 218 if ( 219 options.location.hostType === 'tunnel' && 220 !APISettings.isOffline && 221 // This is a hack to prevent using tunnel on web since we block it upstream for some reason. 222 this.isTargetingNative() 223 ) { 224 await this._startTunnelAsync(); 225 } 226 await this.startDevSessionAsync(); 227 228 this.watchConfig(); 229 } 230 231 protected abstract getConfigModuleIds(): string[]; 232 233 protected watchConfig() { 234 this.notifier?.stopObserving(); 235 this.notifier = new FileNotifier(this.projectRoot, this.getConfigModuleIds()); 236 this.notifier.startObserving(); 237 } 238 239 /** Create ngrok instance and start the tunnel server. Exposed for testing. */ 240 public async _startTunnelAsync(): Promise<AsyncNgrok | null> { 241 const port = this.getInstance()?.location.port; 242 if (!port) return null; 243 debug('[ngrok] connect to port: ' + port); 244 this.ngrok = new AsyncNgrok(this.projectRoot, port); 245 await this.ngrok.startAsync(); 246 return this.ngrok; 247 } 248 249 protected async startDevSessionAsync() { 250 // This is used to make Expo Go open the project in either Expo Go, or the web browser. 251 // Must come after ngrok (`startTunnelAsync`) setup. 252 this.devSession?.stopNotifying?.(); 253 this.devSession = new DevelopmentSession( 254 this.projectRoot, 255 // This URL will be used on external devices so the computer IP won't be relevant. 256 this.isTargetingNative() 257 ? this.getNativeRuntimeUrl() 258 : this.getDevServerUrl({ hostType: 'localhost' }), 259 () => { 260 // TODO: This appears to be happening consistently after an hour. 261 // We should investigate why this is happening and fix it on our servers. 262 // Log.error( 263 // chalk.red( 264 // '\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.' 265 // ) 266 // ); 267 // Log.exception(error); 268 this.devSession?.closeAsync().catch((error) => { 269 debug('[dev-session] error closing: ' + error.message); 270 }); 271 } 272 ); 273 274 await this.devSession.startAsync({ 275 runtime: this.isTargetingNative() ? 'native' : 'web', 276 }); 277 } 278 279 public isTargetingNative() { 280 // Temporary hack while we implement multi-bundler dev server proxy. 281 return true; 282 } 283 284 public isTargetingWeb() { 285 return this.platformBundlers.web === this.name; 286 } 287 288 /** 289 * Sends a message over web sockets to any connected device, 290 * does nothing when the dev server is not running. 291 * 292 * @param method name of the command. In RN projects `reload`, and `devMenu` are available. In Expo Go, `sendDevCommand` is available. 293 * @param params 294 */ 295 public broadcastMessage( 296 method: 'reload' | 'devMenu' | 'sendDevCommand', 297 params?: Record<string, any> 298 ) { 299 this.getInstance()?.messageSocket.broadcast(method, params); 300 } 301 302 /** Get the running dev server instance. */ 303 public getInstance() { 304 return this.instance; 305 } 306 307 /** Stop the running dev server instance. */ 308 async stopAsync() { 309 // Stop file watching. 310 this.notifier?.stopObserving(); 311 312 // Stop the dev session timer and tell Expo API to remove dev session. 313 await this.devSession?.closeAsync(); 314 315 // Stop ngrok if running. 316 await this.ngrok?.stopAsync().catch((e) => { 317 Log.error(`Error stopping ngrok:`); 318 Log.exception(e); 319 }); 320 321 return resolveWithTimeout( 322 () => 323 new Promise<void>((resolve, reject) => { 324 // Close the server. 325 debug(`Stopping dev server (bundler: ${this.name})`); 326 327 if (this.instance?.server) { 328 this.instance.server.close((error) => { 329 debug(`Stopped dev server (bundler: ${this.name})`); 330 this.instance = null; 331 if (error) { 332 reject(error); 333 } else { 334 resolve(); 335 } 336 }); 337 } else { 338 debug(`Stopped dev server (bundler: ${this.name})`); 339 this.instance = null; 340 resolve(); 341 } 342 }), 343 { 344 // NOTE(Bacon): Metro dev server doesn't seem to be closing in time. 345 timeout: 1000, 346 errorMessage: `Timeout waiting for '${this.name}' dev server to close`, 347 } 348 ); 349 } 350 351 protected getUrlCreator(options: Partial<Pick<BundlerStartOptions, 'port' | 'location'>> = {}) { 352 if (!this.urlCreator) { 353 assert(options?.port, 'Dev server instance not found'); 354 this.urlCreator = new UrlCreator(options.location, { 355 port: options.port, 356 getTunnelUrl: this.getTunnelUrl.bind(this), 357 }); 358 } 359 return this.urlCreator; 360 } 361 362 public getNativeRuntimeUrl(opts: Partial<CreateURLOptions> = {}) { 363 return this.isDevClient 364 ? this.getUrlCreator().constructDevClientUrl(opts) ?? this.getDevServerUrl() 365 : this.getUrlCreator().constructUrl({ ...opts, scheme: 'exp' }); 366 } 367 368 /** Get the URL for the running instance of the dev server. */ 369 public getDevServerUrl(options: { hostType?: 'localhost' } = {}): string | null { 370 const instance = this.getInstance(); 371 if (!instance?.location) { 372 return null; 373 } 374 const { location } = instance; 375 if (options.hostType === 'localhost') { 376 return `${location.protocol}://localhost:${location.port}`; 377 } 378 return location.url ?? null; 379 } 380 381 /** Get the base URL for JS inspector */ 382 public getJsInspectorBaseUrl(): string { 383 if (this.name !== 'metro') { 384 throw new CommandError( 385 'DEV_SERVER', 386 `Cannot get the JS inspector base url - bundler[${this.name}]` 387 ); 388 } 389 return this.getUrlCreator().constructUrl({ scheme: 'http' }); 390 } 391 392 /** Get the tunnel URL from ngrok. */ 393 public getTunnelUrl(): string | null { 394 return this.ngrok?.getActiveUrl() ?? null; 395 } 396 397 /** Open the dev server in a runtime. */ 398 public async openPlatformAsync( 399 launchTarget: keyof typeof PLATFORM_MANAGERS | 'desktop', 400 resolver: BaseResolveDeviceProps<any> = {} 401 ) { 402 if (launchTarget === 'desktop') { 403 const serverUrl = this.getDevServerUrl({ hostType: 'localhost' }); 404 // Allow opening the tunnel URL when using Metro web. 405 const url = this.name === 'metro' ? this.getTunnelUrl() ?? serverUrl : serverUrl; 406 await openBrowserAsync(url!); 407 return { url }; 408 } 409 410 const runtime = this.isTargetingNative() ? (this.isDevClient ? 'custom' : 'expo') : 'web'; 411 const manager = await this.getPlatformManagerAsync(launchTarget); 412 return manager.openAsync({ runtime }, resolver); 413 } 414 415 /** Open the dev server in a runtime. */ 416 public async openCustomRuntimeAsync( 417 launchTarget: keyof typeof PLATFORM_MANAGERS, 418 launchProps: Partial<BaseOpenInCustomProps> = {}, 419 resolver: BaseResolveDeviceProps<any> = {} 420 ) { 421 const runtime = this.isTargetingNative() ? (this.isDevClient ? 'custom' : 'expo') : 'web'; 422 if (runtime !== 'custom') { 423 throw new CommandError( 424 `dev server cannot open custom runtimes either because it does not target native platforms or because it is not targeting dev clients. (target: ${runtime})` 425 ); 426 } 427 428 const manager = await this.getPlatformManagerAsync(launchTarget); 429 return manager.openAsync({ runtime: 'custom', props: launchProps }, resolver); 430 } 431 432 /** Get the URL for opening in Expo Go. */ 433 protected getExpoGoUrl(): string { 434 return this.getUrlCreator().constructUrl({ scheme: 'exp' }); 435 } 436 437 /** Should use the interstitial page for selecting which runtime to use. */ 438 protected isRedirectPageEnabled(): boolean { 439 return ( 440 !env.EXPO_NO_REDIRECT_PAGE && 441 // if user passed --dev-client flag, skip interstitial page 442 !this.isDevClient && 443 // Checks if dev client is installed. 444 !!resolveFrom.silent(this.projectRoot, 'expo-dev-client') 445 ); 446 } 447 448 /** Get the redirect URL when redirecting is enabled. */ 449 public getRedirectUrl(platform: keyof typeof PLATFORM_MANAGERS | null = null): string | null { 450 if (!this.isRedirectPageEnabled()) { 451 debug('Redirect page is disabled'); 452 return null; 453 } 454 455 return ( 456 this.getUrlCreator().constructLoadingUrl( 457 {}, 458 platform === 'emulator' ? 'android' : platform === 'simulator' ? 'ios' : null 459 ) ?? null 460 ); 461 } 462 463 public getReactDevToolsUrl(): string { 464 return new URL( 465 '_expo/react-devtools', 466 this.getUrlCreator().constructUrl({ scheme: 'http' }) 467 ).toString(); 468 } 469 470 protected async getPlatformManagerAsync(platform: keyof typeof PLATFORM_MANAGERS) { 471 if (!this.platformManagers[platform]) { 472 const Manager = PLATFORM_MANAGERS[platform](); 473 const port = this.getInstance()?.location.port; 474 if (!port || !this.urlCreator) { 475 throw new CommandError( 476 'DEV_SERVER', 477 'Cannot interact with native platforms until dev server has started' 478 ); 479 } 480 debug(`Creating platform manager (platform: ${platform}, port: ${port})`); 481 this.platformManagers[platform] = new Manager(this.projectRoot, port, { 482 getCustomRuntimeUrl: this.urlCreator.constructDevClientUrl.bind(this.urlCreator), 483 getExpoGoUrl: this.getExpoGoUrl.bind(this), 484 getRedirectUrl: this.getRedirectUrl.bind(this, platform), 485 getDevServerUrl: this.getDevServerUrl.bind(this, { hostType: 'localhost' }), 486 }); 487 } 488 return this.platformManagers[platform]; 489 } 490} 491