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