/** * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @format */ import {padStart} from 'lodash'; import React, {createContext} from 'react'; import {MenuItemConstructorOptions} from 'electron'; import { ContextMenu, FlexColumn, FlexRow, Button, Text, Glyph, colors, PureComponent, DetailSidebar, styled, SearchableTable, FlipperPlugin, Sheet, TableHighlightedRows, TableRows, TableBodyRow, produce, } from 'flipper'; import {Request, RequestId, Response, Route} from './types'; import {convertRequestToCurlCommand, getHeaderValue, decodeBody} from './utils'; import RequestDetails from './RequestDetails'; import {clipboard} from 'electron'; import {URL} from 'url'; import {DefaultKeyboardAction} from 'app/src/MenuBar'; import {MockResponseDialog} from './MockResponseDialog'; const LOCALSTORAGE_MOCK_ROUTE_LIST_KEY = '__NETWORK_CACHED_MOCK_ROUTE_LIST'; type PersistedState = { requests: {[id: string]: Request}; responses: {[id: string]: Response}; }; type State = { selectedIds: Array; searchTerm: string; routes: {[id: string]: Route}; nextRouteId: number; isMockResponseSupported: boolean; showMockResponseDialog: boolean; }; const COLUMN_SIZE = { requestTimestamp: 100, responseTimestamp: 100, domain: 'flex', method: 100, status: 70, size: 100, duration: 100, }; const COLUMN_ORDER = [ {key: 'requestTimestamp', visible: true}, {key: 'responseTimestamp', visible: false}, {key: 'domain', visible: true}, {key: 'method', visible: true}, {key: 'status', visible: true}, {key: 'size', visible: true}, {key: 'duration', visible: true}, ]; const COLUMNS = { requestTimestamp: {value: 'Request Time'}, responseTimestamp: {value: 'Response Time'}, domain: {value: 'Domain'}, method: {value: 'Method'}, status: {value: 'Status'}, size: {value: 'Size'}, duration: {value: 'Duration'}, }; const mockingStyle = { backgroundColor: colors.yellowTint, color: colors.yellow, fontWeight: 500, }; export function formatBytes(count: number): string { if (count > 1024 * 1024) { return (count / (1024.0 * 1024)).toFixed(1) + ' MB'; } if (count > 1024) { return (count / 1024.0).toFixed(1) + ' kB'; } return count + ' B'; } const TextEllipsis = styled(Text)({ overflowX: 'hidden', textOverflow: 'ellipsis', maxWidth: '100%', lineHeight: '18px', paddingTop: 4, }); // State management export interface NetworkRouteManager { addRoute(): void; modifyRoute(id: string, routeChange: Partial): void; removeRoute(id: string): void; } const nullNetworkRouteManager: NetworkRouteManager = { addRoute() {}, modifyRoute(_id: string, _routeChange: Partial) {}, removeRoute(_id: string) {}, }; export const NetworkRouteContext = createContext( nullNetworkRouteManager, ); export default class extends FlipperPlugin { static keyboardActions: Array = ['clear']; static subscribed = []; static defaultPersistedState = { requests: {}, responses: {}, }; networkRouteManager: NetworkRouteManager = nullNetworkRouteManager; static metricsReducer(persistedState: PersistedState) { const failures = Object.values(persistedState.responses).reduce(function ( previous, values, ) { return previous + (values.status >= 400 ? 1 : 0); }, 0); return Promise.resolve({NUMBER_NETWORK_FAILURES: failures}); } static persistedStateReducer( persistedState: PersistedState, method: string, data: Request | Response, ) { switch (method) { case 'newRequest': return Object.assign({}, persistedState, { requests: {...persistedState.requests, [data.id]: data as Request}, }); case 'newResponse': return Object.assign({}, persistedState, { responses: {...persistedState.responses, [data.id]: data as Response}, }); default: return persistedState; } } static serializePersistedState = (persistedState: PersistedState) => { return Promise.resolve(JSON.stringify(persistedState)); }; static deserializePersistedState = (serializedString: string) => { return JSON.parse(serializedString); }; static getActiveNotifications(persistedState: PersistedState) { const responses = persistedState ? persistedState.responses || new Map() : new Map(); const r: Array = Object.values(responses); return ( r // Show error messages for all status codes indicating a client or server error .filter((response: Response) => response.status >= 400) .map((response: Response) => { const request = persistedState.requests[response.id]; const url: string = (request && request.url) || '(URL missing)'; return { id: response.id, title: `HTTP ${response.status}: Network request failed`, message: `Request to ${url} failed. ${response.reason}`, severity: 'error' as 'error', timestamp: response.timestamp, category: `HTTP${response.status}`, action: response.id, }; }) ); } constructor(props: any) { super(props); this.state = { selectedIds: [], searchTerm: '', routes: {}, nextRouteId: 0, isMockResponseSupported: false, showMockResponseDialog: false, }; } init() { this.client.supportsMethod('mockResponses').then((result) => { const routes = JSON.parse( localStorage.getItem(LOCALSTORAGE_MOCK_ROUTE_LIST_KEY) || '{}', ); this.setState({ routes: routes, isMockResponseSupported: result, showMockResponseDialog: false, nextRouteId: Object.keys(routes).length, }); }); this.setState(this.parseDeepLinkPayload(this.props.deepLinkPayload)); // declare new variable to be called inside the interface const setState = this.setState.bind(this); const informClientMockChange = this.informClientMockChange.bind(this); this.networkRouteManager = { addRoute() { setState( produce((draftState: State) => { const nextRouteId = draftState.nextRouteId; draftState.routes[nextRouteId.toString()] = { requestUrl: '', requestMethod: 'GET', responseData: '', responseHeaders: {}, responseStatus: '200', }; draftState.nextRouteId = nextRouteId + 1; }), ); }, modifyRoute(id: string, routeChange: Partial) { setState( produce((draftState: State) => { if (!draftState.routes.hasOwnProperty(id)) { return; } draftState.routes[id] = {...draftState.routes[id], ...routeChange}; informClientMockChange(draftState.routes); }), ); }, removeRoute(id: string) { setState( produce((draftState: State) => { if (draftState.routes.hasOwnProperty(id)) { delete draftState.routes[id]; } informClientMockChange(draftState.routes); }), ); }, }; } teardown() {} onKeyboardAction = (action: string) => { if (action === 'clear') { this.clearLogs(); } }; parseDeepLinkPayload = ( deepLinkPayload: unknown, ): Pick => { const searchTermDelim = 'searchTerm='; if (typeof deepLinkPayload !== 'string') { return { selectedIds: [], searchTerm: '', }; } else if (deepLinkPayload.startsWith(searchTermDelim)) { return { selectedIds: [], searchTerm: deepLinkPayload.slice(searchTermDelim.length), }; } return { selectedIds: [deepLinkPayload], searchTerm: '', }; }; onRowHighlighted = (selectedIds: Array) => this.setState({selectedIds}); copyRequestCurlCommand = () => { const {requests} = this.props.persistedState; const {selectedIds} = this.state; // Ensure there is only one row highlighted. if (selectedIds.length !== 1) { return; } const request = requests[selectedIds[0]]; if (!request) { return; } const command = convertRequestToCurlCommand(request); clipboard.writeText(command); }; clearLogs = () => { this.setState({selectedIds: []}); this.props.setPersistedState({responses: {}, requests: {}}); }; informClientMockChange = (routes: {[id: string]: Route}) => { const existedIdSet: {[id: string]: {[method: string]: boolean}} = {}; const filteredRoutes: {[id: string]: Route} = Object.entries(routes).reduce( (accRoutes, [id, route]) => { if (existedIdSet.hasOwnProperty(route.requestUrl)) { if ( existedIdSet[route.requestUrl].hasOwnProperty(route.requestMethod) ) { return accRoutes; } existedIdSet[route.requestUrl] = { ...existedIdSet[route.requestUrl], [route.requestMethod]: true, }; return Object.assign({[id]: route}, accRoutes); } else { existedIdSet[route.requestUrl] = { [route.requestMethod]: true, }; return Object.assign({[id]: route}, accRoutes); } }, {}, ); if (this.state.isMockResponseSupported) { const routesValuesArray = Object.values(filteredRoutes); localStorage.setItem( LOCALSTORAGE_MOCK_ROUTE_LIST_KEY, JSON.stringify(routesValuesArray), ); this.client.call('mockResponses', { routes: routesValuesArray.map((route: Route) => ({ requestUrl: route.requestUrl, method: route.requestMethod, data: route.responseData, headers: [...Object.values(route.responseHeaders)], status: route.responseStatus, })), }); } }; onMockButtonPressed = () => { this.setState({showMockResponseDialog: true}); }; onCloseButtonPressed = () => { this.setState({showMockResponseDialog: false}); }; renderSidebar = () => { const {requests, responses} = this.props.persistedState; const {selectedIds} = this.state; const selectedId = selectedIds.length === 1 ? selectedIds[0] : null; if (!selectedId) { return null; } const requestWithId = requests[selectedId]; if (!requestWithId) { return null; } return ( ); }; render() { const {requests, responses} = this.props.persistedState; const { selectedIds, searchTerm, routes, isMockResponseSupported, showMockResponseDialog, } = this.state; return ( {this.renderSidebar()} ); } } type NetworkTableProps = { requests: {[id: string]: Request}; responses: {[id: string]: Response}; routes: {[id: string]: Route}; clear: () => void; copyRequestCurlCommand: () => void; onRowHighlighted: (keys: TableHighlightedRows) => void; highlightedRows: Set | null | undefined; searchTerm: string; onMockButtonPressed: () => void; onCloseButtonPressed: () => void; showMockResponseDialog: boolean; isMockResponseSupported: boolean; }; type NetworkTableState = { sortedRows: TableRows; routes: {[id: string]: Route}; }; function formatTimestamp(timestamp: number): string { const date = new Date(timestamp); return `${padStart(date.getHours().toString(), 2, '0')}:${padStart( date.getMinutes().toString(), 2, '0', )}:${padStart(date.getSeconds().toString(), 2, '0')}.${padStart( date.getMilliseconds().toString(), 3, '0', )}`; } function buildRow( request: Request, response: Response | null | undefined, ): TableBodyRow | null | undefined { if (request == null) { return null; } if (request.url == null) { return null; } const url = new URL(request.url); const domain = url.host + url.pathname; const friendlyName = getHeaderValue(request.headers, 'X-FB-Friendly-Name'); const style = response && response.isMock ? mockingStyle : undefined; let copyText = `# HTTP request for ${domain} (ID: ${request.id}) ## Request HTTP ${request.method} ${request.url} ${request.headers .map( ({key, value}: {key: string; value: string}): string => `${key}: ${String(value)}`, ) .join('\n')}`; const requestData = request.data ? decodeBody(request) : null; const responseData = response && response.data ? decodeBody(response) : null; if (requestData) { copyText += `\n\n${requestData}`; } if (response) { copyText += ` ## Response HTTP ${response.status} ${response.reason} ${response.headers .map( ({key, value}: {key: string; value: string}): string => `${key}: ${String(value)}`, ) .join('\n')}`; } if (responseData) { copyText += `\n\n${responseData}`; } return { columns: { requestTimestamp: { value: ( {formatTimestamp(request.timestamp)} ), }, responseTimestamp: { value: ( {response && formatTimestamp(response.timestamp)} ), }, domain: { value: ( {friendlyName ? friendlyName : domain} ), isFilterable: true, }, method: { value: {request.method}, isFilterable: true, }, status: { value: ( {response ? response.status : undefined} ), isFilterable: true, }, size: { value: , }, duration: { value: , }, }, key: request.id, filterValue: `${request.method} ${request.url}`, sortKey: request.timestamp, copyText, highlightOnHover: true, style: style, requestBody: requestData, responseBody: responseData, }; } function calculateState( props: { requests: {[id: string]: Request}; responses: {[id: string]: Response}; }, nextProps: NetworkTableProps, rows: TableRows = [], ): NetworkTableState { rows = [...rows]; if (Object.keys(nextProps.requests).length === 0) { // cleared rows = []; } else if (props.requests !== nextProps.requests) { // new request for (const [requestId, request] of Object.entries(nextProps.requests)) { if (props.requests[requestId] == null) { const newRow = buildRow(request, nextProps.responses[requestId]); if (newRow) { rows.push(newRow); } } } } else if (props.responses !== nextProps.responses) { // new or updated response const resId = Object.keys(nextProps.responses).find( (responseId: RequestId) => props.responses[responseId] !== nextProps.responses[responseId], ); if (resId) { const request = nextProps.requests[resId]; // sanity check; to pass null check if (request) { const newRow = buildRow(request, nextProps.responses[resId]); const index = rows.findIndex((r: TableBodyRow) => r.key === request.id); if (index > -1 && newRow) { rows[index] = newRow; } } } } rows.sort( (a: TableBodyRow, b: TableBodyRow) => (a.sortKey as number) - (b.sortKey as number), ); return { sortedRows: rows, routes: nextProps.routes, }; } class NetworkTable extends PureComponent { static ContextMenu = styled(ContextMenu)({ flex: 1, }); constructor(props: NetworkTableProps) { super(props); this.state = calculateState({requests: {}, responses: {}}, props); } UNSAFE_componentWillReceiveProps(nextProps: NetworkTableProps) { this.setState(calculateState(this.props, nextProps, this.state.sortedRows)); } contextMenuItems(): Array { type ContextMenuType = | 'normal' | 'separator' | 'submenu' | 'checkbox' | 'radio'; const separator: ContextMenuType = 'separator'; const {clear, copyRequestCurlCommand, highlightedRows} = this.props; const highlightedMenuItems = highlightedRows && highlightedRows.size === 1 ? [ { type: separator, }, { label: 'Copy as cURL', click: copyRequestCurlCommand, }, ] : []; return highlightedMenuItems.concat([ { type: separator, }, { label: 'Clear all', click: clear, }, ]); } render() { return ( <> {this.props.isMockResponseSupported && ( )} } /> {this.props.showMockResponseDialog ? ( {(onHide) => ( { onHide(); this.props.onCloseButtonPressed(); }} /> )} ) : null} ); } } const Icon = styled(Glyph)({ marginTop: -3, marginRight: 3, }); class StatusColumn extends PureComponent<{ children?: number; }> { render() { const {children} = this.props; let glyph; if (children != null && children >= 400 && children < 600) { glyph = ; } return ( {glyph} {children} ); } } class DurationColumn extends PureComponent<{ request: Request; response: Response | null | undefined; }> { static Text = styled(Text)({ flex: 1, textAlign: 'right', paddingRight: 10, }); render() { const {request, response} = this.props; const duration = response ? response.timestamp - request.timestamp : undefined; return ( {duration != null ? duration.toLocaleString() + 'ms' : ''} ); } } class SizeColumn extends PureComponent<{ response: Response | null | undefined; }> { static Text = styled(Text)({ flex: 1, textAlign: 'right', paddingRight: 10, }); render() { const {response} = this.props; if (response) { const text = formatBytes(this.getResponseLength(response)); return {text}; } else { return null; } } getResponseLength(response: Response | null | undefined) { if (!response) { return 0; } let length = 0; const lengthString = response.headers ? getHeaderValue(response.headers, 'content-length') : undefined; if (lengthString != null && lengthString != '') { length = parseInt(lengthString, 10); } else if (response.data) { length = Buffer.byteLength(response.data, 'base64'); } return length; } }