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