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