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 isTargetingWeb(): boolean { 118 return true; 119 } 120 121 private async createNativeDevServerMiddleware({ 122 port, 123 compiler, 124 options, 125 }: { 126 port: number; 127 compiler: AnyCompiler; 128 options: BundlerStartOptions; 129 }) { 130 if (!this.isTargetingNative()) { 131 return null; 132 } 133 134 const { createDevServerMiddleware } = await import('../middleware/createDevServerMiddleware'); 135 136 const nativeMiddleware = createDevServerMiddleware(this.projectRoot, { 137 port, 138 watchFolders: [this.projectRoot], 139 }); 140 // Add manifest middleware to the other middleware. 141 // TODO: Move this in to expo/dev-server. 142 143 const middleware = await this.getManifestMiddlewareAsync(options); 144 145 nativeMiddleware.middleware.use(middleware).use( 146 '/symbolicate', 147 createSymbolicateMiddleware({ 148 projectRoot: this.projectRoot, 149 compiler, 150 logger: nativeMiddleware.logger, 151 }) 152 ); 153 return nativeMiddleware; 154 } 155 156 private async getAvailablePortAsync(options: { defaultPort?: number }): Promise<number> { 157 try { 158 const defaultPort = options?.defaultPort ?? 19006; 159 const port = await choosePortAsync(this.projectRoot, { 160 defaultPort, 161 host: env.WEB_HOST, 162 }); 163 if (!port) { 164 throw new CommandError('NO_PORT_FOUND', `Port ${defaultPort} not available.`); 165 } 166 return port; 167 } catch (error: any) { 168 throw new CommandError('NO_PORT_FOUND', error.message); 169 } 170 } 171 172 async bundleAsync({ mode, clear }: { mode: 'development' | 'production'; clear: boolean }) { 173 // Do this first to fail faster. 174 const webpack = importWebpackFromProject(this.projectRoot); 175 176 if (clear) { 177 await this.clearWebProjectCacheAsync(this.projectRoot, mode); 178 } 179 180 const config = await this.loadConfigAsync({ 181 isImageEditingEnabled: true, 182 mode, 183 }); 184 185 if (!config.plugins) { 186 config.plugins = []; 187 } 188 189 const bar = createProgressBar(chalk`{bold Web} Bundling Javascript [:bar] :percent`, { 190 width: 64, 191 total: 100, 192 clear: true, 193 complete: '=', 194 incomplete: ' ', 195 }); 196 197 // NOTE(EvanBacon): Add a progress bar to the webpack logger if defined (e.g. not in CI). 198 if (bar != null) { 199 config.plugins.push( 200 new webpack.ProgressPlugin((percent: number) => { 201 bar?.update(percent); 202 if (percent === 1) { 203 bar?.terminate(); 204 } 205 }) 206 ); 207 } 208 209 // Create a webpack compiler that is configured with custom messages. 210 const compiler = webpack(config); 211 212 try { 213 await compileAsync(compiler); 214 } catch (error: any) { 215 Log.error(chalk.red('Failed to compile')); 216 throw error; 217 } finally { 218 bar?.terminate(); 219 } 220 } 221 222 protected async startImplementationAsync( 223 options: BundlerStartOptions 224 ): Promise<DevServerInstance> { 225 // Do this first to fail faster. 226 const webpack = importWebpackFromProject(this.projectRoot); 227 const WebpackDevServer = importWebpackDevServerFromProject(this.projectRoot); 228 229 await this.stopAsync(); 230 231 options.port = await this.getAvailablePortAsync({ 232 defaultPort: options.port, 233 }); 234 const { resetDevServer, https, port, mode } = options; 235 236 this.urlCreator = this.getUrlCreator({ 237 port, 238 location: { 239 scheme: https ? 'https' : 'http', 240 }, 241 }); 242 243 debug('Starting webpack on port: ' + port); 244 245 if (resetDevServer) { 246 await this.clearWebProjectCacheAsync(this.projectRoot, mode); 247 } 248 249 if (https) { 250 debug('Configuring TLS to enable HTTPS support'); 251 await ensureEnvironmentSupportsTLSAsync(this.projectRoot).catch((error) => { 252 Log.error(`Error creating TLS certificates: ${error}`); 253 }); 254 } 255 256 const config = await this.loadConfigAsync(options); 257 258 Log.log(chalk`Starting Webpack on port ${port} in {underline ${mode}} mode.`); 259 260 // Create a webpack compiler that is configured with custom messages. 261 const compiler = webpack(config); 262 263 let nativeMiddleware: Awaited<ReturnType<typeof this.createNativeDevServerMiddleware>> | null = 264 null; 265 if (config.devServer?.before) { 266 // Create the middleware required for interacting with a native runtime (Expo Go, or a development build). 267 nativeMiddleware = await this.createNativeDevServerMiddleware({ 268 port, 269 compiler, 270 options, 271 }); 272 // Inject the native manifest middleware. 273 const originalBefore = config.devServer.before.bind(config.devServer.before); 274 config.devServer.before = ( 275 app: Application, 276 server: WebpackDevServer, 277 compiler: webpack.Compiler 278 ) => { 279 originalBefore(app, server, compiler); 280 281 if (nativeMiddleware?.middleware) { 282 app.use(nativeMiddleware.middleware); 283 } 284 }; 285 } 286 const { attachNativeDevServerMiddlewareToDevServer } = this; 287 288 const server = new WebpackDevServer( 289 // @ts-expect-error: type mismatch -- Webpack types aren't great. 290 compiler, 291 config.devServer 292 ); 293 // Launch WebpackDevServer. 294 server.listen(port, env.WEB_HOST, function (this: http.Server, error) { 295 if (nativeMiddleware) { 296 attachNativeDevServerMiddlewareToDevServer({ 297 server: this, 298 ...nativeMiddleware, 299 }); 300 } 301 if (error) { 302 Log.error(error.message); 303 } 304 }); 305 306 // Extend the close method to ensure that we clean up the local info. 307 const originalClose = server.close.bind(server); 308 309 server.close = (callback?: (err?: Error) => void) => { 310 return originalClose((err?: Error) => { 311 this.instance = null; 312 callback?.(err); 313 }); 314 }; 315 316 const _host = getIpAddress(); 317 const protocol = https ? 'https' : 'http'; 318 319 return { 320 // Server instance 321 server, 322 // URL Info 323 location: { 324 url: `${protocol}://${_host}:${port}`, 325 port, 326 protocol, 327 host: _host, 328 }, 329 middleware: nativeMiddleware?.middleware, 330 // Match the native protocol. 331 messageSocket: { 332 broadcast: this.broadcastMessage, 333 }, 334 }; 335 } 336 337 /** Load the Webpack config. Exposed for testing. */ 338 getProjectConfigFilePath(): string | null { 339 // Check if the project has a webpack.config.js in the root. 340 return ( 341 this.getConfigModuleIds().reduce<string | null | undefined>( 342 (prev, moduleId) => prev || resolveFrom.silent(this.projectRoot, moduleId), 343 null 344 ) ?? null 345 ); 346 } 347 348 async loadConfigAsync( 349 options: Pick<BundlerStartOptions, 'mode' | 'isImageEditingEnabled' | 'https'>, 350 argv?: string[] 351 ): Promise<WebpackConfiguration> { 352 // let bar: ProgressBar | null = null; 353 354 const env = { 355 projectRoot: this.projectRoot, 356 pwa: !!options.isImageEditingEnabled, 357 // TODO: Use a new loader in Webpack config... 358 logger: { 359 info() {}, 360 }, 361 mode: options.mode, 362 https: options.https, 363 }; 364 setMode(env.mode ?? 'development'); 365 // Check if the project has a webpack.config.js in the root. 366 const projectWebpackConfig = this.getProjectConfigFilePath(); 367 let config: WebpackConfiguration; 368 if (projectWebpackConfig) { 369 const webpackConfig = require(projectWebpackConfig); 370 if (typeof webpackConfig === 'function') { 371 config = await webpackConfig(env, argv); 372 } else { 373 config = webpackConfig; 374 } 375 } else { 376 // Fallback to the default expo webpack config. 377 const loadDefaultConfigAsync = importExpoWebpackConfigFromProject(this.projectRoot); 378 // @ts-expect-error: types appear to be broken 379 config = await loadDefaultConfigAsync(env, argv); 380 } 381 return config; 382 } 383 384 protected getConfigModuleIds(): string[] { 385 return ['./webpack.config.js']; 386 } 387 388 protected async clearWebProjectCacheAsync( 389 projectRoot: string, 390 mode: string = 'development' 391 ): Promise<void> { 392 Log.log(chalk.dim(`Clearing Webpack ${mode} cache directory...`)); 393 394 const dir = await ensureDotExpoProjectDirectoryInitialized(projectRoot); 395 const cacheFolder = path.join(dir, 'web/cache', mode); 396 try { 397 await fs.promises.rm(cacheFolder, { recursive: true, force: true }); 398 } catch (error: any) { 399 Log.error(`Could not clear ${mode} web cache directory: ${error.message}`); 400 } 401 } 402} 403 404function setMode(mode: 'development' | 'production' | 'test' | 'none'): void { 405 process.env.BABEL_ENV = mode; 406 process.env.NODE_ENV = mode; 407} 408 409export function getProjectWebpackConfigFilePath(projectRoot: string) { 410 return resolveFrom.silent(projectRoot, './webpack.config.js'); 411} 412