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