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