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 tunnel URL from ngrok. */ 366 public getTunnelUrl(): string | null { 367 return this.ngrok?.getActiveUrl() ?? null; 368 } 369 370 /** Open the dev server in a runtime. */ 371 public async openPlatformAsync( 372 launchTarget: keyof typeof PLATFORM_MANAGERS | 'desktop', 373 resolver: BaseResolveDeviceProps<any> = {} 374 ) { 375 if (launchTarget === 'desktop') { 376 const serverUrl = this.getDevServerUrl({ hostType: 'localhost' }); 377 // Allow opening the tunnel URL when using Metro web. 378 const url = this.name === 'metro' ? this.getTunnelUrl() ?? serverUrl : serverUrl; 379 await openBrowserAsync(url!); 380 return { url }; 381 } 382 383 const runtime = this.isTargetingNative() ? (this.isDevClient ? 'custom' : 'expo') : 'web'; 384 const manager = await this.getPlatformManagerAsync(launchTarget); 385 return manager.openAsync({ runtime }, resolver); 386 } 387 388 /** Open the dev server in a runtime. */ 389 public async openCustomRuntimeAsync( 390 launchTarget: keyof typeof PLATFORM_MANAGERS, 391 launchProps: Partial<BaseOpenInCustomProps> = {}, 392 resolver: BaseResolveDeviceProps<any> = {} 393 ) { 394 const runtime = this.isTargetingNative() ? (this.isDevClient ? 'custom' : 'expo') : 'web'; 395 if (runtime !== 'custom') { 396 throw new CommandError( 397 `dev server cannot open custom runtimes either because it does not target native platforms or because it is not targeting dev clients. (target: ${runtime})` 398 ); 399 } 400 401 const manager = await this.getPlatformManagerAsync(launchTarget); 402 return manager.openAsync({ runtime: 'custom', props: launchProps }, resolver); 403 } 404 405 /** Get the URL for opening in Expo Go. */ 406 protected getExpoGoUrl(): string { 407 return this.getUrlCreator().constructUrl({ scheme: 'exp' }); 408 } 409 410 /** Should use the interstitial page for selecting which runtime to use. */ 411 protected isRedirectPageEnabled(): boolean { 412 return ( 413 !env.EXPO_NO_REDIRECT_PAGE && 414 // if user passed --dev-client flag, skip interstitial page 415 !this.isDevClient && 416 // Checks if dev client is installed. 417 !!resolveFrom.silent(this.projectRoot, 'expo-dev-client') 418 ); 419 } 420 421 /** Get the redirect URL when redirecting is enabled. */ 422 public getRedirectUrl(platform: keyof typeof PLATFORM_MANAGERS | null = null): string | null { 423 if (!this.isRedirectPageEnabled()) { 424 debug('Redirect page is disabled'); 425 return null; 426 } 427 428 return ( 429 this.getUrlCreator().constructLoadingUrl( 430 {}, 431 platform === 'emulator' ? 'android' : platform === 'simulator' ? 'ios' : null 432 ) ?? null 433 ); 434 } 435 436 protected async getPlatformManagerAsync(platform: keyof typeof PLATFORM_MANAGERS) { 437 if (!this.platformManagers[platform]) { 438 const Manager = PLATFORM_MANAGERS[platform](); 439 const port = this.getInstance()?.location.port; 440 if (!port || !this.urlCreator) { 441 throw new CommandError( 442 'DEV_SERVER', 443 'Cannot interact with native platforms until dev server has started' 444 ); 445 } 446 debug(`Creating platform manager (platform: ${platform}, port: ${port})`); 447 this.platformManagers[platform] = new Manager(this.projectRoot, port, { 448 getCustomRuntimeUrl: this.urlCreator.constructDevClientUrl.bind(this.urlCreator), 449 getExpoGoUrl: this.getExpoGoUrl.bind(this), 450 getRedirectUrl: this.getRedirectUrl.bind(this, platform), 451 getDevServerUrl: this.getDevServerUrl.bind(this, { hostType: 'localhost' }), 452 }); 453 } 454 return this.platformManagers[platform]; 455 } 456} 457