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'; 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}; 26 27export type DevServerInstance = { 28 /** Bundler dev server instance. */ 29 server: ServerLike; 30 /** Dev server URL location properties. */ 31 location: { 32 url: string; 33 port: number; 34 protocol: 'http' | 'https'; 35 host?: string; 36 }; 37 /** Additional middleware that's attached to the `server`. */ 38 middleware: any; 39 /** Message socket for communicating with the runtime. */ 40 messageSocket: MessageSocket; 41}; 42 43export interface BundlerStartOptions { 44 /** Should the dev server use `https` protocol. */ 45 https?: boolean; 46 /** Should start the dev servers in development mode (minify). */ 47 mode?: 'development' | 'production'; 48 /** Is dev client enabled. */ 49 devClient?: boolean; 50 /** Should run dev servers with clean caches. */ 51 resetDevServer?: boolean; 52 /** Which manifest type to serve. */ 53 forceManifestType?: 'expo-updates' | 'classic'; 54 /** Code signing private key path (defaults to same directory as certificate) */ 55 privateKeyPath?: string; 56 57 /** Max amount of workers (threads) to use with Metro bundler, defaults to undefined for max workers. */ 58 maxWorkers?: number; 59 /** Port to start the dev server on. */ 60 port?: number; 61 62 /** Should start a headless dev server e.g. mock representation to approximate info from a server running in a different process. */ 63 headless?: boolean; 64 /** Should instruct the bundler to create minified bundles. */ 65 minify?: boolean; 66 67 // Webpack options 68 /** Should modify and create PWA icons. */ 69 isImageEditingEnabled?: boolean; 70 71 location: CreateURLOptions; 72} 73 74const PLATFORM_MANAGERS = { 75 simulator: () => 76 require('../platforms/ios/ApplePlatformManager') 77 .ApplePlatformManager as typeof import('../platforms/ios/ApplePlatformManager').ApplePlatformManager, 78 emulator: () => 79 require('../platforms/android/AndroidPlatformManager') 80 .AndroidPlatformManager as typeof import('../platforms/android/AndroidPlatformManager').AndroidPlatformManager, 81}; 82 83const MIDDLEWARES = { 84 classic: () => 85 require('./middleware/ClassicManifestMiddleware') 86 .ClassicManifestMiddleware as typeof import('./middleware/ClassicManifestMiddleware').ClassicManifestMiddleware, 87 'expo-updates': () => 88 require('./middleware/ExpoGoManifestHandlerMiddleware') 89 .ExpoGoManifestHandlerMiddleware as typeof import('./middleware/ExpoGoManifestHandlerMiddleware').ExpoGoManifestHandlerMiddleware, 90}; 91 92export abstract class BundlerDevServer { 93 /** Name of the bundler. */ 94 abstract get name(): string; 95 96 /** Ngrok instance for managing tunnel connections. */ 97 protected ngrok: AsyncNgrok | null = null; 98 /** Interfaces with the Expo 'Development Session' API. */ 99 protected devSession: DevelopmentSession | null = null; 100 /** Http server and related info. */ 101 protected instance: DevServerInstance | null = null; 102 /** Native platform interfaces for opening projects. */ 103 private platformManagers: Record<string, PlatformManager<any>> = {}; 104 /** Manages the creation of dev server URLs. */ 105 protected urlCreator?: UrlCreator | null = null; 106 107 constructor( 108 /** Project root folder. */ 109 public projectRoot: string, 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< 121 BundlerStartOptions, 122 'minify' | 'mode' | 'forceManifestType' | 'privateKeyPath' 123 > = {} 124 ) { 125 const manifestType = options.forceManifestType || 'classic'; 126 assert(manifestType in MIDDLEWARES, `Manifest middleware for type '${manifestType}' not found`); 127 const Middleware = MIDDLEWARES[manifestType](); 128 129 const urlCreator = this.getUrlCreator(); 130 const middleware = new Middleware(this.projectRoot, { 131 constructUrl: urlCreator.constructUrl.bind(urlCreator), 132 mode: options.mode, 133 minify: options.minify, 134 isNativeWebpack: this.name === 'webpack' && this.isTargetingNative(), 135 privateKeyPath: options.privateKeyPath, 136 }); 137 return middleware.getHandler(); 138 } 139 140 /** Start the dev server using settings defined in the start command. */ 141 public async startAsync(options: BundlerStartOptions): Promise<DevServerInstance> { 142 await this.stopAsync(); 143 144 let instance: DevServerInstance; 145 if (options.headless) { 146 instance = await this.startHeadlessAsync(options); 147 } else { 148 instance = await this.startImplementationAsync(options); 149 } 150 151 this.setInstance(instance); 152 await this.postStartAsync(options); 153 return instance; 154 } 155 156 protected abstract startImplementationAsync( 157 options: BundlerStartOptions 158 ): Promise<DevServerInstance>; 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 }, 176 location: { 177 // The port is the main thing we want to send back. 178 port: options.port, 179 // localhost isn't always correct. 180 host: 'localhost', 181 // http is the only supported protocol on native. 182 url: `http://localhost:${options.port}`, 183 protocol: 'http', 184 }, 185 middleware: {}, 186 messageSocket: { 187 broadcast: () => { 188 throw new CommandError('HEADLESS_SERVER', 'Cannot broadcast messages to headless server'); 189 }, 190 }, 191 }; 192 } 193 194 /** 195 * Runs after the `startAsync` function, performing any additional common operations. 196 * You can assume the dev server is started by the time this function is called. 197 */ 198 protected async postStartAsync(options: BundlerStartOptions) { 199 if ( 200 options.location.hostType === 'tunnel' && 201 !APISettings.isOffline && 202 // This is a hack to prevent using tunnel on web since we block it upstream for some reason. 203 this.isTargetingNative() 204 ) { 205 await this._startTunnelAsync(); 206 } 207 await this.startDevSessionAsync(); 208 209 this.watchConfig(); 210 } 211 212 protected abstract getConfigModuleIds(): string[]; 213 214 protected watchConfig() { 215 const notifier = new FileNotifier(this.projectRoot, this.getConfigModuleIds()); 216 notifier.startObserving(); 217 } 218 219 /** Create ngrok instance and start the tunnel server. Exposed for testing. */ 220 public async _startTunnelAsync(): Promise<AsyncNgrok | null> { 221 const port = this.getInstance()?.location.port; 222 if (!port) return null; 223 debug('[ngrok] connect to port: ' + port); 224 this.ngrok = new AsyncNgrok(this.projectRoot, port); 225 await this.ngrok.startAsync(); 226 return this.ngrok; 227 } 228 229 protected async startDevSessionAsync() { 230 // This is used to make Expo Go open the project in either Expo Go, or the web browser. 231 // Must come after ngrok (`startTunnelAsync`) setup. 232 233 if (this.devSession) { 234 this.devSession.stopNotifying(); 235 } 236 237 this.devSession = new DevelopmentSession( 238 this.projectRoot, 239 // This URL will be used on external devices so the computer IP won't be relevant. 240 this.isTargetingNative() 241 ? this.getNativeRuntimeUrl() 242 : this.getDevServerUrl({ hostType: 'localhost' }) 243 ); 244 245 await this.devSession.startAsync({ 246 runtime: this.isTargetingNative() ? 'native' : 'web', 247 }); 248 } 249 250 public isTargetingNative() { 251 // Temporary hack while we implement multi-bundler dev server proxy. 252 return true; 253 } 254 255 public isTargetingWeb() { 256 return false; 257 } 258 259 /** 260 * Sends a message over web sockets to any connected device, 261 * does nothing when the dev server is not running. 262 * 263 * @param method name of the command. In RN projects `reload`, and `devMenu` are available. In Expo Go, `sendDevCommand` is available. 264 * @param params 265 */ 266 public broadcastMessage( 267 method: 'reload' | 'devMenu' | 'sendDevCommand', 268 params?: Record<string, any> 269 ) { 270 this.getInstance()?.messageSocket.broadcast(method, params); 271 } 272 273 /** Get the running dev server instance. */ 274 public getInstance() { 275 return this.instance; 276 } 277 278 /** Stop the running dev server instance. */ 279 async stopAsync() { 280 // Stop the dev session timer and tell Expo API to remove dev session. 281 await this.devSession?.closeAsync(); 282 283 // Stop ngrok if running. 284 await this.ngrok?.stopAsync().catch((e) => { 285 Log.error(`Error stopping ngrok:`); 286 Log.exception(e); 287 }); 288 289 return resolveWithTimeout( 290 () => 291 new Promise<void>((resolve, reject) => { 292 // Close the server. 293 debug(`Stopping dev server (bundler: ${this.name})`); 294 295 if (this.instance?.server) { 296 this.instance.server.close((error) => { 297 debug(`Stopped dev server (bundler: ${this.name})`); 298 this.instance = null; 299 if (error) { 300 reject(error); 301 } else { 302 resolve(); 303 } 304 }); 305 } else { 306 debug(`Stopped dev server (bundler: ${this.name})`); 307 this.instance = null; 308 resolve(); 309 } 310 }), 311 { 312 // NOTE(Bacon): Metro dev server doesn't seem to be closing in time. 313 timeout: 1000, 314 errorMessage: `Timeout waiting for '${this.name}' dev server to close`, 315 } 316 ); 317 } 318 319 protected getUrlCreator(options: Partial<Pick<BundlerStartOptions, 'port' | 'location'>> = {}) { 320 if (!this.urlCreator) { 321 assert(options?.port, 'Dev server instance not found'); 322 this.urlCreator = new UrlCreator(options.location, { 323 port: options.port, 324 getTunnelUrl: this.getTunnelUrl.bind(this), 325 }); 326 } 327 return this.urlCreator; 328 } 329 330 public getNativeRuntimeUrl(opts: Partial<CreateURLOptions> = {}) { 331 return this.isDevClient 332 ? this.getUrlCreator().constructDevClientUrl(opts) ?? this.getDevServerUrl() 333 : this.getUrlCreator().constructUrl({ ...opts, scheme: 'exp' }); 334 } 335 336 /** Get the URL for the running instance of the dev server. */ 337 public getDevServerUrl(options: { hostType?: 'localhost' } = {}): string | null { 338 const instance = this.getInstance(); 339 if (!instance?.location) { 340 return null; 341 } 342 const { location } = instance; 343 if (options.hostType === 'localhost') { 344 return `${location.protocol}://localhost:${location.port}`; 345 } 346 return location.url ?? null; 347 } 348 349 /** Get the tunnel URL from ngrok. */ 350 public getTunnelUrl(): string | null { 351 return this.ngrok?.getActiveUrl() ?? null; 352 } 353 354 /** Open the dev server in a runtime. */ 355 public async openPlatformAsync( 356 launchTarget: keyof typeof PLATFORM_MANAGERS | 'desktop', 357 resolver: BaseResolveDeviceProps<any> = {} 358 ) { 359 if (launchTarget === 'desktop') { 360 const url = this.getDevServerUrl({ hostType: 'localhost' }); 361 await openBrowserAsync(url!); 362 return { url }; 363 } 364 365 const runtime = this.isTargetingNative() ? (this.isDevClient ? 'custom' : 'expo') : 'web'; 366 const manager = await this.getPlatformManagerAsync(launchTarget); 367 return manager.openAsync({ runtime }, resolver); 368 } 369 370 /** Open the dev server in a runtime. */ 371 public async openCustomRuntimeAsync( 372 launchTarget: keyof typeof PLATFORM_MANAGERS, 373 launchProps: Partial<BaseOpenInCustomProps> = {}, 374 resolver: BaseResolveDeviceProps<any> = {} 375 ) { 376 const runtime = this.isTargetingNative() ? (this.isDevClient ? 'custom' : 'expo') : 'web'; 377 if (runtime !== 'custom') { 378 throw new CommandError( 379 `dev server cannot open custom runtimes either because it does not target native platforms or because it is not targeting dev clients. (target: ${runtime})` 380 ); 381 } 382 383 const manager = await this.getPlatformManagerAsync(launchTarget); 384 return manager.openAsync({ runtime: 'custom', props: launchProps }, resolver); 385 } 386 387 /** Should use the interstitial page for selecting which runtime to use. */ 388 protected shouldUseInterstitialPage(): boolean { 389 return ( 390 env.EXPO_ENABLE_INTERSTITIAL_PAGE && 391 // Checks if dev client is installed. 392 !!resolveFrom.silent(this.projectRoot, 'expo-dev-launcher') 393 ); 394 } 395 396 /** Get the URL for opening in Expo Go. */ 397 protected getExpoGoUrl(platform: keyof typeof PLATFORM_MANAGERS): string | null { 398 if (this.shouldUseInterstitialPage()) { 399 const loadingUrl = 400 platform === 'emulator' 401 ? this.urlCreator?.constructLoadingUrl({}, 'android') 402 : this.urlCreator?.constructLoadingUrl({ hostType: 'localhost' }, 'ios'); 403 return loadingUrl ?? null; 404 } 405 406 return this.urlCreator?.constructUrl({ scheme: 'exp' }) ?? null; 407 } 408 409 protected async getPlatformManagerAsync(platform: keyof typeof PLATFORM_MANAGERS) { 410 if (!this.platformManagers[platform]) { 411 const Manager = PLATFORM_MANAGERS[platform](); 412 const port = this.getInstance()?.location.port; 413 if (!port || !this.urlCreator) { 414 throw new CommandError( 415 'DEV_SERVER', 416 'Cannot interact with native platforms until dev server has started' 417 ); 418 } 419 debug(`Creating platform manager (platform: ${platform}, port: ${port})`); 420 this.platformManagers[platform] = new Manager(this.projectRoot, port, { 421 getCustomRuntimeUrl: this.urlCreator.constructDevClientUrl.bind(this.urlCreator), 422 getExpoGoUrl: this.getExpoGoUrl.bind(this, platform), 423 getDevServerUrl: this.getDevServerUrl.bind(this, { hostType: 'localhost' }), 424 }); 425 } 426 return this.platformManagers[platform]; 427 } 428} 429