1import { MessageSocket } from '@expo/dev-server'; 2import assert from 'assert'; 3import openBrowserAsync from 'better-opn'; 4import chalk from 'chalk'; 5import resolveFrom from 'resolve-from'; 6 7import { APISettings } from '../../api/settings'; 8import * as Log from '../../log'; 9import { FileNotifier } from '../../utils/FileNotifier'; 10import { resolveWithTimeout } from '../../utils/delay'; 11import { env } from '../../utils/env'; 12import { CommandError } from '../../utils/errors'; 13import { 14 BaseOpenInCustomProps, 15 BaseResolveDeviceProps, 16 PlatformManager, 17} from '../platforms/PlatformManager'; 18import { AsyncNgrok } from './AsyncNgrok'; 19import { DevelopmentSession } from './DevelopmentSession'; 20import { CreateURLOptions, UrlCreator } from './UrlCreator'; 21import { PlatformBundlers } from './platformBundlers'; 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}; 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 constructor( 110 /** Project root folder. */ 111 public projectRoot: string, 112 /** A mapping of bundlers to platforms. */ 113 public platformBundlers: PlatformBundlers, 114 // TODO: Replace with custom scheme maybe... 115 public isDevClient?: boolean 116 ) {} 117 118 protected setInstance(instance: DevServerInstance) { 119 this.instance = instance; 120 } 121 122 /** Get the manifest middleware function. */ 123 protected async getManifestMiddlewareAsync( 124 options: Pick< 125 BundlerStartOptions, 126 'minify' | 'mode' | 'forceManifestType' | 'privateKeyPath' 127 > = {} 128 ) { 129 const manifestType = options.forceManifestType || 'classic'; 130 assert(manifestType in MIDDLEWARES, `Manifest middleware for type '${manifestType}' not found`); 131 const Middleware = MIDDLEWARES[manifestType](); 132 133 const urlCreator = this.getUrlCreator(); 134 const middleware = new Middleware(this.projectRoot, { 135 constructUrl: urlCreator.constructUrl.bind(urlCreator), 136 mode: options.mode, 137 minify: options.minify, 138 isNativeWebpack: this.name === 'webpack' && this.isTargetingNative(), 139 privateKeyPath: options.privateKeyPath, 140 }); 141 return middleware.getHandler(); 142 } 143 144 /** Start the dev server using settings defined in the start command. */ 145 public async startAsync(options: BundlerStartOptions): Promise<DevServerInstance> { 146 await this.stopAsync(); 147 148 let instance: DevServerInstance; 149 if (options.headless) { 150 instance = await this.startHeadlessAsync(options); 151 } else { 152 instance = await this.startImplementationAsync(options); 153 } 154 155 this.setInstance(instance); 156 await this.postStartAsync(options); 157 return instance; 158 } 159 160 protected abstract startImplementationAsync( 161 options: BundlerStartOptions 162 ): Promise<DevServerInstance>; 163 164 /** 165 * Creates a mock server representation that can be used to estimate URLs for a server started in another process. 166 * This is used for the run commands where you can reuse the server from a previous run. 167 */ 168 private async startHeadlessAsync(options: BundlerStartOptions): Promise<DevServerInstance> { 169 if (!options.port) 170 throw new CommandError('HEADLESS_SERVER', 'headless dev server requires a port option'); 171 this.urlCreator = this.getUrlCreator(options); 172 173 return { 174 // Create a mock server 175 server: { 176 close: () => { 177 this.instance = null; 178 }, 179 }, 180 location: { 181 // The port is the main thing we want to send back. 182 port: options.port, 183 // localhost isn't always correct. 184 host: 'localhost', 185 // http is the only supported protocol on native. 186 url: `http://localhost:${options.port}`, 187 protocol: 'http', 188 }, 189 middleware: {}, 190 messageSocket: { 191 broadcast: () => { 192 throw new CommandError('HEADLESS_SERVER', 'Cannot broadcast messages to headless server'); 193 }, 194 }, 195 }; 196 } 197 198 /** 199 * Runs after the `startAsync` function, performing any additional common operations. 200 * You can assume the dev server is started by the time this function is called. 201 */ 202 protected async postStartAsync(options: BundlerStartOptions) { 203 if ( 204 options.location.hostType === 'tunnel' && 205 !APISettings.isOffline && 206 // This is a hack to prevent using tunnel on web since we block it upstream for some reason. 207 this.isTargetingNative() 208 ) { 209 await this._startTunnelAsync(); 210 } 211 await this.startDevSessionAsync(); 212 213 this.watchConfig(); 214 } 215 216 protected abstract getConfigModuleIds(): string[]; 217 218 protected watchConfig() { 219 const notifier = new FileNotifier(this.projectRoot, this.getConfigModuleIds()); 220 notifier.startObserving(); 221 } 222 223 /** Create ngrok instance and start the tunnel server. Exposed for testing. */ 224 public async _startTunnelAsync(): Promise<AsyncNgrok | null> { 225 const port = this.getInstance()?.location.port; 226 if (!port) return null; 227 debug('[ngrok] connect to port: ' + port); 228 this.ngrok = new AsyncNgrok(this.projectRoot, port); 229 await this.ngrok.startAsync(); 230 return this.ngrok; 231 } 232 233 protected async startDevSessionAsync() { 234 // This is used to make Expo Go open the project in either Expo Go, or the web browser. 235 // Must come after ngrok (`startTunnelAsync`) setup. 236 237 if (this.devSession) { 238 this.devSession.stopNotifying(); 239 } 240 241 this.devSession = new DevelopmentSession( 242 this.projectRoot, 243 // This URL will be used on external devices so the computer IP won't be relevant. 244 this.isTargetingNative() 245 ? this.getNativeRuntimeUrl() 246 : this.getDevServerUrl({ hostType: 'localhost' }), 247 (error) => { 248 Log.error( 249 chalk.red( 250 '\nAn unexpected error occurred while updating the Dev Client 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 the dev session timer and tell Expo API to remove dev session. 296 await this.devSession?.closeAsync(); 297 298 // Stop ngrok if running. 299 await this.ngrok?.stopAsync().catch((e) => { 300 Log.error(`Error stopping ngrok:`); 301 Log.exception(e); 302 }); 303 304 return resolveWithTimeout( 305 () => 306 new Promise<void>((resolve, reject) => { 307 // Close the server. 308 debug(`Stopping dev server (bundler: ${this.name})`); 309 310 if (this.instance?.server) { 311 this.instance.server.close((error) => { 312 debug(`Stopped dev server (bundler: ${this.name})`); 313 this.instance = null; 314 if (error) { 315 reject(error); 316 } else { 317 resolve(); 318 } 319 }); 320 } else { 321 debug(`Stopped dev server (bundler: ${this.name})`); 322 this.instance = null; 323 resolve(); 324 } 325 }), 326 { 327 // NOTE(Bacon): Metro dev server doesn't seem to be closing in time. 328 timeout: 1000, 329 errorMessage: `Timeout waiting for '${this.name}' dev server to close`, 330 } 331 ); 332 } 333 334 protected getUrlCreator(options: Partial<Pick<BundlerStartOptions, 'port' | 'location'>> = {}) { 335 if (!this.urlCreator) { 336 assert(options?.port, 'Dev server instance not found'); 337 this.urlCreator = new UrlCreator(options.location, { 338 port: options.port, 339 getTunnelUrl: this.getTunnelUrl.bind(this), 340 }); 341 } 342 return this.urlCreator; 343 } 344 345 public getNativeRuntimeUrl(opts: Partial<CreateURLOptions> = {}) { 346 return this.isDevClient 347 ? this.getUrlCreator().constructDevClientUrl(opts) ?? this.getDevServerUrl() 348 : this.getUrlCreator().constructUrl({ ...opts, scheme: 'exp' }); 349 } 350 351 /** Get the URL for the running instance of the dev server. */ 352 public getDevServerUrl(options: { hostType?: 'localhost' } = {}): string | null { 353 const instance = this.getInstance(); 354 if (!instance?.location) { 355 return null; 356 } 357 const { location } = instance; 358 if (options.hostType === 'localhost') { 359 return `${location.protocol}://localhost:${location.port}`; 360 } 361 return location.url ?? null; 362 } 363 364 /** Get the tunnel URL from ngrok. */ 365 public getTunnelUrl(): string | null { 366 return this.ngrok?.getActiveUrl() ?? null; 367 } 368 369 /** Open the dev server in a runtime. */ 370 public async openPlatformAsync( 371 launchTarget: keyof typeof PLATFORM_MANAGERS | 'desktop', 372 resolver: BaseResolveDeviceProps<any> = {} 373 ) { 374 if (launchTarget === 'desktop') { 375 const url = this.getDevServerUrl({ hostType: 'localhost' }); 376 await openBrowserAsync(url!); 377 return { url }; 378 } 379 380 const runtime = this.isTargetingNative() ? (this.isDevClient ? 'custom' : 'expo') : 'web'; 381 const manager = await this.getPlatformManagerAsync(launchTarget); 382 return manager.openAsync({ runtime }, resolver); 383 } 384 385 /** Open the dev server in a runtime. */ 386 public async openCustomRuntimeAsync( 387 launchTarget: keyof typeof PLATFORM_MANAGERS, 388 launchProps: Partial<BaseOpenInCustomProps> = {}, 389 resolver: BaseResolveDeviceProps<any> = {} 390 ) { 391 const runtime = this.isTargetingNative() ? (this.isDevClient ? 'custom' : 'expo') : 'web'; 392 if (runtime !== 'custom') { 393 throw new CommandError( 394 `dev server cannot open custom runtimes either because it does not target native platforms or because it is not targeting dev clients. (target: ${runtime})` 395 ); 396 } 397 398 const manager = await this.getPlatformManagerAsync(launchTarget); 399 return manager.openAsync({ runtime: 'custom', props: launchProps }, resolver); 400 } 401 402 /** Should use the interstitial page for selecting which runtime to use. */ 403 protected shouldUseInterstitialPage(): boolean { 404 return ( 405 env.EXPO_ENABLE_INTERSTITIAL_PAGE && 406 // Checks if dev client is installed. 407 !!resolveFrom.silent(this.projectRoot, 'expo-dev-launcher') 408 ); 409 } 410 411 /** Get the URL for opening in Expo Go. */ 412 protected getExpoGoUrl(platform: keyof typeof PLATFORM_MANAGERS): string | null { 413 if (this.shouldUseInterstitialPage()) { 414 const loadingUrl = 415 platform === 'emulator' 416 ? this.urlCreator?.constructLoadingUrl({}, 'android') 417 : this.urlCreator?.constructLoadingUrl({ hostType: 'localhost' }, 'ios'); 418 return loadingUrl ?? null; 419 } 420 421 return this.urlCreator?.constructUrl({ scheme: 'exp' }) ?? null; 422 } 423 424 protected async getPlatformManagerAsync(platform: keyof typeof PLATFORM_MANAGERS) { 425 if (!this.platformManagers[platform]) { 426 const Manager = PLATFORM_MANAGERS[platform](); 427 const port = this.getInstance()?.location.port; 428 if (!port || !this.urlCreator) { 429 throw new CommandError( 430 'DEV_SERVER', 431 'Cannot interact with native platforms until dev server has started' 432 ); 433 } 434 debug(`Creating platform manager (platform: ${platform}, port: ${port})`); 435 this.platformManagers[platform] = new Manager(this.projectRoot, port, { 436 getCustomRuntimeUrl: this.urlCreator.constructDevClientUrl.bind(this.urlCreator), 437 getExpoGoUrl: this.getExpoGoUrl.bind(this, platform), 438 getDevServerUrl: this.getDevServerUrl.bind(this, { hostType: 'localhost' }), 439 }); 440 } 441 return this.platformManagers[platform]; 442 } 443} 444