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