1import { MessageSocket } from '@expo/dev-server'; 2import assert from 'assert'; 3import resolveFrom from 'resolve-from'; 4 5import { AsyncNgrok } from './AsyncNgrok'; 6import { DevelopmentSession } from './DevelopmentSession'; 7import { CreateURLOptions, UrlCreator } from './UrlCreator'; 8import { PlatformBundlers } from './platformBundlers'; 9import * as Log from '../../log'; 10import { FileNotifier } from '../../utils/FileNotifier'; 11import { resolveWithTimeout } from '../../utils/delay'; 12import { env } from '../../utils/env'; 13import { CommandError } from '../../utils/errors'; 14import { openBrowserAsync } from '../../utils/open'; 15import { 16 BaseOpenInCustomProps, 17 BaseResolveDeviceProps, 18 PlatformManager, 19} from '../platforms/PlatformManager'; 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 /** Code signing private key path (defaults to same directory as certificate) */ 54 privateKeyPath?: string; 55 56 /** Max amount of workers (threads) to use with Metro bundler, defaults to undefined for max workers. */ 57 maxWorkers?: number; 58 /** Port to start the dev server on. */ 59 port?: number; 60 61 /** Should start a headless dev server e.g. mock representation to approximate info from a server running in a different process. */ 62 headless?: boolean; 63 /** Should instruct the bundler to create minified bundles. */ 64 minify?: boolean; 65 66 /** Will the bundler be used for exporting. NOTE: This is an odd option to pass to the dev server. */ 67 isExporting?: 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 85export abstract class BundlerDevServer { 86 /** Name of the bundler. */ 87 abstract get name(): string; 88 89 /** Ngrok instance for managing tunnel connections. */ 90 protected ngrok: AsyncNgrok | null = null; 91 /** Interfaces with the Expo 'Development Session' API. */ 92 protected devSession: DevelopmentSession | null = null; 93 /** Http server and related info. */ 94 protected instance: DevServerInstance | null = null; 95 /** Native platform interfaces for opening projects. */ 96 private platformManagers: Record<string, PlatformManager<any>> = {}; 97 /** Manages the creation of dev server URLs. */ 98 protected urlCreator?: UrlCreator | null = null; 99 100 private notifier: FileNotifier | null = null; 101 102 constructor( 103 /** Project root folder. */ 104 public projectRoot: string, 105 /** A mapping of bundlers to platforms. */ 106 public platformBundlers: PlatformBundlers, 107 // TODO: Replace with custom scheme maybe... 108 public isDevClient?: boolean 109 ) {} 110 111 protected setInstance(instance: DevServerInstance) { 112 this.instance = instance; 113 } 114 115 /** Get the manifest middleware function. */ 116 protected async getManifestMiddlewareAsync( 117 options: Pick<BundlerStartOptions, 'minify' | 'mode' | 'privateKeyPath'> = {} 118 ) { 119 const Middleware = require('./middleware/ExpoGoManifestHandlerMiddleware') 120 .ExpoGoManifestHandlerMiddleware as typeof import('./middleware/ExpoGoManifestHandlerMiddleware').ExpoGoManifestHandlerMiddleware; 121 122 const urlCreator = this.getUrlCreator(); 123 const middleware = new Middleware(this.projectRoot, { 124 constructUrl: urlCreator.constructUrl.bind(urlCreator), 125 mode: options.mode, 126 minify: options.minify, 127 isNativeWebpack: this.name === 'webpack' && this.isTargetingNative(), 128 privateKeyPath: options.privateKeyPath, 129 }); 130 return middleware; 131 } 132 133 /** Start the dev server using settings defined in the start command. */ 134 public async startAsync(options: BundlerStartOptions): Promise<DevServerInstance> { 135 await this.stopAsync(); 136 137 let instance: DevServerInstance; 138 if (options.headless) { 139 instance = await this.startHeadlessAsync(options); 140 } else { 141 instance = await this.startImplementationAsync(options); 142 } 143 144 this.setInstance(instance); 145 await this.postStartAsync(options); 146 return instance; 147 } 148 149 protected abstract startImplementationAsync( 150 options: BundlerStartOptions 151 ): Promise<DevServerInstance>; 152 153 public async waitForTypeScriptAsync(): Promise<boolean> { 154 return false; 155 } 156 157 public abstract startTypeScriptServices(): Promise<void>; 158 159 public async watchEnvironmentVariables(): Promise<void> { 160 // noop -- We've only implemented this functionality in Metro. 161 } 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 addListener() {}, 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 !env.EXPO_OFFLINE && 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 this.notifier?.stopObserving(); 220 this.notifier = new FileNotifier(this.projectRoot, this.getConfigModuleIds()); 221 this.notifier.startObserving(); 222 } 223 224 /** Create ngrok instance and start the tunnel server. Exposed for testing. */ 225 public async _startTunnelAsync(): Promise<AsyncNgrok | null> { 226 const port = this.getInstance()?.location.port; 227 if (!port) return null; 228 debug('[ngrok] connect to port: ' + port); 229 this.ngrok = new AsyncNgrok(this.projectRoot, port); 230 await this.ngrok.startAsync(); 231 return this.ngrok; 232 } 233 234 protected async startDevSessionAsync() { 235 // This is used to make Expo Go open the project in either Expo Go, or the web browser. 236 // Must come after ngrok (`startTunnelAsync`) setup. 237 this.devSession?.stopNotifying?.(); 238 this.devSession = new DevelopmentSession( 239 this.projectRoot, 240 // This URL will be used on external devices so the computer IP won't be relevant. 241 this.isTargetingNative() 242 ? this.getNativeRuntimeUrl() 243 : this.getDevServerUrl({ hostType: 'localhost' }), 244 () => { 245 // TODO: This appears to be happening consistently after an hour. 246 // We should investigate why this is happening and fix it on our servers. 247 // Log.error( 248 // chalk.red( 249 // '\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.' 250 // ) 251 // ); 252 // Log.exception(error); 253 this.devSession?.closeAsync().catch((error) => { 254 debug('[dev-session] error closing: ' + error.message); 255 }); 256 } 257 ); 258 259 await this.devSession.startAsync({ 260 runtime: this.isTargetingNative() ? 'native' : 'web', 261 }); 262 } 263 264 public isTargetingNative() { 265 // Temporary hack while we implement multi-bundler dev server proxy. 266 return true; 267 } 268 269 public isTargetingWeb() { 270 return this.platformBundlers.web === this.name; 271 } 272 273 /** 274 * Sends a message over web sockets to any connected device, 275 * does nothing when the dev server is not running. 276 * 277 * @param method name of the command. In RN projects `reload`, and `devMenu` are available. In Expo Go, `sendDevCommand` is available. 278 * @param params 279 */ 280 public broadcastMessage( 281 method: 'reload' | 'devMenu' | 'sendDevCommand', 282 params?: Record<string, any> 283 ) { 284 this.getInstance()?.messageSocket.broadcast(method, params); 285 } 286 287 /** Get the running dev server instance. */ 288 public getInstance() { 289 return this.instance; 290 } 291 292 /** Stop the running dev server instance. */ 293 async stopAsync() { 294 // Stop file watching. 295 this.notifier?.stopObserving(); 296 297 // Stop the dev session timer and tell Expo API to remove dev session. 298 await this.devSession?.closeAsync(); 299 300 // Stop ngrok if running. 301 await this.ngrok?.stopAsync().catch((e) => { 302 Log.error(`Error stopping ngrok:`); 303 Log.exception(e); 304 }); 305 306 return resolveWithTimeout( 307 () => 308 new Promise<void>((resolve, reject) => { 309 // Close the server. 310 debug(`Stopping dev server (bundler: ${this.name})`); 311 312 if (this.instance?.server) { 313 this.instance.server.close((error) => { 314 debug(`Stopped dev server (bundler: ${this.name})`); 315 this.instance = null; 316 if (error) { 317 reject(error); 318 } else { 319 resolve(); 320 } 321 }); 322 } else { 323 debug(`Stopped dev server (bundler: ${this.name})`); 324 this.instance = null; 325 resolve(); 326 } 327 }), 328 { 329 // NOTE(Bacon): Metro dev server doesn't seem to be closing in time. 330 timeout: 1000, 331 errorMessage: `Timeout waiting for '${this.name}' dev server to close`, 332 } 333 ); 334 } 335 336 public getUrlCreator(options: Partial<Pick<BundlerStartOptions, 'port' | 'location'>> = {}) { 337 if (!this.urlCreator) { 338 assert(options?.port, 'Dev server instance not found'); 339 this.urlCreator = new UrlCreator(options.location, { 340 port: options.port, 341 getTunnelUrl: this.getTunnelUrl.bind(this), 342 }); 343 } 344 return this.urlCreator; 345 } 346 347 public getNativeRuntimeUrl(opts: Partial<CreateURLOptions> = {}) { 348 return this.isDevClient 349 ? this.getUrlCreator().constructDevClientUrl(opts) ?? this.getDevServerUrl() 350 : this.getUrlCreator().constructUrl({ ...opts, scheme: 'exp' }); 351 } 352 353 /** Get the URL for the running instance of the dev server. */ 354 public getDevServerUrl(options: { hostType?: 'localhost' } = {}): string | null { 355 const instance = this.getInstance(); 356 if (!instance?.location) { 357 return null; 358 } 359 const { location } = instance; 360 if (options.hostType === 'localhost') { 361 return `${location.protocol}://localhost:${location.port}`; 362 } 363 return location.url ?? null; 364 } 365 366 /** Get the base URL for JS inspector */ 367 public getJsInspectorBaseUrl(): string { 368 if (this.name !== 'metro') { 369 throw new CommandError( 370 'DEV_SERVER', 371 `Cannot get the JS inspector base url - bundler[${this.name}]` 372 ); 373 } 374 return this.getUrlCreator().constructUrl({ scheme: 'http' }); 375 } 376 377 /** Get the tunnel URL from ngrok. */ 378 public getTunnelUrl(): string | null { 379 return this.ngrok?.getActiveUrl() ?? null; 380 } 381 382 /** Open the dev server in a runtime. */ 383 public async openPlatformAsync( 384 launchTarget: keyof typeof PLATFORM_MANAGERS | 'desktop', 385 resolver: BaseResolveDeviceProps<any> = {} 386 ) { 387 if (launchTarget === 'desktop') { 388 const serverUrl = this.getDevServerUrl({ hostType: 'localhost' }); 389 // Allow opening the tunnel URL when using Metro web. 390 const url = this.name === 'metro' ? this.getTunnelUrl() ?? serverUrl : serverUrl; 391 await openBrowserAsync(url!); 392 return { url }; 393 } 394 395 const runtime = this.isTargetingNative() ? (this.isDevClient ? 'custom' : 'expo') : 'web'; 396 const manager = await this.getPlatformManagerAsync(launchTarget); 397 return manager.openAsync({ runtime }, resolver); 398 } 399 400 /** Open the dev server in a runtime. */ 401 public async openCustomRuntimeAsync( 402 launchTarget: keyof typeof PLATFORM_MANAGERS, 403 launchProps: Partial<BaseOpenInCustomProps> = {}, 404 resolver: BaseResolveDeviceProps<any> = {} 405 ) { 406 const runtime = this.isTargetingNative() ? (this.isDevClient ? 'custom' : 'expo') : 'web'; 407 if (runtime !== 'custom') { 408 throw new CommandError( 409 `dev server cannot open custom runtimes either because it does not target native platforms or because it is not targeting dev clients. (target: ${runtime})` 410 ); 411 } 412 413 const manager = await this.getPlatformManagerAsync(launchTarget); 414 return manager.openAsync({ runtime: 'custom', props: launchProps }, resolver); 415 } 416 417 /** Get the URL for opening in Expo Go. */ 418 protected getExpoGoUrl(): string { 419 return this.getUrlCreator().constructUrl({ scheme: 'exp' }); 420 } 421 422 /** Should use the interstitial page for selecting which runtime to use. */ 423 protected isRedirectPageEnabled(): boolean { 424 return ( 425 !env.EXPO_NO_REDIRECT_PAGE && 426 // if user passed --dev-client flag, skip interstitial page 427 !this.isDevClient && 428 // Checks if dev client is installed. 429 !!resolveFrom.silent(this.projectRoot, 'expo-dev-client') 430 ); 431 } 432 433 /** Get the redirect URL when redirecting is enabled. */ 434 public getRedirectUrl(platform: keyof typeof PLATFORM_MANAGERS | null = null): string | null { 435 if (!this.isRedirectPageEnabled()) { 436 debug('Redirect page is disabled'); 437 return null; 438 } 439 440 return ( 441 this.getUrlCreator().constructLoadingUrl( 442 {}, 443 platform === 'emulator' ? 'android' : platform === 'simulator' ? 'ios' : null 444 ) ?? null 445 ); 446 } 447 448 public getReactDevToolsUrl(): string { 449 return new URL( 450 '_expo/react-devtools', 451 this.getUrlCreator().constructUrl({ scheme: 'http' }) 452 ).toString(); 453 } 454 455 protected async getPlatformManagerAsync(platform: keyof typeof PLATFORM_MANAGERS) { 456 if (!this.platformManagers[platform]) { 457 const Manager = PLATFORM_MANAGERS[platform](); 458 const port = this.getInstance()?.location.port; 459 if (!port || !this.urlCreator) { 460 throw new CommandError( 461 'DEV_SERVER', 462 'Cannot interact with native platforms until dev server has started' 463 ); 464 } 465 debug(`Creating platform manager (platform: ${platform}, port: ${port})`); 466 this.platformManagers[platform] = new Manager(this.projectRoot, port, { 467 getCustomRuntimeUrl: this.urlCreator.constructDevClientUrl.bind(this.urlCreator), 468 getExpoGoUrl: this.getExpoGoUrl.bind(this), 469 getRedirectUrl: this.getRedirectUrl.bind(this, platform), 470 getDevServerUrl: this.getDevServerUrl.bind(this, { hostType: 'localhost' }), 471 }); 472 } 473 return this.platformManagers[platform]; 474 } 475} 476