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