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