Files
react/packages/react-devtools-core/src/standalone.js
Rune Botten 394e75d9a9 [DevTools] Increase max payload for websocket in standalone app (#30848)
<!--
  Thanks for submitting a pull request!
We appreciate you spending the time to work on these changes. Please
provide enough information so that others can review your pull request.
The three fields below are mandatory.

Before submitting a pull request, please make sure the following is
done:

1. Fork [the repository](https://github.com/facebook/react) and create
your branch from `main`.
  2. Run `yarn` in the repository root.
3. If you've fixed a bug or added code that should be tested, add tests!
4. Ensure the test suite passes (`yarn test`). Tip: `yarn test --watch
TestName` is helpful in development.
5. Run `yarn test --prod` to test in the production environment. It
supports the same options as `yarn test`.
6. If you need a debugger, run `yarn test --debug --watch TestName`,
open `chrome://inspect`, and press "Inspect".
7. Format your code with
[prettier](https://github.com/prettier/prettier) (`yarn prettier`).
8. Make sure your code lints (`yarn lint`). Tip: `yarn linc` to only
check changed files.
  9. Run the [Flow](https://flowtype.org/) type checks (`yarn flow`).
  10. If you haven't already, complete the CLA.

Learn more about contributing:
https://reactjs.org/docs/how-to-contribute.html
-->

## Summary

<!--
Explain the **motivation** for making this change. What existing problem
does the pull request solve?
-->

When debugging applications that are experiencing runaway re-rendering,
it is helpful to profile them in the React Developer Tools.
Unfortunately there is a size limit on the captured profile which can
make them impossible to inspect or save. The limitations I have found
are in `postMessage` for the Chrome extension and in the `ws` websocket
server for the standalone app.

Profiling an app that produces a large profile artifact will simply show
that no profiling data was captured and output an error in the console,
here shown for the standalone app:

```text
standalone.js:92 [React DevTools] Error with websocket connection i {target: H, type: 'error', message: 'Max payload size exceeded', error: RangeError: Max payload size exceeded
    at e.exports.haveLength (/Users/rune/.npm/_npx/8ea6ac5c50…}error: RangeError: Max payload size exceeded
```

This change simply increases the max payload of the websocket server in
the standalone app so that larger profiles may be captured and
inspected.

## How did you test this change?

<!--
Demonstrate the code is solid. Example: The exact commands you ran and
their output, screenshots / videos if the pull request changes the user
interface.
How exactly did you verify that your PR solves the issue you wanted to
solve?
  If you leave this empty, your PR will very likely be closed.
-->

I verified that I could capture and inspect profiling data that
previously exceeded the default limitation for a particular app
2024-08-30 10:34:27 +01:00

435 lines
11 KiB
JavaScript

/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import {createElement} from 'react';
import {flushSync} from 'react-dom';
import {createRoot} from 'react-dom/client';
import Bridge from 'react-devtools-shared/src/bridge';
import Store from 'react-devtools-shared/src/devtools/store';
import {
getAppendComponentStack,
getBreakOnConsoleErrors,
getSavedComponentFilters,
getShowInlineWarningsAndErrors,
getHideConsoleLogsInStrictMode,
} from 'react-devtools-shared/src/utils';
import {registerDevToolsEventLogger} from 'react-devtools-shared/src/registerDevToolsEventLogger';
import {Server} from 'ws';
import {join} from 'path';
import {readFileSync} from 'fs';
import {installHook} from 'react-devtools-shared/src/hook';
import DevTools from 'react-devtools-shared/src/devtools/views/DevTools';
import {doesFilePathExist, launchEditor} from './editor';
import {
__DEBUG__,
LOCAL_STORAGE_DEFAULT_TAB_KEY,
} from 'react-devtools-shared/src/constants';
import {localStorageSetItem} from 'react-devtools-shared/src/storage';
import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
import type {Source} from 'react-devtools-shared/src/shared/types';
installHook(window);
export type StatusTypes = 'server-connected' | 'devtools-connected' | 'error';
export type StatusListener = (message: string, status: StatusTypes) => void;
export type OnDisconnectedCallback = () => void;
let node: HTMLElement = ((null: any): HTMLElement);
let nodeWaitingToConnectHTML: string = '';
let projectRoots: Array<string> = [];
let statusListener: StatusListener = (
message: string,
status?: StatusTypes,
) => {};
let disconnectedCallback: OnDisconnectedCallback = () => {};
// TODO (Webpack 5) Hopefully we can remove this prop after the Webpack 5 migration.
function hookNamesModuleLoaderFunction() {
return import(
/* webpackChunkName: 'parseHookNames' */ 'react-devtools-shared/src/hooks/parseHookNames'
);
}
function setContentDOMNode(value: HTMLElement): typeof DevtoolsUI {
node = value;
// Save so we can restore the exact waiting message between sessions.
nodeWaitingToConnectHTML = node.innerHTML;
return DevtoolsUI;
}
function setProjectRoots(value: Array<string>) {
projectRoots = value;
}
function setStatusListener(value: StatusListener): typeof DevtoolsUI {
statusListener = value;
return DevtoolsUI;
}
function setDisconnectedCallback(
value: OnDisconnectedCallback,
): typeof DevtoolsUI {
disconnectedCallback = value;
return DevtoolsUI;
}
let bridge: FrontendBridge | null = null;
let store: Store | null = null;
let root = null;
const log = (...args: Array<mixed>) => console.log('[React DevTools]', ...args);
log.warn = (...args: Array<mixed>) => console.warn('[React DevTools]', ...args);
log.error = (...args: Array<mixed>) =>
console.error('[React DevTools]', ...args);
function debug(methodName: string, ...args: Array<mixed>) {
if (__DEBUG__) {
console.log(
`%c[core/standalone] %c${methodName}`,
'color: teal; font-weight: bold;',
'font-weight: bold;',
...args,
);
}
}
function safeUnmount() {
flushSync(() => {
if (root !== null) {
root.unmount();
root = null;
}
});
}
function reload() {
safeUnmount();
node.innerHTML = '';
setTimeout(() => {
root = createRoot(node);
root.render(
createElement(DevTools, {
bridge: ((bridge: any): FrontendBridge),
canViewElementSourceFunction,
hookNamesModuleLoaderFunction,
showTabBar: true,
store: ((store: any): Store),
warnIfLegacyBackendDetected: true,
viewElementSourceFunction,
fetchFileWithCaching,
}),
);
}, 100);
}
const resourceCache: Map<string, string> = new Map();
// As a potential improvement, this should be done from the backend of RDT.
// Browser extension is doing this via exchanging messages
// between devtools_page and dedicated content script for it, see `fetchFileWithCaching.js`.
async function fetchFileWithCaching(url: string) {
if (resourceCache.has(url)) {
return Promise.resolve(resourceCache.get(url));
}
return fetch(url)
.then(data => data.text())
.then(content => {
resourceCache.set(url, content);
return content;
});
}
function canViewElementSourceFunction(
_source: Source,
symbolicatedSource: Source | null,
): boolean {
if (symbolicatedSource == null) {
return false;
}
return doesFilePathExist(symbolicatedSource.sourceURL, projectRoots);
}
function viewElementSourceFunction(
_source: Source,
symbolicatedSource: Source | null,
): void {
if (symbolicatedSource == null) {
return;
}
launchEditor(
symbolicatedSource.sourceURL,
symbolicatedSource.line,
projectRoots,
);
}
function onDisconnected() {
safeUnmount();
node.innerHTML = nodeWaitingToConnectHTML;
disconnectedCallback();
}
function onError({code, message}: $FlowFixMe) {
safeUnmount();
if (code === 'EADDRINUSE') {
node.innerHTML = `
<div class="box">
<div class="box-header">
Another instance of DevTools is running.
</div>
<div class="box-content">
Only one copy of DevTools can be used at a time.
</div>
</div>
`;
} else {
node.innerHTML = `
<div class="box">
<div class="box-header">
Unknown error
</div>
<div class="box-content">
${message}
</div>
</div>
`;
}
}
function openProfiler() {
// Mocked up bridge and store to allow the DevTools to be rendered
bridge = new Bridge({listen: () => {}, send: () => {}});
store = new Store(bridge, {});
// Ensure the Profiler tab is shown initially.
localStorageSetItem(
LOCAL_STORAGE_DEFAULT_TAB_KEY,
JSON.stringify('profiler'),
);
reload();
}
function initialize(socket: WebSocket) {
const listeners = [];
socket.onmessage = event => {
let data;
try {
if (typeof event.data === 'string') {
data = JSON.parse(event.data);
if (__DEBUG__) {
debug('WebSocket.onmessage', data);
}
} else {
throw Error();
}
} catch (e) {
log.error('Failed to parse JSON', event.data);
return;
}
listeners.forEach(fn => {
try {
fn(data);
} catch (error) {
log.error('Error calling listener', data);
throw error;
}
});
};
bridge = new Bridge({
listen(fn) {
listeners.push(fn);
return () => {
const index = listeners.indexOf(fn);
if (index >= 0) {
listeners.splice(index, 1);
}
};
},
send(event: string, payload: any, transferable?: Array<any>) {
if (socket.readyState === socket.OPEN) {
socket.send(JSON.stringify({event, payload}));
}
},
});
((bridge: any): FrontendBridge).addListener('shutdown', () => {
socket.close();
});
// $FlowFixMe[incompatible-call] found when upgrading Flow
store = new Store(bridge, {
checkBridgeProtocolCompatibility: true,
supportsTraceUpdates: true,
supportsClickToInspect: true,
});
log('Connected');
statusListener('DevTools initialized.', 'devtools-connected');
reload();
}
let startServerTimeoutID: TimeoutID | null = null;
function connectToSocket(socket: WebSocket): {close(): void} {
socket.onerror = err => {
onDisconnected();
log.error('Error with websocket connection', err);
};
socket.onclose = () => {
onDisconnected();
log('Connection to RN closed');
};
initialize(socket);
return {
close: function () {
onDisconnected();
},
};
}
type ServerOptions = {
key?: string,
cert?: string,
};
type LoggerOptions = {
surface?: ?string,
};
function startServer(
port: number = 8097,
host: string = 'localhost',
httpsOptions?: ServerOptions,
loggerOptions?: LoggerOptions,
): {close(): void} {
registerDevToolsEventLogger(loggerOptions?.surface ?? 'standalone');
const useHttps = !!httpsOptions;
const httpServer = useHttps
? require('https').createServer(httpsOptions)
: require('http').createServer();
const server = new Server({server: httpServer, maxPayload: 1e9});
let connected: WebSocket | null = null;
server.on('connection', (socket: WebSocket) => {
if (connected !== null) {
connected.close();
log.warn(
'Only one connection allowed at a time.',
'Closing the previous connection',
);
}
connected = socket;
socket.onerror = error => {
connected = null;
onDisconnected();
log.error('Error with websocket connection', error);
};
socket.onclose = () => {
connected = null;
onDisconnected();
log('Connection to RN closed');
};
initialize(socket);
});
server.on('error', (event: $FlowFixMe) => {
onError(event);
log.error('Failed to start the DevTools server', event);
startServerTimeoutID = setTimeout(() => startServer(port), 1000);
});
httpServer.on('request', (request: $FlowFixMe, response: $FlowFixMe) => {
// Serve a file that immediately sets up the connection.
const backendFile = readFileSync(join(__dirname, 'backend.js'));
// The renderer interface doesn't read saved component filters directly,
// because they are generally stored in localStorage within the context of the extension.
// Because of this it relies on the extension to pass filters, so include them wth the response here.
// This will ensure that saved filters are shared across different web pages.
const savedPreferencesString = `
window.__REACT_DEVTOOLS_APPEND_COMPONENT_STACK__ = ${JSON.stringify(
getAppendComponentStack(),
)};
window.__REACT_DEVTOOLS_BREAK_ON_CONSOLE_ERRORS__ = ${JSON.stringify(
getBreakOnConsoleErrors(),
)};
window.__REACT_DEVTOOLS_COMPONENT_FILTERS__ = ${JSON.stringify(
getSavedComponentFilters(),
)};
window.__REACT_DEVTOOLS_SHOW_INLINE_WARNINGS_AND_ERRORS__ = ${JSON.stringify(
getShowInlineWarningsAndErrors(),
)};
window.__REACT_DEVTOOLS_HIDE_CONSOLE_LOGS_IN_STRICT_MODE__ = ${JSON.stringify(
getHideConsoleLogsInStrictMode(),
)};`;
response.end(
savedPreferencesString +
'\n;' +
backendFile.toString() +
'\n;' +
`ReactDevToolsBackend.connectToDevTools({port: ${port}, host: '${host}', useHttps: ${
useHttps ? 'true' : 'false'
}});`,
);
});
httpServer.on('error', (event: $FlowFixMe) => {
onError(event);
statusListener('Failed to start the server.', 'error');
startServerTimeoutID = setTimeout(() => startServer(port), 1000);
});
httpServer.listen(port, () => {
statusListener(
'The server is listening on the port ' + port + '.',
'server-connected',
);
});
return {
close: function () {
connected = null;
onDisconnected();
if (startServerTimeoutID !== null) {
clearTimeout(startServerTimeoutID);
}
server.close();
httpServer.close();
},
};
}
const DevtoolsUI = {
connectToSocket,
setContentDOMNode,
setProjectRoots,
setStatusListener,
setDisconnectedCallback,
startServer,
openProfiler,
};
export default DevtoolsUI;