/** * 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. * * @format * @flow */ 'use strict'; import type {RenderItemProps} from '@react-native/virtualized-lists'; const ScrollView = require('../Components/ScrollView/ScrollView'); const TouchableHighlight = require('../Components/Touchable/TouchableHighlight'); const View = require('../Components/View/View'); const FlatList = require('../Lists/FlatList'); const XHRInterceptor = require('../Network/XHRInterceptor'); const StyleSheet = require('../StyleSheet/StyleSheet'); const Text = require('../Text/Text'); const WebSocketInterceptor = require('../WebSocket/WebSocketInterceptor'); const React = require('react'); const LISTVIEW_CELL_HEIGHT = 15; // Global id for the intercepted XMLHttpRequest objects. let nextXHRId = 0; type NetworkRequestInfo = { id: number, type?: string, url?: string, method?: string, status?: number, dataSent?: any, responseContentType?: string, responseSize?: number, requestHeaders?: Object, responseHeaders?: string, response?: Object | string, responseURL?: string, responseType?: string, timeout?: number, closeReason?: string, messages?: string, serverClose?: Object, serverError?: Object, ... }; type Props = $ReadOnly<{||}>; type State = {| detailRowId: ?number, requests: Array, |}; function getStringByValue(value: any): string { if (value === undefined) { return 'undefined'; } if (typeof value === 'object') { return JSON.stringify(value); } if (typeof value === 'string' && value.length > 500) { return String(value) .substr(0, 500) .concat('\n***TRUNCATED TO 500 CHARACTERS***'); } return value; } function getTypeShortName(type: any): string { if (type === 'XMLHttpRequest') { return 'XHR'; } else if (type === 'WebSocket') { return 'WS'; } return ''; } function keyExtractor(request: NetworkRequestInfo): string { return String(request.id); } /** * Show all the intercepted network requests over the InspectorPanel. */ class NetworkOverlay extends React.Component { _requestsListView: ?React.ElementRef; _detailScrollView: ?React.ElementRef; // Metrics are used to decide when if the request list should be sticky, and // scroll to the bottom as new network requests come in, or if the user has // intentionally scrolled away from the bottom - to instead flash the scroll bar // and keep the current position _requestsListViewScrollMetrics: { contentLength: number, offset: number, visibleLength: number, } = { offset: 0, visibleLength: 0, contentLength: 0, }; // Map of `socketId` -> `index in `this.state.requests`. _socketIdMap: {[string]: number} = {}; // Map of `xhr._index` -> `index in `this.state.requests`. _xhrIdMap: {[key: number]: number, ...} = {}; state: State = { detailRowId: null, requests: [], }; _enableXHRInterception(): void { if (XHRInterceptor.isInterceptorEnabled()) { return; } // Show the XHR request item in listView as soon as it was opened. XHRInterceptor.setOpenCallback((method, url, xhr) => { // Generate a global id for each intercepted xhr object, add this id // to the xhr object as a private `_index` property to identify it, // so that we can distinguish different xhr objects in callbacks. xhr._index = nextXHRId++; const xhrIndex = this.state.requests.length; this._xhrIdMap[xhr._index] = xhrIndex; const _xhr: NetworkRequestInfo = { id: xhrIndex, type: 'XMLHttpRequest', method: method, url: url, }; this.setState( { requests: this.state.requests.concat(_xhr), }, this._indicateAdditionalRequests, ); }); XHRInterceptor.setRequestHeaderCallback((header, value, xhr) => { const xhrIndex = this._getRequestIndexByXHRID(xhr._index); if (xhrIndex === -1) { return; } this.setState(({requests}) => { const networkRequestInfo = requests[xhrIndex]; if (!networkRequestInfo.requestHeaders) { networkRequestInfo.requestHeaders = ({}: {[any]: any}); } networkRequestInfo.requestHeaders[header] = value; return {requests}; }); }); XHRInterceptor.setSendCallback((data, xhr) => { const xhrIndex = this._getRequestIndexByXHRID(xhr._index); if (xhrIndex === -1) { return; } this.setState(({requests}) => { const networkRequestInfo = requests[xhrIndex]; networkRequestInfo.dataSent = data; return {requests}; }); }); XHRInterceptor.setHeaderReceivedCallback( (type, size, responseHeaders, xhr) => { const xhrIndex = this._getRequestIndexByXHRID(xhr._index); if (xhrIndex === -1) { return; } this.setState(({requests}) => { const networkRequestInfo = requests[xhrIndex]; networkRequestInfo.responseContentType = type; networkRequestInfo.responseSize = size; networkRequestInfo.responseHeaders = responseHeaders; return {requests}; }); }, ); XHRInterceptor.setResponseCallback( (status, timeout, response, responseURL, responseType, xhr) => { const xhrIndex = this._getRequestIndexByXHRID(xhr._index); if (xhrIndex === -1) { return; } this.setState(({requests}) => { const networkRequestInfo = requests[xhrIndex]; networkRequestInfo.status = status; networkRequestInfo.timeout = timeout; networkRequestInfo.response = response; networkRequestInfo.responseURL = responseURL; networkRequestInfo.responseType = responseType; return {requests}; }); }, ); // Fire above callbacks. XHRInterceptor.enableInterception(); } _enableWebSocketInterception(): void { if (WebSocketInterceptor.isInterceptorEnabled()) { return; } // Show the WebSocket request item in listView when 'connect' is called. WebSocketInterceptor.setConnectCallback( (url, protocols, options, socketId) => { const socketIndex = this.state.requests.length; this._socketIdMap[socketId] = socketIndex; const _webSocket: NetworkRequestInfo = { id: socketIndex, type: 'WebSocket', url: url, protocols: protocols, }; this.setState( { requests: this.state.requests.concat(_webSocket), }, this._indicateAdditionalRequests, ); }, ); WebSocketInterceptor.setCloseCallback( (statusCode, closeReason, socketId) => { const socketIndex = this._socketIdMap[socketId]; if (socketIndex === undefined) { return; } if (statusCode !== null && closeReason !== null) { this.setState(({requests}) => { const networkRequestInfo = requests[socketIndex]; networkRequestInfo.status = statusCode; networkRequestInfo.closeReason = closeReason; return {requests}; }); } }, ); WebSocketInterceptor.setSendCallback((data, socketId) => { const socketIndex = this._socketIdMap[socketId]; if (socketIndex === undefined) { return; } this.setState(({requests}) => { const networkRequestInfo = requests[socketIndex]; if (!networkRequestInfo.messages) { networkRequestInfo.messages = ''; } networkRequestInfo.messages += 'Sent: ' + JSON.stringify(data) + '\n'; return {requests}; }); }); WebSocketInterceptor.setOnMessageCallback((socketId, message) => { const socketIndex = this._socketIdMap[socketId]; if (socketIndex === undefined) { return; } this.setState(({requests}) => { const networkRequestInfo = requests[socketIndex]; if (!networkRequestInfo.messages) { networkRequestInfo.messages = ''; } networkRequestInfo.messages += 'Received: ' + JSON.stringify(message) + '\n'; return {requests}; }); }); WebSocketInterceptor.setOnCloseCallback((socketId, message) => { const socketIndex = this._socketIdMap[socketId]; if (socketIndex === undefined) { return; } this.setState(({requests}) => { const networkRequestInfo = requests[socketIndex]; networkRequestInfo.serverClose = message; return {requests}; }); }); WebSocketInterceptor.setOnErrorCallback((socketId, message) => { const socketIndex = this._socketIdMap[socketId]; if (socketIndex === undefined) { return; } this.setState(({requests}) => { const networkRequestInfo = requests[socketIndex]; networkRequestInfo.serverError = message; return {requests}; }); }); // Fire above callbacks. WebSocketInterceptor.enableInterception(); } componentDidMount() { this._enableXHRInterception(); this._enableWebSocketInterception(); } componentWillUnmount() { XHRInterceptor.disableInterception(); WebSocketInterceptor.disableInterception(); } _renderItem = ({ item, index, }: RenderItemProps): React.Element => { const tableRowViewStyle = [ styles.tableRow, index % 2 === 1 ? styles.tableRowOdd : styles.tableRowEven, index === this.state.detailRowId && styles.tableRowPressed, ]; const urlCellViewStyle = styles.urlCellView; const methodCellViewStyle = styles.methodCellView; return ( { this._pressRow(index); }}> {item.url} {getTypeShortName(item.type)} ); }; _renderItemDetail(id: number): React.Node { const requestItem = this.state.requests[id]; const details = Object.keys(requestItem).map(key => { if (key === 'id') { return; } return ( {key} {getStringByValue(requestItem[key])} ); }); return ( v (this._detailScrollView = scrollRef)}> {details} ); } _indicateAdditionalRequests = (): void => { if (this._requestsListView) { const distanceFromEndThreshold = LISTVIEW_CELL_HEIGHT * 2; const {offset, visibleLength, contentLength} = this._requestsListViewScrollMetrics; const distanceFromEnd = contentLength - visibleLength - offset; const isCloseToEnd = distanceFromEnd <= distanceFromEndThreshold; if (isCloseToEnd) { this._requestsListView.scrollToEnd(); } else { this._requestsListView.flashScrollIndicators(); } } }; _captureRequestsListView = (listRef: ?FlatList): void => { this._requestsListView = listRef; }; _requestsListViewOnScroll = (e: Object): void => { this._requestsListViewScrollMetrics.offset = e.nativeEvent.contentOffset.y; this._requestsListViewScrollMetrics.visibleLength = e.nativeEvent.layoutMeasurement.height; this._requestsListViewScrollMetrics.contentLength = e.nativeEvent.contentSize.height; }; /** * Popup a scrollView to dynamically show detailed information of * the request, when pressing a row in the network flow listView. */ _pressRow(rowId: number): void { this.setState({detailRowId: rowId}, this._scrollDetailToTop); } _scrollDetailToTop = (): void => { if (this._detailScrollView) { this._detailScrollView.scrollTo({ y: 0, animated: false, }); } }; _closeButtonClicked = () => { this.setState({detailRowId: null}); }; _getRequestIndexByXHRID(index: number): number { if (index === undefined) { return -1; } const xhrIndex = this._xhrIdMap[index]; if (xhrIndex === undefined) { return -1; } else { return xhrIndex; } } render(): React.Node { const {requests, detailRowId} = this.state; return ( {detailRowId != null && this._renderItemDetail(detailRowId)} {requests.length > 0 && ( URL Type )} ); } } const styles = StyleSheet.create({ container: { paddingTop: 10, paddingBottom: 10, paddingLeft: 5, paddingRight: 5, }, listViewTitle: { height: 20, }, listView: { flex: 1, height: 60, }, tableRow: { flexDirection: 'row', flex: 1, height: LISTVIEW_CELL_HEIGHT, }, tableRowEven: { backgroundColor: '#555', }, tableRowOdd: { backgroundColor: '#000', }, tableRowPressed: { backgroundColor: '#3B5998', }, cellText: { color: 'white', fontSize: 12, }, methodTitleCellView: { height: 18, borderColor: '#DCD7CD', borderTopWidth: 1, borderBottomWidth: 1, borderRightWidth: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: '#444', flex: 1, }, urlTitleCellView: { height: 18, borderColor: '#DCD7CD', borderTopWidth: 1, borderBottomWidth: 1, borderLeftWidth: 1, borderRightWidth: 1, justifyContent: 'center', backgroundColor: '#444', flex: 5, paddingLeft: 3, }, methodCellView: { height: 15, borderColor: '#DCD7CD', borderRightWidth: 1, alignItems: 'center', justifyContent: 'center', flex: 1, }, urlCellView: { height: 15, borderColor: '#DCD7CD', borderLeftWidth: 1, borderRightWidth: 1, justifyContent: 'center', flex: 5, paddingLeft: 3, }, detailScrollView: { flex: 1, height: 180, marginTop: 5, marginBottom: 5, }, detailKeyCellView: { flex: 1.3, }, detailValueCellView: { flex: 2, }, detailViewRow: { flexDirection: 'row', paddingHorizontal: 3, }, detailViewText: { color: 'white', fontSize: 11, }, closeButtonText: { color: 'white', fontSize: 10, }, closeButton: { marginTop: 5, backgroundColor: '#888', justifyContent: 'center', alignItems: 'center', }, }); module.exports = NetworkOverlay;