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