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