1import { ExpoConfig, getConfig } from '@expo/config'; 2import assert from 'assert'; 3import chalk from 'chalk'; 4 5import { FileNotifier } from '../../utils/FileNotifier'; 6import { logEventAsync } from '../../utils/analytics/rudderstackClient'; 7import { ProjectPrerequisite } from '../doctor/Prerequisite'; 8import { TypeScriptProjectPrerequisite } from '../doctor/typescript/TypeScriptProjectPrerequisite'; 9import * as AndroidDebugBridge from '../platforms/android/adb'; 10import { BundlerDevServer, BundlerStartOptions } from './BundlerDevServer'; 11import { getPlatformBundlers } from './platformBundlers'; 12 13const debug = require('debug')('expo:start:server:devServerManager') as typeof console.log; 14 15export type MultiBundlerStartOptions = { 16 type: keyof typeof BUNDLERS; 17 options?: BundlerStartOptions; 18}[]; 19 20const devServers: BundlerDevServer[] = []; 21 22const BUNDLERS = { 23 webpack: () => 24 require('./webpack/WebpackBundlerDevServer') 25 .WebpackBundlerDevServer as typeof import('./webpack/WebpackBundlerDevServer').WebpackBundlerDevServer, 26 metro: () => 27 require('./metro/MetroBundlerDevServer') 28 .MetroBundlerDevServer as typeof import('./metro/MetroBundlerDevServer').MetroBundlerDevServer, 29}; 30 31/** Manages interacting with multiple dev servers. */ 32export class DevServerManager { 33 private projectPrerequisites: ProjectPrerequisite<any, void>[] = []; 34 35 private notifier: FileNotifier | null = null; 36 37 constructor( 38 public projectRoot: string, 39 /** Keep track of the original CLI options for bundlers that are started interactively. */ 40 public options: BundlerStartOptions 41 ) { 42 this.notifier = this.watchBabelConfig(); 43 } 44 45 private watchBabelConfig() { 46 const notifier = new FileNotifier( 47 this.projectRoot, 48 [ 49 './babel.config.js', 50 './babel.config.json', 51 './.babelrc.json', 52 './.babelrc', 53 './.babelrc.js', 54 ], 55 { 56 additionalWarning: chalk` You may need to clear the bundler cache with the {bold --clear} flag for your changes to take effect.`, 57 } 58 ); 59 60 notifier.startObserving(); 61 62 return notifier; 63 } 64 65 /** Lazily load and assert a project-level prerequisite. */ 66 async ensureProjectPrerequisiteAsync(PrerequisiteClass: typeof ProjectPrerequisite<any, any>) { 67 let prerequisite = this.projectPrerequisites.find( 68 (prerequisite) => prerequisite instanceof PrerequisiteClass 69 ); 70 if (!prerequisite) { 71 prerequisite = new PrerequisiteClass(this.projectRoot); 72 this.projectPrerequisites.push(prerequisite); 73 } 74 return await prerequisite.assertAsync(); 75 } 76 77 /** 78 * Sends a message over web sockets to all connected devices, 79 * does nothing when the dev server is not running. 80 * 81 * @param method name of the command. In RN projects `reload`, and `devMenu` are available. In Expo Go, `sendDevCommand` is available. 82 * @param params extra event info to send over the socket. 83 */ 84 broadcastMessage(method: 'reload' | 'devMenu' | 'sendDevCommand', params?: Record<string, any>) { 85 devServers.forEach((server) => { 86 server.broadcastMessage(method, params); 87 }); 88 } 89 90 /** Get the port for the dev server (either Webpack or Metro) that is hosting code for React Native runtimes. */ 91 getNativeDevServerPort() { 92 const server = devServers.find((server) => server.isTargetingNative()); 93 return server?.getInstance()?.location.port ?? null; 94 } 95 96 /** Get the first server that targets web. */ 97 getWebDevServer() { 98 const server = devServers.find((server) => server.isTargetingWeb()); 99 return server ?? null; 100 } 101 102 getDefaultDevServer(): BundlerDevServer { 103 // Return the first native dev server otherwise return the first dev server. 104 const server = devServers.find((server) => server.isTargetingNative()); 105 const defaultServer = server ?? devServers[0]; 106 assert(defaultServer, 'No dev servers are running'); 107 return defaultServer; 108 } 109 110 async ensureWebDevServerRunningAsync() { 111 const [server] = devServers.filter((server) => server.isTargetingWeb()); 112 if (server) { 113 return; 114 } 115 const { exp } = getConfig(this.projectRoot, { 116 skipPlugins: true, 117 skipSDKVersionRequirement: true, 118 }); 119 const bundler = getPlatformBundlers(exp).web; 120 debug(`Starting ${bundler} dev server for web`); 121 return this.startAsync([ 122 { 123 type: bundler, 124 options: this.options, 125 }, 126 ]); 127 } 128 129 /** Start all dev servers. */ 130 async startAsync(startOptions: MultiBundlerStartOptions): Promise<ExpoConfig> { 131 const { exp } = getConfig(this.projectRoot); 132 133 await logEventAsync('Start Project', { 134 sdkVersion: exp.sdkVersion ?? null, 135 }); 136 137 const platformBundlers = getPlatformBundlers(exp); 138 139 // Start all dev servers... 140 for (const { type, options } of startOptions) { 141 const BundlerDevServerClass = await BUNDLERS[type](); 142 const server = new BundlerDevServerClass( 143 this.projectRoot, 144 platformBundlers, 145 !!options?.devClient 146 ); 147 await server.startAsync(options ?? this.options); 148 devServers.push(server); 149 } 150 151 return exp; 152 } 153 154 async bootstrapTypeScriptAsync() { 155 if (await this.ensureProjectPrerequisiteAsync(TypeScriptProjectPrerequisite)) { 156 return; 157 } 158 // Optionally, wait for the user to add TypeScript during the 159 // development cycle. 160 const server = devServers.find((server) => server.name === 'metro'); 161 if (!server) { 162 return; 163 } 164 await server.waitForTypeScriptAsync(); 165 } 166 167 /** Stop all servers including ADB. */ 168 async stopAsync(): Promise<void> { 169 await Promise.allSettled([ 170 this.notifier?.stopObserving(), 171 // Stop all dev servers 172 ...devServers.map((server) => server.stopAsync()), 173 // Stop ADB 174 AndroidDebugBridge.getServer().stopAsync(), 175 ]); 176 } 177} 178