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