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