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