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