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