1import { createSymbolicateMiddleware } from '@expo/dev-server/build/webpack/symbolicateMiddleware'; 2import chalk from 'chalk'; 3import type { Application } from 'express'; 4import fs from 'fs'; 5import http from 'http'; 6import * as path from 'path'; 7import resolveFrom from 'resolve-from'; 8import type webpack from 'webpack'; 9import type WebpackDevServer from 'webpack-dev-server'; 10 11import * as Log from '../../../log'; 12import { env } from '../../../utils/env'; 13import { CommandError } from '../../../utils/errors'; 14import { getIpAddress } from '../../../utils/ip'; 15import { choosePortAsync } from '../../../utils/port'; 16import { ensureDotExpoProjectDirectoryInitialized } from '../../project/dotExpo'; 17import { BundlerDevServer, BundlerStartOptions, DevServerInstance } from '../BundlerDevServer'; 18import { UrlCreator } from '../UrlCreator'; 19import { 20 importExpoWebpackConfigFromProject, 21 importWebpackDevServerFromProject, 22 importWebpackFromProject, 23} from './resolveFromProject'; 24import { ensureEnvironmentSupportsTLSAsync } from './tls'; 25 26type AnyCompiler = webpack.Compiler | webpack.MultiCompiler; 27 28export type WebpackConfiguration = webpack.Configuration & { 29 devServer?: { 30 before?: (app: Application, server: WebpackDevServer, compiler: webpack.Compiler) => void; 31 }; 32}; 33 34function assertIsWebpackDevServer(value: any): asserts value is WebpackDevServer { 35 if (!value?.sockWrite) { 36 throw new CommandError( 37 'WEBPACK', 38 value 39 ? 'Expected Webpack dev server, found: ' + (value.constructor?.name ?? value) 40 : 'Webpack dev server not started yet.' 41 ); 42 } 43} 44 45export class WebpackBundlerDevServer extends BundlerDevServer { 46 get name(): string { 47 return 'webpack'; 48 } 49 50 // A custom message websocket broadcaster used to send messages to a React Native runtime. 51 private customMessageSocketBroadcaster: 52 | undefined 53 | ((message: string, data?: Record<string, any>) => void); 54 55 public broadcastMessage( 56 method: string | 'reload' | 'devMenu' | 'sendDevCommand', 57 params?: Record<string, any> 58 ): void { 59 if (!this.instance) { 60 return; 61 } 62 63 assertIsWebpackDevServer(this.instance?.server); 64 65 // Allow any message on native 66 if (this.customMessageSocketBroadcaster) { 67 this.customMessageSocketBroadcaster(method, params); 68 return; 69 } 70 71 // TODO(EvanBacon): Custom Webpack overlay. 72 // Default webpack-dev-server sockets use "content-changed" instead of "reload" (what we use on native). 73 // For now, just manually convert the value so our CLI interface can be unified. 74 const hackyConvertedMessage = method === 'reload' ? 'content-changed' : method; 75 76 this.instance.server.sockWrite(this.instance.server.sockets, hackyConvertedMessage, params); 77 } 78 79 private async attachNativeDevServerMiddlewareToDevServer({ 80 server, 81 middleware, 82 attachToServer, 83 logger, 84 }: { server: http.Server } & Awaited<ReturnType<typeof this.createNativeDevServerMiddleware>>) { 85 const { attachInspectorProxy, LogReporter } = await import('@expo/dev-server'); 86 87 // Hook up the React Native WebSockets to the Webpack dev server. 88 const { messageSocket, debuggerProxy, eventsSocket } = attachToServer(server); 89 90 this.customMessageSocketBroadcaster = messageSocket.broadcast; 91 92 const logReporter = new LogReporter(logger); 93 logReporter.reportEvent = eventsSocket.reportEvent; 94 95 const { inspectorProxy } = attachInspectorProxy(this.projectRoot, { 96 middleware, 97 server, 98 }); 99 100 return { 101 messageSocket, 102 eventsSocket, 103 debuggerProxy, 104 logReporter, 105 inspectorProxy, 106 }; 107 } 108 109 isTargetingNative(): boolean { 110 // Temporary hack while we implement multi-bundler dev server proxy. 111 return ['ios', 'android'].includes(process.env.EXPO_WEBPACK_PLATFORM || ''); 112 } 113 114 isTargetingWeb(): boolean { 115 return true; 116 } 117 118 private async createNativeDevServerMiddleware({ 119 port, 120 compiler, 121 options, 122 }: { 123 port: number; 124 compiler: AnyCompiler; 125 options: BundlerStartOptions; 126 }) { 127 if (!this.isTargetingNative()) { 128 return null; 129 } 130 131 const { createDevServerMiddleware } = await import('../middleware/createDevServerMiddleware'); 132 133 const nativeMiddleware = createDevServerMiddleware({ 134 port, 135 watchFolders: [this.projectRoot], 136 }); 137 // Add manifest middleware to the other middleware. 138 // TODO: Move this in to expo/dev-server. 139 140 const middleware = await this.getManifestMiddlewareAsync(options); 141 142 nativeMiddleware.middleware.use(middleware).use( 143 '/symbolicate', 144 createSymbolicateMiddleware({ 145 projectRoot: this.projectRoot, 146 compiler, 147 logger: nativeMiddleware.logger, 148 }) 149 ); 150 return nativeMiddleware; 151 } 152 153 private async getAvailablePortAsync(options: { defaultPort?: number }): Promise<number> { 154 try { 155 const defaultPort = options?.defaultPort ?? 19006; 156 const port = await choosePortAsync(this.projectRoot, { 157 defaultPort, 158 host: env.WEB_HOST, 159 }); 160 if (!port) { 161 throw new CommandError('NO_PORT_FOUND', `Port ${defaultPort} not available.`); 162 } 163 return port; 164 } catch (error: any) { 165 throw new CommandError('NO_PORT_FOUND', error.message); 166 } 167 } 168 169 async startAsync(options: BundlerStartOptions): Promise<DevServerInstance> { 170 // Do this first to fail faster. 171 const webpack = importWebpackFromProject(this.projectRoot); 172 const WebpackDevServer = importWebpackDevServerFromProject(this.projectRoot); 173 174 await this.stopAsync(); 175 176 const { resetDevServer, https, mode } = options; 177 178 const port = await this.getAvailablePortAsync({ 179 defaultPort: options.port, 180 }); 181 182 this.urlCreator = new UrlCreator( 183 { 184 scheme: https ? 'https' : 'http', 185 ...options.location, 186 }, 187 { 188 port, 189 getTunnelUrl: this.getTunnelUrl.bind(this), 190 } 191 ); 192 193 Log.debug('Starting webpack on port: ' + port); 194 195 if (resetDevServer) { 196 await clearWebProjectCacheAsync(this.projectRoot, mode); 197 } 198 199 if (https) { 200 Log.debug('Configuring TLS to enable HTTPS support'); 201 await ensureEnvironmentSupportsTLSAsync(this.projectRoot).catch((error) => { 202 Log.error(`Error creating TLS certificates: ${error}`); 203 }); 204 } 205 206 const config = await this.loadConfigAsync(options); 207 208 Log.log(chalk`Starting Webpack on port ${port} in {underline ${mode}} mode.`); 209 210 // Create a webpack compiler that is configured with custom messages. 211 const compiler = webpack(config); 212 213 let nativeMiddleware: Awaited<ReturnType<typeof this.createNativeDevServerMiddleware>> | null = 214 null; 215 if (config.devServer?.before) { 216 // Create the middleware required for interacting with a native runtime (Expo Go, or a development build). 217 nativeMiddleware = await this.createNativeDevServerMiddleware({ 218 port, 219 compiler, 220 options, 221 }); 222 // Inject the native manifest middleware. 223 const originalBefore = config.devServer.before.bind(config.devServer.before); 224 config.devServer.before = ( 225 app: Application, 226 server: WebpackDevServer, 227 compiler: webpack.Compiler 228 ) => { 229 originalBefore(app, server, compiler); 230 231 if (nativeMiddleware?.middleware) { 232 app.use(nativeMiddleware.middleware); 233 } 234 }; 235 } 236 const { attachNativeDevServerMiddlewareToDevServer } = this; 237 238 const server = new WebpackDevServer( 239 // @ts-expect-error: type mismatch -- Webpack types aren't great. 240 compiler, 241 config.devServer 242 ); 243 // Launch WebpackDevServer. 244 server.listen(port, env.WEB_HOST, function (this: http.Server, error) { 245 if (nativeMiddleware) { 246 attachNativeDevServerMiddlewareToDevServer({ 247 server: this, 248 ...nativeMiddleware, 249 }); 250 } 251 if (error) { 252 Log.error(error.message); 253 } 254 }); 255 256 // Extend the close method to ensure that we clean up the local info. 257 const originalClose = server.close.bind(server); 258 259 server.close = (callback?: (err?: Error) => void) => { 260 return originalClose((err?: Error) => { 261 this.instance = null; 262 callback?.(err); 263 }); 264 }; 265 266 const _host = getIpAddress(); 267 const protocol = https ? 'https' : 'http'; 268 269 this.setInstance({ 270 // Server instance 271 server, 272 // URL Info 273 location: { 274 url: `${protocol}://${_host}:${port}`, 275 port, 276 protocol, 277 host: _host, 278 }, 279 middleware: nativeMiddleware?.middleware, 280 // Match the native protocol. 281 messageSocket: { 282 broadcast: this.broadcastMessage, 283 }, 284 }); 285 286 await this.postStartAsync(options); 287 288 return this.instance!; 289 } 290 291 /** Load the Webpack config. Exposed for testing. */ 292 getProjectConfigFilePath(): string | null { 293 // Check if the project has a webpack.config.js in the root. 294 return ( 295 this.getConfigModuleIds().reduce<string | null | undefined>( 296 (prev, moduleId) => prev || resolveFrom.silent(this.projectRoot, moduleId), 297 null 298 ) ?? null 299 ); 300 } 301 302 async loadConfigAsync( 303 options: BundlerStartOptions, 304 argv?: string[] 305 ): Promise<WebpackConfiguration> { 306 // let bar: ProgressBar | null = null; 307 308 const env = { 309 projectRoot: this.projectRoot, 310 pwa: !!options.isImageEditingEnabled, 311 // TODO: Use a new loader in Webpack config... 312 logger: { 313 info() {}, 314 }, 315 mode: options.mode, 316 https: options.https, 317 }; 318 setMode(env.mode ?? 'development'); 319 // Check if the project has a webpack.config.js in the root. 320 const projectWebpackConfig = this.getProjectConfigFilePath(); 321 let config: WebpackConfiguration; 322 if (projectWebpackConfig) { 323 const webpackConfig = require(projectWebpackConfig); 324 if (typeof webpackConfig === 'function') { 325 config = await webpackConfig(env, argv); 326 } else { 327 config = webpackConfig; 328 } 329 } else { 330 // Fallback to the default expo webpack config. 331 const loadDefaultConfigAsync = importExpoWebpackConfigFromProject(this.projectRoot); 332 // @ts-expect-error: types appear to be broken 333 config = await loadDefaultConfigAsync(env, argv); 334 } 335 return config; 336 } 337 338 protected getConfigModuleIds(): string[] { 339 return ['./webpack.config.js']; 340 } 341} 342 343function setMode(mode: 'development' | 'production' | 'test' | 'none'): void { 344 process.env.BABEL_ENV = mode; 345 process.env.NODE_ENV = mode; 346} 347 348export function getProjectWebpackConfigFilePath(projectRoot: string) { 349 return resolveFrom.silent(projectRoot, './webpack.config.js'); 350} 351 352async function clearWebProjectCacheAsync( 353 projectRoot: string, 354 mode: string = 'development' 355): Promise<void> { 356 Log.log(chalk.dim(`Clearing Webpack ${mode} cache directory...`)); 357 358 const dir = await ensureDotExpoProjectDirectoryInitialized(projectRoot); 359 const cacheFolder = path.join(dir, 'web/cache', mode); 360 try { 361 await fs.promises.rm(cacheFolder, { recursive: true, force: true }); 362 } catch (error: any) { 363 Log.error(`Could not clear ${mode} web cache directory: ${error.message}`); 364 } 365} 366