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