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 248 await this.devSession.startAsync({ 249 runtime: this.isTargetingNative() ? 'native' : 'web', 250 }); 251 } 252 253 public isTargetingNative() { 254 // Temporary hack while we implement multi-bundler dev server proxy. 255 return true; 256 } 257 258 public isTargetingWeb() { 259 return this.platformBundlers.web === this.name; 260 } 261 262 /** 263 * Sends a message over web sockets to any connected device, 264 * does nothing when the dev server is not running. 265 * 266 * @param method name of the command. In RN projects `reload`, and `devMenu` are available. In Expo Go, `sendDevCommand` is available. 267 * @param params 268 */ 269 public broadcastMessage( 270 method: 'reload' | 'devMenu' | 'sendDevCommand', 271 params?: Record<string, any> 272 ) { 273 this.getInstance()?.messageSocket.broadcast(method, params); 274 } 275 276 /** Get the running dev server instance. */ 277 public getInstance() { 278 return this.instance; 279 } 280 281 /** Stop the running dev server instance. */ 282 async stopAsync() { 283 // Stop the dev session timer and tell Expo API to remove dev session. 284 await this.devSession?.closeAsync(); 285 286 // Stop ngrok if running. 287 await this.ngrok?.stopAsync().catch((e) => { 288 Log.error(`Error stopping ngrok:`); 289 Log.exception(e); 290 }); 291 292 return resolveWithTimeout( 293 () => 294 new Promise<void>((resolve, reject) => { 295 // Close the server. 296 debug(`Stopping dev server (bundler: ${this.name})`); 297 298 if (this.instance?.server) { 299 this.instance.server.close((error) => { 300 debug(`Stopped dev server (bundler: ${this.name})`); 301 this.instance = null; 302 if (error) { 303 reject(error); 304 } else { 305 resolve(); 306 } 307 }); 308 } else { 309 debug(`Stopped dev server (bundler: ${this.name})`); 310 this.instance = null; 311 resolve(); 312 } 313 }), 314 { 315 // NOTE(Bacon): Metro dev server doesn't seem to be closing in time. 316 timeout: 1000, 317 errorMessage: `Timeout waiting for '${this.name}' dev server to close`, 318 } 319 ); 320 } 321 322 protected getUrlCreator(options: Partial<Pick<BundlerStartOptions, 'port' | 'location'>> = {}) { 323 if (!this.urlCreator) { 324 assert(options?.port, 'Dev server instance not found'); 325 this.urlCreator = new UrlCreator(options.location, { 326 port: options.port, 327 getTunnelUrl: this.getTunnelUrl.bind(this), 328 }); 329 } 330 return this.urlCreator; 331 } 332 333 public getNativeRuntimeUrl(opts: Partial<CreateURLOptions> = {}) { 334 return this.isDevClient 335 ? this.getUrlCreator().constructDevClientUrl(opts) ?? this.getDevServerUrl() 336 : this.getUrlCreator().constructUrl({ ...opts, scheme: 'exp' }); 337 } 338 339 /** Get the URL for the running instance of the dev server. */ 340 public getDevServerUrl(options: { hostType?: 'localhost' } = {}): string | null { 341 const instance = this.getInstance(); 342 if (!instance?.location) { 343 return null; 344 } 345 const { location } = instance; 346 if (options.hostType === 'localhost') { 347 return `${location.protocol}://localhost:${location.port}`; 348 } 349 return location.url ?? null; 350 } 351 352 /** Get the tunnel URL from ngrok. */ 353 public getTunnelUrl(): string | null { 354 return this.ngrok?.getActiveUrl() ?? null; 355 } 356 357 /** Open the dev server in a runtime. */ 358 public async openPlatformAsync( 359 launchTarget: keyof typeof PLATFORM_MANAGERS | 'desktop', 360 resolver: BaseResolveDeviceProps<any> = {} 361 ) { 362 if (launchTarget === 'desktop') { 363 const url = this.getDevServerUrl({ hostType: 'localhost' }); 364 await openBrowserAsync(url!); 365 return { url }; 366 } 367 368 const runtime = this.isTargetingNative() ? (this.isDevClient ? 'custom' : 'expo') : 'web'; 369 const manager = await this.getPlatformManagerAsync(launchTarget); 370 return manager.openAsync({ runtime }, resolver); 371 } 372 373 /** Open the dev server in a runtime. */ 374 public async openCustomRuntimeAsync( 375 launchTarget: keyof typeof PLATFORM_MANAGERS, 376 launchProps: Partial<BaseOpenInCustomProps> = {}, 377 resolver: BaseResolveDeviceProps<any> = {} 378 ) { 379 const runtime = this.isTargetingNative() ? (this.isDevClient ? 'custom' : 'expo') : 'web'; 380 if (runtime !== 'custom') { 381 throw new CommandError( 382 `dev server cannot open custom runtimes either because it does not target native platforms or because it is not targeting dev clients. (target: ${runtime})` 383 ); 384 } 385 386 const manager = await this.getPlatformManagerAsync(launchTarget); 387 return manager.openAsync({ runtime: 'custom', props: launchProps }, resolver); 388 } 389 390 /** Should use the interstitial page for selecting which runtime to use. */ 391 protected shouldUseInterstitialPage(): boolean { 392 return ( 393 env.EXPO_ENABLE_INTERSTITIAL_PAGE && 394 // Checks if dev client is installed. 395 !!resolveFrom.silent(this.projectRoot, 'expo-dev-launcher') 396 ); 397 } 398 399 /** Get the URL for opening in Expo Go. */ 400 protected getExpoGoUrl(platform: keyof typeof PLATFORM_MANAGERS): string | null { 401 if (this.shouldUseInterstitialPage()) { 402 const loadingUrl = 403 platform === 'emulator' 404 ? this.urlCreator?.constructLoadingUrl({}, 'android') 405 : this.urlCreator?.constructLoadingUrl({ hostType: 'localhost' }, 'ios'); 406 return loadingUrl ?? null; 407 } 408 409 return this.urlCreator?.constructUrl({ scheme: 'exp' }) ?? null; 410 } 411 412 protected async getPlatformManagerAsync(platform: keyof typeof PLATFORM_MANAGERS) { 413 if (!this.platformManagers[platform]) { 414 const Manager = PLATFORM_MANAGERS[platform](); 415 const port = this.getInstance()?.location.port; 416 if (!port || !this.urlCreator) { 417 throw new CommandError( 418 'DEV_SERVER', 419 'Cannot interact with native platforms until dev server has started' 420 ); 421 } 422 debug(`Creating platform manager (platform: ${platform}, port: ${port})`); 423 this.platformManagers[platform] = new Manager(this.projectRoot, port, { 424 getCustomRuntimeUrl: this.urlCreator.constructDevClientUrl.bind(this.urlCreator), 425 getExpoGoUrl: this.getExpoGoUrl.bind(this, platform), 426 getDevServerUrl: this.getDevServerUrl.bind(this, { hostType: 'localhost' }), 427 }); 428 } 429 return this.platformManagers[platform]; 430 } 431} 432