main
js 459 lines 12.2 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 *
7 * @flow
8 */
9
10 import {createElement} from 'react';
11 import {flushSync} from 'react-dom';
12 import {createRoot} from 'react-dom/client';
13 import Bridge from 'react-devtools-shared/src/bridge';
14 import Store from 'react-devtools-shared/src/devtools/store';
15 import {subscribeToStoreErrors} from 'react-devtools-shared/src/devtools/storeErrorLogger';
16 import {getSavedComponentFilters} from 'react-devtools-shared/src/utils';
17 import {registerDevToolsEventLogger} from 'react-devtools-shared/src/registerDevToolsEventLogger';
18 import {Server} from 'ws';
19 import {join} from 'path';
20 import {readFileSync} from 'fs';
21 import DevTools from 'react-devtools-shared/src/devtools/views/DevTools';
22 import {doesFilePathExist, launchEditor} from './editor';
23 import {
24 __DEBUG__,
25 LOCAL_STORAGE_DEFAULT_TAB_KEY,
26 } from 'react-devtools-shared/src/constants';
27 import {localStorageSetItem} from 'react-devtools-shared/src/storage';
28
29 import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
30 import type {ReactFunctionLocation, ReactCallSite} from 'shared/ReactTypes';
31
32 export type StatusTypes = 'server-connected' | 'devtools-connected' | 'error';
33 export type StatusListener = (message: string, status: StatusTypes) => void;
34 export type OnDisconnectedCallback = () => void;
35
36 let node: HTMLElement = null as any as HTMLElement;
37 let nodeWaitingToConnectHTML: string = '';
38 let projectRoots: Array<string> = [];
39 let statusListener: StatusListener = (
40 message: string,
41 status?: StatusTypes,
42 ) => {};
43 let disconnectedCallback: OnDisconnectedCallback = () => {};
44
45 // TODO (Webpack 5) Hopefully we can remove this prop after the Webpack 5 migration.
46 function hookNamesModuleLoaderFunction() {
47 return import(
48 /* webpackChunkName: 'parseHookNames' */ 'react-devtools-shared/src/hooks/parseHookNames'
49 );
50 }
51
52 function setContentDOMNode(value: HTMLElement): typeof DevtoolsUI {
53 node = value;
54
55 // Save so we can restore the exact waiting message between sessions.
56 nodeWaitingToConnectHTML = node.innerHTML;
57
58 return DevtoolsUI;
59 }
60
61 function setProjectRoots(value: Array<string>) {
62 projectRoots = value;
63 }
64
65 function setStatusListener(value: StatusListener): typeof DevtoolsUI {
66 statusListener = value;
67 return DevtoolsUI;
68 }
69
70 function setDisconnectedCallback(
71 value: OnDisconnectedCallback,
72 ): typeof DevtoolsUI {
73 disconnectedCallback = value;
74 return DevtoolsUI;
75 }
76
77 let bridge: FrontendBridge | null = null;
78 let store: Store | null = null;
79 let root = null;
80
81 const log = (...args: Array<mixed>) => console.log('[React DevTools]', ...args);
82 log.warn = (...args: Array<mixed>) => console.warn('[React DevTools]', ...args);
83 log.error = (...args: Array<mixed>) =>
84 console.error('[React DevTools]', ...args);
85
86 function debug(methodName: string, ...args: Array<mixed>) {
87 // $FlowFixMe[constant-condition]
88 if (__DEBUG__) {
89 console.log(
90 `%c[core/standalone] %c${methodName}`,
91 'color: teal; font-weight: bold;',
92 'font-weight: bold;',
93 ...args,
94 );
95 }
96 }
97
98 function safeUnmount() {
99 flushSync(() => {
100 if (root !== null) {
101 root.unmount();
102 root = null;
103 }
104 });
105 }
106
107 function reload() {
108 safeUnmount();
109
110 node.innerHTML = '';
111
112 setTimeout(() => {
113 root = createRoot(node);
114 root.render(
115 createElement(DevTools, {
116 bridge: bridge as any as FrontendBridge,
117 canViewElementSourceFunction,
118 hookNamesModuleLoaderFunction,
119 showTabBar: true,
120 store: store as any as Store,
121 warnIfLegacyBackendDetected: true,
122 viewElementSourceFunction,
123 fetchFileWithCaching,
124 }),
125 );
126 }, 100);
127 }
128
129 const resourceCache: Map<string, string> = new Map();
130
131 // As a potential improvement, this should be done from the backend of RDT.
132 // Browser extension is doing this via exchanging messages
133 // between devtools_page and dedicated content script for it, see `fetchFileWithCaching.js`.
134 async function fetchFileWithCaching(url: string) {
135 if (resourceCache.has(url)) {
136 return Promise.resolve(resourceCache.get(url));
137 }
138
139 return fetch(url)
140 .then(data => data.text())
141 .then(content => {
142 resourceCache.set(url, content);
143
144 return content;
145 });
146 }
147
148 function canViewElementSourceFunction(
149 _source: ReactFunctionLocation | ReactCallSite,
150 symbolicatedSource: ReactFunctionLocation | ReactCallSite | null,
151 ): boolean {
152 if (symbolicatedSource == null) {
153 return false;
154 }
155 const [, sourceURL, ,] = symbolicatedSource;
156
157 return doesFilePathExist(sourceURL, projectRoots);
158 }
159
160 function viewElementSourceFunction(
161 _source: ReactFunctionLocation | ReactCallSite,
162 symbolicatedSource: ReactFunctionLocation | ReactCallSite | null,
163 ): void {
164 if (symbolicatedSource == null) {
165 return;
166 }
167
168 const [, sourceURL, line] = symbolicatedSource;
169 launchEditor(sourceURL, line, projectRoots);
170 }
171
172 function onDisconnected() {
173 safeUnmount();
174
175 node.innerHTML = nodeWaitingToConnectHTML;
176
177 disconnectedCallback();
178 }
179
180 function showErrorMessage(headerText: string, contentText: string) {
181 const box = document.createElement('div');
182 box.className = 'box';
183
184 const header = document.createElement('div');
185 header.className = 'box-header';
186 header.textContent = headerText;
187 box.appendChild(header);
188
189 const content = document.createElement('div');
190 content.className = 'box-content';
191 content.textContent = contentText;
192 box.appendChild(content);
193
194 node.textContent = '';
195 node.appendChild(box);
196 }
197
198 function onError({code, message}: $FlowFixMe) {
199 safeUnmount();
200
201 if (code === 'EADDRINUSE') {
202 showErrorMessage(
203 'Another instance of DevTools is running.',
204 'Only one copy of DevTools can be used at a time.',
205 );
206 } else {
207 showErrorMessage('Unknown error', String(message));
208 }
209 }
210
211 function openProfiler() {
212 // Mocked up bridge and store to allow the DevTools to be rendered
213 const profilerBridge: FrontendBridge = new Bridge({
214 listen: () => () => {},
215 send: () => {},
216 });
217 const profilerStore = new Store(profilerBridge, {});
218 bridge = profilerBridge;
219 store = profilerStore;
220 subscribeToStoreErrors(profilerStore, profilerBridge);
221
222 // Ensure the Profiler tab is shown initially.
223 localStorageSetItem(
224 LOCAL_STORAGE_DEFAULT_TAB_KEY,
225 JSON.stringify('profiler'),
226 );
227
228 reload();
229 }
230
231 function initialize(socket: WebSocket) {
232 const listeners = [];
233 socket.onmessage = event => {
234 let data;
235 try {
236 if (typeof event.data === 'string') {
237 data = JSON.parse(event.data);
238
239 // $FlowFixMe[constant-condition]
240 if (__DEBUG__) {
241 debug('WebSocket.onmessage', data);
242 }
243 } else {
244 throw Error();
245 }
246 } catch (e) {
247 log.error('Failed to parse JSON', event.data);
248 return;
249 }
250 listeners.forEach(fn => {
251 try {
252 fn(data);
253 } catch (error) {
254 log.error('Error calling listener', data);
255 throw error;
256 }
257 });
258 };
259
260 bridge = new Bridge({
261 listen(fn) {
262 listeners.push(fn);
263 return () => {
264 const index = listeners.indexOf(fn);
265 if (index >= 0) {
266 listeners.splice(index, 1);
267 }
268 };
269 },
270 send(event: string, payload: mixed, transferable?: $ReadOnlyArray<mixed>) {
271 if (socket.readyState === socket.OPEN) {
272 socket.send(JSON.stringify({event, payload}));
273 }
274 },
275 });
276 (bridge as any as FrontendBridge).addListener('shutdown', () => {
277 socket.close();
278 });
279
280 // $FlowFixMe[incompatible-type] found when upgrading Flow
281 store = new Store(bridge, {
282 checkBridgeProtocolCompatibility: true,
283 supportsTraceUpdates: true,
284 supportsClickToInspect: true,
285 });
286 subscribeToStoreErrors(store, bridge as any as FrontendBridge);
287
288 log('Connected');
289 statusListener('DevTools initialized.', 'devtools-connected');
290 reload();
291 }
292
293 let startServerTimeoutID: TimeoutID | null = null;
294
295 function connectToSocket(socket: WebSocket): {close(): void} {
296 socket.onerror = err => {
297 onDisconnected();
298 log.error('Error with websocket connection', err);
299 };
300 socket.onclose = () => {
301 onDisconnected();
302 log('Connection to RN closed');
303 };
304 initialize(socket);
305
306 return {
307 close: function () {
308 onDisconnected();
309 },
310 };
311 }
312
313 type ServerOptions = {
314 key?: string,
315 cert?: string,
316 };
317
318 type LoggerOptions = {
319 surface?: ?string,
320 };
321
322 type ClientOptions = {
323 host?: string,
324 port?: number,
325 useHttps?: boolean,
326 };
327
328 function startServer(
329 port: number = 8097,
330 host: string = 'localhost',
331 httpsOptions?: ServerOptions,
332 loggerOptions?: LoggerOptions,
333 path?: string,
334 clientOptions?: ClientOptions,
335 ): {close(): void} {
336 registerDevToolsEventLogger(loggerOptions?.surface ?? 'standalone');
337
338 const useHttps = !!httpsOptions;
339 const httpServer = useHttps
340 ? require('https').createServer(httpsOptions)
341 : require('http').createServer();
342 const server = new Server({server: httpServer, maxPayload: 1e9});
343 let connected: WebSocket | null = null;
344 server.on('connection', (socket: WebSocket) => {
345 if (connected !== null) {
346 connected.close();
347 log.warn(
348 'Only one connection allowed at a time.',
349 'Closing the previous connection',
350 );
351 }
352 connected = socket;
353 socket.onerror = error => {
354 connected = null;
355 onDisconnected();
356 log.error('Error with websocket connection', error);
357 };
358 socket.onclose = () => {
359 connected = null;
360 onDisconnected();
361 log('Connection to RN closed');
362 };
363 initialize(socket);
364 });
365
366 server.on('error', (event: $FlowFixMe) => {
367 onError(event);
368 log.error('Failed to start the DevTools server', event);
369 startServerTimeoutID = setTimeout(
370 () =>
371 startServer(
372 port,
373 host,
374 httpsOptions,
375 loggerOptions,
376 path,
377 clientOptions,
378 ),
379 1000,
380 );
381 });
382
383 httpServer.on('request', (request: $FlowFixMe, response: $FlowFixMe) => {
384 // Serve a file that immediately sets up the connection.
385 const backendFile = readFileSync(join(__dirname, 'backend.js'));
386
387 // The renderer interface doesn't read saved component filters directly,
388 // because they are generally stored in localStorage within the context of the extension.
389 // Because of this it relies on the extension to pass filters, so include them wth the response here.
390 // This will ensure that saved filters are shared across different web pages.
391 const componentFiltersString = JSON.stringify(getSavedComponentFilters());
392
393 // Client overrides: when connecting through a reverse proxy, the client
394 // may need to connect to a different host/port/protocol than the server.
395 const clientHost = clientOptions?.host ?? host;
396 const clientPort = clientOptions?.port ?? port;
397 const clientUseHttps = clientOptions?.useHttps ?? useHttps;
398
399 response.end(
400 backendFile.toString() +
401 '\n;' +
402 `var ReactDevToolsBackend = typeof ReactDevToolsBackend !== "undefined" ? ReactDevToolsBackend : require("ReactDevToolsBackend");\n` +
403 `ReactDevToolsBackend.initialize(undefined, undefined, undefined, ${componentFiltersString});` +
404 '\n' +
405 `ReactDevToolsBackend.connectToDevTools({port: ${clientPort}, host: '${clientHost}', useHttps: ${
406 clientUseHttps ? 'true' : 'false'
407 }${path != null ? `, path: '${path}'` : ''}});
408 `,
409 );
410 });
411
412 httpServer.on('error', (event: $FlowFixMe) => {
413 onError(event);
414 statusListener('Failed to start the server.', 'error');
415 startServerTimeoutID = setTimeout(
416 () =>
417 startServer(
418 port,
419 host,
420 httpsOptions,
421 loggerOptions,
422 path,
423 clientOptions,
424 ),
425 1000,
426 );
427 });
428
429 httpServer.listen(port, () => {
430 statusListener(
431 'The server is listening on the port ' + port + '.',
432 'server-connected',
433 );
434 });
435
436 return {
437 close: function () {
438 connected = null;
439 onDisconnected();
440 if (startServerTimeoutID !== null) {
441 clearTimeout(startServerTimeoutID);
442 }
443 server.close();
444 httpServer.close();
445 },
446 };
447 }
448
449 const DevtoolsUI = {
450 connectToSocket,
451 setContentDOMNode,
452 setProjectRoots,
453 setStatusListener,
454 setDisconnectedCallback,
455 startServer,
456 openProfiler,
457 };
458
459 export default DevtoolsUI;