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