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 { BaseResolveDeviceProps, PlatformManager } from '../platforms/PlatformManager'; 13import { AsyncNgrok } from './AsyncNgrok'; 14import { DevelopmentSession } from './DevelopmentSession'; 15import { CreateURLOptions, UrlCreator } from './UrlCreator'; 16 17export type ServerLike = { 18 close(callback?: (err?: Error) => void): void; 19}; 20 21export type DevServerInstance = { 22 /** Bundler dev server instance. */ 23 server: ServerLike; 24 /** Dev server URL location properties. */ 25 location: { 26 url: string; 27 port: number; 28 protocol: 'http' | 'https'; 29 host?: string; 30 }; 31 /** Additional middleware that's attached to the `server`. */ 32 middleware: any; 33 /** Message socket for communicating with the runtime. */ 34 messageSocket: MessageSocket; 35}; 36 37export interface BundlerStartOptions { 38 /** Should the dev server use `https` protocol. */ 39 https?: boolean; 40 /** Should start the dev servers in development mode (minify). */ 41 mode?: 'development' | 'production'; 42 /** Is dev client enabled. */ 43 devClient?: boolean; 44 /** Should run dev servers with clean caches. */ 45 resetDevServer?: boolean; 46 /** Which manifest type to serve. */ 47 forceManifestType?: 'expo-updates' | 'classic'; 48 /** Code signing private key path (defaults to same directory as certificate) */ 49 privateKeyPath?: string; 50 51 /** Max amount of workers (threads) to use with Metro bundler, defaults to undefined for max workers. */ 52 maxWorkers?: number; 53 /** Port to start the dev server on. */ 54 port?: number; 55 56 /** Should instruct the bundler to create minified bundles. */ 57 minify?: boolean; 58 59 // Webpack options 60 /** Should modify and create PWA icons. */ 61 isImageEditingEnabled?: boolean; 62 63 location: CreateURLOptions; 64} 65 66const PLATFORM_MANAGERS = { 67 simulator: () => 68 require('../platforms/ios/ApplePlatformManager') 69 .ApplePlatformManager as typeof import('../platforms/ios/ApplePlatformManager').ApplePlatformManager, 70 emulator: () => 71 require('../platforms/android/AndroidPlatformManager') 72 .AndroidPlatformManager as typeof import('../platforms/android/AndroidPlatformManager').AndroidPlatformManager, 73}; 74 75const MIDDLEWARES = { 76 classic: () => 77 require('./middleware/ClassicManifestMiddleware') 78 .ClassicManifestMiddleware as typeof import('./middleware/ClassicManifestMiddleware').ClassicManifestMiddleware, 79 'expo-updates': () => 80 require('./middleware/ExpoGoManifestHandlerMiddleware') 81 .ExpoGoManifestHandlerMiddleware as typeof import('./middleware/ExpoGoManifestHandlerMiddleware').ExpoGoManifestHandlerMiddleware, 82}; 83 84export abstract class BundlerDevServer { 85 /** Name of the bundler. */ 86 abstract get name(): string; 87 88 /** Ngrok instance for managing tunnel connections. */ 89 protected ngrok: AsyncNgrok | null = null; 90 /** Interfaces with the Expo 'Development Session' API. */ 91 protected devSession: DevelopmentSession | null = null; 92 /** Http server and related info. */ 93 protected instance: DevServerInstance | null = null; 94 /** Native platform interfaces for opening projects. */ 95 private platformManagers: Record<string, PlatformManager<any>> = {}; 96 /** Manages the creation of dev server URLs. */ 97 protected urlCreator?: UrlCreator | null = null; 98 99 constructor( 100 /** Project root folder. */ 101 public projectRoot: string, 102 // TODO: Replace with custom scheme maybe... 103 public isDevClient?: boolean 104 ) {} 105 106 protected setInstance(instance: DevServerInstance) { 107 this.instance = instance; 108 } 109 110 /** Get the manifest middleware function. */ 111 protected async getManifestMiddlewareAsync( 112 options: Pick< 113 BundlerStartOptions, 114 'minify' | 'mode' | 'forceManifestType' | 'privateKeyPath' 115 > = {} 116 ) { 117 const manifestType = options.forceManifestType || 'classic'; 118 assert(manifestType in MIDDLEWARES, `Manifest middleware for type '${manifestType}' not found`); 119 const Middleware = MIDDLEWARES[manifestType](); 120 121 const urlCreator = this.getUrlCreator(); 122 const middleware = new Middleware(this.projectRoot, { 123 constructUrl: urlCreator.constructUrl.bind(urlCreator), 124 mode: options.mode, 125 minify: options.minify, 126 isNativeWebpack: this.name === 'webpack' && this.isTargetingNative(), 127 privateKeyPath: options.privateKeyPath, 128 }); 129 return middleware.getHandler(); 130 } 131 132 /** Start the dev server using settings defined in the start command. */ 133 public abstract startAsync(options: BundlerStartOptions): Promise<DevServerInstance>; 134 135 protected async postStartAsync(options: BundlerStartOptions) { 136 if (options.location.hostType === 'tunnel' && !APISettings.isOffline) { 137 await this._startTunnelAsync(); 138 } 139 await this.startDevSessionAsync(); 140 141 this.watchConfig(); 142 } 143 144 protected abstract getConfigModuleIds(): string[]; 145 146 protected watchConfig() { 147 const notifier = new FileNotifier(this.projectRoot, this.getConfigModuleIds()); 148 notifier.startObserving(); 149 } 150 151 /** Create ngrok instance and start the tunnel server. Exposed for testing. */ 152 public async _startTunnelAsync(): Promise<AsyncNgrok | null> { 153 const port = this.getInstance()?.location.port; 154 if (!port) return null; 155 Log.debug('[ngrok] connect to port: ' + port); 156 this.ngrok = new AsyncNgrok(this.projectRoot, port); 157 await this.ngrok.startAsync(); 158 return this.ngrok; 159 } 160 161 protected async startDevSessionAsync() { 162 // This is used to make Expo Go open the project in either Expo Go, or the web browser. 163 // Must come after ngrok (`startTunnelAsync`) setup. 164 165 if (this.devSession) { 166 this.devSession.stop(); 167 } 168 169 this.devSession = new DevelopmentSession( 170 this.projectRoot, 171 // This URL will be used on external devices so the computer IP won't be relevant. 172 this.isTargetingNative() 173 ? this.getNativeRuntimeUrl() 174 : this.getDevServerUrl({ hostType: 'localhost' }) 175 ); 176 177 await this.devSession.startAsync({ 178 runtime: this.isTargetingNative() ? 'native' : 'web', 179 }); 180 } 181 182 public isTargetingNative() { 183 // Temporary hack while we implement multi-bundler dev server proxy. 184 return true; 185 } 186 187 public isTargetingWeb() { 188 return false; 189 } 190 191 /** 192 * Sends a message over web sockets to any connected device, 193 * does nothing when the dev server is not running. 194 * 195 * @param method name of the command. In RN projects `reload`, and `devMenu` are available. In Expo Go, `sendDevCommand` is available. 196 * @param params 197 */ 198 public broadcastMessage( 199 method: 'reload' | 'devMenu' | 'sendDevCommand', 200 params?: Record<string, any> 201 ) { 202 this.getInstance()?.messageSocket.broadcast(method, params); 203 } 204 205 /** Get the running dev server instance. */ 206 public getInstance() { 207 return this.instance; 208 } 209 210 /** Stop the running dev server instance. */ 211 async stopAsync() { 212 // Stop the dev session timer. 213 this.devSession?.stop(); 214 215 // Stop ngrok if running. 216 await this.ngrok?.stopAsync().catch((e) => { 217 Log.error(`Error stopping ngrok:`); 218 Log.exception(e); 219 }); 220 221 return resolveWithTimeout( 222 () => 223 new Promise<void>((resolve, reject) => { 224 // Close the server. 225 Log.debug(`Stopping dev server (bundler: ${this.name})`); 226 227 if (this.instance?.server) { 228 this.instance.server.close((error) => { 229 Log.debug(`Stopped dev server (bundler: ${this.name})`); 230 this.instance = null; 231 if (error) { 232 reject(error); 233 } else { 234 resolve(); 235 } 236 }); 237 } else { 238 Log.debug(`Stopped dev server (bundler: ${this.name})`); 239 this.instance = null; 240 resolve(); 241 } 242 }), 243 { 244 // NOTE(Bacon): Metro dev server doesn't seem to be closing in time. 245 timeout: 1000, 246 errorMessage: `Timeout waiting for '${this.name}' dev server to close`, 247 } 248 ); 249 } 250 251 private getUrlCreator() { 252 assert(this.urlCreator, 'Dev server is not running.'); 253 return this.urlCreator; 254 } 255 256 public getNativeRuntimeUrl(opts: Partial<CreateURLOptions> = {}) { 257 return this.isDevClient 258 ? this.getUrlCreator().constructDevClientUrl(opts) ?? this.getDevServerUrl() 259 : this.getUrlCreator().constructUrl({ ...opts, scheme: 'exp' }); 260 } 261 262 /** Get the URL for the running instance of the dev server. */ 263 public getDevServerUrl(options: { hostType?: 'localhost' } = {}): string | null { 264 const instance = this.getInstance(); 265 if (!instance?.location) { 266 return null; 267 } 268 const { location } = instance; 269 if (options.hostType === 'localhost') { 270 return `${location.protocol}://localhost:${location.port}`; 271 } 272 return location.url ?? null; 273 } 274 275 /** Get the tunnel URL from ngrok. */ 276 public getTunnelUrl(): string | null { 277 return this.ngrok?.getActiveUrl() ?? null; 278 } 279 280 /** Open the dev server in a runtime. */ 281 public async openPlatformAsync( 282 launchTarget: keyof typeof PLATFORM_MANAGERS | 'desktop', 283 resolver: BaseResolveDeviceProps<any> = {} 284 ) { 285 if (launchTarget === 'desktop') { 286 const url = this.getDevServerUrl({ hostType: 'localhost' }); 287 await openBrowserAsync(url!); 288 return { url }; 289 } 290 291 const runtime = this.isTargetingNative() ? (this.isDevClient ? 'custom' : 'expo') : 'web'; 292 const manager = await this.getPlatformManagerAsync(launchTarget); 293 return manager.openAsync({ runtime }, resolver); 294 } 295 296 /** Should use the interstitial page for selecting which runtime to use. */ 297 protected shouldUseInterstitialPage(): boolean { 298 return ( 299 env.EXPO_ENABLE_INTERSTITIAL_PAGE && 300 // Checks if dev client is installed. 301 !!resolveFrom.silent(this.projectRoot, 'expo-dev-launcher') 302 ); 303 } 304 305 /** Get the URL for opening in Expo Go. */ 306 protected getExpoGoUrl(platform: keyof typeof PLATFORM_MANAGERS): string | null { 307 if (this.shouldUseInterstitialPage()) { 308 const loadingUrl = 309 platform === 'emulator' 310 ? this.urlCreator?.constructLoadingUrl({}, 'android') 311 : this.urlCreator?.constructLoadingUrl({ hostType: 'localhost' }, 'ios'); 312 return loadingUrl ?? null; 313 } 314 315 return this.urlCreator?.constructUrl({ scheme: 'exp' }) ?? null; 316 } 317 318 protected async getPlatformManagerAsync(platform: keyof typeof PLATFORM_MANAGERS) { 319 if (!this.platformManagers[platform]) { 320 const Manager = PLATFORM_MANAGERS[platform](); 321 const port = this.getInstance()?.location.port; 322 if (!port || !this.urlCreator) { 323 throw new CommandError( 324 'DEV_SERVER', 325 'Cannot interact with native platforms until dev server has started' 326 ); 327 } 328 this.platformManagers[platform] = new Manager(this.projectRoot, port, { 329 getCustomRuntimeUrl: this.urlCreator.constructDevClientUrl.bind(this.urlCreator), 330 getExpoGoUrl: this.getExpoGoUrl.bind(this, platform), 331 getDevServerUrl: this.getDevServerUrl.bind(this, { hostType: 'localhost' }), 332 }); 333 } 334 return this.platformManagers[platform]; 335 } 336} 337