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