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