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