1/**
2 * Copyright (c) 650 Industries.
3 * Copyright (c) Meta Platforms, Inc. and affiliates.
4 *
5 * This source code is licensed under the MIT license found in the
6 * LICENSE file in the root directory of this source tree.
7 *
8 * Based on this but with web support:
9 * https://github.com/facebook/react-native/blob/086714b02b0fb838dee5a66c5bcefe73b53cf3df/Libraries/Utilities/HMRClient.js
10 */
11import prettyFormat, { plugins } from 'pretty-format';
12
13import LoadingView from './LoadingView';
14import LogBox from './error-overlay/LogBox';
15import getDevServer from './getDevServer';
16
17const MetroHMRClient = require('metro-runtime/src/modules/HMRClient');
18const pendingEntryPoints: string[] = [];
19
20type HMRClientType = {
21  send: (msg: string) => void;
22  isEnabled: () => boolean;
23  disable: () => void;
24  enable: () => void;
25  hasPendingUpdates: () => boolean;
26};
27
28let hmrClient: HMRClientType | null = null;
29let hmrUnavailableReason: string | null = null;
30let currentCompileErrorMessage: string | null = null;
31let didConnect: boolean = false;
32const pendingLogs: [LogLevel, any[]][] = [];
33
34type LogLevel =
35  | 'trace'
36  | 'info'
37  | 'warn'
38  | 'error'
39  | 'log'
40  | 'group'
41  | 'groupCollapsed'
42  | 'groupEnd'
43  | 'debug';
44
45export type HMRClientNativeInterface = {
46  enable(): void;
47  disable(): void;
48  registerBundle(requestUrl: string): void;
49  log(level: LogLevel, data: any[]): void;
50  setup(props: { isEnabled: boolean }): void;
51};
52
53function assert(foo: any, msg: string): asserts foo {
54  if (!foo) throw new Error(msg);
55}
56
57/**
58 * HMR Client that receives from the server HMR updates and propagates them
59 * runtime to reflects those changes.
60 */
61const HMRClient: HMRClientNativeInterface = {
62  enable() {
63    if (hmrUnavailableReason !== null) {
64      // If HMR became unavailable while you weren't using it,
65      // explain why when you try to turn it on.
66      // This is an error (and not a warning) because it is shown
67      // in response to a direct user action.
68      throw new Error(hmrUnavailableReason);
69    }
70
71    assert(hmrClient, 'Expected HMRClient.setup() call at startup.');
72
73    // We use this for internal logging only.
74    // It doesn't affect the logic.
75    hmrClient.send(JSON.stringify({ type: 'log-opt-in' }));
76
77    // When toggling Fast Refresh on, we might already have some stashed updates.
78    // Since they'll get applied now, we'll show a banner.
79    const hasUpdates = hmrClient!.hasPendingUpdates();
80
81    if (hasUpdates) {
82      LoadingView.showMessage('Refreshing...', 'refresh');
83    }
84    try {
85      hmrClient.enable();
86    } finally {
87      if (hasUpdates) {
88        LoadingView.hide();
89      }
90    }
91
92    // There could be a compile error while Fast Refresh was off,
93    // but we ignored it at the time. Show it now.
94    showCompileError();
95  },
96
97  disable() {
98    assert(hmrClient, 'Expected HMRClient.setup() call at startup.');
99    hmrClient.disable();
100  },
101
102  registerBundle(requestUrl: string) {
103    assert(hmrClient, 'Expected HMRClient.setup() call at startup.');
104    pendingEntryPoints.push(requestUrl);
105    registerBundleEntryPoints(hmrClient);
106  },
107
108  log(level: LogLevel, data: any[]) {
109    if (!hmrClient) {
110      // Catch a reasonable number of early logs
111      // in case hmrClient gets initialized later.
112      pendingLogs.push([level, data]);
113      if (pendingLogs.length > 100) {
114        pendingLogs.shift();
115      }
116      return;
117    }
118    try {
119      hmrClient.send(
120        JSON.stringify({
121          type: 'log',
122          level,
123          mode: 'BRIDGE',
124          data: data.map((item) =>
125            typeof item === 'string'
126              ? item
127              : prettyFormat(item, {
128                  escapeString: true,
129                  highlight: true,
130                  maxDepth: 3,
131                  min: true,
132                  plugins: [plugins.ReactElement],
133                })
134          ),
135        })
136      );
137    } catch {
138      // If sending logs causes any failures we want to silently ignore them
139      // to ensure we do not cause infinite-logging loops.
140    }
141  },
142
143  // Called once by the bridge on startup, even if Fast Refresh is off.
144  // It creates the HMR client but doesn't actually set up the socket yet.
145  setup({ isEnabled }: { isEnabled: boolean }) {
146    assert(!hmrClient, 'Cannot initialize hmrClient twice');
147
148    const serverScheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
149    const client = new MetroHMRClient(`${serverScheme}://${window.location.host}/hot`);
150    hmrClient = client;
151
152    const { fullBundleUrl } = getDevServer();
153    pendingEntryPoints.push(
154      // HMRServer understands regular bundle URLs, so prefer that in case
155      // there are any important URL parameters we can't reconstruct from
156      // `setup()`'s arguments.
157      fullBundleUrl
158    );
159
160    client.on('connection-error', (e: Error) => {
161      let error = `Cannot connect to Metro.
162
163 Try the following to fix the issue:
164 - Ensure the Metro dev server is running and available on the same network as this device`;
165      error += `
166
167 URL: ${window.location.host}
168
169 Error: ${e.message}`;
170
171      setHMRUnavailableReason(error);
172    });
173
174    client.on('update-start', ({ isInitialUpdate }: { isInitialUpdate?: boolean }) => {
175      currentCompileErrorMessage = null;
176      didConnect = true;
177
178      if (client.isEnabled() && !isInitialUpdate) {
179        LoadingView.showMessage('Refreshing...', 'refresh');
180      }
181    });
182
183    client.on('update', ({ isInitialUpdate }: { isInitialUpdate?: boolean }) => {
184      if (client.isEnabled() && !isInitialUpdate) {
185        dismissRedbox();
186        LogBox.clearAllLogs();
187      }
188    });
189
190    client.on('update-done', () => {
191      LoadingView.hide();
192    });
193
194    client.on('error', (data: { type: string; message: string }) => {
195      LoadingView.hide();
196
197      if (data.type === 'GraphNotFoundError') {
198        client.close();
199        setHMRUnavailableReason('Metro has restarted since the last edit. Reload to reconnect.');
200      } else if (data.type === 'RevisionNotFoundError') {
201        client.close();
202        setHMRUnavailableReason('Metro and the client are out of sync. Reload to reconnect.');
203      } else {
204        currentCompileErrorMessage = `${data.type} ${data.message}`;
205        if (client.isEnabled()) {
206          showCompileError();
207        }
208      }
209    });
210
211    client.on('close', (closeEvent: { code: number; reason: string }) => {
212      LoadingView.hide();
213
214      // https://www.rfc-editor.org/rfc/rfc6455.html#section-7.4.1
215      // https://www.rfc-editor.org/rfc/rfc6455.html#section-7.1.5
216      const isNormalOrUnsetCloseReason =
217        closeEvent == null ||
218        closeEvent.code === 1000 ||
219        closeEvent.code === 1005 ||
220        closeEvent.code == null;
221
222      setHMRUnavailableReason(
223        `${
224          isNormalOrUnsetCloseReason
225            ? 'Disconnected from Metro.'
226            : `Disconnected from Metro (${closeEvent.code}: "${closeEvent.reason}").`
227        }
228
229To reconnect:
230- Ensure that Metro is running and available on the same network
231- Reload this app (will trigger further help if Metro cannot be connected to)
232      `
233      );
234    });
235
236    if (isEnabled) {
237      HMRClient.enable();
238    } else {
239      HMRClient.disable();
240    }
241
242    registerBundleEntryPoints(hmrClient);
243    flushEarlyLogs();
244  },
245};
246
247function setHMRUnavailableReason(reason: string) {
248  assert(hmrClient, 'Expected HMRClient.setup() call at startup.');
249  if (hmrUnavailableReason !== null) {
250    // Don't show more than one warning.
251    return;
252  }
253  hmrUnavailableReason = reason;
254
255  // We only want to show a warning if Fast Refresh is on *and* if we ever
256  // previously managed to connect successfully. We don't want to show
257  // the warning to native engineers who use cached bundles without Metro.
258  if (hmrClient.isEnabled() && didConnect) {
259    console.warn(reason);
260    // (Not using the `warning` module to prevent a Buck cycle.)
261  }
262}
263
264function registerBundleEntryPoints(client: HMRClientType | null) {
265  if (hmrUnavailableReason != null) {
266    // "Bundle Splitting – Metro disconnected"
267    window.location.reload();
268    return;
269  }
270
271  if (pendingEntryPoints.length > 0) {
272    client?.send(
273      JSON.stringify({
274        type: 'register-entrypoints',
275        entryPoints: pendingEntryPoints,
276      })
277    );
278    pendingEntryPoints.length = 0;
279  }
280}
281
282function flushEarlyLogs() {
283  try {
284    pendingLogs.forEach(([level, data]) => {
285      HMRClient.log(level, data);
286    });
287  } finally {
288    pendingLogs.length = 0;
289  }
290}
291
292function dismissRedbox() {
293  // TODO(EvanBacon): Error overlay for web.
294}
295
296function showCompileError() {
297  if (currentCompileErrorMessage === null) {
298    return;
299  }
300
301  // Even if there is already a redbox, syntax errors are more important.
302  // Otherwise you risk seeing a stale runtime error while a syntax error is more recent.
303  dismissRedbox();
304
305  const message = currentCompileErrorMessage;
306  currentCompileErrorMessage = null;
307
308  const error = new Error(message);
309  // Symbolicating compile errors is wasted effort
310  // because the stack trace is meaningless:
311  // @ts-expect-error
312  error.preventSymbolication = true;
313  throw error;
314}
315
316export default HMRClient;
317