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